mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:51:36 +00:00
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:
@@ -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.
|
- 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.
|
- 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/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.
|
- 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.
|
- 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.
|
- 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;
|
currentChannelId?: string;
|
||||||
/** Current thread timestamp for auto-threading (Slack). */
|
/** Current thread timestamp for auto-threading (Slack). */
|
||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
|
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
|
||||||
|
currentMessageId?: string | number;
|
||||||
/** Reply-to mode for Slack auto-threading. */
|
/** Reply-to mode for Slack auto-threading. */
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||||
@@ -96,6 +98,7 @@ export function createOpenClawTools(options?: {
|
|||||||
currentChannelId: options?.currentChannelId,
|
currentChannelId: options?.currentChannelId,
|
||||||
currentChannelProvider: options?.agentChannel,
|
currentChannelProvider: options?.agentChannel,
|
||||||
currentThreadTs: options?.currentThreadTs,
|
currentThreadTs: options?.currentThreadTs,
|
||||||
|
currentMessageId: options?.currentMessageId,
|
||||||
replyToMode: options?.replyToMode,
|
replyToMode: options?.replyToMode,
|
||||||
hasRepliedRef: options?.hasRepliedRef,
|
hasRepliedRef: options?.hasRepliedRef,
|
||||||
sandboxRoot: options?.sandboxRoot,
|
sandboxRoot: options?.sandboxRoot,
|
||||||
|
|||||||
@@ -582,6 +582,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
senderIsOwner: params.senderIsOwner,
|
senderIsOwner: params.senderIsOwner,
|
||||||
currentChannelId: params.currentChannelId,
|
currentChannelId: params.currentChannelId,
|
||||||
currentThreadTs: params.currentThreadTs,
|
currentThreadTs: params.currentThreadTs,
|
||||||
|
currentMessageId: params.currentMessageId,
|
||||||
replyToMode: params.replyToMode,
|
replyToMode: params.replyToMode,
|
||||||
hasRepliedRef: params.hasRepliedRef,
|
hasRepliedRef: params.hasRepliedRef,
|
||||||
sessionFile: params.sessionFile,
|
sessionFile: params.sessionFile,
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ export async function runEmbeddedAttempt(
|
|||||||
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
|
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
|
||||||
currentChannelId: params.currentChannelId,
|
currentChannelId: params.currentChannelId,
|
||||||
currentThreadTs: params.currentThreadTs,
|
currentThreadTs: params.currentThreadTs,
|
||||||
|
currentMessageId: params.currentMessageId,
|
||||||
replyToMode: params.replyToMode,
|
replyToMode: params.replyToMode,
|
||||||
hasRepliedRef: params.hasRepliedRef,
|
hasRepliedRef: params.hasRepliedRef,
|
||||||
modelHasVision,
|
modelHasVision,
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export type RunEmbeddedPiAgentParams = {
|
|||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
/** Current thread timestamp for auto-threading (Slack). */
|
/** Current thread timestamp for auto-threading (Slack). */
|
||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
|
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
|
||||||
|
currentMessageId?: string | number;
|
||||||
/** Reply-to mode for Slack auto-threading. */
|
/** Reply-to mode for Slack auto-threading. */
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
/** Current thread timestamp for auto-threading (Slack). */
|
/** Current thread timestamp for auto-threading (Slack). */
|
||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
|
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
|
||||||
|
currentMessageId?: string | number;
|
||||||
/** Group id for channel-level tool policy resolution. */
|
/** Group id for channel-level tool policy resolution. */
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
||||||
@@ -472,6 +474,7 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
]),
|
]),
|
||||||
currentChannelId: options?.currentChannelId,
|
currentChannelId: options?.currentChannelId,
|
||||||
currentThreadTs: options?.currentThreadTs,
|
currentThreadTs: options?.currentThreadTs,
|
||||||
|
currentMessageId: options?.currentMessageId,
|
||||||
replyToMode: options?.replyToMode,
|
replyToMode: options?.replyToMode,
|
||||||
hasRepliedRef: options?.hasRepliedRef,
|
hasRepliedRef: options?.hasRepliedRef,
|
||||||
modelHasVision: options?.modelHasVision,
|
modelHasVision: options?.modelHasVision,
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ describe("readStringOrNumberParam", () => {
|
|||||||
const params = { chatId: " abc " };
|
const params = { chatId: " abc " };
|
||||||
expect(readStringOrNumberParam(params, "chatId")).toBe("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", () => {
|
describe("readNumberParam", () => {
|
||||||
@@ -47,6 +52,11 @@ describe("readNumberParam", () => {
|
|||||||
const params = { messageId: "42.9" };
|
const params = { messageId: "42.9" };
|
||||||
expect(readNumberParam(params, "messageId", { integer: true })).toBe(42);
|
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", () => {
|
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(
|
export function readStringParam(
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
key: string,
|
key: string,
|
||||||
@@ -69,7 +87,7 @@ export function readStringParam(
|
|||||||
options: StringParamOptions = {},
|
options: StringParamOptions = {},
|
||||||
) {
|
) {
|
||||||
const { required = false, trim = true, label = key, allowEmpty = false } = options;
|
const { required = false, trim = true, label = key, allowEmpty = false } = options;
|
||||||
const raw = params[key];
|
const raw = readParamRaw(params, key);
|
||||||
if (typeof raw !== "string") {
|
if (typeof raw !== "string") {
|
||||||
if (required) {
|
if (required) {
|
||||||
throw new ToolInputError(`${label} required`);
|
throw new ToolInputError(`${label} required`);
|
||||||
@@ -92,7 +110,7 @@ export function readStringOrNumberParam(
|
|||||||
options: { required?: boolean; label?: string } = {},
|
options: { required?: boolean; label?: string } = {},
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const { required = false, label = key } = options;
|
const { required = false, label = key } = options;
|
||||||
const raw = params[key];
|
const raw = readParamRaw(params, key);
|
||||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||||
return String(raw);
|
return String(raw);
|
||||||
}
|
}
|
||||||
@@ -114,7 +132,7 @@ export function readNumberParam(
|
|||||||
options: { required?: boolean; label?: string; integer?: boolean } = {},
|
options: { required?: boolean; label?: string; integer?: boolean } = {},
|
||||||
): number | undefined {
|
): number | undefined {
|
||||||
const { required = false, label = key, integer = false } = options;
|
const { required = false, label = key, integer = false } = options;
|
||||||
const raw = params[key];
|
const raw = readParamRaw(params, key);
|
||||||
let value: number | undefined;
|
let value: number | undefined;
|
||||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||||
value = raw;
|
value = raw;
|
||||||
@@ -152,7 +170,7 @@ export function readStringArrayParam(
|
|||||||
options: StringParamOptions = {},
|
options: StringParamOptions = {},
|
||||||
) {
|
) {
|
||||||
const { required = false, label = key } = options;
|
const { required = false, label = key } = options;
|
||||||
const raw = params[key];
|
const raw = readParamRaw(params, key);
|
||||||
if (Array.isArray(raw)) {
|
if (Array.isArray(raw)) {
|
||||||
const values = raw
|
const values = raw
|
||||||
.filter((entry) => typeof entry === "string")
|
.filter((entry) => typeof entry === "string")
|
||||||
|
|||||||
@@ -238,7 +238,19 @@ function buildSendSchema(options: {
|
|||||||
|
|
||||||
function buildReactionSchema() {
|
function buildReactionSchema() {
|
||||||
return {
|
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()),
|
emoji: Type.Optional(Type.String()),
|
||||||
remove: Type.Optional(Type.Boolean()),
|
remove: Type.Optional(Type.Boolean()),
|
||||||
targetAuthor: Type.Optional(Type.String()),
|
targetAuthor: Type.Optional(Type.String()),
|
||||||
@@ -425,6 +437,7 @@ type MessageToolOptions = {
|
|||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
currentChannelProvider?: string;
|
currentChannelProvider?: string;
|
||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
|
currentMessageId?: string | number;
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
hasRepliedRef?: { value: boolean };
|
hasRepliedRef?: { value: boolean };
|
||||||
sandboxRoot?: string;
|
sandboxRoot?: string;
|
||||||
@@ -633,17 +646,23 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
clientDisplayName: "agent",
|
clientDisplayName: "agent",
|
||||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||||
};
|
};
|
||||||
|
const hasCurrentMessageId =
|
||||||
|
typeof options?.currentMessageId === "number" ||
|
||||||
|
(typeof options?.currentMessageId === "string" &&
|
||||||
|
options.currentMessageId.trim().length > 0);
|
||||||
|
|
||||||
const toolContext =
|
const toolContext =
|
||||||
options?.currentChannelId ||
|
options?.currentChannelId ||
|
||||||
options?.currentChannelProvider ||
|
options?.currentChannelProvider ||
|
||||||
options?.currentThreadTs ||
|
options?.currentThreadTs ||
|
||||||
|
hasCurrentMessageId ||
|
||||||
options?.replyToMode ||
|
options?.replyToMode ||
|
||||||
options?.hasRepliedRef
|
options?.hasRepliedRef
|
||||||
? {
|
? {
|
||||||
currentChannelId: options?.currentChannelId,
|
currentChannelId: options?.currentChannelId,
|
||||||
currentChannelProvider: options?.currentChannelProvider,
|
currentChannelProvider: options?.currentChannelProvider,
|
||||||
currentThreadTs: options?.currentThreadTs,
|
currentThreadTs: options?.currentThreadTs,
|
||||||
|
currentMessageId: options?.currentMessageId,
|
||||||
replyToMode: options?.replyToMode,
|
replyToMode: options?.replyToMode,
|
||||||
hasRepliedRef: options?.hasRepliedRef,
|
hasRepliedRef: options?.hasRepliedRef,
|
||||||
// Direct tool invocations should not add cross-context decoration.
|
// Direct tool invocations should not add cross-context decoration.
|
||||||
|
|||||||
@@ -102,6 +102,46 @@ describe("handleTelegramAction", () => {
|
|||||||
await expectReactionAdded("extensive");
|
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 () => {
|
it("removes reactions on empty emoji", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
|
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
|
||||||
@@ -177,18 +217,10 @@ describe("handleTelegramAction", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each(["off", "ack"] as const)(
|
||||||
{
|
"soft-fails reactions when reactionLevel is %s",
|
||||||
level: "off" as const,
|
async (level) => {
|
||||||
expectedMessage: /Telegram agent reactions disabled.*reactionLevel="off"/,
|
const result = await handleTelegramAction(
|
||||||
},
|
|
||||||
{
|
|
||||||
level: "ack" as const,
|
|
||||||
expectedMessage: /Telegram agent reactions disabled.*reactionLevel="ack"/,
|
|
||||||
},
|
|
||||||
])("blocks reactions when reactionLevel is $level", async ({ level, expectedMessage }) => {
|
|
||||||
await expect(
|
|
||||||
handleTelegramAction(
|
|
||||||
{
|
{
|
||||||
action: "react",
|
action: "react",
|
||||||
chatId: "123",
|
chatId: "123",
|
||||||
@@ -196,11 +228,15 @@ describe("handleTelegramAction", () => {
|
|||||||
emoji: "✅",
|
emoji: "✅",
|
||||||
},
|
},
|
||||||
reactionConfig(level),
|
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 = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
telegram: {
|
telegram: {
|
||||||
@@ -210,17 +246,19 @@ describe("handleTelegramAction", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as OpenClawConfig;
|
} as OpenClawConfig;
|
||||||
await expect(
|
const result = await handleTelegramAction(
|
||||||
handleTelegramAction(
|
{
|
||||||
{
|
action: "react",
|
||||||
action: "react",
|
chatId: "123",
|
||||||
chatId: "123",
|
messageId: "456",
|
||||||
messageId: "456",
|
emoji: "✅",
|
||||||
emoji: "✅",
|
},
|
||||||
},
|
cfg,
|
||||||
cfg,
|
);
|
||||||
),
|
expect(result.details).toMatchObject({
|
||||||
).rejects.toThrow(/Telegram reactions are disabled via actions.reactions/);
|
ok: false,
|
||||||
|
reason: "disabled",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends a text message", async () => {
|
it("sends a text message", async () => {
|
||||||
@@ -634,18 +672,20 @@ describe("handleTelegramAction per-account gating", () => {
|
|||||||
},
|
},
|
||||||
} as OpenClawConfig;
|
} as OpenClawConfig;
|
||||||
|
|
||||||
await expect(
|
const result = await handleTelegramAction(
|
||||||
handleTelegramAction(
|
{
|
||||||
{
|
action: "react",
|
||||||
action: "react",
|
chatId: "123",
|
||||||
chatId: "123",
|
messageId: 1,
|
||||||
messageId: 1,
|
emoji: "👀",
|
||||||
emoji: "👀",
|
accountId: "media",
|
||||||
accountId: "media",
|
},
|
||||||
},
|
cfg,
|
||||||
cfg,
|
);
|
||||||
),
|
expect(result.details).toMatchObject({
|
||||||
).rejects.toThrow(/reactions are disabled via actions.reactions/i);
|
ok: false,
|
||||||
|
reason: "disabled",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows account to explicitly re-enable top-level disabled reaction gate", async () => {
|
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 });
|
const isActionEnabled = createTelegramActionGate({ cfg, accountId });
|
||||||
|
|
||||||
if (action === "react") {
|
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({
|
const reactionLevelInfo = resolveTelegramReactionLevel({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
if (!reactionLevelInfo.agentReactionsEnabled) {
|
if (!reactionLevelInfo.agentReactionsEnabled) {
|
||||||
throw new Error(
|
return jsonResult({
|
||||||
`Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
|
ok: false,
|
||||||
`Set channels.telegram.reactionLevel to "minimal" or "extensive" to enable.`,
|
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")) {
|
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", {
|
const chatId = readStringOrNumberParam(params, "chatId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const messageId = readNumberParam(params, "messageId", {
|
const messageId = readNumberParam(params, "messageId", {
|
||||||
required: true,
|
|
||||||
integer: 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, {
|
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||||
removeErrorMessage: "Emoji is required to remove a Telegram reaction.",
|
removeErrorMessage: "Emoji is required to remove a Telegram reaction.",
|
||||||
});
|
});
|
||||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error(
|
return jsonResult({
|
||||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
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) {
|
if (!reactionResult.ok) {
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -22,12 +22,17 @@ export function buildThreadingToolContext(params: {
|
|||||||
hasRepliedRef: { value: boolean } | undefined;
|
hasRepliedRef: { value: boolean } | undefined;
|
||||||
}): ChannelThreadingToolContext {
|
}): ChannelThreadingToolContext {
|
||||||
const { sessionCtx, config, hasRepliedRef } = params;
|
const { sessionCtx, config, hasRepliedRef } = params;
|
||||||
|
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return {};
|
return {
|
||||||
|
currentMessageId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
||||||
if (!rawProvider) {
|
if (!rawProvider) {
|
||||||
return {};
|
return {
|
||||||
|
currentMessageId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider);
|
const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider);
|
||||||
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
|
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
|
||||||
@@ -36,6 +41,7 @@ export function buildThreadingToolContext(params: {
|
|||||||
return {
|
return {
|
||||||
currentChannelId: sessionCtx.To?.trim() || undefined,
|
currentChannelId: sessionCtx.To?.trim() || undefined,
|
||||||
currentChannelProvider: provider ?? (rawProvider as ChannelId),
|
currentChannelProvider: provider ?? (rawProvider as ChannelId),
|
||||||
|
currentMessageId,
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -48,6 +54,7 @@ export function buildThreadingToolContext(params: {
|
|||||||
From: sessionCtx.From,
|
From: sessionCtx.From,
|
||||||
To: sessionCtx.To,
|
To: sessionCtx.To,
|
||||||
ChatType: sessionCtx.ChatType,
|
ChatType: sessionCtx.ChatType,
|
||||||
|
CurrentMessageId: currentMessageId,
|
||||||
ReplyToId: sessionCtx.ReplyToId,
|
ReplyToId: sessionCtx.ReplyToId,
|
||||||
ThreadLabel: sessionCtx.ThreadLabel,
|
ThreadLabel: sessionCtx.ThreadLabel,
|
||||||
MessageThreadId: sessionCtx.MessageThreadId,
|
MessageThreadId: sessionCtx.MessageThreadId,
|
||||||
@@ -57,6 +64,7 @@ export function buildThreadingToolContext(params: {
|
|||||||
return {
|
return {
|
||||||
...context,
|
...context,
|
||||||
currentChannelProvider: provider!, // guaranteed non-null since dock exists
|
currentChannelProvider: provider!, // guaranteed non-null since dock exists
|
||||||
|
currentMessageId: context.currentMessageId ?? currentMessageId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ describe("channels dock", () => {
|
|||||||
|
|
||||||
const telegramContext = telegramDock?.threading?.buildToolContext?.({
|
const telegramContext = telegramDock?.threading?.buildToolContext?.({
|
||||||
cfg: emptyConfig(),
|
cfg: emptyConfig(),
|
||||||
context: { To: " room-1 ", MessageThreadId: 42, ReplyToId: "fallback" },
|
context: {
|
||||||
|
To: " room-1 ",
|
||||||
|
MessageThreadId: 42,
|
||||||
|
ReplyToId: "fallback",
|
||||||
|
CurrentMessageId: "9001",
|
||||||
|
},
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
});
|
});
|
||||||
const googleChatContext = googleChatDock?.threading?.buildToolContext?.({
|
const googleChatContext = googleChatDock?.threading?.buildToolContext?.({
|
||||||
@@ -26,6 +31,7 @@ describe("channels dock", () => {
|
|||||||
expect(telegramContext).toEqual({
|
expect(telegramContext).toEqual({
|
||||||
currentChannelId: "room-1",
|
currentChannelId: "room-1",
|
||||||
currentThreadTs: "42",
|
currentThreadTs: "42",
|
||||||
|
currentMessageId: "9001",
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
});
|
});
|
||||||
expect(googleChatContext).toEqual({
|
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", () => {
|
it("irc resolveDefaultTo matches account id case-insensitively", () => {
|
||||||
const ircDock = getChannelDock("irc");
|
const ircDock = getChannelDock("irc");
|
||||||
const cfg = {
|
const cfg = {
|
||||||
|
|||||||
@@ -253,8 +253,22 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off",
|
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off",
|
||||||
buildToolContext: ({ context, hasRepliedRef }) =>
|
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||||
buildThreadToolContextFromMessageThreadOrReply({ 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: {
|
whatsapp: {
|
||||||
|
|||||||
@@ -673,6 +673,83 @@ describe("telegramMessageActions", () => {
|
|||||||
expect(String(callPayload.messageId)).toBe("456");
|
expect(String(callPayload.messageId)).toBe("456");
|
||||||
expect(callPayload.emoji).toBe("ok");
|
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", () => {
|
describe("signalMessageActions", () => {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||||||
extractToolSend: ({ args }) => {
|
extractToolSend: ({ args }) => {
|
||||||
return extractToolSend(args, "sendMessage");
|
return extractToolSend(args, "sendMessage");
|
||||||
},
|
},
|
||||||
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots }) => {
|
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => {
|
||||||
if (action === "send") {
|
if (action === "send") {
|
||||||
const sendParams = readTelegramSendParams(params);
|
const sendParams = readTelegramSendParams(params);
|
||||||
return await handleTelegramAction(
|
return await handleTelegramAction(
|
||||||
@@ -122,9 +122,8 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "react") {
|
if (action === "react") {
|
||||||
const messageId = readStringOrNumberParam(params, "messageId", {
|
const messageId =
|
||||||
required: true,
|
readStringOrNumberParam(params, "messageId") ?? toolContext?.currentMessageId;
|
||||||
});
|
|
||||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||||
return await handleTelegramAction(
|
return await handleTelegramAction(
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export type ChannelThreadingContext = {
|
|||||||
From?: string;
|
From?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
ChatType?: string;
|
ChatType?: string;
|
||||||
|
CurrentMessageId?: string | number;
|
||||||
ReplyToId?: string;
|
ReplyToId?: string;
|
||||||
ReplyToIdFull?: string;
|
ReplyToIdFull?: string;
|
||||||
ThreadLabel?: string;
|
ThreadLabel?: string;
|
||||||
@@ -259,6 +260,7 @@ export type ChannelThreadingToolContext = {
|
|||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
currentChannelProvider?: ChannelId;
|
currentChannelProvider?: ChannelId;
|
||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
|
currentMessageId?: string | number;
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
hasRepliedRef?: { value: boolean };
|
hasRepliedRef?: { value: boolean };
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user