const DINGTALK_CHANNEL = 'dingtalk' as const const DIRECT_CONVERSATION = '1' const GROUP_CONVERSATION = '2' const MAX_STREAM_DATA_BYTES = 64 * 1024 const DEFAULT_MAX_PROCESSED_MESSAGE_IDS = 1_000 const DINGTALK_SESSION_WEBHOOK_HOST = 'oapi.dingtalk.com' export interface DingTalkStreamEnvelope { headers: { messageId: string } data: string } export interface DingTalkReplyContext { channel: typeof DINGTALK_CHANNEL sessionWebhook: string expiresAt: number } export interface DingTalkInboundTextMessage { channel: typeof DINGTALK_CHANNEL kind: 'text' messageId: string providerMessageId: string dedupeKey: string conversationId: string conversationType: 'direct' | 'group' senderId: string senderName?: string text: string createdAt: number replyContext: DingTalkReplyContext } export type DingTalkMessageHandler = ( message: DingTalkInboundTextMessage ) => Promise | void /** * The SDK-specific boundary. An implementation may wrap DWClient and an HTTP * session-webhook replier; unit tests can provide an entirely local transport. */ export interface DingTalkStreamTransport { start( onEnvelope: (envelope: DingTalkStreamEnvelope) => Promise ): Promise stop(): Promise replyText(sessionWebhook: string, text: string): Promise } export interface DingTalkTransportCredentials { clientId: string clientSecret: string } export interface DingTalkTransportFactory { create( credentials: DingTalkTransportCredentials ): DingTalkStreamTransport | Promise } export interface DingTalkDriverOptions { clientId: string clientSecret: string allowedSenderStaffIds: readonly string[] onMessage?: DingTalkMessageHandler maxProcessedMessageIds?: number now?: () => number } export function normalizeDingTalkStaffId(staffId: string): string { return staffId.normalize('NFKC').trim().toLocaleLowerCase('en-US') } function isRecord(value: unknown): value is Record { return ( typeof value === 'object' && value !== null && !Array.isArray(value) ) } function requiredString( value: unknown, field: string, options: { trim?: boolean } = {} ): string { if (typeof value !== 'string') { throw new Error(`钉钉消息字段 ${field} 必须是字符串`) } const result = options.trim === false ? value : value.trim() if (value.trim().length === 0) { throw new Error(`钉钉消息字段 ${field} 不能为空`) } return result } function requiredTimestamp(value: unknown, field: string): number { if ( typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0 ) { throw new Error(`钉钉消息字段 ${field} 必须是正整数时间戳`) } return value } function parseSessionWebhook(value: unknown): string { const sessionWebhook = requiredString(value, 'sessionWebhook') let parsed: URL try { parsed = new URL(sessionWebhook) } catch { throw new Error('钉钉消息字段 sessionWebhook 无效') } if ( parsed.protocol !== 'https:' || parsed.hostname.toLowerCase() !== DINGTALK_SESSION_WEBHOOK_HOST || parsed.pathname !== '/robot/sendBySession' || parsed.username || parsed.password ) { throw new Error('钉钉消息字段 sessionWebhook 不是受信任的钉钉地址') } return parsed.toString() } function parsePayloadData(data: string): Record { if (Buffer.byteLength(data, 'utf8') > MAX_STREAM_DATA_BYTES) { throw new Error('钉钉消息内容过大') } let payload: unknown try { payload = JSON.parse(data) } catch { throw new Error('钉钉消息不是有效的 JSON') } if (!isRecord(payload)) { throw new Error('钉钉消息 payload 必须是对象') } return payload } /** * Parses one official robot callback frame. Non-text callbacks and group * messages that did not mention the bot are intentionally ignored. */ export function parseDingTalkStreamMessage( envelope: DingTalkStreamEnvelope ): DingTalkInboundTextMessage | null { if (!isRecord(envelope) || !isRecord(envelope.headers)) { throw new Error('钉钉 Stream 消息格式无效') } const messageId = requiredString( envelope.headers.messageId, 'headers.messageId' ) if (typeof envelope.data !== 'string') { throw new Error('钉钉消息字段 data 必须是 JSON 字符串') } const payload = parsePayloadData(envelope.data) const messageType = requiredString(payload.msgtype, 'msgtype') if (messageType !== 'text') { return null } const conversationType = requiredString( payload.conversationType, 'conversationType' ) if ( conversationType !== DIRECT_CONVERSATION && conversationType !== GROUP_CONVERSATION ) { throw new Error('钉钉消息字段 conversationType 无效') } if ( conversationType === GROUP_CONVERSATION && payload.isInAtList !== true ) { return null } if (!isRecord(payload.text)) { throw new Error('钉钉文本消息字段 text 必须是对象') } const text = requiredString(payload.text.content, 'text.content') const rawSenderId = requiredString( payload.senderStaffId, 'senderStaffId' ) const senderId = normalizeDingTalkStaffId(rawSenderId) if (!senderId) { throw new Error('钉钉消息字段 senderStaffId 不能为空') } const senderName = typeof payload.senderNick === 'string' && payload.senderNick.trim().length > 0 ? payload.senderNick.trim() : undefined const replyContext: DingTalkReplyContext = { channel: DINGTALK_CHANNEL, sessionWebhook: parseSessionWebhook(payload.sessionWebhook), expiresAt: requiredTimestamp( payload.sessionWebhookExpiredTime, 'sessionWebhookExpiredTime' ) } return { channel: DINGTALK_CHANNEL, kind: 'text', messageId, providerMessageId: requiredString(payload.msgId, 'msgId'), dedupeKey: messageId, conversationId: requiredString( payload.conversationId, 'conversationId' ), conversationType: conversationType === GROUP_CONVERSATION ? 'group' : 'direct', senderId, ...(senderName ? { senderName } : {}), text, createdAt: requiredTimestamp(payload.createAt, 'createAt'), replyContext } } export class DingTalkDriver { readonly channel = DINGTALK_CHANNEL private readonly credentials: DingTalkTransportCredentials private readonly allowedSenderIds: ReadonlySet private readonly maxProcessedMessageIds: number private readonly now: () => number private handler?: DingTalkMessageHandler private transport?: DingTalkStreamTransport private lifecycle: Promise = Promise.resolve() private readonly inFlightMessageIds = new Set() private readonly processedMessageIds = new Set() constructor( options: DingTalkDriverOptions, private readonly transportFactory: DingTalkTransportFactory ) { this.credentials = { clientId: requiredString(options.clientId, 'clientId'), clientSecret: requiredString(options.clientSecret, 'clientSecret') } this.allowedSenderIds = new Set( options.allowedSenderStaffIds .map((staffId) => normalizeDingTalkStaffId( requiredString(staffId, 'allowedSenderStaffIds') ) ) .filter((staffId) => staffId.length > 0) ) this.handler = options.onMessage this.now = options.now ?? Date.now const maximum = options.maxProcessedMessageIds ?? DEFAULT_MAX_PROCESSED_MESSAGE_IDS if (!Number.isSafeInteger(maximum) || maximum <= 0) { throw new Error('maxProcessedMessageIds 必须是正整数') } this.maxProcessedMessageIds = maximum } start(handler?: DingTalkMessageHandler): Promise { return this.enqueueLifecycle(async () => { if (handler) { this.handler = handler } if (this.transport) { return } if (!this.handler) { throw new Error('启动钉钉通道前必须设置消息处理器') } const transport = await this.transportFactory.create( this.credentials ) this.transport = transport try { await transport.start((envelope) => this.handleEnvelope(envelope) ) } catch (error) { this.transport = undefined try { await transport.stop() } catch { // Keep the original startup failure; the transport owns cleanup. } throw error } }) } stop(): Promise { return this.enqueueLifecycle(async () => { const transport = this.transport if (!transport) { return } await transport.stop() this.transport = undefined }) } async reply( context: DingTalkReplyContext, text: string ): Promise { const transport = this.transport if (!transport) { throw new Error('钉钉通道尚未启动') } if (context.channel !== DINGTALK_CHANNEL) { throw new Error('回复上下文不属于钉钉通道') } const sessionWebhook = parseSessionWebhook( context.sessionWebhook ) if ( !Number.isSafeInteger(context.expiresAt) || context.expiresAt <= this.now() ) { throw new Error('钉钉会话回复地址已过期') } await transport.replyText( sessionWebhook, requiredString(text, 'reply.text', { trim: false }) ) } private enqueueLifecycle(operation: () => Promise): Promise { const result = this.lifecycle.then(operation, operation) this.lifecycle = result.then( () => undefined, () => undefined ) return result } private async handleEnvelope( envelope: DingTalkStreamEnvelope ): Promise { const message = parseDingTalkStreamMessage(envelope) if ( !message || !this.allowedSenderIds.has(message.senderId) || this.processedMessageIds.has(message.dedupeKey) || this.inFlightMessageIds.has(message.dedupeKey) ) { return } const handler = this.handler if (!handler) { throw new Error('钉钉通道没有消息处理器') } this.inFlightMessageIds.add(message.dedupeKey) try { await handler(message) this.rememberProcessedMessageId(message.dedupeKey) } finally { this.inFlightMessageIds.delete(message.dedupeKey) } } private rememberProcessedMessageId(messageId: string): void { this.processedMessageIds.add(messageId) while ( this.processedMessageIds.size > this.maxProcessedMessageIds ) { const oldestMessageId = this.processedMessageIds.values().next().value if (typeof oldestMessageId !== 'string') { break } this.processedMessageIds.delete(oldestMessageId) } } }