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:
Devin Robison
2026-03-25 11:12:09 -07:00
committed by GitHub
parent 89c4c674d1
commit b7d70ade3b
18 changed files with 808 additions and 73 deletions

View File

@@ -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,
}),
},
},

View File

@@ -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,

View File

@@ -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" } })),

View File

@@ -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)

View 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" } }],
}),
);
});
});

View File

@@ -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();