mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 13:44:58 +00:00
fix(telegram): use group allowlist for native command auth in groups (#39267)
* fix(telegram): use group allowlist for native command auth in groups Native slash commands (/status, /model, etc.) in Telegram supergroups and forum topics reject authorized senders with "not authorized" even when the sender is in groupAllowFrom. The bug is in resolveTelegramCommandAuth — the final commandAuthorized check only passes DM allowFrom as an authorizer, so senders who are authorized via groupAllowFrom get rejected. Regular messages don't have this problem because they go through evaluateTelegramGroupPolicyAccess which correctly uses effectiveGroupAllow. Add effectiveGroupAllow as a second authorizer when the message comes from a group. resolveCommandAuthorizedFromAuthorizers uses .some(), so either DM or group allowlist matching is sufficient. Fixes #28216 Fixes #29135 Fixes #30234 * fix(test): resolve TS2769 type errors in group-auth test Remove explicit tuple type annotations on mock.calls.filter() callbacks that conflicted with vitest's mock call types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(telegram): cover topic auth rejection routing * changelog: note telegram native group command auth fix --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
153
src/telegram/bot-native-commands.group-auth.test.ts
Normal file
153
src/telegram/bot-native-commands.group-auth.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||
import type { TelegramAccountConfig } from "../config/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
|
||||
|
||||
const getPluginCommandSpecs = vi.hoisted(() => vi.fn(() => []));
|
||||
const matchPluginCommand = vi.hoisted(() => vi.fn(() => null));
|
||||
const executePluginCommand = vi.hoisted(() => vi.fn(async () => ({ text: "ok" })));
|
||||
|
||||
vi.mock("../plugins/commands.js", () => ({
|
||||
getPluginCommandSpecs,
|
||||
matchPluginCommand,
|
||||
executePluginCommand,
|
||||
}));
|
||||
|
||||
const deliverReplies = vi.hoisted(() => vi.fn(async () => {}));
|
||||
vi.mock("./bot/delivery.js", () => ({ deliverReplies }));
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
describe("native command auth in groups", () => {
|
||||
function setup(params: {
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
groupConfig?: Record<string, unknown>;
|
||||
}) {
|
||||
const handlers: Record<string, (ctx: unknown) => Promise<void>> = {};
|
||||
const sendMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const bot = {
|
||||
api: {
|
||||
setMyCommands: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage,
|
||||
},
|
||||
command: (name: string, handler: (ctx: unknown) => Promise<void>) => {
|
||||
handlers[name] = handler;
|
||||
},
|
||||
} as const;
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
bot: bot as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as unknown as RuntimeEnv,
|
||||
accountId: "default",
|
||||
telegramCfg: {} as TelegramAccountConfig,
|
||||
allowFrom: params.allowFrom ?? [],
|
||||
groupAllowFrom: params.groupAllowFrom ?? [],
|
||||
replyToMode: "off",
|
||||
textLimit: 4000,
|
||||
useAccessGroups: params.useAccessGroups ?? false,
|
||||
nativeEnabled: true,
|
||||
nativeSkillsEnabled: false,
|
||||
nativeDisabledExplicit: false,
|
||||
resolveGroupPolicy: () =>
|
||||
({
|
||||
allowlistEnabled: false,
|
||||
allowed: true,
|
||||
}) as ChannelGroupPolicy,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: params.groupConfig as undefined,
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
shouldSkipUpdate: () => false,
|
||||
opts: { token: "token" },
|
||||
});
|
||||
|
||||
return { handlers, sendMessage };
|
||||
}
|
||||
|
||||
it("authorizes native commands in groups when sender is in groupAllowFrom", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
groupAllowFrom: ["12345"],
|
||||
useAccessGroups: true,
|
||||
// no allowFrom — sender is NOT in DM allowlist
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
message: {
|
||||
chat: { id: -100999, type: "supergroup", is_forum: true },
|
||||
from: { id: 12345, username: "testuser" },
|
||||
message_thread_id: 42,
|
||||
message_id: 1,
|
||||
date: 1700000000,
|
||||
},
|
||||
match: "",
|
||||
};
|
||||
|
||||
await handlers.status?.(ctx);
|
||||
|
||||
// should NOT send "not authorized" rejection
|
||||
const notAuthCalls = sendMessage.mock.calls.filter(
|
||||
(call) => typeof call[1] === "string" && call[1].includes("not authorized"),
|
||||
);
|
||||
expect(notAuthCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects native commands in groups when sender is in neither allowlist", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
allowFrom: ["99999"],
|
||||
groupAllowFrom: ["99999"],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
message: {
|
||||
chat: { id: -100999, type: "supergroup", is_forum: true },
|
||||
from: { id: 12345, username: "intruder" },
|
||||
message_thread_id: 42,
|
||||
message_id: 1,
|
||||
date: 1700000000,
|
||||
},
|
||||
match: "",
|
||||
};
|
||||
|
||||
await handlers.status?.(ctx);
|
||||
|
||||
const notAuthCalls = sendMessage.mock.calls.filter(
|
||||
(call) => typeof call[1] === "string" && call[1].includes("not authorized"),
|
||||
);
|
||||
expect(notAuthCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("replies in the originating forum topic when auth is rejected", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
allowFrom: ["99999"],
|
||||
groupAllowFrom: ["99999"],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
message: {
|
||||
chat: { id: -100999, type: "supergroup", is_forum: true },
|
||||
from: { id: 12345, username: "intruder" },
|
||||
message_thread_id: 42,
|
||||
message_id: 1,
|
||||
date: 1700000000,
|
||||
},
|
||||
match: "",
|
||||
};
|
||||
|
||||
await handlers.status?.(ctx);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-100999,
|
||||
"You are not authorized to use this command.",
|
||||
expect.objectContaining({ message_thread_id: 42 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -285,9 +285,17 @@ async function resolveTelegramCommandAuth(params: {
|
||||
senderId,
|
||||
senderUsername,
|
||||
});
|
||||
const groupSenderAllowed = isGroup
|
||||
? isSenderAllowed({ allow: effectiveGroupAllow, senderId, senderUsername })
|
||||
: false;
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
|
||||
authorizers: [
|
||||
{ configured: dmAllow.hasEntries, allowed: senderAllowed },
|
||||
...(isGroup
|
||||
? [{ configured: effectiveGroupAllow.hasEntries, allowed: groupSenderAllowed }]
|
||||
: []),
|
||||
],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
if (requireAuth && !commandAuthorized) {
|
||||
|
||||
Reference in New Issue
Block a user