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:
parent
13f2d68754
commit
3ca3982c28
|
|
@ -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).
|
* 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 }> {
|
async completeOAuthBinding(code: string, state: string): Promise<{ instanceId: string; instanceName: string }> {
|
||||||
const entry = this.oauthStates.get(state);
|
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' },
|
{ 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 }>(
|
const userInfo = await this.httpsGet<{ openId: string; unionId: string }>(
|
||||||
'api.dingtalk.com', '/v1.0/contact/users/me',
|
'api.dingtalk.com', '/v1.0/contact/users/me',
|
||||||
{ 'x-acs-dingtalk-access-token': tokenResult.accessToken },
|
{ 'x-acs-dingtalk-access-token': tokenResult.accessToken },
|
||||||
);
|
);
|
||||||
|
|
||||||
const dingTalkUserId = userInfo.openId;
|
const openId = userInfo.openId;
|
||||||
if (!dingTalkUserId) throw new Error('无法获取钉钉用户身份,请重试');
|
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);
|
const instance = await this.instanceRepo.findById(entry.instanceId);
|
||||||
if (!instance) throw new Error('智能体实例不存在');
|
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);
|
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
|
// Send proactive greeting using userId (staffId). Skip if not resolved.
|
||||||
this.sendGreeting(dingTalkUserId, instance.name).catch((e: Error) =>
|
this.sendGreeting(userId, openId, instance.name).catch((e: Error) =>
|
||||||
this.logger.warn(`Greeting send failed (non-fatal): ${e.message}`),
|
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.
|
* Send a proactive bot message to a DingTalk user via the batchSend API.
|
||||||
* Called after OAuth binding completes to greet the user.
|
* 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 token = await this.getToken();
|
||||||
const greeting =
|
const greeting =
|
||||||
`👋 你好!我是你的 AI 智能体助手「${agentName}」。\n\n` +
|
`👋 你好!我是你的 AI 智能体助手「${agentName}」。\n\n` +
|
||||||
`从现在起,你可以直接在这里向我发送指令,我会自主地帮你完成工作任务。\n\n` +
|
`从现在起,你可以直接在这里向我发送指令,我会自主地帮你完成工作任务。\n\n` +
|
||||||
`例如:\n• 查询服务器状态\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>(
|
await this.httpsPost<unknown>(
|
||||||
'api.dingtalk.com',
|
'api.dingtalk.com',
|
||||||
'/v1.0/robot/oToMessages/batchSend',
|
'/v1.0/robot/oToMessages/batchSend',
|
||||||
{
|
{
|
||||||
robotCode: this.clientId,
|
robotCode: this.clientId,
|
||||||
userIds: [openId],
|
userIds: [userId],
|
||||||
msgKey: 'sampleText',
|
msgKey: 'sampleText',
|
||||||
msgParam: JSON.stringify({ content: greeting }),
|
msgParam: JSON.stringify({ content: greeting }),
|
||||||
},
|
},
|
||||||
{ 'x-acs-dingtalk-access-token': token },
|
{ 'x-acs-dingtalk-access-token': token },
|
||||||
);
|
);
|
||||||
|
this.logger.log(`Greeting sent successfully to userId=${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Token management ───────────────────────────────────────────────────────
|
// ── Token management ───────────────────────────────────────────────────────
|
||||||
|
|
@ -441,6 +495,9 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
|
||||||
this.logger.warn('Received message with no sender ID, ignoring');
|
this.logger.warn('Received message with no sender ID, ignoring');
|
||||||
return;
|
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.)
|
// Non-text message types (image, file, richText, audio, video, @mention, etc.)
|
||||||
const text = msg.text?.content?.trim() ?? '';
|
const text = msg.text?.content?.trim() ?? '';
|
||||||
|
|
@ -507,8 +564,15 @@ 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> {
|
||||||
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) {
|
if (!instance) {
|
||||||
|
this.logger.warn(`No binding found for senderId=${userId} or senderStaffId=${msg.senderStaffId ?? 'none'}`);
|
||||||
this.reply(
|
this.reply(
|
||||||
msg,
|
msg,
|
||||||
'你还没有绑定小龙虾。\n\n请在 IT0 App 中创建一只小龙虾,然后点击「绑定钉钉」获取验证码。',
|
'你还没有绑定小龙虾。\n\n请在 IT0 App 中创建一只小龙虾,然后点击「绑定钉钉」获取验证码。',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue