mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
fix: persist resolved telegram delivery targets at runtime
This commit is contained in:
@@ -1,23 +1,7 @@
|
||||
import { normalizeTelegramLookupTarget } from "../../../telegram/targets.js";
|
||||
|
||||
export function normalizeTelegramMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
let normalized = trimmed;
|
||||
if (normalized.startsWith("telegram:")) {
|
||||
normalized = normalized.slice("telegram:".length).trim();
|
||||
} else if (normalized.startsWith("tg:")) {
|
||||
normalized = normalized.slice("tg:".length).trim();
|
||||
}
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const tmeMatch =
|
||||
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
|
||||
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
|
||||
if (tmeMatch?.[1]) {
|
||||
normalized = `@${tmeMatch[1]}`;
|
||||
}
|
||||
const normalized = normalizeTelegramLookupTarget(raw);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -25,15 +9,5 @@ export function normalizeTelegramMessagingTarget(raw: string): string | undefine
|
||||
}
|
||||
|
||||
export function looksLikeTelegramTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (/^(telegram|tg):/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
return true;
|
||||
}
|
||||
return /^-?\d{6,}$/.test(trimmed);
|
||||
return Boolean(normalizeTelegramLookupTarget(raw));
|
||||
}
|
||||
|
||||
@@ -27,11 +27,16 @@ const { loadConfig } = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const { maybePersistResolvedTelegramTarget } = vi.hoisted(() => ({
|
||||
maybePersistResolvedTelegramTarget: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
type TelegramSendTestMocks = {
|
||||
botApi: Record<string, MockFn>;
|
||||
botCtorSpy: MockFn;
|
||||
loadConfig: MockFn;
|
||||
loadWebMedia: MockFn;
|
||||
maybePersistResolvedTelegramTarget: MockFn;
|
||||
};
|
||||
|
||||
vi.mock("../web/media.js", () => ({
|
||||
@@ -62,14 +67,20 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./target-writeback.js", () => ({
|
||||
maybePersistResolvedTelegramTarget,
|
||||
}));
|
||||
|
||||
export function getTelegramSendTestMocks(): TelegramSendTestMocks {
|
||||
return { botApi, botCtorSpy, loadConfig, loadWebMedia };
|
||||
return { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget };
|
||||
}
|
||||
|
||||
export function installTelegramSendTestHooks() {
|
||||
beforeEach(() => {
|
||||
loadConfig.mockReturnValue({});
|
||||
loadWebMedia.mockReset();
|
||||
maybePersistResolvedTelegramTarget.mockReset();
|
||||
maybePersistResolvedTelegramTarget.mockResolvedValue(undefined);
|
||||
botCtorSpy.mockReset();
|
||||
for (const fn of Object.values(botApi)) {
|
||||
fn.mockReset();
|
||||
|
||||
@@ -9,7 +9,8 @@ import { clearSentMessageCache, recordSentMessage, wasSentByBot } from "./sent-m
|
||||
|
||||
installTelegramSendTestHooks();
|
||||
|
||||
const { botApi, botCtorSpy, loadConfig, loadWebMedia } = getTelegramSendTestMocks();
|
||||
const { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget } =
|
||||
getTelegramSendTestMocks();
|
||||
const {
|
||||
buildInlineKeyboard,
|
||||
createForumTopicTelegram,
|
||||
@@ -369,6 +370,48 @@ describe("sendMessageTelegram", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves t.me targets to numeric chat ids via getChat", async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 1,
|
||||
chat: { id: "-100123" },
|
||||
});
|
||||
const getChat = vi.fn().mockResolvedValue({ id: -100123 });
|
||||
const api = { sendMessage, getChat } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
getChat: typeof getChat;
|
||||
};
|
||||
|
||||
await sendMessageTelegram("https://t.me/mychannel", "hi", {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(getChat).toHaveBeenCalledWith("@mychannel");
|
||||
expect(sendMessage).toHaveBeenCalledWith("-100123", "hi", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
expect(maybePersistResolvedTelegramTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rawTarget: "https://t.me/mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails clearly when a legacy target cannot be resolved", async () => {
|
||||
const getChat = vi.fn().mockRejectedValue(new Error("400: Bad Request: chat not found"));
|
||||
const api = { getChat } as unknown as {
|
||||
getChat: typeof getChat;
|
||||
};
|
||||
|
||||
await expect(
|
||||
sendMessageTelegram("@missingchannel", "hi", {
|
||||
token: "tok",
|
||||
api,
|
||||
}),
|
||||
).rejects.toThrow(/could not be resolved to a numeric chat ID/i);
|
||||
});
|
||||
|
||||
it("includes thread params in media messages", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const sendPhoto = vi.fn().mockResolvedValue({
|
||||
@@ -1100,6 +1143,31 @@ describe("reactMessageTelegram", () => {
|
||||
|
||||
expect(setMessageReaction).toHaveBeenCalledWith("123", 456, testCase.expected);
|
||||
});
|
||||
|
||||
it("resolves legacy telegram targets before reacting", async () => {
|
||||
const setMessageReaction = vi.fn().mockResolvedValue(undefined);
|
||||
const getChat = vi.fn().mockResolvedValue({ id: -100123 });
|
||||
const api = { setMessageReaction, getChat } as unknown as {
|
||||
setMessageReaction: typeof setMessageReaction;
|
||||
getChat: typeof getChat;
|
||||
};
|
||||
|
||||
await reactMessageTelegram("@mychannel", 456, "✅", {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(getChat).toHaveBeenCalledWith("@mychannel");
|
||||
expect(setMessageReaction).toHaveBeenCalledWith("-100123", 456, [
|
||||
{ type: "emoji", emoji: "✅" },
|
||||
]);
|
||||
expect(maybePersistResolvedTelegramTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rawTarget: "@mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendStickerTelegram", () => {
|
||||
|
||||
@@ -29,7 +29,12 @@ import { renderTelegramHtmlText } from "./format.js";
|
||||
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
|
||||
import { makeProxyFetch } from "./proxy.js";
|
||||
import { recordSentMessage } from "./sent-message-cache.js";
|
||||
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
|
||||
import { maybePersistResolvedTelegramTarget } from "./target-writeback.js";
|
||||
import {
|
||||
normalizeTelegramChatId,
|
||||
normalizeTelegramLookupTarget,
|
||||
parseTelegramTarget,
|
||||
} from "./targets.js";
|
||||
import { resolveTelegramVoiceSend } from "./voice.js";
|
||||
|
||||
type TelegramApi = Bot["api"];
|
||||
@@ -136,42 +141,56 @@ function resolveToken(explicit: string | undefined, params: { accountId: string;
|
||||
return params.token.trim();
|
||||
}
|
||||
|
||||
function normalizeChatId(to: string): string {
|
||||
const trimmed = to.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Recipient is required for Telegram sends");
|
||||
async function resolveChatId(
|
||||
to: string,
|
||||
params: { api: TelegramApiOverride; verbose?: boolean },
|
||||
): Promise<string> {
|
||||
const numericChatId = normalizeTelegramChatId(to);
|
||||
if (numericChatId) {
|
||||
return numericChatId;
|
||||
}
|
||||
const lookupTarget = normalizeTelegramLookupTarget(to);
|
||||
const getChat = params.api.getChat;
|
||||
if (!lookupTarget || typeof getChat !== "function") {
|
||||
throw new Error("Telegram recipient must be a numeric chat ID");
|
||||
}
|
||||
try {
|
||||
const chat = await getChat.call(params.api, lookupTarget);
|
||||
const resolved = normalizeTelegramChatId(String(chat?.id ?? ""));
|
||||
if (!resolved) {
|
||||
throw new Error(`resolved chat id is not numeric (${String(chat?.id ?? "")})`);
|
||||
}
|
||||
if (params.verbose) {
|
||||
sendLogger.warn(`telegram recipient ${lookupTarget} resolved to numeric chat id ${resolved}`);
|
||||
}
|
||||
return resolved;
|
||||
} catch (err) {
|
||||
const detail = formatErrorMessage(err);
|
||||
throw new Error(
|
||||
`Telegram recipient ${lookupTarget} could not be resolved to a numeric chat ID (${detail})`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Common internal prefixes that sometimes leak into outbound sends.
|
||||
// - ctx.To uses `telegram:<id>`
|
||||
// - group sessions often use `telegram:group:<id>`
|
||||
let normalized = stripTelegramInternalPrefixes(trimmed);
|
||||
|
||||
// Accept t.me links for public chats/channels.
|
||||
// (Invite links like `t.me/+...` are not resolvable via Bot API.)
|
||||
const m =
|
||||
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
|
||||
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
|
||||
if (m?.[1]) {
|
||||
normalized = `@${m[1]}`;
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error("Recipient is required for Telegram sends");
|
||||
}
|
||||
if (normalized.startsWith("@")) {
|
||||
return normalized;
|
||||
}
|
||||
if (/^-?\d+$/.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// If the user passed a username without `@`, assume they meant a public chat/channel.
|
||||
if (/^[A-Za-z0-9_]{5,}$/i.test(normalized)) {
|
||||
return `@${normalized}`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
async function resolveAndPersistChatId(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
api: TelegramApiOverride;
|
||||
lookupTarget: string;
|
||||
persistTarget: string;
|
||||
verbose?: boolean;
|
||||
}): Promise<string> {
|
||||
const chatId = await resolveChatId(params.lookupTarget, {
|
||||
api: params.api,
|
||||
verbose: params.verbose,
|
||||
});
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: params.cfg,
|
||||
rawTarget: params.persistTarget,
|
||||
resolvedChatId: chatId,
|
||||
verbose: params.verbose,
|
||||
});
|
||||
return chatId;
|
||||
}
|
||||
|
||||
function normalizeMessageId(raw: string | number): number {
|
||||
@@ -434,7 +453,13 @@ export async function sendMessageTelegram(
|
||||
): Promise<TelegramSendResult> {
|
||||
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
||||
const target = parseTelegramTarget(to);
|
||||
const chatId = normalizeChatId(target.chatId);
|
||||
const chatId = await resolveAndPersistChatId({
|
||||
cfg,
|
||||
api,
|
||||
lookupTarget: target.chatId,
|
||||
persistTarget: to,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const mediaUrl = opts.mediaUrl?.trim();
|
||||
const replyMarkup = buildInlineKeyboard(opts.buttons);
|
||||
|
||||
@@ -722,7 +747,14 @@ export async function reactMessageTelegram(
|
||||
opts: TelegramReactionOpts = {},
|
||||
): Promise<{ ok: true } | { ok: false; warning: string }> {
|
||||
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
||||
const chatId = normalizeChatId(String(chatIdInput));
|
||||
const rawTarget = String(chatIdInput);
|
||||
const chatId = await resolveAndPersistChatId({
|
||||
cfg,
|
||||
api,
|
||||
lookupTarget: rawTarget,
|
||||
persistTarget: rawTarget,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const messageId = normalizeMessageId(messageIdInput);
|
||||
const requestWithDiag = createTelegramRequestWithDiag({
|
||||
cfg,
|
||||
@@ -768,7 +800,14 @@ export async function deleteMessageTelegram(
|
||||
opts: TelegramDeleteOpts = {},
|
||||
): Promise<{ ok: true }> {
|
||||
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
||||
const chatId = normalizeChatId(String(chatIdInput));
|
||||
const rawTarget = String(chatIdInput);
|
||||
const chatId = await resolveAndPersistChatId({
|
||||
cfg,
|
||||
api,
|
||||
lookupTarget: rawTarget,
|
||||
persistTarget: rawTarget,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const messageId = normalizeMessageId(messageIdInput);
|
||||
const requestWithDiag = createTelegramRequestWithDiag({
|
||||
cfg,
|
||||
@@ -807,7 +846,14 @@ export async function editMessageTelegram(
|
||||
...opts,
|
||||
cfg: opts.cfg,
|
||||
});
|
||||
const chatId = normalizeChatId(String(chatIdInput));
|
||||
const rawTarget = String(chatIdInput);
|
||||
const chatId = await resolveAndPersistChatId({
|
||||
cfg,
|
||||
api,
|
||||
lookupTarget: rawTarget,
|
||||
persistTarget: rawTarget,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const messageId = normalizeMessageId(messageIdInput);
|
||||
const requestWithDiag = createTelegramRequestWithDiag({
|
||||
cfg,
|
||||
@@ -928,7 +974,13 @@ export async function sendStickerTelegram(
|
||||
|
||||
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
||||
const target = parseTelegramTarget(to);
|
||||
const chatId = normalizeChatId(target.chatId);
|
||||
const chatId = await resolveAndPersistChatId({
|
||||
cfg,
|
||||
api,
|
||||
lookupTarget: target.chatId,
|
||||
persistTarget: to,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
|
||||
const threadParams = buildTelegramThreadReplyParams({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
@@ -1004,7 +1056,13 @@ export async function sendPollTelegram(
|
||||
): Promise<{ messageId: string; chatId: string; pollId?: string }> {
|
||||
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
||||
const target = parseTelegramTarget(to);
|
||||
const chatId = normalizeChatId(target.chatId);
|
||||
const chatId = await resolveAndPersistChatId({
|
||||
cfg,
|
||||
api,
|
||||
lookupTarget: target.chatId,
|
||||
persistTarget: to,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
|
||||
// Normalize the poll input (validates question, options, maxSelections)
|
||||
const normalizedPoll = normalizePollInput(poll, { maxOptions: 10 });
|
||||
@@ -1130,10 +1188,16 @@ export async function createForumTopicTelegram(
|
||||
const token = resolveToken(opts.token, account);
|
||||
// Accept topic-qualified targets (e.g. telegram:group:<id>:topic:<thread>)
|
||||
// but createForumTopic must always target the base supergroup chat id.
|
||||
const target = parseTelegramTarget(chatId);
|
||||
const normalizedChatId = normalizeChatId(target.chatId);
|
||||
const client = resolveTelegramClientOptions(account);
|
||||
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||
const target = parseTelegramTarget(chatId);
|
||||
const normalizedChatId = await resolveAndPersistChatId({
|
||||
cfg,
|
||||
api,
|
||||
lookupTarget: target.chatId,
|
||||
persistTarget: chatId,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
|
||||
const request = createTelegramRetryRunner({
|
||||
retry: opts.retry,
|
||||
|
||||
146
src/telegram/target-writeback.test.ts
Normal file
146
src/telegram/target-writeback.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const readConfigFileSnapshotForWrite = vi.fn();
|
||||
const writeConfigFile = vi.fn();
|
||||
const loadCronStore = vi.fn();
|
||||
const resolveCronStorePath = vi.fn();
|
||||
const saveCronStore = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshotForWrite,
|
||||
writeConfigFile,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../cron/store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../cron/store.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadCronStore,
|
||||
resolveCronStorePath,
|
||||
saveCronStore,
|
||||
};
|
||||
});
|
||||
|
||||
const { maybePersistResolvedTelegramTarget } = await import("./target-writeback.js");
|
||||
|
||||
describe("maybePersistResolvedTelegramTarget", () => {
|
||||
beforeEach(() => {
|
||||
readConfigFileSnapshotForWrite.mockReset();
|
||||
writeConfigFile.mockReset();
|
||||
loadCronStore.mockReset();
|
||||
resolveCronStorePath.mockReset();
|
||||
saveCronStore.mockReset();
|
||||
resolveCronStorePath.mockReturnValue("/tmp/cron/jobs.json");
|
||||
});
|
||||
|
||||
it("skips writeback when target is already numeric", async () => {
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {} as OpenClawConfig,
|
||||
rawTarget: "-100123",
|
||||
resolvedChatId: "-100123",
|
||||
});
|
||||
|
||||
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
|
||||
expect(loadCronStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes back matching config and cron targets", async () => {
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: {
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "t.me/mychannel",
|
||||
accounts: {
|
||||
alerts: {
|
||||
defaultTo: "@mychannel",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
writeOptions: { expectedConfigPath: "/tmp/openclaw.json" },
|
||||
});
|
||||
loadCronStore.mockResolvedValue({
|
||||
version: 1,
|
||||
jobs: [
|
||||
{ id: "a", delivery: { channel: "telegram", to: "https://t.me/mychannel" } },
|
||||
{ id: "b", delivery: { channel: "slack", to: "C123" } },
|
||||
],
|
||||
});
|
||||
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {
|
||||
cron: { store: "/tmp/cron/jobs.json" },
|
||||
} as OpenClawConfig,
|
||||
rawTarget: "t.me/mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
});
|
||||
|
||||
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "-100123",
|
||||
accounts: {
|
||||
alerts: {
|
||||
defaultTo: "-100123",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({ expectedConfigPath: "/tmp/openclaw.json" }),
|
||||
);
|
||||
expect(saveCronStore).toHaveBeenCalledTimes(1);
|
||||
expect(saveCronStore).toHaveBeenCalledWith(
|
||||
"/tmp/cron/jobs.json",
|
||||
expect.objectContaining({
|
||||
jobs: [
|
||||
{ id: "a", delivery: { channel: "telegram", to: "-100123" } },
|
||||
{ id: "b", delivery: { channel: "slack", to: "C123" } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves topic suffix style in writeback target", async () => {
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: {
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "t.me/mychannel:topic:9",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
writeOptions: {},
|
||||
});
|
||||
loadCronStore.mockResolvedValue({ version: 1, jobs: [] });
|
||||
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {} as OpenClawConfig,
|
||||
rawTarget: "t.me/mychannel:topic:9",
|
||||
resolvedChatId: "-100123",
|
||||
});
|
||||
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "-100123:topic:9",
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
193
src/telegram/target-writeback.ts
Normal file
193
src/telegram/target-writeback.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { readConfigFileSnapshotForWrite, writeConfigFile } from "../config/config.js";
|
||||
import { loadCronStore, resolveCronStorePath, saveCronStore } from "../cron/store.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import {
|
||||
normalizeTelegramChatId,
|
||||
normalizeTelegramLookupTarget,
|
||||
parseTelegramTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
const writebackLogger = createSubsystemLogger("telegram/target-writeback");
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeTelegramTargetForMatch(raw: string): string | undefined {
|
||||
const parsed = parseTelegramTarget(raw);
|
||||
const normalized = normalizeTelegramLookupTarget(parsed.chatId);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const threadKey = parsed.messageThreadId == null ? "" : String(parsed.messageThreadId);
|
||||
return `${normalized}|${threadKey}`;
|
||||
}
|
||||
|
||||
function buildResolvedTelegramTarget(params: {
|
||||
raw: string;
|
||||
parsed: ReturnType<typeof parseTelegramTarget>;
|
||||
resolvedChatId: string;
|
||||
}): string {
|
||||
const { raw, parsed, resolvedChatId } = params;
|
||||
if (parsed.messageThreadId == null) {
|
||||
return resolvedChatId;
|
||||
}
|
||||
return raw.includes(":topic:")
|
||||
? `${resolvedChatId}:topic:${parsed.messageThreadId}`
|
||||
: `${resolvedChatId}:${parsed.messageThreadId}`;
|
||||
}
|
||||
|
||||
function resolveLegacyRewrite(params: {
|
||||
raw: string;
|
||||
resolvedChatId: string;
|
||||
}): { matchKey: string; resolvedTarget: string } | null {
|
||||
const parsed = parseTelegramTarget(params.raw);
|
||||
if (normalizeTelegramChatId(parsed.chatId)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeTelegramLookupTarget(parsed.chatId);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const threadKey = parsed.messageThreadId == null ? "" : String(parsed.messageThreadId);
|
||||
return {
|
||||
matchKey: `${normalized}|${threadKey}`,
|
||||
resolvedTarget: buildResolvedTelegramTarget({
|
||||
raw: params.raw,
|
||||
parsed,
|
||||
resolvedChatId: params.resolvedChatId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function rewriteTargetIfMatch(params: {
|
||||
rawValue: unknown;
|
||||
matchKey: string;
|
||||
resolvedTarget: string;
|
||||
}): string | null {
|
||||
if (typeof params.rawValue !== "string" && typeof params.rawValue !== "number") {
|
||||
return null;
|
||||
}
|
||||
const value = String(params.rawValue).trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (normalizeTelegramTargetForMatch(value) !== params.matchKey) {
|
||||
return null;
|
||||
}
|
||||
return params.resolvedTarget;
|
||||
}
|
||||
|
||||
function replaceTelegramDefaultToTargets(params: {
|
||||
cfg: OpenClawConfig;
|
||||
matchKey: string;
|
||||
resolvedTarget: string;
|
||||
}): boolean {
|
||||
let changed = false;
|
||||
const telegram = asObjectRecord(params.cfg.channels?.telegram);
|
||||
if (!telegram) {
|
||||
return changed;
|
||||
}
|
||||
|
||||
const maybeReplace = (holder: Record<string, unknown>, key: string) => {
|
||||
const nextTarget = rewriteTargetIfMatch({
|
||||
rawValue: holder[key],
|
||||
matchKey: params.matchKey,
|
||||
resolvedTarget: params.resolvedTarget,
|
||||
});
|
||||
if (!nextTarget) {
|
||||
return;
|
||||
}
|
||||
holder[key] = nextTarget;
|
||||
changed = true;
|
||||
};
|
||||
|
||||
maybeReplace(telegram, "defaultTo");
|
||||
const accounts = asObjectRecord(telegram.accounts);
|
||||
if (!accounts) {
|
||||
return changed;
|
||||
}
|
||||
for (const accountId of Object.keys(accounts)) {
|
||||
const account = asObjectRecord(accounts[accountId]);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
maybeReplace(account, "defaultTo");
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function maybePersistResolvedTelegramTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
rawTarget: string;
|
||||
resolvedChatId: string;
|
||||
verbose?: boolean;
|
||||
}): Promise<void> {
|
||||
const raw = params.rawTarget.trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const rewrite = resolveLegacyRewrite({
|
||||
raw,
|
||||
resolvedChatId: params.resolvedChatId,
|
||||
});
|
||||
if (!rewrite) {
|
||||
return;
|
||||
}
|
||||
const { matchKey, resolvedTarget } = rewrite;
|
||||
|
||||
try {
|
||||
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
|
||||
const nextConfig = structuredClone(snapshot.config ?? {});
|
||||
const configChanged = replaceTelegramDefaultToTargets({
|
||||
cfg: nextConfig,
|
||||
matchKey,
|
||||
resolvedTarget,
|
||||
});
|
||||
if (configChanged) {
|
||||
await writeConfigFile(nextConfig, writeOptions);
|
||||
if (params.verbose) {
|
||||
writebackLogger.warn(`resolved Telegram defaultTo target ${raw} -> ${resolvedTarget}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (params.verbose) {
|
||||
writebackLogger.warn(`failed to persist Telegram defaultTo target ${raw}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const storePath = resolveCronStorePath(params.cfg.cron?.store);
|
||||
const store = await loadCronStore(storePath);
|
||||
let cronChanged = false;
|
||||
for (const job of store.jobs) {
|
||||
if (job.delivery?.channel !== "telegram") {
|
||||
continue;
|
||||
}
|
||||
const nextTarget = rewriteTargetIfMatch({
|
||||
rawValue: job.delivery.to,
|
||||
matchKey,
|
||||
resolvedTarget,
|
||||
});
|
||||
if (!nextTarget) {
|
||||
continue;
|
||||
}
|
||||
job.delivery.to = nextTarget;
|
||||
cronChanged = true;
|
||||
}
|
||||
if (cronChanged) {
|
||||
await saveCronStore(storePath, store);
|
||||
if (params.verbose) {
|
||||
writebackLogger.warn(`resolved Telegram cron delivery target ${raw} -> ${resolvedTarget}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (params.verbose) {
|
||||
writebackLogger.warn(`failed to persist Telegram cron target ${raw}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
|
||||
import {
|
||||
isNumericTelegramChatId,
|
||||
normalizeTelegramChatId,
|
||||
normalizeTelegramLookupTarget,
|
||||
parseTelegramTarget,
|
||||
stripTelegramInternalPrefixes,
|
||||
} from "./targets.js";
|
||||
|
||||
describe("stripTelegramInternalPrefixes", () => {
|
||||
it("strips telegram prefix", () => {
|
||||
@@ -73,3 +79,53 @@ describe("parseTelegramTarget", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTelegramChatId", () => {
|
||||
it("rejects username and t.me forms", () => {
|
||||
expect(normalizeTelegramChatId("telegram:https://t.me/MyChannel")).toBeUndefined();
|
||||
expect(normalizeTelegramChatId("tg:t.me/mychannel")).toBeUndefined();
|
||||
expect(normalizeTelegramChatId("@MyChannel")).toBeUndefined();
|
||||
expect(normalizeTelegramChatId("MyChannel")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps numeric chat ids unchanged", () => {
|
||||
expect(normalizeTelegramChatId("-1001234567890")).toBe("-1001234567890");
|
||||
expect(normalizeTelegramChatId("123456789")).toBe("123456789");
|
||||
});
|
||||
|
||||
it("returns undefined for empty input", () => {
|
||||
expect(normalizeTelegramChatId(" ")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTelegramLookupTarget", () => {
|
||||
it("normalizes legacy t.me and username targets", () => {
|
||||
expect(normalizeTelegramLookupTarget("telegram:https://t.me/MyChannel")).toBe("@MyChannel");
|
||||
expect(normalizeTelegramLookupTarget("tg:t.me/mychannel")).toBe("@mychannel");
|
||||
expect(normalizeTelegramLookupTarget("@MyChannel")).toBe("@MyChannel");
|
||||
expect(normalizeTelegramLookupTarget("MyChannel")).toBe("@MyChannel");
|
||||
});
|
||||
|
||||
it("keeps numeric chat ids unchanged", () => {
|
||||
expect(normalizeTelegramLookupTarget("-1001234567890")).toBe("-1001234567890");
|
||||
expect(normalizeTelegramLookupTarget("123456789")).toBe("123456789");
|
||||
});
|
||||
|
||||
it("rejects invalid username forms", () => {
|
||||
expect(normalizeTelegramLookupTarget("@bad-handle")).toBeUndefined();
|
||||
expect(normalizeTelegramLookupTarget("bad-handle")).toBeUndefined();
|
||||
expect(normalizeTelegramLookupTarget("ab")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNumericTelegramChatId", () => {
|
||||
it("matches numeric telegram chat ids", () => {
|
||||
expect(isNumericTelegramChatId("-1001234567890")).toBe(true);
|
||||
expect(isNumericTelegramChatId("123456789")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-numeric chat ids", () => {
|
||||
expect(isNumericTelegramChatId("@mychannel")).toBe(false);
|
||||
expect(isNumericTelegramChatId("t.me/mychannel")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,9 @@ export type TelegramTarget = {
|
||||
chatType: "direct" | "group" | "unknown";
|
||||
};
|
||||
|
||||
const TELEGRAM_NUMERIC_CHAT_ID_REGEX = /^-?\d+$/;
|
||||
const TELEGRAM_USERNAME_REGEX = /^[A-Za-z0-9_]{5,}$/i;
|
||||
|
||||
export function stripTelegramInternalPrefixes(to: string): string {
|
||||
let trimmed = to.trim();
|
||||
let strippedTelegramPrefix = false;
|
||||
@@ -26,6 +29,46 @@ export function stripTelegramInternalPrefixes(to: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTelegramChatId(raw: string): string | undefined {
|
||||
const stripped = stripTelegramInternalPrefixes(raw);
|
||||
if (!stripped) {
|
||||
return undefined;
|
||||
}
|
||||
if (TELEGRAM_NUMERIC_CHAT_ID_REGEX.test(stripped)) {
|
||||
return stripped;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isNumericTelegramChatId(raw: string): boolean {
|
||||
return TELEGRAM_NUMERIC_CHAT_ID_REGEX.test(raw.trim());
|
||||
}
|
||||
|
||||
export function normalizeTelegramLookupTarget(raw: string): string | undefined {
|
||||
const stripped = stripTelegramInternalPrefixes(raw);
|
||||
if (!stripped) {
|
||||
return undefined;
|
||||
}
|
||||
if (isNumericTelegramChatId(stripped)) {
|
||||
return stripped;
|
||||
}
|
||||
const tmeMatch = /^(?:https?:\/\/)?t\.me\/([A-Za-z0-9_]+)$/i.exec(stripped);
|
||||
if (tmeMatch?.[1]) {
|
||||
return `@${tmeMatch[1]}`;
|
||||
}
|
||||
if (stripped.startsWith("@")) {
|
||||
const handle = stripped.slice(1);
|
||||
if (!handle || !TELEGRAM_USERNAME_REGEX.test(handle)) {
|
||||
return undefined;
|
||||
}
|
||||
return `@${handle}`;
|
||||
}
|
||||
if (TELEGRAM_USERNAME_REGEX.test(stripped)) {
|
||||
return `@${stripped}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Telegram delivery target into chatId and optional topic/thread ID.
|
||||
*
|
||||
@@ -39,7 +82,7 @@ function resolveTelegramChatType(chatId: string): "direct" | "group" | "unknown"
|
||||
if (!trimmed) {
|
||||
return "unknown";
|
||||
}
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
if (isNumericTelegramChatId(trimmed)) {
|
||||
return trimmed.startsWith("-") ? "group" : "direct";
|
||||
}
|
||||
return "unknown";
|
||||
|
||||
Reference in New Issue
Block a user