mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 00:00:46 +08:00
feat: add status reactions across messaging channels
This commit is contained in:
parent
aa8fd71b93
commit
da01e3acee
30 changed files with 1853 additions and 274 deletions
|
|
@ -73,7 +73,7 @@ After the model calls the file-return tool, the plugin hands the specified file
|
|||
| DingTalk | The app needs `qyapi_base`, and the bot must support file messages. The current OAPI and bot capability determine the accepted formats and sizes. |
|
||||
| WeCom | The app needs media-upload and file-message capability; the WeCom API response determines the actual range. |
|
||||
| QQ | The bot needs file-message capability and remains subject to QQ's daily upload quota; the bot reports when the quota is exhausted. |
|
||||
| Slack | The Bot Token needs `files:write`; the Workspace's current policy determines the actual size limit. After changing scopes, re-authorize/reinstall the App and reconnect the bot. |
|
||||
| Slack | The Bot Token needs `files:read`, `files:write`, and `reactions:write`; the Workspace's current policy determines the actual file-size limit. After changing scopes, re-authorize/reinstall the App and reconnect the bot. |
|
||||
| Telegram | The bot must be allowed to send documents in the current chat; the Bot API response determines the actual range. |
|
||||
| Discord | Enable **Message Content Intent** in the Developer Portal. The bot needs **Send Messages**, **Create Public Threads**, **Send Messages in Threads**, and **Read Message History**; result-file delivery also requires **Attach Files**. The current account and server capability determine the actual attachment allowance. |
|
||||
| WhatsApp | The linked session must support Document Messages; the WhatsApp/Baileys response determines the actual range. |
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
|
|||
| 钉钉 | 应用需开通 `qyapi_base`,机器人需具备文件消息能力;实际格式和大小以当前 OAPI 与机器人能力返回为准。 |
|
||||
| 企业微信 | 应用需具备素材上传和文件消息能力,实际可发送范围以企业微信接口返回为准。 |
|
||||
| QQ | 机器人需具备文件消息能力,并受 QQ 当日文件上传配额约束;额度耗尽时会明确提示稍后重试。 |
|
||||
| Slack | Bot Token 需有 `files:write`;实际大小上限由 Workspace 当前策略决定。已有 App 新增或变更 Scope 后,必须重新授权/安装 App 并重新连接机器人。 |
|
||||
| Slack | Bot Token 需有 `files:read`、`files:write` 和 `reactions:write`;实际文件大小上限由 Workspace 当前策略决定。已有 App 新增或变更 Scope 后,必须重新授权/安装 App 并重新连接机器人。 |
|
||||
| Telegram | 机器人必须能在当前聊天发送文档,实际可发送范围以 Bot API 返回为准。 |
|
||||
| Discord | Developer Portal 的 Bot 设置中需启用 **Message Content Intent**;机器人需有 **Send Messages**、**Create Public Threads**、**Send Messages in Threads** 和 **Read Message History** 权限;发送结果文件还需 **Attach Files**。实际附件额度由当前账号与服务器能力决定。 |
|
||||
| WhatsApp | 当前绑定会话需支持 Document Message,实际可发送范围以 WhatsApp/Baileys 返回为准。 |
|
||||
|
|
|
|||
|
|
@ -7062,6 +7062,7 @@ oauth_config:
|
|||
- files:read
|
||||
- files:write
|
||||
- im:history
|
||||
- reactions:write
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
|
|
|
|||
398
lib/index.js
398
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,14 @@ export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
|
|||
export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
|
||||
export const DINGTALK_REGISTRATION_SOURCE = 'DING_DWS_CLAW';
|
||||
export const DINGTALK_AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema';
|
||||
export const DINGTALK_THINKING_REACTION_NAME = '🤔思考中';
|
||||
export const DINGTALK_DONE_REACTION_NAME = '✅已完成';
|
||||
export const DINGTALK_ERROR_REACTION_NAME = '❌处理失败';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const REACTION_TIMEOUT_MS = 5_000;
|
||||
const TEXT_REACTION_ID = '2659900';
|
||||
const TEXT_REACTION_BACKGROUND_ID = 'im_bg_1';
|
||||
const REGISTRATION_STATUSES = new Set(['WAITING', 'SUCCESS', 'FAIL', 'EXPIRED']);
|
||||
|
||||
export class DingtalkApiError extends Error {
|
||||
|
|
@ -406,18 +412,20 @@ export function createDingtalkApi({
|
|||
if (!source) throw new TypeError('registrationSource is required');
|
||||
const tokenCache = new Map();
|
||||
const tokenRequests = new Map();
|
||||
const reactionTokenRequests = new Map();
|
||||
let cardSlotTail = Promise.resolve();
|
||||
let nextCardRequestAt = 0;
|
||||
|
||||
const endpoint = (base, pathname) => new URL(pathname.replace(/^\//, ''), base);
|
||||
|
||||
async function accessToken({ clientId, clientSecret, signal }) {
|
||||
async function accessToken({ clientId, clientSecret, signal, requestKind = 'normal' }) {
|
||||
const appKey = nonEmptyString(clientId);
|
||||
const appSecret = nonEmptyString(clientSecret);
|
||||
if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
|
||||
const cached = tokenCache.get(appKey);
|
||||
if (cached && cached.expiresAt > now()) return cached.token;
|
||||
if (tokenRequests.has(appKey)) return tokenRequests.get(appKey);
|
||||
const requests = requestKind === 'reaction' ? reactionTokenRequests : tokenRequests;
|
||||
if (requests.has(appKey)) return requests.get(appKey);
|
||||
|
||||
const request = (async () => {
|
||||
const value = await requestJson(fetchImpl, endpoint(apiBase, 'v1.0/oauth2/accessToken'), {
|
||||
|
|
@ -431,9 +439,75 @@ export function createDingtalkApi({
|
|||
const refreshAfterMs = Math.max(1_000, (expiresInSeconds - 60) * 1_000);
|
||||
tokenCache.set(appKey, { token, expiresAt: now() + refreshAfterMs });
|
||||
return token;
|
||||
})().finally(() => tokenRequests.delete(appKey));
|
||||
tokenRequests.set(appKey, request);
|
||||
return request;
|
||||
})();
|
||||
const shared = request.finally(() => requests.delete(appKey));
|
||||
requests.set(appKey, shared);
|
||||
return shared;
|
||||
}
|
||||
|
||||
async function changeReaction({
|
||||
clientId,
|
||||
clientSecret,
|
||||
robotCode,
|
||||
messageId,
|
||||
conversationId,
|
||||
reactionName = DINGTALK_THINKING_REACTION_NAME,
|
||||
signal,
|
||||
}, action) {
|
||||
const appKey = nonEmptyString(clientId);
|
||||
const appSecret = nonEmptyString(clientSecret);
|
||||
const botCode = nonEmptyString(robotCode) ?? appKey;
|
||||
const openMsgId = nonEmptyString(messageId);
|
||||
const openConversationId = nonEmptyString(conversationId);
|
||||
const emotionName = nonEmptyString(reactionName);
|
||||
if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
|
||||
if (!botCode) throw new TypeError('robotCode is required');
|
||||
if (!openMsgId || !openConversationId) {
|
||||
throw new TypeError('messageId and conversationId are required');
|
||||
}
|
||||
if (!emotionName) throw new TypeError('reactionName is required');
|
||||
// Reactions deduplicate with each other but never own the normal reply's
|
||||
// in-flight token request. Both paths may still reuse a cached token.
|
||||
const token = await accessToken({
|
||||
clientId: appKey,
|
||||
clientSecret: appSecret,
|
||||
signal,
|
||||
requestKind: 'reaction',
|
||||
});
|
||||
const response = await requestJson(
|
||||
fetchImpl,
|
||||
endpoint(apiBase, `v1.0/robot/emotion/${action}`),
|
||||
{
|
||||
body: {
|
||||
robotCode: botCode,
|
||||
openMsgId,
|
||||
openConversationId,
|
||||
emotionType: 2,
|
||||
emotionName,
|
||||
textEmotion: {
|
||||
emotionId: TEXT_REACTION_ID,
|
||||
emotionName,
|
||||
text: emotionName,
|
||||
backgroundId: TEXT_REACTION_BACKGROUND_ID,
|
||||
},
|
||||
},
|
||||
headers: { 'x-acs-dingtalk-access-token': token },
|
||||
signal,
|
||||
timeoutMs: REACTION_TIMEOUT_MS,
|
||||
action: action === 'reply' ? '消息状态添加' : '消息状态撤回',
|
||||
},
|
||||
);
|
||||
const rejection = response?.success === false
|
||||
? safeProviderCode(response?.code ?? response?.errcode) ?? 'rejected'
|
||||
: rejectedProviderResponse(response);
|
||||
if (rejection) {
|
||||
throw new DingtalkApiError(
|
||||
'reaction-rejected',
|
||||
'钉钉服务拒绝了消息状态请求。',
|
||||
{ providerCode: rejection },
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function messageFileDownloadUrl({
|
||||
|
|
@ -690,6 +764,28 @@ export function createDingtalkApi({
|
|||
|
||||
accessToken,
|
||||
|
||||
async addReaction(request) {
|
||||
return changeReaction(request, 'reply');
|
||||
},
|
||||
|
||||
async recallReaction(request) {
|
||||
return changeReaction(request, 'recall');
|
||||
},
|
||||
|
||||
async addThinkingReaction(request) {
|
||||
return changeReaction({
|
||||
...request,
|
||||
reactionName: DINGTALK_THINKING_REACTION_NAME,
|
||||
}, 'reply');
|
||||
},
|
||||
|
||||
async recallThinkingReaction(request) {
|
||||
return changeReaction({
|
||||
...request,
|
||||
reactionName: DINGTALK_THINKING_REACTION_NAME,
|
||||
}, 'recall');
|
||||
},
|
||||
|
||||
async downloadImage({
|
||||
clientId,
|
||||
clientSecret,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import {
|
||||
DINGTALK_DONE_REACTION_NAME,
|
||||
DINGTALK_ERROR_REACTION_NAME,
|
||||
DINGTALK_THINKING_REACTION_NAME,
|
||||
normalizeDingtalkSessionWebhook,
|
||||
splitDingtalkText,
|
||||
} from './dingtalk-api.mjs';
|
||||
|
|
@ -309,7 +312,15 @@ function canClaimInteractionReply(message, pending, sender) {
|
|||
|
||||
function ensureStats(status) {
|
||||
status.stats ??= {};
|
||||
for (const key of ['messagesReceived', 'messagesReplied', 'messagesRejected', 'messagesIgnored']) {
|
||||
for (const key of [
|
||||
'messagesReceived',
|
||||
'messagesReplied',
|
||||
'messagesRejected',
|
||||
'messagesIgnored',
|
||||
'reactionsAdded',
|
||||
'reactionsRemoved',
|
||||
'reactionErrors',
|
||||
]) {
|
||||
status[key] ??= 0;
|
||||
status.stats[key] = status[key];
|
||||
}
|
||||
|
|
@ -328,6 +339,9 @@ export function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) {
|
|||
messagesReplied: 0,
|
||||
messagesRejected: 0,
|
||||
messagesIgnored: 0,
|
||||
reactionsAdded: 0,
|
||||
reactionsRemoved: 0,
|
||||
reactionErrors: 0,
|
||||
lastMessageAt: null,
|
||||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
|
|
@ -339,6 +353,9 @@ export function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) {
|
|||
messagesReplied: 0,
|
||||
messagesRejected: 0,
|
||||
messagesIgnored: 0,
|
||||
reactionsAdded: 0,
|
||||
reactionsRemoved: 0,
|
||||
reactionErrors: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -352,6 +369,7 @@ export class DingtalkHarnessBridge {
|
|||
#status;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#reactionTimeoutMs;
|
||||
#maxMessageChars;
|
||||
#signal;
|
||||
#queues = new Map();
|
||||
|
|
@ -372,6 +390,7 @@ export class DingtalkHarnessBridge {
|
|||
status = createDingtalkBridgeStatus(),
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
reactionTimeoutMs = 5_000,
|
||||
maxMessageChars = 4_000,
|
||||
signal,
|
||||
}) {
|
||||
|
|
@ -389,6 +408,9 @@ export class DingtalkHarnessBridge {
|
|||
this.#logger = logger;
|
||||
this.#approvals = new HarnessApprovalQueue({ label: 'DingTalk', logger });
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#reactionTimeoutMs = Number.isFinite(reactionTimeoutMs) && reactionTimeoutMs > 0
|
||||
? Math.floor(reactionTimeoutMs)
|
||||
: 5_000;
|
||||
this.#maxMessageChars = maxMessageChars;
|
||||
this.#signal = signal;
|
||||
ensureStats(this.#status);
|
||||
|
|
@ -436,15 +458,35 @@ export class DingtalkHarnessBridge {
|
|||
const commandText = nonEmptyString(promptMessage.content) ?? '';
|
||||
const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
|
||||
const direct = String(message.conversationType) !== '2';
|
||||
const statusReaction = sessionWebhook && addressed ? this.#startStatusReaction(message) : null;
|
||||
const finish = (task) => Promise.resolve(task).then(
|
||||
(value) => {
|
||||
this.#finishStatusReaction(
|
||||
statusReaction,
|
||||
this.#signal?.aborted ? 'clear' : 'success',
|
||||
);
|
||||
return value;
|
||||
},
|
||||
(error) => {
|
||||
this.#finishStatusReaction(
|
||||
statusReaction,
|
||||
this.#signal?.aborted || error?.name === 'AbortError' || error?.code === 'turn-stopped'
|
||||
? 'clear'
|
||||
: 'error',
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
const batchCommand = String(message?.msgtype).toLowerCase() === 'text'
|
||||
&& isBatchInputCommand(commandText);
|
||||
const batchStatus = this.#batchInputs.status(key);
|
||||
if (batchCommand && !direct && sessionWebhook && addressed) {
|
||||
return this.#finishBatchResult(
|
||||
return finish(this.#finishBatchResult(
|
||||
messageId,
|
||||
sessionWebhook,
|
||||
{ message: batchInputGroupUnsupportedMessage() },
|
||||
);
|
||||
statusReaction,
|
||||
));
|
||||
}
|
||||
if (direct && sessionWebhook && (batchCommand || batchStatus.phase === 'collecting')) {
|
||||
const exactBatchStart = /^\/batch$/iu.test(commandText);
|
||||
|
|
@ -460,7 +502,7 @@ export class DingtalkHarnessBridge {
|
|||
});
|
||||
if (result.handled) {
|
||||
if (result.kind === 'submit') {
|
||||
return this.#enqueueMessage(
|
||||
return finish(this.#enqueueMessage(
|
||||
{
|
||||
...message,
|
||||
msgtype: 'text',
|
||||
|
|
@ -469,10 +511,15 @@ export class DingtalkHarnessBridge {
|
|||
messageId,
|
||||
sender,
|
||||
key,
|
||||
{ batchSubmission: result },
|
||||
);
|
||||
{ batchSubmission: result, statusReaction },
|
||||
));
|
||||
}
|
||||
return this.#finishBatchResult(messageId, sessionWebhook, result);
|
||||
return finish(this.#finishBatchResult(
|
||||
messageId,
|
||||
sessionWebhook,
|
||||
result,
|
||||
statusReaction,
|
||||
));
|
||||
}
|
||||
}
|
||||
const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
|
||||
|
|
@ -490,7 +537,11 @@ export class DingtalkHarnessBridge {
|
|||
promptMessage,
|
||||
commandRunner,
|
||||
).catch((error) => {
|
||||
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
|
||||
if (error?.code === 'turn-stopped' || this.#signal?.aborted) {
|
||||
this.#finishStatusReaction(statusReaction, 'clear');
|
||||
return;
|
||||
}
|
||||
this.#finishStatusReaction(statusReaction, 'error');
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
|
|
@ -503,7 +554,7 @@ export class DingtalkHarnessBridge {
|
|||
this.#commandTasks.delete(task);
|
||||
});
|
||||
this.#commandTasks.add(task);
|
||||
return task;
|
||||
return finish(task);
|
||||
}
|
||||
const approvalReply = this.#approvals.claimReply({
|
||||
key,
|
||||
|
|
@ -537,7 +588,11 @@ export class DingtalkHarnessBridge {
|
|||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
if (this.#signal?.aborted) {
|
||||
this.#finishStatusReaction(statusReaction, 'clear');
|
||||
return;
|
||||
}
|
||||
this.#finishStatusReaction(statusReaction, 'error');
|
||||
this.#status.lastError = t('钉钉审批处理失败。');
|
||||
this.#logger.error?.('[dsh-dingtalk] failed to process an approval reply', error);
|
||||
})
|
||||
|
|
@ -546,18 +601,18 @@ export class DingtalkHarnessBridge {
|
|||
this.#interactionTasks.delete(current);
|
||||
});
|
||||
this.#interactionTasks.add(current);
|
||||
return current;
|
||||
return finish(current);
|
||||
}
|
||||
|
||||
if (pending && pending.actor !== sender) {
|
||||
return this.#enqueueMessage(message, messageId, sender, key);
|
||||
return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
|
||||
}
|
||||
// Once one valid answer has been claimed, later messages are subsequent
|
||||
// prompts even if the network submission eventually needs a retry. Invalid
|
||||
// replies do not claim the question, so the next valid answer can still
|
||||
// pass through this interaction queue.
|
||||
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
||||
return this.#enqueueMessage(message, messageId, sender, key);
|
||||
return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
|
||||
}
|
||||
if (pending) {
|
||||
if (canClaimInteractionReply(message, pending, sender)) {
|
||||
|
|
@ -566,7 +621,14 @@ export class DingtalkHarnessBridge {
|
|||
const previous = pending.queue ?? Promise.resolve();
|
||||
const current = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.#processInteractionReply(message, messageId, sender, key, pending))
|
||||
.then(() => this.#processInteractionReply(
|
||||
message,
|
||||
messageId,
|
||||
sender,
|
||||
key,
|
||||
pending,
|
||||
statusReaction,
|
||||
))
|
||||
.finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
if (pending.claimedReplyMessageId === messageId) {
|
||||
|
|
@ -575,15 +637,101 @@ export class DingtalkHarnessBridge {
|
|||
if (pending.queue === current) pending.queue = null;
|
||||
});
|
||||
pending.queue = current;
|
||||
return current;
|
||||
return finish(current);
|
||||
}
|
||||
return this.#enqueueMessage(message, messageId, sender, key);
|
||||
return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
|
||||
}
|
||||
|
||||
#runReactionCall(method, target, reactionName, kind) {
|
||||
const controller = new AbortController();
|
||||
const operation = Promise.resolve().then(() => this.#api[method]({
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
...target,
|
||||
reactionName,
|
||||
signal: controller.signal,
|
||||
}));
|
||||
let timer;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
const error = new DOMException('DingTalk reaction timed out', 'TimeoutError');
|
||||
controller.abort(error);
|
||||
reject(error);
|
||||
}, this.#reactionTimeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
return Promise.race([operation, timeout])
|
||||
.then(() => {
|
||||
increment(this.#status, kind === 'add' ? 'reactionsAdded' : 'reactionsRemoved');
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
increment(this.#status, 'reactionErrors');
|
||||
this.#logger.debug?.(`[dsh-dingtalk] ${method} failed`, safeErrorDiagnostic(error));
|
||||
return false;
|
||||
})
|
||||
.finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
#startStatusReaction(message) {
|
||||
if (typeof this.#api.addReaction !== 'function'
|
||||
|| typeof this.#api.recallReaction !== 'function') return null;
|
||||
const messageId = nonEmptyString(message?.msgId);
|
||||
const conversationId = nonEmptyString(message?.conversationId);
|
||||
if (!messageId || !conversationId) return null;
|
||||
const target = {
|
||||
messageId,
|
||||
conversationId,
|
||||
robotCode: nonEmptyString(message?.robotCode) ?? this.#clientId,
|
||||
};
|
||||
return {
|
||||
target,
|
||||
attached: this.#runReactionCall(
|
||||
'addReaction',
|
||||
target,
|
||||
DINGTALK_THINKING_REACTION_NAME,
|
||||
'add',
|
||||
),
|
||||
terminal: false,
|
||||
};
|
||||
}
|
||||
|
||||
#finishStatusReaction(reaction, outcome) {
|
||||
if (!reaction || reaction.terminal) return;
|
||||
reaction.terminal = true;
|
||||
const terminalName = outcome === 'success'
|
||||
? DINGTALK_DONE_REACTION_NAME
|
||||
: outcome === 'error' ? DINGTALK_ERROR_REACTION_NAME : null;
|
||||
// Preserve attach -> recall -> terminal ordering without extending the message task.
|
||||
void reaction.attached.then(async (attached) => {
|
||||
let cleaned = await this.#runReactionCall(
|
||||
'recallReaction',
|
||||
reaction.target,
|
||||
DINGTALK_THINKING_REACTION_NAME,
|
||||
'remove',
|
||||
);
|
||||
if (!attached || !cleaned) {
|
||||
await new Promise((resolve) => {
|
||||
const retry = setTimeout(resolve, Math.min(1_000, this.#reactionTimeoutMs));
|
||||
retry.unref?.();
|
||||
});
|
||||
cleaned = await this.#runReactionCall(
|
||||
'recallReaction',
|
||||
reaction.target,
|
||||
DINGTALK_THINKING_REACTION_NAME,
|
||||
'remove',
|
||||
) || cleaned;
|
||||
}
|
||||
if (!cleaned || !terminalName || this.#signal?.aborted) return;
|
||||
await this.#runReactionCall('addReaction', reaction.target, terminalName, 'add');
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
#enqueueMessage(message, messageId, sender, key, {
|
||||
releaseMessageId = true,
|
||||
alreadyRecorded = false,
|
||||
batchSubmission = null,
|
||||
statusReaction = null,
|
||||
} = {}) {
|
||||
let hasSafeReplyRoute = false;
|
||||
try {
|
||||
|
|
@ -607,6 +755,7 @@ export class DingtalkHarnessBridge {
|
|||
alreadyRecorded,
|
||||
preparedMessage,
|
||||
batchSubmission,
|
||||
statusReaction,
|
||||
}))
|
||||
.finally(() => {
|
||||
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
||||
|
|
@ -659,7 +808,7 @@ export class DingtalkHarnessBridge {
|
|||
this.#status.lastError = null;
|
||||
}
|
||||
|
||||
#finishBatchResult(messageId, sessionWebhook, result) {
|
||||
#finishBatchResult(messageId, sessionWebhook, result, statusReaction) {
|
||||
let task;
|
||||
task = Promise.resolve().then(async () => {
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
|
|
@ -669,7 +818,11 @@ export class DingtalkHarnessBridge {
|
|||
if (result.message) await this.#send(sessionWebhook, result.message);
|
||||
this.#status.lastError = null;
|
||||
}).catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
if (this.#signal?.aborted) {
|
||||
this.#finishStatusReaction(statusReaction, 'clear');
|
||||
return;
|
||||
}
|
||||
this.#finishStatusReaction(statusReaction, 'error');
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
|
|
@ -689,6 +842,7 @@ export class DingtalkHarnessBridge {
|
|||
alreadyRecorded = false,
|
||||
preparedMessage,
|
||||
batchSubmission = null,
|
||||
statusReaction = null,
|
||||
} = {}) {
|
||||
this.#signal?.throwIfAborted();
|
||||
if (!alreadyRecorded) {
|
||||
|
|
@ -865,10 +1019,15 @@ export class DingtalkHarnessBridge {
|
|||
batchSettled = true;
|
||||
}
|
||||
if (error?.code === 'turn-stopped') {
|
||||
this.#finishStatusReaction(statusReaction, 'clear');
|
||||
if (cardStarted) await cardStream.finish(t('已停止。')).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (this.#signal?.aborted) return;
|
||||
if (this.#signal?.aborted) {
|
||||
this.#finishStatusReaction(statusReaction, 'clear');
|
||||
return;
|
||||
}
|
||||
this.#finishStatusReaction(statusReaction, 'error');
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const userMessage = inboundFileUserMessage(error)
|
||||
?? dingtalkImageErrorUserMessage(error);
|
||||
|
|
@ -896,7 +1055,14 @@ export class DingtalkHarnessBridge {
|
|||
}
|
||||
}
|
||||
|
||||
async #processInteractionReply(message, messageId, sender, key, expected) {
|
||||
async #processInteractionReply(
|
||||
message,
|
||||
messageId,
|
||||
sender,
|
||||
key,
|
||||
expected,
|
||||
statusReaction,
|
||||
) {
|
||||
this.#signal?.throwIfAborted();
|
||||
const current = this.#pendingInteractions.get(key);
|
||||
const claimed = expected.claimedReplyMessageId === messageId;
|
||||
|
|
@ -904,7 +1070,10 @@ export class DingtalkHarnessBridge {
|
|||
if (claimed && (!current || current !== expected)) {
|
||||
return this.#discardResolvedInteractionReply(message, messageId);
|
||||
}
|
||||
return this.#enqueueMessage(message, messageId, sender, key, { releaseMessageId: false });
|
||||
return this.#enqueueMessage(message, messageId, sender, key, {
|
||||
releaseMessageId: false,
|
||||
statusReaction,
|
||||
});
|
||||
}
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
await this.#state.markSeen(messageId);
|
||||
|
|
@ -949,6 +1118,7 @@ export class DingtalkHarnessBridge {
|
|||
return this.#enqueueMessage(message, messageId, sender, key, {
|
||||
releaseMessageId: false,
|
||||
alreadyRecorded: true,
|
||||
statusReaction,
|
||||
});
|
||||
}
|
||||
pending.sessionWebhook = sessionWebhook;
|
||||
|
|
@ -956,6 +1126,7 @@ export class DingtalkHarnessBridge {
|
|||
try {
|
||||
await this.#presentInteraction(pending);
|
||||
} catch {
|
||||
this.#finishStatusReaction(statusReaction, 'error');
|
||||
this.#status.lastError = t('钉钉交互问题发送失败。');
|
||||
this.#logger.error?.('[dsh-dingtalk] failed to retry an interaction question');
|
||||
pending.interaction.reconnect?.();
|
||||
|
|
@ -975,6 +1146,7 @@ export class DingtalkHarnessBridge {
|
|||
try {
|
||||
await this.#presentInteraction(pending);
|
||||
} catch {
|
||||
this.#finishStatusReaction(statusReaction, 'error');
|
||||
this.#status.lastError = t('钉钉交互问题发送失败。');
|
||||
this.#logger.error?.('[dsh-dingtalk] failed to send the next interaction question');
|
||||
pending.interaction.reconnect?.();
|
||||
|
|
@ -994,7 +1166,10 @@ export class DingtalkHarnessBridge {
|
|||
this.#clearPendingInteraction(key, pending.interactionId);
|
||||
this.#status.lastError = null;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) return;
|
||||
if (this.#signal?.aborted) {
|
||||
this.#finishStatusReaction(statusReaction, 'clear');
|
||||
return;
|
||||
}
|
||||
if (this.#pendingInteractions.get(key) !== pending) return;
|
||||
if (error?.code === 'interaction-not-pending') {
|
||||
this.#clearPendingInteraction(key, pending.interactionId);
|
||||
|
|
@ -1005,6 +1180,7 @@ export class DingtalkHarnessBridge {
|
|||
}
|
||||
return;
|
||||
}
|
||||
this.#finishStatusReaction(statusReaction, 'error');
|
||||
pending.submitting = false;
|
||||
pending.answers.pop();
|
||||
pending.index -= 1;
|
||||
|
|
|
|||
|
|
@ -88,6 +88,12 @@ function snowflake(value, name) {
|
|||
return id;
|
||||
}
|
||||
|
||||
function reactionEmoji(value) {
|
||||
const emoji = cleanString(value);
|
||||
if (!emoji) throw new TypeError('A Discord reaction emoji is required');
|
||||
return emoji;
|
||||
}
|
||||
|
||||
export function validDiscordToken(value) {
|
||||
return typeof value === 'string'
|
||||
&& /^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{20,}$/.test(value.trim());
|
||||
|
|
@ -229,6 +235,35 @@ export class DiscordApi {
|
|||
});
|
||||
}
|
||||
|
||||
async addOwnReaction({ channelId, messageId, emoji, signal } = {}) {
|
||||
const normalizedEmoji = reactionEmoji(emoji);
|
||||
await this.#request(
|
||||
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`
|
||||
+ `/reactions/${encodeURIComponent(normalizedEmoji)}/@me`,
|
||||
{
|
||||
method: 'PUT',
|
||||
signal,
|
||||
expectBody: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
return normalizedEmoji;
|
||||
}
|
||||
|
||||
removeOwnReaction({ channelId, messageId, emoji, signal } = {}) {
|
||||
const normalizedEmoji = reactionEmoji(emoji);
|
||||
return this.#request(
|
||||
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`
|
||||
+ `/reactions/${encodeURIComponent(normalizedEmoji)}/@me`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
expectBody: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async #request(path, {
|
||||
method,
|
||||
body,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export const DISCORD_DESCRIPTOR = Object.freeze({
|
|||
key: 'discord',
|
||||
label: 'Discord',
|
||||
connectionLabel: ' Gateway 长连接',
|
||||
reactions: Object.freeze({ processing: '👀', success: '✅', error: '❌' }),
|
||||
});
|
||||
|
||||
export class DiscordHarnessBridge extends TextHarnessBridge {
|
||||
|
|
|
|||
|
|
@ -231,6 +231,10 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|||
channelId: String(message.channel_id),
|
||||
replyToMessageId: String(message.id),
|
||||
},
|
||||
reactionTarget: {
|
||||
channelId: String(message.channel_id),
|
||||
messageId: String(message.id),
|
||||
},
|
||||
connectionTestTarget: { channelId: String(message.channel_id) },
|
||||
};
|
||||
}
|
||||
|
|
@ -350,6 +354,24 @@ export class DiscordBotClient {
|
|||
});
|
||||
}
|
||||
|
||||
addReaction(target, emoji, { signal } = {}) {
|
||||
return this.#api.addOwnReaction({
|
||||
channelId: target.channelId,
|
||||
messageId: target.messageId,
|
||||
emoji,
|
||||
signal: this.#operationSignal(signal),
|
||||
});
|
||||
}
|
||||
|
||||
removeReaction(target, reactionKey, { signal } = {}) {
|
||||
return this.#api.removeOwnReaction({
|
||||
channelId: target.channelId,
|
||||
messageId: target.messageId,
|
||||
emoji: reactionKey,
|
||||
signal: this.#operationSignal(signal),
|
||||
});
|
||||
}
|
||||
|
||||
async openStream(target) {
|
||||
const notice = !this.#deliveredNotices.has(target) && target?.notice
|
||||
? String(target.notice) : null;
|
||||
|
|
@ -381,6 +403,10 @@ export class DiscordBotClient {
|
|||
});
|
||||
return stream.start();
|
||||
}
|
||||
|
||||
#operationSignal(signal) {
|
||||
return signal ?? this.#signal;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiscordRuntimeStatus() {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import {
|
|||
messageFailureText,
|
||||
setLastMessageFailure,
|
||||
} from '../shared/message-failure.mjs';
|
||||
import { beginStatusReaction } from '../shared/status-reaction.mjs';
|
||||
import {
|
||||
MENU_PAGE_SIZE,
|
||||
PRESET_FOLLOW_DEFAULT_SENTINEL,
|
||||
|
|
@ -549,7 +550,7 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
|
||||
this.#acceptedMessageIds.add(messageId);
|
||||
const processingReaction = this.#addReaction(messageId, 'OnIt');
|
||||
const processingReaction = this.#beginReaction(messageId);
|
||||
const commandMessage = extractInboundMessage(event, this.#client);
|
||||
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
||||
const batchText = event.message.message_type === 'text'
|
||||
|
|
@ -3603,35 +3604,24 @@ export class FeishuHarnessBridge {
|
|||
return t('_{text}_', { text: update.text || t('正在处理…') });
|
||||
}
|
||||
|
||||
async #addReaction(messageId, emojiType) {
|
||||
if (!this.#channel?.addReaction) return null;
|
||||
try {
|
||||
const reactionId = await this.#channel.addReaction(messageId, emojiType);
|
||||
this.#status.reactionsAdded = (this.#status.reactionsAdded ?? 0) + 1;
|
||||
return reactionId;
|
||||
} catch (error) {
|
||||
this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(`[dsh-feishu] unable to add ${emojiType} reaction:`, error.message);
|
||||
return null;
|
||||
}
|
||||
#beginReaction(messageId) {
|
||||
return beginStatusReaction({
|
||||
adapter: this.#channel,
|
||||
target: messageId,
|
||||
reactions: { processing: 'OnIt', success: 'DONE', error: 'ERROR' },
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
label: 'feishu',
|
||||
});
|
||||
}
|
||||
|
||||
async #removeProcessingReaction(messageId, processingReaction) {
|
||||
const reactionId = await processingReaction;
|
||||
if (reactionId && this.#channel?.removeReaction) {
|
||||
try {
|
||||
await this.#channel.removeReaction(messageId, reactionId);
|
||||
this.#status.reactionsRemoved = (this.#status.reactionsRemoved ?? 0) + 1;
|
||||
} catch (error) {
|
||||
this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
|
||||
this.#logger.warn?.('[dsh-feishu] unable to remove processing reaction:', error.message);
|
||||
}
|
||||
}
|
||||
#removeProcessingReaction(_messageId, processingReaction) {
|
||||
processingReaction.clear();
|
||||
}
|
||||
|
||||
async #finishReaction(messageId, processingReaction, finalEmojiType) {
|
||||
await this.#removeProcessingReaction(messageId, processingReaction);
|
||||
await this.#addReaction(messageId, finalEmojiType);
|
||||
#finishReaction(_messageId, processingReaction, finalEmojiType) {
|
||||
if (finalEmojiType === 'ERROR') processingReaction.error();
|
||||
else processingReaction.success();
|
||||
}
|
||||
|
||||
async #send(chatId, text) {
|
||||
|
|
|
|||
107
src/channels/shared/status-reaction.mjs
Normal file
107
src/channels/shared/status-reaction.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
const DEFAULT_TIMEOUT_MS = 2_000;
|
||||
|
||||
const NOOP_REACTION = Object.freeze({
|
||||
success() {},
|
||||
error() {},
|
||||
clear() {},
|
||||
settled: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
function increment(status, key) {
|
||||
if (!status || typeof status !== 'object') return;
|
||||
status[key] = (status[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
async function runWithTimeout(operation, timeoutMs) {
|
||||
const signal = AbortSignal.timeout(timeoutMs);
|
||||
let onAbort;
|
||||
const aborted = new Promise((_, reject) => {
|
||||
onAbort = () => reject(signal.reason ?? new DOMException('Timed out', 'TimeoutError'));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([operation(signal), aborted]);
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a best-effort status reaction lifecycle which is deliberately not
|
||||
* part of the caller's message queue. Calls are serialized only for this one
|
||||
* source message; every provider operation is bounded and absorbs failures.
|
||||
*/
|
||||
export function beginStatusReaction({
|
||||
adapter,
|
||||
target,
|
||||
reactions,
|
||||
status,
|
||||
logger = console,
|
||||
label = 'channel',
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
if (!target
|
||||
|| typeof adapter?.addReaction !== 'function'
|
||||
|| typeof adapter?.removeReaction !== 'function'
|
||||
|| typeof reactions?.processing !== 'string'
|
||||
|| !reactions.processing
|
||||
|| !Number.isSafeInteger(timeoutMs)
|
||||
|| timeoutMs <= 0) return NOOP_REACTION;
|
||||
|
||||
let currentReaction = null;
|
||||
let terminal = false;
|
||||
|
||||
const safely = async (kind, operation) => {
|
||||
try {
|
||||
const value = await runWithTimeout(operation, timeoutMs);
|
||||
increment(status, kind === 'add' ? 'reactionsAdded' : 'reactionsRemoved');
|
||||
return { ok: true, value };
|
||||
} catch (cause) {
|
||||
increment(status, 'reactionErrors');
|
||||
logger.warn?.(
|
||||
`[dsh-im:${label}] status reaction ${kind} failed:`,
|
||||
cause?.message ?? cause?.name ?? String(cause),
|
||||
);
|
||||
return { ok: false, value: null };
|
||||
}
|
||||
};
|
||||
|
||||
const transition = async (emoji) => {
|
||||
if (currentReaction !== null) {
|
||||
const previous = currentReaction;
|
||||
currentReaction = null;
|
||||
await safely('remove', (signal) => adapter.removeReaction(
|
||||
target,
|
||||
previous,
|
||||
{ signal },
|
||||
));
|
||||
}
|
||||
if (typeof emoji !== 'string' || !emoji) return;
|
||||
const added = await safely('add', (signal) => adapter.addReaction(
|
||||
target,
|
||||
emoji,
|
||||
{ signal },
|
||||
));
|
||||
if (added.ok && added.value !== undefined && added.value !== null) {
|
||||
currentReaction = added.value;
|
||||
}
|
||||
};
|
||||
|
||||
// Calling an async function starts the provider request synchronously up to
|
||||
// its first await, while the returned tail remains completely detached from
|
||||
// normal message processing.
|
||||
let tail = transition(reactions.processing);
|
||||
const finish = (emoji) => {
|
||||
if (terminal) return;
|
||||
terminal = true;
|
||||
tail = tail.then(() => transition(emoji), () => transition(emoji));
|
||||
void tail.catch(() => undefined);
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
success: () => finish(reactions.success),
|
||||
error: () => finish(reactions.error),
|
||||
clear: () => finish(null),
|
||||
settled: () => tail,
|
||||
});
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ import {
|
|||
messageFailureText,
|
||||
setLastMessageFailure,
|
||||
} from './message-failure.mjs';
|
||||
import { beginStatusReaction } from './status-reaction.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
|
||||
|
|
@ -114,6 +115,9 @@ export function createTextBridgeStatus() {
|
|||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
lastMessageError: null,
|
||||
reactionsAdded: 0,
|
||||
reactionsRemoved: 0,
|
||||
reactionErrors: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -178,6 +182,27 @@ export class TextHarnessBridge {
|
|||
return Promise.resolve();
|
||||
}
|
||||
this.#acceptedMessageIds.add(messageId);
|
||||
const statusReaction = beginStatusReaction({
|
||||
adapter: this.#bot,
|
||||
target: normalized.kind === 'direct' || normalized.addressed === true
|
||||
? normalized.reactionTarget
|
||||
: null,
|
||||
reactions: this.#descriptor.reactions,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
label: this.#descriptor.key,
|
||||
});
|
||||
normalized.statusReaction = statusReaction;
|
||||
|
||||
const processing = this.#acceptAcceptedMessage(normalized, messageId, senderId);
|
||||
void processing.then(
|
||||
() => statusReaction.success(),
|
||||
() => statusReaction.error(),
|
||||
);
|
||||
return processing;
|
||||
}
|
||||
|
||||
#acceptAcceptedMessage(normalized, messageId, senderId) {
|
||||
if (normalized.kind === 'direct') {
|
||||
rememberConnectionTestTarget(
|
||||
this.#state,
|
||||
|
|
@ -185,7 +210,7 @@ export class TextHarnessBridge {
|
|||
);
|
||||
}
|
||||
|
||||
const key = `${kind}:${conversationId}`;
|
||||
const key = `${normalized.kind}:${normalized.conversationId}`;
|
||||
const pending = this.#pendingInteractions.get(key);
|
||||
const text = cleanText(normalized.content);
|
||||
const batchCommand = isBatchInputCommand(text);
|
||||
|
|
@ -326,7 +351,11 @@ export class TextHarnessBridge {
|
|||
if (reply) await this.#bot.sendText(message.replyTarget, reply);
|
||||
this.#status.lastError = null;
|
||||
})().catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
if (this.#signal?.aborted) {
|
||||
message.statusReaction?.clear();
|
||||
return;
|
||||
}
|
||||
message.statusReaction?.error();
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
|
|
@ -406,7 +435,11 @@ export class TextHarnessBridge {
|
|||
}
|
||||
this.#status.lastError = null;
|
||||
} catch (error) {
|
||||
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
|
||||
if (error?.code === 'turn-stopped' || this.#signal?.aborted) {
|
||||
message.statusReaction?.clear();
|
||||
return;
|
||||
}
|
||||
message.statusReaction?.error();
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
|
|
@ -706,6 +739,7 @@ export class TextHarnessBridge {
|
|||
? this.#batches.fail(conversationKey, batchSubmission.token)
|
||||
: null;
|
||||
if (turnStopped) {
|
||||
message.statusReaction?.clear();
|
||||
if (stream) {
|
||||
try {
|
||||
await stream.finish(t('已停止。'));
|
||||
|
|
@ -716,9 +750,11 @@ export class TextHarnessBridge {
|
|||
return;
|
||||
}
|
||||
if (this.#signal?.aborted) {
|
||||
message.statusReaction?.clear();
|
||||
stream?.cancel?.();
|
||||
return;
|
||||
}
|
||||
message.statusReaction?.error();
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const presentStreamFailure = async (text) => {
|
||||
const method = typeof stream?.fail === 'function'
|
||||
|
|
@ -773,7 +809,10 @@ export class TextHarnessBridge {
|
|||
}
|
||||
|
||||
async #processInteractionReply(message, messageId, senderId, key, expected) {
|
||||
if (this.#signal?.aborted) return;
|
||||
if (this.#signal?.aborted) {
|
||||
message.statusReaction?.clear();
|
||||
return;
|
||||
}
|
||||
const current = this.#pendingInteractions.get(key);
|
||||
const claimed = expected.claimedReplyMessageId === messageId;
|
||||
if (!current || current !== expected || current.submitting) {
|
||||
|
|
@ -889,7 +928,10 @@ export class TextHarnessBridge {
|
|||
} catch (error) {
|
||||
if (error?.code === 'interaction-not-pending') {
|
||||
this.#clearPendingInteraction(key, pending.interactionId);
|
||||
if (this.#signal?.aborted) return;
|
||||
if (this.#signal?.aborted) {
|
||||
message.statusReaction?.clear();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.#bot.sendText(target, t(INTERACTION_RESOLVED_TEXT));
|
||||
} catch (sendError) {
|
||||
|
|
@ -900,7 +942,11 @@ export class TextHarnessBridge {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (this.#signal?.aborted || this.#pendingInteractions.get(key) !== pending) return;
|
||||
if (this.#signal?.aborted) {
|
||||
message.statusReaction?.clear();
|
||||
return;
|
||||
}
|
||||
if (this.#pendingInteractions.get(key) !== pending) return;
|
||||
pending.submitting = false;
|
||||
pending.answers.pop();
|
||||
pending.index -= 1;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ oauth_config:
|
|||
- files:read
|
||||
- files:write
|
||||
- im:history
|
||||
- reactions:write
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
|
|
|
|||
|
|
@ -276,6 +276,34 @@ export class SlackApi {
|
|||
});
|
||||
}
|
||||
|
||||
addReaction({ channelId, messageTs, emojiName, signal, timeoutMs }) {
|
||||
return this.#request('reactions.add', {
|
||||
tokenKind: 'bot',
|
||||
signal,
|
||||
timeoutMs,
|
||||
retry: false,
|
||||
body: {
|
||||
channel: slackId(channelId, 'channel id'),
|
||||
timestamp: requiredString(messageTs, 'message timestamp'),
|
||||
name: requiredString(emojiName, 'reaction name'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
removeReaction({ channelId, messageTs, emojiName, signal, timeoutMs }) {
|
||||
return this.#request('reactions.remove', {
|
||||
tokenKind: 'bot',
|
||||
signal,
|
||||
timeoutMs,
|
||||
retry: false,
|
||||
body: {
|
||||
channel: slackId(channelId, 'channel id'),
|
||||
timestamp: requiredString(messageTs, 'message timestamp'),
|
||||
name: requiredString(emojiName, 'reaction name'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
startStream({ channelId, threadTs, recipientTeamId, recipientUserId, markdownText, signal }) {
|
||||
return this.#request('chat.startStream', {
|
||||
tokenKind: 'bot',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ export const SLACK_DESCRIPTOR = Object.freeze({
|
|||
key: 'slack',
|
||||
label: 'Slack',
|
||||
connectionLabel: ' Socket Mode 长连接',
|
||||
reactions: Object.freeze({
|
||||
processing: 'eyes',
|
||||
success: 'white_check_mark',
|
||||
error: 'x',
|
||||
}),
|
||||
});
|
||||
|
||||
export class SlackHarnessBridge extends TextHarnessBridge {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,10 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|||
? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
|
||||
: [],
|
||||
addressed: direct || mentioned,
|
||||
reactionTarget: {
|
||||
channelId: String(event.channel),
|
||||
messageTs: String(event.ts),
|
||||
},
|
||||
replyTarget: {
|
||||
channelId: String(event.channel),
|
||||
threadTs,
|
||||
|
|
@ -284,6 +288,26 @@ export class SlackBotClient {
|
|||
return { providerMessageIds };
|
||||
}
|
||||
|
||||
async addReaction(target, emoji, { signal } = {}) {
|
||||
const reactionKey = String(emoji ?? '').trim();
|
||||
await this.#api.addReaction({
|
||||
channelId: target.channelId,
|
||||
messageTs: target.messageTs,
|
||||
emojiName: reactionKey,
|
||||
signal: signal ?? this.#signal,
|
||||
});
|
||||
return reactionKey;
|
||||
}
|
||||
|
||||
removeReaction(target, reactionKey, { signal } = {}) {
|
||||
return this.#api.removeReaction({
|
||||
channelId: target.channelId,
|
||||
messageTs: target.messageTs,
|
||||
emojiName: reactionKey,
|
||||
signal: signal ?? this.#signal,
|
||||
});
|
||||
}
|
||||
|
||||
openStream(target) {
|
||||
return createSlackMessageStream({
|
||||
api: this.#api,
|
||||
|
|
|
|||
|
|
@ -203,6 +203,15 @@ export class TelegramApi {
|
|||
}, { signal });
|
||||
}
|
||||
|
||||
async setMessageReaction({ chatId, messageId, emoji, signal, timeoutMs }) {
|
||||
const normalizedEmoji = cleanString(emoji);
|
||||
return this.#call('setMessageReaction', {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
reaction: normalizedEmoji ? [{ type: 'emoji', emoji: normalizedEmoji }] : [],
|
||||
}, { signal, timeoutMs });
|
||||
}
|
||||
|
||||
async sendRichMessage({
|
||||
chatId,
|
||||
richMessage,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export const TELEGRAM_DESCRIPTOR = Object.freeze({
|
|||
key: 'telegram',
|
||||
label: 'Telegram',
|
||||
connectionLabel: ' Bot API 长轮询',
|
||||
reactions: Object.freeze({ processing: '👀', success: '👍', error: '👎' }),
|
||||
});
|
||||
|
||||
export class TelegramHarnessBridge extends TextHarnessBridge {
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ export function normalizeTelegramUpdate(update, {
|
|||
images: image ? [image] : [],
|
||||
files: file ? [file] : [],
|
||||
addressed,
|
||||
reactionTarget: { chatId, messageId },
|
||||
replyTarget: {
|
||||
chatId,
|
||||
chatType: message.chat.type,
|
||||
|
|
@ -303,6 +304,25 @@ export class TelegramBotClient {
|
|||
return { providerMessageIds };
|
||||
}
|
||||
|
||||
async addReaction(target, emoji, { signal } = {}) {
|
||||
const reactionKey = String(emoji ?? '').trim();
|
||||
await this.#api.setMessageReaction({
|
||||
chatId: target.chatId,
|
||||
messageId: target.messageId,
|
||||
emoji: reactionKey,
|
||||
signal: signal ?? this.#signal,
|
||||
});
|
||||
return reactionKey;
|
||||
}
|
||||
|
||||
removeReaction(target, _reactionKey, { signal } = {}) {
|
||||
return this.#api.setMessageReaction({
|
||||
chatId: target.chatId,
|
||||
messageId: target.messageId,
|
||||
signal: signal ?? this.#signal,
|
||||
});
|
||||
}
|
||||
|
||||
sendTyping(target) {
|
||||
return this.#api.sendChatAction({
|
||||
chatId: target.chatId,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const WHATSAPP_DESCRIPTOR = Object.freeze({
|
|||
label: 'WhatsApp',
|
||||
// Translated lazily: t() must run after setImHostLanguage, not at import time.
|
||||
get connectionLabel() { return t(' Web 关联设备'); },
|
||||
reactions: Object.freeze({ processing: '👀', success: '✅', error: '❌' }),
|
||||
});
|
||||
|
||||
export class WhatsappHarnessBridge extends TextHarnessBridge {
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
|
|||
addressed: !group || fromMe || mentioned || replyToSelf,
|
||||
selfChat,
|
||||
replyTarget: { jid: remoteJid, quoted: message, selfChat },
|
||||
reactionTarget: { jid: remoteJid, key: message.key },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -411,6 +412,41 @@ export class WhatsappBotClient {
|
|||
}, 'image');
|
||||
}
|
||||
|
||||
async addReaction(target, emoji, { signal } = {}) {
|
||||
if (typeof emoji !== 'string' || !emoji.trim()) {
|
||||
throw new TypeError('A WhatsApp reaction emoji is required');
|
||||
}
|
||||
const reactionKey = emoji.trim();
|
||||
await this.#sendReaction(target, reactionKey, signal);
|
||||
return reactionKey;
|
||||
}
|
||||
|
||||
removeReaction(target, _reactionKey, { signal } = {}) {
|
||||
return this.#sendReaction(target, '', signal);
|
||||
}
|
||||
|
||||
async #sendReaction(target, text, signal) {
|
||||
if (typeof target?.jid !== 'string' || !target.jid || !target.key?.id) {
|
||||
throw new TypeError('A WhatsApp reaction target is required');
|
||||
}
|
||||
const operationSignal = signal ?? this.#signal;
|
||||
operationSignal?.throwIfAborted();
|
||||
const messageId = randomBytes(10).toString('hex').toUpperCase();
|
||||
if (typeof this.#outboundIds.reserve === 'function') {
|
||||
this.#outboundIds.reserve(messageId);
|
||||
} else {
|
||||
this.#outboundIds.remember(messageId);
|
||||
}
|
||||
const pending = this.#socket.sendMessage(
|
||||
target.jid,
|
||||
{ react: { text, key: target.key } },
|
||||
{ messageId },
|
||||
);
|
||||
const result = await waitWithSignal(pending, operationSignal);
|
||||
this.#outboundIds.remember(result?.key?.id);
|
||||
return result;
|
||||
}
|
||||
|
||||
async #sendArtifact(target, file, content, presentation) {
|
||||
this.#signal?.throwIfAborted();
|
||||
await this.#stopTyping(target.jid);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import {
|
|||
createDingtalkApi,
|
||||
DINGTALK_AI_CARD_TEMPLATE_ID,
|
||||
DINGTALK_API_BASE_URL,
|
||||
DINGTALK_DONE_REACTION_NAME,
|
||||
DINGTALK_ERROR_REACTION_NAME,
|
||||
DINGTALK_THINKING_REACTION_NAME,
|
||||
DingtalkApiError,
|
||||
normalizeDingtalkCardMarkdown,
|
||||
normalizeDingtalkSessionWebhook,
|
||||
|
|
@ -114,6 +117,120 @@ test('session replies use the fixed token endpoint, reject redirects, and cache
|
|||
});
|
||||
});
|
||||
|
||||
test('DingTalk adds, recalls, and replaces its native status reactions', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: url.toString(), options });
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'reaction-access-token', expireIn: 7_200 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
const request = {
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
robotCode: 'robot-from-callback',
|
||||
messageId: 'open-message-one',
|
||||
conversationId: 'open-conversation-one',
|
||||
};
|
||||
|
||||
await api.addReaction({ ...request, reactionName: DINGTALK_THINKING_REACTION_NAME });
|
||||
await api.recallReaction({ ...request, reactionName: DINGTALK_THINKING_REACTION_NAME });
|
||||
await api.addReaction({ ...request, reactionName: DINGTALK_DONE_REACTION_NAME });
|
||||
await api.addReaction({ ...request, reactionName: DINGTALK_ERROR_REACTION_NAME });
|
||||
|
||||
assert.equal(calls.filter(({ url }) => url.includes('/oauth2/accessToken')).length, 1);
|
||||
assert.deepEqual(calls.slice(1).map(({ url, options }) => ({
|
||||
path: new URL(url).pathname,
|
||||
token: options.headers['x-acs-dingtalk-access-token'],
|
||||
body: JSON.parse(options.body),
|
||||
})), [
|
||||
['reply', DINGTALK_THINKING_REACTION_NAME],
|
||||
['recall', DINGTALK_THINKING_REACTION_NAME],
|
||||
['reply', DINGTALK_DONE_REACTION_NAME],
|
||||
['reply', DINGTALK_ERROR_REACTION_NAME],
|
||||
].map(([action, reactionName]) => ({
|
||||
path: `/v1.0/robot/emotion/${action}`,
|
||||
token: 'reaction-access-token',
|
||||
body: {
|
||||
robotCode: 'robot-from-callback',
|
||||
openMsgId: 'open-message-one',
|
||||
openConversationId: 'open-conversation-one',
|
||||
emotionType: 2,
|
||||
emotionName: reactionName,
|
||||
textEmotion: {
|
||||
emotionId: '2659900',
|
||||
emotionName: reactionName,
|
||||
text: reactionName,
|
||||
backgroundId: 'im_bg_1',
|
||||
},
|
||||
},
|
||||
})));
|
||||
});
|
||||
|
||||
test('DingTalk treats a success=false emotion response as rejected', async () => {
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: async (url) => url.pathname.endsWith('/oauth2/accessToken')
|
||||
? jsonResponse({ accessToken: 'reaction-access-token', expireIn: 7_200 })
|
||||
: jsonResponse({ success: false, code: 'emotion_not_supported' }),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
api.addReaction({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
robotCode: 'robot-from-callback',
|
||||
messageId: 'open-message-one',
|
||||
conversationId: 'open-conversation-one',
|
||||
reactionName: DINGTALK_DONE_REACTION_NAME,
|
||||
}),
|
||||
(error) => error instanceof DingtalkApiError
|
||||
&& error.code === 'reaction-rejected'
|
||||
&& error.providerCode === 'emotion_not_supported',
|
||||
);
|
||||
});
|
||||
|
||||
test('cold reactions share their token request without delaying the normal reply token request', async () => {
|
||||
const firstToken = deferred();
|
||||
const calls = [];
|
||||
let tokenRequests = 0;
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: url.toString(), options });
|
||||
if (new URL(url).pathname.endsWith('/oauth2/accessToken')) {
|
||||
tokenRequests += 1;
|
||||
if (tokenRequests === 1) return firstToken.promise;
|
||||
return jsonResponse({ accessToken: 'normal-access-token', expireIn: 7_200 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
const reactions = Array.from({ length: 10 }, (_unused, index) => (
|
||||
api.addReaction({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
messageId: `open-message-${index + 1}`,
|
||||
conversationId: 'open-conversation-one',
|
||||
reactionName: DINGTALK_THINKING_REACTION_NAME,
|
||||
})
|
||||
));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(tokenRequests, 1, 'concurrent reactions must share their own cold token request');
|
||||
|
||||
await api.sendText({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=normal',
|
||||
text: '正常回复',
|
||||
});
|
||||
assert.equal(tokenRequests, 2);
|
||||
|
||||
firstToken.resolve(jsonResponse({ accessToken: 'reaction-access-token', expireIn: 7_200 }));
|
||||
await Promise.all(reactions);
|
||||
const normalSend = calls.find(({ url }) => url.includes('ticket=normal'));
|
||||
assert.equal(normalSend.options.headers['x-acs-dingtalk-access-token'], 'normal-access-token');
|
||||
});
|
||||
|
||||
test('DingTalk uploads and sends a native file message to the exact robot conversation', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import {
|
|||
dingtalkInboundMessage,
|
||||
DingtalkHarnessBridge,
|
||||
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||
import {
|
||||
DINGTALK_DONE_REACTION_NAME,
|
||||
DINGTALK_ERROR_REACTION_NAME,
|
||||
DINGTALK_THINKING_REACTION_NAME,
|
||||
} from '../../../src/channels/dingtalk/dingtalk-api.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
|
|
@ -945,6 +950,260 @@ test('bridge maps a DingTalk direct conversation to one persistent Harness sessi
|
|||
assert.equal(status.stats.messagesReplied, 2);
|
||||
});
|
||||
|
||||
test('bridge replaces the native thinking reaction with done after a successful reply', async () => {
|
||||
const fixture = stateFixture();
|
||||
const events = [];
|
||||
const recallPending = deferred();
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
addReaction: async (request) => {
|
||||
events.push([
|
||||
'add',
|
||||
request.reactionName,
|
||||
request.messageId,
|
||||
request.conversationId,
|
||||
request.robotCode,
|
||||
]);
|
||||
},
|
||||
recallReaction: async (request) => {
|
||||
events.push(['recall', request.reactionName, request.messageId, request.conversationId]);
|
||||
return recallPending.promise;
|
||||
},
|
||||
sendText: async ({ text }) => events.push(['reply', text]),
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-reaction-success',
|
||||
ask: async () => '已完成',
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('reaction-success', '请回答', {
|
||||
robotCode: 'robot-from-callback',
|
||||
}));
|
||||
await eventually(() => events.some(([event]) => event === 'recall'));
|
||||
|
||||
assert.deepEqual(events, [
|
||||
[
|
||||
'add',
|
||||
DINGTALK_THINKING_REACTION_NAME,
|
||||
'reaction-success',
|
||||
'conversation-reaction-success',
|
||||
'robot-from-callback',
|
||||
],
|
||||
['reply', '已完成'],
|
||||
[
|
||||
'recall',
|
||||
DINGTALK_THINKING_REACTION_NAME,
|
||||
'reaction-success',
|
||||
'conversation-reaction-success',
|
||||
],
|
||||
]);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
assert.equal(bridge.status.reactionsAdded, 1);
|
||||
recallPending.resolve();
|
||||
await eventually(() => bridge.status.reactionsAdded === 2);
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'add',
|
||||
DINGTALK_DONE_REACTION_NAME,
|
||||
'reaction-success',
|
||||
'conversation-reaction-success',
|
||||
'robot-from-callback',
|
||||
]);
|
||||
assert.equal(bridge.status.reactionsRemoved, 1);
|
||||
assert.equal(bridge.status.reactionErrors, 0);
|
||||
});
|
||||
|
||||
test('bridge reacts only to safe, addressed, non-duplicate messages', async () => {
|
||||
const fixture = stateFixture();
|
||||
const reactions = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
addReaction: async ({ messageId, reactionName }) => {
|
||||
reactions.push(`add:${messageId}:${reactionName}`);
|
||||
},
|
||||
recallReaction: async ({ messageId, reactionName }) => {
|
||||
reactions.push(`recall:${messageId}:${reactionName}`);
|
||||
},
|
||||
sendText: async () => true,
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-reaction-validation',
|
||||
ask: async () => '已回复',
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('reaction-unmentioned', '群聊噪音', {
|
||||
conversationType: '2',
|
||||
conversationId: 'reaction-group',
|
||||
isInAtList: false,
|
||||
}));
|
||||
await bridge.accept(message('reaction-unsafe', '不安全路由', {
|
||||
sessionWebhook: 'https://example.com/reply',
|
||||
}));
|
||||
await bridge.accept(message('reaction-valid', '正常问题'));
|
||||
await bridge.accept(message('reaction-valid', '重复问题'));
|
||||
await eventually(() => reactions.includes(
|
||||
`add:reaction-valid:${DINGTALK_DONE_REACTION_NAME}`,
|
||||
));
|
||||
|
||||
assert.deepEqual(reactions, [
|
||||
`add:reaction-valid:${DINGTALK_THINKING_REACTION_NAME}`,
|
||||
`recall:reaction-valid:${DINGTALK_THINKING_REACTION_NAME}`,
|
||||
`add:reaction-valid:${DINGTALK_DONE_REACTION_NAME}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('a hanging reaction cannot delay an error reply or the message queue', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let thinkingAdds = 0;
|
||||
let asks = 0;
|
||||
let recalls = 0;
|
||||
const terminals = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
addReaction: async ({ reactionName }) => {
|
||||
if (reactionName !== DINGTALK_THINKING_REACTION_NAME) {
|
||||
terminals.push(reactionName);
|
||||
return true;
|
||||
}
|
||||
thinkingAdds += 1;
|
||||
if (thinkingAdds === 1) throw new Error('reaction unavailable');
|
||||
return new Promise(() => {});
|
||||
},
|
||||
recallReaction: async () => { recalls += 1; },
|
||||
sendText: async ({ text }) => sent.push(text),
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-reaction-failure',
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
if (asks === 1) throw new Error('private Harness failure');
|
||||
return '第二条正常';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { debug() {}, error() {} },
|
||||
reactionTimeoutMs: 20,
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
bridge.accept(message('reaction-failure', '第一条')),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('error reply was blocked')), 100)),
|
||||
]);
|
||||
await Promise.race([
|
||||
bridge.accept(message('reaction-next', '第二条')),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('next message was blocked')), 100)),
|
||||
]);
|
||||
|
||||
assert.equal(asks, 2);
|
||||
assert.equal(sent.length, 2, 'the safe error reply and next normal reply are both delivered');
|
||||
assert.equal(sent.some((text) => text.includes('private Harness failure')), false);
|
||||
assert.equal(sent.some((text) => text.includes('第二条正常')), true);
|
||||
await eventually(() => recalls === 4 && terminals.length === 2);
|
||||
assert.deepEqual(terminals, [DINGTALK_ERROR_REACTION_NAME, DINGTALK_DONE_REACTION_NAME]);
|
||||
assert.equal(bridge.status.reactionsRemoved, 4);
|
||||
assert.equal(bridge.status.reactionErrors, 2);
|
||||
});
|
||||
|
||||
test('runtime abort recalls the native thinking reaction without delaying stop', async () => {
|
||||
const fixture = stateFixture();
|
||||
const controller = new AbortController();
|
||||
const askStarted = deferred();
|
||||
let recalls = 0;
|
||||
const added = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
addReaction: async ({ reactionName }) => {
|
||||
added.push(reactionName);
|
||||
return true;
|
||||
},
|
||||
recallReaction: async () => { recalls += 1; },
|
||||
sendText: async () => true,
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-reaction-abort',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
askStarted.resolve();
|
||||
await new Promise((resolve, reject) => {
|
||||
options.signal.addEventListener('abort', () => reject(options.signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const task = bridge.accept(message('reaction-abort', '等待停止'));
|
||||
await askStarted.promise;
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
await Promise.race([
|
||||
task,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('abort was blocked')), 100)),
|
||||
]);
|
||||
await eventually(() => recalls === 1);
|
||||
assert.deepEqual(added, [DINGTALK_THINKING_REACTION_NAME]);
|
||||
});
|
||||
|
||||
test('a failed DingTalk reaction cleanup is retried once without delaying the reply', async () => {
|
||||
const fixture = stateFixture();
|
||||
let recalls = 0;
|
||||
const events = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
addReaction: async ({ reactionName }) => {
|
||||
events.push(`add:${reactionName}`);
|
||||
return true;
|
||||
},
|
||||
recallReaction: async ({ reactionName }) => {
|
||||
recalls += 1;
|
||||
events.push(`recall:${reactionName}:${recalls}`);
|
||||
if (recalls === 1) throw new Error('temporary cleanup failure');
|
||||
return true;
|
||||
},
|
||||
sendText: async () => true,
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-reaction-retry',
|
||||
ask: async () => '已完成',
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { debug() {}, error() {} },
|
||||
reactionTimeoutMs: 20,
|
||||
});
|
||||
|
||||
await bridge.accept(message('reaction-retry', '请回答'));
|
||||
await eventually(() => events.includes(`add:${DINGTALK_DONE_REACTION_NAME}`));
|
||||
assert.deepEqual(events, [
|
||||
`add:${DINGTALK_THINKING_REACTION_NAME}`,
|
||||
`recall:${DINGTALK_THINKING_REACTION_NAME}:1`,
|
||||
`recall:${DINGTALK_THINKING_REACTION_NAME}:2`,
|
||||
`add:${DINGTALK_DONE_REACTION_NAME}`,
|
||||
]);
|
||||
assert.equal(bridge.status.reactionErrors, 1);
|
||||
assert.equal(bridge.status.reactionsRemoved, 1);
|
||||
assert.equal(bridge.status.reactionsAdded, 2);
|
||||
});
|
||||
|
||||
test('senders in the bot visibility scope enter Harness without local approval', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -165,6 +165,32 @@ test('Discord API gets a channel and starts one thread from a source message', a
|
|||
assert.equal(requests.length, 2);
|
||||
});
|
||||
|
||||
test('Discord API adds and removes the current bot reaction with an encoded emoji', async () => {
|
||||
const requests = [];
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
const target = {
|
||||
channelId: '123456789012345678',
|
||||
messageId: '987654321012345678',
|
||||
};
|
||||
|
||||
assert.equal(await api.addOwnReaction({ ...target, emoji: '👀' }), '👀');
|
||||
await api.removeOwnReaction({ ...target, emoji: '👀' });
|
||||
|
||||
assert.deepEqual(requests.map(({ options }) => options.method), ['PUT', 'DELETE']);
|
||||
assert.equal(
|
||||
decodeURIComponent(requests[0].url.pathname),
|
||||
'/api/v10/channels/123456789012345678/messages/987654321012345678/reactions/👀/@me',
|
||||
);
|
||||
assert.equal(requests[1].url.pathname, requests[0].url.pathname);
|
||||
assert.equal(requests[0].options.body, undefined);
|
||||
});
|
||||
|
||||
test('Discord API uploads a result file as a native attachment and preserves the reply', async () => {
|
||||
let request;
|
||||
const api = new DiscordApi({
|
||||
|
|
@ -440,6 +466,10 @@ test('Discord normalizes DMs and only addressed server messages', () => {
|
|||
assert.equal(direct.addressed, true);
|
||||
assert.equal(direct.plainText, true);
|
||||
assert.deepEqual(direct.connectionTestTarget, { channelId: '222222222222222222' });
|
||||
assert.deepEqual(direct.reactionTarget, {
|
||||
channelId: '222222222222222222',
|
||||
messageId: '111111111111111111',
|
||||
});
|
||||
|
||||
const sticker = normalizeDiscordMessage({
|
||||
id: '111111111111111115',
|
||||
|
|
@ -599,6 +629,10 @@ test('Discord routes mentioned text and announcement messages into native thread
|
|||
});
|
||||
assert.equal(route.conversationId, message.id);
|
||||
assert.deepEqual(route.replyTarget, { channelId: message.id });
|
||||
assert.deepEqual(route.reactionTarget, {
|
||||
channelId: message.channel_id,
|
||||
messageId: message.id,
|
||||
});
|
||||
assert.deepEqual(route.conversationRoute, {
|
||||
peerId: message.channel_id,
|
||||
threadId: message.id,
|
||||
|
|
@ -912,6 +946,37 @@ test('Discord merges a deterministic Thread fallback notice into one delivered a
|
|||
assert.equal(edits[0].content, `${streamTarget.notice}\n\nstreamed answer`);
|
||||
});
|
||||
|
||||
test('Discord bot client exposes cancellable add and remove reaction operations', async () => {
|
||||
const operations = [];
|
||||
const api = {
|
||||
async addOwnReaction(options) {
|
||||
operations.push({ operation: 'add', ...options });
|
||||
return options.emoji;
|
||||
},
|
||||
async removeOwnReaction(options) {
|
||||
operations.push({ operation: 'remove', ...options });
|
||||
},
|
||||
};
|
||||
const client = new DiscordBotClient({ api });
|
||||
const target = {
|
||||
channelId: '222222222222222266',
|
||||
messageId: '111111111111111166',
|
||||
};
|
||||
const controller = new AbortController();
|
||||
|
||||
const reactionKey = await client.addReaction(target, '👀', { signal: controller.signal });
|
||||
await client.removeReaction(target, reactionKey, { signal: controller.signal });
|
||||
|
||||
assert.equal(reactionKey, '👀');
|
||||
assert.deepEqual(operations.map(({ operation, channelId, messageId, emoji, signal }) => ({
|
||||
operation, channelId, messageId, emoji, signal,
|
||||
})), [{
|
||||
operation: 'add', ...target, emoji: '👀', signal: controller.signal,
|
||||
}, {
|
||||
operation: 'remove', ...target, emoji: '👀', signal: controller.signal,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Discord keeps streamed text and result files on the final created Thread target', async () => {
|
||||
const botId = '1234567890123456789';
|
||||
const message = {
|
||||
|
|
|
|||
|
|
@ -2349,6 +2349,7 @@ test('reaction failures do not block streaming replies', async () => {
|
|||
client: {},
|
||||
channel: {
|
||||
addReaction: async () => { throw new Error('reaction unavailable'); },
|
||||
removeReaction: async () => undefined,
|
||||
stream: async (_chatId, input) => input.markdown({ setContent: async () => undefined }),
|
||||
},
|
||||
harness: {
|
||||
|
|
@ -2371,10 +2372,51 @@ test('reaction failures do not block streaming replies', async () => {
|
|||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(status.messagesReplied, 1);
|
||||
await eventually(() => status.reactionErrors === 2);
|
||||
assert.equal(status.reactionErrors, 2);
|
||||
assert.equal(status.streamResponses, 1);
|
||||
});
|
||||
|
||||
test('a hanging Feishu reaction does not delay the message promise or the next queued turn', async () => {
|
||||
const seen = new Set();
|
||||
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||||
let asks = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: {},
|
||||
channel: {
|
||||
addReaction: () => new Promise(() => {}),
|
||||
removeReaction: async () => undefined,
|
||||
stream: async (_chatId, input) => input.markdown({ setContent: async () => undefined }),
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => `正常回答 ${++asks}`,
|
||||
},
|
||||
state: {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
},
|
||||
status,
|
||||
logger: { debug() {}, error() {}, warn() {} },
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
Promise.all([
|
||||
bridge.accept(event('reaction-hang-one', '第一条')),
|
||||
bridge.accept(event('reaction-hang-two', '第二条')),
|
||||
]),
|
||||
new Promise((_, reject) => setTimeout(
|
||||
() => reject(new Error('Feishu message flow waited for a reaction request')),
|
||||
100,
|
||||
)),
|
||||
]);
|
||||
|
||||
assert.equal(asks, 2);
|
||||
assert.equal(status.messagesReplied, 2);
|
||||
});
|
||||
|
||||
test('Feishu routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
|
||||
const scenarios = [
|
||||
{
|
||||
|
|
@ -2737,7 +2779,7 @@ test('Feishu still delivers a file-only result when CardKit and fallback text bo
|
|||
assert.match(status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
});
|
||||
|
||||
test('Feishu returns the receipt after reaction finalization and one safe notice when text and file delivery fail', async (t) => {
|
||||
test('Feishu returns the receipt independently and emits one safe notice when text and file delivery fail', async (t) => {
|
||||
for (const errorCode of ['artifact-invalid', 'artifact-unavailable']) {
|
||||
await t.test(errorCode, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, `${errorCode}.txt`, 'file bytes', errorCode);
|
||||
|
|
|
|||
79
test/channels/shared/status-reaction.test.mjs
Normal file
79
test/channels/shared/status-reaction.test.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { beginStatusReaction } from '../../../src/channels/shared/status-reaction.mjs';
|
||||
|
||||
test('status reaction replaces processing with the successful terminal reaction in order', async () => {
|
||||
const calls = [];
|
||||
const status = {};
|
||||
const reaction = beginStatusReaction({
|
||||
adapter: {
|
||||
addReaction: async (target, emoji) => {
|
||||
calls.push(['add', target, emoji]);
|
||||
return `key:${emoji}`;
|
||||
},
|
||||
removeReaction: async (target, key) => calls.push(['remove', target, key]),
|
||||
},
|
||||
target: 'source-message',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
status,
|
||||
logger: { warn() {} },
|
||||
});
|
||||
|
||||
reaction.success();
|
||||
await reaction.settled();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['add', 'source-message', 'eyes'],
|
||||
['remove', 'source-message', 'key:eyes'],
|
||||
['add', 'source-message', 'done'],
|
||||
]);
|
||||
assert.equal(status.reactionsAdded, 2);
|
||||
assert.equal(status.reactionsRemoved, 1);
|
||||
assert.equal(status.reactionErrors ?? 0, 0);
|
||||
});
|
||||
|
||||
test('status reaction has a hard timeout and absorbs provider failures', async () => {
|
||||
const status = {};
|
||||
const warnings = [];
|
||||
const reaction = beginStatusReaction({
|
||||
adapter: {
|
||||
addReaction: () => new Promise(() => {}),
|
||||
removeReaction: async () => undefined,
|
||||
},
|
||||
target: 'source-message',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
status,
|
||||
logger: { warn: (...args) => warnings.push(args) },
|
||||
timeoutMs: 20,
|
||||
});
|
||||
|
||||
reaction.error();
|
||||
await reaction.settled();
|
||||
|
||||
assert.equal(status.reactionErrors, 2);
|
||||
assert.equal(status.reactionsAdded ?? 0, 0);
|
||||
assert.equal(warnings.length, 2);
|
||||
});
|
||||
|
||||
test('the first terminal reaction wins', async () => {
|
||||
const added = [];
|
||||
const reaction = beginStatusReaction({
|
||||
adapter: {
|
||||
addReaction: async (_target, emoji) => {
|
||||
added.push(emoji);
|
||||
return emoji;
|
||||
},
|
||||
removeReaction: async () => undefined,
|
||||
},
|
||||
target: 'source-message',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
logger: { warn() {} },
|
||||
});
|
||||
|
||||
reaction.error();
|
||||
reaction.success();
|
||||
reaction.clear();
|
||||
await reaction.settled();
|
||||
|
||||
assert.deepEqual(added, ['eyes', 'error']);
|
||||
});
|
||||
|
|
@ -187,6 +187,163 @@ async function committedArtifact(t, fileName, content, suffix) {
|
|||
return artifact;
|
||||
}
|
||||
|
||||
test('status reactions never delay safe errors, the conversation queue, or waitForIdle', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
let reactionAdds = 0;
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: {
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
},
|
||||
state: fixture.state,
|
||||
status: createTextBridgeStatus(),
|
||||
bot: {
|
||||
addReaction: () => {
|
||||
reactionAdds += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
removeReaction: async () => undefined,
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-reaction-sidecar',
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
if (asks === 1) throw new Error('private failure detail');
|
||||
return '第二条正常完成';
|
||||
},
|
||||
},
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const first = bridge.accept(message('reaction-hang-one', '第一条', {
|
||||
reactionTarget: { id: 'source-one' },
|
||||
}));
|
||||
const second = bridge.accept(message('reaction-hang-two', '第二条', {
|
||||
reactionTarget: { id: 'source-two' },
|
||||
}));
|
||||
|
||||
await within(Promise.all([first, second, bridge.waitForIdle()]), 100,
|
||||
'a hanging status reaction blocked normal message processing');
|
||||
|
||||
assert.equal(asks, 2);
|
||||
assert.equal(reactionAdds, 2);
|
||||
assert.equal(sent.some((text) => text.includes('第二条正常完成')), true);
|
||||
assert.equal(sent.some((text) => text.includes('private failure detail')), false);
|
||||
});
|
||||
|
||||
test('shared status reactions replace processing with success without joining the main task', async () => {
|
||||
const fixture = stateFixture();
|
||||
const reactions = [];
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: {
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
},
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
addReaction: async (target, emoji) => {
|
||||
reactions.push(['add', target.id, emoji]);
|
||||
return emoji;
|
||||
},
|
||||
removeReaction: async (target, emoji) => {
|
||||
reactions.push(['remove', target.id, emoji]);
|
||||
},
|
||||
sendText: async () => undefined,
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-reaction-success',
|
||||
ask: async () => '完成',
|
||||
},
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('reaction-success', '执行', {
|
||||
reactionTarget: { id: 'source-success' },
|
||||
}));
|
||||
await eventually(() => reactions.length === 3);
|
||||
|
||||
assert.deepEqual(reactions, [
|
||||
['add', 'source-success', 'eyes'],
|
||||
['remove', 'source-success', 'eyes'],
|
||||
['add', 'source-success', 'done'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('runtime abort clears a queued interaction reply reaction instead of marking success', async () => {
|
||||
const fixture = stateFixture();
|
||||
const controller = new AbortController();
|
||||
const invalidStarted = deferred();
|
||||
const releaseInvalid = deferred();
|
||||
const originalMarkSeen = fixture.state.markSeen.bind(fixture.state);
|
||||
fixture.state.markSeen = async (messageId) => {
|
||||
await originalMarkSeen(messageId);
|
||||
if (messageId === 'blocking-invalid') {
|
||||
invalidStarted.resolve();
|
||||
await releaseInvalid.promise;
|
||||
}
|
||||
};
|
||||
const sent = [];
|
||||
const reactions = [];
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: {
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
},
|
||||
state: fixture.state,
|
||||
signal: controller.signal,
|
||||
bot: {
|
||||
addReaction: async (target, emoji) => {
|
||||
reactions.push(['add', target.id, emoji]);
|
||||
return emoji;
|
||||
},
|
||||
removeReaction: async (target, emoji) => {
|
||||
reactions.push(['remove', target.id, emoji]);
|
||||
},
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-reaction-abort',
|
||||
ask: async (sessionId, _text, options) => {
|
||||
await options.onInteraction(questionInteraction({ sessionId }));
|
||||
await new Promise((resolve, reject) => {
|
||||
options.signal.addEventListener('abort', () => reject(options.signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const processing = bridge.accept(message('interaction-start', '启动交互'));
|
||||
await eventually(() => sent.some((text) => text.includes('请回答')));
|
||||
const invalid = bridge.accept(message('blocking-invalid', ''));
|
||||
await invalidStarted.promise;
|
||||
const answer = bridge.accept(message('queued-answer', '有效回答', {
|
||||
reactionTarget: { id: 'source-answer' },
|
||||
}));
|
||||
await eventually(() => reactions.some((call) => call[0] === 'add'));
|
||||
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
releaseInvalid.resolve();
|
||||
await Promise.all([processing, invalid, answer]);
|
||||
await eventually(() => reactions.some((call) => call[0] === 'remove'));
|
||||
|
||||
assert.deepEqual(reactions, [
|
||||
['add', 'source-answer', 'eyes'],
|
||||
['remove', 'source-answer', 'eyes'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('all four shared text channels execute /compact outside the model prompt path', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
validSlackBotToken,
|
||||
} from '../../../src/channels/slack/slack-api.mjs';
|
||||
import {
|
||||
SlackBotClient,
|
||||
SlackRuntime,
|
||||
isSlackToolProgress,
|
||||
normalizeSlackEvent,
|
||||
|
|
@ -150,6 +151,79 @@ test('Slack API uses native streaming methods and suppresses generated mass ment
|
|||
assert.equal(calls[3].body.text, '请通知 @channel 和 @U99999999');
|
||||
});
|
||||
|
||||
test('Slack API and bot client add and remove reactions on the source message', async () => {
|
||||
const calls = [];
|
||||
const requestAbort = new AbortController();
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({
|
||||
method: url.pathname.split('/').pop(),
|
||||
body: JSON.parse(options.body),
|
||||
signal: options.signal,
|
||||
});
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
await api.addReaction({
|
||||
channelId: 'C12345678',
|
||||
messageTs: '1700000000.123',
|
||||
emojiName: 'eyes',
|
||||
signal: requestAbort.signal,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
await api.removeReaction({
|
||||
channelId: 'C12345678',
|
||||
messageTs: '1700000000.123',
|
||||
emojiName: 'eyes',
|
||||
signal: requestAbort.signal,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
assert.deepEqual(calls.map(({ method, body }) => ({ method, body })), [{
|
||||
method: 'reactions.add',
|
||||
body: { channel: 'C12345678', timestamp: '1700000000.123', name: 'eyes' },
|
||||
}, {
|
||||
method: 'reactions.remove',
|
||||
body: { channel: 'C12345678', timestamp: '1700000000.123', name: 'eyes' },
|
||||
}]);
|
||||
requestAbort.abort();
|
||||
assert.equal(calls.every(({ signal }) => signal.aborted), true);
|
||||
|
||||
const adapterCalls = [];
|
||||
const runtimeAbort = new AbortController();
|
||||
const sidecarAbort = new AbortController();
|
||||
const client = new SlackBotClient({
|
||||
api: {
|
||||
addReaction: async (payload) => adapterCalls.push({ action: 'add', ...payload }),
|
||||
removeReaction: async (payload) => adapterCalls.push({ action: 'remove', ...payload }),
|
||||
},
|
||||
signal: runtimeAbort.signal,
|
||||
});
|
||||
const target = { channelId: 'C12345678', messageTs: '1700000000.456' };
|
||||
assert.equal(await client.addReaction(target, 'white_check_mark', {
|
||||
signal: sidecarAbort.signal,
|
||||
}), 'white_check_mark');
|
||||
await client.removeReaction(target, 'white_check_mark', { signal: sidecarAbort.signal });
|
||||
assert.deepEqual(adapterCalls.map(({ action, channelId, messageTs, emojiName }) => ({
|
||||
action, channelId, messageTs, emojiName,
|
||||
})), [{
|
||||
action: 'add',
|
||||
channelId: 'C12345678',
|
||||
messageTs: '1700000000.456',
|
||||
emojiName: 'white_check_mark',
|
||||
}, {
|
||||
action: 'remove',
|
||||
channelId: 'C12345678',
|
||||
messageTs: '1700000000.456',
|
||||
emojiName: 'white_check_mark',
|
||||
}]);
|
||||
runtimeAbort.abort();
|
||||
assert.equal(adapterCalls.every(({ signal }) => signal.aborted), false);
|
||||
sidecarAbort.abort();
|
||||
assert.equal(adapterCalls.every(({ signal }) => signal.aborted), true);
|
||||
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- reactions:write\n/);
|
||||
});
|
||||
|
||||
test('Slack API completes the native external-upload flow in the original thread', async () => {
|
||||
const calls = [];
|
||||
const api = new SlackApi({
|
||||
|
|
@ -792,6 +866,24 @@ test('Slack normalizes direct messages and addressed channel events', () => {
|
|||
assert.equal(group.conversationId, 'C12345678:1700000000.002');
|
||||
assert.equal(group.replyTarget.threadTs, '1700000000.002');
|
||||
|
||||
const threaded = normalizeSlackEvent({
|
||||
event_id: 'Ev002-threaded',
|
||||
team_id: 'T12345678',
|
||||
event: {
|
||||
type: 'app_mention',
|
||||
channel: 'C12345678',
|
||||
user: 'U87654321',
|
||||
ts: '1700000000.003',
|
||||
thread_ts: '1700000000.002',
|
||||
text: '<@U12345678> continue',
|
||||
},
|
||||
}, 'U12345678');
|
||||
assert.equal(threaded.replyTarget.threadTs, '1700000000.002');
|
||||
assert.deepEqual(threaded.reactionTarget, {
|
||||
channelId: 'C12345678',
|
||||
messageTs: '1700000000.003',
|
||||
});
|
||||
|
||||
const botMessage = normalizeSlackEvent({
|
||||
event_id: 'Ev003',
|
||||
event: {
|
||||
|
|
|
|||
|
|
@ -305,6 +305,68 @@ test('Telegram API preserves the legacy plain send and edit payloads', async ()
|
|||
assert.equal(Object.hasOwn(calls[1].body, 'parse_mode'), false);
|
||||
});
|
||||
|
||||
test('Telegram API and bot client set and clear one reaction on the source message', async () => {
|
||||
const calls = [];
|
||||
const requestAbort = new AbortController();
|
||||
const api = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({
|
||||
method: url.pathname.split('/').at(-1),
|
||||
body: JSON.parse(options.body),
|
||||
signal: options.signal,
|
||||
});
|
||||
return jsonResponse({ ok: true, result: true });
|
||||
},
|
||||
});
|
||||
await api.setMessageReaction({
|
||||
chatId: -100123,
|
||||
messageId: 44,
|
||||
emoji: ' 👀 ',
|
||||
signal: requestAbort.signal,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
await api.setMessageReaction({
|
||||
chatId: -100123,
|
||||
messageId: 44,
|
||||
signal: requestAbort.signal,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
assert.deepEqual(calls.map(({ method, body }) => ({ method, body })), [{
|
||||
method: 'setMessageReaction',
|
||||
body: {
|
||||
chat_id: -100123,
|
||||
message_id: 44,
|
||||
reaction: [{ type: 'emoji', emoji: '👀' }],
|
||||
},
|
||||
}, {
|
||||
method: 'setMessageReaction',
|
||||
body: { chat_id: -100123, message_id: 44, reaction: [] },
|
||||
}]);
|
||||
requestAbort.abort();
|
||||
assert.equal(calls.every(({ signal }) => signal.aborted), true);
|
||||
|
||||
const adapterCalls = [];
|
||||
const runtimeAbort = new AbortController();
|
||||
const sidecarAbort = new AbortController();
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
setMessageReaction: async (payload) => adapterCalls.push(payload),
|
||||
},
|
||||
signal: runtimeAbort.signal,
|
||||
});
|
||||
const target = { chatId: -100123, messageId: 45 };
|
||||
assert.equal(await client.addReaction(target, '👍', { signal: sidecarAbort.signal }), '👍');
|
||||
await client.removeReaction(target, '👍', { signal: sidecarAbort.signal });
|
||||
assert.deepEqual(adapterCalls.map(({ chatId, messageId, emoji }) => ({
|
||||
chatId, messageId, emoji,
|
||||
})), [{ chatId: -100123, messageId: 45, emoji: '👍' }, {
|
||||
chatId: -100123, messageId: 45, emoji: undefined,
|
||||
}]);
|
||||
sidecarAbort.abort();
|
||||
assert.equal(adapterCalls.every(({ signal }) => signal.aborted), true);
|
||||
});
|
||||
|
||||
test('Telegram plain delivery keeps the 4000 boundary, reply, topic, and content', async () => {
|
||||
const calls = [];
|
||||
let nextMessageId = 600;
|
||||
|
|
@ -967,6 +1029,7 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
|||
assert.equal(privateMessage.kind, 'direct');
|
||||
assert.equal(privateMessage.addressed, true);
|
||||
assert.equal(privateMessage.replyTarget.chatType, 'private');
|
||||
assert.deepEqual(privateMessage.reactionTarget, { chatId: 88, messageId: 4 });
|
||||
assert.deepEqual(privateMessage.connectionTestTarget, { chatId: 88, messageThreadId: undefined });
|
||||
|
||||
const groupMessage = normalizeTelegramUpdate({
|
||||
|
|
@ -1011,6 +1074,8 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
|||
assert.notEqual(topicOne.conversationId, topicTwo.conversationId);
|
||||
assert.equal(topicOne.replyTarget.messageThreadId, 100);
|
||||
assert.equal(topicTwo.replyTarget.messageThreadId, 200);
|
||||
assert.deepEqual(topicOne.reactionTarget, { chatId: -1001, messageId: 6 });
|
||||
assert.deepEqual(topicTwo.reactionTarget, { chatId: -1001, messageId: 7 });
|
||||
});
|
||||
|
||||
test('Telegram compatible mode preserves old routing and private allowlist mode restricts inbound messages', () => {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ async function within(promise, timeoutMs, message) {
|
|||
}
|
||||
}
|
||||
|
||||
async function eventually(predicate, timeoutMs = 1_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.fail('condition was not met before timeout');
|
||||
}
|
||||
|
||||
async function committedArtifact(t, {
|
||||
suffix,
|
||||
fileName = 'result.txt',
|
||||
|
|
@ -323,13 +332,26 @@ test('WhatsApp media downloader supplies Baileys reupload context', async () =>
|
|||
});
|
||||
|
||||
test('WhatsApp normalizes direct, linked-account, and explicitly mentioned group messages', () => {
|
||||
const directKey = {
|
||||
remoteJid: '16505550999@s.whatsapp.net',
|
||||
remoteJidAlt: '987654321098765@lid',
|
||||
participantAlt: '123456789012345@lid',
|
||||
addressingMode: 'lid',
|
||||
id: 'direct-1',
|
||||
fromMe: false,
|
||||
};
|
||||
const direct = normalizeWhatsappMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-1', fromMe: false },
|
||||
key: directKey,
|
||||
message: { conversation: 'hello' },
|
||||
}, ACCOUNT_JID);
|
||||
assert.equal(direct.kind, 'direct');
|
||||
assert.equal(direct.addressed, true);
|
||||
assert.equal(direct.content, 'hello');
|
||||
assert.equal(direct.reactionTarget.key, directKey);
|
||||
assert.deepEqual(direct.reactionTarget, {
|
||||
jid: '16505550999@s.whatsapp.net',
|
||||
key: directKey,
|
||||
});
|
||||
|
||||
const group = normalizeWhatsappMessage({
|
||||
key: {
|
||||
|
|
@ -660,10 +682,12 @@ test('WhatsApp open mode answers linked-account group messages without processin
|
|||
readMessages: async () => {},
|
||||
sendMessage: async (jid, content, options = {}) => {
|
||||
sent.push({ jid, content, options });
|
||||
replyEchoTask = callbacks.onMessage({
|
||||
key: { remoteJid: groupJid, id: options.messageId, fromMe: true },
|
||||
message: { conversation: content.text },
|
||||
});
|
||||
if (typeof content.text === 'string') {
|
||||
replyEchoTask = callbacks.onMessage({
|
||||
key: { remoteJid: groupJid, id: options.messageId, fromMe: true },
|
||||
message: { conversation: content.text },
|
||||
});
|
||||
}
|
||||
return { key: { id: options.messageId } };
|
||||
},
|
||||
};
|
||||
|
|
@ -698,13 +722,19 @@ test('WhatsApp open mode answers linked-account group messages without processin
|
|||
};
|
||||
await callbacks.onMessage(inbound);
|
||||
await replyEchoTask;
|
||||
await eventually(() => sent.filter(({ content }) => content.react).length === 2);
|
||||
|
||||
assert.equal(askCount, 1);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].jid, groupJid);
|
||||
assert.equal(sent[0].content.text, 'Harness group answer');
|
||||
assert.equal(sent[0].options.quoted, inbound);
|
||||
assert.match(sent[0].options.messageId, /^[0-9A-F]{20}$/);
|
||||
const textSends = sent.filter(({ content }) => typeof content.text === 'string');
|
||||
assert.equal(textSends.length, 1);
|
||||
assert.equal(textSends[0].jid, groupJid);
|
||||
assert.equal(textSends[0].content.text, 'Harness group answer');
|
||||
assert.equal(textSends[0].options.quoted, inbound);
|
||||
assert.match(textSends[0].options.messageId, /^[0-9A-F]{20}$/);
|
||||
const reactionSends = sent.filter(({ content }) => content.react);
|
||||
assert.deepEqual(reactionSends.map(({ content }) => content.react.text), ['👀', '']);
|
||||
assert.equal(reactionSends.every(({ jid }) => jid === groupJid), true);
|
||||
assert.equal(reactionSends.every(({ content }) => content.react.key === inbound.key), true);
|
||||
});
|
||||
|
||||
test('WhatsApp runtime sends result files with native metadata, quote, stable id, and upload timeout', async (t) => {
|
||||
|
|
@ -818,6 +848,67 @@ test('WhatsApp bot client sends native images with stable id and early echo supp
|
|||
assert.deepEqual(remembered, [expectedMessageId, 'provider-image-message']);
|
||||
});
|
||||
|
||||
test('WhatsApp bot client adds and clears a reaction against the full source key', async () => {
|
||||
const calls = [];
|
||||
const reserved = [];
|
||||
const remembered = [];
|
||||
const sourceKey = {
|
||||
remoteJid: '120363000000000000@g.us',
|
||||
participant: '16505550999@s.whatsapp.net',
|
||||
participantAlt: '987654321098765@lid',
|
||||
addressingMode: 'lid',
|
||||
id: 'reaction-source-1',
|
||||
fromMe: false,
|
||||
};
|
||||
const socket = {
|
||||
sendMessage: async (jid, content, options) => {
|
||||
calls.push({ jid, content, options });
|
||||
return { key: { id: `reaction-result-${calls.length}` } };
|
||||
},
|
||||
};
|
||||
const client = new WhatsappBotClient(socket, {
|
||||
reserve: (id) => reserved.push(id),
|
||||
remember: (id) => remembered.push(id),
|
||||
});
|
||||
const target = { jid: sourceKey.remoteJid, key: sourceKey };
|
||||
|
||||
const reactionKey = await client.addReaction(target, '👀');
|
||||
await client.removeReaction(target, reactionKey);
|
||||
|
||||
assert.equal(reactionKey, '👀');
|
||||
assert.deepEqual(calls.map(({ jid, content }) => ({ jid, content })), [{
|
||||
jid: sourceKey.remoteJid,
|
||||
content: { react: { text: '👀', key: sourceKey } },
|
||||
}, {
|
||||
jid: sourceKey.remoteJid,
|
||||
content: { react: { text: '', key: sourceKey } },
|
||||
}]);
|
||||
assert.equal(calls.every(({ content }) => content.react.key === sourceKey), true);
|
||||
assert.equal(calls.every(({ options }) => /^[0-9A-F]{20}$/.test(options.messageId)), true);
|
||||
assert.notEqual(calls[0].options.messageId, calls[1].options.messageId);
|
||||
assert.deepEqual(reserved, calls.map(({ options }) => options.messageId));
|
||||
assert.deepEqual(remembered, ['reaction-result-1', 'reaction-result-2']);
|
||||
});
|
||||
|
||||
test('WhatsApp reaction operations obey an upper-layer hard timeout', async () => {
|
||||
const socket = {
|
||||
sendMessage: async () => new Promise(() => {}),
|
||||
};
|
||||
const client = new WhatsappBotClient(socket, {
|
||||
reserve() {},
|
||||
remember() {},
|
||||
});
|
||||
|
||||
await assert.rejects(() => client.addReaction({
|
||||
jid: '16505550999@s.whatsapp.net',
|
||||
key: {
|
||||
remoteJid: '16505550999@s.whatsapp.net',
|
||||
id: 'reaction-timeout-source',
|
||||
fromMe: false,
|
||||
},
|
||||
}, '👀', { signal: AbortSignal.timeout(10) }), (error) => error.name === 'TimeoutError');
|
||||
});
|
||||
|
||||
test('WhatsApp classifies a definite image rejection and uses a distinct fallback file id', async () => {
|
||||
const calls = [];
|
||||
const deliveryKey = 'whatsapp-image-fallback-delivery';
|
||||
|
|
@ -1189,16 +1280,25 @@ test('WhatsApp runtime answers self-chat without processing its own reply echo',
|
|||
},
|
||||
});
|
||||
await runtime.start();
|
||||
await callbacks.onMessage({
|
||||
const inbound = {
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'owner-message-1', fromMe: true },
|
||||
message: { conversation: 'hello from message yourself' },
|
||||
});
|
||||
};
|
||||
await callbacks.onMessage(inbound);
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'bot-reply-1', fromMe: true },
|
||||
message: { conversation: 'Harness self-chat answer' },
|
||||
});
|
||||
await eventually(() => sent.filter(([, content]) => content.react).length === 2);
|
||||
assert.equal(askCount, 1);
|
||||
assert.deepEqual(sent, [[ACCOUNT_JID, { text: 'Harness self-chat answer' }]]);
|
||||
assert.deepEqual(
|
||||
sent.filter(([, content]) => typeof content.text === 'string'),
|
||||
[[ACCOUNT_JID, { text: 'Harness self-chat answer' }]],
|
||||
);
|
||||
const reactionSends = sent.filter(([, content]) => content.react);
|
||||
assert.deepEqual(reactionSends.map(([, content]) => content.react.text), ['👀', '']);
|
||||
assert.equal(reactionSends.every(([jid]) => jid === ACCOUNT_JID), true);
|
||||
assert.equal(reactionSends.every(([, content]) => content.react.key === inbound.key), true);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue