程序员阿江(Relakkes) 2ca5228171 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
2026-05-03 21:08:12 +08:00

271 lines
7.7 KiB
TypeScript

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