fix(dingtalk): senderStaffId-first routing + bridge response size cap

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 Stream API senderId != OAuth openId (different encodings), so
primary lookup by senderId always missed OAuth-bound instances, requiring
a fallback every time. Reverse the lookup order: try senderStaffId first
(direct hit for OAuth binding), fall back to senderId (code binding).

Also add MAX_RESPONSE_BYTES cap to httpPostJson — previously uncapped
unlike the DingTalk API helpers which already had the 256KB guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-08 22:48:03 -07:00
parent e905559c46
commit 5a66f85235
1 changed files with 29 additions and 8 deletions

View File

@ -27,6 +27,8 @@
* - 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
* - Periodic cleanup for all in-memory maps (5 min interval)
*/
@ -572,15 +574,26 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
// ── Message routing ────────────────────────────────────────────────────────
private async routeToAgent(userId: string, text: string, msg: BotMsg): Promise<void> {
// Primary lookup by senderId (openId). If not found, also try senderStaffId
// as a fallback in case DingTalk delivers the message with staffId in senderId.
let instance = await this.instanceRepo.findByDingTalkUserId(userId);
if (!instance && msg.senderStaffId && msg.senderStaffId !== userId) {
this.logger.log(`Primary lookup (senderId=${userId}) not found, trying senderStaffId=${msg.senderStaffId}`);
instance = await this.instanceRepo.findByDingTalkUserId(msg.senderStaffId);
// 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 senderId=${userId} or senderStaffId=${msg.senderStaffId ?? 'none'}`);
this.logger.warn(`No binding found for senderStaffId=${staffId ?? 'none'} or senderId=${userId}`);
this.reply(
msg,
'你还没有绑定小龙虾。\n\n请在 IT0 App 中创建一只小龙虾,然后点击「绑定钉钉」获取验证码。',
@ -841,8 +854,16 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
},
(res) => {
let data = '';
res.on('data', (c: Buffer) => (data += c.toString()));
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) {