mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 21:50:22 +00:00
Fix/telegram writeback admin scope gate (#54561)
* fix(telegram): require operator.admin for legacy target writeback persistence * Address claude feedback * Update extensions/telegram/src/target-writeback.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Remove stray brace * Add updated docs * Add missing test file, address codex concerns * Fix test formatting error * Address comments, fix tests --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
@@ -88,6 +88,7 @@ function buildTelegramSendOptions(params: {
|
||||
threadId?: string | number | null;
|
||||
silent?: boolean | null;
|
||||
forceDocument?: boolean | null;
|
||||
gatewayClientScopes?: readonly string[] | null;
|
||||
}): TelegramSendOptions {
|
||||
return {
|
||||
verbose: false,
|
||||
@@ -99,6 +100,9 @@ function buildTelegramSendOptions(params: {
|
||||
accountId: params.accountId ?? undefined,
|
||||
silent: params.silent ?? undefined,
|
||||
forceDocument: params.forceDocument ?? undefined,
|
||||
...(Array.isArray(params.gatewayClientScopes)
|
||||
? { gatewayClientScopes: [...params.gatewayClientScopes] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +117,7 @@ async function sendTelegramOutbound(params: {
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
silent?: boolean | null;
|
||||
gatewayClientScopes?: readonly string[] | null;
|
||||
}) {
|
||||
const send =
|
||||
resolveOutboundSendDep<TelegramSendFn>(params.deps, "telegram") ??
|
||||
@@ -128,6 +133,7 @@ async function sendTelegramOutbound(params: {
|
||||
replyToId: params.replyToId,
|
||||
threadId: params.threadId,
|
||||
silent: params.silent,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -710,6 +716,7 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
threadId,
|
||||
silent,
|
||||
forceDocument,
|
||||
gatewayClientScopes,
|
||||
}) => {
|
||||
const send =
|
||||
resolveOutboundSendDep<TelegramSendFn>(deps, "telegram") ??
|
||||
@@ -726,6 +733,7 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
threadId,
|
||||
silent,
|
||||
forceDocument,
|
||||
gatewayClientScopes,
|
||||
}),
|
||||
});
|
||||
return attachChannelToResult("telegram", result);
|
||||
@@ -733,7 +741,17 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
},
|
||||
attachedResults: {
|
||||
channel: "telegram",
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) =>
|
||||
sendText: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
silent,
|
||||
gatewayClientScopes,
|
||||
}) =>
|
||||
await sendTelegramOutbound({
|
||||
cfg,
|
||||
to,
|
||||
@@ -743,6 +761,7 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
replyToId,
|
||||
threadId,
|
||||
silent,
|
||||
gatewayClientScopes,
|
||||
}),
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
@@ -755,6 +774,7 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
replyToId,
|
||||
threadId,
|
||||
silent,
|
||||
gatewayClientScopes,
|
||||
}) =>
|
||||
await sendTelegramOutbound({
|
||||
cfg,
|
||||
@@ -767,14 +787,25 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
replyToId,
|
||||
threadId,
|
||||
silent,
|
||||
gatewayClientScopes,
|
||||
}),
|
||||
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent, isAnonymous }) =>
|
||||
sendPoll: async ({
|
||||
cfg,
|
||||
to,
|
||||
poll,
|
||||
accountId,
|
||||
threadId,
|
||||
silent,
|
||||
isAnonymous,
|
||||
gatewayClientScopes,
|
||||
}) =>
|
||||
await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
messageThreadId: parseTelegramThreadId(threadId),
|
||||
silent: silent ?? undefined,
|
||||
isAnonymous: isAnonymous ?? undefined,
|
||||
gatewayClientScopes,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ function resolveTelegramSendContext(params: {
|
||||
accountId?: string | null;
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
}): {
|
||||
send: TelegramSendFn;
|
||||
baseOpts: {
|
||||
@@ -39,6 +40,7 @@ function resolveTelegramSendContext(params: {
|
||||
messageThreadId?: number;
|
||||
replyToMessageId?: number;
|
||||
accountId?: string;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
};
|
||||
} {
|
||||
const send =
|
||||
@@ -52,6 +54,7 @@ function resolveTelegramSendContext(params: {
|
||||
messageThreadId: parseTelegramThreadId(params.threadId),
|
||||
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
|
||||
accountId: params.accountId ?? undefined,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -111,13 +114,23 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
|
||||
...createAttachedChannelResultAdapter({
|
||||
channel: "telegram",
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => {
|
||||
sendText: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
gatewayClientScopes,
|
||||
}) => {
|
||||
const { send, baseOpts } = resolveTelegramSendContext({
|
||||
cfg,
|
||||
deps,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
gatewayClientScopes,
|
||||
});
|
||||
return await send(to, text, {
|
||||
...baseOpts,
|
||||
@@ -134,6 +147,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
replyToId,
|
||||
threadId,
|
||||
forceDocument,
|
||||
gatewayClientScopes,
|
||||
}) => {
|
||||
const { send, baseOpts } = resolveTelegramSendContext({
|
||||
cfg,
|
||||
@@ -141,6 +155,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
gatewayClientScopes,
|
||||
});
|
||||
return await send(to, text, {
|
||||
...baseOpts,
|
||||
@@ -160,6 +175,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
replyToId,
|
||||
threadId,
|
||||
forceDocument,
|
||||
gatewayClientScopes,
|
||||
}) => {
|
||||
const { send, baseOpts } = resolveTelegramSendContext({
|
||||
cfg,
|
||||
@@ -167,6 +183,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
gatewayClientScopes,
|
||||
});
|
||||
const result = await sendTelegramPayloadMessages({
|
||||
send,
|
||||
|
||||
@@ -596,6 +596,7 @@ describe("sendMessageTelegram", () => {
|
||||
await sendMessageTelegram("https://t.me/mychannel", "hi", {
|
||||
token: "tok",
|
||||
api,
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
});
|
||||
|
||||
expect(getChat).toHaveBeenCalledWith("@mychannel");
|
||||
@@ -606,6 +607,7 @@ describe("sendMessageTelegram", () => {
|
||||
expect.objectContaining({
|
||||
rawTarget: "https://t.me/mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -2117,6 +2119,32 @@ describe("editMessageTelegram", () => {
|
||||
});
|
||||
|
||||
describe("sendPollTelegram", () => {
|
||||
it("propagates gateway client scopes when resolving legacy poll targets", async () => {
|
||||
const api = {
|
||||
getChat: vi.fn(async () => ({ id: -100321 })),
|
||||
sendPoll: vi.fn(async () => ({ message_id: 123, chat: { id: 555 }, poll: { id: "p1" } })),
|
||||
};
|
||||
|
||||
await sendPollTelegram(
|
||||
"https://t.me/mychannel",
|
||||
{ question: " Q ", options: [" A ", "B "] },
|
||||
{
|
||||
token: "t",
|
||||
api: api as unknown as Bot["api"],
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(api.getChat).toHaveBeenCalledWith("@mychannel");
|
||||
expect(maybePersistResolvedTelegramTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rawTarget: "https://t.me/mychannel",
|
||||
resolvedChatId: "-100321",
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps durationSeconds to open_period", async () => {
|
||||
const api = {
|
||||
sendPoll: vi.fn(async () => ({ message_id: 123, chat: { id: 555 }, poll: { id: "p1" } })),
|
||||
|
||||
@@ -65,6 +65,7 @@ type TelegramSendOpts = {
|
||||
verbose?: boolean;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
gatewayClientScopes?: readonly string[];
|
||||
maxBytes?: number;
|
||||
api?: TelegramApiOverride;
|
||||
retry?: RetryConfig;
|
||||
@@ -315,6 +316,7 @@ async function resolveAndPersistChatId(params: {
|
||||
lookupTarget: string;
|
||||
persistTarget: string;
|
||||
verbose?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
}): Promise<string> {
|
||||
const chatId = await resolveChatId(params.lookupTarget, {
|
||||
api: params.api,
|
||||
@@ -325,6 +327,7 @@ async function resolveAndPersistChatId(params: {
|
||||
rawTarget: params.persistTarget,
|
||||
resolvedChatId: chatId,
|
||||
verbose: params.verbose,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
});
|
||||
return chatId;
|
||||
}
|
||||
@@ -632,6 +635,7 @@ export async function sendMessageTelegram(
|
||||
lookupTarget: target.chatId,
|
||||
persistTarget: to,
|
||||
verbose: opts.verbose,
|
||||
gatewayClientScopes: opts.gatewayClientScopes,
|
||||
});
|
||||
const mediaUrl = opts.mediaUrl?.trim();
|
||||
const mediaMaxBytes =
|
||||
@@ -1555,6 +1559,7 @@ type TelegramPollOpts = {
|
||||
verbose?: boolean;
|
||||
api?: TelegramApiOverride;
|
||||
retry?: RetryConfig;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
/** Message ID to reply to (for threading) */
|
||||
replyToMessageId?: number;
|
||||
/** Forum topic thread ID (for forum supergroups) */
|
||||
@@ -1584,6 +1589,7 @@ export async function sendPollTelegram(
|
||||
lookupTarget: target.chatId,
|
||||
persistTarget: to,
|
||||
verbose: opts.verbose,
|
||||
gatewayClientScopes: opts.gatewayClientScopes,
|
||||
});
|
||||
|
||||
// Normalize the poll input (validates question, options, maxSelections)
|
||||
|
||||
216
extensions/telegram/src/target-writeback.test.ts
Normal file
216
extensions/telegram/src/target-writeback.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
|
||||
const readConfigFileSnapshotForWrite = vi.fn();
|
||||
const writeConfigFile = vi.fn();
|
||||
const loadCronStore = vi.fn();
|
||||
const resolveCronStorePath = vi.fn();
|
||||
const saveCronStore = vi.fn();
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshotForWrite,
|
||||
writeConfigFile,
|
||||
loadCronStore,
|
||||
resolveCronStorePath,
|
||||
saveCronStore,
|
||||
};
|
||||
});
|
||||
|
||||
describe("maybePersistResolvedTelegramTarget", () => {
|
||||
let maybePersistResolvedTelegramTarget: typeof import("./target-writeback.js").maybePersistResolvedTelegramTarget;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ maybePersistResolvedTelegramTarget } = await import("./target-writeback.js"));
|
||||
readConfigFileSnapshotForWrite.mockReset();
|
||||
writeConfigFile.mockReset();
|
||||
loadCronStore.mockReset();
|
||||
resolveCronStorePath.mockReset();
|
||||
saveCronStore.mockReset();
|
||||
resolveCronStorePath.mockReturnValue("/tmp/cron/jobs.json");
|
||||
});
|
||||
|
||||
it("skips writeback when target is already numeric", async () => {
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {} as OpenClawConfig,
|
||||
rawTarget: "-100123",
|
||||
resolvedChatId: "-100123",
|
||||
});
|
||||
|
||||
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
|
||||
expect(loadCronStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips config and cron writeback for gateway callers missing operator.admin", async () => {
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {
|
||||
cron: { store: "/tmp/cron/jobs.json" },
|
||||
} as OpenClawConfig,
|
||||
rawTarget: "t.me/mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
});
|
||||
|
||||
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(loadCronStore).not.toHaveBeenCalled();
|
||||
expect(saveCronStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips config and cron writeback for gateway callers with an empty scope set", async () => {
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {
|
||||
cron: { store: "/tmp/cron/jobs.json" },
|
||||
} as OpenClawConfig,
|
||||
rawTarget: "t.me/mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: [],
|
||||
});
|
||||
|
||||
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(loadCronStore).not.toHaveBeenCalled();
|
||||
expect(saveCronStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes back matching config and cron targets for gateway callers with operator.admin", async () => {
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: {
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "t.me/mychannel",
|
||||
accounts: {
|
||||
alerts: {
|
||||
defaultTo: "@mychannel",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
writeOptions: { expectedConfigPath: "/tmp/openclaw.json" },
|
||||
});
|
||||
loadCronStore.mockResolvedValue({
|
||||
version: 1,
|
||||
jobs: [
|
||||
{ id: "a", delivery: { channel: "telegram", to: "https://t.me/mychannel" } },
|
||||
{ id: "b", delivery: { channel: "slack", to: "C123" } },
|
||||
],
|
||||
});
|
||||
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {
|
||||
cron: { store: "/tmp/cron/jobs.json" },
|
||||
} as OpenClawConfig,
|
||||
rawTarget: "t.me/mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "-100123",
|
||||
accounts: {
|
||||
alerts: {
|
||||
defaultTo: "-100123",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({ expectedConfigPath: "/tmp/openclaw.json" }),
|
||||
);
|
||||
expect(saveCronStore).toHaveBeenCalledTimes(1);
|
||||
expect(saveCronStore).toHaveBeenCalledWith(
|
||||
"/tmp/cron/jobs.json",
|
||||
expect.objectContaining({
|
||||
jobs: [
|
||||
{ id: "a", delivery: { channel: "telegram", to: "-100123" } },
|
||||
{ id: "b", delivery: { channel: "slack", to: "C123" } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves topic suffix style in writeback target", async () => {
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: {
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "t.me/mychannel:topic:9",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
writeOptions: {},
|
||||
});
|
||||
loadCronStore.mockResolvedValue({ version: 1, jobs: [] });
|
||||
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {} as OpenClawConfig,
|
||||
rawTarget: "t.me/mychannel:topic:9",
|
||||
resolvedChatId: "-100123",
|
||||
});
|
||||
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "-100123:topic:9",
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("matches username targets case-insensitively", async () => {
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: {
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "https://t.me/mychannel",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
writeOptions: {},
|
||||
});
|
||||
loadCronStore.mockResolvedValue({
|
||||
version: 1,
|
||||
jobs: [{ id: "a", delivery: { channel: "telegram", to: "https://t.me/mychannel" } }],
|
||||
});
|
||||
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {} as OpenClawConfig,
|
||||
rawTarget: "@MyChannel",
|
||||
resolvedChatId: "-100123",
|
||||
});
|
||||
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "-100123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(saveCronStore).toHaveBeenCalledWith(
|
||||
"/tmp/cron/jobs.json",
|
||||
expect.objectContaining({
|
||||
jobs: [{ id: "a", delivery: { channel: "telegram", to: "-100123" } }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "./targets.js";
|
||||
|
||||
const writebackLogger = createSubsystemLogger("telegram/target-writeback");
|
||||
const TELEGRAM_ADMIN_SCOPE = "operator.admin";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -141,6 +142,7 @@ export async function maybePersistResolvedTelegramTarget(params: {
|
||||
rawTarget: string;
|
||||
resolvedChatId: string;
|
||||
verbose?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
}): Promise<void> {
|
||||
const raw = params.rawTarget.trim();
|
||||
if (!raw) {
|
||||
@@ -154,6 +156,15 @@ export async function maybePersistResolvedTelegramTarget(params: {
|
||||
return;
|
||||
}
|
||||
const { matchKey, resolvedTarget } = rewrite;
|
||||
if (
|
||||
Array.isArray(params.gatewayClientScopes) &&
|
||||
!params.gatewayClientScopes.includes(TELEGRAM_ADMIN_SCOPE)
|
||||
) {
|
||||
writebackLogger.warn(
|
||||
`skipping Telegram target writeback for ${raw} because gateway caller is missing ${TELEGRAM_ADMIN_SCOPE}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
|
||||
|
||||
Reference in New Issue
Block a user