mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
|
||||
- iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.
|
||||
- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.
|
||||
- Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it.
|
||||
- Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow.
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -55,6 +56,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Tools/web_search: handle xAI Responses API payloads that emit top-level `output_text` blocks (without a `message` wrapper) so Grok web_search no longer returns `No response` for those results. (#20508) Thanks @echoVic.
|
||||
- Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii.
|
||||
- Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal `<think>` tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus.
|
||||
- Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow.
|
||||
|
||||
- Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow.
|
||||
- Security/Net: strip sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Cookie2`) on cross-origin redirects in `fetchWithSsrFGuard` to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm.
|
||||
|
||||
543
src/channels/status-reactions.test.ts
Normal file
543
src/channels/status-reactions.test.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
resolveToolEmoji,
|
||||
createStatusReactionController,
|
||||
DEFAULT_EMOJIS,
|
||||
DEFAULT_TIMING,
|
||||
CODING_TOOL_TOKENS,
|
||||
WEB_TOOL_TOKENS,
|
||||
type StatusReactionAdapter,
|
||||
} from "./status-reactions.js";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Mock Adapter
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const createMockAdapter = () => {
|
||||
const calls: { method: string; emoji: string }[] = [];
|
||||
return {
|
||||
adapter: {
|
||||
setReaction: vi.fn(async (emoji: string) => {
|
||||
calls.push({ method: "set", emoji });
|
||||
}),
|
||||
removeReaction: vi.fn(async (emoji: string) => {
|
||||
calls.push({ method: "remove", emoji });
|
||||
}),
|
||||
} as StatusReactionAdapter,
|
||||
calls,
|
||||
};
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveToolEmoji", () => {
|
||||
it("should return coding emoji for exec tool", () => {
|
||||
const result = resolveToolEmoji("exec", DEFAULT_EMOJIS);
|
||||
expect(result).toBe(DEFAULT_EMOJIS.coding);
|
||||
});
|
||||
|
||||
it("should return coding emoji for process tool", () => {
|
||||
const result = resolveToolEmoji("process", DEFAULT_EMOJIS);
|
||||
expect(result).toBe(DEFAULT_EMOJIS.coding);
|
||||
});
|
||||
|
||||
it("should return web emoji for web_search tool", () => {
|
||||
const result = resolveToolEmoji("web_search", DEFAULT_EMOJIS);
|
||||
expect(result).toBe(DEFAULT_EMOJIS.web);
|
||||
});
|
||||
|
||||
it("should return web emoji for browser tool", () => {
|
||||
const result = resolveToolEmoji("browser", DEFAULT_EMOJIS);
|
||||
expect(result).toBe(DEFAULT_EMOJIS.web);
|
||||
});
|
||||
|
||||
it("should return tool emoji for unknown tool", () => {
|
||||
const result = resolveToolEmoji("unknown_tool", DEFAULT_EMOJIS);
|
||||
expect(result).toBe(DEFAULT_EMOJIS.tool);
|
||||
});
|
||||
|
||||
it("should return tool emoji for empty string", () => {
|
||||
const result = resolveToolEmoji("", DEFAULT_EMOJIS);
|
||||
expect(result).toBe(DEFAULT_EMOJIS.tool);
|
||||
});
|
||||
|
||||
it("should return tool emoji for undefined", () => {
|
||||
const result = resolveToolEmoji(undefined, DEFAULT_EMOJIS);
|
||||
expect(result).toBe(DEFAULT_EMOJIS.tool);
|
||||
});
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
const result = resolveToolEmoji("EXEC", DEFAULT_EMOJIS);
|
||||
expect(result).toBe(DEFAULT_EMOJIS.coding);
|
||||
});
|
||||
|
||||
it("should match tokens within tool names", () => {
|
||||
const result = resolveToolEmoji("my_exec_wrapper", DEFAULT_EMOJIS);
|
||||
expect(result).toBe(DEFAULT_EMOJIS.coding);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStatusReactionController", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should not call adapter when disabled", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: false,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setQueued();
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should call setReaction with initialEmoji for setQueued immediately", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setQueued();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(calls).toContainEqual({ method: "set", emoji: "👀" });
|
||||
});
|
||||
|
||||
it("should debounce setThinking and eventually call adapter", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setThinking();
|
||||
|
||||
// Before debounce period
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(calls).toHaveLength(0);
|
||||
|
||||
// After debounce period
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking });
|
||||
});
|
||||
|
||||
it("should classify tool name and debounce", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setTool("exec");
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.coding });
|
||||
});
|
||||
|
||||
it("should execute setDone immediately without debounce", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
await controller.setDone();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.done });
|
||||
});
|
||||
|
||||
it("should execute setError immediately without debounce", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
await controller.setError();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.error });
|
||||
});
|
||||
|
||||
it("should ignore setThinking after setDone (terminal state)", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
await controller.setDone();
|
||||
const callsAfterDone = calls.length;
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(calls.length).toBe(callsAfterDone);
|
||||
});
|
||||
|
||||
it("should ignore setTool after setError (terminal state)", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
await controller.setError();
|
||||
const callsAfterError = calls.length;
|
||||
|
||||
void controller.setTool("exec");
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(calls.length).toBe(callsAfterError);
|
||||
});
|
||||
|
||||
it("should only fire last state when rapidly changing (debounce)", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
void controller.setTool("web_search");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
void controller.setTool("exec");
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
// Should only have the last one (exec → coding)
|
||||
const setEmojis = calls.filter((c) => c.method === "set").map((c) => c.emoji);
|
||||
expect(setEmojis).toEqual([DEFAULT_EMOJIS.coding]);
|
||||
});
|
||||
|
||||
it("should deduplicate same emoji calls", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
const callsAfterFirst = calls.length;
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
// Should not add another call
|
||||
expect(calls.length).toBe(callsAfterFirst);
|
||||
});
|
||||
|
||||
it("should call removeReaction when adapter supports it and emoji changes", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setQueued();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
// Should set thinking, then remove queued
|
||||
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking });
|
||||
expect(calls).toContainEqual({ method: "remove", emoji: "👀" });
|
||||
});
|
||||
|
||||
it("should only call setReaction when adapter lacks removeReaction", async () => {
|
||||
const calls: { method: string; emoji: string }[] = [];
|
||||
const adapter: StatusReactionAdapter = {
|
||||
setReaction: vi.fn(async (emoji: string) => {
|
||||
calls.push({ method: "set", emoji });
|
||||
}),
|
||||
// No removeReaction
|
||||
};
|
||||
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setQueued();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
// Should only have set calls, no remove
|
||||
const removeCalls = calls.filter((c) => c.method === "remove");
|
||||
expect(removeCalls).toHaveLength(0);
|
||||
expect(calls.filter((c) => c.method === "set").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should clear all known emojis when adapter supports removeReaction", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setQueued();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await controller.clear();
|
||||
|
||||
// Should have removed multiple emojis
|
||||
const removeCalls = calls.filter((c) => c.method === "remove");
|
||||
expect(removeCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should handle clear gracefully when adapter lacks removeReaction", async () => {
|
||||
const calls: { method: string; emoji: string }[] = [];
|
||||
const adapter: StatusReactionAdapter = {
|
||||
setReaction: vi.fn(async (emoji: string) => {
|
||||
calls.push({ method: "set", emoji });
|
||||
}),
|
||||
};
|
||||
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
await controller.clear();
|
||||
|
||||
// Should not throw, no remove calls
|
||||
const removeCalls = calls.filter((c) => c.method === "remove");
|
||||
expect(removeCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should restore initial emoji", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
await controller.restoreInitial();
|
||||
|
||||
expect(calls).toContainEqual({ method: "set", emoji: "👀" });
|
||||
});
|
||||
|
||||
it("should use custom emojis when provided", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const customEmojis = {
|
||||
thinking: "🤔",
|
||||
done: "🎉",
|
||||
};
|
||||
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
emojis: customEmojis,
|
||||
});
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
expect(calls).toContainEqual({ method: "set", emoji: "🤔" });
|
||||
|
||||
await controller.setDone();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(calls).toContainEqual({ method: "set", emoji: "🎉" });
|
||||
});
|
||||
|
||||
it("should use custom timing when provided", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const customTiming = {
|
||||
debounceMs: 100,
|
||||
};
|
||||
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
timing: customTiming,
|
||||
});
|
||||
|
||||
void controller.setThinking();
|
||||
|
||||
// Should not fire at 50ms
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(calls).toHaveLength(0);
|
||||
|
||||
// Should fire at 100ms
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking });
|
||||
});
|
||||
|
||||
it("should trigger soft stall timer after stallSoftMs", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
// Advance to soft stall threshold
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs);
|
||||
|
||||
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.stallSoft });
|
||||
});
|
||||
|
||||
it("should trigger hard stall timer after stallHardMs", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
// Advance to hard stall threshold
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallHardMs);
|
||||
|
||||
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.stallHard });
|
||||
});
|
||||
|
||||
it("should reset stall timers on phase change", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
// Advance halfway to soft stall
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2);
|
||||
|
||||
// Change phase
|
||||
void controller.setTool("exec");
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
// Advance another halfway - should not trigger stall yet
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2);
|
||||
|
||||
const stallCalls = calls.filter((c) => c.emoji === DEFAULT_EMOJIS.stallSoft);
|
||||
expect(stallCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should reset stall timers on repeated same-phase updates", async () => {
|
||||
const { adapter, calls } = createMockAdapter();
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
|
||||
void controller.setThinking();
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
|
||||
|
||||
// Advance halfway to soft stall
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2);
|
||||
|
||||
// Re-affirm same phase (should reset timers)
|
||||
void controller.setThinking();
|
||||
|
||||
// Advance another halfway - should not trigger stall yet
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2);
|
||||
|
||||
const stallCalls = calls.filter((c) => c.emoji === DEFAULT_EMOJIS.stallSoft);
|
||||
expect(stallCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should call onError callback when adapter throws", async () => {
|
||||
const onError = vi.fn();
|
||||
const adapter: StatusReactionAdapter = {
|
||||
setReaction: vi.fn(async () => {
|
||||
throw new Error("Network error");
|
||||
}),
|
||||
};
|
||||
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
onError,
|
||||
});
|
||||
|
||||
void controller.setQueued();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("constants", () => {
|
||||
it("should export CODING_TOOL_TOKENS", () => {
|
||||
expect(CODING_TOOL_TOKENS).toContain("exec");
|
||||
expect(CODING_TOOL_TOKENS).toContain("read");
|
||||
expect(CODING_TOOL_TOKENS).toContain("write");
|
||||
});
|
||||
|
||||
it("should export WEB_TOOL_TOKENS", () => {
|
||||
expect(WEB_TOOL_TOKENS).toContain("web_search");
|
||||
expect(WEB_TOOL_TOKENS).toContain("browser");
|
||||
});
|
||||
|
||||
it("should export DEFAULT_EMOJIS with all required keys", () => {
|
||||
expect(DEFAULT_EMOJIS).toHaveProperty("queued");
|
||||
expect(DEFAULT_EMOJIS).toHaveProperty("thinking");
|
||||
expect(DEFAULT_EMOJIS).toHaveProperty("tool");
|
||||
expect(DEFAULT_EMOJIS).toHaveProperty("coding");
|
||||
expect(DEFAULT_EMOJIS).toHaveProperty("web");
|
||||
expect(DEFAULT_EMOJIS).toHaveProperty("done");
|
||||
expect(DEFAULT_EMOJIS).toHaveProperty("error");
|
||||
expect(DEFAULT_EMOJIS).toHaveProperty("stallSoft");
|
||||
expect(DEFAULT_EMOJIS).toHaveProperty("stallHard");
|
||||
});
|
||||
|
||||
it("should export DEFAULT_TIMING with all required keys", () => {
|
||||
expect(DEFAULT_TIMING).toHaveProperty("debounceMs");
|
||||
expect(DEFAULT_TIMING).toHaveProperty("stallSoftMs");
|
||||
expect(DEFAULT_TIMING).toHaveProperty("stallHardMs");
|
||||
expect(DEFAULT_TIMING).toHaveProperty("doneHoldMs");
|
||||
expect(DEFAULT_TIMING).toHaveProperty("errorHoldMs");
|
||||
});
|
||||
});
|
||||
390
src/channels/status-reactions.ts
Normal file
390
src/channels/status-reactions.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Channel-agnostic status reaction controller.
|
||||
* Provides a unified interface for displaying agent status via message reactions.
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type StatusReactionAdapter = {
|
||||
/** Set/replace the current reaction emoji. */
|
||||
setReaction: (emoji: string) => Promise<void>;
|
||||
/** Remove a specific reaction emoji (optional — needed for Discord-style platforms). */
|
||||
removeReaction?: (emoji: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type StatusReactionEmojis = {
|
||||
queued?: string; // Default: uses initialEmoji param
|
||||
thinking?: string; // Default: "🧠"
|
||||
tool?: string; // Default: "🛠️"
|
||||
coding?: string; // Default: "💻"
|
||||
web?: string; // Default: "🌐"
|
||||
done?: string; // Default: "✅"
|
||||
error?: string; // Default: "❌"
|
||||
stallSoft?: string; // Default: "⏳"
|
||||
stallHard?: string; // Default: "⚠️"
|
||||
};
|
||||
|
||||
export type StatusReactionTiming = {
|
||||
debounceMs?: number; // Default: 700
|
||||
stallSoftMs?: number; // Default: 10000
|
||||
stallHardMs?: number; // Default: 30000
|
||||
doneHoldMs?: number; // Default: 1500 (not used in controller, but exported for callers)
|
||||
errorHoldMs?: number; // Default: 2500 (not used in controller, but exported for callers)
|
||||
};
|
||||
|
||||
export type StatusReactionController = {
|
||||
setQueued: () => Promise<void> | void;
|
||||
setThinking: () => Promise<void> | void;
|
||||
setTool: (toolName?: string) => Promise<void> | void;
|
||||
setDone: () => Promise<void>;
|
||||
setError: () => Promise<void>;
|
||||
clear: () => Promise<void>;
|
||||
restoreInitial: () => Promise<void>;
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_EMOJIS: Required<StatusReactionEmojis> = {
|
||||
queued: "👀",
|
||||
thinking: "🤔",
|
||||
tool: "🔥",
|
||||
coding: "👨💻",
|
||||
web: "⚡",
|
||||
done: "👍",
|
||||
error: "😱",
|
||||
stallSoft: "🥱",
|
||||
stallHard: "😨",
|
||||
};
|
||||
|
||||
export const DEFAULT_TIMING: Required<StatusReactionTiming> = {
|
||||
debounceMs: 700,
|
||||
stallSoftMs: 10_000,
|
||||
stallHardMs: 30_000,
|
||||
doneHoldMs: 1500,
|
||||
errorHoldMs: 2500,
|
||||
};
|
||||
|
||||
export const CODING_TOOL_TOKENS: string[] = [
|
||||
"exec",
|
||||
"process",
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"session_status",
|
||||
"bash",
|
||||
];
|
||||
|
||||
export const WEB_TOOL_TOKENS: string[] = [
|
||||
"web_search",
|
||||
"web-search",
|
||||
"web_fetch",
|
||||
"web-fetch",
|
||||
"browser",
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Functions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the appropriate emoji for a tool invocation.
|
||||
*/
|
||||
export function resolveToolEmoji(
|
||||
toolName: string | undefined,
|
||||
emojis: Required<StatusReactionEmojis>,
|
||||
): string {
|
||||
const normalized = toolName?.trim().toLowerCase() ?? "";
|
||||
if (!normalized) {
|
||||
return emojis.tool;
|
||||
}
|
||||
if (WEB_TOOL_TOKENS.some((token) => normalized.includes(token))) {
|
||||
return emojis.web;
|
||||
}
|
||||
if (CODING_TOOL_TOKENS.some((token) => normalized.includes(token))) {
|
||||
return emojis.coding;
|
||||
}
|
||||
return emojis.tool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a status reaction controller.
|
||||
*
|
||||
* Features:
|
||||
* - Promise chain serialization (prevents concurrent API calls)
|
||||
* - Debouncing (intermediate states debounce, terminal states are immediate)
|
||||
* - Stall timers (soft/hard warnings on inactivity)
|
||||
* - Terminal state protection (done/error mark finished, subsequent updates ignored)
|
||||
*/
|
||||
export function createStatusReactionController(params: {
|
||||
enabled: boolean;
|
||||
adapter: StatusReactionAdapter;
|
||||
initialEmoji: string;
|
||||
emojis?: StatusReactionEmojis;
|
||||
timing?: StatusReactionTiming;
|
||||
onError?: (err: unknown) => void;
|
||||
}): StatusReactionController {
|
||||
const { enabled, adapter, initialEmoji, onError } = params;
|
||||
|
||||
// Merge user-provided overrides with defaults
|
||||
const emojis: Required<StatusReactionEmojis> = {
|
||||
...DEFAULT_EMOJIS,
|
||||
queued: params.emojis?.queued ?? initialEmoji,
|
||||
...params.emojis,
|
||||
};
|
||||
|
||||
const timing: Required<StatusReactionTiming> = {
|
||||
...DEFAULT_TIMING,
|
||||
...params.timing,
|
||||
};
|
||||
|
||||
// State
|
||||
let currentEmoji = "";
|
||||
let pendingEmoji = "";
|
||||
let debounceTimer: NodeJS.Timeout | null = null;
|
||||
let stallSoftTimer: NodeJS.Timeout | null = null;
|
||||
let stallHardTimer: NodeJS.Timeout | null = null;
|
||||
let finished = false;
|
||||
let chainPromise = Promise.resolve();
|
||||
|
||||
// Known emojis for clear operation
|
||||
const knownEmojis = new Set<string>([
|
||||
initialEmoji,
|
||||
emojis.queued,
|
||||
emojis.thinking,
|
||||
emojis.tool,
|
||||
emojis.coding,
|
||||
emojis.web,
|
||||
emojis.done,
|
||||
emojis.error,
|
||||
emojis.stallSoft,
|
||||
emojis.stallHard,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Serialize async operations to prevent race conditions.
|
||||
*/
|
||||
function enqueue(fn: () => Promise<void>): Promise<void> {
|
||||
chainPromise = chainPromise.then(fn, fn);
|
||||
return chainPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all timers.
|
||||
*/
|
||||
function clearAllTimers(): void {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
if (stallSoftTimer) {
|
||||
clearTimeout(stallSoftTimer);
|
||||
stallSoftTimer = null;
|
||||
}
|
||||
if (stallHardTimer) {
|
||||
clearTimeout(stallHardTimer);
|
||||
stallHardTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear debounce timer only (used during phase transitions).
|
||||
*/
|
||||
function clearDebounceTimer(): void {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset stall timers (called on each phase change).
|
||||
*/
|
||||
function resetStallTimers(): void {
|
||||
if (stallSoftTimer) {
|
||||
clearTimeout(stallSoftTimer);
|
||||
}
|
||||
if (stallHardTimer) {
|
||||
clearTimeout(stallHardTimer);
|
||||
}
|
||||
|
||||
stallSoftTimer = setTimeout(() => {
|
||||
scheduleEmoji(emojis.stallSoft, { immediate: true, skipStallReset: true });
|
||||
}, timing.stallSoftMs);
|
||||
|
||||
stallHardTimer = setTimeout(() => {
|
||||
scheduleEmoji(emojis.stallHard, { immediate: true, skipStallReset: true });
|
||||
}, timing.stallHardMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an emoji: set new reaction and optionally remove old one.
|
||||
*/
|
||||
async function applyEmoji(newEmoji: string): Promise<void> {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const previousEmoji = currentEmoji;
|
||||
await adapter.setReaction(newEmoji);
|
||||
|
||||
// If adapter supports removeReaction and there's a different previous emoji, remove it
|
||||
if (adapter.removeReaction && previousEmoji && previousEmoji !== newEmoji) {
|
||||
await adapter.removeReaction(previousEmoji);
|
||||
}
|
||||
|
||||
currentEmoji = newEmoji;
|
||||
} catch (err) {
|
||||
if (onError) {
|
||||
onError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule an emoji change (debounced or immediate).
|
||||
*/
|
||||
function scheduleEmoji(
|
||||
emoji: string,
|
||||
options: { immediate?: boolean; skipStallReset?: boolean } = {},
|
||||
): void {
|
||||
if (!enabled || finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate: if already scheduled/current, skip send but keep stall timers fresh
|
||||
if (emoji === currentEmoji || emoji === pendingEmoji) {
|
||||
if (!options.skipStallReset) {
|
||||
resetStallTimers();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pendingEmoji = emoji;
|
||||
clearDebounceTimer();
|
||||
|
||||
if (options.immediate) {
|
||||
// Immediate execution for terminal states
|
||||
void enqueue(async () => {
|
||||
await applyEmoji(emoji);
|
||||
pendingEmoji = "";
|
||||
});
|
||||
} else {
|
||||
// Debounced execution for intermediate states
|
||||
debounceTimer = setTimeout(() => {
|
||||
void enqueue(async () => {
|
||||
await applyEmoji(emoji);
|
||||
pendingEmoji = "";
|
||||
});
|
||||
}, timing.debounceMs);
|
||||
}
|
||||
|
||||
// Reset stall timers on phase change (unless triggered by stall timer itself)
|
||||
if (!options.skipStallReset) {
|
||||
resetStallTimers();
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Controller API
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function setQueued(): void {
|
||||
scheduleEmoji(emojis.queued, { immediate: true });
|
||||
}
|
||||
|
||||
function setThinking(): void {
|
||||
scheduleEmoji(emojis.thinking);
|
||||
}
|
||||
|
||||
function setTool(toolName?: string): void {
|
||||
const emoji = resolveToolEmoji(toolName, emojis);
|
||||
scheduleEmoji(emoji);
|
||||
}
|
||||
|
||||
function setDone(): Promise<void> {
|
||||
if (!enabled) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
finished = true;
|
||||
clearAllTimers();
|
||||
|
||||
// Directly enqueue to ensure we return the updated promise
|
||||
return enqueue(async () => {
|
||||
await applyEmoji(emojis.done);
|
||||
pendingEmoji = "";
|
||||
});
|
||||
}
|
||||
|
||||
function setError(): Promise<void> {
|
||||
if (!enabled) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
finished = true;
|
||||
clearAllTimers();
|
||||
|
||||
// Directly enqueue to ensure we return the updated promise
|
||||
return enqueue(async () => {
|
||||
await applyEmoji(emojis.error);
|
||||
pendingEmoji = "";
|
||||
});
|
||||
}
|
||||
|
||||
async function clear(): Promise<void> {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearAllTimers();
|
||||
finished = true;
|
||||
|
||||
await enqueue(async () => {
|
||||
if (adapter.removeReaction) {
|
||||
// Remove all known emojis (Discord-style)
|
||||
const emojisToRemove = Array.from(knownEmojis);
|
||||
for (const emoji of emojisToRemove) {
|
||||
try {
|
||||
await adapter.removeReaction(emoji);
|
||||
} catch (err) {
|
||||
if (onError) {
|
||||
onError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For platforms without removeReaction, set empty or just skip
|
||||
// (Telegram handles this atomically on the next setReaction)
|
||||
}
|
||||
currentEmoji = "";
|
||||
pendingEmoji = "";
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreInitial(): Promise<void> {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearAllTimers();
|
||||
await enqueue(async () => {
|
||||
await applyEmoji(initialEmoji);
|
||||
pendingEmoji = "";
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
setQueued,
|
||||
setThinking,
|
||||
setTool,
|
||||
setDone,
|
||||
setError,
|
||||
clear,
|
||||
restoreInitial,
|
||||
};
|
||||
}
|
||||
@@ -373,6 +373,14 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).",
|
||||
"messages.ackReactionScope":
|
||||
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
|
||||
"messages.statusReactions":
|
||||
"Lifecycle status reactions that update the emoji on the trigger message as the agent progresses (queued → thinking → tool → done/error).",
|
||||
"messages.statusReactions.enabled":
|
||||
"Enable lifecycle status reactions for Telegram. When enabled, the ack reaction becomes the initial 'queued' state and progresses through thinking, tool, done/error automatically. Default: false.",
|
||||
"messages.statusReactions.emojis":
|
||||
"Override default status reaction emojis. Keys: thinking, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.",
|
||||
"messages.statusReactions.timing":
|
||||
"Override default timing. Keys: debounceMs (700), stallSoftMs (25000), stallHardMs (60000), doneHoldMs (1500), errorHoldMs (2500).",
|
||||
"messages.inbound.debounceMs":
|
||||
"Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).",
|
||||
"channels.telegram.dmPolicy":
|
||||
|
||||
@@ -241,6 +241,10 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"messages.suppressToolErrors": "Suppress Tool Error Warnings",
|
||||
"messages.ackReaction": "Ack Reaction Emoji",
|
||||
"messages.ackReactionScope": "Ack Reaction Scope",
|
||||
"messages.statusReactions": "Status Reactions",
|
||||
"messages.statusReactions.enabled": "Enable Status Reactions",
|
||||
"messages.statusReactions.emojis": "Status Reaction Emojis",
|
||||
"messages.statusReactions.timing": "Status Reaction Timing",
|
||||
"messages.inbound.debounceMs": "Inbound Message Debounce (ms)",
|
||||
"talk.apiKey": "Talk API Key",
|
||||
"channels.whatsapp": "WhatsApp",
|
||||
|
||||
@@ -49,6 +49,39 @@ export type AudioConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
export type StatusReactionsEmojiConfig = {
|
||||
thinking?: string;
|
||||
tool?: string;
|
||||
coding?: string;
|
||||
web?: string;
|
||||
done?: string;
|
||||
error?: string;
|
||||
stallSoft?: string;
|
||||
stallHard?: string;
|
||||
};
|
||||
|
||||
export type StatusReactionsTimingConfig = {
|
||||
/** Debounce interval for intermediate states (ms). Default: 700. */
|
||||
debounceMs?: number;
|
||||
/** Soft stall warning timeout (ms). Default: 25000. */
|
||||
stallSoftMs?: number;
|
||||
/** Hard stall warning timeout (ms). Default: 60000. */
|
||||
stallHardMs?: number;
|
||||
/** How long to hold done emoji before cleanup (ms). Default: 1500. */
|
||||
doneHoldMs?: number;
|
||||
/** How long to hold error emoji before cleanup (ms). Default: 2500. */
|
||||
errorHoldMs?: number;
|
||||
};
|
||||
|
||||
export type StatusReactionsConfig = {
|
||||
/** Enable lifecycle status reactions (default: false). */
|
||||
enabled?: boolean;
|
||||
/** Override default emojis. */
|
||||
emojis?: StatusReactionsEmojiConfig;
|
||||
/** Override default timing. */
|
||||
timing?: StatusReactionsTimingConfig;
|
||||
};
|
||||
|
||||
export type MessagesConfig = {
|
||||
/** @deprecated Use `whatsapp.messagePrefix` (WhatsApp-only inbound prefix). */
|
||||
messagePrefix?: string;
|
||||
@@ -82,6 +115,8 @@ export type MessagesConfig = {
|
||||
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
|
||||
/** Remove ack reaction after reply is sent (default: false). */
|
||||
removeAckAfterReply?: boolean;
|
||||
/** Lifecycle status reactions configuration. */
|
||||
statusReactions?: StatusReactionsConfig;
|
||||
/** When true, suppress ⚠️ tool-error warnings from being shown to the user. Default: false. */
|
||||
suppressToolErrors?: boolean;
|
||||
/** Text-to-speech settings for outbound replies. */
|
||||
|
||||
@@ -114,6 +114,35 @@ export const MessagesSchema = z
|
||||
ackReaction: z.string().optional(),
|
||||
ackReactionScope: z.enum(["group-mentions", "group-all", "direct", "all"]).optional(),
|
||||
removeAckAfterReply: z.boolean().optional(),
|
||||
statusReactions: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
emojis: z
|
||||
.object({
|
||||
thinking: z.string().optional(),
|
||||
tool: z.string().optional(),
|
||||
coding: z.string().optional(),
|
||||
web: z.string().optional(),
|
||||
done: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
stallSoft: z.string().optional(),
|
||||
stallHard: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
timing: z
|
||||
.object({
|
||||
debounceMs: z.number().int().min(0).optional(),
|
||||
stallSoftMs: z.number().int().min(0).optional(),
|
||||
stallHardMs: z.number().int().min(0).optional(),
|
||||
doneHoldMs: z.number().int().min(0).optional(),
|
||||
errorHoldMs: z.number().int().min(0).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
suppressToolErrors: z.boolean().optional(),
|
||||
tts: TtsConfigSchema,
|
||||
})
|
||||
|
||||
@@ -15,6 +15,11 @@ import { shouldAckReaction as shouldAckReactionGate } from "../../channels/ack-r
|
||||
import { logTypingFailure, logAckFailure } from "../../channels/logging.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { recordInboundSession } from "../../channels/session.js";
|
||||
import {
|
||||
createStatusReactionController,
|
||||
DEFAULT_TIMING,
|
||||
type StatusReactionAdapter,
|
||||
} from "../../channels/status-reactions.js";
|
||||
import { createTypingCallbacks } from "../../channels/typing.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
|
||||
@@ -43,240 +48,12 @@ import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
import { resolveDiscordAutoThreadReplyPlan, resolveDiscordThreadStarter } from "./threading.js";
|
||||
import { sendTyping } from "./typing.js";
|
||||
|
||||
const DISCORD_STATUS_THINKING_EMOJI = "🧠";
|
||||
const DISCORD_STATUS_TOOL_EMOJI = "🛠️";
|
||||
const DISCORD_STATUS_CODING_EMOJI = "💻";
|
||||
const DISCORD_STATUS_WEB_EMOJI = "🌐";
|
||||
const DISCORD_STATUS_DONE_EMOJI = "✅";
|
||||
const DISCORD_STATUS_ERROR_EMOJI = "❌";
|
||||
const DISCORD_STATUS_STALL_SOFT_EMOJI = "⏳";
|
||||
const DISCORD_STATUS_STALL_HARD_EMOJI = "⚠️";
|
||||
const DISCORD_STATUS_DONE_HOLD_MS = 1500;
|
||||
const DISCORD_STATUS_ERROR_HOLD_MS = 2500;
|
||||
const DISCORD_STATUS_DEBOUNCE_MS = 700;
|
||||
const DISCORD_STATUS_STALL_SOFT_MS = 10_000;
|
||||
const DISCORD_STATUS_STALL_HARD_MS = 30_000;
|
||||
|
||||
const CODING_STATUS_TOOL_TOKENS = [
|
||||
"exec",
|
||||
"process",
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"session_status",
|
||||
"bash",
|
||||
];
|
||||
|
||||
const WEB_STATUS_TOOL_TOKENS = ["web_search", "web-search", "web_fetch", "web-fetch", "browser"];
|
||||
|
||||
function resolveToolStatusEmoji(toolName?: string): string {
|
||||
const normalized = toolName?.trim().toLowerCase() ?? "";
|
||||
if (!normalized) {
|
||||
return DISCORD_STATUS_TOOL_EMOJI;
|
||||
}
|
||||
if (WEB_STATUS_TOOL_TOKENS.some((token) => normalized.includes(token))) {
|
||||
return DISCORD_STATUS_WEB_EMOJI;
|
||||
}
|
||||
if (CODING_STATUS_TOOL_TOKENS.some((token) => normalized.includes(token))) {
|
||||
return DISCORD_STATUS_CODING_EMOJI;
|
||||
}
|
||||
return DISCORD_STATUS_TOOL_EMOJI;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function createDiscordStatusReactionController(params: {
|
||||
enabled: boolean;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
initialEmoji: string;
|
||||
rest: unknown;
|
||||
}) {
|
||||
let activeEmoji: string | null = null;
|
||||
let chain: Promise<void> = Promise.resolve();
|
||||
let pendingEmoji: string | null = null;
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let finished = false;
|
||||
let softStallTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hardStallTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const enqueue = (work: () => Promise<void>) => {
|
||||
chain = chain.then(work).catch((err) => {
|
||||
logAckFailure({
|
||||
log: logVerbose,
|
||||
channel: "discord",
|
||||
target: `${params.channelId}/${params.messageId}`,
|
||||
error: err,
|
||||
});
|
||||
});
|
||||
return chain;
|
||||
};
|
||||
|
||||
const clearStallTimers = () => {
|
||||
if (softStallTimer) {
|
||||
clearTimeout(softStallTimer);
|
||||
softStallTimer = null;
|
||||
}
|
||||
if (hardStallTimer) {
|
||||
clearTimeout(hardStallTimer);
|
||||
hardStallTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearPendingDebounce = () => {
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
pendingTimer = null;
|
||||
}
|
||||
pendingEmoji = null;
|
||||
};
|
||||
|
||||
const applyEmoji = (emoji: string) =>
|
||||
enqueue(async () => {
|
||||
if (!params.enabled || !emoji || activeEmoji === emoji) {
|
||||
return;
|
||||
}
|
||||
const previousEmoji = activeEmoji;
|
||||
await reactMessageDiscord(params.channelId, params.messageId, emoji, {
|
||||
rest: params.rest as never,
|
||||
});
|
||||
activeEmoji = emoji;
|
||||
if (previousEmoji && previousEmoji !== emoji) {
|
||||
await removeReactionDiscord(params.channelId, params.messageId, previousEmoji, {
|
||||
rest: params.rest as never,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const requestEmoji = (emoji: string, options?: { immediate?: boolean }) => {
|
||||
if (!params.enabled || !emoji) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (options?.immediate) {
|
||||
clearPendingDebounce();
|
||||
return applyEmoji(emoji);
|
||||
}
|
||||
pendingEmoji = emoji;
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
}
|
||||
pendingTimer = setTimeout(() => {
|
||||
pendingTimer = null;
|
||||
const emojiToApply = pendingEmoji;
|
||||
pendingEmoji = null;
|
||||
if (!emojiToApply || emojiToApply === activeEmoji) {
|
||||
return;
|
||||
}
|
||||
void applyEmoji(emojiToApply);
|
||||
}, DISCORD_STATUS_DEBOUNCE_MS);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const scheduleStallTimers = () => {
|
||||
if (!params.enabled || finished) {
|
||||
return;
|
||||
}
|
||||
clearStallTimers();
|
||||
softStallTimer = setTimeout(() => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
void requestEmoji(DISCORD_STATUS_STALL_SOFT_EMOJI, { immediate: true });
|
||||
}, DISCORD_STATUS_STALL_SOFT_MS);
|
||||
hardStallTimer = setTimeout(() => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
void requestEmoji(DISCORD_STATUS_STALL_HARD_EMOJI, { immediate: true });
|
||||
}, DISCORD_STATUS_STALL_HARD_MS);
|
||||
};
|
||||
|
||||
const setPhase = (emoji: string) => {
|
||||
if (!params.enabled || finished) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
scheduleStallTimers();
|
||||
return requestEmoji(emoji);
|
||||
};
|
||||
|
||||
const setTerminal = async (emoji: string) => {
|
||||
if (!params.enabled) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
clearStallTimers();
|
||||
await requestEmoji(emoji, { immediate: true });
|
||||
};
|
||||
|
||||
const clear = async () => {
|
||||
if (!params.enabled) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
clearStallTimers();
|
||||
clearPendingDebounce();
|
||||
await enqueue(async () => {
|
||||
const cleanupCandidates = new Set<string>([
|
||||
params.initialEmoji,
|
||||
activeEmoji ?? "",
|
||||
DISCORD_STATUS_THINKING_EMOJI,
|
||||
DISCORD_STATUS_TOOL_EMOJI,
|
||||
DISCORD_STATUS_CODING_EMOJI,
|
||||
DISCORD_STATUS_WEB_EMOJI,
|
||||
DISCORD_STATUS_DONE_EMOJI,
|
||||
DISCORD_STATUS_ERROR_EMOJI,
|
||||
DISCORD_STATUS_STALL_SOFT_EMOJI,
|
||||
DISCORD_STATUS_STALL_HARD_EMOJI,
|
||||
]);
|
||||
activeEmoji = null;
|
||||
for (const emoji of cleanupCandidates) {
|
||||
if (!emoji) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await removeReactionDiscord(params.channelId, params.messageId, emoji, {
|
||||
rest: params.rest as never,
|
||||
});
|
||||
} catch (err) {
|
||||
logAckFailure({
|
||||
log: logVerbose,
|
||||
channel: "discord",
|
||||
target: `${params.channelId}/${params.messageId}`,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const restoreInitial = async () => {
|
||||
if (!params.enabled) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
clearStallTimers();
|
||||
clearPendingDebounce();
|
||||
await requestEmoji(params.initialEmoji, { immediate: true });
|
||||
};
|
||||
|
||||
return {
|
||||
setQueued: () => {
|
||||
scheduleStallTimers();
|
||||
return requestEmoji(params.initialEmoji, { immediate: true });
|
||||
},
|
||||
setThinking: () => setPhase(DISCORD_STATUS_THINKING_EMOJI),
|
||||
setTool: (toolName?: string) => setPhase(resolveToolStatusEmoji(toolName)),
|
||||
setDone: () => setTerminal(DISCORD_STATUS_DONE_EMOJI),
|
||||
setError: () => setTerminal(DISCORD_STATUS_ERROR_EMOJI),
|
||||
clear,
|
||||
restoreInitial,
|
||||
};
|
||||
}
|
||||
|
||||
export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) {
|
||||
const {
|
||||
cfg,
|
||||
@@ -349,12 +126,30 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
}),
|
||||
);
|
||||
const statusReactionsEnabled = shouldAckReaction();
|
||||
const statusReactions = createDiscordStatusReactionController({
|
||||
const discordAdapter: StatusReactionAdapter = {
|
||||
setReaction: async (emoji) => {
|
||||
await reactMessageDiscord(messageChannelId, message.id, emoji, {
|
||||
rest: client.rest as never,
|
||||
});
|
||||
},
|
||||
removeReaction: async (emoji) => {
|
||||
await removeReactionDiscord(messageChannelId, message.id, emoji, {
|
||||
rest: client.rest as never,
|
||||
});
|
||||
},
|
||||
};
|
||||
const statusReactions = createStatusReactionController({
|
||||
enabled: statusReactionsEnabled,
|
||||
channelId: messageChannelId,
|
||||
messageId: message.id,
|
||||
adapter: discordAdapter,
|
||||
initialEmoji: ackReaction,
|
||||
rest: client.rest,
|
||||
onError: (err) => {
|
||||
logAckFailure({
|
||||
log: logVerbose,
|
||||
channel: "discord",
|
||||
target: `${messageChannelId}/${message.id}`,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (statusReactionsEnabled) {
|
||||
void statusReactions.setQueued();
|
||||
@@ -914,7 +709,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
}
|
||||
if (removeAckAfterReply) {
|
||||
void (async () => {
|
||||
await sleep(dispatchError ? DISCORD_STATUS_ERROR_HOLD_MS : DISCORD_STATUS_DONE_HOLD_MS);
|
||||
await sleep(dispatchError ? DEFAULT_TIMING.errorHoldMs : DEFAULT_TIMING.doneHoldMs);
|
||||
await statusReactions.clear();
|
||||
})();
|
||||
} else {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user