mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +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:
@@ -173,6 +173,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/DM draft duplicate display: clear stale DM draft previews after materializing the real final message, including threadless fallback when DM topic lookup fails, so partial streaming no longer briefly shows duplicate replies. (#36746) Thanks @joelnishanth.
|
||||
- Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress `NO_REPLY` lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus.
|
||||
- Telegram/`groupAllowFrom` sender-ID validation: restore sender-only runtime validation so negative chat/group IDs remain invalid entries instead of appearing accepted while still being unable to authorize group access. (#37134) Thanks @qiuyuemartin-max and @vincentkoc.
|
||||
- Telegram/native group command auth: authorize native commands in groups and forum topics against `groupAllowFrom` and per-group/topic sender overrides, while keeping auth rejection replies in the originating topic thread. (#39267) Thanks @edwluo.
|
||||
- Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.
|
||||
- Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.
|
||||
- Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow.
|
||||
|
||||
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