telegram: align model picker callback auth (#70235)

* telegram: align model picker callback auth

* docs(changelog): note telegram model callback auth fix

* fix(telegram): use runtime config for model callback auth
This commit is contained in:
Devin Robison
2026-04-22 11:06:01 -06:00
committed by GitHub
parent be317769e6
commit 5b32c3138c
3 changed files with 254 additions and 1 deletions

View File

@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
- Gateway/restart: write restart sentinel files atomically so interrupted writes cannot leave a truncated sentinel behind. (#70225) Thanks @obviyus.
- Pairing: remove stale pending requests for a device when that paired device is deleted, so an old repair approval cannot recreate the removed device from leftover state.
- Security/dotenv: block workspace `.env` overrides for Matrix, Mattermost, IRC, and Synology endpoint settings so cloned workspaces cannot redirect bundled connector traffic through local endpoint config. (#70240) Thanks @drobison00.
- Telegram: require the same `/models` authorization for group model-picker callbacks, so unauthorized participants can no longer browse or change the session model through inline buttons. (#70235) Thanks @drobison00.
## 2026.4.21

View File

@@ -6,6 +6,10 @@ import {
resolveInboundDebounceMs,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth";
import {
resolveCommandAuthorization,
resolveCommandAuthorizedFromAuthorizers,
} from "openclaw/plugin-sdk/command-auth-native";
import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status";
import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
import {
@@ -13,7 +17,7 @@ import {
resolveSessionStoreEntry,
updateSessionStore,
} from "openclaw/plugin-sdk/config-runtime";
import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime";
import type { DmPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { TelegramGroupConfig, TelegramTopicConfig } from "openclaw/plugin-sdk/config-runtime";
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/config-runtime";
import {
@@ -60,6 +64,7 @@ import {
import { resolveMedia } from "./bot/delivery.js";
import {
getTelegramTextParts,
buildTelegramGroupFrom,
buildTelegramGroupPeerId,
buildTelegramParentPeer,
resolveTelegramForumFlag,
@@ -783,6 +788,76 @@ export const registerTelegramHandlers = ({
return { allowed: true };
};
const isTelegramModelCallbackAuthorized = (params: {
chatId: number;
isGroup: boolean;
senderId: string;
senderUsername: string;
context: TelegramEventAuthorizationContext;
cfg: OpenClawConfig;
}): boolean => {
const { chatId, isGroup, senderId, senderUsername, context, cfg } = params;
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const dmAllowFrom = context.groupAllowOverride ?? allowFrom;
const commandsAllowFrom = cfg.commands?.allowFrom;
const commandsAllowFromConfigured =
commandsAllowFrom != null &&
typeof commandsAllowFrom === "object" &&
(Array.isArray(commandsAllowFrom.telegram) || Array.isArray(commandsAllowFrom["*"]));
if (commandsAllowFromConfigured) {
return resolveCommandAuthorization({
ctx: {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
AccountId: accountId,
ChatType: isGroup ? "group" : "direct",
From: isGroup
? buildTelegramGroupFrom(chatId, context.resolvedThreadId)
: `telegram:${chatId}`,
SenderId: senderId || undefined,
SenderUsername: senderUsername || undefined,
},
cfg,
commandAuthorized: false,
}).isAuthorizedSender;
}
const dmAllow = normalizeDmAllowFromWithStore({
allowFrom: dmAllowFrom,
storeAllowFrom: isGroup ? [] : context.storeAllowFrom,
dmPolicy: context.dmPolicy,
});
const senderAllowed = isSenderAllowed({
allow: dmAllow,
senderId,
senderUsername,
});
const groupSenderAllowed = isGroup
? isSenderAllowed({
allow: context.effectiveGroupAllow,
senderId,
senderUsername,
})
: false;
return resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: dmAllow.hasEntries, allowed: senderAllowed },
...(isGroup
? [
{
configured: context.effectiveGroupAllow.hasEntries,
allowed: groupSenderAllowed,
},
]
: []),
],
modeWhenAccessGroupsOff: "configured",
});
};
// Handle emoji reactions to messages.
bot.on("message_reaction", async (ctx) => {
try {
@@ -1453,6 +1528,21 @@ export const registerTelegramHandlers = ({
// Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back)
const modelCallback = parseModelCallbackData(data);
if (modelCallback) {
if (
!isTelegramModelCallbackAuthorized({
chatId,
isGroup,
senderId,
senderUsername,
context: eventAuthContext,
cfg: runtimeCfg,
})
) {
logVerbose(
`Blocked telegram model callback from ${senderId || "unknown"} (not authorized for /models)`,
);
return;
}
let sessionState: ReturnType<typeof resolveTelegramSessionState>;
let modelData: Awaited<ReturnType<typeof telegramDeps.buildModelsProviderData>>;
try {

View File

@@ -223,6 +223,168 @@ describe("createTelegramBot", () => {
}
});
it("blocks group model-selection callbacks for senders who are not authorized for /models", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = `/tmp/openclaw-telegram-group-model-authz-${process.pid}-${Date.now()}.json`;
await rm(storePath, { force: true });
try {
const config = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
models: {
"anthropic/claude-opus-4-6": {},
"openai/gpt-5.4": {},
},
},
},
commands: {
allowFrom: {
telegram: ["9"],
},
},
channels: {
telegram: {
dmPolicy: "open",
capabilities: { inlineButtons: "group" },
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-group-model-authz-1",
data: "mdl_sel_openai/gpt-5.4",
from: { id: 999, first_name: "Mallory", username: "mallory" },
message: {
chat: { id: -100999, type: "supergroup", title: "Test Group" },
date: 1736380800,
message_id: 21,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(loadSessionStore(storePath, { skipCache: true })).toEqual({});
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-model-authz-1");
} finally {
await rm(storePath, { force: true });
}
});
it("recomputes group model-selection callback auth from runtime command config", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = `/tmp/openclaw-telegram-group-model-authz-runtime-${process.pid}-${Date.now()}.json`;
await rm(storePath, { force: true });
try {
let currentConfig = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
models: {
"anthropic/claude-opus-4-6": {},
"openai/gpt-5.4": {},
},
},
},
commands: {
allowFrom: {
telegram: ["999"],
},
},
channels: {
telegram: {
dmPolicy: "open",
capabilities: { inlineButtons: "group" },
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockImplementation(() => currentConfig);
createTelegramBot({
token: "tok",
config: currentConfig,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
expect(callbackHandler).toBeDefined();
currentConfig = {
...currentConfig,
commands: {
allowFrom: {
telegram: ["9"],
},
},
};
await callbackHandler({
callbackQuery: {
id: "cbq-group-model-authz-runtime-1",
data: "mdl_sel_openai/gpt-5.4",
from: { id: 999, first_name: "Mallory", username: "mallory" },
message: {
chat: { id: -100999, type: "supergroup", title: "Test Group" },
date: 1736380800,
message_id: 22,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(loadSessionStore(storePath, { skipCache: true })).toEqual({});
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-model-authz-runtime-1");
} finally {
loadConfig.mockReset();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
},
});
await rm(storePath, { force: true });
}
});
it("allows callback_query in groups when group policy authorizes the sender", async () => {
onSpy.mockClear();
editMessageTextSpy.mockClear();