1589: final draft - pre log
This commit is contained in:
parent
6a9301c27d
commit
005b601b13
68
scripts/run-test-gateway.sh
Executable file
68
scripts/run-test-gateway.sh
Executable file
@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PORT="${CLAWDBOT_TEST_GATEWAY_PORT:-19001}"
|
||||||
|
STATE_DIR="${CLAWDBOT_TEST_STATE_DIR:-$HOME/.clawdbot-test}"
|
||||||
|
CONFIG_PATH="${CLAWDBOT_TEST_CONFIG_PATH:-$STATE_DIR/clawdbot.json}"
|
||||||
|
MAIN_CONFIG_PATH="${CLAWDBOT_MAIN_CONFIG_PATH:-$HOME/.clawdbot/clawdbot.json}"
|
||||||
|
TEST_WORKSPACE="${CLAWDBOT_TEST_WORKSPACE:-$HOME/clawd-test}"
|
||||||
|
|
||||||
|
if ! command -v node >/dev/null 2>&1 || ! command -v pnpm >/dev/null 2>&1; then
|
||||||
|
if command -v nix-shell >/dev/null 2>&1 && [[ -z "${IN_NIX_SHELL:-}" ]]; then
|
||||||
|
exec nix-shell -p nodejs_22 pnpm --run "$0 $*"
|
||||||
|
fi
|
||||||
|
echo "Missing node/pnpm. Install them or run inside nix-shell." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$MAIN_CONFIG_PATH" ]]; then
|
||||||
|
echo "Main config not found at $MAIN_CONFIG_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$STATE_DIR" "$TEST_WORKSPACE"
|
||||||
|
|
||||||
|
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||||
|
python - "$MAIN_CONFIG_PATH" "$CONFIG_PATH" "$PORT" "$TEST_WORKSPACE" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
main_path = Path(sys.argv[1])
|
||||||
|
out_path = Path(sys.argv[2])
|
||||||
|
port = int(sys.argv[3])
|
||||||
|
workspace = sys.argv[4]
|
||||||
|
|
||||||
|
with main_path.open() as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
data.setdefault("gateway", {})
|
||||||
|
data["gateway"]["port"] = port
|
||||||
|
data["gateway"]["bind"] = "loopback"
|
||||||
|
data["gateway"]["mode"] = "local"
|
||||||
|
|
||||||
|
data.setdefault("agents", {})
|
||||||
|
data["agents"].setdefault("defaults", {})["workspace"] = workspace
|
||||||
|
|
||||||
|
channels = data.get("channels") or {}
|
||||||
|
if "telegram" in channels:
|
||||||
|
channels["telegram"]["enabled"] = False
|
||||||
|
data["channels"] = channels
|
||||||
|
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with out_path.open("w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
export CLAWDBOT_STATE_DIR="$STATE_DIR"
|
||||||
|
export CLAWDBOT_CONFIG_PATH="$CONFIG_PATH"
|
||||||
|
|
||||||
|
node dist/entry.js gateway --port "$PORT"
|
||||||
@ -37,6 +37,13 @@ import type { FollowupRun } from "./queue.js";
|
|||||||
import { parseReplyDirectives } from "./reply-directives.js";
|
import { parseReplyDirectives } from "./reply-directives.js";
|
||||||
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
|
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
|
||||||
import type { TypingSignaler } from "./typing-mode.js";
|
import type { TypingSignaler } from "./typing-mode.js";
|
||||||
|
import {
|
||||||
|
handleAgentEventForStatus,
|
||||||
|
type StatusUpdateRunContext,
|
||||||
|
} from "./status-updates-integration.js";
|
||||||
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
|
||||||
|
const log = createSubsystemLogger("agent-runner-execution");
|
||||||
|
|
||||||
export type AgentRunLoopResult =
|
export type AgentRunLoopResult =
|
||||||
| {
|
| {
|
||||||
@ -77,6 +84,7 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
activeSessionStore?: Record<string, SessionEntry>;
|
activeSessionStore?: Record<string, SessionEntry>;
|
||||||
storePath?: string;
|
storePath?: string;
|
||||||
resolvedVerboseLevel: VerboseLevel;
|
resolvedVerboseLevel: VerboseLevel;
|
||||||
|
statusUpdateContext?: StatusUpdateRunContext;
|
||||||
}): Promise<AgentRunLoopResult> {
|
}): Promise<AgentRunLoopResult> {
|
||||||
let didLogHeartbeatStrip = false;
|
let didLogHeartbeatStrip = false;
|
||||||
let autoCompactionCompleted = false;
|
let autoCompactionCompleted = false;
|
||||||
@ -283,6 +291,13 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
onAgentEvent: async (evt) => {
|
onAgentEvent: async (evt) => {
|
||||||
|
// ===== STATUS UPDATES: Handle agent events =====
|
||||||
|
if (params.statusUpdateContext) {
|
||||||
|
log.debug(`onAgentEvent: forwarding to status updates, stream=${evt.stream}`);
|
||||||
|
await handleAgentEventForStatus(params.statusUpdateContext, evt);
|
||||||
|
}
|
||||||
|
// ===== END STATUS UPDATES =====
|
||||||
|
|
||||||
// Trigger typing when tools start executing.
|
// Trigger typing when tools start executing.
|
||||||
// Must await to ensure typing indicator starts before tool summaries are emitted.
|
// Must await to ensure typing indicator starts before tool summaries are emitted.
|
||||||
if (evt.stream === "tool") {
|
if (evt.stream === "tool") {
|
||||||
|
|||||||
@ -41,6 +41,17 @@ import { incrementCompactionCount } from "./session-updates.js";
|
|||||||
import type { TypingController } from "./typing.js";
|
import type { TypingController } from "./typing.js";
|
||||||
import { createTypingSignaler } from "./typing-mode.js";
|
import { createTypingSignaler } from "./typing-mode.js";
|
||||||
import { emitDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
import { emitDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||||
|
import {
|
||||||
|
createAgentStatusController,
|
||||||
|
createStatusUpdateRunContext,
|
||||||
|
completeStatusUpdate,
|
||||||
|
cleanupStatusUpdate,
|
||||||
|
handleAgentEventForStatus,
|
||||||
|
noopStatusCallbacks,
|
||||||
|
} from "./status-updates-integration.js";
|
||||||
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
|
||||||
|
const log = createSubsystemLogger("agent-runner");
|
||||||
|
|
||||||
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
|
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@ -112,6 +123,10 @@ export async function runReplyAgent(params: {
|
|||||||
isHeartbeat,
|
isHeartbeat,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ensure we have a runId for event tracking
|
||||||
|
const runId = opts?.runId ?? crypto.randomUUID();
|
||||||
|
const optsWithRunId = opts?.runId ? opts : { ...opts, runId };
|
||||||
|
|
||||||
const shouldEmitToolResult = createShouldEmitToolResult({
|
const shouldEmitToolResult = createShouldEmitToolResult({
|
||||||
sessionKey,
|
sessionKey,
|
||||||
storePath,
|
storePath,
|
||||||
@ -298,13 +313,80 @@ export async function runReplyAgent(params: {
|
|||||||
`Role ordering conflict (${reason}). Restarting session ${sessionKey} -> ${nextSessionId}.`,
|
`Role ordering conflict (${reason}). Restarting session ${sessionKey} -> ${nextSessionId}.`,
|
||||||
cleanupTranscripts: true,
|
cleanupTranscripts: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== STATUS UPDATES INTEGRATION =====
|
||||||
|
// Create status update controller for this agent run
|
||||||
|
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||||
|
|
||||||
|
log.info(`Setting up status updates for agentId=${agentId}, channel=${replyToChannel}`);
|
||||||
|
|
||||||
|
// Create callbacks using onBlockReply if available
|
||||||
|
// NOTE: Edit mode requires channel-specific edit APIs which aren't available
|
||||||
|
// at the agent-runner level. Use mode="inline" in config for now.
|
||||||
|
// Telegram/Discord support editing but need integration at dispatch level.
|
||||||
|
let lastStatusMessageId: string | undefined;
|
||||||
|
const statusCallbacks = optsWithRunId?.onBlockReply
|
||||||
|
? {
|
||||||
|
sendStatus: async (text: string, _messageId?: string) => {
|
||||||
|
log.debug(`Sending status update: ${text}`);
|
||||||
|
// Send as new message via block reply
|
||||||
|
const res = await optsWithRunId.onBlockReply?.(
|
||||||
|
{ text, isStatusMessage: true },
|
||||||
|
{ timeoutMs: 3000 },
|
||||||
|
);
|
||||||
|
if (typeof res === "string") {
|
||||||
|
lastStatusMessageId = res;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
editFinal: async (text: string, messageId: string) => {
|
||||||
|
log.debug(`Editing final status update: ${messageId} (tracked: ${lastStatusMessageId})`);
|
||||||
|
await optsWithRunId.onBlockReply?.(
|
||||||
|
{ text, isStatusMessage: true, editMessageId: messageId },
|
||||||
|
{ timeoutMs: 3000 },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
supportsEdit: () => true,
|
||||||
|
}
|
||||||
|
: noopStatusCallbacks;
|
||||||
|
|
||||||
|
const statusController = createAgentStatusController({
|
||||||
|
cfg,
|
||||||
|
agentId,
|
||||||
|
callbacks: statusCallbacks,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusUpdateContext = createStatusUpdateRunContext(statusController);
|
||||||
|
log.info(
|
||||||
|
`Status update context created, controller=${statusController ? "present" : "undefined"}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to agent events for status phase updates
|
||||||
|
let removeEventListener: (() => void) | undefined;
|
||||||
|
if (statusUpdateContext.controller) {
|
||||||
|
const { onAgentEvent } = await import("../../infra/agent-events.js");
|
||||||
|
removeEventListener = onAgentEvent((evt) => {
|
||||||
|
// Only handle events for this run
|
||||||
|
if (evt.runId === runId) {
|
||||||
|
handleAgentEventForStatus(statusUpdateContext, {
|
||||||
|
stream: evt.stream,
|
||||||
|
data: evt.data,
|
||||||
|
}).catch((err) => {
|
||||||
|
log.debug(`Error handling agent event for status: ${String(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ===== END STATUS UPDATES INTEGRATION =====
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const runStartedAt = Date.now();
|
const runStartedAt = Date.now();
|
||||||
const runOutcome = await runAgentTurnWithFallback({
|
const runOutcome = await runAgentTurnWithFallback({
|
||||||
commandBody,
|
commandBody,
|
||||||
followupRun,
|
followupRun,
|
||||||
sessionCtx,
|
sessionCtx,
|
||||||
opts,
|
opts: optsWithRunId,
|
||||||
typingSignals,
|
typingSignals,
|
||||||
blockReplyPipeline,
|
blockReplyPipeline,
|
||||||
blockStreamingEnabled,
|
blockStreamingEnabled,
|
||||||
@ -322,6 +404,7 @@ export async function runReplyAgent(params: {
|
|||||||
activeSessionStore,
|
activeSessionStore,
|
||||||
storePath,
|
storePath,
|
||||||
resolvedVerboseLevel,
|
resolvedVerboseLevel,
|
||||||
|
statusUpdateContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (runOutcome.kind === "final") {
|
if (runOutcome.kind === "final") {
|
||||||
@ -502,6 +585,20 @@ export async function runReplyAgent(params: {
|
|||||||
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
|
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== STATUS UPDATES: Complete and mark final response =====
|
||||||
|
if (statusUpdateContext && replyPayloads.length > 0) {
|
||||||
|
const lastPayload = replyPayloads[replyPayloads.length - 1];
|
||||||
|
if (lastPayload?.text) {
|
||||||
|
log.info(`[agent-runner] Completing status update with final text (fast-path)`);
|
||||||
|
const markedText = await completeStatusUpdate(statusUpdateContext, lastPayload.text);
|
||||||
|
if (markedText) {
|
||||||
|
// Just update the text. The delivery layer handles replacement.
|
||||||
|
replyPayloads[replyPayloads.length - 1] = { ...lastPayload, text: markedText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ===== END STATUS UPDATES =====
|
||||||
|
|
||||||
return finalizeWithFollowup(
|
return finalizeWithFollowup(
|
||||||
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
||||||
queueKey,
|
queueKey,
|
||||||
@ -510,5 +607,15 @@ export async function runReplyAgent(params: {
|
|||||||
} finally {
|
} finally {
|
||||||
blockReplyPipeline?.stop();
|
blockReplyPipeline?.stop();
|
||||||
typing.markRunComplete();
|
typing.markRunComplete();
|
||||||
|
|
||||||
|
// ===== STATUS UPDATES: Cleanup =====
|
||||||
|
if (removeEventListener) {
|
||||||
|
removeEventListener();
|
||||||
|
}
|
||||||
|
if (statusUpdateContext) {
|
||||||
|
log.info(`Cleaning up status update context`);
|
||||||
|
await cleanupStatusUpdate(statusUpdateContext);
|
||||||
|
}
|
||||||
|
// ===== END STATUS UPDATES =====
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export function createBlockReplyPipeline(params: {
|
|||||||
onBlockReply: (
|
onBlockReply: (
|
||||||
payload: ReplyPayload,
|
payload: ReplyPayload,
|
||||||
options?: { abortSignal?: AbortSignal; timeoutMs?: number },
|
options?: { abortSignal?: AbortSignal; timeoutMs?: number },
|
||||||
) => Promise<void> | void;
|
) => Promise<string | void> | string | void;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
coalescing?: BlockStreamingCoalescing;
|
coalescing?: BlockStreamingCoalescing;
|
||||||
buffer?: BlockReplyBuffer;
|
buffer?: BlockReplyBuffer;
|
||||||
@ -102,10 +102,12 @@ export function createBlockReplyPipeline(params: {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
if (aborted) return false;
|
if (aborted) return false;
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
onBlockReply(payload, {
|
Promise.resolve(
|
||||||
abortSignal: abortController.signal,
|
onBlockReply(payload, {
|
||||||
timeoutMs,
|
abortSignal: abortController.signal,
|
||||||
}) ?? Promise.resolve(),
|
timeoutMs,
|
||||||
|
}),
|
||||||
|
),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
timeoutError,
|
timeoutError,
|
||||||
);
|
);
|
||||||
|
|||||||
201
src/auto-reply/reply/status-updates-integration.ts
Normal file
201
src/auto-reply/reply/status-updates-integration.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Status Updates Integration
|
||||||
|
*
|
||||||
|
* Integrates the status update controller with the agent runner
|
||||||
|
* and provides helpers for resolving configuration from the config.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { StatusUpdateConfig } from "../../config/types.base.js";
|
||||||
|
import type { ChannelCapabilities } from "../../channels/plugins/types.core.js";
|
||||||
|
import {
|
||||||
|
createStatusUpdateController,
|
||||||
|
type StatusPhase,
|
||||||
|
type StatusUpdateCallbacks,
|
||||||
|
type StatusUpdateController,
|
||||||
|
} from "./status-updates.js";
|
||||||
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
|
||||||
|
const log = createSubsystemLogger("status-updates-integration");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve status update configuration from the Clawdbot config.
|
||||||
|
*/
|
||||||
|
export function resolveStatusUpdateConfigFromConfig(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
agentId?: string,
|
||||||
|
): StatusUpdateConfig {
|
||||||
|
log.debug(`Resolving status update config for agentId=${agentId}`);
|
||||||
|
|
||||||
|
// Check agent-specific config first
|
||||||
|
if (agentId && cfg.agents?.list) {
|
||||||
|
const agentCfg = cfg.agents.list.find((a) => a.id === agentId);
|
||||||
|
if (agentCfg?.statusUpdates) {
|
||||||
|
log.debug(`Found agent-specific config: ${JSON.stringify(agentCfg.statusUpdates)}`);
|
||||||
|
return agentCfg.statusUpdates;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to agent defaults
|
||||||
|
const defaultConfig = cfg.agents?.defaults?.statusUpdates ?? {};
|
||||||
|
log.debug(`Using default config: ${JSON.stringify(defaultConfig)}`);
|
||||||
|
return defaultConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a channel supports message editing.
|
||||||
|
*/
|
||||||
|
export function channelSupportsEdit(capabilities?: ChannelCapabilities): boolean {
|
||||||
|
return capabilities?.edit === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a status update controller for an agent run.
|
||||||
|
* Returns undefined if status updates are disabled.
|
||||||
|
*/
|
||||||
|
export function createAgentStatusController(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
agentId?: string;
|
||||||
|
callbacks: StatusUpdateCallbacks;
|
||||||
|
}): StatusUpdateController | undefined {
|
||||||
|
const { cfg, agentId, callbacks } = params;
|
||||||
|
log.info(`createAgentStatusController called for agentId=${agentId}`);
|
||||||
|
|
||||||
|
const config = resolveStatusUpdateConfigFromConfig(cfg, agentId);
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
log.info(`Status updates disabled (enabled=${config.enabled})`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
`Creating status update controller with mode=${config.mode}, supportsEdit=${callbacks.supportsEdit?.()}`,
|
||||||
|
);
|
||||||
|
const controller = createStatusUpdateController(config, callbacks);
|
||||||
|
log.info(`Status update controller created successfully`);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map agent lifecycle events to status phases.
|
||||||
|
*/
|
||||||
|
export function mapAgentEventToPhase(event: {
|
||||||
|
stream: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}): StatusPhase | undefined {
|
||||||
|
const { stream, data } = event;
|
||||||
|
const phase = typeof data.phase === "string" ? data.phase : "";
|
||||||
|
|
||||||
|
if (stream === "lifecycle") {
|
||||||
|
if (phase === "start") return "sending_query";
|
||||||
|
if (phase === "end") return "complete";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream === "tool") {
|
||||||
|
if (phase === "start") return "processing_tools";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream === "thinking" || stream === "reasoning") {
|
||||||
|
return "receiving_reasoning";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream === "message") {
|
||||||
|
if (phase === "start") return "generating_response";
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context for status updates within an agent run.
|
||||||
|
*/
|
||||||
|
export type StatusUpdateRunContext = {
|
||||||
|
controller?: StatusUpdateController;
|
||||||
|
startedAt: number;
|
||||||
|
currentPhase: StatusPhase;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a run context for status updates.
|
||||||
|
*/
|
||||||
|
export function createStatusUpdateRunContext(
|
||||||
|
controller?: StatusUpdateController,
|
||||||
|
): StatusUpdateRunContext {
|
||||||
|
log.info(`createStatusUpdateRunContext: controller=${controller ? "present" : "undefined"}`);
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
controller,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
currentPhase: "sending_query" as StatusPhase,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (controller) {
|
||||||
|
log.info(`Status update context created with controller, starting...`);
|
||||||
|
// Start the controller asynchronously
|
||||||
|
controller.start().catch((err) => {
|
||||||
|
log.debug(`Failed to start status controller: ${String(err)}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.info(`Status update context created WITHOUT controller (status updates disabled)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an agent event and update the status phase if applicable.
|
||||||
|
*/
|
||||||
|
export async function handleAgentEventForStatus(
|
||||||
|
ctx: StatusUpdateRunContext,
|
||||||
|
event: { stream: string; data: Record<string, unknown> },
|
||||||
|
): Promise<void> {
|
||||||
|
if (!ctx.controller) {
|
||||||
|
log.debug(`handleAgentEventForStatus: no controller, skipping event stream=${event.stream}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = mapAgentEventToPhase(event);
|
||||||
|
log.debug(
|
||||||
|
`handleAgentEventForStatus: stream=${event.stream}, phase=${event.data.phase}, mappedPhase=${phase}, currentPhase=${ctx.currentPhase}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (phase && phase !== ctx.currentPhase) {
|
||||||
|
log.info(`Status phase change: ${ctx.currentPhase} -> ${phase}`);
|
||||||
|
ctx.currentPhase = phase;
|
||||||
|
await ctx.controller.setPhase(phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the status as complete and optionally add checkmark to final text.
|
||||||
|
*/
|
||||||
|
export async function completeStatusUpdate(
|
||||||
|
ctx: StatusUpdateRunContext,
|
||||||
|
finalText?: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!ctx.controller) {
|
||||||
|
log.debug(`completeStatusUpdate: no controller, returning original text`);
|
||||||
|
return finalText;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`completeStatusUpdate: finalizing status update`);
|
||||||
|
const result = await ctx.controller.complete(finalText);
|
||||||
|
log.debug(`completeStatusUpdate: result=${result?.substring(0, 50)}...`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup the status update controller.
|
||||||
|
*/
|
||||||
|
export async function cleanupStatusUpdate(ctx: StatusUpdateRunContext): Promise<void> {
|
||||||
|
if (!ctx.controller) return;
|
||||||
|
await ctx.controller.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a no-op callbacks object for channels that don't support status updates.
|
||||||
|
*/
|
||||||
|
export const noopStatusCallbacks: StatusUpdateCallbacks = {
|
||||||
|
sendStatus: async () => undefined,
|
||||||
|
supportsEdit: () => false,
|
||||||
|
};
|
||||||
346
src/auto-reply/reply/status-updates.ts
Normal file
346
src/auto-reply/reply/status-updates.ts
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* Status Updates Module
|
||||||
|
*
|
||||||
|
* Provides intermittent status messages during bot activity to keep users informed
|
||||||
|
* about what's happening while the AI processes their request.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Configurable status message phases
|
||||||
|
* - Elapsed time tracking
|
||||||
|
* - Final response checkmark marking
|
||||||
|
* - Platform-specific message update handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
|
||||||
|
const log = createSubsystemLogger("status-updates");
|
||||||
|
|
||||||
|
export type StatusPhase =
|
||||||
|
| "sending_query"
|
||||||
|
| "receiving_reasoning"
|
||||||
|
| "processing_tools"
|
||||||
|
| "generating_response"
|
||||||
|
| "complete";
|
||||||
|
|
||||||
|
export type StatusUpdateMode = "off" | "edit" | "inline";
|
||||||
|
|
||||||
|
export type StatusUpdateConfig = {
|
||||||
|
/** Enable status updates (default: false). */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Mode for status updates: "off", "edit" (update same message), "inline" (new messages). */
|
||||||
|
mode?: StatusUpdateMode;
|
||||||
|
/** Interval in milliseconds between elapsed time updates (default: 5000). */
|
||||||
|
updateIntervalMs?: number;
|
||||||
|
/** Show phase descriptions like "Sending query..." (default: true). */
|
||||||
|
showPhases?: boolean;
|
||||||
|
/** Show elapsed time in status messages (default: true). */
|
||||||
|
showElapsedTime?: boolean;
|
||||||
|
/** Mark final responses with ✅ (default: true). */
|
||||||
|
markFinalWithCheckmark?: boolean;
|
||||||
|
/** Minimum duration before showing elapsed time (ms, default: 3000). */
|
||||||
|
elapsedTimeThresholdMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatusUpdateCallbacks = {
|
||||||
|
/** Send or update status message. Returns message ID if applicable. */
|
||||||
|
sendStatus: (text: string, messageId?: string) => Promise<string | undefined>;
|
||||||
|
/** Edit an existing message with final content and checkmark. */
|
||||||
|
editFinal?: (text: string, messageId: string) => Promise<void>;
|
||||||
|
/** Check if the channel supports message editing. */
|
||||||
|
supportsEdit?: () => boolean;
|
||||||
|
/** Delete a status message (for cleanup). */
|
||||||
|
deleteStatus?: (messageId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatusUpdateState = {
|
||||||
|
phase: StatusPhase;
|
||||||
|
startedAt: number;
|
||||||
|
statusMessageId?: string;
|
||||||
|
lastUpdateAt: number;
|
||||||
|
elapsedSeconds: number;
|
||||||
|
isComplete: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: Required<StatusUpdateConfig> = {
|
||||||
|
enabled: false,
|
||||||
|
mode: "edit",
|
||||||
|
updateIntervalMs: 5000,
|
||||||
|
showPhases: true,
|
||||||
|
showElapsedTime: true,
|
||||||
|
markFinalWithCheckmark: true,
|
||||||
|
elapsedTimeThresholdMs: 3000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PHASE_MESSAGES: Record<StatusPhase, string> = {
|
||||||
|
sending_query: "Sending query to AI model",
|
||||||
|
receiving_reasoning: "Processing reasoning data",
|
||||||
|
processing_tools: "Executing tools",
|
||||||
|
generating_response: "Generating response",
|
||||||
|
complete: "Complete",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PHASE_EMOJI: Record<StatusPhase, string> = {
|
||||||
|
sending_query: "⏳",
|
||||||
|
receiving_reasoning: "🧠",
|
||||||
|
processing_tools: "🔧",
|
||||||
|
generating_response: "✍️",
|
||||||
|
complete: "✅",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveStatusUpdateConfig(
|
||||||
|
config?: Partial<StatusUpdateConfig>,
|
||||||
|
): Required<StatusUpdateConfig> {
|
||||||
|
return {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsedTime(seconds: number): string {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
const paddedSeconds = remainingSeconds.toString().padStart(2, "0");
|
||||||
|
return `${minutes}:${paddedSeconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatusMessage(
|
||||||
|
phase: StatusPhase,
|
||||||
|
elapsedSeconds: number,
|
||||||
|
config: Required<StatusUpdateConfig>,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (config.showPhases) {
|
||||||
|
const emoji = PHASE_EMOJI[phase];
|
||||||
|
const message = PHASE_MESSAGES[phase];
|
||||||
|
parts.push(`${emoji} ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.showElapsedTime && elapsedSeconds * 1000 >= config.elapsedTimeThresholdMs) {
|
||||||
|
const elapsed = formatElapsedTime(elapsedSeconds);
|
||||||
|
parts.push(`(${elapsed})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(" ") || "Processing...";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StatusUpdateController {
|
||||||
|
private config: Required<StatusUpdateConfig>;
|
||||||
|
private callbacks: StatusUpdateCallbacks;
|
||||||
|
private state: StatusUpdateState;
|
||||||
|
private updateTimer: NodeJS.Timeout | null = null;
|
||||||
|
private stopped = false;
|
||||||
|
private editedInPlace: boolean = false;
|
||||||
|
|
||||||
|
constructor(config: StatusUpdateConfig, callbacks: StatusUpdateCallbacks) {
|
||||||
|
this.config = resolveStatusUpdateConfig(config);
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
this.state = {
|
||||||
|
phase: "sending_query",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
lastUpdateAt: Date.now(),
|
||||||
|
elapsedSeconds: 0,
|
||||||
|
isComplete: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.config.enabled && this.config.mode !== "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
wasEditedInPlace(): boolean {
|
||||||
|
return this.editedInPlace;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusMessageId(): string | undefined {
|
||||||
|
return this.state.statusMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
supportsEdit(): boolean {
|
||||||
|
return this.callbacks.supportsEdit?.() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (!this.isEnabled() || this.stopped) return;
|
||||||
|
|
||||||
|
log.debug("Starting status updates");
|
||||||
|
|
||||||
|
// Send initial status message
|
||||||
|
await this.sendUpdate();
|
||||||
|
|
||||||
|
// Start update timer if showing elapsed time
|
||||||
|
if (this.config.showElapsedTime) {
|
||||||
|
this.startUpdateTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPhase(phase: StatusPhase): Promise<void> {
|
||||||
|
if (!this.isEnabled() || this.stopped || this.state.isComplete) return;
|
||||||
|
|
||||||
|
if (phase === this.state.phase) return;
|
||||||
|
|
||||||
|
log.debug(`Status phase: ${this.state.phase} -> ${phase}`);
|
||||||
|
this.state.phase = phase;
|
||||||
|
|
||||||
|
if (phase === "complete") {
|
||||||
|
this.state.isComplete = true;
|
||||||
|
this.stopUpdateTimer();
|
||||||
|
return; // Suppress redundant sendUpdate for "complete" phase
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sendUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(finalText?: string): Promise<string | undefined> {
|
||||||
|
if (this.stopped) return undefined;
|
||||||
|
|
||||||
|
this.state.isComplete = true;
|
||||||
|
this.stopUpdateTimer();
|
||||||
|
this.stopped = true;
|
||||||
|
|
||||||
|
if (!this.isEnabled()) return finalText;
|
||||||
|
|
||||||
|
// If we have a final text and config says to mark with checkmark
|
||||||
|
if (finalText && this.config.markFinalWithCheckmark) {
|
||||||
|
this.state.elapsedSeconds = Math.floor((Date.now() - this.state.startedAt) / 1000);
|
||||||
|
// NOTE: We rely on Telegram delivery to do the edit/replacement, so we just format
|
||||||
|
// the string correctly for the final response.
|
||||||
|
const marked = `${finalText.trimEnd()} _(${formatElapsedTime(this.state.elapsedSeconds)})_`;
|
||||||
|
|
||||||
|
return marked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up status message if it exists and we're not editing
|
||||||
|
if (this.state.statusMessageId && this.callbacks.deleteStatus) {
|
||||||
|
try {
|
||||||
|
await this.callbacks.deleteStatus(this.state.statusMessageId);
|
||||||
|
} catch (err) {
|
||||||
|
log.debug(`Failed to delete status message: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalText;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
this.stopUpdateTimer();
|
||||||
|
this.stopped = true;
|
||||||
|
|
||||||
|
// Delete status message if it exists and response is being sent separately
|
||||||
|
if (this.state.statusMessageId && !this.state.isComplete && this.callbacks.deleteStatus) {
|
||||||
|
try {
|
||||||
|
await this.callbacks.deleteStatus(this.state.statusMessageId);
|
||||||
|
} catch (err) {
|
||||||
|
log.debug(`Failed to cleanup status message: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendUpdate(): Promise<void> {
|
||||||
|
if (this.stopped) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
this.state.elapsedSeconds = Math.floor((now - this.state.startedAt) / 1000);
|
||||||
|
this.state.lastUpdateAt = now;
|
||||||
|
|
||||||
|
const text = formatStatusMessage(this.state.phase, this.state.elapsedSeconds, this.config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.config.mode === "edit" && this.supportsEdit() && this.state.statusMessageId) {
|
||||||
|
// Edit existing message
|
||||||
|
this.state.statusMessageId = await this.callbacks.sendStatus(
|
||||||
|
text,
|
||||||
|
this.state.statusMessageId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Send new message (or first message in edit mode)
|
||||||
|
const messageId = await this.callbacks.sendStatus(text, this.state.statusMessageId);
|
||||||
|
if (messageId) {
|
||||||
|
this.state.statusMessageId = messageId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.debug(`Failed to send status update: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startUpdateTimer(): void {
|
||||||
|
if (this.updateTimer) return;
|
||||||
|
|
||||||
|
this.updateTimer = setInterval(async () => {
|
||||||
|
if (this.stopped || this.state.isComplete) {
|
||||||
|
this.stopUpdateTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.sendUpdate();
|
||||||
|
}, this.config.updateIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopUpdateTimer(): void {
|
||||||
|
if (this.updateTimer) {
|
||||||
|
clearInterval(this.updateTimer);
|
||||||
|
this.updateTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatusUpdateController(
|
||||||
|
config: StatusUpdateConfig,
|
||||||
|
callbacks: StatusUpdateCallbacks,
|
||||||
|
): StatusUpdateController {
|
||||||
|
return new StatusUpdateController(config, callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a text response with the completion checkmark if enabled.
|
||||||
|
*/
|
||||||
|
export function markResponseComplete(
|
||||||
|
text: string | undefined,
|
||||||
|
config?: StatusUpdateConfig,
|
||||||
|
): string | undefined {
|
||||||
|
if (!text) return text;
|
||||||
|
|
||||||
|
const resolved = resolveStatusUpdateConfig(config);
|
||||||
|
if (!resolved.enabled || !resolved.markFinalWithCheckmark) return text;
|
||||||
|
|
||||||
|
// Don't double-mark
|
||||||
|
if (text.trimEnd().endsWith("✅")) return text;
|
||||||
|
|
||||||
|
return `${text.trimEnd()} ✅`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create status update callbacks for channels that support message editing.
|
||||||
|
*/
|
||||||
|
export function createEditableStatusCallbacks(params: {
|
||||||
|
sendMessage: (text: string) => Promise<string | undefined>;
|
||||||
|
editMessage: (text: string, messageId: string) => Promise<void>;
|
||||||
|
deleteMessage?: (messageId: string) => Promise<void>;
|
||||||
|
}): StatusUpdateCallbacks {
|
||||||
|
return {
|
||||||
|
sendStatus: async (text, messageId) => {
|
||||||
|
if (messageId) {
|
||||||
|
await params.editMessage(text, messageId);
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
return params.sendMessage(text);
|
||||||
|
},
|
||||||
|
editFinal: params.editMessage,
|
||||||
|
supportsEdit: () => true,
|
||||||
|
deleteStatus: params.deleteMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create status update callbacks for channels that don't support editing.
|
||||||
|
*/
|
||||||
|
export function createInlineStatusCallbacks(params: {
|
||||||
|
sendMessage: (text: string) => Promise<string | undefined>;
|
||||||
|
}): StatusUpdateCallbacks {
|
||||||
|
return {
|
||||||
|
sendStatus: async (text) => {
|
||||||
|
return params.sendMessage(text);
|
||||||
|
},
|
||||||
|
supportsEdit: () => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -27,7 +27,10 @@ export type GetReplyOptions = {
|
|||||||
isHeartbeat?: boolean;
|
isHeartbeat?: boolean;
|
||||||
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
|
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
|
||||||
onReasoningStream?: (payload: ReplyPayload) => Promise<void> | void;
|
onReasoningStream?: (payload: ReplyPayload) => Promise<void> | void;
|
||||||
onBlockReply?: (payload: ReplyPayload, context?: BlockReplyContext) => Promise<void> | void;
|
onBlockReply?: (
|
||||||
|
payload: ReplyPayload,
|
||||||
|
context?: BlockReplyContext,
|
||||||
|
) => Promise<string | void> | string | void;
|
||||||
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
|
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
|
||||||
/** Called when the actual model is selected (including after fallback).
|
/** Called when the actual model is selected (including after fallback).
|
||||||
* Use this to get model/provider/thinkLevel for responsePrefix template interpolation. */
|
* Use this to get model/provider/thinkLevel for responsePrefix template interpolation. */
|
||||||
@ -51,5 +54,9 @@ export type ReplyPayload = {
|
|||||||
replyToCurrent?: boolean;
|
replyToCurrent?: boolean;
|
||||||
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
|
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
|
||||||
audioAsVoice?: boolean;
|
audioAsVoice?: boolean;
|
||||||
|
isStatusMessage?: boolean;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
editMessageId?: string;
|
||||||
|
/** Whether this payload should replace the last status message (provider-specific logic). */
|
||||||
|
replaceStatus?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -262,6 +262,14 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"agents.defaults.humanDelay.mode": "Human Delay Mode",
|
"agents.defaults.humanDelay.mode": "Human Delay Mode",
|
||||||
"agents.defaults.humanDelay.minMs": "Human Delay Min (ms)",
|
"agents.defaults.humanDelay.minMs": "Human Delay Min (ms)",
|
||||||
"agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)",
|
"agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)",
|
||||||
|
"agents.defaults.statusUpdates": "Status Updates",
|
||||||
|
"agents.defaults.statusUpdates.enabled": "Enable Status Updates",
|
||||||
|
"agents.defaults.statusUpdates.mode": "Status Update Mode",
|
||||||
|
"agents.defaults.statusUpdates.updateIntervalMs": "Status Update Interval (ms)",
|
||||||
|
"agents.defaults.statusUpdates.showPhases": "Show Processing Phases",
|
||||||
|
"agents.defaults.statusUpdates.showElapsedTime": "Show Elapsed Time",
|
||||||
|
"agents.defaults.statusUpdates.markFinalWithCheckmark": "Mark Final with ✅",
|
||||||
|
"agents.defaults.statusUpdates.elapsedTimeThresholdMs": "Elapsed Time Threshold (ms)",
|
||||||
"agents.defaults.cliBackends": "CLI Backends",
|
"agents.defaults.cliBackends": "CLI Backends",
|
||||||
"commands.native": "Native Commands",
|
"commands.native": "Native Commands",
|
||||||
"commands.nativeSkills": "Native Skill Commands",
|
"commands.nativeSkills": "Native Skill Commands",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type {
|
|||||||
BlockStreamingChunkConfig,
|
BlockStreamingChunkConfig,
|
||||||
BlockStreamingCoalesceConfig,
|
BlockStreamingCoalesceConfig,
|
||||||
HumanDelayConfig,
|
HumanDelayConfig,
|
||||||
|
StatusUpdateConfig,
|
||||||
TypingMode,
|
TypingMode,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
@ -161,6 +162,11 @@ export type AgentDefaultsConfig = {
|
|||||||
typingIntervalSeconds?: number;
|
typingIntervalSeconds?: number;
|
||||||
/** Typing indicator start mode (never|instant|thinking|message). */
|
/** Typing indicator start mode (never|instant|thinking|message). */
|
||||||
typingMode?: TypingMode;
|
typingMode?: TypingMode;
|
||||||
|
/**
|
||||||
|
* Intermittent status message configuration.
|
||||||
|
* Sends status updates during AI processing to keep users informed.
|
||||||
|
*/
|
||||||
|
statusUpdates?: StatusUpdateConfig;
|
||||||
/** Periodic background heartbeat runs. */
|
/** Periodic background heartbeat runs. */
|
||||||
heartbeat?: {
|
heartbeat?: {
|
||||||
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
|
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { AgentDefaultsConfig } from "./types.agent-defaults.js";
|
import type { AgentDefaultsConfig } from "./types.agent-defaults.js";
|
||||||
import type { HumanDelayConfig, IdentityConfig } from "./types.base.js";
|
import type { HumanDelayConfig, IdentityConfig, StatusUpdateConfig } from "./types.base.js";
|
||||||
import type { GroupChatConfig } from "./types.messages.js";
|
import type { GroupChatConfig } from "./types.messages.js";
|
||||||
import type {
|
import type {
|
||||||
SandboxBrowserSettings,
|
SandboxBrowserSettings,
|
||||||
@ -29,6 +29,8 @@ export type AgentConfig = {
|
|||||||
humanDelay?: HumanDelayConfig;
|
humanDelay?: HumanDelayConfig;
|
||||||
/** Optional per-agent heartbeat overrides. */
|
/** Optional per-agent heartbeat overrides. */
|
||||||
heartbeat?: AgentDefaultsConfig["heartbeat"];
|
heartbeat?: AgentDefaultsConfig["heartbeat"];
|
||||||
|
/** Per-agent status update configuration. */
|
||||||
|
statusUpdates?: StatusUpdateConfig;
|
||||||
identity?: IdentityConfig;
|
identity?: IdentityConfig;
|
||||||
groupChat?: GroupChatConfig;
|
groupChat?: GroupChatConfig;
|
||||||
subagents?: {
|
subagents?: {
|
||||||
|
|||||||
@ -164,3 +164,28 @@ export type IdentityConfig = {
|
|||||||
/** Avatar image: workspace-relative path, http(s) URL, or data URI. */
|
/** Avatar image: workspace-relative path, http(s) URL, or data URI. */
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status Update Configuration
|
||||||
|
*
|
||||||
|
* Configures intermittent status messages sent during AI processing
|
||||||
|
* to keep users informed about what's happening.
|
||||||
|
*/
|
||||||
|
export type StatusUpdateMode = "off" | "edit" | "inline";
|
||||||
|
|
||||||
|
export type StatusUpdateConfig = {
|
||||||
|
/** Enable status updates (default: false). */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Mode for status updates: "off", "edit" (update same message), "inline" (new messages). */
|
||||||
|
mode?: StatusUpdateMode;
|
||||||
|
/** Interval in milliseconds between elapsed time updates (default: 5000). */
|
||||||
|
updateIntervalMs?: number;
|
||||||
|
/** Show phase descriptions like "Sending query..." (default: true). */
|
||||||
|
showPhases?: boolean;
|
||||||
|
/** Show elapsed time in status messages (default: true). */
|
||||||
|
showElapsedTime?: boolean;
|
||||||
|
/** Mark final responses with ✅ (default: true). */
|
||||||
|
markFinalWithCheckmark?: boolean;
|
||||||
|
/** Minimum duration before showing elapsed time (ms, default: 3000). */
|
||||||
|
elapsedTimeThresholdMs?: number;
|
||||||
|
};
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
BlockStreamingCoalesceSchema,
|
BlockStreamingCoalesceSchema,
|
||||||
CliBackendSchema,
|
CliBackendSchema,
|
||||||
HumanDelaySchema,
|
HumanDelaySchema,
|
||||||
|
StatusUpdateSchema,
|
||||||
} from "./zod-schema.core.js";
|
} from "./zod-schema.core.js";
|
||||||
|
|
||||||
export const AgentDefaultsSchema = z
|
export const AgentDefaultsSchema = z
|
||||||
@ -132,6 +133,8 @@ export const AgentDefaultsSchema = z
|
|||||||
z.literal("message"),
|
z.literal("message"),
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
|
/** Configure intermittent status messages during AI processing. */
|
||||||
|
statusUpdates: StatusUpdateSchema,
|
||||||
heartbeat: HeartbeatSchema,
|
heartbeat: HeartbeatSchema,
|
||||||
maxConcurrent: z.number().int().positive().optional(),
|
maxConcurrent: z.number().int().positive().optional(),
|
||||||
subagents: z
|
subagents: z
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
GroupChatSchema,
|
GroupChatSchema,
|
||||||
HumanDelaySchema,
|
HumanDelaySchema,
|
||||||
IdentitySchema,
|
IdentitySchema,
|
||||||
|
StatusUpdateSchema,
|
||||||
ToolsLinksSchema,
|
ToolsLinksSchema,
|
||||||
ToolsMediaSchema,
|
ToolsMediaSchema,
|
||||||
} from "./zod-schema.core.js";
|
} from "./zod-schema.core.js";
|
||||||
@ -397,6 +398,8 @@ export const AgentEntrySchema = z
|
|||||||
memorySearch: MemorySearchSchema,
|
memorySearch: MemorySearchSchema,
|
||||||
humanDelay: HumanDelaySchema.optional(),
|
humanDelay: HumanDelaySchema.optional(),
|
||||||
heartbeat: HeartbeatSchema,
|
heartbeat: HeartbeatSchema,
|
||||||
|
/** Per-agent status update configuration. */
|
||||||
|
statusUpdates: StatusUpdateSchema,
|
||||||
identity: IdentitySchema,
|
identity: IdentitySchema,
|
||||||
groupChat: GroupChatSchema,
|
groupChat: GroupChatSchema,
|
||||||
subagents: z
|
subagents: z
|
||||||
|
|||||||
@ -500,3 +500,35 @@ export const ProviderCommandsSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status Updates Configuration Schema
|
||||||
|
*
|
||||||
|
* Configures intermittent status messages sent during AI processing
|
||||||
|
* to keep users informed about what's happening.
|
||||||
|
*/
|
||||||
|
export const StatusUpdateModeSchema = z.union([
|
||||||
|
z.literal("off"),
|
||||||
|
z.literal("edit"),
|
||||||
|
z.literal("inline"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const StatusUpdateSchema = z
|
||||||
|
.object({
|
||||||
|
/** Enable status updates (default: false). */
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
/** Mode for status updates: "off", "edit" (update same message), "inline" (new messages). */
|
||||||
|
mode: StatusUpdateModeSchema.optional(),
|
||||||
|
/** Interval in milliseconds between elapsed time updates (default: 5000). */
|
||||||
|
updateIntervalMs: z.number().int().positive().optional(),
|
||||||
|
/** Show phase descriptions like "Sending query..." (default: true). */
|
||||||
|
showPhases: z.boolean().optional(),
|
||||||
|
/** Show elapsed time in status messages (default: true). */
|
||||||
|
showElapsedTime: z.boolean().optional(),
|
||||||
|
/** Mark final responses with ✅ (default: true). */
|
||||||
|
markFinalWithCheckmark: z.boolean().optional(),
|
||||||
|
/** Minimum duration before showing elapsed time (ms, default: 3000). */
|
||||||
|
elapsedTimeThresholdMs: z.number().int().nonnegative().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional();
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { resolveTelegramForumThreadId } from "./bot/helpers.js";
|
|||||||
import type { TelegramMessage } from "./bot/types.js";
|
import type { TelegramMessage } from "./bot/types.js";
|
||||||
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
|
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
|
||||||
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
|
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
|
||||||
|
import { invalidateLastStatus } from "./sent-message-cache.js";
|
||||||
import { migrateTelegramGroupConfig } from "./group-migration.js";
|
import { migrateTelegramGroupConfig } from "./group-migration.js";
|
||||||
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
||||||
import { readTelegramAllowFromStore } from "./pairing-store.js";
|
import { readTelegramAllowFromStore } from "./pairing-store.js";
|
||||||
@ -383,6 +384,10 @@ export const registerTelegramHandlers = ({
|
|||||||
if (shouldSkipUpdate(ctx)) return;
|
if (shouldSkipUpdate(ctx)) return;
|
||||||
|
|
||||||
const chatId = msg.chat.id;
|
const chatId = msg.chat.id;
|
||||||
|
// Invalidate status message state on ANY inbound message.
|
||||||
|
// This ensures we never edit a previous turn's message.
|
||||||
|
invalidateLastStatus(chatId);
|
||||||
|
|
||||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||||
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||||
@ -477,6 +482,7 @@ export const registerTelegramHandlers = ({
|
|||||||
// Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars).
|
// Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars).
|
||||||
// We buffer “near-limit” messages and append immediately-following parts.
|
// We buffer “near-limit” messages and append immediately-following parts.
|
||||||
const text = typeof msg.text === "string" ? msg.text : undefined;
|
const text = typeof msg.text === "string" ? msg.text : undefined;
|
||||||
|
|
||||||
const isCommandLike = (text ?? "").trim().startsWith("/");
|
const isCommandLike = (text ?? "").trim().startsWith("/");
|
||||||
if (text && !isCommandLike) {
|
if (text && !isCommandLike) {
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
|||||||
import { deliverReplies } from "./bot/delivery.js";
|
import { deliverReplies } from "./bot/delivery.js";
|
||||||
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
||||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||||
|
import { editMessageTelegram } from "./send.js";
|
||||||
|
|
||||||
export const dispatchTelegramMessage = async ({
|
export const dispatchTelegramMessage = async ({
|
||||||
context,
|
context,
|
||||||
@ -143,6 +144,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
replies: [payload],
|
replies: [payload],
|
||||||
chatId: String(chatId),
|
chatId: String(chatId),
|
||||||
token: opts.token,
|
token: opts.token,
|
||||||
|
accountId: route.accountId,
|
||||||
runtime,
|
runtime,
|
||||||
bot,
|
bot,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
|
|||||||
@ -341,6 +341,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
replies: [payload],
|
replies: [payload],
|
||||||
chatId: String(chatId),
|
chatId: String(chatId),
|
||||||
token: opts.token,
|
token: opts.token,
|
||||||
|
accountId,
|
||||||
runtime,
|
runtime,
|
||||||
bot,
|
bot,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
|
|||||||
@ -32,6 +32,7 @@ describe("deliverReplies", () => {
|
|||||||
replies: [{ audioAsVoice: true }],
|
replies: [{ audioAsVoice: true }],
|
||||||
chatId: "123",
|
chatId: "123",
|
||||||
token: "tok",
|
token: "tok",
|
||||||
|
accountId: "test",
|
||||||
runtime,
|
runtime,
|
||||||
bot,
|
bot,
|
||||||
replyToMode: "off",
|
replyToMode: "off",
|
||||||
@ -63,6 +64,7 @@ describe("deliverReplies", () => {
|
|||||||
replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }],
|
replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }],
|
||||||
chatId: "123",
|
chatId: "123",
|
||||||
token: "tok",
|
token: "tok",
|
||||||
|
accountId: "test",
|
||||||
runtime,
|
runtime,
|
||||||
bot,
|
bot,
|
||||||
replyToMode: "off",
|
replyToMode: "off",
|
||||||
@ -93,6 +95,7 @@ describe("deliverReplies", () => {
|
|||||||
replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "hi **boss**" }],
|
replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "hi **boss**" }],
|
||||||
chatId: "123",
|
chatId: "123",
|
||||||
token: "tok",
|
token: "tok",
|
||||||
|
accountId: "test",
|
||||||
runtime,
|
runtime,
|
||||||
bot,
|
bot,
|
||||||
replyToMode: "off",
|
replyToMode: "off",
|
||||||
@ -121,6 +124,7 @@ describe("deliverReplies", () => {
|
|||||||
replies: [{ text: "Check https://example.com" }],
|
replies: [{ text: "Check https://example.com" }],
|
||||||
chatId: "123",
|
chatId: "123",
|
||||||
token: "tok",
|
token: "tok",
|
||||||
|
accountId: "test",
|
||||||
runtime,
|
runtime,
|
||||||
bot,
|
bot,
|
||||||
replyToMode: "off",
|
replyToMode: "off",
|
||||||
@ -149,6 +153,7 @@ describe("deliverReplies", () => {
|
|||||||
replies: [{ text: "Check https://example.com" }],
|
replies: [{ text: "Check https://example.com" }],
|
||||||
chatId: "123",
|
chatId: "123",
|
||||||
token: "tok",
|
token: "tok",
|
||||||
|
accountId: "test",
|
||||||
runtime,
|
runtime,
|
||||||
bot,
|
bot,
|
||||||
replyToMode: "off",
|
replyToMode: "off",
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { sendMessageTelegram, editMessageTelegram } from "../send.js";
|
||||||
import { type Bot, InputFile } from "grammy";
|
import { type Bot, InputFile } from "grammy";
|
||||||
import {
|
import {
|
||||||
markdownToTelegramChunks,
|
markdownToTelegramChunks,
|
||||||
@ -6,6 +7,7 @@ import {
|
|||||||
} from "../format.js";
|
} from "../format.js";
|
||||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
|
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
|
||||||
import { splitTelegramCaption } from "../caption.js";
|
import { splitTelegramCaption } from "../caption.js";
|
||||||
|
import { getLastSentMessage, recordSentMessage } from "../sent-message-cache.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import type { ReplyToMode } from "../../config/config.js";
|
import type { ReplyToMode } from "../../config/config.js";
|
||||||
import type { MarkdownTableMode } from "../../config/types.base.js";
|
import type { MarkdownTableMode } from "../../config/types.base.js";
|
||||||
@ -27,6 +29,7 @@ export async function deliverReplies(params: {
|
|||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
chatId: string;
|
chatId: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
accountId?: string;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
bot: Bot;
|
bot: Bot;
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
@ -80,20 +83,71 @@ export async function deliverReplies(params: {
|
|||||||
? [reply.mediaUrl]
|
? [reply.mediaUrl]
|
||||||
: [];
|
: [];
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
const chunks = chunkText(reply.text || "");
|
// Check if the previous message was a status message.
|
||||||
for (const chunk of chunks) {
|
// If so, we should edit it instead of sending a new message.
|
||||||
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
const lastSent = getLastSentMessage(chatId);
|
||||||
replyToMessageId:
|
const shouldEditLast =
|
||||||
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
|
lastSent?.isStatus && lastSent.messageId && Date.now() - lastSent.timestamp < 120000; // 2 min threshold
|
||||||
messageThreadId,
|
|
||||||
textMode: "html",
|
let sentMessageId: string | undefined;
|
||||||
plainText: chunk.text,
|
|
||||||
linkPreview,
|
if (shouldEditLast && reply.text) {
|
||||||
});
|
const editId = String(lastSent!.messageId);
|
||||||
if (replyToId && !hasReplied) {
|
console.error(
|
||||||
hasReplied = true;
|
`[CRITICAL LOG] [telegram/bot/delivery.ts] Editing previous STATUS message ${editId} with: ${JSON.stringify(reply.text)}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await editMessageTelegram(chatId, editId, reply.text, {
|
||||||
|
token: params.token,
|
||||||
|
accountId: params.accountId,
|
||||||
|
api: bot.api,
|
||||||
|
});
|
||||||
|
sentMessageId = editId;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[CRITICAL LOG] [telegram/bot/delivery.ts] Edit failed, falling back to new message: ${String(err)}`,
|
||||||
|
);
|
||||||
|
// Fall through to send new message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!sentMessageId && reply.isStatusMessage && reply.text) {
|
||||||
|
console.error(
|
||||||
|
`[CRITICAL LOG] [telegram/bot/delivery.ts] Sending NEW status message: ${JSON.stringify(reply.text)}`,
|
||||||
|
);
|
||||||
|
const result = await sendMessageTelegram(chatId, reply.text, {
|
||||||
|
token: params.token,
|
||||||
|
accountId: params.accountId,
|
||||||
|
api: bot.api,
|
||||||
|
isStatusMessage: true,
|
||||||
|
messageThreadId,
|
||||||
|
});
|
||||||
|
sentMessageId = result.messageId;
|
||||||
|
} else if (!sentMessageId) {
|
||||||
|
const chunks = chunkText(reply.text || "");
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
console.error(
|
||||||
|
`[CRITICAL LOG] [telegram/bot/delivery.ts] Sending text chunk: ${JSON.stringify(chunk.text)}`,
|
||||||
|
);
|
||||||
|
const msgId = await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
||||||
|
replyToMessageId:
|
||||||
|
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
|
||||||
|
messageThreadId,
|
||||||
|
textMode: "html",
|
||||||
|
plainText: chunk.text,
|
||||||
|
linkPreview,
|
||||||
|
});
|
||||||
|
if (msgId) sentMessageId = String(msgId);
|
||||||
|
if (replyToId && !hasReplied) {
|
||||||
|
hasReplied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the message state for next time
|
||||||
|
if (sentMessageId) {
|
||||||
|
recordSentMessage(chatId, Number(sentMessageId), !!reply.isStatusMessage);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// media with optional caption on first item
|
// media with optional caption on first item
|
||||||
|
|||||||
243
src/telegram/send.intermittent.test.ts
Normal file
243
src/telegram/send.intermittent.test.ts
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { botApi, botCtorSpy } = vi.hoisted(() => ({
|
||||||
|
botApi: {
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
editMessageText: vi.fn(),
|
||||||
|
},
|
||||||
|
botCtorSpy: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("grammy", () => ({
|
||||||
|
Bot: class {
|
||||||
|
api = botApi;
|
||||||
|
constructor(
|
||||||
|
public token: string,
|
||||||
|
public options?: {
|
||||||
|
client?: { fetch?: typeof fetch; timeoutSeconds?: number };
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
botCtorSpy(token, options);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
InputFile: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { loadConfig } = vi.hoisted(() => ({
|
||||||
|
loadConfig: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadConfig,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getLastSentMessage, recordSentMessage, getLastStatusMessage, recordStatusMessage } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
|
getLastSentMessage: vi.fn(),
|
||||||
|
recordSentMessage: vi.fn(),
|
||||||
|
getLastStatusMessage: vi.fn(),
|
||||||
|
recordStatusMessage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./sent-message-cache.js", () => ({
|
||||||
|
getLastSentMessage,
|
||||||
|
recordSentMessage,
|
||||||
|
getLastStatusMessage,
|
||||||
|
recordStatusMessage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { sendMessageTelegram } from "./send.js";
|
||||||
|
|
||||||
|
describe("sendMessageTelegram status message editing", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
loadConfig.mockReturnValue({});
|
||||||
|
botApi.sendMessage.mockReset();
|
||||||
|
botApi.editMessageText.mockReset();
|
||||||
|
getLastSentMessage.mockReset();
|
||||||
|
recordSentMessage.mockReset();
|
||||||
|
getLastStatusMessage.mockReset();
|
||||||
|
recordStatusMessage.mockReset();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends new message if isStatusMessage is false (reversion check)", async () => {
|
||||||
|
const chatId = "123";
|
||||||
|
const text = "Hello";
|
||||||
|
|
||||||
|
botApi.sendMessage.mockResolvedValue({
|
||||||
|
message_id: 100,
|
||||||
|
chat: { id: chatId },
|
||||||
|
});
|
||||||
|
// Even if we have a recent status message, it should be ignored if isStatusMessage is false
|
||||||
|
const now = 100000;
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
getLastStatusMessage.mockReturnValue({
|
||||||
|
messageId: 99,
|
||||||
|
timestamp: now - 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendMessageTelegram(chatId, text, { token: "tok" });
|
||||||
|
|
||||||
|
expect(botApi.sendMessage).toHaveBeenCalledWith(chatId, text, expect.anything());
|
||||||
|
expect(botApi.editMessageText).not.toHaveBeenCalled();
|
||||||
|
expect(recordSentMessage).toHaveBeenCalledWith(chatId, 100);
|
||||||
|
expect(recordStatusMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends new message if no previous status message", async () => {
|
||||||
|
const chatId = "123";
|
||||||
|
const text = "Status update";
|
||||||
|
|
||||||
|
botApi.sendMessage.mockResolvedValue({
|
||||||
|
message_id: 100,
|
||||||
|
chat: { id: chatId },
|
||||||
|
});
|
||||||
|
getLastStatusMessage.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
await sendMessageTelegram(chatId, text, { token: "tok", isStatusMessage: true });
|
||||||
|
|
||||||
|
expect(botApi.sendMessage).toHaveBeenCalledWith(chatId, text, expect.anything());
|
||||||
|
expect(botApi.editMessageText).not.toHaveBeenCalled();
|
||||||
|
expect(recordStatusMessage).toHaveBeenCalledWith(chatId, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits previous status message if sent < 10s ago", async () => {
|
||||||
|
const chatId = "123";
|
||||||
|
const text = "Updated status";
|
||||||
|
const lastMsgId = 100;
|
||||||
|
|
||||||
|
// Setup last sent message 5s ago
|
||||||
|
const now = 100000;
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
getLastStatusMessage.mockReturnValue({
|
||||||
|
messageId: lastMsgId,
|
||||||
|
timestamp: now - 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
botApi.editMessageText.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await sendMessageTelegram(chatId, text, { token: "tok", isStatusMessage: true });
|
||||||
|
|
||||||
|
expect(botApi.editMessageText).toHaveBeenCalledWith(
|
||||||
|
chatId,
|
||||||
|
lastMsgId,
|
||||||
|
text,
|
||||||
|
expect.objectContaining({ parse_mode: "HTML" }),
|
||||||
|
);
|
||||||
|
expect(botApi.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(res.messageId).toBe("100");
|
||||||
|
// Should update timestamp for same messageId
|
||||||
|
expect(recordStatusMessage).toHaveBeenCalledWith(chatId, lastMsgId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends new message if status sent > 10s ago", async () => {
|
||||||
|
const chatId = "123";
|
||||||
|
const text = "New status";
|
||||||
|
const lastMsgId = 100;
|
||||||
|
|
||||||
|
// Setup last sent message 11s ago
|
||||||
|
const now = 100000;
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
getLastStatusMessage.mockReturnValue({
|
||||||
|
messageId: lastMsgId,
|
||||||
|
timestamp: now - 11000,
|
||||||
|
});
|
||||||
|
|
||||||
|
botApi.sendMessage.mockResolvedValue({
|
||||||
|
message_id: 101,
|
||||||
|
chat: { id: chatId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendMessageTelegram(chatId, text, { token: "tok", isStatusMessage: true });
|
||||||
|
|
||||||
|
expect(botApi.sendMessage).toHaveBeenCalled();
|
||||||
|
expect(botApi.editMessageText).not.toHaveBeenCalled();
|
||||||
|
expect(recordStatusMessage).toHaveBeenCalledWith(chatId, 101);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to send if edit fails", async () => {
|
||||||
|
const chatId = "123";
|
||||||
|
const text = "Updated status";
|
||||||
|
const lastMsgId = 100;
|
||||||
|
|
||||||
|
const now = 100000;
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
getLastStatusMessage.mockReturnValue({
|
||||||
|
messageId: lastMsgId,
|
||||||
|
timestamp: now - 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
botApi.editMessageText.mockRejectedValue(new Error("Message not found"));
|
||||||
|
botApi.sendMessage.mockResolvedValue({
|
||||||
|
message_id: 102,
|
||||||
|
chat: { id: chatId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendMessageTelegram(chatId, text, { token: "tok", isStatusMessage: true });
|
||||||
|
|
||||||
|
expect(botApi.editMessageText).toHaveBeenCalled();
|
||||||
|
expect(botApi.sendMessage).toHaveBeenCalled(); // Fallback
|
||||||
|
expect(recordStatusMessage).toHaveBeenCalledWith(chatId, 102);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats 'message is not modified' error as success", async () => {
|
||||||
|
const chatId = "123";
|
||||||
|
const text = "Same status";
|
||||||
|
const lastMsgId = 100;
|
||||||
|
|
||||||
|
const now = 100000;
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
getLastStatusMessage.mockReturnValue({
|
||||||
|
messageId: lastMsgId,
|
||||||
|
timestamp: now - 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
botApi.editMessageText.mockRejectedValue(
|
||||||
|
new Error(
|
||||||
|
"Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await sendMessageTelegram(chatId, text, { token: "tok", isStatusMessage: true });
|
||||||
|
|
||||||
|
expect(botApi.editMessageText).toHaveBeenCalled();
|
||||||
|
expect(botApi.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(res.messageId).toBe("100");
|
||||||
|
// Should still update timestamp (treated as success)
|
||||||
|
expect(recordStatusMessage).toHaveBeenCalledWith(chatId, lastMsgId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes buttons when editing status", async () => {
|
||||||
|
const chatId = "123";
|
||||||
|
const text = "Status with buttons";
|
||||||
|
const lastMsgId = 100;
|
||||||
|
const buttons = [[{ text: "Btn", callback_data: "data" }]];
|
||||||
|
|
||||||
|
const now = 100000;
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
getLastStatusMessage.mockReturnValue({
|
||||||
|
messageId: lastMsgId,
|
||||||
|
timestamp: now - 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
botApi.editMessageText.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await sendMessageTelegram(chatId, text, { token: "tok", buttons, isStatusMessage: true });
|
||||||
|
|
||||||
|
expect(botApi.editMessageText).toHaveBeenCalledWith(
|
||||||
|
chatId,
|
||||||
|
lastMsgId,
|
||||||
|
text,
|
||||||
|
expect.objectContaining({
|
||||||
|
reply_markup: { inline_keyboard: buttons },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -42,6 +42,7 @@ type TelegramSendOpts = {
|
|||||||
messageThreadId?: number;
|
messageThreadId?: number;
|
||||||
/** Inline keyboard buttons (reply markup). */
|
/** Inline keyboard buttons (reply markup). */
|
||||||
buttons?: Array<Array<{ text: string; callback_data: string }>>;
|
buttons?: Array<Array<{ text: string; callback_data: string }>>;
|
||||||
|
isStatusMessage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TelegramSendResult = {
|
type TelegramSendResult = {
|
||||||
@ -202,6 +203,10 @@ export async function sendMessageTelegram(
|
|||||||
const linkPreviewEnabled = account.config.linkPreview ?? true;
|
const linkPreviewEnabled = account.config.linkPreview ?? true;
|
||||||
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
||||||
|
|
||||||
|
// Status message optimization:
|
||||||
|
// Moved to delivery.ts to centralize logic.
|
||||||
|
// We no longer attempt to optimize here to avoid race conditions and split-brain logic.
|
||||||
|
|
||||||
const sendTelegramText = async (
|
const sendTelegramText = async (
|
||||||
rawText: string,
|
rawText: string,
|
||||||
params?: Record<string, unknown>,
|
params?: Record<string, unknown>,
|
||||||
@ -241,6 +246,11 @@ export async function sendMessageTelegram(
|
|||||||
throw wrapChatNotFound(err);
|
throw wrapChatNotFound(err);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (res?.message_id) {
|
||||||
|
console.error(
|
||||||
|
`[CRITICAL LOG] [telegram/send.ts] Sent message ${res.message_id} to ${chatId}. Payload: ${JSON.stringify({ text: rawText, params: sendParams })}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -324,7 +334,11 @@ export async function sendMessageTelegram(
|
|||||||
const mediaMessageId = String(result?.message_id ?? "unknown");
|
const mediaMessageId = String(result?.message_id ?? "unknown");
|
||||||
const resolvedChatId = String(result?.chat?.id ?? chatId);
|
const resolvedChatId = String(result?.chat?.id ?? chatId);
|
||||||
if (result?.message_id) {
|
if (result?.message_id) {
|
||||||
recordSentMessage(chatId, result.message_id);
|
if (opts.isStatusMessage) {
|
||||||
|
recordSentMessage(chatId, result.message_id, true);
|
||||||
|
} else {
|
||||||
|
recordSentMessage(chatId, result.message_id, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
recordChannelActivity({
|
recordChannelActivity({
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
@ -366,7 +380,11 @@ export async function sendMessageTelegram(
|
|||||||
const res = await sendTelegramText(text, textParams, opts.plainText);
|
const res = await sendTelegramText(text, textParams, opts.plainText);
|
||||||
const messageId = String(res?.message_id ?? "unknown");
|
const messageId = String(res?.message_id ?? "unknown");
|
||||||
if (res?.message_id) {
|
if (res?.message_id) {
|
||||||
recordSentMessage(chatId, res.message_id);
|
if (opts.isStatusMessage) {
|
||||||
|
recordSentMessage(chatId, Number(res.message_id), true);
|
||||||
|
} else {
|
||||||
|
recordSentMessage(chatId, Number(res.message_id), false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
recordChannelActivity({
|
recordChannelActivity({
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
@ -451,6 +469,89 @@ export async function deleteMessageTelegram(
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TelegramEditOpts = {
|
||||||
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
|
verbose?: boolean;
|
||||||
|
api?: Bot["api"];
|
||||||
|
retry?: RetryConfig;
|
||||||
|
textMode?: "markdown" | "html";
|
||||||
|
buttons?: TelegramSendOpts["buttons"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function editMessageTelegram(
|
||||||
|
chatIdInput: string | number,
|
||||||
|
messageIdInput: string | number,
|
||||||
|
text: string,
|
||||||
|
opts: TelegramEditOpts = {},
|
||||||
|
): Promise<{ ok: true }> {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const account = resolveTelegramAccount({
|
||||||
|
cfg,
|
||||||
|
accountId: opts.accountId,
|
||||||
|
});
|
||||||
|
const token = resolveToken(opts.token, account);
|
||||||
|
const chatId = normalizeChatId(String(chatIdInput));
|
||||||
|
const messageId = normalizeMessageId(messageIdInput);
|
||||||
|
const fetchImpl = resolveTelegramFetch();
|
||||||
|
const client: ApiClientOptions | undefined = fetchImpl
|
||||||
|
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
||||||
|
: undefined;
|
||||||
|
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||||
|
const request = createTelegramRetryRunner({
|
||||||
|
retry: opts.retry,
|
||||||
|
configRetry: account.config.retry,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
|
const replyMarkup = buildInlineKeyboard(opts.buttons);
|
||||||
|
|
||||||
|
const renderHtmlText = (rawText: string) =>
|
||||||
|
renderTelegramHtmlText(rawText, {
|
||||||
|
tableMode: resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "telegram",
|
||||||
|
accountId: opts.accountId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const textMode = opts.textMode ?? "html";
|
||||||
|
const htmlText = textMode === "html" ? renderHtmlText(text) : text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request(
|
||||||
|
() =>
|
||||||
|
api.editMessageText(chatId, messageId, htmlText, {
|
||||||
|
parse_mode: textMode === "html" ? "HTML" : "MarkdownV2",
|
||||||
|
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||||
|
}),
|
||||||
|
"editMessage",
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
`[CRITICAL LOG] [telegram/send.ts] Edited message ${messageId} in chat ${chatId}. Payload: ${JSON.stringify({ text, htmlText })}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// If HTML parsing fails, fall back to plain text
|
||||||
|
const errText = formatErrorMessage(err);
|
||||||
|
if (PARSE_ERR_RE.test(errText)) {
|
||||||
|
if (opts.verbose) {
|
||||||
|
console.warn(`telegram HTML parse failed on edit, retrying as plain text: ${errText}`);
|
||||||
|
}
|
||||||
|
await request(
|
||||||
|
() =>
|
||||||
|
api.editMessageText(chatId, messageId, text, {
|
||||||
|
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||||
|
}),
|
||||||
|
"editMessage-plain",
|
||||||
|
);
|
||||||
|
logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId} (plain text fallback)`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
|
|||||||
@ -8,6 +8,7 @@ const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|||||||
type CacheEntry = {
|
type CacheEntry = {
|
||||||
messageIds: Set<number>;
|
messageIds: Set<number>;
|
||||||
timestamps: Map<number, number>;
|
timestamps: Map<number, number>;
|
||||||
|
lastSent?: { messageId: number; timestamp: number; isStatus: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
const sentMessages = new Map<string, CacheEntry>();
|
const sentMessages = new Map<string, CacheEntry>();
|
||||||
@ -29,21 +30,50 @@ function cleanupExpired(entry: CacheEntry): void {
|
|||||||
/**
|
/**
|
||||||
* Record a message ID as sent by the bot.
|
* Record a message ID as sent by the bot.
|
||||||
*/
|
*/
|
||||||
export function recordSentMessage(chatId: number | string, messageId: number): void {
|
export function recordSentMessage(
|
||||||
|
chatId: number | string,
|
||||||
|
messageId: number,
|
||||||
|
isStatus: boolean = false,
|
||||||
|
): void {
|
||||||
const key = getChatKey(chatId);
|
const key = getChatKey(chatId);
|
||||||
let entry = sentMessages.get(key);
|
let entry = sentMessages.get(key);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = { messageIds: new Set(), timestamps: new Map() };
|
entry = { messageIds: new Set(), timestamps: new Map() };
|
||||||
sentMessages.set(key, entry);
|
sentMessages.set(key, entry);
|
||||||
}
|
}
|
||||||
|
const now = Date.now();
|
||||||
entry.messageIds.add(messageId);
|
entry.messageIds.add(messageId);
|
||||||
entry.timestamps.set(messageId, Date.now());
|
entry.timestamps.set(messageId, now);
|
||||||
|
entry.lastSent = { messageId, timestamp: now, isStatus };
|
||||||
// Periodic cleanup
|
// Periodic cleanup
|
||||||
if (entry.messageIds.size > 100) {
|
if (entry.messageIds.size > 100) {
|
||||||
cleanupExpired(entry);
|
cleanupExpired(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate the "status" flag of the last sent message.
|
||||||
|
* This prevents subsequent messages from overwriting it.
|
||||||
|
*/
|
||||||
|
export function invalidateLastStatus(chatId: number | string): void {
|
||||||
|
const key = getChatKey(chatId);
|
||||||
|
const entry = sentMessages.get(key);
|
||||||
|
if (entry?.lastSent) {
|
||||||
|
entry.lastSent.isStatus = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last sent message ID and timestamp for a chat.
|
||||||
|
*/
|
||||||
|
export function getLastSentMessage(
|
||||||
|
chatId: number | string,
|
||||||
|
): { messageId: number; timestamp: number; isStatus: boolean } | undefined {
|
||||||
|
const key = getChatKey(chatId);
|
||||||
|
const entry = sentMessages.get(key);
|
||||||
|
return entry?.lastSent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a message was sent by the bot.
|
* Check if a message was sent by the bot.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user