mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 23:52:40 -06:00
c02e39345b
Route public poll votes through Telegram’s authorized inbound turn path while preserving chat/topic origin and durable ordering. Surface unsupported routing without duplicating sent polls. Co-authored-by: clawSean <260045960+clawSean@users.noreply.github.com>
571 lines
17 KiB
TypeScript
571 lines
17 KiB
TypeScript
// Telegram tests cover channel actions plugin behavior.
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { telegramMessageActions } from "./channel-actions.js";
|
|
|
|
const handleTelegramActionMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("./action-runtime.js", async () => {
|
|
const actual = await vi.importActual<typeof import("./action-runtime.js")>("./action-runtime.js");
|
|
return { ...actual, handleTelegramAction: handleTelegramActionMock };
|
|
});
|
|
|
|
describe("telegramMessageActions", () => {
|
|
beforeEach(() => {
|
|
handleTelegramActionMock.mockReset().mockResolvedValue({
|
|
ok: true,
|
|
content: [],
|
|
details: {},
|
|
});
|
|
});
|
|
|
|
it("executes message actions in the gateway when a gateway is available", () => {
|
|
for (const action of ["send", "poll", "react", "delete", "edit"] as const) {
|
|
expect(telegramMessageActions.resolveExecutionMode?.({ action })).toBe("gateway");
|
|
}
|
|
});
|
|
|
|
it("classifies provider-native mutation actions", () => {
|
|
for (const action of ["sendMessage", "editMessage", "deleteMessage", "react", "topic-edit"]) {
|
|
expect(telegramMessageActions.isToolDeliveryAction?.({ args: { action } })).toBe(true);
|
|
}
|
|
for (const action of ["searchSticker", "stickerCacheStats"]) {
|
|
expect(telegramMessageActions.isToolDeliveryAction?.({ args: { action } })).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("classifies Telegram message ids as resources rather than delivery targets", () => {
|
|
for (const action of ["react", "edit", "delete"] as const) {
|
|
expect(telegramMessageActions.messageActionTargetAliases?.[action]).toEqual({
|
|
aliases: ["messageId"],
|
|
deliveryTargetAliases: [],
|
|
});
|
|
}
|
|
});
|
|
|
|
it("forwards only host-owned mutation context to the runtime", async () => {
|
|
const mediaAccess = {
|
|
localRoots: ["/tmp/agent-root"],
|
|
workspaceDir: "/tmp/agent-root",
|
|
};
|
|
await telegramMessageActions.handleAction?.({
|
|
channel: "telegram",
|
|
action: "delete",
|
|
params: {
|
|
messageId: "9001",
|
|
to: "-1001:topic:77",
|
|
conversationReadOrigin: "direct-operator",
|
|
mediaAccess: { localRoots: ["/tmp/forged-root"], workspaceDir: "/tmp/forged-root" },
|
|
},
|
|
cfg: { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig,
|
|
accountId: "work",
|
|
mediaAccess,
|
|
mediaLocalRoots: ["/tmp/conflicting-root"],
|
|
requesterAccountId: "work",
|
|
conversationReadOrigin: "delegated",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "telegram:-1001:topic:77",
|
|
currentMessageId: "9001",
|
|
},
|
|
} as never);
|
|
|
|
expect(handleTelegramActionMock).toHaveBeenCalledWith(
|
|
expect.not.objectContaining({ conversationReadOrigin: "direct-operator" }),
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
conversationReadOrigin: "delegated",
|
|
mediaAccess,
|
|
requesterAccountId: "work",
|
|
toolContext: expect.objectContaining({ currentMessageId: "9001" }),
|
|
}),
|
|
);
|
|
expect(handleTelegramActionMock.mock.calls[0]?.[0]).toMatchObject({
|
|
action: "deleteMessage",
|
|
messageId: "9001",
|
|
});
|
|
expect(handleTelegramActionMock.mock.calls[0]?.[0]).not.toHaveProperty("mediaAccess");
|
|
expect(handleTelegramActionMock.mock.calls[0]?.[2]?.mediaAccess).toBe(mediaAccess);
|
|
});
|
|
|
|
it("allows interactive-only sends", async () => {
|
|
await telegramMessageActions.handleAction!({
|
|
action: "send",
|
|
params: {
|
|
to: "123456",
|
|
interactive: {
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: "Approve", value: "approve", style: "success" }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
cfg: {} as never,
|
|
accountId: "default",
|
|
mediaLocalRoots: [],
|
|
sessionKey: "telegram-session",
|
|
} as never);
|
|
|
|
expect(handleTelegramActionMock).toHaveBeenCalledWith(
|
|
{
|
|
action: "sendMessage",
|
|
to: "123456",
|
|
interactive: {
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: "Approve", value: "approve", style: "success" }],
|
|
},
|
|
],
|
|
},
|
|
accountId: "default",
|
|
},
|
|
{},
|
|
{
|
|
mediaLocalRoots: [],
|
|
mediaReadFile: undefined,
|
|
sessionKey: "telegram-session",
|
|
gatewayClientScopes: undefined,
|
|
},
|
|
);
|
|
});
|
|
|
|
it("computes poll/topic action availability from config gates", () => {
|
|
const cases = [
|
|
{
|
|
name: "configured telegram enables poll",
|
|
cfg: { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig,
|
|
expectSend: true,
|
|
expectPoll: true,
|
|
expectTopicEdit: true,
|
|
},
|
|
{
|
|
name: "sendMessage disabled hides poll",
|
|
cfg: {
|
|
channels: {
|
|
telegram: {
|
|
botToken: "tok",
|
|
actions: { sendMessage: false },
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
expectSend: false,
|
|
expectPoll: false,
|
|
expectTopicEdit: true,
|
|
},
|
|
{
|
|
name: "poll gate disabled hides poll",
|
|
cfg: {
|
|
channels: {
|
|
telegram: {
|
|
botToken: "tok",
|
|
actions: { poll: false },
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
expectSend: true,
|
|
expectPoll: false,
|
|
expectTopicEdit: true,
|
|
},
|
|
{
|
|
name: "split account gates do not expose poll",
|
|
cfg: {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
senderOnly: {
|
|
botToken: "tok-send",
|
|
actions: {
|
|
sendMessage: true,
|
|
poll: false,
|
|
},
|
|
},
|
|
pollOnly: {
|
|
botToken: "tok-poll",
|
|
actions: {
|
|
sendMessage: false,
|
|
poll: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
expectSend: true,
|
|
expectPoll: false,
|
|
expectTopicEdit: true,
|
|
},
|
|
{
|
|
name: "all account send gates disabled hide send",
|
|
cfg: {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
first: {
|
|
botToken: "tok-first",
|
|
actions: { sendMessage: false },
|
|
},
|
|
second: {
|
|
botToken: "tok-second",
|
|
actions: { sendMessage: false },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
expectSend: false,
|
|
expectPoll: false,
|
|
expectTopicEdit: true,
|
|
},
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
const actions =
|
|
telegramMessageActions.describeMessageTool?.({
|
|
cfg: testCase.cfg,
|
|
})?.actions ?? [];
|
|
if (testCase.expectSend) {
|
|
expect(actions, testCase.name).toContain("send");
|
|
} else {
|
|
expect(actions, testCase.name).not.toContain("send");
|
|
}
|
|
if (testCase.expectPoll) {
|
|
expect(actions, testCase.name).toContain("poll");
|
|
} else {
|
|
expect(actions, testCase.name).not.toContain("poll");
|
|
}
|
|
if (testCase.expectTopicEdit) {
|
|
expect(actions, testCase.name).toContain("topic-edit");
|
|
} else {
|
|
expect(actions, testCase.name).not.toContain("topic-edit");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("lists sticker actions only when enabled by config", () => {
|
|
const cases = [
|
|
{
|
|
name: "default config",
|
|
cfg: { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig,
|
|
expectSticker: false,
|
|
},
|
|
{
|
|
name: "per-account sticker enabled",
|
|
cfg: {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
media: { botToken: "tok", actions: { sticker: true } },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
expectSticker: true,
|
|
},
|
|
{
|
|
name: "all accounts omit sticker",
|
|
cfg: {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
a: { botToken: "tok1" },
|
|
b: { botToken: "tok2" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
expectSticker: false,
|
|
},
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
const actions =
|
|
telegramMessageActions.describeMessageTool?.({
|
|
cfg: testCase.cfg,
|
|
})?.actions ?? [];
|
|
if (testCase.expectSticker) {
|
|
expect(actions, testCase.name).toContain("sticker");
|
|
expect(actions, testCase.name).toContain("sticker-search");
|
|
} else {
|
|
expect(actions, testCase.name).not.toContain("sticker");
|
|
expect(actions, testCase.name).not.toContain("sticker-search");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("honors account-scoped action gates during discovery", () => {
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
botToken: "tok-default",
|
|
actions: {
|
|
reactions: false,
|
|
poll: true,
|
|
},
|
|
accounts: {
|
|
work: {
|
|
botToken: "tok-work",
|
|
actions: {
|
|
sendMessage: false,
|
|
reactions: true,
|
|
poll: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const defaultActions =
|
|
telegramMessageActions.describeMessageTool?.({
|
|
cfg,
|
|
accountId: "default",
|
|
})?.actions ?? [];
|
|
const workActions =
|
|
telegramMessageActions.describeMessageTool?.({
|
|
cfg,
|
|
accountId: "work",
|
|
})?.actions ?? [];
|
|
|
|
expect(defaultActions).toContain("send");
|
|
expect(defaultActions).toContain("poll");
|
|
expect(defaultActions).not.toContain("react");
|
|
expect(workActions).not.toContain("send");
|
|
expect(workActions).toContain("react");
|
|
expect(workActions).not.toContain("poll");
|
|
});
|
|
|
|
it("normalizes reaction message identifiers before dispatch", async () => {
|
|
const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig;
|
|
const cases = [
|
|
{
|
|
name: "numeric channelId/messageId",
|
|
params: {
|
|
channelId: 123,
|
|
messageId: 456,
|
|
emoji: "ok",
|
|
},
|
|
expectedChannelField: "channelId",
|
|
expectedChannelValue: "123",
|
|
expectedMessageId: "456",
|
|
},
|
|
{
|
|
name: "snake_case message_id",
|
|
params: {
|
|
channelId: 123,
|
|
message_id: "456",
|
|
emoji: "ok",
|
|
},
|
|
expectedChannelField: "channelId",
|
|
expectedChannelValue: "123",
|
|
expectedMessageId: "456",
|
|
},
|
|
{
|
|
name: "toolContext fallback",
|
|
params: {
|
|
chatId: "123",
|
|
emoji: "ok",
|
|
},
|
|
toolContext: { currentMessageId: "9001" },
|
|
expectedChannelField: "chatId",
|
|
expectedChannelValue: "123",
|
|
expectedMessageId: "9001",
|
|
},
|
|
{
|
|
name: "missing messageId soft-falls through",
|
|
params: {
|
|
chatId: "123",
|
|
emoji: "ok",
|
|
},
|
|
expectedChannelField: "chatId",
|
|
expectedChannelValue: "123",
|
|
expectedMessageId: undefined,
|
|
},
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
handleTelegramActionMock.mockClear();
|
|
await telegramMessageActions.handleAction?.({
|
|
channel: "telegram",
|
|
action: "react",
|
|
params: testCase.params,
|
|
cfg,
|
|
toolContext: "toolContext" in testCase ? testCase.toolContext : undefined,
|
|
});
|
|
|
|
const call = handleTelegramActionMock.mock.calls.at(0)?.[0] as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
if (!call) {
|
|
throw new Error(`expected Telegram action call for ${testCase.name}`);
|
|
}
|
|
expect(call.action, testCase.name).toBe("react");
|
|
expect(String(call[testCase.expectedChannelField]), testCase.name).toBe(
|
|
testCase.expectedChannelValue,
|
|
);
|
|
if (testCase.expectedMessageId === undefined) {
|
|
expect(call.messageId, testCase.name).toBeUndefined();
|
|
} else {
|
|
expect(String(call.messageId), testCase.name).toBe(testCase.expectedMessageId);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Regression for #75433: prompt discovery reads raw config before the active
|
|
// runtime snapshot has resolved SecretRefs. Treat SecretRef-backed accounts
|
|
// as configured and keep advertising config-derived actions.
|
|
it("describes discovery when botToken is an unresolved SecretRef instead of crashing the embedded run", () => {
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
botToken: { source: "exec", provider: "default", id: "telegram-token" },
|
|
actions: {
|
|
reactions: true,
|
|
poll: false,
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const discovery = telegramMessageActions.describeMessageTool?.({ cfg });
|
|
|
|
expect(discovery?.actions).toContain("send");
|
|
expect(discovery?.actions).toContain("react");
|
|
expect(discovery?.actions).not.toContain("poll");
|
|
});
|
|
|
|
it("describes scoped account discovery when Telegram account token is an unresolved SecretRef", () => {
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
ops: {
|
|
botToken: { source: "exec", provider: "default", id: "telegram-ops" },
|
|
actions: {
|
|
reactions: false,
|
|
poll: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const discovery = telegramMessageActions.describeMessageTool?.({
|
|
cfg,
|
|
accountId: "ops",
|
|
});
|
|
|
|
expect(discovery?.actions).toContain("send");
|
|
expect(discovery?.actions).toContain("poll");
|
|
expect(discovery?.actions).not.toContain("react");
|
|
});
|
|
|
|
it("advertises poll duration and public vote routing in message tool schema", () => {
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
botToken: "tok",
|
|
actions: { poll: true },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const discovery = telegramMessageActions.describeMessageTool?.({ cfg });
|
|
const schema = Array.isArray(discovery?.schema) ? discovery.schema[0] : undefined;
|
|
|
|
expect(schema?.properties.pollDurationSeconds).toMatchObject({
|
|
type: "integer",
|
|
minimum: 1,
|
|
});
|
|
expect(schema?.properties.pollAnonymous).toMatchObject({
|
|
type: "boolean",
|
|
description: expect.stringContaining("do not create agent turns"),
|
|
});
|
|
expect(schema?.properties.pollPublic).toMatchObject({
|
|
type: "boolean",
|
|
description: expect.stringContaining("route into the originating agent conversation"),
|
|
});
|
|
});
|
|
|
|
it("matches runtime account-key normalization during SecretRef-tolerant discovery", () => {
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
"Carey Notifications": {
|
|
botToken: { source: "exec", provider: "default", id: "telegram-carey" },
|
|
actions: {
|
|
poll: true,
|
|
reactions: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const discovery = telegramMessageActions.describeMessageTool?.({
|
|
cfg,
|
|
accountId: "carey-notifications",
|
|
});
|
|
|
|
expect(discovery?.actions).toContain("send");
|
|
expect(discovery?.actions).toContain("poll");
|
|
expect(discovery?.actions).not.toContain("react");
|
|
});
|
|
|
|
it("does not discover unknown scoped accounts via channel-level fallback in multi-account config", () => {
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
botToken: "tok-channel",
|
|
accounts: {
|
|
work: { botToken: "tok-work" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(
|
|
telegramMessageActions.describeMessageTool?.({
|
|
cfg,
|
|
accountId: "unknown",
|
|
})?.actions,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("keeps healthy Telegram accounts discoverable when a sibling token is an unresolved SecretRef", () => {
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
unresolved: {
|
|
botToken: { source: "exec", provider: "default", id: "telegram-unresolved" },
|
|
actions: {
|
|
reactions: false,
|
|
poll: false,
|
|
},
|
|
},
|
|
healthy: {
|
|
botToken: "tok-healthy",
|
|
actions: {
|
|
reactions: true,
|
|
poll: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const discovery = telegramMessageActions.describeMessageTool?.({ cfg });
|
|
|
|
expect(discovery?.actions).toContain("send");
|
|
expect(discovery?.actions).toContain("react");
|
|
expect(discovery?.actions).not.toContain("poll");
|
|
});
|
|
});
|