mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-12 16:40:44 +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
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue