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:
parent
e905559c46
commit
5a66f85235
|
|
@ -27,6 +27,8 @@
|
||||||
* - Per-user queue: promise chain guaranteed to always resolve (no dead-tail bug)
|
* - Per-user queue: promise chain guaranteed to always resolve (no dead-tail bug)
|
||||||
* - sessionWebhookExpiredTime unit auto-detected (seconds or ms)
|
* - sessionWebhookExpiredTime unit auto-detected (seconds or ms)
|
||||||
* - DingTalk API response capped at 256 KB (prevents memory spike on bad response)
|
* - 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)
|
* - Periodic cleanup for all in-memory maps (5 min interval)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -572,15 +574,26 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
|
||||||
// ── Message routing ────────────────────────────────────────────────────────
|
// ── Message routing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async routeToAgent(userId: string, text: string, msg: BotMsg): Promise<void> {
|
private async routeToAgent(userId: string, text: string, msg: BotMsg): Promise<void> {
|
||||||
// Primary lookup by senderId (openId). If not found, also try senderStaffId
|
// Two binding paths store different DingTalk ID types:
|
||||||
// as a fallback in case DingTalk delivers the message with staffId in senderId.
|
// OAuth binding → stores staffId (resolved via unionId→userId at auth time)
|
||||||
let instance = await this.instanceRepo.findByDingTalkUserId(userId);
|
// Code binding → stores senderId ($:LWCP_v1:$... format from bot message)
|
||||||
if (!instance && msg.senderStaffId && msg.senderStaffId !== userId) {
|
//
|
||||||
this.logger.log(`Primary lookup (senderId=${userId}) not found, trying senderStaffId=${msg.senderStaffId}`);
|
// DingTalk's Stream API senderId ($:LWCP_v1:$...) is NOT the same as the OAuth
|
||||||
instance = await this.instanceRepo.findByDingTalkUserId(msg.senderStaffId);
|
// 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) {
|
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(
|
this.reply(
|
||||||
msg,
|
msg,
|
||||||
'你还没有绑定小龙虾。\n\n请在 IT0 App 中创建一只小龙虾,然后点击「绑定钉钉」获取验证码。',
|
'你还没有绑定小龙虾。\n\n请在 IT0 App 中创建一只小龙虾,然后点击「绑定钉钉」获取验证码。',
|
||||||
|
|
@ -841,8 +854,16 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
|
||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
let data = '';
|
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', () => {
|
res.on('end', () => {
|
||||||
|
if (totalBytes > MAX_RESPONSE_BYTES) {
|
||||||
|
reject(new Error(`Bridge response too large (${totalBytes} bytes)`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(data);
|
const json = JSON.parse(data);
|
||||||
if (res.statusCode && res.statusCode >= 400) {
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue