From 3ca3982c28fc56a07894518a017de53989d69704 Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 8 Mar 2026 13:53:08 -0700 Subject: [PATCH] =?UTF-8?q?fix(dingtalk):=20correct=20greeting=20flow=20?= =?UTF-8?q?=E2=80=94=20use=20userId=20(staffId)=20for=20batchSend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: sendGreeting() was passing openId as `userIds` to batchSend, but the API requires the enterprise staffId (userId). This caused HTTP 400 "staffId.notExisted" for every OAuth-bound greeting. Fix: 1. completeOAuthBinding now resolves unionId → userId via oapi.dingtalk.com/topapi/user/getbyunionid with corp app token. Non-fatal: if the user has no enterprise context, greeting is skipped with a clear log explaining why (no Contact.User.Read permission or user is not an enterprise member). 2. sendGreeting accepts userId (staffId) and openId separately; uses the correct staffId for batchSend. If userId is undefined, emits a WARN and skips (user gets greeting on first message instead). 3. routeToAgent now tries senderStaffId as fallback if senderId lookup misses — handles edge cases where DingTalk delivers staffId in senderId. 4. Added detailed logging: all three IDs (openId, unionId, userId) are logged at binding time so future issues are immediately diagnosable. Co-Authored-By: Claude Sonnet 4.6 --- .../dingtalk/dingtalk-router.service.ts | 86 ++++++++++++++++--- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts b/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts index df006dd..50b7c06 100644 --- a/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts +++ b/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts @@ -191,8 +191,16 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { } /** - * Complete the OAuth flow: exchange auth code → get user openId → save to DB. + * 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); @@ -209,24 +217,53 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { { clientId: this.clientId, clientSecret: this.clientSecret, code, grantType: 'authorization_code' }, ); - // Get user's openId — this is the same as senderId in bot messages (per-app ID) + // 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 dingTalkUserId = userInfo.openId; - if (!dingTalkUserId) throw new Error('无法获取钉钉用户身份,请重试'); + 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('智能体实例不存在'); - instance.dingTalkUserId = dingTalkUserId; + // Store openId — this matches senderId in incoming bot messages (used for routing) + instance.dingTalkUserId = openId; await this.instanceRepo.save(instance); - this.logger.log(`OAuth binding: instance ${entry.instanceId} → DingTalk openId ${dingTalkUserId}`); + this.logger.log(`OAuth binding saved: instance ${entry.instanceId} → dingTalkUserId(openId)=${openId}`); - // Send a proactive greeting so the user knows the binding succeeded - this.sendGreeting(dingTalkUserId, instance.name).catch((e: Error) => + // 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}`), ); @@ -236,25 +273,42 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { /** * 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(openId: string, agentName: string): Promise { + 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: [openId], + 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 ─────────────────────────────────────────────────────── @@ -441,6 +495,9 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { 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() ?? ''; @@ -507,8 +564,15 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { // ── Message routing ──────────────────────────────────────────────────────── private async routeToAgent(userId: string, text: string, msg: BotMsg): Promise { - const instance = await this.instanceRepo.findByDingTalkUserId(userId); + // 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); + } if (!instance) { + this.logger.warn(`No binding found for senderId=${userId} or senderStaffId=${msg.senderStaffId ?? 'none'}`); this.reply( msg, '你还没有绑定小龙虾。\n\n请在 IT0 App 中创建一只小龙虾,然后点击「绑定钉钉」获取验证码。',