refactor: move Telegram channel implementation to extensions/ (#45635)

* refactor: move Telegram channel implementation to extensions/telegram/src/

Move all Telegram channel code (123 files + 10 bot/ files + 8 channel plugin
files) from src/telegram/ and src/channels/plugins/*/telegram.ts to
extensions/telegram/src/. Leave thin re-export shims at original locations so
cross-cutting src/ imports continue to resolve.

- Fix all relative import paths in moved files (../X/ -> ../../../src/X/)
- Fix vi.mock paths in 60 test files
- Fix inline typeof import() expressions
- Update tsconfig.plugin-sdk.dts.json rootDir to "." for cross-directory DTS
- Update write-plugin-sdk-entry-dts.ts for new rootDir structure
- Move channel plugin files with correct path remapping

* fix: support keyed telegram send deps

* fix: sync telegram extension copies with latest main

* fix: correct import paths and remove misplaced files in telegram extension

* fix: sync outbound-adapter with main (add sendTelegramPayloadMessages) and fix delivery.test import path
This commit is contained in:
scoootscooob
2026-03-14 02:50:17 -07:00
committed by GitHub
parent 8746362f5e
commit e5bca0832f
230 changed files with 19157 additions and 19204 deletions

View File

@@ -1,287 +1 @@
import {
readNumberParam,
readStringArrayParam,
readStringOrNumberParam,
readStringParam,
} from "../../../agents/tools/common.js";
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js";
import type { TelegramActionConfig } from "../../../config/types.telegram.js";
import { readBooleanParam } from "../../../plugin-sdk/boolean-param.js";
import { extractToolSend } from "../../../plugin-sdk/tool-send.js";
import { resolveTelegramPollVisibility } from "../../../poll-params.js";
import {
createTelegramActionGate,
listEnabledTelegramAccounts,
resolveTelegramPollActionGateState,
} from "../../../telegram/accounts.js";
import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
import { resolveReactionMessageId } from "./reaction-message-id.js";
import { createUnionActionGate, listTokenSourcedAccounts } from "./shared.js";
const providerId = "telegram";
function readTelegramSendParams(params: Record<string, unknown>) {
const to = readStringParam(params, "to", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false });
const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true });
const caption = readStringParam(params, "caption", { allowEmpty: true });
const content = message || caption || "";
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
const asVoice = readBooleanParam(params, "asVoice");
const silent = readBooleanParam(params, "silent");
const quoteText = readStringParam(params, "quoteText");
return {
to,
content,
mediaUrl: mediaUrl ?? undefined,
replyToMessageId: replyTo ?? undefined,
messageThreadId: threadId ?? undefined,
buttons,
asVoice,
silent,
quoteText: quoteText ?? undefined,
};
}
function readTelegramChatIdParam(params: Record<string, unknown>): string | number {
return (
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "channelId") ??
readStringParam(params, "to", { required: true })
);
}
function readTelegramMessageIdParam(params: Record<string, unknown>): number {
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
});
if (typeof messageId !== "number") {
throw new Error("messageId is required.");
}
return messageId;
}
export const telegramMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg));
if (accounts.length === 0) {
return [];
}
// Union of all accounts' action gates (any account enabling an action makes it available)
const gate = createUnionActionGate(accounts, (account) =>
createTelegramActionGate({
cfg,
accountId: account.accountId,
}),
);
const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) =>
gate(key, defaultValue);
const actions = new Set<ChannelMessageActionName>(["send"]);
const pollEnabledForAnyAccount = accounts.some((account) => {
const accountGate = createTelegramActionGate({
cfg,
accountId: account.accountId,
});
return resolveTelegramPollActionGateState(accountGate).enabled;
});
if (pollEnabledForAnyAccount) {
actions.add("poll");
}
if (isEnabled("reactions")) {
actions.add("react");
}
if (isEnabled("deleteMessage")) {
actions.add("delete");
}
if (isEnabled("editMessage")) {
actions.add("edit");
}
if (isEnabled("sticker", false)) {
actions.add("sticker");
actions.add("sticker-search");
}
if (isEnabled("createForumTopic")) {
actions.add("topic-create");
}
return Array.from(actions);
},
supportsButtons: ({ cfg }) => {
const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg));
if (accounts.length === 0) {
return false;
}
return accounts.some((account) =>
isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }),
);
},
extractToolSend: ({ args }) => {
return extractToolSend(args, "sendMessage");
},
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => {
if (action === "send") {
const sendParams = readTelegramSendParams(params);
return await handleTelegramAction(
{
action: "sendMessage",
...sendParams,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "react") {
const messageId = resolveReactionMessageId({ args: params, toolContext });
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = readBooleanParam(params, "remove");
return await handleTelegramAction(
{
action: "react",
chatId: readTelegramChatIdParam(params),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", { required: true });
const answers = readStringArrayParam(params, "pollOption", { required: true });
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
strict: true,
});
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
integer: true,
strict: true,
});
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
const allowMultiselect = readBooleanParam(params, "pollMulti");
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
const pollPublic = readBooleanParam(params, "pollPublic");
const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic });
const silent = readBooleanParam(params, "silent");
return await handleTelegramAction(
{
action: "poll",
to,
question,
answers,
allowMultiselect,
durationHours: durationHours ?? undefined,
durationSeconds: durationSeconds ?? undefined,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
isAnonymous,
silent,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "delete") {
const chatId = readTelegramChatIdParam(params);
const messageId = readTelegramMessageIdParam(params);
return await handleTelegramAction(
{
action: "deleteMessage",
chatId,
messageId,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "edit") {
const chatId = readTelegramChatIdParam(params);
const messageId = readTelegramMessageIdParam(params);
const message = readStringParam(params, "message", { required: true, allowEmpty: false });
const buttons = params.buttons;
return await handleTelegramAction(
{
action: "editMessage",
chatId,
messageId,
content: message,
buttons,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "sticker") {
const to =
readStringParam(params, "to") ?? readStringParam(params, "target", { required: true });
// Accept stickerId (array from shared schema) and use first element as fileId
const stickerIds = readStringArrayParam(params, "stickerId");
const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true });
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
return await handleTelegramAction(
{
action: "sendSticker",
to,
fileId,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "sticker-search") {
const query = readStringParam(params, "query", { required: true });
const limit = readNumberParam(params, "limit", { integer: true });
return await handleTelegramAction(
{
action: "searchSticker",
query,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "topic-create") {
const chatId = readTelegramChatIdParam(params);
const name = readStringParam(params, "name", { required: true });
const iconColor = readNumberParam(params, "iconColor", { integer: true });
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
return await handleTelegramAction(
{
action: "createForumTopic",
chatId,
name,
iconColor: iconColor ?? undefined,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};
export * from "../../../../extensions/telegram/src/channel-actions.js";

View File

@@ -1,43 +0,0 @@
import { describe, expect, it } from "vitest";
import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./telegram.js";
describe("normalizeTelegramMessagingTarget", () => {
it("normalizes t.me links to prefixed usernames", () => {
expect(normalizeTelegramMessagingTarget("https://t.me/MyChannel")).toBe("telegram:@mychannel");
});
it("keeps unprefixed topic targets valid", () => {
expect(normalizeTelegramMessagingTarget("@MyChannel:topic:9")).toBe(
"telegram:@mychannel:topic:9",
);
expect(normalizeTelegramMessagingTarget("-1001234567890:topic:456")).toBe(
"telegram:-1001234567890:topic:456",
);
});
it("keeps legacy prefixed topic targets valid", () => {
expect(normalizeTelegramMessagingTarget("telegram:group:-1001234567890:topic:456")).toBe(
"telegram:group:-1001234567890:topic:456",
);
expect(normalizeTelegramMessagingTarget("tg:group:-1001234567890:topic:456")).toBe(
"telegram:group:-1001234567890:topic:456",
);
});
});
describe("looksLikeTelegramTargetId", () => {
it("recognizes unprefixed topic targets", () => {
expect(looksLikeTelegramTargetId("@mychannel:topic:9")).toBe(true);
expect(looksLikeTelegramTargetId("-1001234567890:topic:456")).toBe(true);
});
it("recognizes legacy prefixed topic targets", () => {
expect(looksLikeTelegramTargetId("telegram:group:-1001234567890:topic:456")).toBe(true);
expect(looksLikeTelegramTargetId("tg:group:-1001234567890:topic:456")).toBe(true);
});
it("still recognizes normalized lookup targets", () => {
expect(looksLikeTelegramTargetId("https://t.me/MyChannel")).toBe(true);
expect(looksLikeTelegramTargetId("@mychannel")).toBe(true);
});
});

View File

@@ -1,44 +1 @@
import { normalizeTelegramLookupTarget, parseTelegramTarget } from "../../../telegram/targets.js";
const TELEGRAM_PREFIX_RE = /^(telegram|tg):/i;
function normalizeTelegramTargetBody(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
const prefixStripped = trimmed.replace(TELEGRAM_PREFIX_RE, "").trim();
if (!prefixStripped) {
return undefined;
}
const parsed = parseTelegramTarget(trimmed);
const normalizedChatId = normalizeTelegramLookupTarget(parsed.chatId);
if (!normalizedChatId) {
return undefined;
}
const keepLegacyGroupPrefix = /^group:/i.test(prefixStripped);
const hasTopicSuffix = /:topic:\d+$/i.test(prefixStripped);
const chatSegment = keepLegacyGroupPrefix ? `group:${normalizedChatId}` : normalizedChatId;
if (parsed.messageThreadId == null) {
return chatSegment;
}
const threadSuffix = hasTopicSuffix
? `:topic:${parsed.messageThreadId}`
: `:${parsed.messageThreadId}`;
return `${chatSegment}${threadSuffix}`;
}
export function normalizeTelegramMessagingTarget(raw: string): string | undefined {
const normalizedBody = normalizeTelegramTargetBody(raw);
if (!normalizedBody) {
return undefined;
}
return `telegram:${normalizedBody}`.toLowerCase();
}
export function looksLikeTelegramTargetId(raw: string): boolean {
return normalizeTelegramTargetBody(raw) !== undefined;
}
export * from "../../../../extensions/telegram/src/normalize.js";

View File

@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { normalizeTelegramAllowFromInput, parseTelegramAllowFromId } from "./telegram.js";
describe("normalizeTelegramAllowFromInput", () => {
it("strips telegram/tg prefixes and trims whitespace", () => {
expect(normalizeTelegramAllowFromInput(" telegram:123 ")).toBe("123");
expect(normalizeTelegramAllowFromInput("tg:@alice")).toBe("@alice");
expect(normalizeTelegramAllowFromInput(" @bob ")).toBe("@bob");
});
});
describe("parseTelegramAllowFromId", () => {
it("accepts numeric ids with optional prefixes", () => {
expect(parseTelegramAllowFromId("12345")).toBe("12345");
expect(parseTelegramAllowFromId("telegram:98765")).toBe("98765");
expect(parseTelegramAllowFromId("tg:2468")).toBe("2468");
});
it("rejects non-numeric values", () => {
expect(parseTelegramAllowFromId("@alice")).toBeNull();
expect(parseTelegramAllowFromId("tg:alice")).toBeNull();
});
});

View File

@@ -1,243 +1 @@
import { formatCliCommand } from "../../../cli/command-format.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import { inspectTelegramAccount } from "../../../telegram/account-inspect.js";
import {
listTelegramAccountIds,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "../../../telegram/accounts.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import { fetchTelegramChatId } from "../../telegram/api.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import {
applySingleTokenPromptResult,
patchChannelConfigForAccount,
promptResolvedAllowFrom,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
runSingleChannelSecretStep,
setChannelDmPolicyWithAllowFrom,
setOnboardingChannelEnabled,
splitOnboardingEntries,
} from "./helpers.js";
const channel = "telegram" as const;
async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Open Telegram and chat with @BotFather",
"2) Run /newbot (or /mybots)",
"3) Copy the token (looks like 123456:ABC...)",
"Tip: you can also set TELEGRAM_BOT_TOKEN in your env.",
`Docs: ${formatDocsLink("/telegram")}`,
"Website: https://openclaw.ai",
].join("\n"),
"Telegram bot token",
);
}
async function noteTelegramUserIdHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
`1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`,
"2) Or call https://api.telegram.org/bot<bot_token>/getUpdates and read message.from.id",
"3) Third-party: DM @userinfobot or @getidsbot",
`Docs: ${formatDocsLink("/telegram")}`,
"Website: https://openclaw.ai",
].join("\n"),
"Telegram user id",
);
}
export function normalizeTelegramAllowFromInput(raw: string): string {
return raw
.trim()
.replace(/^(telegram|tg):/i, "")
.trim();
}
export function parseTelegramAllowFromId(raw: string): string | null {
const stripped = normalizeTelegramAllowFromInput(raw);
return /^\d+$/.test(stripped) ? stripped : null;
}
async function promptTelegramAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId: string;
tokenOverride?: string;
}): Promise<OpenClawConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveTelegramAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
await noteTelegramUserIdHelp(prompter);
const token = params.tokenOverride?.trim() || resolved.token;
if (!token) {
await prompter.note("Telegram token missing; username lookup is unavailable.", "Telegram");
}
const unique = await promptResolvedAllowFrom({
prompter,
existing: existingAllowFrom,
token,
message: "Telegram allowFrom (numeric sender id; @username resolves to id)",
placeholder: "@username",
label: "Telegram allowlist",
parseInputs: splitOnboardingEntries,
parseId: parseTelegramAllowFromId,
invalidWithoutTokenNote:
"Telegram token missing; use numeric sender ids (usernames require a bot token).",
resolveEntries: async ({ token: tokenValue, entries }) => {
const results = await Promise.all(
entries.map(async (entry) => {
const numericId = parseTelegramAllowFromId(entry);
if (numericId) {
return { input: entry, resolved: true, id: numericId };
}
const stripped = normalizeTelegramAllowFromInput(entry);
if (!stripped) {
return { input: entry, resolved: false, id: null };
}
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const id = await fetchTelegramChatId({ token: tokenValue, chatId: username });
return { input: entry, resolved: Boolean(id), id };
}),
);
return results;
},
});
return patchChannelConfigForAccount({
cfg,
channel: "telegram",
accountId,
patch: { dmPolicy: "allowlist", allowFrom: unique },
});
}
async function promptTelegramAllowFromForAccount(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveOnboardingAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultTelegramAccountId(params.cfg),
});
return promptTelegramAllowFrom({
cfg: params.cfg,
prompter: params.prompter,
accountId,
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Telegram",
channel,
policyKey: "channels.telegram.dmPolicy",
allowFromKey: "channels.telegram.allowFrom",
getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) =>
setChannelDmPolicyWithAllowFrom({
cfg,
channel: "telegram",
dmPolicy: policy,
}),
promptAllowFrom: promptTelegramAllowFromForAccount,
};
export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listTelegramAccountIds(cfg).some((accountId) => {
const account = inspectTelegramAccount({ cfg, accountId });
return account.configured;
});
return {
channel,
configured,
statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`],
selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly",
quickstartScore: configured ? 1 : 10,
};
},
configure: async ({
cfg,
prompter,
options,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg);
const telegramAccountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "Telegram",
accountOverride: accountOverrides.telegram,
shouldPromptAccountIds,
listAccountIds: listTelegramAccountIds,
defaultAccountId: defaultTelegramAccountId,
});
let next = cfg;
const resolvedAccount = resolveTelegramAccount({
cfg: next,
accountId: telegramAccountId,
});
const hasConfiguredBotToken = hasConfiguredSecretInput(resolvedAccount.config.botToken);
const hasConfigToken =
hasConfiguredBotToken || Boolean(resolvedAccount.config.tokenFile?.trim());
const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID;
const tokenStep = await runSingleChannelSecretStep({
cfg: next,
prompter,
providerHint: "telegram",
credentialLabel: "Telegram bot token",
secretInputMode: options?.secretInputMode,
accountConfigured: Boolean(resolvedAccount.token) || hasConfigToken,
hasConfigToken,
allowEnv,
envValue: process.env.TELEGRAM_BOT_TOKEN,
envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?",
keepPrompt: "Telegram token already configured. Keep it?",
inputPrompt: "Enter Telegram bot token",
preferredEnvVar: allowEnv ? "TELEGRAM_BOT_TOKEN" : undefined,
onMissingConfigured: async () => await noteTelegramTokenHelp(prompter),
applyUseEnv: async (cfg) =>
applySingleTokenPromptResult({
cfg,
channel: "telegram",
accountId: telegramAccountId,
tokenPatchKey: "botToken",
tokenResult: { useEnv: true, token: null },
}),
applySet: async (cfg, value) =>
applySingleTokenPromptResult({
cfg,
channel: "telegram",
accountId: telegramAccountId,
tokenPatchKey: "botToken",
tokenResult: { useEnv: false, token: value },
}),
});
next = tokenStep.cfg;
if (forceAllowFrom) {
next = await promptTelegramAllowFrom({
cfg: next,
prompter,
accountId: telegramAccountId,
tokenOverride: tokenStep.resolvedValue,
});
}
return { cfg: next, accountId: telegramAccountId };
},
dmPolicy,
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
};
export * from "../../../../extensions/telegram/src/onboarding.js";

View File

@@ -1,142 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { telegramOutbound } from "./telegram.js";
describe("telegramOutbound", () => {
it("passes parsed reply/thread ids for sendText", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-text-1", chatId: "123" });
const sendText = telegramOutbound.sendText;
expect(sendText).toBeDefined();
const result = await sendText!({
cfg: {},
to: "123",
text: "<b>hello</b>",
accountId: "work",
replyToId: "44",
threadId: "55",
deps: { telegram: sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledWith(
"123",
"<b>hello</b>",
expect.objectContaining({
textMode: "html",
verbose: false,
accountId: "work",
replyToMessageId: 44,
messageThreadId: 55,
}),
);
expect(result).toEqual({ channel: "telegram", messageId: "tg-text-1", chatId: "123" });
});
it("parses scoped DM thread ids for sendText", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-text-2", chatId: "12345" });
const sendText = telegramOutbound.sendText;
expect(sendText).toBeDefined();
await sendText!({
cfg: {},
to: "12345",
text: "<b>hello</b>",
accountId: "work",
threadId: "12345:99",
deps: { telegram: sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledWith(
"12345",
"<b>hello</b>",
expect.objectContaining({
textMode: "html",
verbose: false,
accountId: "work",
messageThreadId: 99,
}),
);
});
it("passes media options for sendMedia", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-media-1", chatId: "123" });
const sendMedia = telegramOutbound.sendMedia;
expect(sendMedia).toBeDefined();
const result = await sendMedia!({
cfg: {},
to: "123",
text: "caption",
mediaUrl: "https://example.com/a.jpg",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
deps: { telegram: sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledWith(
"123",
"caption",
expect.objectContaining({
textMode: "html",
verbose: false,
mediaUrl: "https://example.com/a.jpg",
mediaLocalRoots: ["/tmp/media"],
}),
);
expect(result).toEqual({ channel: "telegram", messageId: "tg-media-1", chatId: "123" });
});
it("sends payload media list and applies buttons only to first message", async () => {
const sendTelegram = vi
.fn()
.mockResolvedValueOnce({ messageId: "tg-1", chatId: "123" })
.mockResolvedValueOnce({ messageId: "tg-2", chatId: "123" });
const sendPayload = telegramOutbound.sendPayload;
expect(sendPayload).toBeDefined();
const payload: ReplyPayload = {
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
channelData: {
telegram: {
quoteText: "quoted",
buttons: [[{ text: "Approve", callback_data: "ok" }]],
},
},
};
const result = await sendPayload!({
cfg: {},
to: "123",
text: "",
payload,
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
deps: { telegram: sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledTimes(2);
expect(sendTelegram).toHaveBeenNthCalledWith(
1,
"123",
"caption",
expect.objectContaining({
mediaUrl: "https://example.com/1.jpg",
quoteText: "quoted",
buttons: [[{ text: "Approve", callback_data: "ok" }]],
}),
);
expect(sendTelegram).toHaveBeenNthCalledWith(
2,
"123",
"",
expect.objectContaining({
mediaUrl: "https://example.com/2.jpg",
quoteText: "quoted",
}),
);
const secondCallOpts = sendTelegram.mock.calls[1]?.[2] as Record<string, unknown>;
expect(secondCallOpts?.buttons).toBeUndefined();
expect(result).toEqual({ channel: "telegram", messageId: "tg-2", chatId: "123" });
});
});

View File

@@ -1,159 +1 @@
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js";
import type { TelegramInlineButtons } from "../../../telegram/button-types.js";
import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js";
import {
parseTelegramReplyToMessageId,
parseTelegramThreadId,
} from "../../../telegram/outbound-params.js";
import { sendMessageTelegram } from "../../../telegram/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
import { resolvePayloadMediaUrls, sendPayloadMediaSequence } from "./direct-text-media.js";
type TelegramSendFn = typeof sendMessageTelegram;
type TelegramSendOpts = Parameters<TelegramSendFn>[2];
function resolveTelegramSendContext(params: {
cfg: NonNullable<TelegramSendOpts>["cfg"];
deps?: OutboundSendDeps;
accountId?: string | null;
replyToId?: string | null;
threadId?: string | number | null;
}): {
send: TelegramSendFn;
baseOpts: {
cfg: NonNullable<TelegramSendOpts>["cfg"];
verbose: false;
textMode: "html";
messageThreadId?: number;
replyToMessageId?: number;
accountId?: string;
};
} {
const send =
resolveOutboundSendDep<typeof sendMessageTelegram>(params.deps, "telegram") ??
sendMessageTelegram;
return {
send,
baseOpts: {
verbose: false,
textMode: "html",
cfg: params.cfg,
messageThreadId: parseTelegramThreadId(params.threadId),
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
accountId: params.accountId ?? undefined,
},
};
}
export async function sendTelegramPayloadMessages(params: {
send: TelegramSendFn;
to: string;
payload: ReplyPayload;
baseOpts: Omit<NonNullable<TelegramSendOpts>, "buttons" | "mediaUrl" | "quoteText">;
}): Promise<Awaited<ReturnType<TelegramSendFn>>> {
const telegramData = params.payload.channelData?.telegram as
| { buttons?: TelegramInlineButtons; quoteText?: string }
| undefined;
const quoteText =
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
const text = params.payload.text ?? "";
const mediaUrls = resolvePayloadMediaUrls(params.payload);
const payloadOpts = {
...params.baseOpts,
quoteText,
};
if (mediaUrls.length === 0) {
return await params.send(params.to, text, {
...payloadOpts,
buttons: telegramData?.buttons,
});
}
// Telegram allows reply_markup on media; attach buttons only to the first send.
const finalResult = await sendPayloadMediaSequence({
text,
mediaUrls,
send: async ({ text, mediaUrl, isFirst }) =>
await params.send(params.to, text, {
...payloadOpts,
mediaUrl,
...(isFirst ? { buttons: telegramData?.buttons } : {}),
}),
});
return finalResult ?? { messageId: "unknown", chatId: params.to };
}
export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks,
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
threadId,
});
const result = await send(to, text, {
...baseOpts,
});
return { channel: "telegram", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
}) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
threadId,
});
const result = await send(to, text, {
...baseOpts,
mediaUrl,
mediaLocalRoots,
});
return { channel: "telegram", ...result };
},
sendPayload: async ({
cfg,
to,
payload,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
}) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
threadId,
});
const result = await sendTelegramPayloadMessages({
send,
to,
payload,
baseOpts: {
...baseOpts,
mediaLocalRoots,
},
});
return { channel: "telegram", ...result };
},
};
export * from "../../../../extensions/telegram/src/outbound-adapter.js";

View File

@@ -1,145 +1 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
import {
appendMatchMetadata,
asString,
isRecord,
resolveEnabledConfiguredAccountId,
} from "./shared.js";
type TelegramAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
allowUnmentionedGroups?: unknown;
audit?: unknown;
};
type TelegramGroupMembershipAuditSummary = {
unresolvedGroups?: number;
hasWildcardUnmentionedGroups?: boolean;
groups?: Array<{
chatId: string;
ok?: boolean;
status?: string | null;
error?: string | null;
matchKey?: string;
matchSource?: string;
}>;
};
function readTelegramAccountStatus(value: ChannelAccountSnapshot): TelegramAccountStatus | null {
if (!isRecord(value)) {
return null;
}
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
allowUnmentionedGroups: value.allowUnmentionedGroups,
audit: value.audit,
};
}
function readTelegramGroupMembershipAuditSummary(
value: unknown,
): TelegramGroupMembershipAuditSummary {
if (!isRecord(value)) {
return {};
}
const unresolvedGroups =
typeof value.unresolvedGroups === "number" && Number.isFinite(value.unresolvedGroups)
? value.unresolvedGroups
: undefined;
const hasWildcardUnmentionedGroups =
typeof value.hasWildcardUnmentionedGroups === "boolean"
? value.hasWildcardUnmentionedGroups
: undefined;
const groupsRaw = value.groups;
const groups = Array.isArray(groupsRaw)
? (groupsRaw
.map((entry) => {
if (!isRecord(entry)) {
return null;
}
const chatId = asString(entry.chatId);
if (!chatId) {
return null;
}
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
const status = asString(entry.status) ?? null;
const error = asString(entry.error) ?? null;
const matchKey = asString(entry.matchKey) ?? undefined;
const matchSource = asString(entry.matchSource) ?? undefined;
return { chatId, ok, status, error, matchKey, matchSource };
})
.filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"])
: undefined;
return { unresolvedGroups, hasWildcardUnmentionedGroups, groups };
}
export function collectTelegramStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const account = readTelegramAccountStatus(entry);
if (!account) {
continue;
}
const accountId = resolveEnabledConfiguredAccountId(account);
if (!accountId) {
continue;
}
if (account.allowUnmentionedGroups === true) {
issues.push({
channel: "telegram",
accountId,
kind: "config",
message:
"Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.",
fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).",
});
}
const audit = readTelegramGroupMembershipAuditSummary(account.audit);
if (audit.hasWildcardUnmentionedGroups === true) {
issues.push({
channel: "telegram",
accountId,
kind: "config",
message:
'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.',
fix: "Add explicit numeric group ids under channels.telegram.groups (or per-account groups) to enable probing.",
});
}
if (audit.unresolvedGroups && audit.unresolvedGroups > 0) {
issues.push({
channel: "telegram",
accountId,
kind: "config",
message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`,
fix: "Use numeric chat IDs (e.g. -100...) as keys in channels.telegram.groups for requireMention=false groups.",
});
}
for (const group of audit.groups ?? []) {
if (group.ok === true) {
continue;
}
const status = group.status ? ` status=${group.status}` : "";
const err = group.error ? `: ${group.error}` : "";
const baseMessage = `Group ${group.chatId} not reachable by bot.${status}${err}`;
issues.push({
channel: "telegram",
accountId,
kind: "runtime",
message: appendMatchMetadata(baseMessage, {
matchKey: group.matchKey,
matchSource: group.matchSource,
}),
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
});
}
}
return issues;
}
export * from "../../../../extensions/telegram/src/status-issues.js";