import type { ChannelInboundText, ChannelResultMessage } from '../../shared/channel-contracts' import type { ChannelDriver, ChannelInboundHandler } from './channel-driver' 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 disconnect(): void } type DingTalkClientFactory = ( credentials: DingTalkTransportCredentials ) => Promise type DingTalkFetch = ( input: string, init: RequestInit ) => Promise export type OfficialDingTalkTransportOptions = { clientFactory?: DingTalkClientFactory fetchImpl?: DingTalkFetch } async function defaultClientFactory( credentials: DingTalkTransportCredentials ): Promise { 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 ): Promise { 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 { const client = this.client this.client = undefined client?.disconnect() } async replyText(sessionWebhook: string, text: string): Promise { 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) try { const response = await this.fetchImpl(sessionWebhook, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body, redirect: 'error', signal: controller.signal }) const responseLength = Number( response.headers.get('content-length') ?? '0' ) if ( !response.ok || !Number.isFinite(responseLength) || responseLength > MAXIMUM_RESPONSE_BYTES ) { throw new Error('钉钉回复请求失败') } await response.body?.cancel() } catch { throw new Error('钉钉回复请求失败') } finally { clearTimeout(timeout) } } } 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() 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 { this.handler = handler try { await this.driver.start() } catch { this.handler = undefined throw new Error('钉钉通道启动失败') } } async send( message: ChannelResultMessage, signal: AbortSignal ): Promise { 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('钉钉回复上下文无效或已过期') } try { signal.throwIfAborted() await this.driver.reply(record.context, resultText(message)) } catch { throw new Error('钉钉消息回复失败') } if (!message.attachments?.length) { this.replyContexts.delete(message.eventId) } } async stop(): Promise { this.handler = undefined this.replyContexts.clear() try { await this.driver.stop() } catch { throw new Error('钉钉通道停止失败') } } private async handleMessage( message: DingTalkInboundTextMessage ): Promise { 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', workMode: 'ask', 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) } } }