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:
Edward
2026-03-08 08:47:57 +08:00
committed by GitHub
parent 0d66834f94
commit 02eef1d45a
3 changed files with 163 additions and 1 deletions

View File

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

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

View File

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