feat: make IM adapter behavior consistent

WeChat and DingTalk were using different pairing, attachment, and response
state paths, which made the new IM channels behave differently from Feishu
and Telegram. Align the shared pairing model, wire inbound media into the
existing attachment bridge, and map platform response capabilities to their
real APIs: WeChat block streaming plus typing, DingTalk AI Card streaming.

Constraint: WeChat iLink exposes typing and block streaming, but no editable message/card streaming API
Constraint: DingTalk streaming depends on the AI Card create/deliver/stream/finalize lifecycle
Rejected: Fake DingTalk typing with standalone markdown | it would add chat noise instead of platform state
Rejected: Auto-pair WeChat after QR login | it bypasses the shared IM pairing model
Confidence: high
Scope-risk: moderate
Directive: Keep WeChat streaming as block-send unless iLink adds editable messages; keep DingTalk streaming on AI Card APIs
Tested: bun run check:adapters; bun run check:server; cd desktop && bun run test src/stores/adapterStore.test.ts; cd desktop && bun run build; bunx tsc -p adapters/tsconfig.json --noEmit; git diff --check
Not-tested: Live WeChat and DingTalk platform smoke with real production credentials
This commit is contained in:
程序员阿江(Relakkes) 2026-05-03 21:08:12 +08:00
parent 6d9aa98022
commit 2ca5228171
20 changed files with 1170 additions and 70 deletions

View File

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

View File

@ -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')
})
})

View File

@ -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' }])
})
})

View File

@ -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<string>
export class DingTalkAiCardService {
constructor(
private readonly getAccessToken: TokenProvider,
private readonly robotCode: string,
) {}
async createForTarget(target: DingTalkAiCardTarget): Promise<DingTalkAiCardInstance | null> {
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<void> {
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<void> {
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<void> {
const body: Record<string, unknown> = {
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<void> {
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<string, unknown> {
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<string, unknown>): Promise<void> {
await requestJson('POST', path, token, body)
}
async function putJson(path: string, token: string, body: Record<string, unknown>): Promise<void> {
await requestJson('PUT', path, token, body)
}
async function requestJson(
method: 'POST' | 'PUT',
path: string,
token: string,
body: Record<string, unknown>,
): Promise<void> {
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<void>): Promise<void> {
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<unknown>,
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<void> {
const prev = this.queueTail
let release!: () => void
this.queueTail = new Promise<void>((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<void> {
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')
}

View File

@ -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<string, unknown>
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<string, any> | null {
if (!raw) return null
if (typeof raw === 'object') return raw as Record<string, any>

View File

@ -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<string, string>()
const pendingProjectSelection = new Map<string, boolean>()
const runtimeStates = new Map<string, ChatRuntimeState>()
const responseBuffers = new Map<string, MessageBuffer>()
const aiCardBuffers = new Map<string, MessageBuffer>()
const aiCardTargets = new Map<string, DingTalkAiCardTarget>()
const streamingCards = new Map<string, Promise<DingTalkAiCardInstance | null>>()
const streamingCardText = new Map<string, string>()
const pendingPermissions = new Map<string, Set<string>>()
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<void> {
}
}
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<DingTalkAiCardInstance | null> | 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<void> {
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<boolean> {
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
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<void> {
async function routeUserMessage(chatId: string, text: string, attachments: AttachmentRef[] = []): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<typeof extractDingTalkAttachments>,
): Promise<AttachmentRef[]> {
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<void> {

View File

@ -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<LocalAttachment> {
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<string | null> {
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'
}

View File

@ -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',
},
])
})
})

View File

@ -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<string, boolean>()
const runtimeStates = new Map<string, ChatRuntimeState>()
const accumulatedText = new Map<string, string>()
const blockBuffers = new Map<string, MessageBuffer>()
const contextTokens = new Map<string, string>()
const typingTickets = new Map<string, string>()
const pendingPermissions = new Map<string, Set<string>>()
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<typeof collectWechatMediaCandidates>,
): Promise<AttachmentRef[]> {
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<void> {
while (!stopped) {
try {

108
adapters/wechat/media.ts Normal file
View File

@ -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<LocalAttachment> {
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<Buffer> {
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
}

View File

@ -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<void> {
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<QrStatusResponse> {
try {
const rawText = await apiGetFetch({

View File

@ -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=="],

View File

@ -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",

View File

@ -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',

View File

@ -284,7 +284,7 @@ export const zh: Record<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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 服务',

View File

@ -23,6 +23,7 @@ export function AdapterSettings() {
removePairedUser,
beginDingtalkRegistration,
pollDingtalkRegistration,
unbindWechatAccount,
unbindDingtalkBot,
} = useAdapterStore()
@ -51,6 +52,7 @@ export function AdapterSettings() {
const [wechatSessionKey, setWechatSessionKey] = useState<string | null>(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')}
</p>
</div>
<Button onClick={handleWechatBind} loading={isWechatBinding && !wechatQrUrl}>
{config.wechat?.accountId ? t('settings.adapters.wechatRebind') : t('settings.adapters.wechatBind')}
</Button>
<div className="flex items-center gap-2 shrink-0">
<Button onClick={handleWechatBind} loading={isWechatBinding && !wechatQrUrl} size="sm">
{config.wechat?.accountId ? t('settings.adapters.wechatRebind') : t('settings.adapters.wechatBind')}
</Button>
{config.wechat?.accountId && (
<Button onClick={handleUnbindWechatAccount} loading={isUnbindingWechatAccount} size="sm" variant="danger">
{t('settings.adapters.wechatUnbindAccount')}
</Button>
)}
</div>
</div>
{wechatQrUrl && (

View File

@ -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)
})
})

View File

@ -54,6 +54,7 @@ type AdapterStore = {
removePairedUser: (platform: 'telegram' | 'feishu' | 'wechat' | 'dingtalk', userId: string | number) => Promise<void>
beginDingtalkRegistration: () => Promise<DingtalkRegistrationBegin>
pollDingtalkRegistration: (deviceCode: string) => Promise<DingtalkRegistrationPoll>
unbindWechatAccount: () => Promise<void>
unbindDingtalkBot: () => Promise<void>
}
@ -124,6 +125,12 @@ export const useAdapterStore = create<AdapterStore>((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<AdapterStore>((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

View File

@ -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([])
})
})

View File

@ -188,17 +188,13 @@ async function handleWechatAdaptersApi(req: Request, tail: string[]): Promise<Re
if (!body.sessionKey) throw ApiError.badRequest('Missing sessionKey')
const result = await pollWechatLoginWithQr({ sessionKey: body.sessionKey })
if (result.connected) {
const pairedUsers = result.userId
? [{ userId: result.userId, displayName: 'WeChat User', pairedAt: Date.now() }]
: []
await adapterService.updateConfig({
wechat: {
accountId: result.accountId,
botToken: result.botToken,
baseUrl: result.baseUrl || WECHAT_DEFAULT_BASE_URL,
userId: result.userId,
pairedUsers,
allowedUsers: [],
pairedUsers: [],
},
})
}