Status reactions: fix stall timers and gating (#22190)

* feat: add shared status reaction controller

* feat: add statusReactions config schema

* feat: wire status reactions for Discord and Telegram

* fix: restore original 10s/30s stall defaults for Discord compatibility

* Status reactions: fix stall timers and gating

* Format status reaction imports

---------

Co-authored-by: Matt <mateus.carniatto@gmail.com>
This commit is contained in:
Shadow
2026-02-20 15:27:42 -06:00
committed by GitHub
parent 47f3979758
commit 30a0d3fce1
10 changed files with 1121 additions and 252 deletions

View File

@@ -23,6 +23,10 @@ import { formatLocationText, toLocationContext } from "../channels/location.js";
import { logInboundDrop } from "../channels/logging.js";
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
import { recordInboundSession } from "../channels/session.js";
import {
createStatusReactionController,
type StatusReactionController,
} from "../channels/status-reactions.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js";
@@ -521,8 +525,41 @@ export const buildTelegramMessageContext = async ({
};
const reactionApi =
typeof api.setMessageReaction === "function" ? api.setMessageReaction.bind(api) : null;
const ackReactionPromise =
shouldAckReaction() && msg.message_id && reactionApi
// Status Reactions controller (lifecycle reactions)
const statusReactionsConfig = cfg.messages?.statusReactions;
const statusReactionsEnabled =
statusReactionsConfig?.enabled === true && Boolean(reactionApi) && shouldAckReaction();
const statusReactionController: StatusReactionController | null =
statusReactionsEnabled && msg.message_id
? createStatusReactionController({
enabled: true,
adapter: {
setReaction: async (emoji: string) => {
if (reactionApi) {
await reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji }]);
}
},
// Telegram replaces atomically — no removeReaction needed
},
initialEmoji: ackReaction,
emojis: statusReactionsConfig?.emojis,
timing: statusReactionsConfig?.timing,
onError: (err) => {
logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`);
},
})
: null;
// When status reactions are enabled, setQueued() replaces the simple ack reaction
const ackReactionPromise = statusReactionController
? shouldAckReaction()
? Promise.resolve(statusReactionController.setQueued()).then(
() => true,
() => false,
)
: null
: shouldAckReaction() && msg.message_id && reactionApi
? withTelegramApiErrorLogging({
operation: "setMessageReaction",
fn: () => reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReaction }]),
@@ -741,6 +778,7 @@ export const buildTelegramMessageContext = async ({
ackReactionPromise,
reactionApi,
removeAckAfterReply,
statusReactionController,
accountId: account.accountId,
};
};

View File

@@ -119,6 +119,7 @@ export const dispatchTelegramMessage = async ({
ackReactionPromise,
reactionApi,
removeAckAfterReply,
statusReactionController,
} = context;
const draftMaxChars = Math.min(textLimit, 4096);
@@ -545,6 +546,11 @@ export const dispatchTelegramMessage = async ({
};
let queuedFinal = false;
if (statusReactionController) {
void statusReactionController.setThinking();
}
try {
({ queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
@@ -691,6 +697,11 @@ export const dispatchTelegramMessage = async ({
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
}
: undefined,
onToolStart: statusReactionController
? async (payload) => {
await statusReactionController.setTool(payload.name);
}
: undefined,
onModelSelected,
},
}));
@@ -737,26 +748,40 @@ export const dispatchTelegramMessage = async ({
}
const hasFinalResponse = queuedFinal || sentFallback;
if (statusReactionController && !hasFinalResponse) {
void statusReactionController.setError().catch((err) => {
logVerbose(`telegram: status reaction error finalize failed: ${String(err)}`);
});
}
if (!hasFinalResponse) {
clearGroupHistory();
return;
}
removeAckReactionAfterReply({
removeAfterReply: removeAckAfterReply,
ackReactionPromise,
ackReactionValue: ackReactionPromise ? "ack" : null,
remove: () => reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve(),
onError: (err) => {
if (!msg.message_id) {
return;
}
logAckFailure({
log: logVerbose,
channel: "telegram",
target: `${chatId}/${msg.message_id}`,
error: err,
});
},
});
if (statusReactionController) {
void statusReactionController.setDone().catch((err) => {
logVerbose(`telegram: status reaction finalize failed: ${String(err)}`);
});
} else {
removeAckReactionAfterReply({
removeAfterReply: removeAckAfterReply,
ackReactionPromise,
ackReactionValue: ackReactionPromise ? "ack" : null,
remove: () => reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve(),
onError: (err) => {
if (!msg.message_id) {
return;
}
logAckFailure({
log: logVerbose,
channel: "telegram",
target: `${chatId}/${msg.message_id}`,
error: err,
});
},
});
}
clearGroupHistory();
};