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 @@ + + + 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