feat(channels): add reaction feedback and webhook caching for DingTalk

- Cache sessionWebhook by conversationId for reliable message routing
- Show 👀 reaction while processing messages, then recall it
- Use conversationId as chatId instead of webhook URL
- Fix rawData parsing for already-parsed message data

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-03-26 08:29:27 +00:00
parent 92c54ff309
commit 217964b849

View file

@ -14,10 +14,17 @@ import type {
/** Track seen msgIds to deduplicate retried callbacks. */
const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
const ACK_REACTION_NAME = '👀';
const ACK_EMOTION_ID = '2659900';
const ACK_EMOTION_BG_ID = 'im_bg_1';
const EMOTION_API = 'https://api.dingtalk.com/v1.0/robot/emotion';
export class DingtalkChannel extends ChannelBase {
private client: DWClient;
private seenMessages: Map<string, number> = new Map();
private dedupTimer?: ReturnType<typeof setInterval>;
/** Map conversationId → latest sessionWebhook URL for sending replies. */
private webhooks: Map<string, string> = new Map();
constructor(
name: string,
@ -68,7 +75,15 @@ export class DingtalkChannel extends ChannelBase {
}
async sendMessage(chatId: string, text: string): Promise<void> {
// chatId is the sessionWebhook URL for DingTalk
// chatId is a conversationId — resolve to the latest sessionWebhook
const webhook = this.webhooks.get(chatId);
if (!webhook) {
process.stderr.write(
`[DingTalk:${this.name}] No webhook for chatId ${chatId}, cannot send.\n`,
);
return;
}
const body = {
msgtype: 'markdown',
markdown: {
@ -77,7 +92,7 @@ export class DingtalkChannel extends ChannelBase {
},
};
const resp = await fetch(chatId, {
const resp = await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@ -91,6 +106,67 @@ export class DingtalkChannel extends ChannelBase {
}
}
private getAccessToken(): string | undefined {
return this.client.getConfig().access_token;
}
private async emotionApi(
endpoint: 'reply' | 'recall',
msgId: string,
conversationId: string,
): Promise<void> {
const token = this.getAccessToken();
if (!token) return;
const robotCode = this.config.clientId;
if (!robotCode || !msgId || !conversationId) return;
try {
const resp = await fetch(`${EMOTION_API}/${endpoint}`, {
method: 'POST',
headers: {
'x-acs-dingtalk-access-token': token,
'Content-Type': 'application/json',
},
body: JSON.stringify({
robotCode,
openMsgId: msgId,
openConversationId: conversationId,
emotionType: 2,
emotionName: ACK_REACTION_NAME,
textEmotion: {
emotionId: ACK_EMOTION_ID,
emotionName: ACK_REACTION_NAME,
text: ACK_REACTION_NAME,
backgroundId: ACK_EMOTION_BG_ID,
},
}),
});
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
process.stderr.write(
`[DingTalk:${this.name}] emotion/${endpoint} failed: ${resp.status} ${detail}\n`,
);
}
} catch {
// best-effort, don't break message flow
}
}
private async attachReaction(
msgId: string,
conversationId: string,
): Promise<void> {
await this.emotionApi('reply', msgId, conversationId);
}
private async recallReaction(
msgId: string,
conversationId: string,
): Promise<void> {
await this.emotionApi('recall', msgId, conversationId);
}
disconnect(): void {
if (this.dedupTimer) {
clearInterval(this.dedupTimer);
@ -118,6 +194,7 @@ export class DingtalkChannel extends ChannelBase {
const isGroup = data.conversationType === '2';
const text = data.text?.content?.trim() || '';
const sessionWebhook = data.sessionWebhook;
const conversationId = data.conversationId;
if (!sessionWebhook) {
process.stderr.write(
@ -126,36 +203,61 @@ export class DingtalkChannel extends ChannelBase {
return;
}
// Cache webhook by conversationId so sendMessage can look it up
if (conversationId) {
this.webhooks.set(conversationId, sessionWebhook);
}
// In group chats, check isInAtList from the raw data
const rawData = JSON.parse(downstream.data);
const rawData =
typeof downstream.data === 'string'
? JSON.parse(downstream.data)
: downstream.data;
const isMentioned = Boolean(rawData.isInAtList);
// Strip @bot mention from text
let cleanText = text;
if (isMentioned && data.senderNick) {
// DingTalk prepends the @mention text; remove it
if (isMentioned) {
cleanText = text.replace(/@\S+/g, '').trim();
}
const chatId = conversationId || sessionWebhook;
const envelope: Envelope = {
channelName: this.name,
senderId: data.senderId || data.senderStaffId,
senderName: data.senderNick || 'Unknown',
chatId: sessionWebhook, // Use webhook URL as chatId for sendMessage
chatId,
text: cleanText || text,
isGroup,
isMentioned,
isReplyToBot: false,
};
// Attach 👀 reaction, process message, then recall reaction
const reactionMsgId = msgId;
const reactionConvId = conversationId;
const processMessage = async () => {
if (reactionMsgId && reactionConvId) {
this.attachReaction(reactionMsgId, reactionConvId).catch(() => {});
}
try {
await this.handleInbound(envelope);
} finally {
if (reactionMsgId && reactionConvId) {
this.recallReaction(reactionMsgId, reactionConvId).catch(() => {});
}
}
};
// Don't await — stream callback should return quickly
this.handleInbound(envelope).catch((err) => {
processMessage().catch((err) => {
process.stderr.write(
`[DingTalk:${this.name}] Error handling message: ${err}\n`,
);
// Try to send error reply
this.sendMessage(
sessionWebhook,
chatId,
'Sorry, something went wrong processing your message.',
).catch(() => {});
});