diff --git a/adapters/common/attachment/attachment-types.ts b/adapters/common/attachment/attachment-types.ts
index 533b1a36..9574695b 100644
--- a/adapters/common/attachment/attachment-types.ts
+++ b/adapters/common/attachment/attachment-types.ts
@@ -6,7 +6,7 @@ import type { AttachmentRef } from '../ws-bridge.js'
export type { AttachmentRef }
/** Platform tag — used for local staging subdir and telemetry. */
-export type ImPlatform = 'feishu' | 'telegram' | 'wechat'
+export type ImPlatform = 'feishu' | 'telegram' | 'wechat' | 'dingtalk'
/** Result of downloading an IM resource into the local stage dir. */
export interface LocalAttachment {
diff --git a/adapters/dingtalk/__tests__/ai-card.test.ts b/adapters/dingtalk/__tests__/ai-card.test.ts
new file mode 100644
index 00000000..bc2658bb
--- /dev/null
+++ b/adapters/dingtalk/__tests__/ai-card.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
+import { buildDeliverBody, DingTalkAiCardService } from '../ai-card.js'
+
+describe('DingTalk AI Card streaming', () => {
+ const originalFetch = globalThis.fetch
+ const calls: Array<{ url: string; method: string; body: any }> = []
+
+ beforeEach(() => {
+ calls.length = 0
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
+ calls.push({
+ url: String(url),
+ method: init?.method ?? 'GET',
+ body: init?.body ? JSON.parse(String(init.body)) : null,
+ })
+ return new Response('{}', { status: 200 })
+ }) as any
+ })
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch
+ })
+
+ it('builds the official IM_ROBOT deliver payload', () => {
+ expect(buildDeliverBody('card-1', { type: 'user', userId: 'staff-1' }, 'robot-1')).toMatchObject({
+ outTrackId: 'card-1',
+ openSpaceId: 'dtv1.card//IM_ROBOT.staff-1',
+ imRobotOpenDeliverModel: {
+ spaceType: 'IM_ROBOT',
+ robotCode: 'robot-1',
+ },
+ })
+ })
+
+ it('creates, streams, and finishes an AI card', async () => {
+ const service = new DingTalkAiCardService(async () => 'token-1', 'robot-1')
+ const card = await service.createForTarget({ type: 'user', userId: 'staff-1' })
+
+ expect(card?.cardInstanceId.startsWith('card_')).toBe(true)
+ expect(calls.map((call) => `${call.method} ${new URL(call.url).pathname}`)).toEqual([
+ 'POST /v1.0/card/instances',
+ 'POST /v1.0/card/instances/deliver',
+ ])
+
+ await service.stream(card!, 'Hello', false)
+ expect(calls.at(-2)?.body.cardData.cardParamMap.flowStatus).toBe('2')
+ expect(new URL(calls.at(-1)!.url).pathname).toBe('/v1.0/card/streaming')
+ expect(calls.at(-1)?.body).toMatchObject({
+ key: 'msgContent',
+ content: 'Hello',
+ isFull: true,
+ isFinalize: false,
+ })
+
+ calls.length = 0
+ await service.finish(card!, 'Final')
+ expect(calls.map((call) => `${call.method} ${new URL(call.url).pathname}`)).toEqual([
+ 'PUT /v1.0/card/streaming',
+ 'PUT /v1.0/card/instances',
+ ])
+ expect(calls[0]!.body.isFinalize).toBe(true)
+ expect(calls[1]!.body.cardData.cardParamMap.flowStatus).toBe('3')
+ })
+})
diff --git a/adapters/dingtalk/__tests__/helpers.test.ts b/adapters/dingtalk/__tests__/helpers.test.ts
index 192731d5..b072da1b 100644
--- a/adapters/dingtalk/__tests__/helpers.test.ts
+++ b/adapters/dingtalk/__tests__/helpers.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test'
import {
+ extractDingTalkAttachments,
extractDingTalkText,
getDingTalkChatId,
getDingTalkSenderId,
@@ -37,4 +38,21 @@ describe('DingTalk helpers', () => {
},
})).toBe('hello world')
})
+
+ it('extracts image and file attachment candidates', () => {
+ expect(extractDingTalkAttachments({
+ msgtype: 'picture',
+ content: { pictureUrl: 'https://example.com/a.jpg', downloadCode: 'pic-code' },
+ })).toEqual([{ kind: 'image', url: 'https://example.com/a.jpg', downloadCode: 'pic-code' }])
+
+ expect(extractDingTalkAttachments({
+ msgtype: 'file',
+ content: '{"fileName":"report.pdf","downloadCode":"file-code"}',
+ })).toEqual([{ kind: 'file', downloadCode: 'file-code', fileName: 'report.pdf' }])
+
+ expect(extractDingTalkAttachments({
+ msgtype: 'richText',
+ content: { richText: [{ text: 'hi' }, { type: 'picture', downloadCode: 'rich-pic' }] },
+ })).toEqual([{ kind: 'image', url: undefined, downloadCode: 'rich-pic' }])
+ })
})
diff --git a/adapters/dingtalk/ai-card.ts b/adapters/dingtalk/ai-card.ts
new file mode 100644
index 00000000..b3982835
--- /dev/null
+++ b/adapters/dingtalk/ai-card.ts
@@ -0,0 +1,270 @@
+const DINGTALK_API = 'https://api.dingtalk.com'
+const AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema'
+const CARD_API_MAX_QPS = 20
+const QPS_BACKOFF_DURATION_MS = 2_000
+
+const AICardStatus = {
+ INPUTING: '2',
+ FINISHED: '3',
+} as const
+type AICardFlowStatus = (typeof AICardStatus)[keyof typeof AICardStatus]
+
+export type DingTalkAiCardTarget =
+ | { type: 'user'; userId: string }
+ | { type: 'group'; openConversationId: string }
+
+export type DingTalkAiCardInstance = {
+ cardInstanceId: string
+ accessToken: string
+ tokenExpireTime: number
+ inputingStarted: boolean
+}
+
+type TokenProvider = () => Promise
+
+export class DingTalkAiCardService {
+ constructor(
+ private readonly getAccessToken: TokenProvider,
+ private readonly robotCode: string,
+ ) {}
+
+ async createForTarget(target: DingTalkAiCardTarget): Promise {
+ try {
+ const token = await this.getAccessToken()
+ const cardInstanceId = `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
+ await postJson('/v1.0/card/instances', token, {
+ cardTemplateId: AI_CARD_TEMPLATE_ID,
+ outTrackId: cardInstanceId,
+ cardData: {
+ cardParamMap: {
+ config: JSON.stringify({ autoLayout: true }),
+ },
+ },
+ callbackType: 'STREAM',
+ imGroupOpenSpaceModel: { supportForward: true },
+ imRobotOpenSpaceModel: { supportForward: true },
+ })
+
+ await postJson('/v1.0/card/instances/deliver', token, buildDeliverBody(cardInstanceId, target, this.robotCode))
+
+ return {
+ cardInstanceId,
+ accessToken: token,
+ tokenExpireTime: Date.now() + 2 * 60 * 60 * 1000,
+ inputingStarted: false,
+ }
+ } catch (err) {
+ console.warn('[DingTalk][AICard] create failed:', err instanceof Error ? err.message : err)
+ return null
+ }
+ }
+
+ async stream(card: DingTalkAiCardInstance, content: string, finished = false): Promise {
+ await this.ensureValidToken(card)
+
+ if (!card.inputingStarted) {
+ await this.updateStatus(card, AICardStatus.INPUTING, content)
+ card.inputingStarted = true
+ }
+
+ await withCardRateLimit(() =>
+ putJson('/v1.0/card/streaming', card.accessToken, {
+ outTrackId: card.cardInstanceId,
+ guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+ key: 'msgContent',
+ content: ensureTableBlankLines(content),
+ isFull: true,
+ isFinalize: finished,
+ isError: false,
+ }),
+ )
+ }
+
+ async finish(card: DingTalkAiCardInstance, content: string): Promise {
+ await this.stream(card, content, true)
+ try {
+ await this.updateStatus(card, AICardStatus.FINISHED, ensureTableBlankLines(content))
+ } catch (err) {
+ console.warn('[DingTalk][AICard] finish status failed:', err instanceof Error ? err.message : err)
+ }
+ }
+
+ private async updateStatus(
+ card: DingTalkAiCardInstance,
+ flowStatus: AICardFlowStatus,
+ content: string,
+ ): Promise {
+ const body: Record = {
+ outTrackId: card.cardInstanceId,
+ cardData: {
+ cardParamMap: {
+ flowStatus,
+ msgContent: ensureTableBlankLines(content),
+ staticMsgContent: '',
+ sys_full_json_obj: JSON.stringify({ order: ['msgContent'] }),
+ config: JSON.stringify({ autoLayout: true }),
+ },
+ },
+ }
+ if (flowStatus === AICardStatus.FINISHED) {
+ body.cardUpdateOptions = { updateCardDataByKey: true }
+ }
+
+ await withCardRateLimit(() =>
+ putJson('/v1.0/card/instances', card.accessToken, body),
+ )
+ }
+
+ private async ensureValidToken(card: DingTalkAiCardInstance): Promise {
+ if (Date.now() <= card.tokenExpireTime - 5 * 60 * 1000) return
+ card.accessToken = await this.getAccessToken()
+ card.tokenExpireTime = Date.now() + 2 * 60 * 60 * 1000
+ }
+}
+
+export function buildDeliverBody(
+ cardInstanceId: string,
+ target: DingTalkAiCardTarget,
+ robotCode: string,
+): Record {
+ const base = { outTrackId: cardInstanceId, userIdType: 1 }
+ if (target.type === 'group') {
+ return {
+ ...base,
+ openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`,
+ imGroupOpenDeliverModel: {
+ robotCode,
+ },
+ }
+ }
+
+ return {
+ ...base,
+ openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`,
+ imRobotOpenDeliverModel: {
+ spaceType: 'IM_ROBOT',
+ robotCode,
+ extension: {
+ dynamicSummary: 'true',
+ },
+ },
+ }
+}
+
+async function postJson(path: string, token: string, body: Record): Promise {
+ await requestJson('POST', path, token, body)
+}
+
+async function putJson(path: string, token: string, body: Record): Promise {
+ await requestJson('PUT', path, token, body)
+}
+
+async function requestJson(
+ method: 'POST' | 'PUT',
+ path: string,
+ token: string,
+ body: Record,
+): Promise {
+ const res = await fetch(`${DINGTALK_API}${path}`, {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-acs-dingtalk-access-token': token,
+ },
+ body: JSON.stringify(body),
+ })
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ const err = new Error(`${method} ${path} failed: ${res.status} ${text}`)
+ ;(err as any).status = res.status
+ ;(err as any).body = text
+ throw err
+ }
+}
+
+async function withCardRateLimit(fn: () => Promise): Promise {
+ await cardRateLimiter.waitForToken()
+ try {
+ await fn()
+ } catch (err) {
+ if (!isQpsLimitError(err)) throw err
+ cardRateLimiter.triggerBackoff()
+ await cardRateLimiter.waitForToken()
+ await fn()
+ }
+}
+
+function isQpsLimitError(err: unknown): boolean {
+ return (err as any)?.status === 403 && String((err as any)?.body ?? '').includes('QpsLimit')
+}
+
+const cardRateLimiter = {
+ tokens: CARD_API_MAX_QPS,
+ lastRefillTime: Date.now(),
+ backoffUntil: 0,
+ queueTail: Promise.resolve() as Promise,
+
+ refill(): void {
+ const now = Date.now()
+ const elapsedSeconds = (now - this.lastRefillTime) / 1000
+ if (elapsedSeconds <= 0) return
+ this.tokens = Math.min(CARD_API_MAX_QPS, this.tokens + elapsedSeconds * CARD_API_MAX_QPS)
+ this.lastRefillTime = now
+ },
+
+ async waitForToken(): Promise {
+ const prev = this.queueTail
+ let release!: () => void
+ this.queueTail = new Promise((resolve) => {
+ release = resolve
+ })
+ try {
+ await prev.catch(() => {})
+ const now = Date.now()
+ if (now < this.backoffUntil) await sleep(this.backoffUntil - now)
+ this.refill()
+ if (this.tokens < 1) {
+ await sleep(Math.ceil(((1 - this.tokens) / CARD_API_MAX_QPS) * 1000))
+ this.refill()
+ }
+ this.tokens -= 1
+ } finally {
+ release()
+ }
+ },
+
+ triggerBackoff(): void {
+ const backoffEnd = Date.now() + QPS_BACKOFF_DURATION_MS
+ this.backoffUntil = backoffEnd
+ this.tokens = 0
+ this.lastRefillTime = backoffEnd
+ },
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+function ensureTableBlankLines(text: string): string {
+ const lines = text.split('\n')
+ const result: string[] = []
+ const tableDividerRegex = /^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$/
+ const tableRowRegex = /^\s*\|?.*\|.*\|?\s*$/
+
+ for (let i = 0; i < lines.length; i++) {
+ const currentLine = lines[i] ?? ''
+ const nextLine = lines[i + 1] ?? ''
+ if (
+ tableRowRegex.test(currentLine) &&
+ nextLine.includes('|') &&
+ tableDividerRegex.test(nextLine) &&
+ i > 0 &&
+ lines[i - 1]?.trim() !== '' &&
+ !tableRowRegex.test(lines[i - 1] ?? '')
+ ) {
+ result.push('')
+ }
+ result.push(currentLine)
+ }
+ return result.join('\n')
+}
diff --git a/adapters/dingtalk/helpers.ts b/adapters/dingtalk/helpers.ts
index 33d72945..a88592ed 100644
--- a/adapters/dingtalk/helpers.ts
+++ b/adapters/dingtalk/helpers.ts
@@ -13,6 +13,13 @@ export type DingTalkRobotMessage = {
content?: unknown
}
+export type DingTalkAttachmentCandidate = {
+ kind: 'image' | 'file'
+ url?: string
+ downloadCode?: string
+ fileName?: string
+}
+
export function parseDingTalkPayload(raw: unknown): DingTalkRobotMessage | null {
if (!raw) return null
if (typeof raw === 'object') return raw as DingTalkRobotMessage
@@ -61,6 +68,38 @@ export function extractDingTalkText(data: DingTalkRobotMessage): string {
return ''
}
+export function extractDingTalkAttachments(data: DingTalkRobotMessage): DingTalkAttachmentCandidate[] {
+ const content = resolveContentObject(data.content)
+ const candidates: DingTalkAttachmentCandidate[] = []
+
+ if (data.msgtype === 'picture') {
+ const url = stringValue(content?.pictureUrl)
+ const downloadCode = stringValue(content?.downloadCode)
+ if (url || downloadCode) candidates.push({ kind: 'image', url, downloadCode })
+ } else if (data.msgtype === 'file') {
+ const downloadCode = stringValue(content?.downloadCode)
+ const fileName = stringValue(content?.fileName) || 'dingtalk-file'
+ if (downloadCode) candidates.push({ kind: 'file', downloadCode, fileName })
+ } else if (data.msgtype === 'richText') {
+ const richText = Array.isArray(content?.richText) ? content.richText : []
+ for (const item of richText) {
+ if (!item || typeof item !== 'object') continue
+ const record = item as Record
+ const pictureUrl = stringValue(record.pictureUrl)
+ const downloadCode = stringValue(record.downloadCode)
+ if (pictureUrl || downloadCode) {
+ candidates.push({ kind: 'image', url: pictureUrl, downloadCode })
+ }
+ }
+ }
+
+ return candidates
+}
+
+function stringValue(value: unknown): string | undefined {
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
+}
+
function resolveContentObject(raw: unknown): Record | null {
if (!raw) return null
if (typeof raw === 'object') return raw as Record
diff --git a/adapters/dingtalk/index.ts b/adapters/dingtalk/index.ts
index c3bb62e2..ef11f768 100644
--- a/adapters/dingtalk/index.ts
+++ b/adapters/dingtalk/index.ts
@@ -7,7 +7,7 @@
import path from 'node:path'
import { DWClient, TOPIC_ROBOT } from 'dingtalk-stream'
-import { WsBridge, type ServerMessage } from '../common/ws-bridge.js'
+import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js'
import { MessageDedup } from '../common/message-dedup.js'
import { MessageBuffer } from '../common/message-buffer.js'
import { enqueue } from '../common/chat-queue.js'
@@ -16,7 +16,10 @@ import { formatImHelp, formatImStatus, formatPermissionRequest, splitMessage } f
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient, type RecentProject } from '../common/http-client.js'
import { isAllowedUser, tryPair } from '../common/pairing.js'
+import { AttachmentStore } from '../common/attachment/attachment-store.js'
+import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
import {
+ extractDingTalkAttachments,
extractDingTalkText,
getDingTalkChatId,
getDingTalkSenderId,
@@ -24,6 +27,12 @@ import {
parseDingTalkPayload,
type DingTalkRobotMessage,
} from './helpers.js'
+import { DingTalkMediaService } from './media.js'
+import {
+ DingTalkAiCardService,
+ type DingTalkAiCardInstance,
+ type DingTalkAiCardTarget,
+} from './ai-card.js'
const DINGTALK_API = 'https://api.dingtalk.com'
@@ -38,14 +47,24 @@ const bridge = new WsBridge(config.serverUrl, 'dingtalk')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
+const attachmentStore = new AttachmentStore()
+const media = new DingTalkMediaService(attachmentStore)
+const aiCards = new DingTalkAiCardService(getAccessToken, config.dingtalk.clientId)
const sessionWebhooks = new Map()
const pendingProjectSelection = new Map()
const runtimeStates = new Map()
-const responseBuffers = new Map()
+const aiCardBuffers = new Map()
+const aiCardTargets = new Map()
+const streamingCards = new Map>()
+const streamingCardText = new Map()
const pendingPermissions = new Map>()
let accessTokenCache: { token: string; expiresAt: number } | null = null
+attachmentStore.gc().catch((err) => {
+ console.warn('[DingTalk] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
+})
+
type ChatRuntimeState = {
state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending'
verb?: string
@@ -118,22 +137,65 @@ async function sendText(chatId: string, text: string): Promise {
}
}
-function getResponseBuffer(chatId: string): MessageBuffer {
- let buffer = responseBuffers.get(chatId)
+function getAiCardBuffer(chatId: string): MessageBuffer {
+ let buffer = aiCardBuffers.get(chatId)
if (!buffer) {
buffer = new MessageBuffer(
- async (text) => sendText(chatId, text),
- 900,
+ async (text, isComplete) => flushToAiCard(chatId, text, isComplete),
1200,
+ 200,
)
- responseBuffers.set(chatId, buffer)
+ aiCardBuffers.set(chatId, buffer)
}
return buffer
}
+function getOrCreateAiCard(chatId: string): Promise | null {
+ const target = aiCardTargets.get(chatId)
+ if (!target) return null
+
+ let card = streamingCards.get(chatId)
+ if (!card) {
+ card = aiCards.createForTarget(target)
+ streamingCards.set(chatId, card)
+ }
+ return card
+}
+
+async function flushToAiCard(chatId: string, newText: string, isComplete: boolean): Promise {
+ const fullText = (streamingCardText.get(chatId) ?? '') + newText
+ streamingCardText.set(chatId, fullText)
+ if (!fullText.trim()) return
+
+ const cardPromise = getOrCreateAiCard(chatId)
+ const card = cardPromise ? await cardPromise : null
+ if (!card) {
+ if (isComplete) await sendText(chatId, fullText)
+ return
+ }
+
+ try {
+ if (isComplete) {
+ await aiCards.finish(card, fullText)
+ streamingCards.delete(chatId)
+ streamingCardText.delete(chatId)
+ aiCardBuffers.get(chatId)?.reset()
+ aiCardBuffers.delete(chatId)
+ } else {
+ await aiCards.stream(card, `${fullText} ▍`, false)
+ }
+ } catch (err) {
+ console.warn('[DingTalk][AICard] stream failed, falling back to markdown:', err instanceof Error ? err.message : err)
+ streamingCards.delete(chatId)
+ if (isComplete) await sendText(chatId, fullText)
+ }
+}
+
function clearTransientChatState(chatId: string): void {
- responseBuffers.get(chatId)?.reset()
- responseBuffers.delete(chatId)
+ aiCardBuffers.get(chatId)?.reset()
+ aiCardBuffers.delete(chatId)
+ streamingCards.delete(chatId)
+ streamingCardText.delete(chatId)
const runtime = getRuntimeState(chatId)
runtime.state = 'idle'
runtime.verb = undefined
@@ -221,8 +283,7 @@ async function ensureSession(chatId: string): Promise {
async function createSessionForChat(chatId: string, workDir: string): Promise {
try {
bridge.resetSession(chatId)
- responseBuffers.get(chatId)?.reset()
- responseBuffers.delete(chatId)
+ clearTransientChatState(chatId)
const sessionId = await httpClient.createSession(workDir)
sessionStore.set(chatId, sessionId, workDir)
@@ -304,11 +365,14 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined
break
case 'content_start':
- if (msg.blockType === 'text') runtime.state = 'streaming'
+ if (msg.blockType === 'text') {
+ runtime.state = 'streaming'
+ void getOrCreateAiCard(chatId)
+ }
if (msg.blockType === 'tool_use') runtime.state = 'tool_executing'
break
case 'content_delta':
- if (typeof msg.text === 'string' && msg.text) getResponseBuffer(chatId).append(msg.text)
+ if (typeof msg.text === 'string' && msg.text) getAiCardBuffer(chatId).append(msg.text)
break
case 'tool_use_complete':
runtime.state = 'streaming'
@@ -325,12 +389,14 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
case 'message_complete':
runtime.state = 'idle'
runtime.verb = undefined
- await responseBuffers.get(chatId)?.complete()
+ await aiCardBuffers.get(chatId)?.complete()
break
case 'error':
runtime.state = 'idle'
runtime.verb = undefined
- responseBuffers.get(chatId)?.reset()
+ aiCardBuffers.get(chatId)?.reset()
+ streamingCards.delete(chatId)
+ streamingCardText.delete(chatId)
await sendText(chatId, `❌ ${msg.message}`)
break
case 'system_notification':
@@ -362,31 +428,32 @@ function handlePermissionCommand(chatId: string, text: string): boolean {
return true
}
-async function routeUserMessage(chatId: string, text: string): Promise {
+async function routeUserMessage(chatId: string, text: string, attachments: AttachmentRef[] = []): Promise {
enqueue(chatId, async () => {
const trimmed = text.trim()
+ const hasAttachments = attachments.length > 0
- if (handlePermissionCommand(chatId, trimmed)) return
+ if (!hasAttachments && handlePermissionCommand(chatId, trimmed)) return
- if (pendingProjectSelection.has(chatId)) {
+ if (!hasAttachments && pendingProjectSelection.has(chatId)) {
if (trimmed) await startNewSession(chatId, trimmed)
return
}
- if (trimmed === '/new' || trimmed === '新会话' || trimmed.startsWith('/new ')) {
+ if (!hasAttachments && (trimmed === '/new' || trimmed === '新会话' || trimmed.startsWith('/new '))) {
const arg = trimmed.startsWith('/new ') ? trimmed.slice(5).trim() : ''
await startNewSession(chatId, arg || undefined)
return
}
- if (trimmed === '/help' || trimmed === '帮助') {
+ if (!hasAttachments && (trimmed === '/help' || trimmed === '帮助')) {
await sendText(chatId, formatImHelp())
return
}
- if (trimmed === '/status' || trimmed === '状态') {
+ if (!hasAttachments && (trimmed === '/status' || trimmed === '状态')) {
await sendText(chatId, await buildStatusText(chatId))
return
}
- if (trimmed === '/clear' || trimmed === '清空') {
+ if (!hasAttachments && (trimmed === '/clear' || trimmed === '清空')) {
const stored = await ensureExistingSession(chatId)
if (!stored) {
await sendText(chatId, formatImStatus(null))
@@ -400,7 +467,7 @@ async function routeUserMessage(chatId: string, text: string): Promise {
await sendText(chatId, '🧹 已清空当前会话上下文。')
return
}
- if (trimmed === '/stop' || trimmed === '停止') {
+ if (!hasAttachments && (trimmed === '/stop' || trimmed === '停止')) {
const stored = await ensureExistingSession(chatId)
if (!stored) {
await sendText(chatId, formatImStatus(null))
@@ -410,14 +477,16 @@ async function routeUserMessage(chatId: string, text: string): Promise {
await sendText(chatId, '⏹ 已发送停止信号。')
return
}
- if (trimmed === '/projects' || trimmed === '项目列表') {
+ if (!hasAttachments && (trimmed === '/projects' || trimmed === '项目列表')) {
await showProjectPicker(chatId)
return
}
const ready = await ensureSession(chatId)
- if (!ready || !trimmed) return
- if (!bridge.sendUserMessage(chatId, trimmed)) {
+ if (!ready) return
+ const effectiveText = trimmed || (attachments.length > 0 ? '(用户发送了附件)' : '')
+ if (!effectiveText && attachments.length === 0) return
+ if (!bridge.sendUserMessage(chatId, effectiveText, attachments.length ? attachments : undefined)) {
await sendText(chatId, '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。')
}
})
@@ -429,7 +498,8 @@ async function handleRobotMessage(data: DingTalkRobotMessage): Promise {
const chatId = getDingTalkChatId(data)
const userId = getDingTalkSenderId(data)
const text = extractDingTalkText(data)
- if (!chatId || !userId || !text) return
+ const mediaCandidates = extractDingTalkAttachments(data)
+ if (!chatId || !userId || (!text && mediaCandidates.length === 0)) return
if (data.sessionWebhook) sessionWebhooks.set(chatId, data.sessionWebhook)
@@ -444,7 +514,72 @@ async function handleRobotMessage(data: DingTalkRobotMessage): Promise {
return
}
- await routeUserMessage(chatId, text)
+ aiCardTargets.set(chatId, { type: 'user', userId })
+ const attachments = await collectAttachments(chatId, mediaCandidates)
+ await routeUserMessage(chatId, text, attachments)
+}
+
+async function collectAttachments(
+ chatId: string,
+ candidates: ReturnType,
+): Promise {
+ if (candidates.length === 0) return []
+ const stored = sessionStore.get(chatId)
+ const sessionId = stored?.sessionId ?? chatId
+ let token: string
+ try {
+ token = await getAccessToken()
+ } catch (err) {
+ console.error('[DingTalk] access token for attachment download failed:', err)
+ await sendText(chatId, '📎 附件下载授权失败,请稍后重试。')
+ return []
+ }
+
+ const settled = await Promise.allSettled(
+ candidates.map((candidate) =>
+ media.downloadCandidate(candidate, sessionId, {
+ clientId: config.dingtalk.clientId,
+ accessToken: token,
+ }),
+ ),
+ )
+ const attachments: AttachmentRef[] = []
+ let failures = 0
+ for (const result of settled) {
+ if (result.status === 'rejected') {
+ failures += 1
+ console.error('[DingTalk] media download failed:', result.reason)
+ continue
+ }
+ const local = result.value
+ const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
+ if (!check.ok) {
+ await sendText(chatId, check.hint)
+ continue
+ }
+ if (local.kind === 'image') {
+ attachments.push({
+ type: 'image',
+ name: local.name,
+ data: local.buffer.toString('base64'),
+ mimeType: local.mimeType,
+ })
+ } else {
+ attachments.push({
+ type: 'file',
+ name: local.name,
+ path: local.path,
+ mimeType: local.mimeType,
+ })
+ }
+ }
+ if (failures > 0) {
+ await sendText(
+ chatId,
+ failures === candidates.length ? '📎 附件下载失败,请稍后重试。' : `📎 ${failures} 个附件下载失败,已跳过。`,
+ )
+ }
+ return attachments
}
async function start(): Promise {
diff --git a/adapters/dingtalk/media.ts b/adapters/dingtalk/media.ts
new file mode 100644
index 00000000..33225326
--- /dev/null
+++ b/adapters/dingtalk/media.ts
@@ -0,0 +1,84 @@
+import path from 'node:path'
+import { AttachmentStore } from '../common/attachment/attachment-store.js'
+import type { LocalAttachment } from '../common/attachment/attachment-types.js'
+import type { DingTalkAttachmentCandidate } from './helpers.js'
+
+const DINGTALK_API = 'https://api.dingtalk.com'
+
+export class DingTalkMediaService {
+ constructor(private readonly store: AttachmentStore) {}
+
+ async downloadCandidate(
+ candidate: DingTalkAttachmentCandidate,
+ sessionId: string,
+ opts: { clientId: string; accessToken: string },
+ ): Promise {
+ const downloadUrl = candidate.url || await this.resolveDownloadUrl(candidate.downloadCode, opts)
+ if (!downloadUrl) throw new Error('DingTalk media item is missing a download URL')
+
+ const resp = await fetch(downloadUrl)
+ if (!resp.ok) {
+ throw new Error(`DingTalk media download failed: ${resp.status} ${resp.statusText}`)
+ }
+ const buffer = Buffer.from(await resp.arrayBuffer())
+ const contentType = resp.headers.get('content-type') || inferMime(candidate.fileName, candidate.kind)
+ const name = candidate.fileName || buildImageName(contentType)
+ const target = this.store.resolvePath('dingtalk', sessionId, name)
+ const savedPath = await this.store.write(target, buffer)
+
+ return {
+ kind: candidate.kind,
+ name,
+ path: savedPath,
+ buffer,
+ size: buffer.length,
+ mimeType: contentType,
+ }
+ }
+
+ private async resolveDownloadUrl(
+ downloadCode: string | undefined,
+ opts: { clientId: string; accessToken: string },
+ ): Promise {
+ if (!downloadCode) return null
+ const resp = await fetch(`${DINGTALK_API}/v1.0/robot/messageFiles/download`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-acs-dingtalk-access-token': opts.accessToken,
+ },
+ body: JSON.stringify({
+ downloadCode,
+ robotCode: opts.clientId,
+ }),
+ })
+ const body = await resp.json().catch(() => null) as { downloadUrl?: string; message?: string } | null
+ if (!resp.ok || !body?.downloadUrl) {
+ throw new Error(body?.message || `DingTalk downloadCode exchange failed: ${resp.status}`)
+ }
+ return body.downloadUrl
+ }
+}
+
+function buildImageName(mime?: string): string {
+ const ext = mime?.includes('png')
+ ? '.png'
+ : mime?.includes('gif')
+ ? '.gif'
+ : mime?.includes('webp')
+ ? '.webp'
+ : '.jpg'
+ return `dingtalk-image-${Date.now()}${ext}`
+}
+
+function inferMime(fileName: string | undefined, kind: 'image' | 'file'): string {
+ if (kind === 'image') return 'image/jpeg'
+ const ext = path.extname(fileName || '').toLowerCase()
+ if (ext === '.pdf') return 'application/pdf'
+ if (ext === '.txt') return 'text/plain'
+ if (ext === '.png') return 'image/png'
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
+ if (ext === '.gif') return 'image/gif'
+ if (ext === '.webp') return 'image/webp'
+ return 'application/octet-stream'
+}
diff --git a/adapters/wechat/__tests__/protocol.test.ts b/adapters/wechat/__tests__/protocol.test.ts
index 32ed6a97..ab377de1 100644
--- a/adapters/wechat/__tests__/protocol.test.ts
+++ b/adapters/wechat/__tests__/protocol.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test'
import { buildClientVersion, extractWechatText } from '../protocol.js'
+import { collectWechatMediaCandidates } from '../media.js'
describe('WeChat protocol helpers', () => {
it('encodes iLink client versions like the OpenClaw Weixin plugin', () => {
@@ -31,4 +32,43 @@ describe('WeChat protocol helpers', () => {
},
])).toBe('[引用: quote title | quoted body]\nreply')
})
+
+ it('collects image and file media candidates from message items', () => {
+ expect(collectWechatMediaCandidates([
+ {
+ type: 2,
+ msg_id: 'img-1',
+ image_item: {
+ aeskey: '00112233445566778899aabbccddeeff',
+ media: {
+ full_url: 'https://cdn.example.com/image',
+ encrypt_query_param: 'enc=1',
+ },
+ },
+ },
+ {
+ type: 4,
+ msg_id: 'file-1',
+ file_item: {
+ file_name: 'report.pdf',
+ media: {
+ full_url: 'https://cdn.example.com/file',
+ aes_key: Buffer.from('00112233445566778899aabbccddeeff').toString('base64'),
+ },
+ },
+ },
+ ])).toMatchObject([
+ {
+ kind: 'image',
+ name: 'wechat-image-img-1.jpg',
+ url: 'https://cdn.example.com/image',
+ },
+ {
+ kind: 'file',
+ name: 'report.pdf',
+ url: 'https://cdn.example.com/file',
+ mimeType: 'application/pdf',
+ },
+ ])
+ })
})
diff --git a/adapters/wechat/index.ts b/adapters/wechat/index.ts
index 1a3d40fb..eba2d708 100644
--- a/adapters/wechat/index.ts
+++ b/adapters/wechat/index.ts
@@ -1,6 +1,7 @@
import * as path from 'node:path'
-import { WsBridge, type ServerMessage } from '../common/ws-bridge.js'
+import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js'
import { MessageDedup } from '../common/message-dedup.js'
+import { MessageBuffer } from '../common/message-buffer.js'
import { enqueue } from '../common/chat-queue.js'
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
import {
@@ -11,14 +12,19 @@ import {
} from '../common/format.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
-import { isAllowedUser } from '../common/pairing.js'
+import { isAllowedUser, tryPair } from '../common/pairing.js'
+import { AttachmentStore } from '../common/attachment/attachment-store.js'
+import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
import {
extractWechatText,
+ getWechatConfig,
getWechatUpdates,
+ sendWechatTyping,
sendWechatText,
WECHAT_DEFAULT_BASE_URL,
type WechatMessage,
} from './protocol.js'
+import { collectWechatMediaCandidates, WechatMediaService } from './media.js'
const WECHAT_TEXT_LIMIT = 3500
const GET_UPDATES_TIMEOUT_MS = 35_000
@@ -37,15 +43,22 @@ const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
const defaultWorkDir = getConfiguredWorkDir(config, config.wechat)
+const attachmentStore = new AttachmentStore()
+const media = new WechatMediaService(attachmentStore)
const pendingProjectSelection = new Map()
const runtimeStates = new Map()
-const accumulatedText = new Map()
+const blockBuffers = new Map()
const contextTokens = new Map()
+const typingTickets = new Map()
const pendingPermissions = new Map>()
let getUpdatesBuf = ''
let stopped = false
+attachmentStore.gc().catch((err) => {
+ console.warn('[WeChat] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
+})
+
type ChatRuntimeState = {
state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending'
verb?: string
@@ -77,8 +90,50 @@ async function sendText(chatId: string, text: string): Promise {
console.log(`[WeChat] Sent ${chunks.length} message chunk(s) to ${redactChatId(chatId)}`)
}
+function getBlockBuffer(chatId: string): MessageBuffer {
+ let buffer = blockBuffers.get(chatId)
+ if (!buffer) {
+ buffer = new MessageBuffer(
+ async (text) => {
+ if (text.trim()) await sendText(chatId, text)
+ },
+ 3000,
+ 200,
+ )
+ blockBuffers.set(chatId, buffer)
+ }
+ return buffer
+}
+
+async function sendTypingIndicator(chatId: string, status: 'typing' | 'cancel'): Promise {
+ try {
+ let typingTicket = typingTickets.get(chatId)
+ if (!typingTicket && status === 'typing') {
+ const configResp = await getWechatConfig({
+ baseUrl,
+ token: botToken,
+ ilinkUserId: chatId,
+ contextToken: contextTokens.get(chatId),
+ })
+ typingTicket = configResp.typing_ticket
+ if (typingTicket) typingTickets.set(chatId, typingTicket)
+ }
+ if (!typingTicket) return
+ await sendWechatTyping({
+ baseUrl,
+ token: botToken,
+ ilinkUserId: chatId,
+ typingTicket,
+ status,
+ })
+ } catch (err) {
+ console.warn('[WeChat] sendTyping failed:', err instanceof Error ? err.message : err)
+ }
+}
+
function clearTransientChatState(chatId: string): void {
- accumulatedText.delete(chatId)
+ blockBuffers.get(chatId)?.reset()
+ blockBuffers.delete(chatId)
pendingPermissions.delete(chatId)
const runtime = getRuntimeState(chatId)
runtime.state = 'idle'
@@ -226,10 +281,15 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
case 'status':
runtime.state = msg.state
runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined
+ if (msg.state === 'thinking' || msg.state === 'tool_executing') {
+ void sendTypingIndicator(chatId, 'typing')
+ } else if (msg.state === 'idle') {
+ void sendTypingIndicator(chatId, 'cancel')
+ }
break
case 'content_delta':
if (typeof msg.text === 'string' && msg.text) {
- accumulatedText.set(chatId, (accumulatedText.get(chatId) ?? '') + msg.text)
+ getBlockBuffer(chatId).append(msg.text)
}
break
case 'permission_request': {
@@ -250,15 +310,17 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
case 'message_complete': {
runtime.state = 'idle'
runtime.verb = undefined
- const text = accumulatedText.get(chatId)
- accumulatedText.delete(chatId)
- if (text?.trim()) await sendText(chatId, text)
+ void sendTypingIndicator(chatId, 'cancel')
+ await blockBuffers.get(chatId)?.complete()
+ blockBuffers.delete(chatId)
break
}
case 'error':
runtime.state = 'idle'
runtime.verb = undefined
- accumulatedText.delete(chatId)
+ void sendTypingIndicator(chatId, 'cancel')
+ blockBuffers.get(chatId)?.reset()
+ blockBuffers.delete(chatId)
await sendText(chatId, `错误: ${msg.message}`)
break
case 'system_notification':
@@ -278,33 +340,43 @@ async function routeUserMessage(message: WechatMessage): Promise {
if (message.context_token) contextTokens.set(chatId, message.context_token)
const text = extractWechatText(message.item_list).trim()
- if (!text) return
+ const mediaCandidates = collectWechatMediaCandidates(message.item_list)
+ if (!text && mediaCandidates.length === 0) return
console.log(`[WeChat] Received from ${redactChatId(chatId)}: ${text.slice(0, 80)}`)
if (!isAllowedUser('wechat', chatId)) {
- await sendText(chatId, '未绑定。请在 Claude Code 桌面端「设置 -> IM 接入 -> 微信」中扫码绑定。')
+ const success = text
+ ? tryPair(text, { userId: chatId, displayName: 'WeChat User' }, 'wechat')
+ : false
+ await sendText(
+ chatId,
+ success
+ ? '配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。'
+ : '未授权。请先在 Claude Code 桌面端完成微信扫码绑定,再生成 IM 配对码后发送给我。',
+ )
return
}
enqueue(chatId, async () => {
- if (text === '/help' || text === '帮助') {
+ const hasAttachments = mediaCandidates.length > 0
+ if (!hasAttachments && (text === '/help' || text === '帮助')) {
await sendText(chatId, formatImHelp())
return
}
- if (text === '/status' || text === '状态') {
+ if (!hasAttachments && (text === '/status' || text === '状态')) {
await sendText(chatId, await buildStatusText(chatId))
return
}
- if (text === '/projects' || text === '项目列表') {
+ if (!hasAttachments && (text === '/projects' || text === '项目列表')) {
await showProjectPicker(chatId)
return
}
- if (text === '/new' || text === '新会话' || text.startsWith('/new ')) {
+ if (!hasAttachments && (text === '/new' || text === '新会话' || text.startsWith('/new '))) {
const arg = text.startsWith('/new ') ? text.slice(5).trim() : ''
await startNewSession(chatId, arg || undefined)
return
}
- if (text === '/stop' || text === '停止') {
+ if (!hasAttachments && (text === '/stop' || text === '停止')) {
const stored = await ensureExistingSession(chatId)
if (!stored) {
await sendText(chatId, formatImStatus(null))
@@ -314,7 +386,7 @@ async function routeUserMessage(message: WechatMessage): Promise {
await sendText(chatId, '已发送停止信号。')
return
}
- if (text === '/clear' || text === '清空') {
+ if (!hasAttachments && (text === '/clear' || text === '清空')) {
const stored = await ensureExistingSession(chatId)
if (!stored) {
await sendText(chatId, formatImStatus(null))
@@ -325,7 +397,7 @@ async function routeUserMessage(message: WechatMessage): Promise {
await sendText(chatId, sent ? '已清空当前会话上下文。' : '无法发送 /clear,请先发送 /new 重新连接会话。')
return
}
- if (text.startsWith('/allow ') || text.startsWith('/deny ')) {
+ if (!hasAttachments && (text.startsWith('/allow ') || text.startsWith('/deny '))) {
const requestId = text.split(/\s+/)[1]
if (!requestId) return
const allowed = text.startsWith('/allow ')
@@ -336,18 +408,69 @@ async function routeUserMessage(message: WechatMessage): Promise {
await sendText(chatId, sent ? (allowed ? '已允许。' : '已拒绝。') : '权限响应发送失败,请检查会话状态。')
return
}
- if (pendingProjectSelection.has(chatId)) {
+ if (!hasAttachments && pendingProjectSelection.has(chatId)) {
await startNewSession(chatId, text)
return
}
const ready = await ensureSession(chatId)
if (!ready) return
- const sent = bridge.sendUserMessage(chatId, text)
+ const attachments = await collectAttachments(chatId, mediaCandidates)
+ const effectiveText = text || (attachments.length > 0 ? '(用户发送了附件)' : '')
+ if (!effectiveText && attachments.length === 0) return
+ void sendTypingIndicator(chatId, 'typing')
+ const sent = bridge.sendUserMessage(chatId, effectiveText, attachments.length ? attachments : undefined)
if (!sent) await sendText(chatId, '消息发送失败,连接可能已断开。请发送 /new 重新开始。')
})
}
+async function collectAttachments(
+ chatId: string,
+ candidates: ReturnType,
+): Promise {
+ if (candidates.length === 0) return []
+ const stored = sessionStore.get(chatId)
+ const sessionId = stored?.sessionId ?? chatId
+ const settled = await Promise.allSettled(candidates.map((candidate) => media.downloadCandidate(candidate, sessionId)))
+ const attachments: AttachmentRef[] = []
+ let failures = 0
+ for (const result of settled) {
+ if (result.status === 'rejected') {
+ failures += 1
+ console.error('[WeChat] media download failed:', result.reason)
+ continue
+ }
+ const local = result.value
+ const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
+ if (!check.ok) {
+ await sendText(chatId, check.hint)
+ continue
+ }
+ if (local.kind === 'image') {
+ attachments.push({
+ type: 'image',
+ name: local.name,
+ data: local.buffer.toString('base64'),
+ mimeType: local.mimeType,
+ })
+ } else {
+ attachments.push({
+ type: 'file',
+ name: local.name,
+ path: local.path,
+ mimeType: local.mimeType,
+ })
+ }
+ }
+ if (failures > 0) {
+ await sendText(
+ chatId,
+ failures === candidates.length ? '附件下载失败,请稍后重试。' : `${failures} 个附件下载失败,已跳过。`,
+ )
+ }
+ return attachments
+}
+
async function pollLoop(): Promise {
while (!stopped) {
try {
diff --git a/adapters/wechat/media.ts b/adapters/wechat/media.ts
new file mode 100644
index 00000000..2597256b
--- /dev/null
+++ b/adapters/wechat/media.ts
@@ -0,0 +1,108 @@
+import crypto from 'node:crypto'
+import { AttachmentStore } from '../common/attachment/attachment-store.js'
+import type { LocalAttachment } from '../common/attachment/attachment-types.js'
+import type { WechatMessageItem } from './protocol.js'
+
+type WechatMediaCandidate = {
+ kind: 'image' | 'file'
+ name: string
+ mimeType?: string
+ url?: string
+ encryptQueryParam?: string
+ aesKey?: string
+}
+
+const DEFAULT_CDN_BASE_URL = 'https://findermp.video.qq.com/251/20304/stodownload'
+
+export function collectWechatMediaCandidates(items?: WechatMessageItem[]): WechatMediaCandidate[] {
+ const candidates: WechatMediaCandidate[] = []
+ for (const item of items ?? []) {
+ if (item.type === 2 && item.image_item?.media) {
+ const media = item.image_item.media
+ candidates.push({
+ kind: 'image',
+ name: `wechat-image-${item.msg_id ?? Date.now()}.jpg`,
+ mimeType: 'image/jpeg',
+ url: media.full_url || item.image_item.url,
+ encryptQueryParam: media.encrypt_query_param,
+ aesKey: item.image_item.aeskey
+ ? Buffer.from(item.image_item.aeskey, 'hex').toString('base64')
+ : media.aes_key,
+ })
+ } else if (item.type === 4 && item.file_item?.media) {
+ const media = item.file_item.media
+ candidates.push({
+ kind: 'file',
+ name: item.file_item.file_name || `wechat-file-${item.msg_id ?? Date.now()}`,
+ mimeType: inferMime(item.file_item.file_name),
+ url: media.full_url,
+ encryptQueryParam: media.encrypt_query_param,
+ aesKey: media.aes_key,
+ })
+ }
+ }
+ return candidates
+}
+
+export class WechatMediaService {
+ constructor(private readonly store: AttachmentStore) {}
+
+ async downloadCandidate(
+ candidate: WechatMediaCandidate,
+ sessionId: string,
+ ): Promise {
+ const encrypted = await fetchWechatMediaBytes(candidate)
+ const buffer = candidate.aesKey ? decryptAesEcb(encrypted, parseAesKey(candidate.aesKey)) : encrypted
+ const target = this.store.resolvePath('wechat', sessionId, candidate.name)
+ const path = await this.store.write(target, buffer)
+ return {
+ kind: candidate.kind,
+ name: candidate.name,
+ path,
+ buffer,
+ size: buffer.length,
+ mimeType: candidate.mimeType ?? (candidate.kind === 'image' ? 'image/jpeg' : 'application/octet-stream'),
+ }
+ }
+}
+
+async function fetchWechatMediaBytes(candidate: WechatMediaCandidate): Promise {
+ const url = candidate.url || buildCdnDownloadUrl(candidate.encryptQueryParam)
+ if (!url) throw new Error('WeChat media item is missing a download URL')
+ const resp = await fetch(url)
+ if (!resp.ok) {
+ throw new Error(`WeChat media download failed: ${resp.status} ${resp.statusText}`)
+ }
+ return Buffer.from(await resp.arrayBuffer())
+}
+
+function buildCdnDownloadUrl(encryptQueryParam?: string): string | null {
+ if (!encryptQueryParam) return null
+ return `${DEFAULT_CDN_BASE_URL}?${encryptQueryParam}`
+}
+
+function parseAesKey(aesKeyBase64: string): Buffer {
+ const decoded = Buffer.from(aesKeyBase64, 'base64')
+ if (decoded.length === 16) return decoded
+ if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii'))) {
+ return Buffer.from(decoded.toString('ascii'), 'hex')
+ }
+ throw new Error(`WeChat AES key must decode to 16 bytes, got ${decoded.length}`)
+}
+
+function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
+ const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()])
+}
+
+function inferMime(fileName?: string): string | undefined {
+ const ext = fileName?.split('.').pop()?.toLowerCase()
+ if (!ext) return undefined
+ if (ext === 'png') return 'image/png'
+ if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg'
+ if (ext === 'gif') return 'image/gif'
+ if (ext === 'webp') return 'image/webp'
+ if (ext === 'pdf') return 'application/pdf'
+ if (ext === 'txt') return 'text/plain'
+ return undefined
+}
diff --git a/adapters/wechat/protocol.ts b/adapters/wechat/protocol.ts
index 7aa03d27..e7e0a751 100644
--- a/adapters/wechat/protocol.ts
+++ b/adapters/wechat/protocol.ts
@@ -53,14 +53,37 @@ export type WechatQrPollResult = {
export type WechatMessageItem = {
type?: number
+ msg_id?: string
text_item?: { text?: string }
voice_item?: { text?: string }
+ media?: WechatCdnMedia
+ image_item?: {
+ media?: WechatCdnMedia
+ thumb_media?: WechatCdnMedia
+ aeskey?: string
+ url?: string
+ }
+ file_item?: {
+ media?: WechatCdnMedia
+ file_name?: string
+ len?: string
+ }
+ video_item?: {
+ media?: WechatCdnMedia
+ thumb_media?: WechatCdnMedia
+ }
ref_msg?: {
title?: string
message_item?: WechatMessageItem
}
}
+export type WechatCdnMedia = {
+ encrypt_query_param?: string
+ aes_key?: string
+ full_url?: string
+}
+
export type WechatMessage = {
seq?: number
message_id?: number
@@ -262,6 +285,51 @@ export async function sendWechatText(params: {
})
}
+export async function getWechatConfig(params: {
+ baseUrl: string
+ token: string
+ ilinkUserId: string
+ contextToken?: string
+ timeoutMs?: number
+}): Promise<{ ret?: number; errmsg?: string; typing_ticket?: string }> {
+ const rawText = await apiPostFetch({
+ baseUrl: params.baseUrl,
+ endpoint: 'ilink/bot/getconfig',
+ body: JSON.stringify({
+ ilink_user_id: params.ilinkUserId,
+ context_token: params.contextToken,
+ base_info: buildBaseInfo(),
+ }),
+ token: params.token,
+ timeoutMs: params.timeoutMs ?? 10_000,
+ label: 'wechatGetConfig',
+ })
+ return JSON.parse(rawText) as { ret?: number; errmsg?: string; typing_ticket?: string }
+}
+
+export async function sendWechatTyping(params: {
+ baseUrl: string
+ token: string
+ ilinkUserId: string
+ typingTicket: string
+ status: 'typing' | 'cancel'
+ timeoutMs?: number
+}): Promise {
+ await apiPostFetch({
+ baseUrl: params.baseUrl,
+ endpoint: 'ilink/bot/sendtyping',
+ body: JSON.stringify({
+ ilink_user_id: params.ilinkUserId,
+ typing_ticket: params.typingTicket,
+ status: params.status === 'typing' ? 1 : 2,
+ base_info: buildBaseInfo(),
+ }),
+ token: params.token,
+ timeoutMs: params.timeoutMs ?? 10_000,
+ label: 'wechatSendTyping',
+ })
+}
+
async function pollQrStatus(apiBaseUrl: string, qrcode: string): Promise {
try {
const rawText = await apiGetFetch({
diff --git a/desktop/bun.lock b/desktop/bun.lock
index 07d09b95..dbf3735f 100644
--- a/desktop/bun.lock
+++ b/desktop/bun.lock
@@ -14,6 +14,7 @@
"marked": "^15.0.7",
"mermaid": "^11.14.0",
"prism-react-renderer": "^2.4.1",
+ "qrcode": "^1.5.4",
"react": "^18.3.1",
"react-diff-viewer-continued": "^4.2.0",
"react-dom": "^18.3.1",
@@ -507,6 +508,8 @@
"callsites": ["callsites@3.1.0", "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
+ "camelcase": ["camelcase@5.3.1", "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
+
"caniuse-lite": ["caniuse-lite@1.0.30001784", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", {}, "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw=="],
"ccount": ["ccount@2.0.1", "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -529,8 +532,14 @@
"classnames": ["classnames@2.5.1", "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
+ "cliui": ["cliui@6.0.0", "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
+
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+ "color-convert": ["color-convert@2.0.1", "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
"combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
@@ -631,6 +640,8 @@
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "decamelize": ["decamelize@1.2.0", "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
+
"decimal.js": ["decimal.js@10.6.0", "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
@@ -649,6 +660,8 @@
"diff": ["diff@8.0.4", "https://registry.npmmirror.com/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
+ "dijkstrajs": ["dijkstrajs@1.0.3", "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
+
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"dompurify": ["dompurify@3.3.3", "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
@@ -657,6 +670,8 @@
"electron-to-chromium": ["electron-to-chromium@1.5.331", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="],
+ "emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
"enhanced-resolve": ["enhanced-resolve@5.20.1", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
"entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
@@ -689,6 +704,8 @@
"find-root": ["find-root@1.1.0", "https://registry.npmmirror.com/find-root/-/find-root-1.1.0.tgz", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
+ "find-up": ["find-up@4.1.0", "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
+
"form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -697,6 +714,8 @@
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+ "get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
@@ -749,6 +768,8 @@
"is-decimal": ["is-decimal@2.0.1", "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
"is-hexadecimal": ["is-hexadecimal@2.0.1", "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
@@ -801,6 +822,8 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
+ "locate-path": ["locate-path@5.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
+
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
@@ -903,6 +926,12 @@
"oniguruma-to-es": ["oniguruma-to-es@4.3.5", "https://registry.npmmirror.com/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="],
+ "p-limit": ["p-limit@2.3.0", "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+
+ "p-locate": ["p-locate@4.1.0", "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+
+ "p-try": ["p-try@2.2.0", "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
+
"package-manager-detector": ["package-manager-detector@1.6.0", "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
@@ -915,6 +944,8 @@
"path-data-parser": ["path-data-parser@0.1.0", "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
+ "path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
"path-parse": ["path-parse@1.0.7", "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-type": ["path-type@4.0.0", "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
@@ -929,6 +960,8 @@
"pkg-types": ["pkg-types@1.3.1", "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
+ "pngjs": ["pngjs@5.0.0", "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
+
"points-on-curve": ["points-on-curve@0.2.0", "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
"points-on-path": ["points-on-path@0.2.1", "https://registry.npmmirror.com/points-on-path/-/points-on-path-0.2.1.tgz", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
@@ -945,6 +978,8 @@
"punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+ "qrcode": ["qrcode@1.5.4", "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
+
"react": ["react@18.3.1", "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-diff-viewer-continued": ["react-diff-viewer-continued@4.2.0", "https://registry.npmmirror.com/react-diff-viewer-continued/-/react-diff-viewer-continued-4.2.0.tgz", { "dependencies": { "@emotion/css": "^11.13.5", "@emotion/react": "^11.14.0", "classnames": "^2.5.1", "diff": "^8.0.3", "js-yaml": "^4.1.1", "memoize-one": "^6.0.0" }, "peerDependencies": { "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-KXeevuPpMRNDAtF878G04Yih/01DBBoC+RjDzWiA5S6TPtUzSfqF5XOlEWyXVWvJuz5n+EQ9QdUQd0ffK2By6w=="],
@@ -965,6 +1000,10 @@
"regex-utilities": ["regex-utilities@2.3.0", "https://registry.npmmirror.com/regex-utilities/-/regex-utilities-2.3.0.tgz", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
+ "require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
+
+ "require-main-filename": ["require-main-filename@2.0.0", "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
+
"resolve": ["resolve@1.22.11", "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -987,6 +1026,8 @@
"semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
+
"shiki": ["shiki@4.0.2", "https://registry.npmmirror.com/shiki/-/shiki-4.0.2.tgz", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
"siginfo": ["siginfo@2.0.0", "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
@@ -1001,8 +1042,12 @@
"std-env": ["std-env@3.10.0", "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
+ "string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
"stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
+ "strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
"strip-indent": ["strip-indent@3.0.0", "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"strip-literal": ["strip-literal@3.1.0", "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
@@ -1097,18 +1142,28 @@
"whatwg-url": ["whatwg-url@14.2.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
+ "which-module": ["which-module@2.0.1", "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
+
"why-is-node-running": ["why-is-node-running@2.3.0", "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+ "wrap-ansi": ["wrap-ansi@6.2.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
+
"ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
+ "y18n": ["y18n@4.0.3", "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
+
"yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.3", "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz", { "bin": "bin.mjs" }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
+ "yargs": ["yargs@15.4.1", "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
+
+ "yargs-parser": ["yargs-parser@18.1.3", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
+
"zustand": ["zustand@5.0.12", "https://registry.npmmirror.com/zustand/-/zustand-5.0.12.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["immer", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
"zwitch": ["zwitch@2.0.4", "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
@@ -1147,6 +1202,8 @@
"strip-literal/js-tokens": ["js-tokens@9.0.1", "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
+ "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
diff --git a/desktop/package.json b/desktop/package.json
index 7680df94..9a4a411d 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -25,6 +25,7 @@
"marked": "^15.0.7",
"mermaid": "^11.14.0",
"prism-react-renderer": "^2.4.1",
+ "qrcode": "^1.5.4",
"react": "^18.3.1",
"react-diff-viewer-continued": "^4.2.0",
"react-dom": "^18.3.1",
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index ff389953..fc088e9f 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -282,7 +282,7 @@ export const en = {
'settings.adapters.codeExpired': 'Expired',
'settings.adapters.codeExpiresIn': 'Expires in',
'settings.adapters.minutes': 'min',
- 'settings.adapters.pairingCodeHint': 'Send this code to the Bot in DingTalk, Feishu, or Telegram private chat. WeChat uses QR binding.',
+ 'settings.adapters.pairingCodeHint': 'Send this code to the Bot in DingTalk, Feishu, WeChat, or Telegram private chat. WeChat requires QR binding first.',
'settings.adapters.pairedUsers': 'Paired Users',
'settings.adapters.noPairedUsers': 'No paired users yet',
'settings.adapters.unbind': 'Unbind',
@@ -294,7 +294,7 @@ export const en = {
'settings.adapters.dingtalkQrTitle': 'Bind DingTalk bot with QR',
'settings.adapters.dingtalkQrDesc': 'Scan with the DingTalk mobile app to create and authorize the bot. Client ID / Secret are saved automatically when it succeeds.',
'settings.adapters.dingtalkStartAuth': 'Scan to bind',
- 'settings.adapters.dingtalkUnbindBot': 'Unbind bot',
+ 'settings.adapters.dingtalkUnbindBot': 'Unbind bot account',
'settings.adapters.dingtalkQrAlt': 'DingTalk authorization QR code',
'settings.adapters.dingtalkWaiting': 'Waiting for DingTalk scan confirmation...',
'settings.adapters.dingtalkBound': 'DingTalk bot is bound.',
@@ -315,7 +315,10 @@ export const en = {
'settings.adapters.wechatQrAlt': 'WeChat binding QR code',
'settings.adapters.wechatWaiting': 'Waiting for WeChat confirmation...',
'settings.adapters.wechatBindSuccess': 'WeChat bound successfully.',
- 'settings.adapters.wechatAllowedUsersHint': 'The scanned user is paired automatically. Add extra WeChat user IDs here if needed.',
+ 'settings.adapters.wechatUnbindAccount': 'Unbind WeChat account',
+ 'settings.adapters.wechatUnbound': 'WeChat account unbound.',
+ 'settings.adapters.wechatUnbindFailed': 'Failed to unbind WeChat.',
+ 'settings.adapters.wechatAllowedUsersHint': 'QR binding enables the account only. Users still need to send a pairing code, or you can add allowed WeChat user IDs here.',
// Settings > MCP
'settings.mcp.title': 'MCP servers',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 652602ca..cd48c2c4 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -284,7 +284,7 @@ export const zh: Record = {
'settings.adapters.codeExpired': '已过期',
'settings.adapters.codeExpiresIn': '有效期剩余',
'settings.adapters.minutes': '分钟',
- 'settings.adapters.pairingCodeHint': '请在钉钉、飞书或 Telegram 私聊中发送此配对码给 Bot。微信使用扫码绑定。',
+ 'settings.adapters.pairingCodeHint': '请在钉钉、飞书、微信或 Telegram 私聊中发送此配对码给 Bot。微信需要先完成扫码绑定。',
'settings.adapters.pairedUsers': '已配对用户',
'settings.adapters.noPairedUsers': '暂无已配对用户',
'settings.adapters.unbind': '解绑',
@@ -296,7 +296,7 @@ export const zh: Record = {
'settings.adapters.dingtalkQrTitle': '扫码绑定钉钉机器人',
'settings.adapters.dingtalkQrDesc': '使用钉钉手机 App 扫码,一键创建并授权机器人。成功后会自动保存 Client ID / Secret 并重启适配器。',
'settings.adapters.dingtalkStartAuth': '扫码绑定',
- 'settings.adapters.dingtalkUnbindBot': '解除绑定',
+ 'settings.adapters.dingtalkUnbindBot': '解除机器人绑定',
'settings.adapters.dingtalkQrAlt': '钉钉授权二维码',
'settings.adapters.dingtalkWaiting': '等待钉钉扫码确认...',
'settings.adapters.dingtalkBound': '钉钉机器人已绑定。',
@@ -317,7 +317,10 @@ export const zh: Record = {
'settings.adapters.wechatQrAlt': '微信绑定二维码',
'settings.adapters.wechatWaiting': '等待微信扫码确认...',
'settings.adapters.wechatBindSuccess': '微信绑定成功。',
- 'settings.adapters.wechatAllowedUsersHint': '扫码用户会自动加入已配对用户;这里可额外填写允许的微信用户 ID。',
+ 'settings.adapters.wechatUnbindAccount': '解除微信绑定',
+ 'settings.adapters.wechatUnbound': '微信绑定已解除。',
+ 'settings.adapters.wechatUnbindFailed': '微信解绑失败。',
+ 'settings.adapters.wechatAllowedUsersHint': '微信扫码只绑定账号能力;用户仍需发送配对码,或在这里额外填写允许的微信用户 ID。',
// Settings > MCP
'settings.mcp.title': 'MCP 服务',
diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx
index d3f691ef..05effd2c 100644
--- a/desktop/src/pages/AdapterSettings.tsx
+++ b/desktop/src/pages/AdapterSettings.tsx
@@ -23,6 +23,7 @@ export function AdapterSettings() {
removePairedUser,
beginDingtalkRegistration,
pollDingtalkRegistration,
+ unbindWechatAccount,
unbindDingtalkBot,
} = useAdapterStore()
@@ -51,6 +52,7 @@ export function AdapterSettings() {
const [wechatSessionKey, setWechatSessionKey] = useState(null)
const [wechatStatus, setWechatStatus] = useState('')
const [isWechatBinding, setIsWechatBinding] = useState(false)
+ const [isUnbindingWechatAccount, setIsUnbindingWechatAccount] = useState(false)
// DingTalk
const [dtClientId, setDtClientId] = useState('')
@@ -303,6 +305,23 @@ export function AdapterSettings() {
}
}, [beginDingtalkRegistration, t])
+ const handleUnbindWechatAccount = useCallback(async () => {
+ setIsUnbindingWechatAccount(true)
+ setWechatStatus('')
+ try {
+ await unbindWechatAccount()
+ await fetchConfig()
+ setWechatQrUrl(null)
+ setWechatSessionKey(null)
+ setWechatStatus(t('settings.adapters.wechatUnbound'))
+ } catch (err) {
+ setWechatStatus(err instanceof Error ? err.message : t('settings.adapters.wechatUnbindFailed'))
+ } finally {
+ setIsUnbindingWechatAccount(false)
+ setIsWechatBinding(false)
+ }
+ }, [unbindWechatAccount, fetchConfig, t])
+
const handleUnbindDingtalkBot = useCallback(async () => {
setIsUnbindingDtBot(true)
setDtAuthError('')
@@ -544,9 +563,16 @@ export function AdapterSettings() {
{t('settings.adapters.wechatQrHint')}
-
+
+
+ {config.wechat?.accountId && (
+
+ )}
+
{wechatQrUrl && (
diff --git a/desktop/src/stores/adapterStore.test.ts b/desktop/src/stores/adapterStore.test.ts
new file mode 100644
index 00000000..88cdf057
--- /dev/null
+++ b/desktop/src/stores/adapterStore.test.ts
@@ -0,0 +1,62 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+describe('adapterStore IM pairing behavior', () => {
+ const adaptersApi = {
+ getConfig: vi.fn(),
+ updateConfig: vi.fn(),
+ startWechatLogin: vi.fn(),
+ pollWechatLogin: vi.fn(),
+ unbindWechat: vi.fn(),
+ beginDingtalkRegistration: vi.fn(),
+ pollDingtalkRegistration: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.resetModules()
+ vi.clearAllMocks()
+ adaptersApi.updateConfig.mockImplementation(async (patch) => patch)
+ vi.doMock('../api/adapters', () => ({ adaptersApi }))
+ vi.doMock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
+ })
+
+ it('removes a WeChat paired user without clearing the bound account', async () => {
+ const { useAdapterStore } = await import('./adapterStore')
+ useAdapterStore.setState({
+ config: {
+ wechat: {
+ accountId: 'wx-account',
+ botToken: '****oken',
+ userId: 'wx-login-user',
+ pairedUsers: [
+ { userId: 'wx-user-1', displayName: 'User 1', pairedAt: 1 },
+ { userId: 'wx-user-2', displayName: 'User 2', pairedAt: 2 },
+ ],
+ },
+ },
+ })
+
+ await useAdapterStore.getState().removePairedUser('wechat', 'wx-user-1')
+
+ expect(adaptersApi.unbindWechat).not.toHaveBeenCalled()
+ expect(adaptersApi.updateConfig).toHaveBeenCalledWith({
+ wechat: {
+ accountId: 'wx-account',
+ botToken: '****oken',
+ userId: 'wx-login-user',
+ pairedUsers: [{ userId: 'wx-user-2', displayName: 'User 2', pairedAt: 2 }],
+ },
+ })
+ })
+
+ it('unbinds the WeChat account only through the explicit account action', async () => {
+ const nextConfig = { wechat: { pairedUsers: [] } }
+ adaptersApi.unbindWechat.mockResolvedValue(nextConfig)
+
+ const { useAdapterStore } = await import('./adapterStore')
+
+ await useAdapterStore.getState().unbindWechatAccount()
+
+ expect(adaptersApi.unbindWechat).toHaveBeenCalledTimes(1)
+ expect(useAdapterStore.getState().config).toBe(nextConfig)
+ })
+})
diff --git a/desktop/src/stores/adapterStore.ts b/desktop/src/stores/adapterStore.ts
index bd6f9618..e43b70ea 100644
--- a/desktop/src/stores/adapterStore.ts
+++ b/desktop/src/stores/adapterStore.ts
@@ -54,6 +54,7 @@ type AdapterStore = {
removePairedUser: (platform: 'telegram' | 'feishu' | 'wechat' | 'dingtalk', userId: string | number) => Promise
beginDingtalkRegistration: () => Promise
pollDingtalkRegistration: (deviceCode: string) => Promise
+ unbindWechatAccount: () => Promise
unbindDingtalkBot: () => Promise
}
@@ -124,6 +125,12 @@ export const useAdapterStore = create((set, get) => ({
return result
},
+ unbindWechatAccount: async () => {
+ const config = await adaptersApi.unbindWechat()
+ set({ config })
+ void notifyTauriRestartAdapters()
+ },
+
unbindDingtalkBot: async () => {
await get().updateConfig({
dingtalk: {
@@ -135,13 +142,6 @@ export const useAdapterStore = create((set, get) => ({
},
removePairedUser: async (platform, userId) => {
- if (platform === 'wechat') {
- const config = await adaptersApi.unbindWechat()
- set({ config })
- void notifyTauriRestartAdapters()
- return
- }
-
const { config } = get()
const platformConfig = config[platform]
if (!platformConfig) return
diff --git a/src/server/__tests__/adapters.test.ts b/src/server/__tests__/adapters.test.ts
index 76c0c37f..fc02288e 100644
--- a/src/server/__tests__/adapters.test.ts
+++ b/src/server/__tests__/adapters.test.ts
@@ -93,6 +93,7 @@ describe('Adapters API', () => {
accountId: 'bot-1',
botToken: 'wechat-secret-token',
userId: 'wx-user',
+ allowedUsers: ['wx-allowed-user'],
pairedUsers: [{ userId: 'wx-user', displayName: 'WeChat User', pairedAt: 1 }],
},
})
@@ -104,6 +105,8 @@ describe('Adapters API', () => {
const json = await res.json() as any
expect(json.wechat.botToken).toBeUndefined()
expect(json.wechat.accountId).toBeUndefined()
+ expect(json.wechat.userId).toBeUndefined()
+ expect(json.wechat.allowedUsers).toEqual([])
expect(json.wechat.pairedUsers).toEqual([])
})
})
diff --git a/src/server/api/adapters.ts b/src/server/api/adapters.ts
index 277e13f3..5e3a6096 100644
--- a/src/server/api/adapters.ts
+++ b/src/server/api/adapters.ts
@@ -188,17 +188,13 @@ async function handleWechatAdaptersApi(req: Request, tail: string[]): Promise