fix(telegram): require admin for target writeback [AI] (#88973)

* fix: require admin for Telegram target writeback

* fix(telegram): preserve internal target writeback

* fix: scope Telegram target writeback authority

* fix: infer internal writeback for durable sends

* fix: preserve scoped Telegram writeback boundaries

* fix: preserve direct Telegram writeback

* test: make Telegram writeback scope intent explicit

* fix(telegram): keep target writeback authority local

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Pavan Kumar Gondhi
2026-06-03 19:25:40 +05:30
committed by GitHub
co-authored by Vincent Koc
parent 70a989a97a
commit 5483ff705f
6 changed files with 106 additions and 5 deletions
+26
View File
@@ -1080,6 +1080,32 @@ describe("sendMessageTelegram", () => {
});
});
it("preserves internal target writeback when gateway scopes are absent", async () => {
const sendMessage = vi.fn().mockResolvedValue({
message_id: 1,
chat: { id: "-100123" },
});
const getChat = vi.fn().mockResolvedValue({ id: -100123 });
const api = { sendMessage, getChat } as unknown as {
sendMessage: typeof sendMessage;
getChat: typeof getChat;
};
await sendMessageTelegram("https://t.me/mychannel", "hi", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
api,
});
expect(getChat).toHaveBeenCalledWith("@mychannel");
expectPersistedTarget({
rawTarget: "https://t.me/mychannel",
resolvedChatId: "-100123",
gatewayClientScopes: undefined,
trustedInternalWriteback: true,
});
});
it("fails clearly when a legacy target cannot be resolved", async () => {
const getChat = vi.fn().mockRejectedValue(new Error("400: Bad Request: chat not found"));
const api = { getChat } as unknown as {
+1
View File
@@ -399,6 +399,7 @@ async function resolveAndPersistChatId(params: {
resolvedChatId: chatId,
verbose: params.verbose,
gatewayClientScopes: params.gatewayClientScopes,
...(params.gatewayClientScopes === undefined ? { trustedInternalWriteback: true } : {}),
});
return chatId;
}
@@ -95,6 +95,7 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
cfg: {} as OpenClawConfig,
rawTarget: "-100123",
resolvedChatId: "-100123",
gatewayClientScopes: ["operator.admin"],
});
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
@@ -118,6 +119,23 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
expect(saveCronStore).not.toHaveBeenCalled();
});
it("does not let internal writeback override non-admin gateway scopes", async () => {
await maybePersistResolvedTelegramTarget({
cfg: {
cron: { store: "/tmp/cron/jobs.json" },
} as OpenClawConfig,
rawTarget: "t.me/mychannel",
resolvedChatId: "-100123",
gatewayClientScopes: ["operator.write"],
trustedInternalWriteback: true,
});
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: {
@@ -133,6 +151,53 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
expect(loadCronStore).not.toHaveBeenCalled();
expect(saveCronStore).not.toHaveBeenCalled();
});
it("skips config and cron writeback when gateway scopes are missing", async () => {
await maybePersistResolvedTelegramTarget({
cfg: {
cron: { store: "/tmp/cron/jobs.json" },
} as OpenClawConfig,
rawTarget: "t.me/mychannel",
resolvedChatId: "-100123",
gatewayClientScopes: undefined,
});
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(loadCronStore).not.toHaveBeenCalled();
expect(saveCronStore).not.toHaveBeenCalled();
});
it("writes back for gateway callers with operator.admin", async () => {
readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot: {
config: {
channels: {
telegram: {
defaultTo: "t.me/mychannel",
},
},
},
},
writeOptions: {},
});
loadCronStore.mockResolvedValue({
version: 1,
jobs: [{ id: "a", delivery: { channel: "telegram", to: "t.me/mychannel" } }],
});
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(saveCronStore).toHaveBeenCalledTimes(1);
});
}
it("writes back matching config and cron targets", async () => {
@@ -167,6 +232,8 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
} as OpenClawConfig,
rawTarget: "t.me/mychannel",
resolvedChatId: "-100123",
gatewayClientScopes: undefined,
trustedInternalWriteback: true,
});
expect(writeConfigFile).toHaveBeenCalledTimes(1);
@@ -202,6 +269,8 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
cfg: {} as OpenClawConfig,
rawTarget: "t.me/mychannel:topic:9",
resolvedChatId: "-100123",
gatewayClientScopes: undefined,
trustedInternalWriteback: true,
});
expect(writeConfigFile).toHaveBeenCalledTimes(1);
@@ -232,6 +301,8 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
cfg: {} as OpenClawConfig,
rawTarget: "@MyChannel",
resolvedChatId: "-100123",
gatewayClientScopes: undefined,
trustedInternalWriteback: true,
});
expect(writeConfigFile).toHaveBeenCalledTimes(1);
+5 -4
View File
@@ -147,6 +147,7 @@ export async function maybePersistResolvedTelegramTarget(params: {
resolvedChatId: string;
verbose?: boolean;
gatewayClientScopes?: readonly string[];
trustedInternalWriteback?: boolean;
}): Promise<void> {
const raw = params.rawTarget.trim();
if (!raw) {
@@ -160,10 +161,10 @@ export async function maybePersistResolvedTelegramTarget(params: {
return;
}
const { matchKey, resolvedTarget } = rewrite;
if (
Array.isArray(params.gatewayClientScopes) &&
!params.gatewayClientScopes.includes(TELEGRAM_ADMIN_SCOPE)
) {
const hasGatewayAdminScope = params.gatewayClientScopes?.includes(TELEGRAM_ADMIN_SCOPE) === true;
const trustedInternalWriteback =
params.gatewayClientScopes === undefined && params.trustedInternalWriteback === true;
if (!hasGatewayAdminScope && !trustedInternalWriteback) {
writebackLogger.warn(
`skipping Telegram target writeback for ${raw} because gateway caller is missing ${TELEGRAM_ADMIN_SCOPE}`,
);
@@ -33,6 +33,7 @@ type SendDurableMessageBatchRequest = {
to?: string;
threadId?: string | number | null;
durability?: string;
gatewayClientScopes?: readonly string[];
};
type DeliverySupportRequest = {
@@ -141,6 +142,7 @@ describe("durable inbound reply delivery", () => {
expect(request.to).toBe("chat-1");
expect(request.threadId).toBeNull();
expect(request.durability).toBe("best_effort");
expect(request.gatewayClientScopes).toEqual([]);
});
it("does not require unknown-send reconciliation for the default best-effort final path", async () => {
+1 -1
View File
@@ -207,7 +207,7 @@ export async function deliverInboundReplyWithMessageSendContext(
silent: params.silent,
durability,
session,
gatewayClientScopes: params.ctxPayload.GatewayClientScopes,
gatewayClientScopes: params.ctxPayload.GatewayClientScopes ?? [],
});
if (send.status === "failed") {
return { status: "failed" as const, error: send.error };