Files
goodbuddy/src/main/channels/dingtalk-channel-driver.ts
T
mesalogo bbf24c282a fix: resolve runtime, review, channel and chat state failures
Keep remote startup cancellable without blocking other sessions and surface local OpenCode submission failures promptly. Preserve supervisor project isolation, pause behavior and valid story destinations.

Restore generated-image channel delivery, Telegram reconnection on unchanged Save and DingTalk business-error retries. Preserve virtualized message disclosures using sparse state and release stale supervisor UI requests.

Add production-path regressions, Electron checks, isolated SQLite validation and audit evidence. Fix the SDK cleanup test fixture lifetime under garbage collection without weakening its assertions.

Validation: 6136 tests passed, 87 skipped; typecheck, lint and diff checks passed. Seven real text-model calls, current-source Linux x64 Host scenarios and large-history Electron comparisons completed.

Release note: Fix stuck runtime requests, inconsistent review results and channel delivery failures, and retain expanded details while browsing long conversations. Remote runtime fixes require an Agent build containing these changes.
2026-10-05 10:25:43 +08:00

309 lines
8.6 KiB
TypeScript

import type {
ChannelInboundText,
ChannelResultMessage
} from '../../shared/channel-contracts'
import type { ChannelDriver, ChannelInboundHandler } from './channel-driver'
import { readBoundedResponseText } from '../../shared/node/bounded-response'
import {
DingTalkDriver,
type DingTalkStreamEnvelope,
type DingTalkStreamTransport,
type DingTalkInboundTextMessage,
type DingTalkReplyContext,
type DingTalkTransportCredentials,
type DingTalkTransportFactory
} from './dingtalk-driver'
const DEFAULT_MAXIMUM_REPLY_CONTEXTS = 1_000
const MAXIMUM_REPLY_BYTES = 32 * 1024
const MAXIMUM_RESPONSE_BYTES = 64 * 1024
const REPLY_TIMEOUT_MS = 10_000
const DINGTALK_ROBOT_TOPIC = '/v1.0/im/bot/messages/get'
type ReplyRecord = {
context: DingTalkReplyContext
conversationId: string
senderId: string
}
export type DingTalkChannelDriverOptions = {
clientId: string
clientSecret: string
allowedSenderIds: readonly string[]
transportFactory?: DingTalkTransportFactory
maximumReplyContexts?: number
}
type DingTalkSdkClient = {
registerCallbackListener(
topic: string,
listener: (message: {
headers: { messageId: string }
data: string
}) => void
): unknown
socketCallBackResponse(messageId: string, result: unknown): void
connect(): Promise<void>
disconnect(): void
}
type DingTalkClientFactory = (
credentials: DingTalkTransportCredentials
) => Promise<DingTalkSdkClient>
type DingTalkFetch = (
input: string,
init: RequestInit
) => Promise<Response>
export type OfficialDingTalkTransportOptions = {
clientFactory?: DingTalkClientFactory
fetchImpl?: DingTalkFetch
}
async function defaultClientFactory(
credentials: DingTalkTransportCredentials
): Promise<DingTalkSdkClient> {
const { DWClient } = await import('dingtalk-stream')
return new DWClient({
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
debug: false
})
}
class OfficialDingTalkTransport implements DingTalkStreamTransport {
private client?: DingTalkSdkClient
constructor(
private readonly credentials: DingTalkTransportCredentials,
private readonly clientFactory: DingTalkClientFactory,
private readonly fetchImpl: DingTalkFetch
) {}
async start(
onEnvelope: (envelope: DingTalkStreamEnvelope) => Promise<void>
): Promise<void> {
const client = await this.clientFactory(this.credentials)
client.registerCallbackListener(
DINGTALK_ROBOT_TOPIC,
(message) => {
const messageId = message.headers.messageId
client.socketCallBackResponse(messageId, {
status: 'SUCCESS'
})
void Promise.resolve()
.then(() =>
onEnvelope({
headers: { messageId },
data: message.data
})
)
.catch(() => undefined)
}
)
this.client = client
try {
await client.connect()
} catch {
this.client = undefined
client.disconnect()
throw new Error('钉钉 Stream 连接失败')
}
}
async stop(): Promise<void> {
const client = this.client
this.client = undefined
client?.disconnect()
}
async replyText(sessionWebhook: string, text: string): Promise<void> {
const body = JSON.stringify({
msgtype: 'text',
text: { content: text }
})
if (
Buffer.byteLength(text, 'utf8') > MAXIMUM_REPLY_BYTES ||
Buffer.byteLength(body, 'utf8') > MAXIMUM_REPLY_BYTES
) {
throw new Error('钉钉回复内容过大')
}
const controller = new AbortController()
const timeout = setTimeout(() => {
controller.abort(new Error('钉钉回复超时'))
}, REPLY_TIMEOUT_MS)
let result: unknown
try {
const response = await this.fetchImpl(sessionWebhook, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
body,
redirect: 'error',
signal: controller.signal
})
if (!response.ok) {
await response.body?.cancel()
throw new Error('钉钉回复请求失败')
}
result = JSON.parse(await readBoundedResponseText(response, {
maxBytes: MAXIMUM_RESPONSE_BYTES,
tooLargeMessage: '钉钉回复响应过大',
truncatedMessage: '钉钉回复响应不完整'
}))
} catch {
throw new Error('钉钉回复请求失败')
} finally {
clearTimeout(timeout)
}
if (typeof result !== 'object' || result === null || !('errcode' in result) || result.errcode !== 0) {
const code = typeof result === 'object' && result !== null && 'errcode' in result &&
typeof result.errcode === 'number' && Number.isSafeInteger(result.errcode)
? ` (${result.errcode})` : ''
throw new Error(`钉钉回复请求失败${code}`)
}
}
}
export function createOfficialDingTalkTransportFactory(
options: OfficialDingTalkTransportOptions = {}
): DingTalkTransportFactory {
const clientFactory = options.clientFactory ?? defaultClientFactory
const fetchImpl =
options.fetchImpl ??
((input, init) => fetch(input, init))
return {
create: (credentials) =>
new OfficialDingTalkTransport(
credentials,
clientFactory,
fetchImpl
)
}
}
function maximumReplyContexts(value: number | undefined): number {
const candidate = value ?? DEFAULT_MAXIMUM_REPLY_CONTEXTS
if (!Number.isSafeInteger(candidate) || candidate < 1) {
throw new Error('钉钉回复上下文容量无效')
}
return candidate
}
function resultText(message: ChannelResultMessage): string {
return message.output?.trim() || message.error?.trim() || '请求已完成'
}
export class DingTalkChannelDriver implements ChannelDriver {
readonly channel = 'dingtalk'
private readonly accountId: string
private readonly driver: DingTalkDriver
private readonly maximumContexts: number
private readonly replyContexts = new Map<string, ReplyRecord>()
private handler?: ChannelInboundHandler
constructor(options: DingTalkChannelDriverOptions) {
this.accountId = options.clientId
this.maximumContexts = maximumReplyContexts(
options.maximumReplyContexts
)
this.driver = new DingTalkDriver(
{
clientId: options.clientId,
clientSecret: options.clientSecret,
allowedSenderStaffIds: options.allowedSenderIds,
onMessage: (message) => this.handleMessage(message)
},
options.transportFactory ??
createOfficialDingTalkTransportFactory()
)
}
async start(handler: ChannelInboundHandler): Promise<void> {
this.handler = handler
try {
await this.driver.start()
} catch {
this.handler = undefined
throw new Error('钉钉通道启动失败')
}
}
async send(
message: ChannelResultMessage,
signal: AbortSignal
): Promise<void> {
if (message.attachments?.length) {
throw new Error('钉钉通道暂不支持发送附件')
}
const record = this.replyContexts.get(message.eventId)
if (
!record ||
message.channel !== this.channel ||
message.conversationId !== record.conversationId ||
message.recipientId !== record.senderId
) {
throw new Error('钉钉回复上下文无效或已过期')
}
signal.throwIfAborted()
await this.driver.reply(record.context, resultText(message))
if (!message.attachments?.length) {
this.replyContexts.delete(message.eventId)
}
}
async stop(): Promise<void> {
this.handler = undefined
this.replyContexts.clear()
try {
await this.driver.stop()
} catch {
throw new Error('钉钉通道停止失败')
}
}
private async handleMessage(
message: DingTalkInboundTextMessage
): Promise<void> {
const handler = this.handler
if (!handler) {
return
}
this.replyContexts.set(message.dedupeKey, {
context: message.replyContext,
conversationId: message.conversationId,
senderId: message.senderId
})
this.enforceContextLimit()
const inbound: ChannelInboundText = {
channel: this.channel,
accountId: this.accountId,
eventId: message.dedupeKey,
senderId: message.senderId,
conversationId: message.conversationId,
conversationType: message.conversationType,
text: message.text,
mentioned: message.conversationType === 'group',
receivedAt: message.createdAt
}
await handler(inbound, () => undefined)
}
private enforceContextLimit(): void {
while (this.replyContexts.size > this.maximumContexts) {
const oldest = this.replyContexts.keys().next().value
if (typeof oldest !== 'string') {
return
}
this.replyContexts.delete(oldest)
}
}
}