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