Compare commits

...

1 Commits

Author SHA1 Message Date
Keshav's Bot
0dd656bdb3 fix(telegram): refine typing and progress drafts 2026-05-26 19:02:05 +01:00
6 changed files with 146 additions and 11 deletions

View File

@@ -24,10 +24,12 @@ type BuildTelegramMessageContextForTestParams = {
options?: BuildTelegramMessageContextParams["options"];
cfg?: Record<string, unknown>;
accountId?: string;
dmPolicy?: BuildTelegramMessageContextParams["dmPolicy"];
historyLimit?: number;
groupHistories?: Map<string, import("openclaw/plugin-sdk/reply-history").HistoryEntry[]>;
ackReactionScope?: BuildTelegramMessageContextParams["ackReactionScope"];
botApi?: Record<string, unknown>;
sendChatActionHandler?: BuildTelegramMessageContextParams["sendChatActionHandler"];
runtime?: BuildTelegramMessageContextParams["runtime"];
sessionRuntime?: BuildTelegramMessageContextParams["sessionRuntime"] | null;
resolveGroupActivation?: BuildTelegramMessageContextParams["resolveGroupActivation"];
@@ -127,7 +129,7 @@ export async function buildTelegramMessageContextForTest(
account: { accountId: params.accountId ?? "default" } as never,
historyLimit: params.historyLimit ?? 0,
groupHistories: params.groupHistories ?? new Map(),
dmPolicy: "open",
dmPolicy: params.dmPolicy ?? "open",
allowFrom: ["*"],
groupAllowFrom: [],
ackReactionScope: params.ackReactionScope ?? "off",
@@ -140,7 +142,7 @@ export async function buildTelegramMessageContextForTest(
groupConfig: { requireMention: false },
topicConfig: undefined,
})),
sendChatActionHandler: { sendChatAction: vi.fn() } as never,
sendChatActionHandler: params.sendChatActionHandler ?? ({ sendChatAction: vi.fn() } as never),
});
}

View File

