mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
6d9aa98022
commit
2ca5228171
@ -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 {
|
||||
|
||||
64
adapters/dingtalk/__tests__/ai-card.test.ts
Normal file
64
adapters/dingtalk/__tests__/ai-card.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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' }])
|
||||
})
|
||||
})
|
||||
|
||||
270
adapters/dingtalk/ai-card.ts
Normal file
270
adapters/dingtalk/ai-card.ts
Normal 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')
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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> {
|
||||
|
||||
84
adapters/dingtalk/media.ts
Normal file
84
adapters/dingtalk/media.ts
Normal 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'
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
108
adapters/wechat/media.ts
Normal 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
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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=="],
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 服务',
|
||||
|
||||
@ -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 && (
|
||||
|
||||
62
desktop/src/stores/adapterStore.test.ts
Normal file
62
desktop/src/stores/adapterStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user