fix(dingtalk): correct greeting flow — use userId (staffId) for batchSend

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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-08 13:53:08 -07:00
parent 13f2d68754
commit 3ca3982c28
1 changed files with 75 additions and 11 deletions

View File

@ -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<void> {
private async sendGreeting(userId: string | undefined, openId: string, agentName: string): Promise<void> {
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<unknown>(
'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<void> {
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 中创建一只小龙虾,然后点击「绑定钉钉」获取验证码。',