@@ -109,6 +109,7 @@ export type TelegramMessageContext = {
sendTyping: () => Promise<void>;
sendRecordVoice: () => Promise<void>;
sendChatActionHandler: BuildTelegramMessageContextParams["sendChatActionHandler"];
initialTypingCueSent?: boolean;
ackReactionPromise: Promise<boolean> | null;
reactionApi: TelegramReactionApi | null;
removeAckAfterReply: boolean;
@@ -368,6 +369,7 @@ export const buildTelegramMessageContext = async ({
) {
return null;
}
let initialTypingCueSent = false;
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
if (!configuredBinding) {
return true;
@@ -480,6 +482,15 @@ export const buildTelegramMessageContext = async ({
return null;
}
// Direct chats are now reply-eligible; send the first typing cue before
// expensive context/session construction without showing typing for dropped turns.
if (!isGroup) {
initialTypingCueSent = true;
void sendTyping().catch((err) => {
logVerbose(`telegram early direct typing cue failed for chat ${chatId}: ${String(err)}`);
});
}
const { ctxPayload, skillFilter, turn } = await buildTelegramInboundContextPayload({
cfg,
primaryCtx,
@@ -643,6 +654,7 @@ export const buildTelegramMessageContext = async ({
sendTyping,
sendRecordVoice,
sendChatActionHandler,
initialTypingCueSent,
ackReactionPromise,
reactionApi,
removeAckAfterReply,

View File

@@ -0,0 +1,80 @@
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
import { describe, expect, it, vi } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
import type { TelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
function createSendChatActionHandler(
sendChatAction = vi.fn(async () => undefined),
): TelegramSendChatActionHandler & { sendChatAction: typeof sendChatAction } {
return {
sendChatAction,
isSuspended: () => false,
reset: () => undefined,
};
}
describe("buildTelegramMessageContext typing", () => {
it("sends direct typing after body resolution and before session context construction", async () => {
const buildInboundContext = vi.fn(buildChannelInboundEventContext);
const sendChatActionHandler = createSendChatActionHandler();
await expect(
buildTelegramMessageContextForTest({
message: {
chat: { id: 42, type: "private", first_name: "Pat" },
from: { id: 42, first_name: "Pat" },
text: "hello",
},
sendChatActionHandler,
sessionRuntime: {
buildChannelInboundEventContext: buildInboundContext,
},
}),
).resolves.not.toBeNull();
expect(sendChatActionHandler.sendChatAction).toHaveBeenCalledWith(42, "typing", undefined);
expect(sendChatActionHandler.sendChatAction.mock.invocationCallOrder[0]).toBeLessThan(
buildInboundContext.mock.invocationCallOrder[0],
);
});
it("does not send direct typing when there is no replyable body", async () => {
const sendChatActionHandler = createSendChatActionHandler();
await expect(
buildTelegramMessageContextForTest({
message: {
chat: { id: 42, type: "private", first_name: "Pat" },
from: { id: 42, first_name: "Pat" },
text: undefined,
},
sendChatActionHandler,
}),
).resolves.toBeNull();
expect(sendChatActionHandler.sendChatAction).not.toHaveBeenCalled();
});
it("does not send early direct typing before DM access passes", async () => {
const sendChatActionHandler = createSendChatActionHandler();
await expect(
buildTelegramMessageContextForTest({
message: {
chat: { id: 42, type: "private", first_name: "Pat" },
from: { id: 42, first_name: "Pat" },
text: "hello",
},
cfg: {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { telegram: { dmPolicy: "disabled", allowFrom: [] } },
messages: { groupChat: { mentionPatterns: [] } },
},
dmPolicy: "disabled",
sendChatActionHandler,
}),
).resolves.toBeNull();
expect(sendChatActionHandler.sendChatAction).not.toHaveBeenCalled();
});
});

View File

@@ -1981,6 +1981,35 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(editMessageTelegram).not.toHaveBeenCalled();
});
it("does not stream text-only tool results into progress drafts", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await dispatcherOptions.deliver(
{ text: "stdout line one\nstdout line two" },
{ kind: "tool" },
);
await replyOptions?.onItemEvent?.({ kind: "search", progressText: "docs lookup" });
return { queuedFinal: false };
},
);
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(answerDraftStream.update).not.toHaveBeenCalledWith(
expect.stringContaining("stdout line one"),
);
expect(answerDraftStream.update).toHaveBeenLastCalledWith(
"Shelling\n\n`🛠️ Exec`\n`🔎 Web Search: docs lookup`",
);
expect(deliverReplies).not.toHaveBeenCalled();
});
it("does not restart progress drafts after final answer delivery", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(

View File

@@ -1752,19 +1752,28 @@ export const dispatchTelegramMessage = async ({
reasoningStepState.noteReasoningHint();
}
if (segment.lane === "answer" && info.kind === "tool") {
if (
nativeToolProgressDraft &&
canUseNativeToolProgressDraft({
payload: effectivePayload,
reply,
buttons: telegramButtons,
})
) {
const canRepresentAsTransientProgress = canUseNativeToolProgressDraft({
payload: effectivePayload,
reply,
buttons: telegramButtons,
});
if (nativeToolProgressDraft && canRepresentAsTransientProgress) {
if (await pushStreamToolProgress(segment.update.text)) {
blockDelivered = true;
continue;
}
}
if (
canRepresentAsTransientProgress &&
streamMode === "progress" &&
answerLane.stream
) {
// Progress-mode streams render tool status in the
// live draft. Do not also emit text-only tool output
// as answer text, or simple commands duplicate and
// restart the progress draft.
continue;
}
await prepareAnswerLaneForToolProgress();
}
const result =

View File

@@ -160,7 +160,10 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
);
}
if (context.ctxPayload.InboundEventKind !== "room_event") {
if (
context.ctxPayload.InboundEventKind !== "room_event" &&
context.initialTypingCueSent !== true
) {
void context.sendTyping().catch((err) => {
logVerbose(`telegram early typing cue failed for chat ${context.chatId}: ${String(err)}`);
});