From 52e730e090f8a95af6c521a60d36267e2a2556db Mon Sep 17 00:00:00 2001 From: Muhammed Mukhthar CM Date: Fri, 23 Jan 2026 04:17:52 +0000 Subject: [PATCH] Channels: add telegram-user plugin --- extensions/telegram-user/.gitignore | 1 + extensions/telegram-user/clawdbot.plugin.json | 11 + extensions/telegram-user/index.ts | 18 + extensions/telegram-user/package.json | 34 ++ extensions/telegram-user/src/accounts.ts | 95 ++++++ extensions/telegram-user/src/active-client.ts | 11 + extensions/telegram-user/src/channel.ts | 315 ++++++++++++++++++ extensions/telegram-user/src/client.ts | 13 + extensions/telegram-user/src/config-schema.ts | 20 ++ extensions/telegram-user/src/login.ts | 41 +++ .../telegram-user/src/monitor/handler.ts | 299 +++++++++++++++++ extensions/telegram-user/src/monitor/index.ts | 93 ++++++ extensions/telegram-user/src/runtime.ts | 14 + extensions/telegram-user/src/send.ts | 128 +++++++ extensions/telegram-user/src/session.ts | 20 ++ extensions/telegram-user/src/types.ts | 31 ++ src/channels/plugins/types.core.ts | 2 + src/cli/channels-cli.ts | 4 + src/commands/channels/add-mutators.ts | 4 + src/commands/channels/add.ts | 13 +- 20 files changed, 1166 insertions(+), 1 deletion(-) create mode 100644 extensions/telegram-user/.gitignore create mode 100644 extensions/telegram-user/clawdbot.plugin.json create mode 100644 extensions/telegram-user/index.ts create mode 100644 extensions/telegram-user/package.json create mode 100644 extensions/telegram-user/src/accounts.ts create mode 100644 extensions/telegram-user/src/active-client.ts create mode 100644 extensions/telegram-user/src/channel.ts create mode 100644 extensions/telegram-user/src/client.ts create mode 100644 extensions/telegram-user/src/config-schema.ts create mode 100644 extensions/telegram-user/src/login.ts create mode 100644 extensions/telegram-user/src/monitor/handler.ts create mode 100644 extensions/telegram-user/src/monitor/index.ts create mode 100644 extensions/telegram-user/src/runtime.ts create mode 100644 extensions/telegram-user/src/send.ts create mode 100644 extensions/telegram-user/src/session.ts create mode 100644 extensions/telegram-user/src/types.ts diff --git a/extensions/telegram-user/.gitignore b/extensions/telegram-user/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/extensions/telegram-user/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/extensions/telegram-user/clawdbot.plugin.json b/extensions/telegram-user/clawdbot.plugin.json new file mode 100644 index 000000000..359ee5d90 --- /dev/null +++ b/extensions/telegram-user/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "telegram-user", + "channels": [ + "telegram-user" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/telegram-user/index.ts b/extensions/telegram-user/index.ts new file mode 100644 index 000000000..db524a6ed --- /dev/null +++ b/extensions/telegram-user/index.ts @@ -0,0 +1,18 @@ +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { telegramUserPlugin } from "./src/channel.js"; +import { setTelegramUserRuntime } from "./src/runtime.js"; + +const plugin = { + id: "telegram-user", + name: "Telegram User", + description: "Telegram MTProto user channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: ClawdbotPluginApi) { + setTelegramUserRuntime(api.runtime); + api.registerChannel({ plugin: telegramUserPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/telegram-user/package.json b/extensions/telegram-user/package.json new file mode 100644 index 000000000..54f2c7eaa --- /dev/null +++ b/extensions/telegram-user/package.json @@ -0,0 +1,34 @@ +{ + "name": "@clawdbot/telegram-user", + "version": "2026.1.22", + "type": "module", + "description": "Clawdbot Telegram user (MTProto) channel plugin", + "clawdbot": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "telegram-user", + "label": "Telegram User", + "selectionLabel": "Telegram User (MTProto)", + "detailLabel": "Telegram User", + "docsPath": "/channels/telegram-user", + "docsLabel": "telegram-user", + "blurb": "login as a Telegram user via QR; DM-only for now.", + "order": 12, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@clawdbot/telegram-user", + "localPath": "extensions/telegram-user", + "defaultChoice": "npm" + } + }, + "dependencies": { + "@mtcute/core": "^0.27.6", + "@mtcute/dispatcher": "^0.27.6", + "@mtcute/node": "^0.27.6", + "qrcode-terminal": "^0.12.0", + "clawdbot": "workspace:*" + } +} diff --git a/extensions/telegram-user/src/accounts.ts b/extensions/telegram-user/src/accounts.ts new file mode 100644 index 000000000..6754a1052 --- /dev/null +++ b/extensions/telegram-user/src/accounts.ts @@ -0,0 +1,95 @@ +import type { CoreConfig, TelegramUserAccountConfig } from "./types.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; + +export type TelegramUserCredentials = { + apiId?: number; + apiHash?: string; + apiIdSource: "env" | "config" | "none"; + apiHashSource: "env" | "config" | "none"; +}; + +export type ResolvedTelegramUserAccount = { + accountId: string; + enabled: boolean; + name?: string; + credentials: TelegramUserCredentials; + config: TelegramUserAccountConfig; +}; + +function resolveAccountConfig( + cfg: CoreConfig, + accountId: string, +): TelegramUserAccountConfig | undefined { + const accounts = cfg.channels?.["telegram-user"]?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + const direct = accounts[accountId] as TelegramUserAccountConfig | undefined; + if (direct) return direct; + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); + return matchKey ? (accounts[matchKey] as TelegramUserAccountConfig | undefined) : undefined; +} + +function mergeTelegramUserAccountConfig( + cfg: CoreConfig, + accountId: string, +): TelegramUserAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.["telegram-user"] ?? + {}) as TelegramUserAccountConfig & { accounts?: unknown }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +function resolveCredentials(cfg: CoreConfig, accountId: string): TelegramUserCredentials { + const merged = mergeTelegramUserAccountConfig(cfg, accountId); + const envApiId = + accountId === DEFAULT_ACCOUNT_ID + ? Number.parseInt(process.env.TELEGRAM_USER_API_ID ?? "", 10) + : Number.NaN; + const envApiHash = + accountId === DEFAULT_ACCOUNT_ID ? process.env.TELEGRAM_USER_API_HASH?.trim() : undefined; + const apiId = + Number.isFinite(envApiId) && envApiId > 0 ? envApiId : merged.apiId ?? undefined; + const apiHash = envApiHash || merged.apiHash?.trim(); + return { + apiId, + apiHash, + apiIdSource: + Number.isFinite(envApiId) && envApiId > 0 + ? "env" + : merged.apiId + ? "config" + : "none", + apiHashSource: envApiHash ? "env" : merged.apiHash ? "config" : "none", + }; +} + +export function listTelegramUserAccountIds(cfg: CoreConfig): string[] { + const accounts = cfg.channels?.["telegram-user"]?.accounts; + const ids = accounts ? Object.keys(accounts).filter(Boolean) : []; + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + if (!ids.includes(DEFAULT_ACCOUNT_ID)) ids.push(DEFAULT_ACCOUNT_ID); + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultTelegramUserAccountId(cfg: CoreConfig): string { + const ids = listTelegramUserAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +export function resolveTelegramUserAccount(params: { + cfg: CoreConfig; + accountId?: string | null; +}): ResolvedTelegramUserAccount { + const normalized = normalizeAccountId(params.accountId); + const merged = mergeTelegramUserAccountConfig(params.cfg, normalized); + const baseEnabled = params.cfg.channels?.["telegram-user"]?.enabled !== false; + const enabled = baseEnabled && merged.enabled !== false; + return { + accountId: normalized, + enabled, + name: merged.name?.trim() || undefined, + credentials: resolveCredentials(params.cfg, normalized), + config: merged, + }; +} diff --git a/extensions/telegram-user/src/active-client.ts b/extensions/telegram-user/src/active-client.ts new file mode 100644 index 000000000..4eb60171f --- /dev/null +++ b/extensions/telegram-user/src/active-client.ts @@ -0,0 +1,11 @@ +import type { TelegramClient } from "@mtcute/node"; + +let activeClient: TelegramClient | null = null; + +export function setActiveTelegramUserClient(next: TelegramClient | null) { + activeClient = next; +} + +export function getActiveTelegramUserClient(): TelegramClient | null { + return activeClient; +} diff --git a/extensions/telegram-user/src/channel.ts b/extensions/telegram-user/src/channel.ts new file mode 100644 index 000000000..b2ae2af2b --- /dev/null +++ b/extensions/telegram-user/src/channel.ts @@ -0,0 +1,315 @@ +import fs from "node:fs"; + +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + normalizeAccountId, + setAccountEnabledInConfigSection, + type ChannelPlugin, + type ChannelSetupInput, + type ClawdbotConfig, +} from "clawdbot/plugin-sdk"; + +import { + listTelegramUserAccountIds, + resolveDefaultTelegramUserAccountId, + resolveTelegramUserAccount, + type ResolvedTelegramUserAccount, +} from "./accounts.js"; +import { TelegramUserConfigSchema } from "./config-schema.js"; +import { loginTelegramUser } from "./login.js"; +import { monitorTelegramUserProvider } from "./monitor/index.js"; +import { + looksLikeTelegramUserTargetId, + normalizeTelegramUserMessagingTarget, + sendMediaTelegramUser, + sendMessageTelegramUser, +} from "./send.js"; +import { resolveTelegramUserSessionPath } from "./session.js"; +import { getTelegramUserRuntime } from "./runtime.js"; +import type { CoreConfig } from "./types.js"; + +const meta = { + id: "telegram-user", + label: "Telegram User", + selectionLabel: "Telegram User (MTProto)", + detailLabel: "Telegram User", + docsPath: "/channels/telegram-user", + docsLabel: "telegram-user", + blurb: "login as a Telegram user via QR; DM-only for now.", + order: 12, + quickstartAllowFrom: true, +}; + +type TelegramUserSetupInput = ChannelSetupInput & { + apiId?: number; + apiHash?: string; +}; + +const isSessionLinked = async (accountId: string): Promise => { + const sessionPath = resolveTelegramUserSessionPath(accountId); + return fs.existsSync(sessionPath); +}; + +export const telegramUserPlugin: ChannelPlugin = { + id: "telegram-user", + meta, + pairing: { + idLabel: "telegramUserId", + normalizeAllowEntry: (entry) => + entry.replace(/^(telegram-user|telegram|tg):/i, "").toLowerCase(), + notifyApproval: async ({ id }) => { + await sendMessageTelegramUser(String(id), "Clawdbot: access approved.", {}); + }, + }, + capabilities: { + chatTypes: ["direct"], + reactions: false, + threads: false, + media: true, + nativeCommands: false, + blockStreaming: true, + }, + messaging: { + normalizeTarget: normalizeTelegramUserMessagingTarget, + targetResolver: { + looksLikeId: looksLikeTelegramUserTargetId, + hint: "", + }, + }, + reload: { configPrefixes: ["channels.telegram-user"] }, + configSchema: buildChannelConfigSchema(TelegramUserConfigSchema), + config: { + listAccountIds: (cfg) => listTelegramUserAccountIds(cfg as CoreConfig), + resolveAccount: (cfg, accountId) => + resolveTelegramUserAccount({ cfg: cfg as CoreConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultTelegramUserAccountId(cfg as CoreConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "telegram-user", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "telegram-user", + accountId, + clearBaseFields: ["apiId", "apiHash", "name"], + }), + isConfigured: (account) => + Boolean(account.credentials.apiId && account.credentials.apiHash), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.credentials.apiId && account.credentials.apiHash), + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveTelegramUserAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^(telegram-user|telegram|tg):/i, "")) + .map((entry) => entry.toLowerCase()), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.channels?.["telegram-user"]?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath + ? `channels.telegram-user.accounts.${resolvedAccountId}.` + : "channels.telegram-user."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("telegram-user"), + normalizeEntry: (raw) => + raw.replace(/^(telegram-user|telegram|tg):/i, "").toLowerCase(), + }; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => + getTelegramUserRuntime().channel.text.chunkMarkdownText(text, limit), + textChunkLimit: 4000, + sendText: async ({ to, text, accountId }) => { + const result = await sendMessageTelegramUser(to, text, { accountId: accountId ?? undefined }); + return { channel: "telegram-user", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId }) => { + const result = await sendMediaTelegramUser(to, text, { + accountId: accountId ?? undefined, + mediaUrl, + }); + return { channel: "telegram-user", ...result }; + }, + }, + auth: { + login: async ({ cfg, accountId, runtime }) => { + const account = resolveTelegramUserAccount({ + cfg: cfg as CoreConfig, + accountId, + }); + const apiId = account.credentials.apiId; + const apiHash = account.credentials.apiHash; + if (!apiId || !apiHash) { + throw new Error("Telegram user apiId/apiHash required. Set in config or env."); + } + const storagePath = resolveTelegramUserSessionPath(account.accountId); + await loginTelegramUser({ + apiId, + apiHash, + storagePath, + runtime, + }); + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + lastInboundAt: null, + lastOutboundAt: null, + }, + buildAccountSnapshot: async ({ account, runtime }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.credentials.apiId && account.credentials.apiHash), + linked: await isSessionLinked(account.accountId), + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + dmPolicy: account.config.dmPolicy ?? "pairing", + allowFrom: (account.config.allowFrom ?? []).map((entry) => String(entry)), + }), + resolveAccountState: ({ configured }) => (configured ? "configured" : "not configured"), + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as ClawdbotConfig, + channelKey: "telegram-user", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + const setupInput = input as TelegramUserSetupInput; + if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "TELEGRAM_USER_API_ID/TELEGRAM_USER_API_HASH can only be used for the default account."; + } + if (!setupInput.useEnv && (!setupInput.apiId || !setupInput.apiHash)) { + return "Telegram user requires apiId/apiHash (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const setupInput = input as TelegramUserSetupInput; + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as ClawdbotConfig, + channelKey: "telegram-user", + accountId, + name: setupInput.name, + }); + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + "telegram-user": { + ...namedConfig.channels?.["telegram-user"], + enabled: true, + ...(setupInput.useEnv + ? {} + : { + apiId: setupInput.apiId, + apiHash: setupInput.apiHash, + }), + }, + }, + }; + } + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + "telegram-user": { + ...namedConfig.channels?.["telegram-user"], + enabled: true, + accounts: { + ...namedConfig.channels?.["telegram-user"]?.accounts, + [accountId]: { + ...namedConfig.channels?.["telegram-user"]?.accounts?.[accountId], + enabled: true, + ...(setupInput.useEnv + ? {} + : { + apiId: setupInput.apiId, + apiHash: setupInput.apiHash, + }), + }, + }, + }, + }, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + ctx.setStatus({ + accountId: ctx.accountId, + running: true, + lastStartAt: Date.now(), + lastError: null, + }); + try { + await monitorTelegramUserProvider({ + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + accountId: ctx.accountId, + }); + ctx.setStatus({ + accountId: ctx.accountId, + running: false, + lastStopAt: Date.now(), + }); + } catch (err) { + ctx.setStatus({ + accountId: ctx.accountId, + running: false, + lastStopAt: Date.now(), + lastError: String(err), + }); + throw err; + } + }, + stopAccount: async () => { + const { getActiveTelegramUserClient, setActiveTelegramUserClient } = + await import("./active-client.js"); + const active = getActiveTelegramUserClient(); + if (active) { + await active.destroy().catch(() => undefined); + setActiveTelegramUserClient(null); + } + }, + }, +}; diff --git a/extensions/telegram-user/src/client.ts b/extensions/telegram-user/src/client.ts new file mode 100644 index 000000000..5a3b09dea --- /dev/null +++ b/extensions/telegram-user/src/client.ts @@ -0,0 +1,13 @@ +import { TelegramClient } from "@mtcute/node"; + +export function createTelegramUserClient(params: { + apiId: number; + apiHash: string; + storagePath: string; +}) { + return new TelegramClient({ + apiId: params.apiId, + apiHash: params.apiHash, + storage: params.storagePath, + }); +} diff --git a/extensions/telegram-user/src/config-schema.ts b/extensions/telegram-user/src/config-schema.ts new file mode 100644 index 000000000..d40d029ca --- /dev/null +++ b/extensions/telegram-user/src/config-schema.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +const TelegramUserAccountSchema = z + .object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + apiId: z.number().int().positive().optional(), + apiHash: z.string().optional(), + dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + allowFrom: z.array(allowFromEntry).optional(), + textChunkLimit: z.number().int().positive().optional(), + mediaMaxMb: z.number().positive().optional(), + }) + .strict(); + +export const TelegramUserConfigSchema = TelegramUserAccountSchema.extend({ + accounts: z.record(z.string(), TelegramUserAccountSchema.optional()).optional(), +}); diff --git a/extensions/telegram-user/src/login.ts b/extensions/telegram-user/src/login.ts new file mode 100644 index 000000000..b26fd911f --- /dev/null +++ b/extensions/telegram-user/src/login.ts @@ -0,0 +1,41 @@ +import qrcode from "qrcode-terminal"; +import type { RuntimeEnv } from "clawdbot/plugin-sdk"; + +import { createTelegramUserClient } from "./client.js"; +import { ensureTelegramUserSessionDir } from "./session.js"; + +export async function loginTelegramUser(params: { + apiId: number; + apiHash: string; + storagePath: string; + runtime: RuntimeEnv; +}) { + const { apiId, apiHash, storagePath, runtime } = params; + ensureTelegramUserSessionDir({ sessionPath: storagePath }); + const client = createTelegramUserClient({ apiId, apiHash, storagePath }); + let lastUrl = ""; + + const password = process.env.TELEGRAM_USER_PASSWORD?.trim() || undefined; + + try { + const user = await client.start({ + qrCodeHandler: (url, expires) => { + if (url === lastUrl) return; + lastUrl = url; + runtime.log(`Scan this QR in Telegram (expires ${expires.toLocaleTimeString()}):`); + qrcode.generate(url, { small: true }); + }, + ...(password ? { password } : {}), + invalidCodeCallback: async (type) => { + if (type === "password") { + runtime.error?.( + "Telegram 2FA password rejected. Set TELEGRAM_USER_PASSWORD and rerun.", + ); + } + }, + }); + runtime.log(`Telegram user logged in as ${user.displayName}.`); + } finally { + await client.destroy(); + } +} diff --git a/extensions/telegram-user/src/monitor/handler.ts b/extensions/telegram-user/src/monitor/handler.ts new file mode 100644 index 000000000..e8d2a0046 --- /dev/null +++ b/extensions/telegram-user/src/monitor/handler.ts @@ -0,0 +1,299 @@ +import type { TelegramClient } from "@mtcute/node"; +import type { MessageContext } from "@mtcute/dispatcher"; +import type { RuntimeEnv } from "clawdbot/plugin-sdk"; + +import { getTelegramUserRuntime } from "../runtime.js"; +import type { CoreConfig, TelegramUserAccountConfig } from "../types.js"; +import { sendMediaTelegramUser, sendMessageTelegramUser } from "../send.js"; + +const DEFAULT_TEXT_LIMIT = 4000; +const DEFAULT_MEDIA_MAX_MB = 5; + +type TelegramUserHandlerParams = { + client: TelegramClient; + cfg: CoreConfig; + runtime: RuntimeEnv; + accountId: string; + accountConfig: TelegramUserAccountConfig; +}; + +function normalizeAllowEntry(raw: string): string { + const trimmed = raw.trim().toLowerCase(); + return trimmed + .replace(/^(telegram-user|telegram|tg):/i, "") + .replace(/^user:/i, "") + .trim(); +} + +function parseAllowlist(entries: Array | undefined) { + const normalized = (entries ?? []) + .map((entry) => normalizeAllowEntry(String(entry))) + .filter(Boolean); + const hasWildcard = normalized.includes("*"); + const usernames = new Set(); + const ids = new Set(); + for (const entry of normalized) { + if (entry === "*") continue; + if (/^-?\d+$/.test(entry)) { + ids.add(entry); + continue; + } + const username = entry.startsWith("@") ? entry.slice(1) : entry; + if (username) usernames.add(username); + } + return { hasWildcard, usernames, ids }; +} + +function isSenderAllowed(params: { + allowFrom: Array | undefined; + senderId: string; + senderUsername?: string | null; +}): boolean { + const parsed = parseAllowlist(params.allowFrom); + if (parsed.hasWildcard) return true; + if (parsed.ids.has(params.senderId)) return true; + const username = params.senderUsername?.trim().toLowerCase(); + if (!username) return false; + return parsed.usernames.has(username.replace(/^@/, "")); +} + +async function resolveMediaAttachment(params: { + client: TelegramClient; + mediaMaxMb: number; + media: MessageContext["media"]; +}) { + if (!params.media) return null; + const core = getTelegramUserRuntime(); + const maxBytes = Math.max(1, params.mediaMaxMb) * 1024 * 1024; + if ("fileSize" in params.media && typeof params.media.fileSize === "number") { + if (params.media.fileSize > maxBytes) { + throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`); + } + } + const buffer = Buffer.from(await params.client.downloadAsBuffer(params.media)); + const fileName = + params.media && "fileName" in params.media && typeof params.media.fileName === "string" + ? params.media.fileName + : undefined; + const contentType = + params.media && "mimeType" in params.media && typeof params.media.mimeType === "string" + ? params.media.mimeType + : await core.media.detectMime({ buffer, filePath: fileName }); + const saved = await core.channel.media.saveMediaBuffer( + buffer, + contentType, + "telegram-user", + maxBytes, + fileName, + ); + return { + path: saved.path, + contentType: saved.contentType ?? contentType, + }; +} + +export function createTelegramUserMessageHandler(params: TelegramUserHandlerParams) { + const { client, cfg, runtime, accountId, accountConfig } = params; + const core = getTelegramUserRuntime(); + const textLimit = accountConfig.textChunkLimit ?? DEFAULT_TEXT_LIMIT; + const mediaMaxMb = accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; + const dmPolicy = accountConfig.dmPolicy ?? "pairing"; + const allowFrom = accountConfig.allowFrom ?? []; + + return async (msg: MessageContext) => { + try { + if (msg.isOutgoing || msg.isService) return; + if (msg.chat.type !== "user") return; + + const sender = await msg.getCompleteSender().catch(() => msg.sender); + if (sender.type !== "user") return; + if ("isSelf" in sender && sender.isSelf) return; + + const senderId = String(sender.id); + const senderUsername = "username" in sender ? sender.username : null; + const senderName = "displayName" in sender ? sender.displayName : senderId; + const storeAllowFrom = await core.channel.pairing + .readAllowFromStore("telegram-user") + .catch(() => []); + const combinedAllowFrom = [...allowFrom, ...storeAllowFrom]; + + if (dmPolicy === "disabled") return; + if ( + dmPolicy !== "open" && + !isSenderAllowed({ allowFrom: combinedAllowFrom, senderId, senderUsername }) + ) { + if (dmPolicy === "pairing") { + const pairing = await core.channel.pairing.upsertPairingRequest({ + channel: "telegram-user", + id: senderId, + meta: { + username: senderUsername ?? undefined, + name: senderName, + }, + }); + const reply = core.channel.pairing.buildPairingReply({ + channel: "telegram-user", + idLine: `Telegram user id: ${senderId}`, + code: pairing.code, + }); + await sendMessageTelegramUser(`telegram-user:${senderId}`, reply, { + client, + accountId, + }); + } + return; + } + + const text = msg.text?.trim() ?? ""; + const media = await resolveMediaAttachment({ + client, + mediaMaxMb, + media: msg.media, + }).catch((err) => { + runtime.error?.(`telegram-user media download failed: ${String(err)}`); + return null; + }); + if (!text && !media) return; + + core.channel.activity.record({ + channel: "telegram-user", + accountId, + direction: "inbound", + }); + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "telegram-user", + accountId, + peer: { + kind: "dm", + id: senderId, + }, + }); + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Telegram User", + from: senderName, + timestamp: msg.date, + previousTimestamp, + envelope: envelopeOptions, + body: text || "(media)", + }); + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: text, + CommandBody: text, + From: `telegram-user:${senderId}`, + To: `telegram-user:${senderId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: "direct", + ConversationLabel: senderName, + SenderName: senderName, + SenderId: senderId, + SenderUsername: senderUsername ?? undefined, + Provider: "telegram-user" as const, + Surface: "telegram-user" as const, + MessageSid: String(msg.id), + ReplyToId: String(msg.id), + Timestamp: msg.date, + MediaPath: media?.path, + MediaType: media?.contentType, + MediaUrl: media?.path, + CommandAuthorized: true, + CommandSource: "text" as const, + OriginatingChannel: "telegram-user" as const, + OriginatingTo: `telegram-user:${senderId}`, + }); + + void core.channel.session + .recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + }) + .catch((err) => { + runtime.error?.(`telegram-user failed to update session meta: ${String(err)}`); + }); + + await core.channel.session.updateLastRoute({ + storePath, + sessionKey: route.mainSessionKey, + channel: "telegram-user", + to: `telegram-user:${senderId}`, + accountId: route.accountId, + ctx: ctxPayload, + }); + + let hasReplied = false; + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload) => { + const replyToId = hasReplied ? undefined : msg.id; + const replyText = payload.text ?? ""; + const mediaUrl = payload.mediaUrl; + if (mediaUrl) { + await sendMediaTelegramUser(`telegram-user:${senderId}`, replyText, { + client, + accountId, + replyToId, + mediaUrl, + maxBytes: mediaMaxMb * 1024 * 1024, + }); + hasReplied = true; + core.channel.activity.record({ + channel: "telegram-user", + accountId, + direction: "outbound", + }); + return; + } + if (replyText) { + for (const chunk of core.channel.text.chunkMarkdownText(replyText, textLimit)) { + const trimmed = chunk.trim(); + if (!trimmed) continue; + await sendMessageTelegramUser(`telegram-user:${senderId}`, trimmed, { + client, + accountId, + replyToId, + }); + hasReplied = true; + core.channel.activity.record({ + channel: "telegram-user", + accountId, + direction: "outbound", + }); + } + } + }, + onReplyStart: async () => { + await client.sendTyping(senderId).catch(() => undefined); + }, + onError: (err) => { + runtime.error?.(`telegram-user reply failed: ${String(err)}`); + }, + }); + + await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }); + markDispatchIdle(); + } catch (err) { + runtime.error?.(`telegram-user handler failed: ${String(err)}`); + } + }; +} diff --git a/extensions/telegram-user/src/monitor/index.ts b/extensions/telegram-user/src/monitor/index.ts new file mode 100644 index 000000000..55d3d4203 --- /dev/null +++ b/extensions/telegram-user/src/monitor/index.ts @@ -0,0 +1,93 @@ +import fs from "node:fs"; +import { Dispatcher, filters } from "@mtcute/dispatcher"; +import type { RuntimeEnv } from "clawdbot/plugin-sdk"; + +import { createTelegramUserClient } from "../client.js"; +import { resolveTelegramUserAccount } from "../accounts.js"; +import { resolveTelegramUserSessionPath } from "../session.js"; +import { getTelegramUserRuntime } from "../runtime.js"; +import { setActiveTelegramUserClient } from "../active-client.js"; +import { createTelegramUserMessageHandler } from "./handler.js"; +import type { CoreConfig } from "../types.js"; + +export type MonitorTelegramUserOpts = { + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + accountId?: string | null; +}; + +export async function monitorTelegramUserProvider(opts: MonitorTelegramUserOpts = {}) { + const core = getTelegramUserRuntime(); + const cfg = core.config.loadConfig() as CoreConfig; + const account = resolveTelegramUserAccount({ + cfg, + accountId: opts.accountId, + }); + if (!account.enabled) return; + + const apiId = account.credentials.apiId; + const apiHash = account.credentials.apiHash; + if (!apiId || !apiHash) { + throw new Error("Telegram user credentials missing (apiId/apiHash required)."); + } + + const runtime: RuntimeEnv = + opts.runtime ?? + ({ + log: (message: string) => core.logging.getChildLogger({ module: "telegram-user" }).info(message), + error: (message: string) => + core.logging.getChildLogger({ module: "telegram-user" }).error(message), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + } satisfies RuntimeEnv); + + const storagePath = resolveTelegramUserSessionPath(account.accountId); + if (!fs.existsSync(storagePath)) { + throw new Error( + "Telegram user session missing. Run `clawdbot channels login --channel telegram-user` first.", + ); + } + const client = createTelegramUserClient({ apiId, apiHash, storagePath }); + setActiveTelegramUserClient(client); + + const stop = async () => { + setActiveTelegramUserClient(null); + await client.destroy().catch(() => undefined); + }; + + opts.abortSignal?.addEventListener( + "abort", + () => { + void stop(); + }, + { once: true }, + ); + + await client.start(); + + const dispatcher = Dispatcher.for(client); + const handleMessage = createTelegramUserMessageHandler({ + client, + cfg, + runtime, + accountId: account.accountId, + accountConfig: account.config, + }); + + dispatcher.onNewMessage(filters.chat("user"), handleMessage); + + await new Promise((resolve, reject) => { + client.onError.add((err) => { + runtime.error?.(`telegram-user client error: ${String(err)}`); + reject(err); + }); + if (opts.abortSignal?.aborted) { + resolve(); + return; + } + opts.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); + }); + + await stop(); +} diff --git a/extensions/telegram-user/src/runtime.ts b/extensions/telegram-user/src/runtime.ts new file mode 100644 index 000000000..464387f19 --- /dev/null +++ b/extensions/telegram-user/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setTelegramUserRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getTelegramUserRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Telegram user runtime not initialized"); + } + return runtime; +} diff --git a/extensions/telegram-user/src/send.ts b/extensions/telegram-user/src/send.ts new file mode 100644 index 000000000..e52412d7f --- /dev/null +++ b/extensions/telegram-user/src/send.ts @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import type { TelegramClient } from "@mtcute/node"; +import { InputMedia } from "@mtcute/core"; + +import { getTelegramUserRuntime } from "./runtime.js"; +import { resolveTelegramUserAccount } from "./accounts.js"; +import { createTelegramUserClient } from "./client.js"; +import { resolveTelegramUserSessionPath } from "./session.js"; +import type { CoreConfig } from "./types.js"; + +export type TelegramUserSendResult = { + messageId: string; + chatId: string; +}; + +export type TelegramUserSendOpts = { + client?: TelegramClient; + accountId?: string; + replyToId?: number; + mediaUrl?: string; +}; + +const normalizeTarget = (raw: string): string => { + const trimmed = raw.trim(); + if (!trimmed) throw new Error("Recipient is required for Telegram User sends"); + return trimmed + .replace(/^(telegram-user|telegram|tg):/i, "") + .replace(/^user:/i, "") + .trim(); +}; + +export function normalizeTelegramUserMessagingTarget(raw: string): string { + return normalizeTarget(raw); +} + +export function looksLikeTelegramUserTargetId(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return false; + return /^-?\d+$/.test(trimmed) || /^@?[a-z0-9_]{5,}$/i.test(trimmed); +} + +function resolveTelegramUserPeer(target: string): number | string { + if (/^-?\d+$/.test(target)) { + const parsed = Number.parseInt(target, 10); + if (Number.isFinite(parsed)) return parsed; + } + return target; +} + +async function resolveClient(params: { + client?: TelegramClient; + cfg: CoreConfig; + accountId?: string; +}): Promise<{ client: TelegramClient; stopOnDone: boolean }> { + if (params.client) return { client: params.client, stopOnDone: false }; + const account = resolveTelegramUserAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + const apiId = account.credentials.apiId; + const apiHash = account.credentials.apiHash; + if (!apiId || !apiHash) { + throw new Error("Telegram user credentials missing (apiId/apiHash required)."); + } + const storagePath = resolveTelegramUserSessionPath(account.accountId); + if (!fs.existsSync(storagePath)) { + throw new Error( + "Telegram user session missing. Run `clawdbot channels login --channel telegram-user` first.", + ); + } + const client = createTelegramUserClient({ apiId, apiHash, storagePath }); + await client.start(); + return { client, stopOnDone: true }; +} + +export async function sendMessageTelegramUser( + to: string, + text: string, + opts: TelegramUserSendOpts = {}, +): Promise { + const cfg = getTelegramUserRuntime().config.loadConfig() as CoreConfig; + const { client, stopOnDone } = await resolveClient({ + client: opts.client, + cfg, + accountId: opts.accountId, + }); + try { + const target = resolveTelegramUserPeer(normalizeTarget(to)); + const message = await client.sendText(target, text, { + ...(opts.replyToId ? { replyTo: opts.replyToId } : {}), + }); + return { messageId: String(message.id), chatId: String(target) }; + } finally { + if (stopOnDone) { + await client.destroy(); + } + } +} + +export async function sendMediaTelegramUser( + to: string, + text: string, + opts: TelegramUserSendOpts & { mediaUrl: string; maxBytes?: number }, +): Promise { + const cfg = getTelegramUserRuntime().config.loadConfig() as CoreConfig; + const { client, stopOnDone } = await resolveClient({ + client: opts.client, + cfg, + accountId: opts.accountId, + }); + try { + const target = resolveTelegramUserPeer(normalizeTarget(to)); + const media = await getTelegramUserRuntime().media.loadWebMedia(opts.mediaUrl, opts.maxBytes); + const input = InputMedia.auto(media.buffer, { + fileName: media.fileName ?? undefined, + fileMime: media.contentType, + caption: text, + }); + const message = await client.sendMedia(target, input, { + ...(opts.replyToId ? { replyTo: opts.replyToId } : {}), + }); + return { messageId: String(message.id), chatId: String(target) }; + } finally { + if (stopOnDone) { + await client.destroy(); + } + } +} diff --git a/extensions/telegram-user/src/session.ts b/extensions/telegram-user/src/session.ts new file mode 100644 index 000000000..23874658c --- /dev/null +++ b/extensions/telegram-user/src/session.ts @@ -0,0 +1,20 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { normalizeAccountId } from "clawdbot/plugin-sdk"; +import { getTelegramUserRuntime } from "./runtime.js"; + +export function resolveTelegramUserSessionPath(accountId?: string | null): string { + const normalized = normalizeAccountId(accountId); + const stateDir = getTelegramUserRuntime().state.resolveStateDir(); + return path.join(stateDir, "telegram-user", `session-${normalized}.sqlite`); +} + +export function ensureTelegramUserSessionDir(params?: { + accountId?: string | null; + sessionPath?: string; +}): void { + const sessionPath = + params?.sessionPath ?? resolveTelegramUserSessionPath(params?.accountId); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); +} diff --git a/extensions/telegram-user/src/types.ts b/extensions/telegram-user/src/types.ts new file mode 100644 index 000000000..2c7439af7 --- /dev/null +++ b/extensions/telegram-user/src/types.ts @@ -0,0 +1,31 @@ +export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; + +export type TelegramUserAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this Telegram user account. Default: true. */ + enabled?: boolean; + /** Telegram API ID from my.telegram.org. */ + apiId?: number; + /** Telegram API hash from my.telegram.org. */ + apiHash?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + /** Allowlist for DM senders (user ids or usernames, or "*"). */ + allowFrom?: Array; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Max outbound media size in MB. */ + mediaMaxMb?: number; +}; + +export type TelegramUserConfig = TelegramUserAccountConfig & { + accounts?: Record; +}; + +export type CoreConfig = { + channels?: { + "telegram-user"?: TelegramUserConfig; + }; + [key: string]: unknown; +}; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index d640b389f..44391a9f2 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -36,6 +36,8 @@ export type ChannelSetupInput = { audienceType?: string; audience?: string; useEnv?: boolean; + apiId?: number; + apiHash?: string; homeserver?: string; userId?: string; accessToken?: string; diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index dd60016d4..54d064883 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -39,6 +39,8 @@ const optionNamesAdd = [ "audienceType", "audience", "useEnv", + "apiId", + "apiHash", "homeserver", "userId", "accessToken", @@ -175,6 +177,8 @@ export function registerChannelsCli(program: Command) { .option("--webhook-url ", "Google Chat webhook URL") .option("--audience-type ", "Google Chat audience type (app-url|project-number)") .option("--audience ", "Google Chat audience value (app URL or project number)") + .option("--api-id ", "Telegram user API id (my.telegram.org)") + .option("--api-hash ", "Telegram user API hash (my.telegram.org)") .option("--homeserver ", "Matrix homeserver URL") .option("--user-id ", "Matrix user ID") .option("--access-token ", "Matrix access token") diff --git a/src/commands/channels/add-mutators.ts b/src/commands/channels/add-mutators.ts index f6d9d3a56..fe25c5cdb 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -40,6 +40,8 @@ export function applyChannelAccountConfig(params: { audienceType?: string; audience?: string; useEnv?: boolean; + apiId?: number; + apiHash?: string; homeserver?: string; userId?: string; accessToken?: string; @@ -77,6 +79,8 @@ export function applyChannelAccountConfig(params: { audienceType: params.audienceType, audience: params.audience, useEnv: params.useEnv, + apiId: params.apiId, + apiHash: params.apiHash, homeserver: params.homeserver, userId: params.userId, accessToken: params.accessToken, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 274df1775..dbbd93592 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -37,6 +37,8 @@ export type ChannelsAddOptions = { audienceType?: string; audience?: string; useEnv?: boolean; + apiId?: string | number; + apiHash?: string; homeserver?: string; userId?: string; accessToken?: string; @@ -181,7 +183,12 @@ export async function channelsAddCommand( : undefined; const groupChannels = parseList(opts.groupChannels); const dmAllowlist = parseList(opts.dmAllowlist); - + const apiId = + typeof opts.apiId === "number" + ? opts.apiId + : typeof opts.apiId === "string" && opts.apiId.trim() + ? Number.parseInt(opts.apiId, 10) + : undefined; const validationError = plugin.setup.validateInput?.({ cfg: nextConfig, accountId, @@ -204,6 +211,8 @@ export async function channelsAddCommand( webhookUrl: opts.webhookUrl, audienceType: opts.audienceType, audience: opts.audience, + apiId, + apiHash: opts.apiHash, homeserver: opts.homeserver, userId: opts.userId, accessToken: opts.accessToken, @@ -247,6 +256,8 @@ export async function channelsAddCommand( webhookUrl: opts.webhookUrl, audienceType: opts.audienceType, audience: opts.audience, + apiId, + apiHash: opts.apiHash, homeserver: opts.homeserver, userId: opts.userId, accessToken: opts.accessToken,