fix: telegram sendPayload and plugin auth (#1917) (thanks @JoshuaLelon)
This commit is contained in:
@@ -29,6 +29,7 @@ Status: unreleased.
|
||||
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
|
||||
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
|
||||
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
|
||||
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
|
||||
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
|
||||
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
|
||||
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
|
||||
|
||||
81
src/channels/plugins/outbound/telegram.test.ts
Normal file
81
src/channels/plugins/outbound/telegram.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { telegramOutbound } from "./telegram.js";
|
||||
|
||||
describe("telegramOutbound.sendPayload", () => {
|
||||
it("sends text payload with buttons", async () => {
|
||||
const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" }));
|
||||
|
||||
const result = await telegramOutbound.sendPayload?.({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
to: "telegram:123",
|
||||
text: "ignored",
|
||||
payload: {
|
||||
text: "Hello",
|
||||
channelData: {
|
||||
telegram: {
|
||||
buttons: [[{ text: "Option", callback_data: "/option" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
deps: { sendTelegram },
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"telegram:123",
|
||||
"Hello",
|
||||
expect.objectContaining({
|
||||
buttons: [[{ text: "Option", callback_data: "/option" }]],
|
||||
textMode: "html",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" });
|
||||
});
|
||||
|
||||
it("sends media payloads and attaches buttons only to first", async () => {
|
||||
const sendTelegram = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "m1", chatId: "c1" })
|
||||
.mockResolvedValueOnce({ messageId: "m2", chatId: "c1" });
|
||||
|
||||
const result = await telegramOutbound.sendPayload?.({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
to: "telegram:123",
|
||||
text: "ignored",
|
||||
payload: {
|
||||
text: "Caption",
|
||||
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
||||
channelData: {
|
||||
telegram: {
|
||||
buttons: [[{ text: "Go", callback_data: "/go" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
deps: { sendTelegram },
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(2);
|
||||
expect(sendTelegram).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"telegram:123",
|
||||
"Caption",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
buttons: [[{ text: "Go", callback_data: "/go" }]],
|
||||
}),
|
||||
);
|
||||
const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined;
|
||||
expect(sendTelegram).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"telegram:123",
|
||||
"",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/b.png",
|
||||
}),
|
||||
);
|
||||
expect(secondOpts?.buttons).toBeUndefined();
|
||||
expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" });
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ function parseThreadId(threadId?: string | number | null) {
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: markdownToTelegramHtmlChunks,
|
||||
@@ -54,20 +55,42 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
const send = deps?.sendTelegram ?? sendMessageTelegram;
|
||||
const replyToMessageId = parseReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseThreadId(threadId);
|
||||
|
||||
// Extract Telegram-specific data from channelData
|
||||
const telegramData = payload.channelData?.telegram as
|
||||
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
|
||||
| undefined;
|
||||
|
||||
const result = await send(to, payload.text ?? "", {
|
||||
const text = payload.text ?? "";
|
||||
const mediaUrls = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
const baseOpts = {
|
||||
verbose: false,
|
||||
textMode: "html",
|
||||
textMode: "html" as const,
|
||||
messageThreadId,
|
||||
replyToMessageId,
|
||||
accountId: accountId ?? undefined,
|
||||
buttons: telegramData?.buttons,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
};
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
const result = await send(to, text, {
|
||||
...baseOpts,
|
||||
buttons: telegramData?.buttons,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
}
|
||||
|
||||
// Telegram allows reply_markup on media; attach buttons only to first send.
|
||||
let finalResult: Awaited<ReturnType<typeof send>> | undefined;
|
||||
for (let i = 0; i < mediaUrls.length; i += 1) {
|
||||
const mediaUrl = mediaUrls[i];
|
||||
const isFirst = i === 0;
|
||||
finalResult = await send(to, isFirst ? text : "", {
|
||||
...baseOpts,
|
||||
mediaUrl,
|
||||
...(isFirst ? { buttons: telegramData?.buttons } : {}),
|
||||
});
|
||||
}
|
||||
return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
|
||||
},
|
||||
};
|
||||
|
||||
106
src/telegram/bot-native-commands.plugin-auth.test.ts
Normal file
106
src/telegram/bot-native-commands.plugin-auth.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||
import type { ClawdbotConfig } from "../config/config.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());
|
||||
const executePluginCommand = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../plugins/commands.js", () => ({
|
||||
getPluginCommandSpecs,
|
||||
matchPluginCommand,
|
||||
executePluginCommand,
|
||||
}));
|
||||
|
||||
const deliverReplies = vi.hoisted(() => vi.fn(async () => {}));
|
||||
vi.mock("./bot/delivery.js", () => ({ deliverReplies }));
|
||||
|
||||
vi.mock("./pairing-store.js", () => ({
|
||||
readTelegramAllowFromStore: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
describe("registerTelegramNativeCommands (plugin auth)", () => {
|
||||
it("allows requireAuth:false plugin command even when sender is unauthorized", async () => {
|
||||
const command = {
|
||||
name: "plugin",
|
||||
description: "Plugin command",
|
||||
requireAuth: false,
|
||||
handler: vi.fn(),
|
||||
} as const;
|
||||
|
||||
getPluginCommandSpecs.mockReturnValue([{ name: "plugin", description: "Plugin command" }]);
|
||||
matchPluginCommand.mockReturnValue({ command, args: undefined });
|
||||
executePluginCommand.mockResolvedValue({ text: "ok" });
|
||||
|
||||
const handlers: Record<string, (ctx: unknown) => Promise<void>> = {};
|
||||
const bot = {
|
||||
api: {
|
||||
setMyCommands: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
command: (name: string, handler: (ctx: unknown) => Promise<void>) => {
|
||||
handlers[name] = handler;
|
||||
},
|
||||
} as const;
|
||||
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const telegramCfg = {} as TelegramAccountConfig;
|
||||
const resolveGroupPolicy = () =>
|
||||
({
|
||||
allowlistEnabled: false,
|
||||
allowed: true,
|
||||
}) as ChannelGroupPolicy;
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
bot: bot as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
cfg,
|
||||
runtime: {} as RuntimeEnv,
|
||||
accountId: "default",
|
||||
telegramCfg,
|
||||
allowFrom: ["999"],
|
||||
groupAllowFrom: [],
|
||||
replyToMode: "off",
|
||||
textLimit: 4000,
|
||||
useAccessGroups: false,
|
||||
nativeEnabled: false,
|
||||
nativeSkillsEnabled: false,
|
||||
nativeDisabledExplicit: false,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: undefined,
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
shouldSkipUpdate: () => false,
|
||||
opts: { token: "token" },
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
message: {
|
||||
chat: { id: 123, type: "private" },
|
||||
from: { id: 111, username: "nope" },
|
||||
message_id: 10,
|
||||
date: 123456,
|
||||
},
|
||||
match: "",
|
||||
};
|
||||
|
||||
await handlers.plugin?.(ctx);
|
||||
|
||||
expect(matchPluginCommand).toHaveBeenCalled();
|
||||
expect(executePluginCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isAuthorizedSender: false,
|
||||
}),
|
||||
);
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replies: [{ text: "ok" }],
|
||||
}),
|
||||
);
|
||||
expect(bot.api.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -17,13 +17,17 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr
|
||||
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
||||
import { danger, logVerbose } from "../globals.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import {
|
||||
normalizeTelegramCommandName,
|
||||
TELEGRAM_COMMAND_NAME_PATTERN,
|
||||
} from "../config/telegram-custom-commands.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
|
||||
import {
|
||||
executePluginCommand,
|
||||
getPluginCommandSpecs,
|
||||
matchPluginCommand,
|
||||
executePluginCommand,
|
||||
} from "../plugins/commands.js";
|
||||
import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||
import type {
|
||||
@@ -47,6 +51,18 @@ import { readTelegramAllowFromStore } from "./pairing-store.js";
|
||||
|
||||
type TelegramNativeCommandContext = Context & { match?: string };
|
||||
|
||||
type TelegramCommandAuthResult = {
|
||||
chatId: number;
|
||||
isGroup: boolean;
|
||||
isForum: boolean;
|
||||
resolvedThreadId?: number;
|
||||
senderId: string;
|
||||
senderUsername: string;
|
||||
groupConfig?: TelegramGroupConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
commandAuthorized: boolean;
|
||||
};
|
||||
|
||||
type RegisterTelegramNativeCommandsParams = {
|
||||
bot: Bot;
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -70,6 +86,134 @@ type RegisterTelegramNativeCommandsParams = {
|
||||
opts: { token: string };
|
||||
};
|
||||
|
||||
async function resolveTelegramCommandAuth(params: {
|
||||
msg: NonNullable<TelegramNativeCommandContext["message"]>;
|
||||
bot: Bot;
|
||||
cfg: ClawdbotConfig;
|
||||
telegramCfg: TelegramAccountConfig;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
useAccessGroups: boolean;
|
||||
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
|
||||
resolveTelegramGroupConfig: (
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
||||
requireAuth: boolean;
|
||||
}): Promise<TelegramCommandAuthResult | null> {
|
||||
const {
|
||||
msg,
|
||||
bot,
|
||||
cfg,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth,
|
||||
} = params;
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||
const resolvedThreadId = resolveTelegramForumThreadId({
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
const effectiveGroupAllow = normalizeAllowFromWithStore({
|
||||
allowFrom: groupAllowOverride ?? groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
});
|
||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||
const senderIdRaw = msg.from?.id;
|
||||
const senderId = senderIdRaw ? String(senderIdRaw) : "";
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
|
||||
if (isGroup && groupConfig?.enabled === false) {
|
||||
await bot.api.sendMessage(chatId, "This group is disabled.");
|
||||
return null;
|
||||
}
|
||||
if (isGroup && topicConfig?.enabled === false) {
|
||||
await bot.api.sendMessage(chatId, "This topic is disabled.");
|
||||
return null;
|
||||
}
|
||||
if (requireAuth && isGroup && hasGroupAllowOverride) {
|
||||
if (
|
||||
senderIdRaw == null ||
|
||||
!isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId: String(senderIdRaw),
|
||||
senderUsername,
|
||||
})
|
||||
) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup && useAccessGroups) {
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
|
||||
if (groupPolicy === "disabled") {
|
||||
await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
|
||||
return null;
|
||||
}
|
||||
if (groupPolicy === "allowlist" && requireAuth) {
|
||||
if (
|
||||
senderIdRaw == null ||
|
||||
!isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId: String(senderIdRaw),
|
||||
senderUsername,
|
||||
})
|
||||
) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const groupAllowlist = resolveGroupPolicy(chatId);
|
||||
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
||||
await bot.api.sendMessage(chatId, "This group is not allowed.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const dmAllow = normalizeAllowFromWithStore({
|
||||
allowFrom: allowFrom,
|
||||
storeAllowFrom,
|
||||
});
|
||||
const senderAllowed = isSenderAllowed({
|
||||
allow: dmAllow,
|
||||
senderId,
|
||||
senderUsername,
|
||||
});
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
if (requireAuth && !commandAuthorized) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
chatId,
|
||||
isGroup,
|
||||
isForum,
|
||||
resolvedThreadId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
commandAuthorized,
|
||||
};
|
||||
}
|
||||
|
||||
export const registerTelegramNativeCommands = ({
|
||||
bot,
|
||||
cfg,
|
||||
@@ -109,15 +253,49 @@ export const registerTelegramNativeCommands = ({
|
||||
}
|
||||
const customCommands = customResolution.commands;
|
||||
const pluginCommandSpecs = getPluginCommandSpecs();
|
||||
const pluginCommands: Array<{ command: string; description: string }> = [];
|
||||
const existingCommands = new Set(
|
||||
[
|
||||
...nativeCommands.map((command) => command.name),
|
||||
...customCommands.map((command) => command.command),
|
||||
].map((command) => command.toLowerCase()),
|
||||
);
|
||||
const pluginCommandNames = new Set<string>();
|
||||
for (const spec of pluginCommandSpecs) {
|
||||
const normalized = normalizeTelegramCommandName(spec.name);
|
||||
if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
|
||||
runtime.error?.(
|
||||
danger(
|
||||
`Plugin command "/${spec.name}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const description = spec.description.trim();
|
||||
if (!description) {
|
||||
runtime.error?.(danger(`Plugin command "/${normalized}" is missing a description.`));
|
||||
continue;
|
||||
}
|
||||
if (existingCommands.has(normalized)) {
|
||||
runtime.error?.(
|
||||
danger(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (pluginCommandNames.has(normalized)) {
|
||||
runtime.error?.(danger(`Plugin command "/${normalized}" is duplicated.`));
|
||||
continue;
|
||||
}
|
||||
pluginCommandNames.add(normalized);
|
||||
existingCommands.add(normalized);
|
||||
pluginCommands.push({ command: normalized, description });
|
||||
}
|
||||
const allCommands: Array<{ command: string; description: string }> = [
|
||||
...nativeCommands.map((command) => ({
|
||||
command: command.name,
|
||||
description: command.description,
|
||||
})),
|
||||
...pluginCommandSpecs.map((spec) => ({
|
||||
command: spec.name,
|
||||
description: spec.description,
|
||||
})),
|
||||
...pluginCommands,
|
||||
...customCommands,
|
||||
];
|
||||
|
||||
@@ -134,99 +312,30 @@ export const registerTelegramNativeCommands = ({
|
||||
const msg = ctx.message;
|
||||
if (!msg) return;
|
||||
if (shouldSkipUpdate(ctx)) return;
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||
const resolvedThreadId = resolveTelegramForumThreadId({
|
||||
const auth = await resolveTelegramCommandAuth({
|
||||
msg,
|
||||
bot,
|
||||
cfg,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth: true,
|
||||
});
|
||||
if (!auth) return;
|
||||
const {
|
||||
chatId,
|
||||
isGroup,
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
const effectiveGroupAllow = normalizeAllowFromWithStore({
|
||||
allowFrom: groupAllowOverride ?? groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
});
|
||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||
|
||||
if (isGroup && groupConfig?.enabled === false) {
|
||||
await bot.api.sendMessage(chatId, "This group is disabled.");
|
||||
return;
|
||||
}
|
||||
if (isGroup && topicConfig?.enabled === false) {
|
||||
await bot.api.sendMessage(chatId, "This topic is disabled.");
|
||||
return;
|
||||
}
|
||||
if (isGroup && hasGroupAllowOverride) {
|
||||
const senderId = msg.from?.id;
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
if (
|
||||
senderId == null ||
|
||||
!isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId: String(senderId),
|
||||
senderUsername,
|
||||
})
|
||||
) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup && useAccessGroups) {
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
|
||||
if (groupPolicy === "disabled") {
|
||||
await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
const senderId = msg.from?.id;
|
||||
if (senderId == null) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
if (
|
||||
!isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId: String(senderId),
|
||||
senderUsername,
|
||||
})
|
||||
) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const groupAllowlist = resolveGroupPolicy(chatId);
|
||||
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
||||
await bot.api.sendMessage(chatId, "This group is not allowed.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
const dmAllow = normalizeAllowFromWithStore({
|
||||
allowFrom: allowFrom,
|
||||
storeAllowFrom,
|
||||
});
|
||||
const senderAllowed = isSenderAllowed({
|
||||
allow: dmAllow,
|
||||
resolvedThreadId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
});
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
if (!commandAuthorized) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
commandAuthorized,
|
||||
} = auth;
|
||||
|
||||
const commandDefinition = findCommandByNativeName(command.name, "telegram");
|
||||
const rawText = ctx.match?.trim() ?? "";
|
||||
@@ -373,114 +482,33 @@ export const registerTelegramNativeCommands = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Register handlers for plugin commands
|
||||
for (const pluginSpec of pluginCommandSpecs) {
|
||||
bot.command(pluginSpec.name, async (ctx: TelegramNativeCommandContext) => {
|
||||
for (const pluginCommand of pluginCommands) {
|
||||
bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => {
|
||||
const msg = ctx.message;
|
||||
if (!msg) return;
|
||||
if (shouldSkipUpdate(ctx)) return;
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||
const resolvedThreadId = resolveTelegramForumThreadId({
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
const effectiveGroupAllow = normalizeAllowFromWithStore({
|
||||
allowFrom: groupAllowOverride ?? groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
});
|
||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||
|
||||
if (isGroup && groupConfig?.enabled === false) {
|
||||
await bot.api.sendMessage(chatId, "This group is disabled.");
|
||||
return;
|
||||
}
|
||||
if (isGroup && topicConfig?.enabled === false) {
|
||||
await bot.api.sendMessage(chatId, "This topic is disabled.");
|
||||
return;
|
||||
}
|
||||
if (isGroup && hasGroupAllowOverride) {
|
||||
const senderId = msg.from?.id;
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
if (
|
||||
senderId == null ||
|
||||
!isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId: String(senderId),
|
||||
senderUsername,
|
||||
})
|
||||
) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup && useAccessGroups) {
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
|
||||
if (groupPolicy === "disabled") {
|
||||
await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
const senderId = msg.from?.id;
|
||||
if (senderId == null) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
if (
|
||||
!isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId: String(senderId),
|
||||
senderUsername,
|
||||
})
|
||||
) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const groupAllowlist = resolveGroupPolicy(chatId);
|
||||
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
||||
await bot.api.sendMessage(chatId, "This group is not allowed.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
const dmAllow = normalizeAllowFromWithStore({
|
||||
allowFrom: allowFrom,
|
||||
storeAllowFrom,
|
||||
});
|
||||
const senderAllowed = isSenderAllowed({
|
||||
allow: dmAllow,
|
||||
senderId,
|
||||
senderUsername,
|
||||
});
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
if (!commandAuthorized) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Match and execute plugin command
|
||||
const rawText = ctx.match?.trim() ?? "";
|
||||
const commandBody = `/${pluginSpec.name}${rawText ? ` ${rawText}` : ""}`;
|
||||
const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`;
|
||||
const match = matchPluginCommand(commandBody);
|
||||
if (!match) {
|
||||
await bot.api.sendMessage(chatId, "Command not found.");
|
||||
return;
|
||||
}
|
||||
const auth = await resolveTelegramCommandAuth({
|
||||
msg,
|
||||
bot,
|
||||
cfg,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth: match.command.requireAuth !== false,
|
||||
});
|
||||
if (!auth) return;
|
||||
const { resolvedThreadId, senderId, commandAuthorized } = auth;
|
||||
|
||||
const result = await executePluginCommand({
|
||||
command: match.command,
|
||||
@@ -491,8 +519,6 @@ export const registerTelegramNativeCommands = ({
|
||||
commandBody,
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
// Deliver the result
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
|
||||
@@ -17,8 +17,8 @@ import { isGifMedia } from "../../media/mime.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
|
||||
import type { TelegramContext } from "./types.js";
|
||||
|
||||
@@ -81,18 +81,16 @@ export async function deliverReplies(params: {
|
||||
: reply.mediaUrl
|
||||
? [reply.mediaUrl]
|
||||
: [];
|
||||
const telegramData = reply.channelData?.telegram as
|
||||
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
|
||||
| undefined;
|
||||
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
|
||||
if (mediaList.length === 0) {
|
||||
// Extract Telegram buttons from channelData
|
||||
const telegramData = reply.channelData?.telegram as
|
||||
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
|
||||
| undefined;
|
||||
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
|
||||
|
||||
const chunks = chunkText(reply.text || "");
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
if (!chunk) continue;
|
||||
// Only attach buttons to the first chunk
|
||||
// Only attach buttons to the first chunk.
|
||||
const shouldAttachButtons = i === 0 && replyMarkup;
|
||||
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
||||
replyToMessageId:
|
||||
@@ -137,10 +135,12 @@ export async function deliverReplies(params: {
|
||||
first = false;
|
||||
const replyToMessageId =
|
||||
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
|
||||
const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText;
|
||||
const mediaParams: Record<string, unknown> = {
|
||||
caption: htmlCaption,
|
||||
reply_to_message_id: replyToMessageId,
|
||||
...(htmlCaption ? { parse_mode: "HTML" } : {}),
|
||||
...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}),
|
||||
};
|
||||
if (threadParams) {
|
||||
mediaParams.message_thread_id = threadParams.message_thread_id;
|
||||
@@ -195,6 +195,7 @@ export async function deliverReplies(params: {
|
||||
hasReplied,
|
||||
messageThreadId,
|
||||
linkPreview,
|
||||
replyMarkup,
|
||||
});
|
||||
// Skip this media item; continue with next.
|
||||
continue;
|
||||
@@ -219,7 +220,8 @@ export async function deliverReplies(params: {
|
||||
// Chunk it in case it's extremely long (same logic as text-only replies).
|
||||
if (pendingFollowUpText && isFirstMedia) {
|
||||
const chunks = chunkText(pendingFollowUpText);
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
const replyToMessageIdFollowup =
|
||||
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
|
||||
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
||||
@@ -228,6 +230,7 @@ export async function deliverReplies(params: {
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview,
|
||||
replyMarkup: i === 0 ? replyMarkup : undefined,
|
||||
});
|
||||
if (replyToId && !hasReplied) {
|
||||
hasReplied = true;
|
||||
@@ -289,10 +292,12 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
hasReplied: boolean;
|
||||
messageThreadId?: number;
|
||||
linkPreview?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
}): Promise<boolean> {
|
||||
const chunks = opts.chunkText(opts.text);
|
||||
let hasReplied = opts.hasReplied;
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
||||
replyToMessageId:
|
||||
opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined,
|
||||
@@ -300,6 +305,7 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: opts.linkPreview,
|
||||
replyMarkup: i === 0 ? opts.replyMarkup : undefined,
|
||||
});
|
||||
if (opts.replyToId && !hasReplied) {
|
||||
hasReplied = true;
|
||||
|
||||
Reference in New Issue
Block a user