feat(channel): add WeChat (weixin) adapter with QR code (#278)

* feat(channel): add WeChat (weixin) adapter with QR code

* fix(channel): fix weixin block streaming

* chore(channel): update weixin logo
This commit is contained in:
晨苒
2026-03-22 23:28:57 +08:00
committed by GitHub
parent 897cc32194
commit e2e3b69acf
31 changed files with 3712 additions and 49 deletions
+2
View File
@@ -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",
@@ -27,6 +27,7 @@ const channelIcons: Record<string, Component> = {
slack: Slack,
feishu: Feishu,
wechat: Wechat,
weixin: Wechat,
matrix: Matrix,
}
+13
View File
@@ -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"
}
+13
View File
@@ -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"
}
@@ -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 ?? ''"
>
<span class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ChannelIcon :channel="item.meta.type as string" size="1.25em" />
@@ -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 ?? '')"
>
<span class="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ChannelIcon :channel="item.meta.type" size="1em" />
@@ -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
}
</script>
@@ -86,7 +86,16 @@
</p>
</div>
<Separator />
<!-- WeChat QR Login -->
<div v-if="channelItem.meta.type === 'weixin'">
<WeixinQrLogin
:bot-id="botId"
@login-success="handleWeixinLoginSuccess"
/>
<Separator class="mt-4" />
</div>
<Separator v-else />
<!-- Credentials form (dynamic from config_schema) -->
<div class="space-y-4">
@@ -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 {
@@ -0,0 +1,292 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h4 class="text-sm font-medium">
{{ $t('bots.channels.weixinQr.title') }}
</h4>
<p class="text-xs text-muted-foreground mt-1">
{{ $t('bots.channels.weixinQr.description') }}
</p>
</div>
</div>
<!-- QR code display -->
<div
v-if="qrState === 'idle'"
class="flex flex-col items-center gap-3 py-4"
>
<Button
:disabled="isStarting"
@click="startLogin"
>
<Spinner
v-if="isStarting"
class="mr-1.5"
/>
<FontAwesomeIcon
v-else
:icon="['fas', 'qrcode']"
class="mr-1.5 size-3.5"
/>
{{ $t('bots.channels.weixinQr.startScan') }}
</Button>
</div>
<div
v-else-if="qrState === 'showing'"
class="flex flex-col items-center gap-4 py-4"
>
<div class="relative rounded-lg border bg-white p-3">
<img
v-if="qrImageDataUrl"
:src="qrImageDataUrl"
alt="WeChat QR Code"
class="size-52"
>
<div
v-else
class="size-52 flex items-center justify-center text-muted-foreground"
>
<Spinner />
</div>
<!-- Overlay for scanned state -->
<div
v-if="pollStatus === 'scaned'"
class="absolute inset-0 flex items-center justify-center rounded-lg bg-background/80"
>
<div class="text-center">
<FontAwesomeIcon
:icon="['fas', 'mobile-screen']"
class="size-8 text-primary mb-2"
/>
<p class="text-sm font-medium text-foreground">
{{ $t('bots.channels.weixinQr.scanned') }}
</p>
</div>
</div>
<!-- Overlay for expired state -->
<div
v-if="pollStatus === 'expired'"
class="absolute inset-0 flex flex-col items-center justify-center rounded-lg bg-background/80 gap-2"
>
<p class="text-sm text-muted-foreground">
{{ $t('bots.channels.weixinQr.expired') }}
</p>
<Button
size="sm"
variant="outline"
@click="startLogin"
>
{{ $t('bots.channels.weixinQr.refresh') }}
</Button>
</div>
</div>
<p class="text-sm text-muted-foreground text-center max-w-xs">
{{ statusText }}
</p>
<Button
variant="ghost"
size="sm"
@click="cancel"
>
{{ $t('common.cancel') }}
</Button>
</div>
<div
v-else-if="qrState === 'success'"
class="flex flex-col items-center gap-3 py-4"
>
<div class="flex size-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<FontAwesomeIcon
:icon="['fas', 'check']"
class="size-5 text-green-600 dark:text-green-400"
/>
</div>
<p class="text-sm font-medium">
{{ $t('bots.channels.weixinQr.success') }}
</p>
</div>
<div
v-else-if="qrState === 'error'"
class="flex flex-col items-center gap-3 py-4"
>
<p class="text-sm text-destructive">
{{ errorMessage }}
</p>
<Button
variant="outline"
size="sm"
@click="startLogin"
>
{{ $t('bots.channels.weixinQr.retry') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import { Button, Spinner } from '@memoh/ui'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { client } from '@memoh/sdk/client'
import QRCode from 'qrcode'
const props = defineProps<{
botId: string
}>()
const emit = defineEmits<{
loginSuccess: []
}>()
const { t } = useI18n()
type QRState = 'idle' | 'showing' | 'success' | 'error'
const qrState = ref<QRState>('idle')
const qrCode = ref('')
const qrImageDataUrl = ref('')
const pollStatus = ref('')
const isStarting = ref(false)
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
let aborted = false
const statusText = computed(() => {
switch (pollStatus.value) {
case 'wait':
return t('bots.channels.weixinQr.waitingScan')
case 'scaned':
return t('bots.channels.weixinQr.scanned')
case 'expired':
return t('bots.channels.weixinQr.expired')
default:
return t('bots.channels.weixinQr.waitingScan')
}
})
async function startLogin() {
aborted = false
isStarting.value = true
errorMessage.value = ''
pollStatus.value = ''
qrImageDataUrl.value = ''
try {
const baseUrl = client.getConfig().baseUrl || ''
const resp = await fetch(`${baseUrl}/bots/${encodeURIComponent(props.botId)}/channel/weixin/qr/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
},
body: JSON.stringify({}),
})
if (!resp.ok) {
const body = await resp.text()
throw new Error(body || `HTTP ${resp.status}`)
}
const data = await resp.json() as { qr_code_url: string; qr_code: string; message: string }
const qrContent = data.qr_code_url || data.qr_code || ''
if (!qrContent) {
throw new Error('No QR code data returned')
}
qrCode.value = data.qr_code || ''
qrImageDataUrl.value = await QRCode.toDataURL(qrContent, { width: 208, margin: 1 })
qrState.value = 'showing'
startPolling()
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : String(err)
qrState.value = 'error'
} finally {
isStarting.value = false
}
}
function startPolling() {
if (aborted) return
pollOnce()
}
async function pollOnce() {
if (aborted || qrState.value !== 'showing') return
try {
const baseUrl = client.getConfig().baseUrl || ''
const resp = await fetch(`${baseUrl}/bots/${encodeURIComponent(props.botId)}/channel/weixin/qr/poll`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
},
body: JSON.stringify({
qr_code: qrCode.value,
}),
})
if (!resp.ok) {
const body = await resp.text()
throw new Error(body || `HTTP ${resp.status}`)
}
const data = await resp.json() as { status: string; message: string }
pollStatus.value = data.status
switch (data.status) {
case 'confirmed':
qrState.value = 'success'
toast.success(t('bots.channels.weixinQr.success'))
emit('loginSuccess')
return
case 'expired':
return
case 'wait':
case 'scaned':
if (!aborted) {
pollTimer = setTimeout(pollOnce, 1500)
}
return
default:
if (!aborted) {
pollTimer = setTimeout(pollOnce, 2000)
}
}
} catch {
if (!aborted) {
pollTimer = setTimeout(pollOnce, 3000)
}
}
}
function cancel() {
aborted = true
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
qrState.value = 'idle'
qrCode.value = ''
qrImageDataUrl.value = ''
pollStatus.value = ''
}
onUnmounted(() => {
aborted = true
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
})
</script>
+34
View File
@@ -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<string, string> = {
feishu: '/channels/feishu.png',
matrix: '/channels/matrix.svg',
telegram: '/channels/telegram.webp',
}
const CHANNEL_ICONS: Record<string, [string, string]> = {
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
}
+5
View File
@@ -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
+5
View File
@@ -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
+1 -1
View File
@@ -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
+32
View File
@@ -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.
+267
View File
@@ -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
}
+160
View File
@@ -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
}
@@ -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"])
}
}
@@ -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)
}
}
}
@@ -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")
}
}
+188
View File
@@ -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
}
@@ -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")
}
}
+232
View File
@@ -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
}
@@ -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)
}
}
@@ -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)
}
@@ -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 }
+218
View File
@@ -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"`
}
+526
View File
@@ -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: "<user_id>",
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:
}
}
@@ -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
}
+1 -1
View File
@@ -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
+168 -38
View File
@@ -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: {}
+156
View File
@@ -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"
}
}
}
}
}`
+156
View File
@@ -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"
}
}
}
}
}
+102
View File
@@ -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