/** * DingTalk Router Service * * IT0 unified DingTalk bot — one central connection for all agent instances. * * Responsibilities: * 1. Maintain a DingTalk Stream connection using IT0's official App credentials. * 2. Handle binding codes: user sends code → maps their DingTalk ID to an agent instance. * 3. Route regular messages: looks up bound instance → POSTs to bridge /task → replies. * * Required env vars: * IT0_DINGTALK_CLIENT_ID — IT0 official DingTalk AppKey * IT0_DINGTALK_CLIENT_SECRET — IT0 official DingTalk AppSecret * * Binding flow: * 1. Frontend calls POST /api/v1/agent/channels/dingtalk/bind/:instanceId * 2. Backend returns { code: "A3K9F2", expiresAt } * 3. User opens DingTalk → IT0 bot → sends "A3K9F2" * 4. This service matches the code → saves dingTalkUserId → replies "✅ 绑定成功" * 5. Frontend polls GET /api/v1/agent/channels/dingtalk/status/:instanceId → { bound: true } * * Robustness guarantees: * - WS message handlers fully wrapped in try/catch — no uncaught exceptions * - Stale-WS guard: close/message events from old WS are ignored after reconnect * - Old WS terminated before creating new one (no orphaned connections) * - DISCONNECT frame handled: immediately reconnects * - Per-user queue: promise chain guaranteed to always resolve (no dead-tail bug) * - sessionWebhookExpiredTime unit auto-detected (seconds or ms) * - DingTalk API response capped at 256 KB (prevents memory spike on bad response) * - Bridge (OpenClaw) response also capped at 256 KB * - Dual routing: senderStaffId (OAuth binding) + senderId (code binding) both handled * - Bridge task timeout explicitly set to 55s (bridge default 25s is too short for LLM) * - sessionWebhook expiry fallback: if webhook expires before LLM replies, uses batchSend * - Periodic cleanup for all in-memory maps (5 min interval) */ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as https from 'https'; import * as crypto from 'crypto'; import * as http from 'http'; import WebSocket from 'ws'; import { AgentInstanceRepository } from '../repositories/agent-instance.repository'; // ── Types ───────────────────────────────────────────────────────────────────── interface DtFrame { type: string; headers: Record; data?: string; } interface BotMsg { /** openId — per-app unique, consistent with OAuth /contact/users/me response */ senderId: string; /** staffId — enterprise employee ID (kept for backward compat) */ senderStaffId: string; sessionWebhook: string; /** * Webhook expiry — DingTalk API has returned both seconds and ms depending on version. * We auto-detect the unit: values < 1e11 are treated as seconds. */ sessionWebhookExpiredTime: number; text?: { content: string }; msgtype?: string; conversationId: string; msgId: string; } interface BindingEntry { instanceId: string; expiresAt: number; } // ── Constants ───────────────────────────────────────────────────────────────── const DINGTALK_MAX_CHARS = 4800; const CODE_TTL_MS = 15 * 60 * 1000; // 15 min const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; // 10 min const TOKEN_REFRESH_BUFFER = 300; // seconds before expiry to proactively refresh const WS_RECONNECT_BASE_MS = 2_000; const WS_RECONNECT_MAX_MS = 60_000; const TASK_TIMEOUT_S = 55; // seconds — bridge default is 25s; must pass explicitly const DEDUP_TTL_MS = 10 * 60 * 1000; const RATE_LIMIT_PER_MIN = 10; const QUEUE_MAX_DEPTH = 5; const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; const MAX_RESPONSE_BYTES = 256 * 1024; // 256 KB cap for DingTalk API responses // ── Service ─────────────────────────────────────────────────────────────────── @Injectable() export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(DingTalkRouterService.name); private readonly clientId: string; private readonly clientSecret: string; private readonly enabled: boolean; // Token private token = ''; private tokenExpiresAt = 0; private tokenRefreshTimer?: NodeJS.Timeout; private tokenRefreshPromise: Promise | null = null; // WS private ws: WebSocket | null = null; private connecting = false; // prevents concurrent connectStream() calls private reconnectDelay = WS_RECONNECT_BASE_MS; private stopping = false; private reconnectTimer?: NodeJS.Timeout; private cleanupTimer?: NodeJS.Timeout; // State private readonly bindingCodes = new Map(); // code → entry private readonly oauthStates = new Map(); // state → entry private readonly dedup = new Map(); // msgId → ts private readonly rateWindows = new Map(); // userId → timestamps private readonly queueTails = new Map>(); // userId → tail private readonly queueDepths = new Map(); // userId → depth constructor( private readonly configService: ConfigService, private readonly instanceRepo: AgentInstanceRepository, ) { this.clientId = this.configService.get('IT0_DINGTALK_CLIENT_ID', ''); this.clientSecret = this.configService.get('IT0_DINGTALK_CLIENT_SECRET', ''); this.enabled = !!(this.clientId && this.clientSecret); } onModuleInit(): void { if (!this.enabled) { this.logger.warn('IT0_DINGTALK_CLIENT_ID/SECRET not set — DingTalk router disabled'); return; } this.logger.log('DingTalk router starting...'); this.connectStream().catch((e: Error) => this.logger.error('Initial stream connection failed:', e.message), ); this.cleanupTimer = setInterval(() => this.periodicCleanup(), CLEANUP_INTERVAL_MS); if (this.cleanupTimer.unref) this.cleanupTimer.unref(); } onModuleDestroy(): void { this.stopping = true; clearInterval(this.cleanupTimer); clearTimeout(this.reconnectTimer); clearTimeout(this.tokenRefreshTimer); this.terminateCurrentWs(); } isEnabled(): boolean { return this.enabled; } // ── Binding code API ─────────────────────────────────────────────────────── generateBindingCode(instanceId: string): { code: string; expiresAt: number } { // Invalidate any existing code for this instance to avoid confusion for (const [code, entry] of this.bindingCodes) { if (entry.instanceId === instanceId) this.bindingCodes.delete(code); } const code = crypto.randomBytes(3).toString('hex').toUpperCase(); // e.g. "A3K9F2" const expiresAt = Date.now() + CODE_TTL_MS; this.bindingCodes.set(code, { instanceId, expiresAt }); return { code, expiresAt }; } // ── OAuth API ────────────────────────────────────────────────────────────── /** * Generate a DingTalk OAuth authorization URL. * The state token encodes the instanceId so the callback can complete binding * without user interaction (one-tap "Authorize" in DingTalk on the same phone). */ generateOAuthUrl(instanceId: string): { oauthUrl: string; state: string } { // Invalidate old states for this instance for (const [s, entry] of this.oauthStates) { if (entry.instanceId === instanceId) this.oauthStates.delete(s); } const state = crypto.randomBytes(16).toString('hex'); const expiresAt = Date.now() + OAUTH_STATE_TTL_MS; this.oauthStates.set(state, { instanceId, expiresAt }); const baseUrl = this.configService.get('IT0_BASE_URL', 'https://it0api.szaiai.com'); const redirectUri = `${baseUrl}/api/v1/agent/channels/dingtalk/oauth/callback`; const params = new URLSearchParams({ client_id: this.clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'openid', state, prompt: 'consent', }); return { state, oauthUrl: `https://login.dingtalk.com/oauth2/auth?${params.toString()}` }; } /** * Complete the OAuth flow: exchange auth code → get user openId/unionId → save to DB. * Called by the public callback endpoint (no JWT). * * ID types in DingTalk: * openId — per-app unique (from OAuth /v1.0/contact/users/me). Matches bot senderId. * unionId — cross-app unique (from same endpoint). * userId — enterprise staff ID (from oapi/topapi/user/getbyunionid). Required for batchSend. * * We store openId (matches senderId in incoming bot messages for routing). * We convert unionId → userId for proactive greeting via batchSend. */ async completeOAuthBinding(code: string, state: string): Promise<{ instanceId: string; instanceName: string }> { const entry = this.oauthStates.get(state); if (!entry) throw new Error('无效或已过期的授权状态,请重新绑定'); if (Date.now() > entry.expiresAt) { this.oauthStates.delete(state); throw new Error('授权已超时,请重新绑定'); } this.oauthStates.delete(state); // Exchange auth code for user access token const tokenResult = await this.httpsPost<{ accessToken: string; expireIn: number }>( 'api.dingtalk.com', '/v1.0/oauth2/userAccessToken', { clientId: this.clientId, clientSecret: this.clientSecret, code, grantType: 'authorization_code' }, ); // Get user's openId and unionId const userInfo = await this.httpsGet<{ openId: string; unionId: string }>( 'api.dingtalk.com', '/v1.0/contact/users/me', { 'x-acs-dingtalk-access-token': tokenResult.accessToken }, ); const openId = userInfo.openId; const unionId = userInfo.unionId; if (!openId) throw new Error('无法获取钉钉用户身份,请重试'); this.logger.log( `OAuth user info: openId=${openId} unionId=${unionId ?? 'none'} for instance=${entry.instanceId}`, ); // Resolve userId (staffId) from unionId — required for proactive batchSend. // Uses old oapi.dingtalk.com topapi endpoint with corp app access token. // Non-fatal if this fails (e.g. user not in enterprise, permission not granted). let userId: string | undefined; if (unionId) { try { const appToken = await this.getToken(); const result = await this.httpsPost<{ errcode: number; errmsg: string; result: { userid: string } }>( 'oapi.dingtalk.com', `/topapi/user/getbyunionid?access_token=${encodeURIComponent(appToken)}`, { unionid: unionId }, ); if (result.errcode === 0 && result.result?.userid) { userId = result.result.userid; this.logger.log(`unionId→userId resolved: ${unionId} → ${userId}`); } else { this.logger.warn(`getbyunionid failed: errcode=${result.errcode} errmsg=${result.errmsg}`); } } catch (e: any) { this.logger.warn(`Cannot resolve unionId→userId (non-fatal): ${e.message}`); } } const instance = await this.instanceRepo.findById(entry.instanceId); if (!instance) throw new Error('智能体实例不存在'); // Store userId (staffId) as the binding identifier if resolved — this matches senderStaffId // in incoming DingTalk bot messages, enabling correct routing. // Fall back to openId if userId could not be resolved (routing may still work if // DingTalk delivers openId in senderId for this app type). const bindingId = userId ?? openId; instance.dingTalkUserId = bindingId; await this.instanceRepo.save(instance); this.logger.log( `OAuth binding saved: instance ${entry.instanceId} → dingTalkUserId=${bindingId} ` + `(${userId ? 'staffId/userId' : 'openId fallback — staffId not resolved'})`, ); // Send proactive greeting using userId (staffId). Skip if not resolved. this.sendGreeting(userId, openId, instance.name).catch((e: Error) => this.logger.warn(`Greeting send failed (non-fatal): ${e.message}`), ); return { instanceId: entry.instanceId, instanceName: instance.name }; } /** * Send a proactive bot message to a DingTalk user via the batchSend API. * Called after OAuth binding completes to greet the user. * * batchSend requires the enterprise userId (staffId), NOT openId. * If userId is unavailable (e.g. user has no enterprise context), we skip the * proactive message. The user will get a greeting on their first incoming message. * * @param userId - enterprise userId (staffId), if resolved; undefined means skip * @param openId - for logging context only */ private async sendGreeting(userId: string | undefined, openId: string, agentName: string): Promise { if (!userId) { // batchSend requires staffId; without it, proactive greeting is not possible. this.logger.warn( `Skipping proactive greeting for openId=${openId}: userId not resolved ` + `(ensure app has Contact.User.Read permission and user is an enterprise member)`, ); return; } const token = await this.getToken(); const greeting = `👋 你好!我是你的 AI 智能体助手「${agentName}」。\n\n` + `从现在起,你可以直接在这里向我发送指令,我会自主地帮你完成工作任务。\n\n` + `例如:\n• 查询服务器状态\n• 执行运维脚本\n• 管理文件和进程\n\n` + `有什么需要帮忙的,直接说吧!`; this.logger.log(`Sending greeting to userId=${userId} (openId=${openId}) for agent "${agentName}"`); await this.httpsPost( 'api.dingtalk.com', '/v1.0/robot/oToMessages/batchSend', { robotCode: this.clientId, userIds: [userId], msgKey: 'sampleText', msgParam: JSON.stringify({ content: greeting }), }, { 'x-acs-dingtalk-access-token': token }, ); this.logger.log(`Greeting sent successfully to userId=${userId}`); } // ── Token management ─────────────────────────────────────────────────────── private async getToken(): Promise { if (this.token && Date.now() < this.tokenExpiresAt - TOKEN_REFRESH_BUFFER * 1000) { return this.token; } if (this.tokenRefreshPromise) return this.tokenRefreshPromise; this.tokenRefreshPromise = this.refreshToken().finally(() => { this.tokenRefreshPromise = null; }); return this.tokenRefreshPromise; } private async refreshToken(): Promise { const { accessToken, expireIn } = await this.httpsPost<{ accessToken: string; expireIn: number }>( 'api.dingtalk.com', '/v1.0/oauth2/accessToken', { appKey: this.clientId, appSecret: this.clientSecret }, ); this.token = accessToken; this.tokenExpiresAt = Date.now() + expireIn * 1000; clearTimeout(this.tokenRefreshTimer); const refreshInMs = Math.max((expireIn - TOKEN_REFRESH_BUFFER) * 1000, 60_000); this.tokenRefreshTimer = setTimeout(() => { this.getToken().catch((e: Error) => this.logger.error('Proactive token refresh failed:', e.message)); }, refreshInMs); if (this.tokenRefreshTimer.unref) this.tokenRefreshTimer.unref(); this.logger.log(`DingTalk token refreshed, valid ${expireIn}s`); return this.token; } // ── Stream connection ────────────────────────────────────────────────────── private async connectStream(): Promise { if (this.stopping || this.connecting) return; this.connecting = true; try { await this.doConnect(); } finally { this.connecting = false; } } private async doConnect(): Promise { let token: string; try { token = await this.getToken(); } catch (e: any) { this.logger.error('Cannot get DingTalk token:', e.message); this.scheduleReconnect(); return; } let wsInfo: { endpoint: string; ticket: string }; try { wsInfo = await this.httpsPost<{ endpoint: string; ticket: string }>( 'api.dingtalk.com', '/v1.0/gateway/connections/open', { clientId: this.clientId, clientSecret: this.clientSecret, subscriptions: [{ type: 'CALLBACK', topic: '/v1.0/im/bot/messages/get' }], ua: 'it0-dingtalk-router/1.0', localIp: '127.0.0.1', }, { 'x-acs-dingtalk-access-token': token }, ); } catch (e: any) { this.logger.error('Failed to get stream endpoint:', e.message); this.scheduleReconnect(); return; } // ── Close stale WS before creating new one ───────────────────────────── // Terminating the old WS now means its 'close' event will fire, but the // stale-WS guard (ws !== this.ws) will prevent it from triggering reconnect. this.terminateCurrentWs(); const ws = new WebSocket(`${wsInfo.endpoint}?ticket=${encodeURIComponent(wsInfo.ticket)}`); this.ws = ws; ws.on('open', () => { if (ws !== this.ws) return; // stale this.logger.log('DingTalk stream connected'); this.reconnectDelay = WS_RECONNECT_BASE_MS; }); // Fully wrapped — no uncaught exceptions can reach the EventEmitter boundary ws.on('message', (raw) => { try { if (ws !== this.ws) return; // stale WS, ignore let frame: DtFrame; try { frame = JSON.parse(raw.toString()); } catch { return; } this.handleFrame(ws, frame); } catch (e: any) { this.logger.error('Uncaught error in WS message handler:', e.message); } }); ws.on('close', (code, reason) => { if (this.stopping || ws !== this.ws) return; // stale or intentional shutdown this.logger.warn(`Stream closed (${code}: ${reason.toString()})`); this.scheduleReconnect(); }); ws.on('error', (e) => { // 'close' fires after 'error' — reconnect is handled there this.logger.error('Stream WS error:', e.message); }); } /** Force-close the current WS without triggering our reconnect logic */ private terminateCurrentWs(): void { const old = this.ws; this.ws = null; if (old && old.readyState !== WebSocket.CLOSED) { try { old.terminate(); } catch { /* ignore */ } } } private scheduleReconnect(): void { if (this.stopping) return; clearTimeout(this.reconnectTimer); this.reconnectTimer = setTimeout(() => { this.connectStream().catch((e: Error) => this.logger.error('Reconnect attempt failed:', e.message), ); }, this.reconnectDelay); this.reconnectDelay = Math.min(this.reconnectDelay * 2, WS_RECONNECT_MAX_MS); } // ── Frame handling ───────────────────────────────────────────────────────── private handleFrame(ws: WebSocket, frame: DtFrame): void { // Server keepalive if (frame.type === 'PING') { this.wsSend(ws, JSON.stringify({ code: 200, headers: frame.headers, message: 'OK', data: '' })); return; } // Server requests graceful disconnect (e.g. deployment rollover, load balancing) if (frame.type === 'DISCONNECT') { this.logger.warn('DingTalk server requested DISCONNECT — reconnecting'); // Nullify reference so close event is treated as "current" and triggers reconnect // (we're about to terminate, which fires close; with ws === this.ws it proceeds) ws.terminate(); return; } if ( frame.type === 'CALLBACK' && frame.headers?.['topic'] === '/v1.0/im/bot/messages/get' && frame.data ) { let msg: BotMsg; try { msg = JSON.parse(frame.data); } catch { return; } // ACK MUST be sent within 1.5s — synchronously before any async work this.wsSend(ws, JSON.stringify({ code: 200, headers: frame.headers, message: 'OK', data: '' })); this.dispatchMessage(msg); } } /** * Safe WebSocket send — never throws. * Checks readyState before calling send() to avoid "WebSocket is not open" errors * that would otherwise propagate as uncaught exceptions through EventEmitter. */ private wsSend(ws: WebSocket, data: string): void { try { if (ws.readyState === WebSocket.OPEN) { ws.send(data); } } catch (e: any) { this.logger.warn('wsSend failed:', e.message); } } private dispatchMessage(msg: BotMsg): void { // senderId = openId (per-app), consistent with OAuth binding identifier. // Fall back to senderStaffId for legacy messages that lack senderId. const userId = (msg.senderId ?? msg.senderStaffId)?.trim(); if (!userId) { this.logger.warn('Received message with no sender ID, ignoring'); return; } this.logger.log( `DingTalk message: senderId=${msg.senderId ?? 'none'} senderStaffId=${msg.senderStaffId ?? 'none'} text="${(msg.text?.content ?? '').slice(0, 60)}"`, ); // Non-text message types (image, file, richText, audio, video, @mention, etc.) const text = msg.text?.content?.trim() ?? ''; if (!text) { this.reply(msg, '我目前只能处理文字消息,请发送文字与小龙虾沟通。'); return; } // Deduplication (DingTalk may deliver duplicates on reconnect) if (this.dedup.has(msg.msgId)) return; this.dedup.set(msg.msgId, Date.now()); // Binding code check (6-char hex, case-insensitive, exact match only) const upperText = text.toUpperCase(); const entry = this.bindingCodes.get(upperText); if (entry) { if (Date.now() > entry.expiresAt) { this.bindingCodes.delete(upperText); this.reply(msg, '验证码已过期,请重新在 IT0 App 中生成新的验证码。'); return; } this.bindingCodes.delete(upperText); this.completeBinding(entry.instanceId, userId, msg).catch((e: Error) => this.logger.error('completeBinding unhandled error:', e.message), ); return; } // Rate limit if (!this.rateAllow(userId)) { this.reply(msg, '消息频率过高,请稍后再试(每分钟最多10条)。'); return; } // Route to agent container (serial per-user queue) const accepted = this.enqueue(userId, () => this.routeToAgent(userId, text, msg)); if (!accepted) { this.reply(msg, '当前请求排队已满(最多5条),请稍后再试。'); } } // ── Binding completion ───────────────────────────────────────────────────── private async completeBinding(instanceId: string, dingTalkUserId: string, msg: BotMsg): Promise { try { const instance = await this.instanceRepo.findById(instanceId); if (!instance) { this.reply(msg, '绑定失败:智能体实例不存在,请重新操作。'); return; } instance.dingTalkUserId = dingTalkUserId; await this.instanceRepo.save(instance); this.logger.log(`Bound instance ${instanceId} to DingTalk user ${dingTalkUserId}`); this.reply( msg, `✅ 绑定成功!\n\n你的小龙虾「${instance.name}」已与钉钉绑定。\n\n现在直接发消息给我,我会帮你转达给它!`, ); } catch (e: any) { this.logger.error('Binding failed:', e.message); this.reply(msg, '绑定时出现错误,请稍后重试。'); } } // ── Message routing ──────────────────────────────────────────────────────── private async routeToAgent(userId: string, text: string, msg: BotMsg): Promise { // Two binding paths store different DingTalk ID types: // OAuth binding → stores staffId (resolved via unionId→userId at auth time) // Code binding → stores senderId ($:LWCP_v1:$... format from bot message) // // DingTalk's Stream API senderId ($:LWCP_v1:$...) is NOT the same as the OAuth // openId returned by /v1.0/contact/users/me. They are different ID encodings. // Therefore we try senderStaffId first (hits OAuth-bound instances immediately), // then fall back to senderId (hits code-bound instances). const staffId = msg.senderStaffId?.trim(); let instance = staffId ? await this.instanceRepo.findByDingTalkUserId(staffId) : null; if (!instance) { // Code-bound instances store senderId directly; also catches any future cases // where DingTalk aligns senderId with the stored identifier. instance = await this.instanceRepo.findByDingTalkUserId(userId); if (instance) { this.logger.log(`Routed via senderId=${userId} (senderStaffId=${staffId ?? 'none'} not matched)`); } } if (!instance) { this.logger.warn(`No binding found for senderStaffId=${staffId ?? 'none'} or senderId=${userId}`); this.reply( msg, '你还没有绑定小龙虾。\n\n请在 IT0 App 中创建一只小龙虾,然后点击「绑定钉钉」获取验证码。', ); return; } if (instance.status !== 'running') { this.reply(msg, `小龙虾「${instance.name}」当前状态为 ${instance.status},暂时无法接收指令。`); return; } if (!instance.serverHost) { this.logger.error(`Instance ${instance.id} has no serverHost configured`); this.reply(msg, '小龙虾配置异常(缺少服务器地址),请联系管理员。'); return; } const bridgeUrl = `http://${instance.serverHost}:${instance.hostPort}/task`; // sessionWebhook TTL is ~90 minutes (per DingTalk docs), but delivering the actual LLM // response synchronously makes the user wait with no feedback. Strategy: // 1. Immediately send "处理中..." via sessionWebhook — user sees instant acknowledgment // 2. Await the bridge call (LLM processing) — the serial queue still blocks here, // preventing concurrent LLM calls for the same user // 3. Always deliver the actual response via batchSend — decoupled from webhook window this.reply(msg, '🤔 小虾米正在思考,稍等...'); let reply: string; try { const result = await this.httpPostJson<{ ok: boolean; result?: unknown; error?: string }>( bridgeUrl, { prompt: text, sessionKey: `agent:main:dt-${userId}`, idempotencyKey: msg.msgId, timeoutSeconds: TASK_TIMEOUT_S, }, (TASK_TIMEOUT_S + 10) * 1000, ); if (result.ok && result.result !== undefined) { reply = typeof result.result === 'string' ? result.result : JSON.stringify(result.result, null, 2); } else { reply = result.error ?? '智能体没有返回内容。'; } } catch (e: any) { this.logger.error(`Bridge call failed for instance ${instance.id}:`, e.message); reply = '与小龙虾通信时出现错误,请稍后重试。'; } await this.batchSend(staffId, reply, msg.msgId); } /** Send a proactive message to a DingTalk user via batchSend. Used for LLM replies * so that users receive the response regardless of sessionWebhook state. */ private batchSend(staffId: string | undefined, content: string, msgId: string): Promise { if (!staffId) { this.logger.warn(`batchSend skipped — no staffId for msgId=${msgId}`); return Promise.resolve(); } // Chunk content to stay within DingTalk's message size limit const safe = content.replace(/\s+at\s+\S+:\d+:\d+/g, '').trim() || '(空响应)'; const chunks: string[] = []; for (let i = 0; i < safe.length; i += DINGTALK_MAX_CHARS) { chunks.push(safe.slice(i, i + DINGTALK_MAX_CHARS)); } return this.getToken() .then(async (token) => { for (const chunk of chunks) { await this.httpsPost( 'api.dingtalk.com', '/v1.0/robot/oToMessages/batchSend', { robotCode: this.clientId, userIds: [staffId], msgKey: 'sampleText', msgParam: JSON.stringify({ content: chunk }), }, { 'x-acs-dingtalk-access-token': token }, ); } }) .catch((e: Error) => this.logger.error(`batchSend failed for msgId=${msgId}:`, e.message), ); } // ── Reply (chunked) ──────────────────────────────────────────────────────── private reply(msg: BotMsg, content: string): void { // Auto-detect unit: DingTalk has returned both seconds and ms depending on API version. // Values < 1e11 (year 1973 in ms) are treated as Unix seconds. const expiryMs = msg.sessionWebhookExpiredTime > 1e11 ? msg.sessionWebhookExpiredTime : msg.sessionWebhookExpiredTime * 1000; if (Date.now() > expiryMs) { this.logger.warn(`sessionWebhook expired for msgId=${msg.msgId}, skipping reply`); return; } // Strip stack-trace lines to avoid leaking internals const safe = content.replace(/\s+at\s+\S+:\d+:\d+/g, '').trim(); const chunks: string[] = []; for (let i = 0; i < safe.length; i += DINGTALK_MAX_CHARS) { chunks.push(safe.slice(i, i + DINGTALK_MAX_CHARS)); } if (chunks.length === 0) chunks.push('(空响应)'); chunks.forEach((chunk, idx) => setTimeout(() => this.sendWebhook(msg.sessionWebhook, chunk), idx * 300), ); } private sendWebhook(webhook: string, content: string): void { try { const url = new URL(webhook); const body = JSON.stringify({ msgtype: 'text', text: { content } }); const req = https.request( { hostname: url.hostname, path: url.pathname + url.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), }, timeout: 10_000, }, (res) => { res.resume(); }, ); req.on('timeout', () => { req.destroy(); }); req.on('error', (e) => this.logger.error('Webhook send error:', e.message)); req.write(body); req.end(); } catch (e: any) { this.logger.error('sendWebhook failed:', e.message); } } // ── Per-user serial queue ────────────────────────────────────────────────── private enqueue(userId: string, task: () => Promise): boolean { const depth = this.queueDepths.get(userId) ?? 0; if (depth >= QUEUE_MAX_DEPTH) return false; this.queueDepths.set(userId, depth + 1); const tail = this.queueTails.get(userId) ?? Promise.resolve(); const next = tail .then(task) .catch((e: Error) => { // Safe catch: this handler must not throw, or the queue tail becomes a dead rejected promise try { this.logger.error(`Queue task error (${userId}):`, e.message); } catch { /* ignore */ } }) .finally(() => { const remaining = (this.queueDepths.get(userId) ?? 1) - 1; if (remaining <= 0) { this.queueDepths.delete(userId); this.queueTails.delete(userId); } else { this.queueDepths.set(userId, remaining); } }) // Guarantee: `next` always resolves, even if finally() somehow throws. // This prevents a permanently dead queue tail that silently drops all future tasks. .catch(() => {}); this.queueTails.set(userId, next); return true; } // ── Rate limiter (sliding window) ───────────────────────────────────────── private rateAllow(userId: string): boolean { const now = Date.now(); const timestamps = (this.rateWindows.get(userId) ?? []).filter((t) => now - t < 60_000); if (timestamps.length >= RATE_LIMIT_PER_MIN) { this.rateWindows.set(userId, timestamps); // persist pruned list return false; } timestamps.push(now); this.rateWindows.set(userId, timestamps); return true; } // ── Periodic cleanup ─────────────────────────────────────────────────────── private periodicCleanup(): void { const now = Date.now(); for (const [id, ts] of this.dedup) { if (now - ts > DEDUP_TTL_MS) this.dedup.delete(id); } for (const [userId, timestamps] of this.rateWindows) { const fresh = timestamps.filter((t) => now - t < 60_000); if (fresh.length === 0) this.rateWindows.delete(userId); else this.rateWindows.set(userId, fresh); } for (const [code, entry] of this.bindingCodes) { if (now > entry.expiresAt) this.bindingCodes.delete(code); } for (const [state, entry] of this.oauthStates) { if (now > entry.expiresAt) this.oauthStates.delete(state); } } // ── HTTP helpers ─────────────────────────────────────────────────────────── /** HTTPS GET to DingTalk API. Response body capped at MAX_RESPONSE_BYTES. */ private httpsGet(hostname: string, path: string, headers: Record = {}): Promise { return new Promise((resolve, reject) => { const req = https.request( { hostname, path, method: 'GET', headers: { ...headers }, timeout: 10_000 }, (res) => { let data = ''; let totalBytes = 0; res.on('data', (chunk: Buffer) => { totalBytes += chunk.length; if (totalBytes <= MAX_RESPONSE_BYTES) data += chunk.toString(); }); res.on('end', () => { if (totalBytes > MAX_RESPONSE_BYTES) { reject(new Error(`Response too large (${totalBytes} bytes)`)); return; } try { const json = JSON.parse(data); if (res.statusCode && res.statusCode >= 400) reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`)); else resolve(json as T); } catch (e) { reject(e); } }); }, ); req.on('timeout', () => { req.destroy(); reject(new Error('DingTalk API GET timeout')); }); req.on('error', reject); req.end(); }); } /** HTTPS POST to DingTalk API. Response body capped at MAX_RESPONSE_BYTES. */ private httpsPost( hostname: string, path: string, payload: object, extraHeaders: Record = {}, ): Promise { return new Promise((resolve, reject) => { const body = JSON.stringify(payload); const req = https.request( { hostname, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...extraHeaders, }, timeout: 10_000, }, (res) => { let data = ''; let totalBytes = 0; res.on('data', (chunk: Buffer) => { totalBytes += chunk.length; if (totalBytes <= MAX_RESPONSE_BYTES) data += chunk.toString(); }); res.on('end', () => { if (totalBytes > MAX_RESPONSE_BYTES) { reject(new Error(`DingTalk API response too large (${totalBytes} bytes)`)); return; } try { const json = JSON.parse(data); if (res.statusCode && res.statusCode >= 400) { reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`)); } else { resolve(json as T); } } catch (e) { reject(e); } }); }, ); req.on('timeout', () => { req.destroy(); reject(new Error('DingTalk API request timeout')); }); req.on('error', reject); req.write(body); req.end(); }); } /** HTTP POST to an internal bridge container (plain http, no TLS). */ private httpPostJson(url: string, payload: object, timeoutMs = 35_000): Promise { return new Promise((resolve, reject) => { const parsed = new URL(url); const body = JSON.stringify(payload); const req = http.request( { hostname: parsed.hostname, port: parseInt(parsed.port, 10), path: parsed.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), }, timeout: timeoutMs, }, (res) => { let data = ''; let totalBytes = 0; res.on('data', (c: Buffer) => { totalBytes += c.length; if (totalBytes <= MAX_RESPONSE_BYTES) data += c.toString(); }); res.on('end', () => { if (totalBytes > MAX_RESPONSE_BYTES) { reject(new Error(`Bridge response too large (${totalBytes} bytes)`)); return; } try { const json = JSON.parse(data); if (res.statusCode && res.statusCode >= 400) { reject(new Error(`Bridge HTTP ${res.statusCode}: ${data.slice(0, 200)}`)); } else { resolve(json as T); } } catch (e) { reject(e); } }); }, ); req.on('timeout', () => { req.destroy(); reject(new Error('Bridge request timeout')); }); req.on('error', reject); req.write(body); req.end(); }); } }