fix(telegram/groups): treat empty accounts.<id>.groups: {} as unspecified in single-account setups

`mergeTelegramAccountConfig` and the generic `resolveChannelGroups` both used
`accountGroups ?? channelConfig.groups` to fall back to root group allowlists,
which only catches the `undefined` case. An explicit empty `{}` survives
nullish coalescing and overrides the root allowlist with an empty allowlist,
which then pairs with the default `groupPolicy: "allowlist"` to silently
deny every group update — the symptom reported in #79427.

Treat an explicit empty `{}` the same as undefined for fallback purposes in
single-account setups (one or zero configured accounts). Multi-account setups
keep current semantics so per-account explicit-empty groups still scope
disable a single account without affecting its siblings. The explicit way to
block all groups for any account remains `groupPolicy: "disabled"`, which
this PR does not touch.

Fixes #79427.
This commit is contained in:
kinjitakabe
2026-05-12 22:18:48 +09:00
committed by Peter Steinberger
parent d540512d00
commit ab719c2f82
5 changed files with 107 additions and 1 deletions

View File

@@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai
- fix(gateway): honor minimal discovery mode for wide-area DNS-SD [AI]. (#80903) Thanks @pgondhi987.
- slack: enforce reaction notification policy [AI]. (#80907) Thanks @pgondhi987.
- Enforce gateway command scopes by caller context [AI]. (#80891) Thanks @pgondhi987.
- Telegram/groups: in single-account setups, treat an explicit empty `accounts.<id>.groups: {}` map the same as undefined so the root `channels.telegram.groups` allowlist still applies, instead of silently dropping every group update under the default `groupPolicy: "allowlist"`. Multi-account semantics are unchanged so per-account explicit-empty groups still scope-disable a single account without affecting siblings; the explicit way to block all groups for any account remains `groupPolicy: "disabled"`. Fixes #79427. Thanks @nikolaykazakovvs-ux.
- Enforce Slack plugin approval button authorization [AI]. (#80899) Thanks @pgondhi987.
- Recognize PowerShell -ec inline commands [AI]. (#80893) Thanks @pgondhi987.
- fix(qqbot): authorize approval button callbacks [AI]. (#80892) Thanks @pgondhi987.

View File

@@ -68,9 +68,19 @@ export function mergeTelegramAccountConfig(
const account = resolveTelegramAccountConfig(cfg, accountId) ?? {};
// Multi-account bots must not inherit channel-level groups unless explicitly set.
// Single-account bots fall back to root `channels.telegram.groups` when the
// account does not declare its own groups — including the empty-literal case
// `accounts.<id>.groups: {}`, which is almost always a config-migration
// artifact rather than an intentional "block all" declaration (use
// `groupPolicy: "disabled"` for that).
const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {});
const isMultiAccount = configuredAccountIds.length > 1;
const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups);
const hasAccountGroups = account.groups && Object.keys(account.groups).length > 0;
const groups = isMultiAccount
? account.groups
: hasAccountGroups
? account.groups
: channelGroups;
const allowFrom = resolveMergedAllowFrom({
baseAllowFrom: base.allowFrom,
accountAllowFrom: account.allowFrom,

View File

@@ -536,6 +536,24 @@ describe("resolveTelegramAccount groups inheritance (#30673)", () => {
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
});
it("inherits channel-level groups when single-account explicitly sets `groups: {}` (regression: #79427)", () => {
const resolved = resolveTelegramAccount({
cfg: {
channels: {
telegram: {
groups: { "-100123": { requireMention: false } },
accounts: {
default: { botToken: "123:default", groups: {} },
},
},
},
},
accountId: "default",
});
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
});
it("does NOT inherit channel-level groups to secondary account in multi-account setup", () => {
const resolved = resolveTelegramAccount({
cfg: createMultiAccountGroupsConfig(),

View File

@@ -169,6 +169,68 @@ describe("resolveChannelGroupPolicy", () => {
}),
).toBe(false);
});
it("falls back to root channel groups when account.groups is an empty object (regression: #79427)", () => {
const cfg = {
channels: {
telegram: {
groupPolicy: "allowlist",
groups: {
"-100123": { requireMention: false },
},
accounts: {
default: { botToken: "123:default", groups: {} },
},
},
},
} as OpenClawConfig;
const policy = resolveChannelGroupPolicy({
cfg,
channel: "telegram",
groupId: "-100123",
accountId: "default",
});
expect(policy.allowlistEnabled).toBe(true);
expect(policy.allowed).toBe(true);
});
it("uses populated account.groups instead of root when both are configured", () => {
const cfg = {
channels: {
telegram: {
groupPolicy: "allowlist",
groups: {
"-100root": { requireMention: false },
},
accounts: {
default: {
botToken: "123:default",
groups: { "-100account": { requireMention: false } },
},
},
},
},
} as OpenClawConfig;
expect(
resolveChannelGroupPolicy({
cfg,
channel: "telegram",
groupId: "-100account",
accountId: "default",
}).allowed,
).toBe(true);
expect(
resolveChannelGroupPolicy({
cfg,
channel: "telegram",
groupId: "-100root",
accountId: "default",
}).allowed,
).toBe(false);
});
});
describe("resolveToolsBySender", () => {

View File

@@ -340,6 +340,21 @@ function resolveChannelGroups(
return undefined;
}
const accountGroups = resolveAccountEntry(channelConfig.accounts, normalizedAccountId)?.groups;
// In a single-account setup, treat an explicit empty account groups map
// (`accounts.<id>.groups: {}`) the same as undefined for fallback: the empty
// literal is almost always a config-migration artifact, not an intentional
// "block all groups" declaration — the explicit way to block is
// `groupPolicy: "disabled"` (or omitting the group from a populated
// allowlist). Without this, an empty `{}` paired with the default
// `groupPolicy: "allowlist"` silently denies every group update even though
// root `channels.<channel>.groups` is populated. Multi-account contexts keep
// the existing semantics so per-account explicit-empty groups still scope
// disable a single account without affecting siblings.
const isMultiAccount = Object.keys(channelConfig.accounts ?? {}).length > 1;
if (!isMultiAccount) {
const hasAccountGroups = accountGroups && Object.keys(accountGroups).length > 0;
return hasAccountGroups ? accountGroups : channelConfig.groups;
}
return accountGroups ?? channelConfig.groups;
}