Skip to content

Commit 488e328

Browse files
committed
refactor(telegram): unify inbound authorization into one pre-cache gate
Fold the fresh-message and edited-message authorization paths into a single authorizeInboundMessage gate (configured-group check, group allowlist, DM requireTopic, DM access with challenge/silent modes). Extends pre-cache enforcement to group edits and edited channel posts, which previously recorded into the reply-chain cache without the group allowlist or configured-channel checks applied to fresh messages.
1 parent 68203a6 commit 488e328

2 files changed

Lines changed: 204 additions & 115 deletions

File tree

extensions/telegram/src/bot-handlers.runtime.ts

Lines changed: 151 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -2743,13 +2743,127 @@ export const registerTelegramHandlers = ({
27432743
} as Message;
27442744
};
27452745

2746-
const recordEditedMessageForReplyChain = async (
2747-
ctxForDedupe: TelegramUpdateKeyContext,
2748-
msg: Message,
2749-
) => {
2750-
if (shouldSkipUpdate(ctxForDedupe)) {
2746+
type TelegramInboundGate =
2747+
| { allowed: false }
2748+
| {
2749+
allowed: true;
2750+
context: TelegramEventAuthorizationContext;
2751+
effectiveDmAllow: NormalizedAllowFrom;
2752+
};
2753+
2754+
// Single authorization gate for every message-like update that can reach the
2755+
// reply-chain cache or dispatch: fresh messages, edits, channel posts. Must run
2756+
// before any cache/dedupe side effect so blocked content is never recorded.
2757+
// dmAccess "challenge" may send a pairing reply; "silent" only decides (edits
2758+
// must never reply).
2759+
const authorizeInboundMessage = async (params: {
2760+
msg: Message;
2761+
chatId: number;
2762+
isGroup: boolean;
2763+
isForum: boolean;
2764+
messageThreadId?: number;
2765+
senderId: string;
2766+
senderUsername: string;
2767+
requireConfiguredGroup: boolean;
2768+
dmAccess: "challenge" | "silent";
2769+
}): Promise<TelegramInboundGate> => {
2770+
const context = await resolveTelegramEventAuthorizationContext({
2771+
chatId: params.chatId,
2772+
isGroup: params.isGroup,
2773+
isForum: params.isForum,
2774+
senderId: params.senderId,
2775+
messageThreadId: params.messageThreadId,
2776+
});
2777+
const {
2778+
dmPolicy,
2779+
resolvedThreadId,
2780+
dmThreadId,
2781+
storeAllowFrom,
2782+
groupConfig,
2783+
topicConfig,
2784+
groupAllowOverride,
2785+
effectiveGroupAllow,
2786+
hasGroupAllowOverride,
2787+
} = context;
2788+
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
2789+
const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({
2790+
cfg,
2791+
allowFrom: groupAllowOverride ?? allowFrom,
2792+
accountId,
2793+
senderId: params.senderId,
2794+
});
2795+
const effectiveDmAllow = normalizeDmAllowFromWithStore({
2796+
allowFrom: expandedDmAllowFrom,
2797+
storeAllowFrom,
2798+
dmPolicy,
2799+
});
2800+
2801+
if (params.requireConfiguredGroup && (!groupConfig || groupConfig.enabled === false)) {
2802+
logVerbose(`Blocked telegram channel ${params.chatId} (channel disabled)`);
2803+
return { allowed: false };
2804+
}
2805+
2806+
if (
2807+
shouldSkipGroupMessage({
2808+
isGroup: params.isGroup,
2809+
chatId: params.chatId,
2810+
chatTitle: params.msg.chat.title,
2811+
resolvedThreadId,
2812+
senderId: params.senderId,
2813+
senderUsername: params.senderUsername,
2814+
effectiveGroupAllow,
2815+
hasGroupAllowOverride,
2816+
groupConfig,
2817+
topicConfig,
2818+
})
2819+
) {
2820+
return { allowed: false };
2821+
}
2822+
2823+
if (!params.isGroup) {
2824+
const requireTopic =
2825+
groupConfig && "requireTopic" in groupConfig ? groupConfig.requireTopic : undefined;
2826+
if (requireTopic === true && dmThreadId == null) {
2827+
logVerbose(`Blocked telegram DM ${params.chatId}: requireTopic=true but no topic present`);
2828+
return { allowed: false };
2829+
}
2830+
const dmAuthorized =
2831+
params.dmAccess === "challenge"
2832+
? await enforceTelegramDmAccess({
2833+
isGroup: params.isGroup,
2834+
dmPolicy,
2835+
msg: params.msg,
2836+
chatId: params.chatId,
2837+
effectiveDmAllow,
2838+
accountId,
2839+
bot,
2840+
logger,
2841+
upsertPairingRequest: telegramDeps.upsertChannelPairingRequest,
2842+
})
2843+
: await isTelegramDmAccessAllowed({
2844+
dmPolicy,
2845+
msg: params.msg,
2846+
chatId: params.chatId,
2847+
effectiveDmAllow,
2848+
accountId,
2849+
});
2850+
if (!dmAuthorized) {
2851+
return { allowed: false };
2852+
}
2853+
}
2854+
2855+
return { allowed: true, context, effectiveDmAllow };
2856+
};
2857+
2858+
const recordEditedMessageForReplyChain = async (params: {
2859+
ctxForDedupe: TelegramUpdateKeyContext;
2860+
msg: Message;
2861+
requireConfiguredGroup: boolean;
2862+
}) => {
2863+
if (shouldSkipUpdate(params.ctxForDedupe)) {
27512864
return;
27522865
}
2866+
const msg = params.msg;
27532867
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
27542868
const isForum = await resolveTelegramForumFlag({
27552869
chatId: msg.chat.id,
@@ -2760,52 +2874,21 @@ export const registerTelegramHandlers = ({
27602874
getChat,
27612875
});
27622876
const normalizedMsg = withResolvedTelegramForumFlag(msg, isForum);
2763-
const resolvedThreadId = resolveTelegramForumThreadId({
2877+
const gate = await authorizeInboundMessage({
2878+
msg: normalizedMsg,
2879+
chatId: normalizedMsg.chat.id,
2880+
isGroup,
27642881
isForum,
27652882
messageThreadId: normalizedMsg.message_thread_id,
2883+
senderId: normalizedMsg.from?.id != null ? String(normalizedMsg.from.id) : "",
2884+
senderUsername: normalizedMsg.from?.username ?? "",
2885+
requireConfiguredGroup: params.requireConfiguredGroup,
2886+
dmAccess: "silent",
27662887
});
2767-
const dmThreadId = !isGroup ? normalizedMsg.message_thread_id : undefined;
2768-
if (!isGroup) {
2769-
const senderId = normalizedMsg.from?.id != null ? String(normalizedMsg.from.id) : "";
2770-
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
2771-
chatId: normalizedMsg.chat.id,
2772-
isGroup,
2773-
isForum,
2774-
senderId,
2775-
messageThreadId: normalizedMsg.message_thread_id,
2776-
});
2777-
const { dmPolicy, storeAllowFrom, groupConfig, groupAllowOverride } = eventAuthContext;
2778-
const requireTopic =
2779-
groupConfig && "requireTopic" in groupConfig ? groupConfig.requireTopic : undefined;
2780-
if (requireTopic === true && dmThreadId == null) {
2781-
logVerbose(
2782-
`Blocked telegram DM ${normalizedMsg.chat.id}: requireTopic=true but no topic present`,
2783-
);
2784-
return;
2785-
}
2786-
const dmAllowFrom = groupAllowOverride ?? allowFrom;
2787-
const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({
2788-
cfg,
2789-
allowFrom: dmAllowFrom,
2790-
accountId,
2791-
senderId,
2792-
});
2793-
const effectiveDmAllow = normalizeDmAllowFromWithStore({
2794-
allowFrom: expandedDmAllowFrom,
2795-
storeAllowFrom,
2796-
dmPolicy,
2797-
});
2798-
const dmAuthorized = await isTelegramDmAccessAllowed({
2799-
dmPolicy,
2800-
msg: normalizedMsg,
2801-
chatId: normalizedMsg.chat.id,
2802-
effectiveDmAllow,
2803-
accountId,
2804-
});
2805-
if (!dmAuthorized) {
2806-
return;
2807-
}
2888+
if (!gate.allowed) {
2889+
return;
28082890
}
2891+
const { resolvedThreadId, dmThreadId } = gate.context;
28092892
await recordMessageForReplyChain(normalizedMsg, resolvedThreadId ?? dmThreadId);
28102893
};
28112894

@@ -2815,85 +2898,30 @@ export const registerTelegramHandlers = ({
28152898
if (shouldSkipUpdate(event.ctxForDedupe)) {
28162899
return;
28172900
}
2818-
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
2901+
const gate = await authorizeInboundMessage({
2902+
msg: event.msg,
28192903
chatId: event.chatId,
28202904
isGroup: event.isGroup,
28212905
isForum: event.isForum,
2822-
senderId: event.senderId,
28232906
messageThreadId: event.messageThreadId,
2907+
senderId: event.senderId,
2908+
senderUsername: event.senderUsername,
2909+
requireConfiguredGroup: event.requireConfiguredGroup,
2910+
dmAccess: "challenge",
28242911
});
2912+
if (!gate.allowed) {
2913+
return;
2914+
}
2915+
const { effectiveDmAllow } = gate;
28252916
const {
28262917
dmPolicy,
28272918
resolvedThreadId,
28282919
dmThreadId,
28292920
storeAllowFrom,
28302921
groupConfig,
28312922
topicConfig,
2832-
groupAllowOverride,
28332923
effectiveGroupAllow,
2834-
hasGroupAllowOverride,
2835-
} = eventAuthContext;
2836-
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
2837-
const dmAllowFrom = groupAllowOverride ?? allowFrom;
2838-
const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({
2839-
cfg,
2840-
allowFrom: dmAllowFrom,
2841-
accountId,
2842-
senderId: event.senderId,
2843-
});
2844-
const effectiveDmAllow = normalizeDmAllowFromWithStore({
2845-
allowFrom: expandedDmAllowFrom,
2846-
storeAllowFrom,
2847-
dmPolicy,
2848-
});
2849-
2850-
if (event.requireConfiguredGroup && (!groupConfig || groupConfig.enabled === false)) {
2851-
logVerbose(`Blocked telegram channel ${event.chatId} (channel disabled)`);
2852-
return;
2853-
}
2854-
2855-
if (
2856-
shouldSkipGroupMessage({
2857-
isGroup: event.isGroup,
2858-
chatId: event.chatId,
2859-
chatTitle: event.msg.chat.title,
2860-
resolvedThreadId,
2861-
senderId: event.senderId,
2862-
senderUsername: event.senderUsername,
2863-
effectiveGroupAllow,
2864-
hasGroupAllowOverride,
2865-
groupConfig,
2866-
topicConfig,
2867-
})
2868-
) {
2869-
return;
2870-
}
2871-
2872-
const requireTopic =
2873-
!event.isGroup && groupConfig && "requireTopic" in groupConfig
2874-
? groupConfig.requireTopic
2875-
: undefined;
2876-
if (!event.isGroup && requireTopic === true && dmThreadId == null) {
2877-
logVerbose(`Blocked telegram DM ${event.chatId}: requireTopic=true but no topic present`);
2878-
return;
2879-
}
2880-
2881-
if (!event.isGroup) {
2882-
const dmAuthorized = await enforceTelegramDmAccess({
2883-
isGroup: event.isGroup,
2884-
dmPolicy,
2885-
msg: event.msg,
2886-
chatId: event.chatId,
2887-
effectiveDmAllow,
2888-
accountId,
2889-
bot,
2890-
logger,
2891-
upsertPairingRequest: telegramDeps.upsertChannelPairingRequest,
2892-
});
2893-
if (!dmAuthorized) {
2894-
return;
2895-
}
2896-
}
2924+
} = gate.context;
28972925

28982926
const promptContextMinTimestampMs = normalizePromptContextMinTimestampMs(
28992927
resolveTelegramSessionState({
@@ -2999,7 +3027,11 @@ export const registerTelegramHandlers = ({
29993027
if (!msg) {
30003028
return;
30013029
}
3002-
await recordEditedMessageForReplyChain(ctx, msg);
3030+
await recordEditedMessageForReplyChain({
3031+
ctxForDedupe: ctx,
3032+
msg,
3033+
requireConfiguredGroup: false,
3034+
});
30033035
});
30043036

30053037
// Handle channel posts — enables bot-to-bot communication via Telegram channels.
@@ -3040,6 +3072,10 @@ export const registerTelegramHandlers = ({
30403072
if (!post) {
30413073
return;
30423074
}
3043-
await recordEditedMessageForReplyChain(ctx, normalizeChannelPostMessage(post));
3075+
await recordEditedMessageForReplyChain({
3076+
ctxForDedupe: ctx,
3077+
msg: normalizeChannelPostMessage(post),
3078+
requireConfiguredGroup: true,
3079+
});
30443080
});
30453081
};

extensions/telegram/src/bot.create-telegram-bot.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,6 +1713,59 @@ describe("createTelegramBot", () => {
17131713
expect(sendMessageSpy).not.toHaveBeenCalled();
17141714
});
17151715

1716+
it("does not cache blocked group-sender edits into authorized prompt context", async () => {
1717+
loadConfig.mockReturnValue({
1718+
channels: {
1719+
telegram: {
1720+
groupPolicy: "allowlist",
1721+
allowFrom: ["123456789"],
1722+
groups: { "*": { requireMention: false } },
1723+
},
1724+
},
1725+
});
1726+
readChannelAllowFromStore.mockResolvedValue([]);
1727+
sendMessageSpy.mockClear();
1728+
replySpy.mockClear();
1729+
1730+
createTelegramBot({ token: "tok" });
1731+
const editedHandler = getOnHandler("edited_message") as (
1732+
ctx: Record<string, unknown>,
1733+
) => Promise<void>;
1734+
const messageHandler = getOnHandler("message") as (
1735+
ctx: Record<string, unknown>,
1736+
) => Promise<void>;
1737+
1738+
await editedHandler({
1739+
editedMessage: {
1740+
chat: { id: -100123456789, type: "group", title: "Test Group" },
1741+
message_id: 416,
1742+
date: 1736380800,
1743+
edit_date: 1736380810,
1744+
text: "edited unauthorized group secret",
1745+
from: { id: 999999, username: "notallowed" },
1746+
},
1747+
me: { username: "openclaw_bot" },
1748+
getFile: async () => ({ download: async () => new Uint8Array() }),
1749+
});
1750+
expect(replySpy).not.toHaveBeenCalled();
1751+
1752+
await messageHandler({
1753+
message: {
1754+
chat: { id: -100123456789, type: "group", title: "Test Group" },
1755+
message_id: 417,
1756+
date: 1736380860,
1757+
text: "authorized follow-up",
1758+
from: { id: 123456789, username: "allowed" },
1759+
},
1760+
me: { username: "openclaw_bot" },
1761+
getFile: async () => ({ download: async () => new Uint8Array() }),
1762+
});
1763+
1764+
expect(replySpy).toHaveBeenCalledTimes(1);
1765+
expect(replySpy.mock.calls.at(0)?.[0].UntrustedStructuredContext).toBeUndefined();
1766+
expect(sendMessageSpy).not.toHaveBeenCalled();
1767+
});
1768+
17161769
it("drops topic-required root DMs before pairing challenges", async () => {
17171770
loadConfig.mockReturnValue({
17181771
channels: {

0 commit comments

Comments
 (0)