Files
goodbuddy/src/main/channels/dingtalk-driver.ts
T
2026-08-05 19:40:40 +08:00

401 lines
11 KiB
TypeScript

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> | 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<void>
): Promise<void>
stop(): Promise<void>
replyText(sessionWebhook: string, text: string): Promise<void>
}
export interface DingTalkTransportCredentials {
clientId: string
clientSecret: string
}
export interface DingTalkTransportFactory {
create(
credentials: DingTalkTransportCredentials
): DingTalkStreamTransport | Promise<DingTalkStreamTransport>
}
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<string, unknown> {
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<string, unknown> {
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<string>
private readonly maxProcessedMessageIds: number
private readonly now: () => number
private handler?: DingTalkMessageHandler
private transport?: DingTalkStreamTransport
private lifecycle: Promise<void> = Promise.resolve()
private readonly inFlightMessageIds = new Set<string>()
private readonly processedMessageIds = new Set<string>()
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<void> {
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<void> {
return this.enqueueLifecycle(async () => {
const transport = this.transport
if (!transport) {
return
}
await transport.stop()
this.transport = undefined
})
}
async reply(
context: DingTalkReplyContext,
text: string
): Promise<void> {
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<void>): Promise<void> {
const result = this.lifecycle.then(operation, operation)
this.lifecycle = result.then(
() => undefined,
() => undefined
)
return result
}
private async handleEnvelope(
envelope: DingTalkStreamEnvelope
): Promise<void> {
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)
}
}
}