diff --git a/apps/web/package.json b/apps/web/package.json
index 2dc6337e..2a34f3f3 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -36,6 +36,7 @@
"monaco-editor": "^0.52.2",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
+ "qrcode": "^1.5.4",
"shiki": "^3.21.0",
"stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.18",
@@ -52,6 +53,7 @@
"devDependencies": {
"@memoh/config": "workspace:*",
"@types/node": "^24.10.1",
+ "@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
diff --git a/apps/web/src/components/channel-icon/index.vue b/apps/web/src/components/channel-icon/index.vue
index 7bce3596..dd722ee5 100644
--- a/apps/web/src/components/channel-icon/index.vue
+++ b/apps/web/src/components/channel-icon/index.vue
@@ -27,6 +27,7 @@ const channelIcons: Record = {
slack: Slack,
feishu: Feishu,
wechat: Wechat,
+ weixin: Wechat,
matrix: Matrix,
}
diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json
index 71fd7f96..10e0cca2 100644
--- a/apps/web/src/i18n/locales/en.json
+++ b/apps/web/src/i18n/locales/en.json
@@ -892,12 +892,24 @@
"feishuWebhookSecurityHint": "For security, webhook mode requires either an Encrypt Key or a Verification Token; an unprotected public callback URL should not be exposed.",
"feishuWebhookSecretRequired": "For security, configure at least one of Encrypt Key or Verification Token.",
"noAvailableTypes": "All platform types have been configured",
+ "weixinQr": {
+ "title": "QR Code Login",
+ "description": "Scan the QR code with WeChat to connect your account.",
+ "startScan": "Get QR Code",
+ "waitingScan": "Open WeChat and scan this QR code",
+ "scanned": "Scanned — confirm on your phone",
+ "expired": "QR code expired",
+ "refresh": "Refresh QR Code",
+ "success": "WeChat connected successfully!",
+ "retry": "Try Again"
+ },
"types": {
"feishu": "Feishu",
"discord": "Discord",
"qq": "QQ",
"matrix": "Matrix",
"telegram": "Telegram",
+ "weixin": "WeChat",
"web": "Web",
"local": "Local"
},
@@ -907,6 +919,7 @@
"qq": "QQ",
"matrix": "MX",
"telegram": "TG",
+ "weixin": "WX",
"web": "Web",
"local": "CLI"
}
diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json
index 399efb8f..9a320c88 100644
--- a/apps/web/src/i18n/locales/zh.json
+++ b/apps/web/src/i18n/locales/zh.json
@@ -888,12 +888,24 @@
"feishuWebhookSecurityHint": "出于安全考虑,Webhook 模式必须配置 Encrypt Key 或 Verification Token 之一;未受保护的回调地址不应直接暴露在公网上。",
"feishuWebhookSecretRequired": "出于安全考虑,请至少配置 Encrypt Key 或 Verification Token 之一。",
"noAvailableTypes": "所有平台类型均已配置",
+ "weixinQr": {
+ "title": "扫码登录",
+ "description": "使用微信扫描二维码以连接微信账号。",
+ "startScan": "获取二维码",
+ "waitingScan": "打开微信扫描此二维码",
+ "scanned": "已扫码 — 请在手机上确认",
+ "expired": "二维码已过期",
+ "refresh": "刷新二维码",
+ "success": "微信连接成功!",
+ "retry": "重试"
+ },
"types": {
"feishu": "飞书",
"discord": "Discord",
"qq": "QQ",
"matrix": "Matrix",
"telegram": "Telegram",
+ "weixin": "微信",
"web": "Web",
"local": "本地"
},
@@ -903,6 +915,7 @@
"qq": "QQ",
"matrix": "MX",
"telegram": "TG",
+ "weixin": "WX",
"web": "Web",
"local": "CLI"
}
diff --git a/apps/web/src/pages/bots/components/bot-channels.vue b/apps/web/src/pages/bots/components/bot-channels.vue
index a0d3b545..e5cd5952 100644
--- a/apps/web/src/pages/bots/components/bot-channels.vue
+++ b/apps/web/src/pages/bots/components/bot-channels.vue
@@ -39,7 +39,7 @@
:aria-pressed="selectedType === item.meta.type"
class="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors hover:bg-accent"
:class="{ 'bg-accent': selectedType === item.meta.type }"
- @click="selectedType = item.meta.type as string"
+ @click="selectedType = item.meta.type ?? ''"
>
@@ -98,7 +98,7 @@
:key="item.meta.type"
type="button"
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
- @click="addChannel(item.meta.type)"
+ @click="addChannel(item.meta.type ?? '')"
>
@@ -168,7 +168,7 @@ const { data: channels, isLoading, refetch } = useQuery({
configurableTypes.map(async (meta) => {
try {
const { data: config } = await getBotsByIdChannelByPlatform({
- path: { id: botIdRef.value, platform: meta.type },
+ path: { id: botIdRef.value, platform: meta.type ?? '' },
throwOnError: true,
})
return { meta, config: config ?? null, configured: true } as BotChannelItem
@@ -194,10 +194,10 @@ const selectedItem = computed(() =>
allChannels.value.find((c) => c.meta.type === selectedType.value) ?? null,
)
-watch(allChannels, (list) => {
- if (list.length === 0) {
- selectedType.value = null
- return
+watch(configuredChannels, (list) => {
+ const first = list[0]
+ if (first && !selectedType.value) {
+ selectedType.value = first.meta.type ?? null
}
const current = selectedType.value
@@ -213,5 +213,4 @@ function addChannel(type: string) {
addPopoverOpen.value = false
selectedType.value = type
}
-
diff --git a/apps/web/src/pages/bots/components/channel-settings-panel.vue b/apps/web/src/pages/bots/components/channel-settings-panel.vue
index 3654b91f..75d91d18 100644
--- a/apps/web/src/pages/bots/components/channel-settings-panel.vue
+++ b/apps/web/src/pages/bots/components/channel-settings-panel.vue
@@ -86,7 +86,16 @@
-
+
+
+
+
+
+
+
@@ -259,6 +268,7 @@ import type { HandlersChannelMeta, ChannelChannelConfig, ChannelFieldSchema, Cha
import { client } from '@memoh/sdk/client'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import ChannelIcon from '@/components/channel-icon/index.vue'
+import WeixinQrLogin from './weixin-qr-login.vue'
interface BotChannelItem {
meta: HandlersChannelMeta
@@ -571,6 +581,11 @@ function isAbsoluteHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value)
}
+function handleWeixinLoginSuccess() {
+ queryCache.invalidateQueries({ key: ['bot-channels', botIdRef.value] })
+ emit('saved')
+}
+
async function copyWebhookCallback() {
if (!webhookCallbackUrl.value) return
try {
diff --git a/apps/web/src/pages/bots/components/weixin-qr-login.vue b/apps/web/src/pages/bots/components/weixin-qr-login.vue
new file mode 100644
index 00000000..118d5a8f
--- /dev/null
+++ b/apps/web/src/pages/bots/components/weixin-qr-login.vue
@@ -0,0 +1,292 @@
+
+
+
+
+
+ {{ $t('bots.channels.weixinQr.title') }}
+
+
+ {{ $t('bots.channels.weixinQr.description') }}
+
+
+
+
+
+
+
+
+
+
+
+
![WeChat QR Code]()
+
+
+
+
+
+
+
+
+
+ {{ $t('bots.channels.weixinQr.scanned') }}
+
+
+
+
+
+
+
+ {{ $t('bots.channels.weixinQr.expired') }}
+
+
+
+
+
+
+ {{ statusText }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('bots.channels.weixinQr.success') }}
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
diff --git a/apps/web/src/utils/channel-icons.ts b/apps/web/src/utils/channel-icons.ts
new file mode 100644
index 00000000..eba07251
--- /dev/null
+++ b/apps/web/src/utils/channel-icons.ts
@@ -0,0 +1,34 @@
+/**
+ * Local channel icons under public/channels/.
+ * getChannelImage: URL to local icon when available.
+ * getChannelIcon: FontAwesome fallback when no local image.
+ */
+
+const LOCAL_CHANNEL_IMAGES: Record
= {
+ feishu: '/channels/feishu.png',
+ matrix: '/channels/matrix.svg',
+ telegram: '/channels/telegram.webp',
+}
+
+const CHANNEL_ICONS: Record = {
+ qq: ['fab', 'qq'],
+ telegram: ['fab', 'telegram'],
+ matrix: ['fas', 'hashtag'],
+ feishu: ['fas', 'comment-dots'],
+ web: ['fas', 'globe'],
+ slack: ['fab', 'slack'],
+ discord: ['fab', 'discord'],
+ email: ['fas', 'envelope'],
+}
+
+const DEFAULT_ICON: [string, string] = ['far', 'comment']
+
+export function getChannelIcon(platformKey: string): [string, string] {
+ if (!platformKey) return DEFAULT_ICON
+ return CHANNEL_ICONS[platformKey] ?? DEFAULT_ICON
+}
+
+export function getChannelImage(platformKey: string): string | null {
+ if (!platformKey) return null
+ return LOCAL_CHANNEL_IMAGES[platformKey] ?? null
+}
diff --git a/cmd/agent/main.go b/cmd/agent/main.go
index 5068b92e..ff604120 100644
--- a/cmd/agent/main.go
+++ b/cmd/agent/main.go
@@ -36,6 +36,7 @@ import (
"github.com/memohai/memoh/internal/channel/adapters/qq"
"github.com/memohai/memoh/internal/channel/adapters/telegram"
"github.com/memohai/memoh/internal/channel/adapters/wecom"
+ "github.com/memohai/memoh/internal/channel/adapters/weixin"
"github.com/memohai/memoh/internal/channel/identities"
"github.com/memohai/memoh/internal/channel/inbound"
"github.com/memohai/memoh/internal/channel/route"
@@ -236,6 +237,7 @@ func runServe() {
provideServerHandler(handlers.NewCompactionHandler),
provideServerHandler(handlers.NewChannelHandler),
provideServerHandler(feishu.NewWebhookServerHandler),
+ provideServerHandler(weixin.NewQRServerHandler),
provideServerHandler(provideUsersHandler),
provideServerHandler(handlers.NewMemoryProvidersHandler),
provideServerHandler(handlers.NewTtsProvidersHandler),
@@ -482,6 +484,9 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService
feishuAdapter.SetAssetOpener(mediaService)
registry.MustRegister(feishuAdapter)
registry.MustRegister(wecom.NewWeComAdapter(log))
+ weixinAdapter := weixin.NewWeixinAdapter(log)
+ weixinAdapter.SetAssetOpener(mediaService)
+ registry.MustRegister(weixinAdapter)
registry.MustRegister(local.NewCLIAdapter(hub))
registry.MustRegister(local.NewWebAdapter(hub))
return registry
diff --git a/cmd/memoh/serve.go b/cmd/memoh/serve.go
index 595c2379..3f709777 100644
--- a/cmd/memoh/serve.go
+++ b/cmd/memoh/serve.go
@@ -37,6 +37,7 @@ import (
"github.com/memohai/memoh/internal/channel/adapters/qq"
"github.com/memohai/memoh/internal/channel/adapters/telegram"
"github.com/memohai/memoh/internal/channel/adapters/wecom"
+ "github.com/memohai/memoh/internal/channel/adapters/weixin"
"github.com/memohai/memoh/internal/channel/identities"
"github.com/memohai/memoh/internal/channel/inbound"
"github.com/memohai/memoh/internal/channel/route"
@@ -162,6 +163,7 @@ func runServe() {
provideServerHandler(handlers.NewCompactionHandler),
provideServerHandler(handlers.NewChannelHandler),
provideServerHandler(feishu.NewWebhookServerHandler),
+ provideServerHandler(weixin.NewQRServerHandler),
provideServerHandler(provideUsersHandler),
provideServerHandler(handlers.NewMemoryProvidersHandler),
provideServerHandler(handlers.NewTtsProvidersHandler),
@@ -396,6 +398,9 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService
feishuAdapter.SetAssetOpener(mediaService)
registry.MustRegister(feishuAdapter)
registry.MustRegister(wecom.NewWeComAdapter(log))
+ weixinAdapter := weixin.NewWeixinAdapter(log)
+ weixinAdapter.SetAssetOpener(mediaService)
+ registry.MustRegister(weixinAdapter)
registry.MustRegister(local.NewCLIAdapter(hub))
registry.MustRegister(local.NewWebAdapter(hub))
return registry
diff --git a/internal/accounts/service.go b/internal/accounts/service.go
index 5df2afb3..c8aa4a00 100644
--- a/internal/accounts/service.go
+++ b/internal/accounts/service.go
@@ -114,7 +114,7 @@ func (s *Service) SearchAccounts(ctx context.Context, query string, limit int) (
}
rows, err := s.queries.SearchAccounts(ctx, sqlc.SearchAccountsParams{
Query: strings.TrimSpace(query),
- LimitCount: int32(limit),
+ LimitCount: int32(limit), //nolint:gosec // limit is capped above
})
if err != nil {
return nil, err
diff --git a/internal/channel/adapters/weixin/LICENSE b/internal/channel/adapters/weixin/LICENSE
new file mode 100644
index 00000000..93ae4b1b
--- /dev/null
+++ b/internal/channel/adapters/weixin/LICENSE
@@ -0,0 +1,32 @@
+This WeChat (Weixin) channel adapter for Memoh is derived from the
+OpenClaw WeChat plugin ("@tencent-weixin/openclaw-weixin"), which is
+licensed under the MIT License. The protocol logic, API structures,
+CDN encryption scheme, and QR login flow were ported from that
+TypeScript codebase to Go.
+
+The original MIT license text is reproduced below, as required by its
+terms.
+
+------------------------------------------------------------------------
+
+MIT License
+
+Copyright (c) 2026 Tencent Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/internal/channel/adapters/weixin/client.go b/internal/channel/adapters/weixin/client.go
new file mode 100644
index 00000000..35309f8d
--- /dev/null
+++ b/internal/channel/adapters/weixin/client.go
@@ -0,0 +1,267 @@
+// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
+// See LICENSE in this directory for the full license text.
+
+package weixin
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ channelVersion = "1.0.0"
+ defaultLongPollTimeout = 35 * time.Second
+ defaultAPITimeout = 15 * time.Second
+ defaultConfigTimeout = 10 * time.Second
+
+ sessionExpiredErrCode = -14
+)
+
+// Client talks to the Tencent iLink WeChat bot API.
+type Client struct {
+ httpClient *http.Client
+ logger *slog.Logger
+}
+
+// NewClient creates a WeChat API client.
+func NewClient(log *slog.Logger) *Client {
+ if log == nil {
+ log = slog.Default()
+ }
+ return &Client{
+ httpClient: &http.Client{Timeout: 0}, // per-request timeout via context
+ logger: log.With(slog.String("component", "weixin_client")),
+ }
+}
+
+func buildBaseInfo() BaseInfo {
+ return BaseInfo{ChannelVersion: channelVersion}
+}
+
+// randomWechatUIN generates the X-WECHAT-UIN header value: random uint32 -> decimal -> base64.
+func randomWechatUIN() string {
+ var buf [4]byte
+ _, _ = rand.Read(buf[:])
+ n := binary.BigEndian.Uint32(buf[:])
+ return base64.StdEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(n), 10)))
+}
+
+func (*Client) buildHeaders(token string, bodyLen int) http.Header {
+ h := http.Header{}
+ h.Set("Content-Type", "application/json")
+ h.Set("AuthorizationType", "ilink_bot_token")
+ h.Set("Content-Length", strconv.Itoa(bodyLen))
+ h.Set("X-WECHAT-UIN", randomWechatUIN())
+ if strings.TrimSpace(token) != "" {
+ h.Set("Authorization", "Bearer "+strings.TrimSpace(token))
+ }
+ return h
+}
+
+func ensureTrailingSlash(u string) string {
+ if strings.HasSuffix(u, "/") {
+ return u
+ }
+ return u + "/"
+}
+
+func (c *Client) apiPost(ctx context.Context, baseURL, endpoint string, body []byte, token string, timeout time.Duration) ([]byte, error) {
+ base := ensureTrailingSlash(baseURL)
+ u, err := url.JoinPath(base, endpoint)
+ if err != nil {
+ return nil, fmt.Errorf("weixin api url: %w", err)
+ }
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("weixin api request: %w", err)
+ }
+ for k, vs := range c.buildHeaders(token, len(body)) {
+ for _, v := range vs {
+ req.Header.Add(k, v)
+ }
+ }
+
+ resp, err := c.httpClient.Do(req) //nolint:gosec // URL is constructed from trusted admin-configured baseURL
+ if err != nil {
+ return nil, fmt.Errorf("weixin api fetch: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("weixin api read: %w", err)
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("weixin api %s %d: %s", endpoint, resp.StatusCode, string(raw))
+ }
+ return raw, nil
+}
+
+// GetUpdates performs a long-poll request to receive new messages.
+func (c *Client) GetUpdates(ctx context.Context, cfg adapterConfig, getUpdatesBuf string) (*GetUpdatesResponse, error) {
+ timeout := defaultLongPollTimeout
+ if cfg.PollTimeoutSeconds > 0 {
+ timeout = time.Duration(cfg.PollTimeoutSeconds) * time.Second
+ }
+ body, err := json.Marshal(GetUpdatesRequest{
+ GetUpdatesBuf: getUpdatesBuf,
+ BaseInfo: buildBaseInfo(),
+ })
+ if err != nil {
+ return nil, err
+ }
+ raw, err := c.apiPost(ctx, cfg.BaseURL, "ilink/bot/getupdates", body, cfg.Token, timeout+5*time.Second)
+ if err != nil {
+ if ctx.Err() != nil {
+ return &GetUpdatesResponse{Ret: 0, Msgs: nil, GetUpdatesBuf: getUpdatesBuf}, nil
+ }
+ return nil, err
+ }
+ var resp GetUpdatesResponse
+ if err := json.Unmarshal(raw, &resp); err != nil {
+ return nil, fmt.Errorf("weixin getupdates decode: %w", err)
+ }
+ return &resp, nil
+}
+
+// SendMessage sends a text or media message downstream.
+func (c *Client) SendMessage(ctx context.Context, cfg adapterConfig, msg SendMessageRequest) error {
+ msg.BaseInfo = buildBaseInfo()
+ body, err := json.Marshal(msg)
+ if err != nil {
+ return err
+ }
+ _, err = c.apiPost(ctx, cfg.BaseURL, "ilink/bot/sendmessage", body, cfg.Token, defaultAPITimeout)
+ return err
+}
+
+// GetConfig fetches bot config (typing_ticket etc.).
+func (c *Client) GetConfig(ctx context.Context, cfg adapterConfig, userID, contextToken string) (*GetConfigResponse, error) {
+ body, err := json.Marshal(GetConfigRequest{
+ ILinkUserID: userID,
+ ContextToken: contextToken,
+ BaseInfo: buildBaseInfo(),
+ })
+ if err != nil {
+ return nil, err
+ }
+ raw, err := c.apiPost(ctx, cfg.BaseURL, "ilink/bot/getconfig", body, cfg.Token, defaultConfigTimeout)
+ if err != nil {
+ return nil, err
+ }
+ var resp GetConfigResponse
+ if err := json.Unmarshal(raw, &resp); err != nil {
+ return nil, fmt.Errorf("weixin getconfig decode: %w", err)
+ }
+ return &resp, nil
+}
+
+// SendTyping sends or cancels the typing indicator.
+func (c *Client) SendTyping(ctx context.Context, cfg adapterConfig, userID, typingTicket string, status int) error {
+ body, err := json.Marshal(SendTypingRequest{
+ ILinkUserID: userID,
+ TypingTicket: typingTicket,
+ Status: status,
+ BaseInfo: buildBaseInfo(),
+ })
+ if err != nil {
+ return err
+ }
+ _, err = c.apiPost(ctx, cfg.BaseURL, "ilink/bot/sendtyping", body, cfg.Token, defaultConfigTimeout)
+ return err
+}
+
+// GetUploadURL requests a CDN pre-signed upload URL.
+func (c *Client) GetUploadURL(ctx context.Context, cfg adapterConfig, req GetUploadURLRequest) (*GetUploadURLResponse, error) {
+ req.BaseInfo = buildBaseInfo()
+ body, err := json.Marshal(req)
+ if err != nil {
+ return nil, err
+ }
+ raw, err := c.apiPost(ctx, cfg.BaseURL, "ilink/bot/getuploadurl", body, cfg.Token, defaultAPITimeout)
+ if err != nil {
+ return nil, err
+ }
+ var resp GetUploadURLResponse
+ if err := json.Unmarshal(raw, &resp); err != nil {
+ return nil, fmt.Errorf("weixin getuploadurl decode: %w", err)
+ }
+ return &resp, nil
+}
+
+// FetchQRCode requests a new QR code for login.
+func (c *Client) FetchQRCode(ctx context.Context, apiBaseURL string) (*QRCodeResponse, error) {
+ base := ensureTrailingSlash(apiBaseURL)
+ u := base + "ilink/bot/get_bot_qrcode?bot_type=" + url.QueryEscape(defaultBotType)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := c.httpClient.Do(req) //nolint:gosec // URL from admin-configured baseURL
+ if err != nil {
+ return nil, fmt.Errorf("weixin qrcode fetch: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("weixin qrcode %d: %s", resp.StatusCode, string(raw))
+ }
+ var qr QRCodeResponse
+ if err := json.Unmarshal(raw, &qr); err != nil {
+ return nil, fmt.Errorf("weixin qrcode decode: %w", err)
+ }
+ return &qr, nil
+}
+
+// PollQRStatus long-polls the QR code login status.
+func (c *Client) PollQRStatus(ctx context.Context, apiBaseURL, qrcode string) (*QRStatusResponse, error) {
+ base := ensureTrailingSlash(apiBaseURL)
+ u := base + "ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
+
+ ctx, cancel := context.WithTimeout(ctx, 35*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("iLink-App-ClientVersion", "1")
+ resp, err := c.httpClient.Do(req) //nolint:gosec // URL from admin-configured baseURL
+ if err != nil {
+ if ctx.Err() != nil {
+ return &QRStatusResponse{Status: "wait"}, nil
+ }
+ return nil, fmt.Errorf("weixin qrstatus fetch: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("weixin qrstatus %d: %s", resp.StatusCode, string(raw))
+ }
+ var status QRStatusResponse
+ if err := json.Unmarshal(raw, &status); err != nil {
+ return nil, fmt.Errorf("weixin qrstatus decode: %w", err)
+ }
+ return &status, nil
+}
diff --git a/internal/channel/adapters/weixin/config.go b/internal/channel/adapters/weixin/config.go
new file mode 100644
index 00000000..4943b0ce
--- /dev/null
+++ b/internal/channel/adapters/weixin/config.go
@@ -0,0 +1,160 @@
+package weixin
+
+import (
+ "errors"
+ "strconv"
+ "strings"
+
+ "github.com/memohai/memoh/internal/channel"
+)
+
+const (
+ defaultBaseURL = "https://ilinkai.weixin.qq.com"
+ defaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
+ defaultBotType = "3"
+)
+
+type adapterConfig struct {
+ Token string
+ BaseURL string
+ CDNBaseURL string
+ PollTimeoutSeconds int
+ EnableTyping bool
+}
+
+func normalizeConfig(raw map[string]any) (map[string]any, error) {
+ cfg, err := parseConfig(raw)
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]any{
+ "token": cfg.Token,
+ "baseUrl": cfg.BaseURL,
+ }
+ if cfg.PollTimeoutSeconds > 0 {
+ out["pollTimeoutSeconds"] = cfg.PollTimeoutSeconds
+ }
+ if cfg.EnableTyping {
+ out["enableTyping"] = true
+ }
+ return out, nil
+}
+
+func parseConfig(raw map[string]any) (adapterConfig, error) {
+ cfg := adapterConfig{
+ Token: strings.TrimSpace(channel.ReadString(raw, "token")),
+ BaseURL: strings.TrimSpace(channel.ReadString(raw, "baseUrl", "base_url")),
+ CDNBaseURL: defaultCDNBaseURL,
+ }
+ if cfg.BaseURL == "" {
+ cfg.BaseURL = defaultBaseURL
+ }
+ if v, ok := readInt(raw, "pollTimeoutSeconds", "poll_timeout_seconds"); ok {
+ cfg.PollTimeoutSeconds = v
+ }
+ if v, ok := readBool(raw, "enableTyping", "enable_typing"); ok {
+ cfg.EnableTyping = v
+ }
+ if cfg.Token == "" {
+ return adapterConfig{}, errors.New("weixin token is required")
+ }
+ return cfg, nil
+}
+
+func normalizeTarget(raw string) string {
+ v := strings.TrimSpace(raw)
+ if v == "" {
+ return ""
+ }
+ v = strings.TrimPrefix(v, "weixin:")
+ return strings.TrimSpace(v)
+}
+
+func normalizeUserConfig(raw map[string]any) (map[string]any, error) {
+ userID := strings.TrimSpace(channel.ReadString(raw, "userId", "user_id"))
+ if userID == "" {
+ return nil, errors.New("weixin user_id is required")
+ }
+ return map[string]any{"user_id": userID}, nil
+}
+
+func resolveTarget(raw map[string]any) (string, error) {
+ userID := strings.TrimSpace(channel.ReadString(raw, "userId", "user_id"))
+ if userID == "" {
+ return "", errors.New("weixin user config requires user_id")
+ }
+ return userID, nil
+}
+
+func matchBinding(config map[string]any, criteria channel.BindingCriteria) bool {
+ userID := strings.TrimSpace(channel.ReadString(config, "userId", "user_id"))
+ if userID == "" {
+ return false
+ }
+ if criteria.SubjectID != "" && criteria.SubjectID == userID {
+ return true
+ }
+ if v := strings.TrimSpace(criteria.Attribute("user_id")); v != "" && v == userID {
+ return true
+ }
+ return false
+}
+
+func buildUserConfig(identity channel.Identity) map[string]any {
+ out := map[string]any{}
+ if v := strings.TrimSpace(identity.SubjectID); v != "" {
+ out["user_id"] = v
+ }
+ return out
+}
+
+func readInt(raw map[string]any, keys ...string) (int, bool) {
+ for _, key := range keys {
+ value, ok := raw[key]
+ if !ok {
+ continue
+ }
+ switch v := value.(type) {
+ case int:
+ return v, true
+ case int32:
+ return int(v), true
+ case int64:
+ return int(v), true
+ case float64:
+ return int(v), true
+ case float32:
+ return int(v), true
+ case string:
+ trimmed := strings.TrimSpace(v)
+ if trimmed == "" {
+ continue
+ }
+ parsed, err := strconv.Atoi(trimmed)
+ if err != nil {
+ continue
+ }
+ return parsed, true
+ }
+ }
+ return 0, false
+}
+
+func readBool(raw map[string]any, keys ...string) (bool, bool) {
+ for _, key := range keys {
+ value, ok := raw[key]
+ if !ok {
+ continue
+ }
+ switch v := value.(type) {
+ case bool:
+ return v, true
+ case string:
+ b, err := strconv.ParseBool(strings.TrimSpace(v))
+ if err == nil {
+ return b, true
+ }
+ }
+ }
+ return false, false
+}
diff --git a/internal/channel/adapters/weixin/config_test.go b/internal/channel/adapters/weixin/config_test.go
new file mode 100644
index 00000000..23cf5531
--- /dev/null
+++ b/internal/channel/adapters/weixin/config_test.go
@@ -0,0 +1,164 @@
+package weixin
+
+import (
+ "testing"
+
+ "github.com/memohai/memoh/internal/channel"
+)
+
+func TestParseConfig(t *testing.T) {
+ t.Run("valid minimal", func(t *testing.T) {
+ cfg, err := parseConfig(map[string]any{"token": "abc123"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.Token != "abc123" {
+ t.Errorf("token = %q, want %q", cfg.Token, "abc123")
+ }
+ if cfg.BaseURL != defaultBaseURL {
+ t.Errorf("baseURL = %q, want %q", cfg.BaseURL, defaultBaseURL)
+ }
+ if cfg.CDNBaseURL != defaultCDNBaseURL {
+ t.Errorf("cdnBaseURL = %q, want %q", cfg.CDNBaseURL, defaultCDNBaseURL)
+ }
+ })
+
+ t.Run("valid full", func(t *testing.T) {
+ cfg, err := parseConfig(map[string]any{
+ "token": "tok",
+ "baseUrl": "https://example.com",
+ "pollTimeoutSeconds": 60,
+ "enableTyping": true,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.BaseURL != "https://example.com" {
+ t.Errorf("baseURL = %q", cfg.BaseURL)
+ }
+ if cfg.CDNBaseURL != defaultCDNBaseURL {
+ t.Errorf("cdnBaseURL = %q, want %q", cfg.CDNBaseURL, defaultCDNBaseURL)
+ }
+ if cfg.PollTimeoutSeconds != 60 {
+ t.Errorf("pollTimeout = %d", cfg.PollTimeoutSeconds)
+ }
+ if !cfg.EnableTyping {
+ t.Error("enableTyping should be true")
+ }
+ })
+
+ t.Run("missing token", func(t *testing.T) {
+ _, err := parseConfig(map[string]any{"baseUrl": "https://example.com"})
+ if err == nil {
+ t.Fatal("expected error for missing token")
+ }
+ })
+
+ t.Run("snake_case keys", func(t *testing.T) {
+ cfg, err := parseConfig(map[string]any{
+ "token": "tok",
+ "base_url": "https://alt.com",
+ "poll_timeout_seconds": 45,
+ "enable_typing": true,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.BaseURL != "https://alt.com" {
+ t.Errorf("baseURL = %q", cfg.BaseURL)
+ }
+ if cfg.CDNBaseURL != defaultCDNBaseURL {
+ t.Errorf("cdnBaseURL = %q", cfg.CDNBaseURL)
+ }
+ if cfg.PollTimeoutSeconds != 45 {
+ t.Errorf("pollTimeout = %d", cfg.PollTimeoutSeconds)
+ }
+ if !cfg.EnableTyping {
+ t.Error("enableTyping should be true")
+ }
+ })
+}
+
+func TestNormalizeConfig(t *testing.T) {
+ out, err := normalizeConfig(map[string]any{
+ "token": "tok",
+ "baseUrl": "https://example.com",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if out["token"] != "tok" {
+ t.Errorf("token = %v", out["token"])
+ }
+ if out["baseUrl"] != "https://example.com" {
+ t.Errorf("baseUrl = %v", out["baseUrl"])
+ }
+ if _, has := out["cdnBaseUrl"]; has {
+ t.Error("cdnBaseUrl should not be in normalized output")
+ }
+}
+
+func TestNormalizeTarget(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"abc@im.wechat", "abc@im.wechat"},
+ {"weixin:abc@im.wechat", "abc@im.wechat"},
+ {" weixin: abc@im.wechat ", "abc@im.wechat"},
+ {"", ""},
+ {" ", ""},
+ }
+ for _, tc := range tests {
+ got := normalizeTarget(tc.input)
+ if got != tc.want {
+ t.Errorf("normalizeTarget(%q) = %q, want %q", tc.input, got, tc.want)
+ }
+ }
+}
+
+func TestNormalizeUserConfig(t *testing.T) {
+ t.Run("valid", func(t *testing.T) {
+ out, err := normalizeUserConfig(map[string]any{"user_id": "u1"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if out["user_id"] != "u1" {
+ t.Errorf("user_id = %v", out["user_id"])
+ }
+ })
+ t.Run("missing", func(t *testing.T) {
+ _, err := normalizeUserConfig(map[string]any{})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ })
+}
+
+func TestResolveTarget(t *testing.T) {
+ target, err := resolveTarget(map[string]any{"user_id": "u1"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if target != "u1" {
+ t.Errorf("target = %q", target)
+ }
+}
+
+func TestMatchBinding(t *testing.T) {
+ config := map[string]any{"user_id": "u1"}
+ if !matchBinding(config, channel.BindingCriteria{SubjectID: "u1"}) {
+ t.Error("should match by subject_id")
+ }
+ if matchBinding(config, channel.BindingCriteria{SubjectID: "u2"}) {
+ t.Error("should not match different subject_id")
+ }
+}
+
+func TestBuildUserConfig(t *testing.T) {
+ id := channel.Identity{SubjectID: "u1"}
+ out := buildUserConfig(id)
+ if out["user_id"] != "u1" {
+ t.Errorf("user_id = %v", out["user_id"])
+ }
+}
diff --git a/internal/channel/adapters/weixin/context_cache.go b/internal/channel/adapters/weixin/context_cache.go
new file mode 100644
index 00000000..714dea99
--- /dev/null
+++ b/internal/channel/adapters/weixin/context_cache.go
@@ -0,0 +1,78 @@
+package weixin
+
+import (
+ "strings"
+ "sync"
+ "time"
+)
+
+// contextTokenCache stores the latest context_token per target user.
+// The WeChat API requires a context_token (issued per inbound message)
+// for every outbound send. This cache is populated by the long-poll
+// receiver and read by the sender, similar to WeCom's callbackContextCache.
+type contextTokenCache struct {
+ mu sync.RWMutex
+ items map[string]contextTokenEntry
+ ttl time.Duration
+}
+
+type contextTokenEntry struct {
+ Token string
+ CreatedAt time.Time
+}
+
+func newContextTokenCache(ttl time.Duration) *contextTokenCache {
+ if ttl <= 0 {
+ ttl = 24 * time.Hour
+ }
+ return &contextTokenCache{
+ items: make(map[string]contextTokenEntry),
+ ttl: ttl,
+ }
+}
+
+func (c *contextTokenCache) Put(target string, token string) {
+ key := strings.TrimSpace(target)
+ if key == "" || strings.TrimSpace(token) == "" {
+ return
+ }
+ c.mu.Lock()
+ c.items[key] = contextTokenEntry{
+ Token: token,
+ CreatedAt: time.Now().UTC(),
+ }
+ c.gcLocked()
+ c.mu.Unlock()
+}
+
+func (c *contextTokenCache) Get(target string) (string, bool) {
+ key := strings.TrimSpace(target)
+ if key == "" {
+ return "", false
+ }
+ c.mu.RLock()
+ entry, ok := c.items[key]
+ c.mu.RUnlock()
+ if !ok {
+ return "", false
+ }
+ if time.Since(entry.CreatedAt) > c.ttl {
+ c.mu.Lock()
+ delete(c.items, key)
+ c.mu.Unlock()
+ return "", false
+ }
+ return entry.Token, true
+}
+
+func (c *contextTokenCache) gcLocked() {
+ if len(c.items) < 512 {
+ return
+ }
+ now := time.Now().UTC()
+ for key, entry := range c.items {
+ if now.Sub(entry.CreatedAt) > c.ttl {
+ delete(c.items, key)
+ }
+ }
+}
diff --git a/internal/channel/adapters/weixin/context_cache_test.go b/internal/channel/adapters/weixin/context_cache_test.go
new file mode 100644
index 00000000..a4bd13f2
--- /dev/null
+++ b/internal/channel/adapters/weixin/context_cache_test.go
@@ -0,0 +1,73 @@
+package weixin
+
+import (
+ "testing"
+ "time"
+)
+
+func TestContextTokenCache_PutGet(t *testing.T) {
+ cache := newContextTokenCache(1 * time.Hour)
+
+ cache.Put("user1", "token1")
+ got, ok := cache.Get("user1")
+ if !ok {
+ t.Fatal("expected to find token")
+ }
+ if got != "token1" {
+ t.Errorf("token = %q, want %q", got, "token1")
+ }
+}
+
+func TestContextTokenCache_Miss(t *testing.T) {
+ cache := newContextTokenCache(1 * time.Hour)
+
+ _, ok := cache.Get("nonexistent")
+ if ok {
+ t.Error("expected miss for nonexistent key")
+ }
+}
+
+func TestContextTokenCache_EmptyKey(t *testing.T) {
+ cache := newContextTokenCache(1 * time.Hour)
+
+ cache.Put("", "token1")
+ _, ok := cache.Get("")
+ if ok {
+ t.Error("expected miss for empty key")
+ }
+}
+
+func TestContextTokenCache_EmptyToken(t *testing.T) {
+ cache := newContextTokenCache(1 * time.Hour)
+
+ cache.Put("user1", "")
+ _, ok := cache.Get("user1")
+ if ok {
+ t.Error("expected miss for empty token")
+ }
+}
+
+func TestContextTokenCache_Overwrite(t *testing.T) {
+ cache := newContextTokenCache(1 * time.Hour)
+
+ cache.Put("user1", "token1")
+ cache.Put("user1", "token2")
+ got, ok := cache.Get("user1")
+ if !ok {
+ t.Fatal("expected to find token")
+ }
+ if got != "token2" {
+ t.Errorf("token = %q, want %q", got, "token2")
+ }
+}
+
+func TestContextTokenCache_Expiry(t *testing.T) {
+ cache := newContextTokenCache(1 * time.Millisecond)
+
+ cache.Put("user1", "token1")
+ time.Sleep(5 * time.Millisecond)
+ _, ok := cache.Get("user1")
+ if ok {
+ t.Error("expected miss after expiry")
+ }
+}
diff --git a/internal/channel/adapters/weixin/crypto.go b/internal/channel/adapters/weixin/crypto.go
new file mode 100644
index 00000000..c6b5692d
--- /dev/null
+++ b/internal/channel/adapters/weixin/crypto.go
@@ -0,0 +1,188 @@
+// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
+// See LICENSE in this directory for the full license text.
+
+package weixin
+
+import (
+ "crypto/aes"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// encryptAESECB encrypts plaintext with AES-128-ECB and PKCS7 padding.
+func encryptAESECB(plaintext, key []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ bs := block.BlockSize()
+ padded := pkcs7Pad(plaintext, bs)
+ out := make([]byte, len(padded))
+ for i := 0; i < len(padded); i += bs {
+ block.Encrypt(out[i:i+bs], padded[i:i+bs])
+ }
+ return out, nil
+}
+
+// decryptAESECB decrypts ciphertext with AES-128-ECB and PKCS7 padding.
+func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ bs := block.BlockSize()
+ if len(ciphertext)%bs != 0 {
+ return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size %d", len(ciphertext), bs)
+ }
+ out := make([]byte, len(ciphertext))
+ for i := 0; i < len(ciphertext); i += bs {
+ block.Decrypt(out[i:i+bs], ciphertext[i:i+bs])
+ }
+ return pkcs7Unpad(out, bs)
+}
+
+func pkcs7Pad(data []byte, blockSize int) []byte {
+ padding := blockSize - len(data)%blockSize
+ padded := make([]byte, len(data)+padding)
+ copy(padded, data)
+ for i := len(data); i < len(padded); i++ {
+ padded[i] = byte(padding) //nolint:gosec // padding is always 1..blockSize(16)
+ }
+ return padded
+}
+
+func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
+ if len(data) == 0 {
+ return data, nil
+ }
+ padding := int(data[len(data)-1])
+ if padding > blockSize || padding == 0 {
+ return nil, fmt.Errorf("invalid pkcs7 padding %d", padding)
+ }
+ for i := len(data) - padding; i < len(data); i++ {
+ if data[i] != byte(padding) { //nolint:gosec // padding is always 1..blockSize(16)
+ return nil, fmt.Errorf("invalid pkcs7 padding at byte %d", i)
+ }
+ }
+ return data[:len(data)-padding], nil
+}
+
+// aesECBPaddedSize returns the ciphertext size after AES-128-ECB with PKCS7 padding.
+// PKCS7 always adds at least 1 byte of padding, rounding up to a 16-byte boundary.
+func aesECBPaddedSize(plaintextSize int) int {
+ // ceil((n+1) / 16) * 16
+ return ((plaintextSize + 1 + 15) / 16) * 16 //nolint:mnd
+}
+
+// parseAESKey parses a base64-encoded AES key. Handles two formats:
+// - base64(raw 16 bytes)
+// - base64(hex string of 16 bytes) -> 32 hex chars.
+func parseAESKey(aesKeyBase64 string) ([]byte, error) {
+ decoded, err := base64.StdEncoding.DecodeString(aesKeyBase64)
+ if err != nil {
+ return nil, fmt.Errorf("aes key base64 decode: %w", err)
+ }
+ if len(decoded) == 16 {
+ return decoded, nil
+ }
+ if len(decoded) == 32 {
+ s := string(decoded)
+ if isHexString(s) {
+ key, err := hex.DecodeString(s)
+ if err != nil {
+ return nil, fmt.Errorf("aes key hex decode: %w", err)
+ }
+ return key, nil
+ }
+ }
+ return nil, fmt.Errorf("aes key must be 16 raw bytes or 32-char hex, got %d bytes", len(decoded))
+}
+
+func isHexString(s string) bool {
+ for _, c := range s {
+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
+ return false
+ }
+ }
+ return true
+}
+
+// CDN URL helpers.
+
+func buildCDNDownloadURL(encryptedQueryParam, cdnBaseURL string) string {
+ return cdnBaseURL + "/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam)
+}
+
+func buildCDNUploadURL(cdnBaseURL, uploadParam, filekey string) string {
+ return cdnBaseURL + "/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) +
+ "&filekey=" + url.QueryEscape(filekey)
+}
+
+// downloadAndDecrypt fetches encrypted bytes from the CDN and decrypts with AES-128-ECB.
+func downloadAndDecrypt(cdnBaseURL, encryptedQueryParam, aesKeyBase64 string) ([]byte, error) {
+ key, err := parseAESKey(aesKeyBase64)
+ if err != nil {
+ return nil, err
+ }
+ u := buildCDNDownloadURL(encryptedQueryParam, cdnBaseURL)
+ encrypted, err := fetchURL(u)
+ if err != nil {
+ return nil, fmt.Errorf("cdn download: %w", err)
+ }
+ return decryptAESECB(encrypted, key)
+}
+
+// downloadPlain fetches unencrypted bytes from the CDN.
+func downloadPlain(cdnBaseURL, encryptedQueryParam string) ([]byte, error) {
+ u := buildCDNDownloadURL(encryptedQueryParam, cdnBaseURL)
+ return fetchURL(u)
+}
+
+func fetchURL(u string) ([]byte, error) {
+ resp, err := http.Get(u) //nolint:gosec,noctx
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("cdn %d: %s", resp.StatusCode, string(body))
+ }
+ return io.ReadAll(resp.Body)
+}
+
+// uploadToCDN encrypts and uploads bytes to the WeChat CDN, returning the download param.
+func uploadToCDN(cdnBaseURL, uploadParam, filekey string, plaintext, aesKey []byte) (string, error) {
+ ciphertext, err := encryptAESECB(plaintext, aesKey)
+ if err != nil {
+ return "", fmt.Errorf("cdn encrypt: %w", err)
+ }
+ u := buildCDNUploadURL(cdnBaseURL, uploadParam, filekey)
+
+ req, err := http.NewRequest(http.MethodPost, u, io.NopCloser(strings.NewReader(string(ciphertext)))) //nolint:noctx
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/octet-stream")
+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) //nolint:mnd,gosec // CDN URL from admin config
+ if err != nil {
+ return "", fmt.Errorf("cdn upload: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("cdn upload %d: %s", resp.StatusCode, string(body))
+ }
+ downloadParam := resp.Header.Get("x-encrypted-param")
+ if downloadParam == "" {
+ return "", errors.New("cdn upload: missing x-encrypted-param header")
+ }
+ return downloadParam, nil
+}
diff --git a/internal/channel/adapters/weixin/crypto_test.go b/internal/channel/adapters/weixin/crypto_test.go
new file mode 100644
index 00000000..a321b286
--- /dev/null
+++ b/internal/channel/adapters/weixin/crypto_test.go
@@ -0,0 +1,98 @@
+package weixin
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/hex"
+ "testing"
+)
+
+func TestEncryptDecryptAESECB(t *testing.T) {
+ key := []byte("0123456789abcdef") // 16 bytes
+ plaintext := []byte("hello world test")
+
+ ciphertext, err := encryptAESECB(plaintext, key)
+ if err != nil {
+ t.Fatalf("encrypt: %v", err)
+ }
+ if bytes.Equal(ciphertext, plaintext) {
+ t.Error("ciphertext should differ from plaintext")
+ }
+
+ decrypted, err := decryptAESECB(ciphertext, key)
+ if err != nil {
+ t.Fatalf("decrypt: %v", err)
+ }
+ if !bytes.Equal(decrypted, plaintext) {
+ t.Errorf("decrypted = %q, want %q", string(decrypted), string(plaintext))
+ }
+}
+
+func TestEncryptDecryptAESECB_ShortInput(t *testing.T) {
+ key := []byte("0123456789abcdef")
+ plaintext := []byte("hi")
+
+ ciphertext, err := encryptAESECB(plaintext, key)
+ if err != nil {
+ t.Fatalf("encrypt: %v", err)
+ }
+ decrypted, err := decryptAESECB(ciphertext, key)
+ if err != nil {
+ t.Fatalf("decrypt: %v", err)
+ }
+ if !bytes.Equal(decrypted, plaintext) {
+ t.Errorf("decrypted = %q, want %q", string(decrypted), string(plaintext))
+ }
+}
+
+func TestAESECBPaddedSize(t *testing.T) {
+ tests := []struct {
+ input int
+ want int
+ }{
+ {0, 16},
+ {1, 16},
+ {15, 16},
+ {16, 32},
+ {17, 32},
+ }
+ for _, tc := range tests {
+ got := aesECBPaddedSize(tc.input)
+ if got != tc.want {
+ t.Errorf("aesECBPaddedSize(%d) = %d, want %d", tc.input, got, tc.want)
+ }
+ }
+}
+
+func TestParseAESKey_Raw16Bytes(t *testing.T) {
+ raw := []byte("0123456789abcdef")
+ b64 := base64.StdEncoding.EncodeToString(raw)
+ key, err := parseAESKey(b64)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !bytes.Equal(key, raw) {
+ t.Errorf("key = %x, want %x", key, raw)
+ }
+}
+
+func TestParseAESKey_HexEncoded(t *testing.T) {
+ rawKey := []byte("0123456789abcdef")
+ hexStr := hex.EncodeToString(rawKey) // 32 hex chars
+ b64 := base64.StdEncoding.EncodeToString([]byte(hexStr))
+ key, err := parseAESKey(b64)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !bytes.Equal(key, rawKey) {
+ t.Errorf("key = %x, want %x", key, rawKey)
+ }
+}
+
+func TestParseAESKey_Invalid(t *testing.T) {
+ b64 := base64.StdEncoding.EncodeToString([]byte("short"))
+ _, err := parseAESKey(b64)
+ if err == nil {
+ t.Error("expected error for invalid key length")
+ }
+}
diff --git a/internal/channel/adapters/weixin/inbound.go b/internal/channel/adapters/weixin/inbound.go
new file mode 100644
index 00000000..23fa3ffd
--- /dev/null
+++ b/internal/channel/adapters/weixin/inbound.go
@@ -0,0 +1,232 @@
+// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
+// See LICENSE in this directory for the full license text.
+
+package weixin
+
+import (
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/memohai/memoh/internal/channel"
+)
+
+// buildInboundMessage maps a WeixinMessage to a Memoh InboundMessage.
+func buildInboundMessage(msg WeixinMessage) (channel.InboundMessage, bool) {
+ text, attachments := extractContent(msg)
+ if strings.TrimSpace(text) == "" && len(attachments) == 0 {
+ return channel.InboundMessage{}, false
+ }
+
+ fromUserID := strings.TrimSpace(msg.FromUserID)
+ if fromUserID == "" {
+ return channel.InboundMessage{}, false
+ }
+
+ msgID := strconv.FormatInt(msg.MessageID, 10)
+ if msg.Seq > 0 {
+ msgID = strconv.FormatInt(msg.MessageID, 10) + ":" + strconv.Itoa(msg.Seq)
+ }
+
+ meta := map[string]any{
+ "session_id": strings.TrimSpace(msg.SessionID),
+ "seq": msg.Seq,
+ }
+ if msg.ContextToken != "" {
+ meta["context_token"] = msg.ContextToken
+ }
+
+ var receivedAt time.Time
+ if msg.CreateTimeMs > 0 {
+ receivedAt = time.UnixMilli(msg.CreateTimeMs)
+ } else {
+ receivedAt = time.Now().UTC()
+ }
+
+ return channel.InboundMessage{
+ Channel: Type,
+ Message: channel.Message{
+ ID: msgID,
+ Format: channel.MessageFormatPlain,
+ Text: text,
+ Attachments: attachments,
+ Metadata: meta,
+ },
+ ReplyTarget: fromUserID,
+ Sender: channel.Identity{
+ SubjectID: fromUserID,
+ Attributes: map[string]string{
+ "user_id": fromUserID,
+ },
+ },
+ Conversation: channel.Conversation{
+ ID: fromUserID,
+ Type: channel.ConversationTypePrivate,
+ },
+ ReceivedAt: receivedAt,
+ Source: "weixin",
+ Metadata: meta,
+ }, true
+}
+
+// extractContent extracts text and attachments from the message item list.
+func extractContent(msg WeixinMessage) (string, []channel.Attachment) {
+ if len(msg.ItemList) == 0 {
+ return "", nil
+ }
+
+ var textParts []string
+ var attachments []channel.Attachment
+
+ for _, item := range msg.ItemList {
+ switch item.Type {
+ case ItemTypeText:
+ t := extractTextFromItem(item)
+ if t != "" {
+ textParts = append(textParts, t)
+ }
+ case ItemTypeImage:
+ if att, ok := buildImageAttachment(item); ok {
+ attachments = append(attachments, att)
+ }
+ case ItemTypeVoice:
+ if item.VoiceItem != nil && strings.TrimSpace(item.VoiceItem.Text) != "" && !hasMediaRef(item) {
+ textParts = append(textParts, item.VoiceItem.Text)
+ } else if att, ok := buildVoiceAttachment(item); ok {
+ attachments = append(attachments, att)
+ }
+ case ItemTypeFile:
+ if att, ok := buildFileAttachment(item); ok {
+ attachments = append(attachments, att)
+ }
+ case ItemTypeVideo:
+ if att, ok := buildVideoAttachment(item); ok {
+ attachments = append(attachments, att)
+ }
+ }
+ }
+
+ return strings.Join(textParts, "\n"), attachments
+}
+
+func extractTextFromItem(item MessageItem) string {
+ if item.TextItem == nil || strings.TrimSpace(item.TextItem.Text) == "" {
+ return ""
+ }
+ text := item.TextItem.Text
+ ref := item.RefMsg
+ if ref == nil {
+ return text
+ }
+ if ref.MessageItem != nil && isMediaItemType(ref.MessageItem.Type) {
+ return text
+ }
+ var parts []string
+ if strings.TrimSpace(ref.Title) != "" {
+ parts = append(parts, ref.Title)
+ }
+ if ref.MessageItem != nil {
+ if ref.MessageItem.TextItem != nil && strings.TrimSpace(ref.MessageItem.TextItem.Text) != "" {
+ parts = append(parts, ref.MessageItem.TextItem.Text)
+ }
+ }
+ if len(parts) == 0 {
+ return text
+ }
+ return fmt.Sprintf("[引用: %s]\n%s", strings.Join(parts, " | "), text)
+}
+
+func isMediaItemType(t int) bool {
+ return t == ItemTypeImage || t == ItemTypeVideo || t == ItemTypeFile || t == ItemTypeVoice
+}
+
+func hasMediaRef(item MessageItem) bool {
+ return item.VoiceItem != nil && item.VoiceItem.Media != nil &&
+ strings.TrimSpace(item.VoiceItem.Media.EncryptQueryParam) != ""
+}
+
+func buildImageAttachment(item MessageItem) (channel.Attachment, bool) {
+ img := item.ImageItem
+ if img == nil || img.Media == nil || strings.TrimSpace(img.Media.EncryptQueryParam) == "" {
+ return channel.Attachment{}, false
+ }
+ aesKey := resolveImageAESKey(img)
+ return channel.Attachment{
+ Type: channel.AttachmentImage,
+ PlatformKey: img.Media.EncryptQueryParam,
+ SourcePlatform: Type.String(),
+ Metadata: map[string]any{
+ "encrypt_query_param": img.Media.EncryptQueryParam,
+ "aes_key": aesKey,
+ },
+ }, true
+}
+
+// resolveImageAESKey picks the best AES key for image decryption.
+// Prefers the hex-encoded aeskey field, falling back to media.aes_key.
+func resolveImageAESKey(img *ImageItem) string {
+ if strings.TrimSpace(img.AESKey) != "" {
+ keyBytes, err := hex.DecodeString(img.AESKey)
+ if err == nil {
+ return base64.StdEncoding.EncodeToString(keyBytes)
+ }
+ }
+ if img.Media != nil {
+ return strings.TrimSpace(img.Media.AESKey)
+ }
+ return ""
+}
+
+func buildVoiceAttachment(item MessageItem) (channel.Attachment, bool) {
+ v := item.VoiceItem
+ if v == nil || v.Media == nil || strings.TrimSpace(v.Media.EncryptQueryParam) == "" || strings.TrimSpace(v.Media.AESKey) == "" {
+ return channel.Attachment{}, false
+ }
+ return channel.Attachment{
+ Type: channel.AttachmentVoice,
+ PlatformKey: v.Media.EncryptQueryParam,
+ SourcePlatform: Type.String(),
+ DurationMs: int64(v.Playtime),
+ Metadata: map[string]any{
+ "encrypt_query_param": v.Media.EncryptQueryParam,
+ "aes_key": v.Media.AESKey,
+ "encode_type": v.EncodeType,
+ },
+ }, true
+}
+
+func buildFileAttachment(item MessageItem) (channel.Attachment, bool) {
+ f := item.FileItem
+ if f == nil || f.Media == nil || strings.TrimSpace(f.Media.EncryptQueryParam) == "" || strings.TrimSpace(f.Media.AESKey) == "" {
+ return channel.Attachment{}, false
+ }
+ return channel.Attachment{
+ Type: channel.AttachmentFile,
+ PlatformKey: f.Media.EncryptQueryParam,
+ SourcePlatform: Type.String(),
+ Name: strings.TrimSpace(f.FileName),
+ Metadata: map[string]any{
+ "encrypt_query_param": f.Media.EncryptQueryParam,
+ "aes_key": f.Media.AESKey,
+ },
+ }, true
+}
+
+func buildVideoAttachment(item MessageItem) (channel.Attachment, bool) {
+ v := item.VideoItem
+ if v == nil || v.Media == nil || strings.TrimSpace(v.Media.EncryptQueryParam) == "" || strings.TrimSpace(v.Media.AESKey) == "" {
+ return channel.Attachment{}, false
+ }
+ return channel.Attachment{
+ Type: channel.AttachmentVideo,
+ PlatformKey: v.Media.EncryptQueryParam,
+ SourcePlatform: Type.String(),
+ Metadata: map[string]any{
+ "encrypt_query_param": v.Media.EncryptQueryParam,
+ "aes_key": v.Media.AESKey,
+ },
+ }, true
+}
diff --git a/internal/channel/adapters/weixin/inbound_test.go b/internal/channel/adapters/weixin/inbound_test.go
new file mode 100644
index 00000000..04200c05
--- /dev/null
+++ b/internal/channel/adapters/weixin/inbound_test.go
@@ -0,0 +1,201 @@
+package weixin
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/memohai/memoh/internal/channel"
+)
+
+func TestBuildInboundMessage_TextOnly(t *testing.T) {
+ msg := WeixinMessage{
+ MessageID: 12345,
+ Seq: 1,
+ FromUserID: "user1@im.wechat",
+ CreateTimeMs: 1700000000000,
+ ContextToken: "ctx-tok-1",
+ ItemList: []MessageItem{
+ {Type: ItemTypeText, TextItem: &TextItem{Text: "hello world"}},
+ },
+ }
+
+ inbound, ok := buildInboundMessage(msg)
+ if !ok {
+ t.Fatal("expected valid inbound message")
+ }
+ if inbound.Channel != Type {
+ t.Errorf("channel = %v, want %v", inbound.Channel, Type)
+ }
+ if inbound.Message.Text != "hello world" {
+ t.Errorf("text = %q, want %q", inbound.Message.Text, "hello world")
+ }
+ if inbound.ReplyTarget != "user1@im.wechat" {
+ t.Errorf("reply_target = %q", inbound.ReplyTarget)
+ }
+ if inbound.Sender.SubjectID != "user1@im.wechat" {
+ t.Errorf("sender = %q", inbound.Sender.SubjectID)
+ }
+ if inbound.Conversation.Type != channel.ConversationTypePrivate {
+ t.Errorf("conv_type = %q", inbound.Conversation.Type)
+ }
+ if inbound.Message.ID != "12345:1" {
+ t.Errorf("message_id = %q", inbound.Message.ID)
+ }
+ meta := inbound.Metadata
+ if meta == nil {
+ t.Fatal("metadata is nil")
+ }
+ if meta["context_token"] != "ctx-tok-1" {
+ t.Errorf("context_token = %v", meta["context_token"])
+ }
+}
+
+func TestBuildInboundMessage_Empty(t *testing.T) {
+ msg := WeixinMessage{
+ MessageID: 1,
+ FromUserID: "u1",
+ ItemList: []MessageItem{},
+ }
+ _, ok := buildInboundMessage(msg)
+ if ok {
+ t.Error("expected false for empty message")
+ }
+}
+
+func TestBuildInboundMessage_NoFrom(t *testing.T) {
+ msg := WeixinMessage{
+ MessageID: 1,
+ ItemList: []MessageItem{{Type: ItemTypeText, TextItem: &TextItem{Text: "hi"}}},
+ }
+ _, ok := buildInboundMessage(msg)
+ if ok {
+ t.Error("expected false for message without from_user_id")
+ }
+}
+
+func TestBuildInboundMessage_ImageAttachment(t *testing.T) {
+ msg := WeixinMessage{
+ MessageID: 1,
+ FromUserID: "u1",
+ ItemList: []MessageItem{
+ {
+ Type: ItemTypeImage,
+ ImageItem: &ImageItem{
+ Media: &CDNMedia{
+ EncryptQueryParam: "enc-param-1",
+ AESKey: "QUJDREVGR0hJSktMTU5PUA==", // base64 of 16 bytes
+ },
+ },
+ },
+ },
+ }
+
+ inbound, ok := buildInboundMessage(msg)
+ if !ok {
+ t.Fatal("expected valid inbound message")
+ }
+ if len(inbound.Message.Attachments) != 1 {
+ t.Fatalf("attachments = %d, want 1", len(inbound.Message.Attachments))
+ }
+ att := inbound.Message.Attachments[0]
+ if att.Type != channel.AttachmentImage {
+ t.Errorf("attachment type = %v", att.Type)
+ }
+ if att.PlatformKey != "enc-param-1" {
+ t.Errorf("platform_key = %q", att.PlatformKey)
+ }
+ if att.SourcePlatform != "weixin" {
+ t.Errorf("source_platform = %q", att.SourcePlatform)
+ }
+}
+
+func TestBuildInboundMessage_VoiceWithText(t *testing.T) {
+ msg := WeixinMessage{
+ MessageID: 1,
+ FromUserID: "u1",
+ ItemList: []MessageItem{
+ {
+ Type: ItemTypeVoice,
+ VoiceItem: &VoiceItem{
+ Text: "transcribed voice text",
+ },
+ },
+ },
+ }
+
+ inbound, ok := buildInboundMessage(msg)
+ if !ok {
+ t.Fatal("expected valid inbound message")
+ }
+ if !strings.Contains(inbound.Message.Text, "transcribed voice text") {
+ t.Errorf("text = %q, expected voice transcription", inbound.Message.Text)
+ }
+ if len(inbound.Message.Attachments) != 0 {
+ t.Errorf("attachments = %d, want 0 (voice with text should be text only)", len(inbound.Message.Attachments))
+ }
+}
+
+func TestBuildInboundMessage_QuotedText(t *testing.T) {
+ msg := WeixinMessage{
+ MessageID: 1,
+ FromUserID: "u1",
+ ItemList: []MessageItem{
+ {
+ Type: ItemTypeText,
+ TextItem: &TextItem{Text: "my reply"},
+ RefMsg: &RefMessage{
+ Title: "Original",
+ MessageItem: &MessageItem{
+ Type: ItemTypeText,
+ TextItem: &TextItem{Text: "original text"},
+ },
+ },
+ },
+ },
+ }
+
+ inbound, ok := buildInboundMessage(msg)
+ if !ok {
+ t.Fatal("expected valid inbound message")
+ }
+ if !strings.Contains(inbound.Message.Text, "引用") {
+ t.Errorf("text should contain quoted context, got: %q", inbound.Message.Text)
+ }
+ if !strings.Contains(inbound.Message.Text, "my reply") {
+ t.Errorf("text should contain reply text, got: %q", inbound.Message.Text)
+ }
+}
+
+func TestBuildInboundMessage_FileAttachment(t *testing.T) {
+ msg := WeixinMessage{
+ MessageID: 1,
+ FromUserID: "u1",
+ ItemList: []MessageItem{
+ {
+ Type: ItemTypeFile,
+ FileItem: &FileItem{
+ Media: &CDNMedia{
+ EncryptQueryParam: "file-enc-1",
+ AESKey: "QUJDREVGR0hJSktMTU5PUA==",
+ },
+ FileName: "report.pdf",
+ },
+ },
+ },
+ }
+
+ inbound, ok := buildInboundMessage(msg)
+ if !ok {
+ t.Fatal("expected valid inbound message")
+ }
+ if len(inbound.Message.Attachments) != 1 {
+ t.Fatalf("attachments = %d, want 1", len(inbound.Message.Attachments))
+ }
+ att := inbound.Message.Attachments[0]
+ if att.Type != channel.AttachmentFile {
+ t.Errorf("type = %v", att.Type)
+ }
+ if att.Name != "report.pdf" {
+ t.Errorf("name = %q", att.Name)
+ }
+}
diff --git a/internal/channel/adapters/weixin/outbound.go b/internal/channel/adapters/weixin/outbound.go
new file mode 100644
index 00000000..d6048cb3
--- /dev/null
+++ b/internal/channel/adapters/weixin/outbound.go
@@ -0,0 +1,261 @@
+// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
+// See LICENSE in this directory for the full license text.
+
+package weixin
+
+import (
+ "context"
+ "crypto/md5" //nolint:gosec
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "strconv"
+ "strings"
+
+ "github.com/memohai/memoh/internal/media"
+)
+
+type assetOpener interface {
+ Open(ctx context.Context, botID, contentHash string) (io.ReadCloser, media.Asset, error)
+}
+
+// sendText sends a plain text message through the WeChat API.
+func sendText(ctx context.Context, client *Client, cfg adapterConfig, target, text, contextToken string) error {
+ if strings.TrimSpace(contextToken) == "" {
+ return errors.New("weixin: context_token is required to send messages")
+ }
+ clientID := generateClientID()
+ req := SendMessageRequest{
+ Msg: WeixinMessage{
+ ToUserID: target,
+ ClientID: clientID,
+ MessageType: MessageTypeBot,
+ MessageState: MessageStateFinish,
+ ItemList: []MessageItem{
+ {Type: ItemTypeText, TextItem: &TextItem{Text: text}},
+ },
+ ContextToken: contextToken,
+ },
+ }
+ return client.SendMessage(ctx, cfg, req)
+}
+
+// sendImageFromReader uploads an image and sends it.
+func sendImageFromReader(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text string, r io.Reader, logger *slog.Logger) error {
+ data, err := io.ReadAll(r)
+ if err != nil {
+ return fmt.Errorf("weixin: read image: %w", err)
+ }
+ return sendMediaBytes(ctx, client, cfg, target, contextToken, text, data, UploadMediaImage, ItemTypeImage, logger)
+}
+
+// sendFileFromReader uploads a file and sends it.
+func sendFileFromReader(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text, fileName string, r io.Reader, logger *slog.Logger) error {
+ data, err := io.ReadAll(r)
+ if err != nil {
+ return fmt.Errorf("weixin: read file: %w", err)
+ }
+ return sendMediaBytesAsFile(ctx, client, cfg, target, contextToken, text, fileName, data, logger)
+}
+
+func sendMediaBytes(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text string, data []byte, uploadType, itemType int, logger *slog.Logger) error {
+ if strings.TrimSpace(contextToken) == "" {
+ return errors.New("weixin: context_token is required for media send")
+ }
+
+ aesKey := make([]byte, 16)
+ if _, err := rand.Read(aesKey); err != nil {
+ return fmt.Errorf("weixin: gen aes key: %w", err)
+ }
+ filekey := make([]byte, 16)
+ if _, err := rand.Read(filekey); err != nil {
+ return fmt.Errorf("weixin: gen filekey: %w", err)
+ }
+ filekeyHex := hex.EncodeToString(filekey)
+ rawMD5 := md5.Sum(data) //nolint:gosec
+ rawMD5Hex := hex.EncodeToString(rawMD5[:])
+ fileSize := aesECBPaddedSize(len(data))
+
+ uploadResp, err := client.GetUploadURL(ctx, cfg, GetUploadURLRequest{
+ FileKey: filekeyHex,
+ MediaType: uploadType,
+ ToUserID: target,
+ RawSize: len(data),
+ RawFileMD5: rawMD5Hex,
+ FileSize: fileSize,
+ NoNeedThumb: true,
+ AESKey: hex.EncodeToString(aesKey),
+ })
+ if err != nil {
+ return fmt.Errorf("weixin: get upload url: %w", err)
+ }
+ if strings.TrimSpace(uploadResp.UploadParam) == "" {
+ return errors.New("weixin: empty upload_param")
+ }
+
+ downloadParam, err := uploadToCDN(cfg.CDNBaseURL, uploadResp.UploadParam, filekeyHex, data, aesKey)
+ if err != nil {
+ return fmt.Errorf("weixin: cdn upload: %w", err)
+ }
+
+ var mediaItem MessageItem
+ switch itemType {
+ case ItemTypeImage:
+ mediaItem = MessageItem{
+ Type: ItemTypeImage,
+ ImageItem: &ImageItem{
+ Media: &CDNMedia{
+ EncryptQueryParam: downloadParam,
+ AESKey: encodeAESKeyForSend(aesKey),
+ EncryptType: 1,
+ },
+ MidSize: fileSize,
+ },
+ }
+ case ItemTypeVideo:
+ mediaItem = MessageItem{
+ Type: ItemTypeVideo,
+ VideoItem: &VideoItem{
+ Media: &CDNMedia{
+ EncryptQueryParam: downloadParam,
+ AESKey: encodeAESKeyForSend(aesKey),
+ EncryptType: 1,
+ },
+ VideoSize: fileSize,
+ },
+ }
+ default:
+ return fmt.Errorf("weixin: unsupported media item type %d", itemType)
+ }
+
+ if logger != nil {
+ logger.Debug("weixin media uploaded",
+ slog.String("filekey", filekeyHex),
+ slog.Int("raw_size", len(data)),
+ slog.Int("cipher_size", fileSize),
+ )
+ }
+
+ items := make([]MessageItem, 0, 2)
+ if strings.TrimSpace(text) != "" {
+ items = append(items, MessageItem{Type: ItemTypeText, TextItem: &TextItem{Text: text}})
+ }
+ items = append(items, mediaItem)
+
+ for _, it := range items {
+ req := SendMessageRequest{
+ Msg: WeixinMessage{
+ ToUserID: target,
+ ClientID: generateClientID(),
+ MessageType: MessageTypeBot,
+ MessageState: MessageStateFinish,
+ ItemList: []MessageItem{it},
+ ContextToken: contextToken,
+ },
+ }
+ if err := client.SendMessage(ctx, cfg, req); err != nil {
+ return fmt.Errorf("weixin: send media item: %w", err)
+ }
+ }
+ return nil
+}
+
+func sendMediaBytesAsFile(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text, fileName string, data []byte, logger *slog.Logger) error {
+ if strings.TrimSpace(contextToken) == "" {
+ return errors.New("weixin: context_token is required for file send")
+ }
+
+ aesKey := make([]byte, 16)
+ if _, err := rand.Read(aesKey); err != nil {
+ return fmt.Errorf("weixin: gen aes key: %w", err)
+ }
+ filekey := make([]byte, 16)
+ if _, err := rand.Read(filekey); err != nil {
+ return fmt.Errorf("weixin: gen filekey: %w", err)
+ }
+ filekeyHex := hex.EncodeToString(filekey)
+ rawMD5 := md5.Sum(data) //nolint:gosec
+ rawMD5Hex := hex.EncodeToString(rawMD5[:])
+ fileSize := aesECBPaddedSize(len(data))
+
+ uploadResp, err := client.GetUploadURL(ctx, cfg, GetUploadURLRequest{
+ FileKey: filekeyHex,
+ MediaType: UploadMediaFile,
+ ToUserID: target,
+ RawSize: len(data),
+ RawFileMD5: rawMD5Hex,
+ FileSize: fileSize,
+ NoNeedThumb: true,
+ AESKey: hex.EncodeToString(aesKey),
+ })
+ if err != nil {
+ return fmt.Errorf("weixin: get upload url: %w", err)
+ }
+ if strings.TrimSpace(uploadResp.UploadParam) == "" {
+ return errors.New("weixin: empty upload_param")
+ }
+
+ downloadParam, err := uploadToCDN(cfg.CDNBaseURL, uploadResp.UploadParam, filekeyHex, data, aesKey)
+ if err != nil {
+ return fmt.Errorf("weixin: cdn upload: %w", err)
+ }
+
+ if logger != nil {
+ logger.Debug("weixin file uploaded",
+ slog.String("filekey", filekeyHex),
+ slog.String("filename", fileName),
+ slog.Int("raw_size", len(data)),
+ )
+ }
+
+ fileItem := MessageItem{
+ Type: ItemTypeFile,
+ FileItem: &FileItem{
+ Media: &CDNMedia{
+ EncryptQueryParam: downloadParam,
+ AESKey: encodeAESKeyForSend(aesKey),
+ EncryptType: 1,
+ },
+ FileName: fileName,
+ Len: strconv.Itoa(len(data)),
+ },
+ }
+
+ items := make([]MessageItem, 0, 2)
+ if strings.TrimSpace(text) != "" {
+ items = append(items, MessageItem{Type: ItemTypeText, TextItem: &TextItem{Text: text}})
+ }
+ items = append(items, fileItem)
+
+ for _, it := range items {
+ req := SendMessageRequest{
+ Msg: WeixinMessage{
+ ToUserID: target,
+ ClientID: generateClientID(),
+ MessageType: MessageTypeBot,
+ MessageState: MessageStateFinish,
+ ItemList: []MessageItem{it},
+ ContextToken: contextToken,
+ },
+ }
+ if err := client.SendMessage(ctx, cfg, req); err != nil {
+ return fmt.Errorf("weixin: send file item: %w", err)
+ }
+ }
+ return nil
+}
+
+// encodeAESKeyForSend encodes a raw 16-byte AES key for the sendmessage protocol.
+func encodeAESKeyForSend(key []byte) string {
+ hexStr := hex.EncodeToString(key)
+ return strings.TrimSpace(hexStr)
+}
+
+func generateClientID() string {
+ b := make([]byte, 8)
+ _, _ = rand.Read(b)
+ return "memoh-weixin-" + hex.EncodeToString(b)
+}
diff --git a/internal/channel/adapters/weixin/qr_handler.go b/internal/channel/adapters/weixin/qr_handler.go
new file mode 100644
index 00000000..fe6c6a94
--- /dev/null
+++ b/internal/channel/adapters/weixin/qr_handler.go
@@ -0,0 +1,173 @@
+package weixin
+
+import (
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "github.com/labstack/echo/v4"
+
+ "github.com/memohai/memoh/internal/channel"
+)
+
+// QRHandler handles WeChat QR code login for the management UI.
+type QRHandler struct {
+ logger *slog.Logger
+ client *Client
+ lifecycle *channel.Lifecycle
+}
+
+// NewQRHandler creates a QR handler.
+func NewQRHandler(log *slog.Logger, lifecycle *channel.Lifecycle) *QRHandler {
+ if log == nil {
+ log = slog.Default()
+ }
+ return &QRHandler{
+ logger: log.With(slog.String("handler", "weixin_qr")),
+ client: NewClient(log),
+ lifecycle: lifecycle,
+ }
+}
+
+// NewQRServerHandler is a DI-friendly constructor for fx, returning the handler
+// that implements server.Handler.
+func NewQRServerHandler(log *slog.Logger, lifecycle *channel.Lifecycle) *QRHandler {
+ return NewQRHandler(log, lifecycle)
+}
+
+// Register registers QR login routes on the Echo instance.
+func (h *QRHandler) Register(e *echo.Echo) {
+ e.POST("/bots/:id/channel/weixin/qr/start", h.Start)
+ e.POST("/bots/:id/channel/weixin/qr/poll", h.Poll)
+}
+
+// QRStartResponse returns QR code data to the frontend.
+type QRStartResponse struct {
+ QRCodeURL string `json:"qr_code_url"`
+ QRCode string `json:"qr_code"`
+ Message string `json:"message"`
+}
+
+// Start godoc
+// @Summary Start WeChat QR login
+// @Description Fetch a QR code from WeChat for scanning.
+// @Tags bots
+// @Param id path string true "Bot ID"
+// @Success 200 {object} QRStartResponse
+// @Failure 500 {object} map[string]string
+// @Router /bots/{id}/channel/weixin/qr/start [post].
+func (h *QRHandler) Start(c echo.Context) error {
+ qr, err := h.client.FetchQRCode(c.Request().Context(), defaultBaseURL)
+ if err != nil {
+ h.logger.Error("weixin qr start failed", slog.Any("error", err))
+ return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch QR code: "+err.Error())
+ }
+
+ return c.JSON(http.StatusOK, QRStartResponse{
+ QRCodeURL: strings.TrimSpace(qr.QRCodeImgContent),
+ QRCode: strings.TrimSpace(qr.QRCode),
+ Message: "Scan the QR code with WeChat",
+ })
+}
+
+// QRPollRequest is the request body for polling QR status.
+type QRPollRequest struct {
+ QRCode string `json:"qr_code"`
+}
+
+// QRPollResponse returns the poll result.
+type QRPollResponse struct {
+ Status string `json:"status"` // wait, scaned, confirmed, expired
+ Message string `json:"message"`
+}
+
+// Poll godoc
+// @Summary Poll WeChat QR login status
+// @Description Long-poll the QR code scan status. On confirmed, auto-saves credentials.
+// @Tags bots
+// @Param id path string true "Bot ID"
+// @Param payload body QRPollRequest true "QR code to poll"
+// @Success 200 {object} QRPollResponse
+// @Failure 400 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Router /bots/{id}/channel/weixin/qr/poll [post].
+func (h *QRHandler) Poll(c echo.Context) error {
+ botID := strings.TrimSpace(c.Param("id"))
+ if botID == "" {
+ return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
+ }
+
+ var req QRPollRequest
+ if err := c.Bind(&req); err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, err.Error())
+ }
+ qrCode := strings.TrimSpace(req.QRCode)
+ if qrCode == "" {
+ return echo.NewHTTPError(http.StatusBadRequest, "qr_code is required")
+ }
+
+ status, err := h.client.PollQRStatus(c.Request().Context(), defaultBaseURL, qrCode)
+ if err != nil {
+ h.logger.Error("weixin qr poll failed", slog.Any("error", err))
+ return echo.NewHTTPError(http.StatusInternalServerError, "Poll failed: "+err.Error())
+ }
+
+ resp := QRPollResponse{
+ Status: status.Status,
+ Message: statusMessage(status.Status),
+ }
+
+ if status.Status == "confirmed" && strings.TrimSpace(status.BotToken) != "" {
+ resolvedBaseURL := defaultBaseURL
+ if strings.TrimSpace(status.BaseURL) != "" {
+ resolvedBaseURL = strings.TrimSpace(status.BaseURL)
+ }
+
+ if h.lifecycle != nil {
+ credentials := map[string]any{
+ "token": status.BotToken,
+ "baseUrl": resolvedBaseURL,
+ }
+
+ _, saveErr := h.lifecycle.UpsertBotChannelConfig(
+ c.Request().Context(),
+ botID,
+ Type,
+ channel.UpsertConfigRequest{
+ Credentials: credentials,
+ Disabled: boolPtr(false),
+ },
+ )
+ if saveErr != nil {
+ h.logger.Error("weixin qr save credentials failed",
+ slog.String("bot_id", botID),
+ slog.Any("error", saveErr),
+ )
+ return echo.NewHTTPError(http.StatusInternalServerError, "Login succeeded but failed to save credentials: "+saveErr.Error())
+ }
+ h.logger.Info("weixin qr login saved",
+ slog.String("bot_id", botID),
+ slog.String("account_id", status.ILinkBotID),
+ )
+ }
+ }
+
+ return c.JSON(http.StatusOK, resp)
+}
+
+func statusMessage(s string) string {
+ switch s {
+ case "wait":
+ return "Waiting for scan..."
+ case "scaned":
+ return "Scanned — confirm on your phone"
+ case "confirmed":
+ return "Login successful"
+ case "expired":
+ return "QR code expired"
+ default:
+ return s
+ }
+}
+
+func boolPtr(b bool) *bool { return &b }
diff --git a/internal/channel/adapters/weixin/types.go b/internal/channel/adapters/weixin/types.go
new file mode 100644
index 00000000..c298df53
--- /dev/null
+++ b/internal/channel/adapters/weixin/types.go
@@ -0,0 +1,218 @@
+// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
+// See LICENSE in this directory for the full license text.
+
+package weixin
+
+// WeChat iLink protocol types.
+// Mirrors the JSON structures used by the getupdates / sendmessage / getuploadurl / getconfig / sendtyping APIs.
+
+// BaseInfo is common metadata attached to every outgoing API request.
+type BaseInfo struct {
+ ChannelVersion string `json:"channel_version,omitempty"`
+}
+
+// MessageItemType constants for message items.
+const (
+ ItemTypeNone = 0
+ ItemTypeText = 1
+ ItemTypeImage = 2
+ ItemTypeVoice = 3
+ ItemTypeFile = 4
+ ItemTypeVideo = 5
+)
+
+// MessageType sender type.
+const (
+ MessageTypeNone = 0
+ MessageTypeUser = 1
+ MessageTypeBot = 2
+)
+
+// MessageState lifecycle.
+const (
+ MessageStateNew = 0
+ MessageStateGenerating = 1
+ MessageStateFinish = 2
+)
+
+// UploadMediaType for getUploadUrl.
+const (
+ UploadMediaImage = 1
+ UploadMediaVideo = 2
+ UploadMediaFile = 3
+ UploadMediaVoice = 4
+)
+
+// TypingStatus values.
+const (
+ TypingStatusTyping = 1
+ TypingStatusCancel = 2
+)
+
+// CDNMedia is a CDN reference attached to images/voices/files/videos.
+type CDNMedia struct {
+ EncryptQueryParam string `json:"encrypt_query_param,omitempty"`
+ AESKey string `json:"aes_key,omitempty"`
+ EncryptType int `json:"encrypt_type,omitempty"`
+}
+
+type TextItem struct {
+ Text string `json:"text,omitempty"`
+}
+
+type ImageItem struct {
+ Media *CDNMedia `json:"media,omitempty"`
+ ThumbMedia *CDNMedia `json:"thumb_media,omitempty"`
+ AESKey string `json:"aeskey,omitempty"` // hex-encoded preferred key
+ URL string `json:"url,omitempty"`
+ MidSize int `json:"mid_size,omitempty"`
+ ThumbSize int `json:"thumb_size,omitempty"`
+ ThumbHeight int `json:"thumb_height,omitempty"`
+ ThumbWidth int `json:"thumb_width,omitempty"`
+ HDSize int `json:"hd_size,omitempty"`
+}
+
+type VoiceItem struct {
+ Media *CDNMedia `json:"media,omitempty"`
+ EncodeType int `json:"encode_type,omitempty"`
+ BitsPerSample int `json:"bits_per_sample,omitempty"`
+ SampleRate int `json:"sample_rate,omitempty"`
+ Playtime int `json:"playtime,omitempty"` // ms
+ Text string `json:"text,omitempty"` // speech-to-text
+}
+
+type FileItem struct {
+ Media *CDNMedia `json:"media,omitempty"`
+ FileName string `json:"file_name,omitempty"`
+ MD5 string `json:"md5,omitempty"`
+ Len string `json:"len,omitempty"`
+}
+
+type VideoItem struct {
+ Media *CDNMedia `json:"media,omitempty"`
+ VideoSize int `json:"video_size,omitempty"`
+ PlayLength int `json:"play_length,omitempty"`
+ VideoMD5 string `json:"video_md5,omitempty"`
+ ThumbMedia *CDNMedia `json:"thumb_media,omitempty"`
+ ThumbSize int `json:"thumb_size,omitempty"`
+ ThumbHeight int `json:"thumb_height,omitempty"`
+ ThumbWidth int `json:"thumb_width,omitempty"`
+}
+
+type RefMessage struct {
+ MessageItem *MessageItem `json:"message_item,omitempty"`
+ Title string `json:"title,omitempty"`
+}
+
+type MessageItem struct {
+ Type int `json:"type,omitempty"`
+ CreateTimeMs int64 `json:"create_time_ms,omitempty"`
+ UpdateTimeMs int64 `json:"update_time_ms,omitempty"`
+ IsCompleted bool `json:"is_completed,omitempty"`
+ MsgID string `json:"msg_id,omitempty"`
+ RefMsg *RefMessage `json:"ref_msg,omitempty"`
+ TextItem *TextItem `json:"text_item,omitempty"`
+ ImageItem *ImageItem `json:"image_item,omitempty"`
+ VoiceItem *VoiceItem `json:"voice_item,omitempty"`
+ FileItem *FileItem `json:"file_item,omitempty"`
+ VideoItem *VideoItem `json:"video_item,omitempty"`
+}
+
+// WeixinMessage is a unified message from the getupdates response.
+type WeixinMessage struct {
+ Seq int `json:"seq,omitempty"`
+ MessageID int64 `json:"message_id,omitempty"`
+ FromUserID string `json:"from_user_id,omitempty"`
+ ToUserID string `json:"to_user_id,omitempty"`
+ ClientID string `json:"client_id,omitempty"`
+ CreateTimeMs int64 `json:"create_time_ms,omitempty"`
+ UpdateTimeMs int64 `json:"update_time_ms,omitempty"`
+ DeleteTimeMs int64 `json:"delete_time_ms,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
+ GroupID string `json:"group_id,omitempty"`
+ MessageType int `json:"message_type,omitempty"`
+ MessageState int `json:"message_state,omitempty"`
+ ItemList []MessageItem `json:"item_list,omitempty"`
+ ContextToken string `json:"context_token,omitempty"`
+}
+
+// GetUpdatesRequest is the getupdates request body.
+type GetUpdatesRequest struct {
+ GetUpdatesBuf string `json:"get_updates_buf"`
+ BaseInfo BaseInfo `json:"base_info,omitempty"`
+}
+
+// GetUpdatesResponse is the getupdates response body.
+type GetUpdatesResponse struct {
+ Ret int `json:"ret"`
+ ErrCode int `json:"errcode,omitempty"`
+ ErrMsg string `json:"errmsg,omitempty"`
+ Msgs []WeixinMessage `json:"msgs,omitempty"`
+ GetUpdatesBuf string `json:"get_updates_buf,omitempty"`
+ LongPollingTimeout int `json:"longpolling_timeout_ms,omitempty"`
+}
+
+// SendMessageRequest wraps a single message for the sendmessage API.
+type SendMessageRequest struct {
+ Msg WeixinMessage `json:"msg"`
+ BaseInfo BaseInfo `json:"base_info,omitempty"`
+}
+
+// GetUploadURLRequest is the getuploadurl request body.
+type GetUploadURLRequest struct {
+ FileKey string `json:"filekey,omitempty"`
+ MediaType int `json:"media_type,omitempty"`
+ ToUserID string `json:"to_user_id,omitempty"`
+ RawSize int `json:"rawsize,omitempty"`
+ RawFileMD5 string `json:"rawfilemd5,omitempty"`
+ FileSize int `json:"filesize,omitempty"`
+ ThumbRawSize int `json:"thumb_rawsize,omitempty"`
+ ThumbRawMD5 string `json:"thumb_rawfilemd5,omitempty"`
+ ThumbFileSize int `json:"thumb_filesize,omitempty"`
+ NoNeedThumb bool `json:"no_need_thumb,omitempty"`
+ AESKey string `json:"aeskey,omitempty"`
+ BaseInfo BaseInfo `json:"base_info,omitempty"`
+}
+
+// GetUploadURLResponse contains CDN upload params.
+type GetUploadURLResponse struct {
+ UploadParam string `json:"upload_param,omitempty"`
+ ThumbUploadParam string `json:"thumb_upload_param,omitempty"`
+}
+
+// GetConfigRequest is the getconfig request body.
+type GetConfigRequest struct {
+ ILinkUserID string `json:"ilink_user_id,omitempty"`
+ ContextToken string `json:"context_token,omitempty"`
+ BaseInfo BaseInfo `json:"base_info,omitempty"`
+}
+
+// GetConfigResponse contains bot config (typing ticket etc.).
+type GetConfigResponse struct {
+ Ret int `json:"ret"`
+ ErrMsg string `json:"errmsg,omitempty"`
+ TypingTicket string `json:"typing_ticket,omitempty"`
+}
+
+// SendTypingRequest is the sendtyping request body.
+type SendTypingRequest struct {
+ ILinkUserID string `json:"ilink_user_id,omitempty"`
+ TypingTicket string `json:"typing_ticket,omitempty"`
+ Status int `json:"status,omitempty"`
+ BaseInfo BaseInfo `json:"base_info,omitempty"`
+}
+
+// QRCodeResponse from get_bot_qrcode.
+type QRCodeResponse struct {
+ QRCode string `json:"qrcode"`
+ QRCodeImgContent string `json:"qrcode_img_content"`
+}
+
+// QRStatusResponse from get_qrcode_status.
+type QRStatusResponse struct {
+ Status string `json:"status"` // wait, scaned, confirmed, expired
+ BotToken string `json:"bot_token,omitempty"`
+ ILinkBotID string `json:"ilink_bot_id,omitempty"`
+ BaseURL string `json:"baseurl,omitempty"`
+ ILinkUserID string `json:"ilink_user_id,omitempty"`
+}
diff --git a/internal/channel/adapters/weixin/weixin.go b/internal/channel/adapters/weixin/weixin.go
new file mode 100644
index 00000000..0bd08480
--- /dev/null
+++ b/internal/channel/adapters/weixin/weixin.go
@@ -0,0 +1,526 @@
+package weixin
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "strings"
+ "time"
+
+ attachmentpkg "github.com/memohai/memoh/internal/attachment"
+ "github.com/memohai/memoh/internal/channel"
+)
+
+// Type is the channel type identifier for WeChat.
+const Type channel.ChannelType = "weixin"
+
+// WeixinAdapter is the Memoh channel adapter for personal WeChat via the Tencent iLink API.
+type WeixinAdapter struct {
+ logger *slog.Logger
+ client *Client
+ contextCache *contextTokenCache
+ assets assetOpener
+}
+
+// NewWeixinAdapter creates a new WeChat adapter.
+func NewWeixinAdapter(log *slog.Logger) *WeixinAdapter {
+ if log == nil {
+ log = slog.Default()
+ }
+ return &WeixinAdapter{
+ logger: log.With(slog.String("adapter", "weixin")),
+ client: NewClient(log),
+ contextCache: newContextTokenCache(24 * time.Hour),
+ }
+}
+
+// SetAssetOpener configures the media asset reader for outbound attachments.
+func (a *WeixinAdapter) SetAssetOpener(opener assetOpener) {
+ a.assets = opener
+}
+
+func (*WeixinAdapter) Type() channel.ChannelType { return Type }
+
+func (*WeixinAdapter) Descriptor() channel.Descriptor {
+ return channel.Descriptor{
+ Type: Type,
+ DisplayName: "WeChat",
+ Capabilities: channel.ChannelCapabilities{
+ Text: true,
+ Attachments: true,
+ Media: true,
+ Reply: true,
+ BlockStreaming: true,
+ ChatTypes: []string{channel.ConversationTypePrivate},
+ },
+ OutboundPolicy: channel.OutboundPolicy{
+ TextChunkLimit: 4000,
+ },
+ ConfigSchema: channel.ConfigSchema{
+ Version: 1,
+ Fields: map[string]channel.FieldSchema{
+ "token": {Type: channel.FieldSecret, Required: true, Title: "Token"},
+ "pollTimeoutSeconds": {Type: channel.FieldNumber, Title: "Poll Timeout (s)"},
+ "enableTyping": {Type: channel.FieldBool, Title: "Enable Typing Indicator"},
+ },
+ },
+ UserConfigSchema: channel.ConfigSchema{
+ Version: 1,
+ Fields: map[string]channel.FieldSchema{
+ "user_id": {Type: channel.FieldString, Required: true, Title: "WeChat User ID"},
+ },
+ },
+ TargetSpec: channel.TargetSpec{
+ Format: "",
+ Hints: []channel.TargetHint{
+ {Label: "User ID", Example: "abc123@im.wechat"},
+ },
+ },
+ }
+}
+
+// -- ConfigNormalizer --
+
+func (*WeixinAdapter) NormalizeConfig(raw map[string]any) (map[string]any, error) {
+ return normalizeConfig(raw)
+}
+
+func (*WeixinAdapter) NormalizeUserConfig(raw map[string]any) (map[string]any, error) {
+ return normalizeUserConfig(raw)
+}
+
+// -- TargetResolver --
+
+func (*WeixinAdapter) NormalizeTarget(raw string) string { return normalizeTarget(raw) }
+
+func (*WeixinAdapter) ResolveTarget(userConfig map[string]any) (string, error) {
+ return resolveTarget(userConfig)
+}
+
+// -- BindingMatcher --
+
+func (*WeixinAdapter) MatchBinding(config map[string]any, criteria channel.BindingCriteria) bool {
+ return matchBinding(config, criteria)
+}
+
+func (*WeixinAdapter) BuildUserConfig(identity channel.Identity) map[string]any {
+ return buildUserConfig(identity)
+}
+
+// -- Receiver (long-poll) --
+
+func (a *WeixinAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, handler channel.InboundHandler) (channel.Connection, error) {
+ parsed, err := parseConfig(cfg.Credentials)
+ if err != nil {
+ return nil, err
+ }
+
+ connCtx, cancel := context.WithCancel(ctx)
+ done := make(chan struct{})
+
+ go func() {
+ defer close(done)
+ a.pollLoop(connCtx, cfg, parsed, handler)
+ }()
+
+ stop := func(context.Context) error {
+ cancel()
+ <-done
+ return nil
+ }
+ return channel.NewConnection(cfg, stop), nil
+}
+
+func (a *WeixinAdapter) pollLoop(ctx context.Context, cfg channel.ChannelConfig, parsed adapterConfig, handler channel.InboundHandler) {
+ const (
+ maxConsecutiveFailures = 3
+ backoffDelay = 30 * time.Second
+ retryDelay = 2 * time.Second
+ sessionPauseDuration = 1 * time.Hour
+ )
+
+ var getUpdatesBuf string
+ var consecutiveFailures int
+
+ a.logger.Info("weixin poll loop started",
+ slog.String("config_id", cfg.ID),
+ slog.String("bot_id", cfg.BotID),
+ slog.String("base_url", parsed.BaseURL),
+ )
+
+ for {
+ select {
+ case <-ctx.Done():
+ a.logger.Info("weixin poll loop stopped", slog.String("config_id", cfg.ID))
+ return
+ default:
+ }
+
+ resp, err := a.client.GetUpdates(ctx, parsed, getUpdatesBuf)
+ if err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ consecutiveFailures++
+ a.logger.Error("weixin getupdates error",
+ slog.String("config_id", cfg.ID),
+ slog.Any("error", err),
+ slog.Int("failures", consecutiveFailures),
+ )
+ if consecutiveFailures >= maxConsecutiveFailures {
+ consecutiveFailures = 0
+ sleepCtx(ctx, backoffDelay)
+ } else {
+ sleepCtx(ctx, retryDelay)
+ }
+ continue
+ }
+
+ // Handle API-level errors.
+ isAPIError := (resp.Ret != 0) || (resp.ErrCode != 0)
+ if isAPIError {
+ if resp.ErrCode == sessionExpiredErrCode || resp.Ret == sessionExpiredErrCode {
+ a.logger.Error("weixin session expired, pausing",
+ slog.String("config_id", cfg.ID),
+ slog.Int("errcode", resp.ErrCode),
+ )
+ sleepCtx(ctx, sessionPauseDuration)
+ consecutiveFailures = 0
+ continue
+ }
+ consecutiveFailures++
+ a.logger.Error("weixin getupdates api error",
+ slog.String("config_id", cfg.ID),
+ slog.Int("ret", resp.Ret),
+ slog.Int("errcode", resp.ErrCode),
+ slog.String("errmsg", resp.ErrMsg),
+ )
+ if consecutiveFailures >= maxConsecutiveFailures {
+ consecutiveFailures = 0
+ sleepCtx(ctx, backoffDelay)
+ } else {
+ sleepCtx(ctx, retryDelay)
+ }
+ continue
+ }
+
+ consecutiveFailures = 0
+
+ if resp.GetUpdatesBuf != "" {
+ getUpdatesBuf = resp.GetUpdatesBuf
+ }
+
+ for _, msg := range resp.Msgs {
+ inbound, ok := buildInboundMessage(msg)
+ if !ok {
+ continue
+ }
+
+ // Cache context_token for outbound replies.
+ if strings.TrimSpace(msg.ContextToken) != "" {
+ cacheKey := cfg.ID + ":" + strings.TrimSpace(msg.FromUserID)
+ a.contextCache.Put(cacheKey, msg.ContextToken)
+ }
+
+ inbound.BotID = cfg.BotID
+
+ if err := handler(ctx, cfg, inbound); err != nil {
+ a.logger.Error("weixin inbound handler error",
+ slog.String("config_id", cfg.ID),
+ slog.String("from", msg.FromUserID),
+ slog.Any("error", err),
+ )
+ }
+ }
+ }
+}
+
+// -- StreamSender (block-streaming: buffer deltas, send final as one message) --
+
+func (a *WeixinAdapter) OpenStream(_ context.Context, cfg channel.ChannelConfig, target string, _ channel.StreamOptions) (channel.OutboundStream, error) {
+ target = strings.TrimSpace(target)
+ if target == "" {
+ return nil, errors.New("weixin target is required")
+ }
+ return &weixinBlockStream{
+ adapter: a,
+ cfg: cfg,
+ target: target,
+ }, nil
+}
+
+// -- Sender --
+
+func (a *WeixinAdapter) Send(ctx context.Context, cfg channel.ChannelConfig, msg channel.OutboundMessage) error {
+ parsed, err := parseConfig(cfg.Credentials)
+ if err != nil {
+ return err
+ }
+ target := strings.TrimSpace(msg.Target)
+ if target == "" {
+ return errors.New("weixin target is required")
+ }
+
+ cacheKey := cfg.ID + ":" + target
+ contextToken, ok := a.contextCache.Get(cacheKey)
+ if !ok {
+ return fmt.Errorf("weixin: no context_token cached for target %s (reply-only channel — message can only be sent after receiving an inbound message)", target)
+ }
+
+ // Send attachments first if present (media + text in one flow).
+ if len(msg.Message.Attachments) > 0 {
+ return a.sendWithAttachments(ctx, parsed, cfg.BotID, target, contextToken, msg.Message)
+ }
+
+ text := strings.TrimSpace(msg.Message.PlainText())
+ if text == "" {
+ return errors.New("weixin: message is empty")
+ }
+ return sendText(ctx, a.client, parsed, target, text, contextToken)
+}
+
+func (a *WeixinAdapter) sendWithAttachments(ctx context.Context, cfg adapterConfig, botID, target, contextToken string, msg channel.Message) error {
+ text := strings.TrimSpace(msg.PlainText())
+
+ for i, att := range msg.Attachments {
+ caption := ""
+ if i == 0 {
+ caption = text
+ }
+
+ r, err := a.openAttachment(ctx, botID, att)
+ if err != nil {
+ a.logger.Error("weixin: open attachment failed",
+ slog.String("type", string(att.Type)),
+ slog.Any("error", err),
+ )
+ continue
+ }
+
+ switch att.Type {
+ case channel.AttachmentImage, channel.AttachmentGIF:
+ if err := sendImageFromReader(ctx, a.client, cfg, target, contextToken, caption, r, a.logger); err != nil {
+ _ = r.Close()
+ return err
+ }
+ case channel.AttachmentVideo:
+ data, readErr := io.ReadAll(r)
+ _ = r.Close()
+ if readErr != nil {
+ return fmt.Errorf("weixin: read video: %w", readErr)
+ }
+ if err := sendMediaBytes(ctx, a.client, cfg, target, contextToken, caption, data, UploadMediaVideo, ItemTypeVideo, a.logger); err != nil {
+ return err
+ }
+ default:
+ name := strings.TrimSpace(att.Name)
+ if name == "" {
+ name = "file"
+ }
+ if err := sendFileFromReader(ctx, a.client, cfg, target, contextToken, caption, name, r, a.logger); err != nil {
+ _ = r.Close()
+ return err
+ }
+ }
+ _ = r.Close()
+ }
+
+ // If there are attachments but no text was sent as caption and there is text, send separately.
+ if len(msg.Attachments) == 0 && text != "" {
+ return sendText(ctx, a.client, cfg, target, text, contextToken)
+ }
+ return nil
+}
+
+func (a *WeixinAdapter) openAttachment(ctx context.Context, botID string, att channel.Attachment) (io.ReadCloser, error) {
+ // Try content hash first (from media store).
+ if strings.TrimSpace(att.ContentHash) != "" && a.assets != nil {
+ r, _, err := a.assets.Open(ctx, botID, att.ContentHash)
+ if err == nil {
+ return r, nil
+ }
+ }
+ // Try base64 data URL.
+ if strings.TrimSpace(att.Base64) != "" {
+ r, err := attachmentpkg.DecodeBase64(att.Base64, 100*1024*1024)
+ if err == nil {
+ data, readErr := io.ReadAll(r)
+ if readErr == nil {
+ return io.NopCloser(bytes.NewReader(data)), nil
+ }
+ }
+ }
+ return nil, errors.New("weixin: cannot open attachment (no content_hash or base64)")
+}
+
+// -- AttachmentResolver (for inbound media download/decrypt) --
+
+func (*WeixinAdapter) ResolveAttachment(_ context.Context, cfg channel.ChannelConfig, attachment channel.Attachment) (channel.AttachmentPayload, error) {
+ parsed, err := parseConfig(cfg.Credentials)
+ if err != nil {
+ return channel.AttachmentPayload{}, err
+ }
+
+ encryptedQP := ""
+ aesKey := ""
+ if attachment.Metadata != nil {
+ if v, ok := attachment.Metadata["encrypt_query_param"].(string); ok {
+ encryptedQP = strings.TrimSpace(v)
+ }
+ if v, ok := attachment.Metadata["aes_key"].(string); ok {
+ aesKey = strings.TrimSpace(v)
+ }
+ }
+ if encryptedQP == "" {
+ encryptedQP = strings.TrimSpace(attachment.PlatformKey)
+ }
+ if encryptedQP == "" {
+ return channel.AttachmentPayload{}, errors.New("weixin: no encrypt_query_param for attachment")
+ }
+
+ var data []byte
+ if aesKey != "" {
+ data, err = downloadAndDecrypt(parsed.CDNBaseURL, encryptedQP, aesKey)
+ } else {
+ data, err = downloadPlain(parsed.CDNBaseURL, encryptedQP)
+ }
+ if err != nil {
+ return channel.AttachmentPayload{}, fmt.Errorf("weixin: download attachment: %w", err)
+ }
+
+ mime := resolveMIME(attachment)
+ return channel.AttachmentPayload{
+ Reader: io.NopCloser(bytes.NewReader(data)),
+ Mime: mime,
+ Name: strings.TrimSpace(attachment.Name),
+ Size: int64(len(data)),
+ }, nil
+}
+
+func resolveMIME(att channel.Attachment) string {
+ if strings.TrimSpace(att.Mime) != "" {
+ return att.Mime
+ }
+ switch att.Type {
+ case channel.AttachmentImage:
+ return "image/jpeg"
+ case channel.AttachmentVoice, channel.AttachmentAudio:
+ return "audio/silk"
+ case channel.AttachmentVideo:
+ return "video/mp4"
+ default:
+ return "application/octet-stream"
+ }
+}
+
+// -- ProcessingStatusNotifier (typing indicator) --
+
+func (a *WeixinAdapter) ProcessingStarted(ctx context.Context, cfg channel.ChannelConfig, _ channel.InboundMessage, info channel.ProcessingStatusInfo) (channel.ProcessingStatusHandle, error) {
+ parsed, err := parseConfig(cfg.Credentials)
+ if err != nil || !parsed.EnableTyping {
+ return channel.ProcessingStatusHandle{}, nil
+ }
+ target := strings.TrimSpace(info.ReplyTarget)
+ if target == "" {
+ return channel.ProcessingStatusHandle{}, nil
+ }
+
+ cacheKey := cfg.ID + ":" + target
+ contextToken, _ := a.contextCache.Get(cacheKey)
+
+ configResp, err := a.client.GetConfig(ctx, parsed, target, contextToken)
+ if err != nil || strings.TrimSpace(configResp.TypingTicket) == "" {
+ return channel.ProcessingStatusHandle{}, nil
+ }
+
+ _ = a.client.SendTyping(ctx, parsed, target, configResp.TypingTicket, TypingStatusTyping)
+ return channel.ProcessingStatusHandle{Token: configResp.TypingTicket}, nil
+}
+
+func (a *WeixinAdapter) ProcessingCompleted(ctx context.Context, cfg channel.ChannelConfig, _ channel.InboundMessage, info channel.ProcessingStatusInfo, handle channel.ProcessingStatusHandle) error {
+ if strings.TrimSpace(handle.Token) == "" {
+ return nil
+ }
+ parsed, err := parseConfig(cfg.Credentials)
+ if err != nil || !parsed.EnableTyping {
+ return nil
+ }
+ target := strings.TrimSpace(info.ReplyTarget)
+ if target == "" {
+ return nil
+ }
+ return a.client.SendTyping(ctx, parsed, target, handle.Token, TypingStatusCancel)
+}
+
+func (a *WeixinAdapter) ProcessingFailed(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, info channel.ProcessingStatusInfo, handle channel.ProcessingStatusHandle, _ error) error {
+ return a.ProcessingCompleted(ctx, cfg, msg, info, handle)
+}
+
+// weixinBlockStream buffers streaming deltas and sends the final message as one Send call.
+type weixinBlockStream struct {
+ adapter *WeixinAdapter
+ cfg channel.ChannelConfig
+ target string
+ textBuilder strings.Builder
+ attachments []channel.Attachment
+ final *channel.Message
+ closed bool
+}
+
+func (s *weixinBlockStream) Push(_ context.Context, event channel.StreamEvent) error {
+ if s.closed {
+ return nil
+ }
+ switch event.Type {
+ case channel.StreamEventDelta:
+ if strings.TrimSpace(event.Delta) != "" && event.Phase != channel.StreamPhaseReasoning {
+ s.textBuilder.WriteString(event.Delta)
+ }
+ case channel.StreamEventAttachment:
+ s.attachments = append(s.attachments, event.Attachments...)
+ case channel.StreamEventFinal:
+ if event.Final != nil {
+ msg := event.Final.Message
+ s.final = &msg
+ }
+ }
+ return nil
+}
+
+func (s *weixinBlockStream) Close(ctx context.Context) error {
+ if s.closed {
+ return nil
+ }
+ s.closed = true
+
+ msg := channel.Message{Format: channel.MessageFormatPlain}
+ if s.final != nil {
+ msg = *s.final
+ }
+ if strings.TrimSpace(msg.Text) == "" {
+ msg.Text = strings.TrimSpace(s.textBuilder.String())
+ }
+ if len(msg.Attachments) == 0 && len(s.attachments) > 0 {
+ msg.Attachments = append(msg.Attachments, s.attachments...)
+ }
+ if msg.IsEmpty() {
+ return nil
+ }
+ return s.adapter.Send(ctx, s.cfg, channel.OutboundMessage{
+ Target: s.target,
+ Message: msg,
+ })
+}
+
+// sleepCtx sleeps for the given duration or until the context is cancelled.
+func sleepCtx(ctx context.Context, d time.Duration) {
+ t := time.NewTimer(d)
+ defer t.Stop()
+ select {
+ case <-ctx.Done():
+ case <-t.C:
+ }
+}
diff --git a/internal/channel/adapters/weixin/weixin_test.go b/internal/channel/adapters/weixin/weixin_test.go
new file mode 100644
index 00000000..d7ca3b72
--- /dev/null
+++ b/internal/channel/adapters/weixin/weixin_test.go
@@ -0,0 +1,69 @@
+package weixin
+
+import (
+ "testing"
+
+ "github.com/memohai/memoh/internal/channel"
+)
+
+func TestWeixinAdapter_Type(t *testing.T) {
+ adapter := NewWeixinAdapter(nil)
+ if adapter.Type() != Type {
+ t.Errorf("Type() = %v, want %v", adapter.Type(), Type)
+ }
+}
+
+func TestWeixinAdapter_Descriptor(t *testing.T) {
+ adapter := NewWeixinAdapter(nil)
+ desc := adapter.Descriptor()
+
+ if desc.Type != Type {
+ t.Errorf("desc.Type = %v", desc.Type)
+ }
+ if desc.DisplayName != "WeChat" {
+ t.Errorf("desc.DisplayName = %q", desc.DisplayName)
+ }
+ if !desc.Capabilities.Text {
+ t.Error("should support text")
+ }
+ if !desc.Capabilities.Media {
+ t.Error("should support media")
+ }
+ if !desc.Capabilities.Attachments {
+ t.Error("should support attachments")
+ }
+ if len(desc.Capabilities.ChatTypes) != 1 || desc.Capabilities.ChatTypes[0] != channel.ConversationTypePrivate {
+ t.Errorf("chat types = %v", desc.Capabilities.ChatTypes)
+ }
+
+ if _, ok := desc.ConfigSchema.Fields["token"]; !ok {
+ t.Error("config schema should have 'token' field")
+ }
+ if desc.ConfigSchema.Fields["token"].Type != channel.FieldSecret {
+ t.Error("token field should be secret")
+ }
+ if !desc.ConfigSchema.Fields["token"].Required {
+ t.Error("token field should be required")
+ }
+}
+
+func TestWeixinAdapter_Interfaces(_ *testing.T) {
+ adapter := NewWeixinAdapter(nil)
+
+ // Adapter
+ var _ channel.Adapter = adapter
+ // ConfigNormalizer
+ var _ channel.ConfigNormalizer = adapter
+ // TargetResolver
+ var _ channel.TargetResolver = adapter
+ // BindingMatcher
+ var _ channel.BindingMatcher = adapter
+ // Receiver
+ var _ channel.Receiver = adapter
+ // Sender
+ var _ channel.Sender = adapter
+ // AttachmentResolver
+ var _ channel.AttachmentResolver = adapter
+ // ProcessingStatusNotifier
+ var _ channel.ProcessingStatusNotifier = adapter
+}
diff --git a/internal/channel/identities/service.go b/internal/channel/identities/service.go
index c34e54ce..597afa3e 100644
--- a/internal/channel/identities/service.go
+++ b/internal/channel/identities/service.go
@@ -201,7 +201,7 @@ func (s *Service) Search(ctx context.Context, query string, limit int) ([]Search
}
rows, err := s.queries.SearchChannelIdentities(ctx, sqlc.SearchChannelIdentitiesParams{
Query: strings.TrimSpace(query),
- LimitCount: int32(limit),
+ LimitCount: int32(limit), //nolint:gosec // limit is capped above
})
if err != nil {
return nil, err
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f78c7765..f5897de7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -157,6 +157,9 @@ importers:
pinia-plugin-persistedstate:
specifier: ^4.7.1
version: 4.7.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)))
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
shiki:
specifier: ^3.21.0
version: 3.23.0
@@ -200,6 +203,9 @@ importers:
'@types/node':
specifier: ^24.10.1
version: 24.10.4
+ '@types/qrcode':
+ specifier: ^1.5.6
+ version: 1.5.6
'@vitejs/plugin-vue':
specifier: ^6.0.5
version: 6.0.5(vite@8.0.1(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
@@ -220,7 +226,7 @@ importers:
devDependencies:
vitepress:
specifier: ^1.6.0
- version: 1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(search-insights@2.17.3)(typescript@5.9.3)
+ version: 1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@5.9.3)
vue:
specifier: ^3.5.0
version: 3.5.26(typescript@5.9.3)
@@ -251,13 +257,13 @@ importers:
devDependencies:
'@types/bun':
specifier: latest
- version: 1.3.9
+ version: 1.3.11
'@types/node':
specifier: ^22.10.5
version: 22.19.5
bun-types:
specifier: latest
- version: 1.3.9
+ version: 1.3.11
tsup:
specifier: ^8.4.0
version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@22.19.5))(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
@@ -1677,42 +1683,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.10':
resolution: {integrity: sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10':
resolution: {integrity: sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10':
resolution: {integrity: sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.10':
resolution: {integrity: sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.10':
resolution: {integrity: sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.10':
resolution: {integrity: sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==}
@@ -1786,67 +1786,56 @@ packages:
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.54.0':
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.54.0':
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.54.0':
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.54.0':
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.54.0':
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.54.0':
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.54.0':
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-openharmony-arm64@4.54.0':
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
@@ -2011,28 +2000,24 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
@@ -2104,8 +2089,8 @@ packages:
'@types/bun@1.3.10':
resolution: {integrity: sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ==}
- '@types/bun@1.3.9':
- resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==}
+ '@types/bun@1.3.11':
+ resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -2239,6 +2224,9 @@ packages:
'@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
+ '@types/qrcode@1.5.6':
+ resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -2662,8 +2650,8 @@ packages:
bun-types@1.3.10:
resolution: {integrity: sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg==}
- bun-types@1.3.9:
- resolution: {integrity: sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==}
+ bun-types@1.3.11:
+ resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
@@ -2695,6 +2683,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
caniuse-lite@1.0.30001762:
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
@@ -2759,6 +2751,9 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -3026,6 +3021,10 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -3072,6 +3071,9 @@ packages:
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
engines: {node: '>=0.3.1'}
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
dompurify@3.3.2:
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
engines: {node: '>=20'}
@@ -3295,6 +3297,10 @@ packages:
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
engines: {node: '>=20'}
+ find-up@4.1.0:
+ resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+ engines: {node: '>=8'}
+
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -3346,6 +3352,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
+ get-caller-file@2.0.5:
+ resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+ engines: {node: 6.* || 8.* || >= 10.*}
+
get-east-asian-width@1.4.0:
resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==}
engines: {node: '>=18'}
@@ -3656,28 +3666,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -3713,6 +3719,10 @@ packages:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
+ locate-path@5.0.0:
+ resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+ engines: {node: '>=8'}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -3981,14 +3991,26 @@ packages:
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
engines: {node: '>=18'}
+ p-limit@2.3.0:
+ resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+ engines: {node: '>=6'}
+
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
+ p-locate@4.1.0:
+ resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+ engines: {node: '>=8'}
+
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ p-try@2.2.0:
+ resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+ engines: {node: '>=6'}
+
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
@@ -4080,6 +4102,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -4141,6 +4167,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -4173,10 +4204,17 @@ packages:
peerDependencies:
vue: '>= 3.2.0'
+ require-directory@2.1.1:
+ resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+ engines: {node: '>=0.10.0'}
+
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -4254,6 +4292,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -4936,6 +4977,9 @@ packages:
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
engines: {node: '>=20'}
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -4990,6 +5034,9 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -5001,6 +5048,14 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -6588,9 +6643,9 @@ snapshots:
dependencies:
bun-types: 1.3.10
- '@types/bun@1.3.9':
+ '@types/bun@1.3.11':
dependencies:
- bun-types: 1.3.9
+ bun-types: 1.3.11
'@types/chai@5.2.3':
dependencies:
@@ -6751,6 +6806,10 @@ snapshots:
dependencies:
undici-types: 7.16.0
+ '@types/qrcode@1.5.6':
+ dependencies:
+ '@types/node': 24.10.4
+
'@types/trusted-types@2.0.7':
optional: true
@@ -7078,7 +7137,7 @@ snapshots:
'@vueuse/shared': 14.1.0(vue@3.5.26(typescript@5.9.3))
vue: 3.5.26(typescript@5.9.3)
- '@vueuse/integrations@12.8.2(axios@1.13.4)(focus-trap@7.8.0)(typescript@5.9.3)':
+ '@vueuse/integrations@12.8.2(axios@1.13.4)(focus-trap@7.8.0)(qrcode@1.5.4)(typescript@5.9.3)':
dependencies:
'@vueuse/core': 12.8.2(typescript@5.9.3)
'@vueuse/shared': 12.8.2(typescript@5.9.3)
@@ -7086,6 +7145,7 @@ snapshots:
optionalDependencies:
axios: 1.13.4
focus-trap: 7.8.0
+ qrcode: 1.5.4
transitivePeerDependencies:
- typescript
@@ -7264,7 +7324,7 @@ snapshots:
dependencies:
'@types/node': 24.10.4
- bun-types@1.3.9:
+ bun-types@1.3.11:
dependencies:
'@types/node': 24.10.4
@@ -7302,6 +7362,8 @@ snapshots:
callsites@3.1.0: {}
+ camelcase@5.3.1: {}
+
caniuse-lite@1.0.30001762: {}
ccount@2.0.1: {}
@@ -7361,6 +7423,12 @@ snapshots:
cli-width@4.1.0: {}
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
clsx@2.1.1: {}
color-convert@2.0.1:
@@ -7634,6 +7702,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
decimal.js@10.6.0:
optional: true
@@ -7669,6 +7739,8 @@ snapshots:
diff@8.0.2: {}
+ dijkstrajs@1.0.3: {}
+
dompurify@3.3.2:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -7992,6 +8064,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ find-up@4.1.0:
+ dependencies:
+ locate-path: 5.0.0
+ path-exists: 4.0.0
+
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -8042,6 +8119,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
+ get-caller-file@2.0.5: {}
+
get-east-asian-width@1.4.0: {}
get-intrinsic@1.3.0:
@@ -8386,6 +8465,10 @@ snapshots:
pkg-types: 2.3.0
quansync: 0.2.11
+ locate-path@5.0.0:
+ dependencies:
+ p-locate: 4.1.0
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -8672,14 +8755,24 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.1.2
+ p-limit@2.3.0:
+ dependencies:
+ p-try: 2.2.0
+
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
+ p-locate@4.1.0:
+ dependencies:
+ p-limit: 2.3.0
+
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
+ p-try@2.2.0: {}
+
package-manager-detector@1.6.0: {}
parent-module@1.0.1:
@@ -8748,6 +8841,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
+ pngjs@5.0.0: {}
+
points-on-curve@0.2.0: {}
points-on-path@0.2.1:
@@ -8796,6 +8891,12 @@ snapshots:
punycode@2.3.1: {}
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
quansync@0.2.11: {}
rc9@2.1.2:
@@ -8839,8 +8940,12 @@ snapshots:
- '@vue/composition-api'
- typescript
+ require-directory@2.1.1: {}
+
require-from-string@2.0.2: {}
+ require-main-filename@2.0.0: {}
+
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
@@ -8945,6 +9050,8 @@ snapshots:
semver@7.7.3: {}
+ set-blocking@2.0.0: {}
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -9430,7 +9537,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
- vitepress@1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(search-insights@2.17.3)(typescript@5.9.3):
+ vitepress@1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@5.9.3):
dependencies:
'@docsearch/css': 3.8.2
'@docsearch/js': 3.8.2(@algolia/client-search@5.46.2)(search-insights@2.17.3)
@@ -9443,7 +9550,7 @@ snapshots:
'@vue/devtools-api': 7.7.9
'@vue/shared': 3.5.26
'@vueuse/core': 12.8.2(typescript@5.9.3)
- '@vueuse/integrations': 12.8.2(axios@1.13.4)(focus-trap@7.8.0)(typescript@5.9.3)
+ '@vueuse/integrations': 12.8.2(axios@1.13.4)(focus-trap@7.8.0)(qrcode@1.5.4)(typescript@5.9.3)
focus-trap: 7.8.0
mark.js: 8.11.1
minisearch: 7.2.0
@@ -9608,6 +9715,8 @@ snapshots:
webidl-conversions: 8.0.1
optional: true
+ which-module@2.0.1: {}
+
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -9649,12 +9758,33 @@ snapshots:
xmlchars@2.2.0:
optional: true
+ y18n@4.0.3: {}
+
yallist@3.1.1: {}
yallist@4.0.0: {}
yaml@2.8.2: {}
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.3: {}
diff --git a/spec/docs.go b/spec/docs.go
index 1dd6a179..ef65c0ef 100644
--- a/spec/docs.go
+++ b/spec/docs.go
@@ -4949,6 +4949,111 @@ const docTemplate = `{
}
}
},
+ "/bots/{id}/channel/weixin/qr/poll": {
+ "post": {
+ "description": "Long-poll the QR code scan status. On confirmed, auto-saves credentials.",
+ "tags": [
+ "bots"
+ ],
+ "summary": "Poll WeChat QR login status",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bot ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "QR code to poll",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/weixin.QRPollRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/weixin.QRPollResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/bots/{id}/channel/weixin/qr/start": {
+ "post": {
+ "description": "Fetch a QR code from WeChat for scanning",
+ "tags": [
+ "bots"
+ ],
+ "summary": "Start WeChat QR login",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bot ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Optional base URL override",
+ "name": "payload",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/weixin.QRStartRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/weixin.QRStartResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
"/bots/{id}/channel/{platform}": {
"get": {
"description": "Get bot channel configuration",
@@ -12398,6 +12503,57 @@ const docTemplate = `{
"type": "string"
}
}
+ },
+ "weixin.QRPollRequest": {
+ "type": "object",
+ "properties": {
+ "baseUrl": {
+ "type": "string"
+ },
+ "qr_code": {
+ "type": "string"
+ },
+ "routeTag": {
+ "type": "string"
+ }
+ }
+ },
+ "weixin.QRPollResponse": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "status": {
+ "description": "wait, scaned, confirmed, expired",
+ "type": "string"
+ }
+ }
+ },
+ "weixin.QRStartRequest": {
+ "type": "object",
+ "properties": {
+ "baseUrl": {
+ "type": "string"
+ },
+ "routeTag": {
+ "type": "string"
+ }
+ }
+ },
+ "weixin.QRStartResponse": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "qr_code": {
+ "type": "string"
+ },
+ "qr_code_url": {
+ "type": "string"
+ }
+ }
}
}
}`
diff --git a/spec/swagger.json b/spec/swagger.json
index ce7fe1bc..00269095 100644
--- a/spec/swagger.json
+++ b/spec/swagger.json
@@ -4940,6 +4940,111 @@
}
}
},
+ "/bots/{id}/channel/weixin/qr/poll": {
+ "post": {
+ "description": "Long-poll the QR code scan status. On confirmed, auto-saves credentials.",
+ "tags": [
+ "bots"
+ ],
+ "summary": "Poll WeChat QR login status",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bot ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "QR code to poll",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/weixin.QRPollRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/weixin.QRPollResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/bots/{id}/channel/weixin/qr/start": {
+ "post": {
+ "description": "Fetch a QR code from WeChat for scanning",
+ "tags": [
+ "bots"
+ ],
+ "summary": "Start WeChat QR login",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bot ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Optional base URL override",
+ "name": "payload",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/weixin.QRStartRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/weixin.QRStartResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
"/bots/{id}/channel/{platform}": {
"get": {
"description": "Get bot channel configuration",
@@ -12389,6 +12494,57 @@
"type": "string"
}
}
+ },
+ "weixin.QRPollRequest": {
+ "type": "object",
+ "properties": {
+ "baseUrl": {
+ "type": "string"
+ },
+ "qr_code": {
+ "type": "string"
+ },
+ "routeTag": {
+ "type": "string"
+ }
+ }
+ },
+ "weixin.QRPollResponse": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "status": {
+ "description": "wait, scaned, confirmed, expired",
+ "type": "string"
+ }
+ }
+ },
+ "weixin.QRStartRequest": {
+ "type": "object",
+ "properties": {
+ "baseUrl": {
+ "type": "string"
+ },
+ "routeTag": {
+ "type": "string"
+ }
+ }
+ },
+ "weixin.QRStartResponse": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "qr_code": {
+ "type": "string"
+ },
+ "qr_code_url": {
+ "type": "string"
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/spec/swagger.yaml b/spec/swagger.yaml
index 89c9fe60..2585ea60 100644
--- a/spec/swagger.yaml
+++ b/spec/swagger.yaml
@@ -2531,6 +2531,39 @@ definitions:
name:
type: string
type: object
+ weixin.QRPollRequest:
+ properties:
+ baseUrl:
+ type: string
+ qr_code:
+ type: string
+ routeTag:
+ type: string
+ type: object
+ weixin.QRPollResponse:
+ properties:
+ message:
+ type: string
+ status:
+ description: wait, scaned, confirmed, expired
+ type: string
+ type: object
+ weixin.QRStartRequest:
+ properties:
+ baseUrl:
+ type: string
+ routeTag:
+ type: string
+ type: object
+ weixin.QRStartResponse:
+ properties:
+ message:
+ type: string
+ qr_code:
+ type: string
+ qr_code_url:
+ type: string
+ type: object
info:
contact: {}
title: Memoh API
@@ -6072,6 +6105,75 @@ paths:
summary: Update bot channel status
tags:
- bots
+ /bots/{id}/channel/weixin/qr/poll:
+ post:
+ description: Long-poll the QR code scan status. On confirmed, auto-saves credentials.
+ parameters:
+ - description: Bot ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: QR code to poll
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/weixin.QRPollRequest'
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/weixin.QRPollResponse'
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ summary: Poll WeChat QR login status
+ tags:
+ - bots
+ /bots/{id}/channel/weixin/qr/start:
+ post:
+ description: Fetch a QR code from WeChat for scanning
+ parameters:
+ - description: Bot ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Optional base URL override
+ in: body
+ name: payload
+ schema:
+ $ref: '#/definitions/weixin.QRStartRequest'
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/weixin.QRStartResponse'
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ summary: Start WeChat QR login
+ tags:
+ - bots
/bots/{id}/checks:
get:
description: Evaluate bot attached resource checks in runtime