fix(telegram): make reaction handling soft-fail and message-id resilient (#20236)
* Telegram: soft-fail reactions and fallback to inbound message id * Telegram: soft-fail missing reaction message id * Update CHANGELOG.md --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
parent
ea47ab29bd
commit
c1b75ab8e2
@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm.
|
||||
- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc.
|
||||
- Telegram/Polling: scope persisted polling offsets to bot identity and reuse a single awaited runner-stop path on abort/retry, preventing cross-token offset bleed and overlapping pollers during restart/error recovery. (#10850, #11347) Thanks @talhaorak, @anooprdawar, and @vincentkoc.
|
||||
- Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc.
|
||||
- Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86.
|
||||
- Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc.
|
||||
- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc.
|
||||
|
||||
@ -49,6 +49,8 @@ export function createOpenClawTools(options?: {
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
currentThreadTs?: string;
|
||||
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
|
||||
currentMessageId?: string | number;
|
||||
/** Reply-to mode for Slack auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
@ -96,6 +98,7 @@ export function createOpenClawTools(options?: {
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentChannelProvider: options?.agentChannel,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
currentMessageId: options?.currentMessageId,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
sandboxRoot: options?.sandboxRoot,
|
||||
|
||||
@ -582,6 +582,7 @@ export async function runEmbeddedPiAgent(
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
currentMessageId: params.currentMessageId,
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
sessionFile: params.sessionFile,
|
||||
|
||||
@ -391,6 +391,7 @@ export async function runEmbeddedAttempt(
|
||||
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
currentMessageId: params.currentMessageId,
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
modelHasVision,
|
||||
|
||||
@ -48,6 +48,8 @@ export type RunEmbeddedPiAgentParams = {
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
currentThreadTs?: string;
|
||||
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
|
||||
currentMessageId?: string | number;
|
||||
/** Reply-to mode for Slack auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
|
||||
@ -199,6 +199,8 @@ export function createOpenClawCodingTools(options?: {
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
currentThreadTs?: string;
|
||||
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
|
||||
currentMessageId?: string | number;
|
||||
/** Group id for channel-level tool policy resolution. */
|
||||
groupId?: string | null;
|
||||
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
||||
@ -472,6 +474,7 @@ export function createOpenClawCodingTools(options?: {
|
||||
]),
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
currentMessageId: options?.currentMessageId,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
modelHasVision: options?.modelHasVision,
|
||||
|
||||
@ -35,6 +35,11 @@ describe("readStringOrNumberParam", () => {
|
||||
const params = { chatId: " abc " };
|
||||
expect(readStringOrNumberParam(params, "chatId")).toBe("abc");
|
||||
});
|
||||
|
||||
it("accepts snake_case aliases for camelCase keys", () => {
|
||||
const params = { chat_id: "123" };
|
||||
expect(readStringOrNumberParam(params, "chatId")).toBe("123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readNumberParam", () => {
|
||||
@ -47,6 +52,11 @@ describe("readNumberParam", () => {
|
||||
const params = { messageId: "42.9" };
|
||||
expect(readNumberParam(params, "messageId", { integer: true })).toBe(42);
|
||||
});
|
||||
|
||||
it("accepts snake_case aliases for camelCase keys", () => {
|
||||
const params = { message_id: "42" };
|
||||
expect(readNumberParam(params, "messageId")).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("required parameter validation", () => {
|
||||
|
||||
@ -53,6 +53,24 @@ export function createActionGate<T extends Record<string, boolean | undefined>>(
|
||||
};
|
||||
}
|
||||
|
||||
function toSnakeCaseKey(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function readParamRaw(params: Record<string, unknown>, key: string): unknown {
|
||||
if (Object.hasOwn(params, key)) {
|
||||
return params[key];
|
||||
}
|
||||
const snakeKey = toSnakeCaseKey(key);
|
||||
if (snakeKey !== key && Object.hasOwn(params, snakeKey)) {
|
||||
return params[snakeKey];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function readStringParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
@ -69,7 +87,7 @@ export function readStringParam(
|
||||
options: StringParamOptions = {},
|
||||
) {
|
||||
const { required = false, trim = true, label = key, allowEmpty = false } = options;
|
||||
const raw = params[key];
|
||||
const raw = readParamRaw(params, key);
|
||||
if (typeof raw !== "string") {
|
||||
if (required) {
|
||||
throw new ToolInputError(`${label} required`);
|
||||
@ -92,7 +110,7 @@ export function readStringOrNumberParam(
|
||||
options: { required?: boolean; label?: string } = {},
|
||||
): string | undefined {
|
||||
const { required = false, label = key } = options;
|
||||
const raw = params[key];
|
||||
const raw = readParamRaw(params, key);
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return String(raw);
|
||||
}
|
||||
@ -114,7 +132,7 @@ export function readNumberParam(
|
||||
options: { required?: boolean; label?: string; integer?: boolean } = {},
|
||||
): number | undefined {
|
||||
const { required = false, label = key, integer = false } = options;
|
||||
const raw = params[key];
|
||||
const raw = readParamRaw(params, key);
|
||||
let value: number | undefined;
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
value = raw;
|
||||
@ -152,7 +170,7 @@ export function readStringArrayParam(
|
||||
options: StringParamOptions = {},
|
||||
) {
|
||||
const { required = false, label = key } = options;
|
||||
const raw = params[key];
|
||||
const raw = readParamRaw(params, key);
|
||||
if (Array.isArray(raw)) {
|
||||
const values = raw
|
||||
.filter((entry) => typeof entry === "string")
|
||||
|
||||
@ -238,7 +238,19 @@ function buildSendSchema(options: {
|
||||
|
||||
function buildReactionSchema() {
|
||||
return {
|
||||
messageId: Type.Optional(Type.String()),
|
||||
messageId: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Target message id for reaction. For Telegram, if omitted, defaults to the current inbound message id when available.",
|
||||
}),
|
||||
),
|
||||
message_id: Type.Optional(
|
||||
Type.String({
|
||||
// Intentional duplicate alias for tool-schema discoverability in LLMs.
|
||||
description:
|
||||
"snake_case alias of messageId. For Telegram, if omitted, defaults to the current inbound message id when available.",
|
||||
}),
|
||||
),
|
||||
emoji: Type.Optional(Type.String()),
|
||||
remove: Type.Optional(Type.Boolean()),
|
||||
targetAuthor: Type.Optional(Type.String()),
|
||||
@ -425,6 +437,7 @@ type MessageToolOptions = {
|
||||
currentChannelId?: string;
|
||||
currentChannelProvider?: string;
|
||||
currentThreadTs?: string;
|
||||
currentMessageId?: string | number;
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
sandboxRoot?: string;
|
||||
@ -633,17 +646,23 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
clientDisplayName: "agent",
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
};
|
||||
const hasCurrentMessageId =
|
||||
typeof options?.currentMessageId === "number" ||
|
||||
(typeof options?.currentMessageId === "string" &&
|
||||
options.currentMessageId.trim().length > 0);
|
||||
|
||||
const toolContext =
|
||||
options?.currentChannelId ||
|
||||
options?.currentChannelProvider ||
|
||||
options?.currentThreadTs ||
|
||||
hasCurrentMessageId ||
|
||||
options?.replyToMode ||
|
||||
options?.hasRepliedRef
|
||||
? {
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentChannelProvider: options?.currentChannelProvider,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
currentMessageId: options?.currentMessageId,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
// Direct tool invocations should not add cross-context decoration.
|
||||
|
||||
@ -102,6 +102,46 @@ describe("handleTelegramAction", () => {
|
||||
await expectReactionAdded("extensive");
|
||||
});
|
||||
|
||||
it("accepts snake_case message_id for reactions", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
|
||||
} as OpenClawConfig;
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
message_id: "456",
|
||||
emoji: "✅",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(reactMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
456,
|
||||
"✅",
|
||||
expect.objectContaining({ token: "tok", remove: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("soft-fails when messageId is missing", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
|
||||
} as OpenClawConfig;
|
||||
const result = await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
emoji: "✅",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(result.details).toMatchObject({
|
||||
ok: false,
|
||||
reason: "missing_message_id",
|
||||
});
|
||||
expect(reactMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes reactions on empty emoji", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
|
||||
@ -177,18 +217,10 @@ describe("handleTelegramAction", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
level: "off" as const,
|
||||
expectedMessage: /Telegram agent reactions disabled.*reactionLevel="off"/,
|
||||
},
|
||||
{
|
||||
level: "ack" as const,
|
||||
expectedMessage: /Telegram agent reactions disabled.*reactionLevel="ack"/,
|
||||
},
|
||||
])("blocks reactions when reactionLevel is $level", async ({ level, expectedMessage }) => {
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
it.each(["off", "ack"] as const)(
|
||||
"soft-fails reactions when reactionLevel is %s",
|
||||
async (level) => {
|
||||
const result = await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
@ -196,11 +228,15 @@ describe("handleTelegramAction", () => {
|
||||
emoji: "✅",
|
||||
},
|
||||
reactionConfig(level),
|
||||
),
|
||||
).rejects.toThrow(expectedMessage);
|
||||
});
|
||||
);
|
||||
expect(result.details).toMatchObject({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("also respects legacy actions.reactions gating", async () => {
|
||||
it("soft-fails when reactions are disabled via actions.reactions", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
@ -210,17 +246,19 @@ describe("handleTelegramAction", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
messageId: "456",
|
||||
emoji: "✅",
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/Telegram reactions are disabled via actions.reactions/);
|
||||
const result = await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
messageId: "456",
|
||||
emoji: "✅",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(result.details).toMatchObject({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a text message", async () => {
|
||||
@ -634,18 +672,20 @@ describe("handleTelegramAction per-account gating", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
messageId: 1,
|
||||
emoji: "👀",
|
||||
accountId: "media",
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/reactions are disabled via actions.reactions/i);
|
||||
const result = await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
messageId: 1,
|
||||
emoji: "👀",
|
||||
accountId: "media",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(result.details).toMatchObject({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows account to explicitly re-enable top-level disabled reaction gate", async () => {
|
||||
|
||||
@ -94,42 +94,69 @@ export async function handleTelegramAction(
|
||||
const isActionEnabled = createTelegramActionGate({ cfg, accountId });
|
||||
|
||||
if (action === "react") {
|
||||
// Check reaction level first
|
||||
// All react failures return soft results (jsonResult with ok:false) instead
|
||||
// of throwing, because hard tool errors can trigger model re-generation
|
||||
// loops and duplicate content.
|
||||
const reactionLevelInfo = resolveTelegramReactionLevel({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
if (!reactionLevelInfo.agentReactionsEnabled) {
|
||||
throw new Error(
|
||||
`Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
|
||||
`Set channels.telegram.reactionLevel to "minimal" or "extensive" to enable.`,
|
||||
);
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
hint: `Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). Do not retry.`,
|
||||
});
|
||||
}
|
||||
// Also check the existing action gate for backward compatibility
|
||||
if (!isActionEnabled("reactions")) {
|
||||
throw new Error("Telegram reactions are disabled via actions.reactions.");
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
hint: "Telegram reactions are disabled via actions.reactions. Do not retry.",
|
||||
});
|
||||
}
|
||||
const chatId = readStringOrNumberParam(params, "chatId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readNumberParam(params, "messageId", {
|
||||
required: true,
|
||||
integer: true,
|
||||
});
|
||||
if (typeof messageId !== "number" || !Number.isFinite(messageId) || messageId <= 0) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "missing_message_id",
|
||||
hint: "Telegram reaction requires a valid messageId (or inbound context fallback). Do not retry.",
|
||||
});
|
||||
}
|
||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||
removeErrorMessage: "Emoji is required to remove a Telegram reaction.",
|
||||
});
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "missing_token",
|
||||
hint: "Telegram bot token missing. Do not retry.",
|
||||
});
|
||||
}
|
||||
let reactionResult: Awaited<ReturnType<typeof reactMessageTelegram>>;
|
||||
try {
|
||||
reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", {
|
||||
token,
|
||||
remove,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
const isInvalid = String(err).includes("REACTION_INVALID");
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: isInvalid ? "REACTION_INVALID" : "error",
|
||||
emoji,
|
||||
hint: isInvalid
|
||||
? "This emoji is not supported for Telegram reactions. Add it to your reaction disallow list so you do not try it again."
|
||||
: "Reaction failed. Do not retry.",
|
||||
});
|
||||
}
|
||||
const reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", {
|
||||
token,
|
||||
remove,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
if (!reactionResult.ok) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
|
||||
@ -22,12 +22,17 @@ export function buildThreadingToolContext(params: {
|
||||
hasRepliedRef: { value: boolean } | undefined;
|
||||
}): ChannelThreadingToolContext {
|
||||
const { sessionCtx, config, hasRepliedRef } = params;
|
||||
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
|
||||
if (!config) {
|
||||
return {};
|
||||
return {
|
||||
currentMessageId,
|
||||
};
|
||||
}
|
||||
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
||||
if (!rawProvider) {
|
||||
return {};
|
||||
return {
|
||||
currentMessageId,
|
||||
};
|
||||
}
|
||||
const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider);
|
||||
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
|
||||
@ -36,6 +41,7 @@ export function buildThreadingToolContext(params: {
|
||||
return {
|
||||
currentChannelId: sessionCtx.To?.trim() || undefined,
|
||||
currentChannelProvider: provider ?? (rawProvider as ChannelId),
|
||||
currentMessageId,
|
||||
hasRepliedRef,
|
||||
};
|
||||
}
|
||||
@ -48,6 +54,7 @@ export function buildThreadingToolContext(params: {
|
||||
From: sessionCtx.From,
|
||||
To: sessionCtx.To,
|
||||
ChatType: sessionCtx.ChatType,
|
||||
CurrentMessageId: currentMessageId,
|
||||
ReplyToId: sessionCtx.ReplyToId,
|
||||
ThreadLabel: sessionCtx.ThreadLabel,
|
||||
MessageThreadId: sessionCtx.MessageThreadId,
|
||||
@ -57,6 +64,7 @@ export function buildThreadingToolContext(params: {
|
||||
return {
|
||||
...context,
|
||||
currentChannelProvider: provider!, // guaranteed non-null since dock exists
|
||||
currentMessageId: context.currentMessageId ?? currentMessageId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,12 @@ describe("channels dock", () => {
|
||||
|
||||
const telegramContext = telegramDock?.threading?.buildToolContext?.({
|
||||
cfg: emptyConfig(),
|
||||
context: { To: " room-1 ", MessageThreadId: 42, ReplyToId: "fallback" },
|
||||
context: {
|
||||
To: " room-1 ",
|
||||
MessageThreadId: 42,
|
||||
ReplyToId: "fallback",
|
||||
CurrentMessageId: "9001",
|
||||
},
|
||||
hasRepliedRef,
|
||||
});
|
||||
const googleChatContext = googleChatDock?.threading?.buildToolContext?.({
|
||||
@ -26,6 +31,7 @@ describe("channels dock", () => {
|
||||
expect(telegramContext).toEqual({
|
||||
currentChannelId: "room-1",
|
||||
currentThreadTs: "42",
|
||||
currentMessageId: "9001",
|
||||
hasRepliedRef,
|
||||
});
|
||||
expect(googleChatContext).toEqual({
|
||||
@ -35,6 +41,23 @@ describe("channels dock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("telegram threading does not treat ReplyToId as thread id in DMs", () => {
|
||||
const hasRepliedRef = { value: false };
|
||||
const telegramDock = getChannelDock("telegram");
|
||||
const context = telegramDock?.threading?.buildToolContext?.({
|
||||
cfg: emptyConfig(),
|
||||
context: { To: " dm-1 ", ReplyToId: "12345", CurrentMessageId: "12345" },
|
||||
hasRepliedRef,
|
||||
});
|
||||
|
||||
expect(context).toEqual({
|
||||
currentChannelId: "dm-1",
|
||||
currentThreadTs: undefined,
|
||||
currentMessageId: "12345",
|
||||
hasRepliedRef,
|
||||
});
|
||||
});
|
||||
|
||||
it("irc resolveDefaultTo matches account id case-insensitively", () => {
|
||||
const ircDock = getChannelDock("irc");
|
||||
const cfg = {
|
||||
|
||||
@ -253,8 +253,22 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) =>
|
||||
buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }),
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
// Telegram auto-threading should only use actual thread/topic IDs.
|
||||
// ReplyToId is a message ID and causes invalid message_thread_id in DMs.
|
||||
const threadId = context.MessageThreadId;
|
||||
const rawCurrentMessageId = context.CurrentMessageId;
|
||||
const currentMessageId =
|
||||
typeof rawCurrentMessageId === "number"
|
||||
? rawCurrentMessageId
|
||||
: rawCurrentMessageId?.trim() || undefined;
|
||||
return {
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: threadId != null ? String(threadId) : undefined,
|
||||
currentMessageId,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
|
||||
@ -673,6 +673,83 @@ describe("telegramMessageActions", () => {
|
||||
expect(String(callPayload.messageId)).toBe("456");
|
||||
expect(callPayload.emoji).toBe("ok");
|
||||
});
|
||||
|
||||
it("accepts snake_case message_id for reactions", async () => {
|
||||
const cfg = telegramCfg();
|
||||
|
||||
await telegramMessageActions.handleAction?.({
|
||||
channel: "telegram",
|
||||
action: "react",
|
||||
params: {
|
||||
channelId: 123,
|
||||
message_id: "456",
|
||||
emoji: "ok",
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
});
|
||||
|
||||
expect(handleTelegramAction).toHaveBeenCalledTimes(1);
|
||||
const call = handleTelegramAction.mock.calls[0]?.[0];
|
||||
if (!call) {
|
||||
throw new Error("missing telegram action call");
|
||||
}
|
||||
const callPayload = call as Record<string, unknown>;
|
||||
expect(callPayload.action).toBe("react");
|
||||
expect(String(callPayload.chatId)).toBe("123");
|
||||
expect(String(callPayload.messageId)).toBe("456");
|
||||
});
|
||||
|
||||
it("falls back to toolContext.currentMessageId for reactions when messageId is omitted", async () => {
|
||||
const cfg = telegramCfg();
|
||||
|
||||
await telegramMessageActions.handleAction?.({
|
||||
channel: "telegram",
|
||||
action: "react",
|
||||
params: {
|
||||
chatId: "123",
|
||||
emoji: "ok",
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: { currentMessageId: "9001" },
|
||||
});
|
||||
|
||||
expect(handleTelegramAction).toHaveBeenCalledTimes(1);
|
||||
const call = handleTelegramAction.mock.calls[0]?.[0];
|
||||
if (!call) {
|
||||
throw new Error("missing telegram action call");
|
||||
}
|
||||
const callPayload = call as Record<string, unknown>;
|
||||
expect(callPayload.action).toBe("react");
|
||||
expect(String(callPayload.messageId)).toBe("9001");
|
||||
});
|
||||
|
||||
it("forwards missing reaction messageId to telegram-actions for soft-fail handling", async () => {
|
||||
const cfg = telegramCfg();
|
||||
|
||||
await expect(
|
||||
telegramMessageActions.handleAction?.({
|
||||
channel: "telegram",
|
||||
action: "react",
|
||||
params: {
|
||||
chatId: "123",
|
||||
emoji: "ok",
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
expect(handleTelegramAction).toHaveBeenCalledTimes(1);
|
||||
const call = handleTelegramAction.mock.calls[0]?.[0];
|
||||
if (!call) {
|
||||
throw new Error("missing telegram action call");
|
||||
}
|
||||
const callPayload = call as Record<string, unknown>;
|
||||
expect(callPayload.action).toBe("react");
|
||||
expect(callPayload.messageId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalMessageActions", () => {
|
||||
|
||||
@ -107,7 +107,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
extractToolSend: ({ args }) => {
|
||||
return extractToolSend(args, "sendMessage");
|
||||
},
|
||||
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots }) => {
|
||||
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => {
|
||||
if (action === "send") {
|
||||
const sendParams = readTelegramSendParams(params);
|
||||
return await handleTelegramAction(
|
||||
@ -122,9 +122,8 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
const messageId = readStringOrNumberParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId =
|
||||
readStringOrNumberParam(params, "messageId") ?? toolContext?.currentMessageId;
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
return await handleTelegramAction(
|
||||
|
||||
@ -249,6 +249,7 @@ export type ChannelThreadingContext = {
|
||||
From?: string;
|
||||
To?: string;
|
||||
ChatType?: string;
|
||||
CurrentMessageId?: string | number;
|
||||
ReplyToId?: string;
|
||||
ReplyToIdFull?: string;
|
||||
ThreadLabel?: string;
|
||||
@ -259,6 +260,7 @@ export type ChannelThreadingToolContext = {
|
||||
currentChannelId?: string;
|
||||
currentChannelProvider?: ChannelId;
|
||||
currentThreadTs?: string;
|
||||
currentMessageId?: string | number;
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user