feat: add status reactions across messaging channels

This commit is contained in:
xmanrui 2026-08-25 17:12:28 +08:00
parent aa8fd71b93
commit da01e3acee
30 changed files with 1853 additions and 274 deletions

View 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,
});
}

View file

@ -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;