diff --git a/.github/labeler.yml b/.github/labeler.yml index 5c19fa418..73bc4fb75 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -68,6 +68,11 @@ - "src/telegram/**" - "extensions/telegram/**" - "docs/channels/telegram.md" +"channel: telegram-user": + - changed-files: + - any-glob-to-any-file: + - "extensions/telegram-user/**" + - "docs/channels/telegram-user.md" "channel: tlon": - changed-files: - any-glob-to-any-file: diff --git a/docs/channels/index.md b/docs/channels/index.md index ea1b1bc8a..203b43259 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -13,6 +13,7 @@ Text is supported everywhere; media and reactions vary by channel. - [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing. - [Telegram](/channels/telegram) — Bot API via grammY; supports groups. +- [Telegram User](/channels/telegram-user) — MTProto user account with DM + group support (plugin, installed separately). - [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs. - [Slack](/channels/slack) — Bolt SDK; workspace apps. - [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook. diff --git a/docs/channels/telegram-user.md b/docs/channels/telegram-user.md new file mode 100644 index 000000000..02678951c --- /dev/null +++ b/docs/channels/telegram-user.md @@ -0,0 +1,68 @@ +--- +summary: "Connect a Telegram user account via MTProto (DMs + groups)" +--- +# Telegram User + +Telegram User connects OpenClaw to a **personal Telegram account** using MTProto. +Use this when you need user-level DMs or want to message from your own account in groups. + +## Requirements + +- Telegram API ID + API hash from [my.telegram.org](https://my.telegram.org). +- The `telegram-user` plugin installed. + +## Install the plugin + +If the plugin is not bundled, install it: + +```bash +openclaw plugins install @openclaw/telegram-user +``` + +## Configure + +You can store credentials in config or use env vars. + +Option A: env vars (default account only) +```bash +export TELEGRAM_USER_API_ID="123456" +export TELEGRAM_USER_API_HASH="your_api_hash" +openclaw channels add --channel telegram-user --use-env +``` + +Option B: config +```bash +openclaw channels add --channel telegram-user --api-id 123456 --api-hash your_api_hash +``` + +## Login (QR or phone code) + +QR login (default): +```bash +openclaw channels login --channel telegram-user +``` + +Phone login: +```bash +export TELEGRAM_USER_PHONE="+15551234567" +openclaw channels login --channel telegram-user +``` + +Optional env helpers: +- `TELEGRAM_USER_CODE` (one-time code) +- `TELEGRAM_USER_PASSWORD` (2FA password) + +## Security (DM policy) + +By default, DMs are protected with pairing. Approve requests with: + +```bash +openclaw pairing approve telegram-user +``` + +See [Pairing](/start/pairing) for details. + +## Limitations + +- Broadcast channels are not supported. +- Calls are not supported. 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/index.ts b/extensions/telegram-user/index.ts new file mode 100644 index 000000000..8d8ac04d7 --- /dev/null +++ b/extensions/telegram-user/index.ts @@ -0,0 +1,18 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/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: OpenClawPluginApi) { + setTelegramUserRuntime(api.runtime); + api.registerChannel({ plugin: telegramUserPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/telegram-user/openclaw.plugin.json b/extensions/telegram-user/openclaw.plugin.json new file mode 100644 index 000000000..359ee5d90 --- /dev/null +++ b/extensions/telegram-user/openclaw.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "telegram-user", + "channels": [ + "telegram-user" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/telegram-user/package.json b/extensions/telegram-user/package.json new file mode 100644 index 000000000..903286ee4 --- /dev/null +++ b/extensions/telegram-user/package.json @@ -0,0 +1,41 @@ +{ + "name": "@openclaw/telegram-user", + "version": "2026.1.29", + "type": "module", + "description": "OpenClaw Telegram user (MTProto) channel plugin", + "openclaw": { + "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 or phone code; supports DMs + groups.", + "order": 12, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/telegram-user", + "localPath": "extensions/telegram-user", + "defaultChoice": "npm" + } + }, + "dependencies": { + "@mtcute/core": "^0.27.6", + "@mtcute/dispatcher": "^0.27.6", + "@mtcute/node": "^0.27.6", + "@clack/prompts": "^0.8.2", + "qrcode-terminal": "^0.12.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "peerDependencies": { + "openclaw": ">=2026.1.29" + } +} diff --git a/extensions/telegram-user/src/accounts.ts b/extensions/telegram-user/src/accounts.ts new file mode 100644 index 000000000..2f6a29ac3 --- /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 "openclaw/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..d6639ab71 --- /dev/null +++ b/extensions/telegram-user/src/active-client.ts @@ -0,0 +1,25 @@ +import type { TelegramClient } from "@mtcute/node"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; + +const activeClients = new Map(); + +function resolveAccountKey(accountId?: string | null): string { + return normalizeAccountId(accountId ?? DEFAULT_ACCOUNT_ID); +} + +export function setActiveTelegramUserClient( + accountId: string | null | undefined, + next: TelegramClient | null, +) { + const key = resolveAccountKey(accountId); + if (next) { + activeClients.set(key, next); + return; + } + activeClients.delete(key); +} + +export function getActiveTelegramUserClient(accountId?: string | null): TelegramClient | null { + const key = resolveAccountKey(accountId); + return activeClients.get(key) ?? null; +} diff --git a/extensions/telegram-user/src/channel.test.ts b/extensions/telegram-user/src/channel.test.ts new file mode 100644 index 000000000..dc3161a36 --- /dev/null +++ b/extensions/telegram-user/src/channel.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk"; + +const sendMediaTelegramUser = vi.fn< + typeof import("./send.js").sendMediaTelegramUser +>(); + +vi.mock("./send.js", () => { + return { + looksLikeTelegramUserTargetId: () => true, + normalizeTelegramUserMessagingTarget: (raw: string) => raw, + sendMessageTelegramUser: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })), + sendPollTelegramUser: vi.fn(async () => ({ messageId: "m2", chatId: "c2" })), + sendMediaTelegramUser, + }; +}); + +describe("telegram-user channel plugin", () => { + beforeEach(() => { + sendMediaTelegramUser.mockReset(); + }); + + it("declares thread/reaction capabilities consistent with handler behavior", async () => { + const mod = await import("./channel.js"); + expect(mod.telegramUserPlugin.capabilities?.reactions).toBe(true); + expect(mod.telegramUserPlugin.capabilities?.threads).toBe(true); + expect(mod.telegramUserPlugin.capabilities?.chatTypes).toContain("thread"); + }); + + it("enforces mediaMaxMb in outbound sendMedia", async () => { + sendMediaTelegramUser.mockResolvedValue({ messageId: "m3", chatId: "c3" }); + + const cfg = { + channels: { + "telegram-user": { + mediaMaxMb: 7, + }, + }, + } satisfies Partial as unknown as OpenClawConfig; + + const mod = await import("./channel.js"); + await mod.telegramUserPlugin.outbound?.sendMedia?.({ + cfg, + to: "telegram-user:123", + text: "hello", + mediaUrl: "file:///tmp/example.jpg", + accountId: "default", + }); + + expect(sendMediaTelegramUser).toHaveBeenCalledTimes(1); + const [, , opts] = sendMediaTelegramUser.mock.calls[0] ?? []; + expect(opts?.maxBytes).toBe(7 * 1024 * 1024); + }); + + it("omits maxBytes when mediaMaxMb is not configured", async () => { + sendMediaTelegramUser.mockResolvedValue({ messageId: "m4", chatId: "c4" }); + + const cfg = { + channels: { + "telegram-user": {}, + }, + } satisfies Partial as unknown as OpenClawConfig; + + const mod = await import("./channel.js"); + await mod.telegramUserPlugin.outbound?.sendMedia?.({ + cfg, + to: "telegram-user:123", + text: "hello", + mediaUrl: "file:///tmp/example.jpg", + accountId: "default", + }); + + expect(sendMediaTelegramUser).toHaveBeenCalledTimes(1); + const [, , opts] = sendMediaTelegramUser.mock.calls[0] ?? []; + expect(opts).not.toHaveProperty("maxBytes"); + }); + + it("lists peers and groups from config like the telegram plugin directory", async () => { + const cfg = { + channels: { + "telegram-user": { + allowFrom: ["123", "@alice", "telegram-user:456", "user:@bob", "*"], + groupAllowFrom: ["tg:carol", 789], + groups: { + "-1001": {}, + "*": {}, + }, + }, + }, + } satisfies Partial as unknown as OpenClawConfig; + + const mod = await import("./channel.js"); + const runtime = { + log: () => {}, + warn: () => {}, + error: () => {}, + exit: (): never => { + throw new Error("exit called"); + }, + } satisfies RuntimeEnv; + const peers = await mod.telegramUserPlugin.directory?.listPeers?.({ + cfg, + runtime, + }); + const groups = await mod.telegramUserPlugin.directory?.listGroups?.({ + cfg, + runtime, + }); + + expect(peers?.map((p) => p.id).sort()).toEqual( + ["123", "456", "@alice", "@bob", "@carol", "789"].sort(), + ); + expect(groups?.map((g) => g.id)).toEqual(["-1001"]); + }); +}); diff --git a/extensions/telegram-user/src/channel.ts b/extensions/telegram-user/src/channel.ts new file mode 100644 index 000000000..ffd9fe1fc --- /dev/null +++ b/extensions/telegram-user/src/channel.ts @@ -0,0 +1,545 @@ +import fs from "node:fs"; + +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + resolveChannelMediaMaxBytes, + setAccountEnabledInConfigSection, + type ChannelGroupContext, + type ChannelPlugin, + type ChannelSetupInput, + type OpenClawConfig, + type GroupToolPolicyConfig, +} from "openclaw/plugin-sdk"; + +import { + listTelegramUserAccountIds, + resolveDefaultTelegramUserAccountId, + resolveTelegramUserAccount, + type ResolvedTelegramUserAccount, +} from "./accounts.js"; +import { TelegramUserConfigSchema } from "./config-schema.js"; +import { + listTelegramUserDirectoryGroupsFromConfig, + listTelegramUserDirectoryPeersFromConfig, +} from "./directory-config.js"; +import { loginTelegramUser } from "./login.js"; +import { monitorTelegramUserProvider } from "./monitor/index.js"; +import { + looksLikeTelegramUserTargetId, + normalizeTelegramUserMessagingTarget, + sendMediaTelegramUser, + sendMessageTelegramUser, + sendPollTelegramUser, +} from "./send.js"; +import { resolveTelegramUserSessionPath } from "./session.js"; +import { getTelegramUserRuntime } from "./runtime.js"; +import { telegramUserOnboardingAdapter } from "./onboarding.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 or phone code; supports DMs + groups.", + order: 12, + quickstartAllowFrom: true, +}; + +type TelegramUserSetupInput = ChannelSetupInput & { + apiId?: number; + apiHash?: string; +}; + +function parseReplyToId(replyToId?: string | null): number | undefined { + if (!replyToId) return undefined; + const parsed = Number.parseInt(replyToId, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function normalizeTelegramUserGroupKey(raw?: string | null): string | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + const withoutPrefix = trimmed.replace(/^telegram-user:group:/i, ""); + const [base] = withoutPrefix.split(/:topic:/i); + const normalized = base?.trim(); + return normalized ? normalized : undefined; +} + +function resolveTelegramUserGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const account = resolveTelegramUserAccount({ + cfg: params.cfg as CoreConfig, + accountId: params.accountId, + }); + const groups = account.config.groups ?? {}; + const groupId = normalizeTelegramUserGroupKey(params.groupId); + const groupChannel = normalizeTelegramUserGroupKey(params.groupChannel); + const candidates = [groupId, groupChannel, "*"].filter( + (value): value is string => Boolean(value), + ); + for (const key of candidates) { + const entry = groups[key]; + if (entry?.tools) return entry.tools; + } + return undefined; +} + +const isSessionLinked = async (accountId: string): Promise => { + const sessionPath = resolveTelegramUserSessionPath(accountId); + return fs.existsSync(sessionPath); +}; + +export const telegramUserPlugin: ChannelPlugin = { + id: "telegram-user", + meta, + onboarding: telegramUserOnboardingAdapter, + pairing: { + idLabel: "telegramUserId", + normalizeAllowEntry: (entry) => + entry.replace(/^(telegram-user|telegram|tg):/i, "").toLowerCase(), + notifyApproval: async ({ id }) => { + await sendMessageTelegramUser(String(id), PAIRING_APPROVED_MESSAGE, {}); + }, + }, + capabilities: { + chatTypes: ["direct", "group", "thread"], + polls: true, + reactions: true, + threads: true, + media: true, + nativeCommands: false, + blockStreaming: true, + }, + messaging: { + normalizeTarget: normalizeTelegramUserMessagingTarget, + targetResolver: { + looksLikeId: looksLikeTelegramUserTargetId, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async (params) => listTelegramUserDirectoryPeersFromConfig(params), + listGroups: async (params) => listTelegramUserDirectoryGroupsFromConfig(params), + }, + 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(), + }; + }, + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") return []; + const groupAllowlistConfigured = + account.config.groups && Object.keys(account.config.groups).length > 0; + if (groupAllowlistConfigured) { + return [ + `- Telegram user groups: groupPolicy="open" allows any member in allowed groups to trigger (mention-gated). Set channels.telegram-user.groupPolicy="allowlist" + channels.telegram-user.groupAllowFrom to restrict senders.`, + ]; + } + return [ + `- Telegram user groups: groupPolicy="open" with no channels.telegram-user.groups allowlist; any group can add + ping (mention-gated). Set channels.telegram-user.groupPolicy="allowlist" + channels.telegram-user.groupAllowFrom or configure channels.telegram-user.groups.`, + ]; + }, + }, + groups: { + resolveRequireMention: ({ cfg, groupId, accountId }) => + getTelegramUserRuntime().channel.groups.resolveRequireMention({ + cfg, + channel: "telegram-user", + groupId, + accountId, + }), + resolveToolPolicy: resolveTelegramUserGroupToolPolicy, + }, + threading: { + resolveReplyToMode: ({ cfg }) => cfg.channels?.["telegram-user"]?.replyToMode ?? "first", + buildToolContext: ({ context, hasRepliedRef }) => { + const threadId = context.MessageThreadId ?? context.ReplyToId; + return { + currentChannelId: context.To?.trim() || undefined, + currentThreadTs: threadId != null ? String(threadId) : undefined, + hasRepliedRef, + }; + }, + }, + actions: { + listActions: ({ cfg }) => { + if (!cfg.channels?.["telegram-user"]) return []; + return ["poll"]; + }, + }, + agentPrompt: { + messageToolHints: () => [ + "Telegram user polls only work in groups/channels (DM polls return MEDIA_INVALID). Use the group id for polls.", + "When ChatType is group, use currentChannelId as the target for message/poll actions.", + "To send files, use `message` action=send with `filePath` (local path) or `media` (URL); put any caption in `message`.", + ], + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => + getTelegramUserRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + pollMaxOptions: 10, + sendText: async ({ to, text, accountId, threadId, replyToId }) => { + const parsedReplyToId = parseReplyToId(replyToId); + const result = await sendMessageTelegramUser(to, text, { + accountId: accountId ?? undefined, + threadId, + ...(parsedReplyToId ? { replyToId: parsedReplyToId } : {}), + }); + return { channel: "telegram-user", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, threadId, replyToId }) => { + const parsedReplyToId = parseReplyToId(replyToId); + const maxBytes = resolveChannelMediaMaxBytes({ + cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + resolveTelegramUserAccount({ + cfg: cfg as CoreConfig, + accountId, + }).config.mediaMaxMb, + accountId, + }); + const result = await sendMediaTelegramUser(to, text, { + accountId: accountId ?? undefined, + mediaUrl, + threadId, + ...(parsedReplyToId ? { replyToId: parsedReplyToId } : {}), + ...(maxBytes ? { maxBytes } : {}), + }); + return { channel: "telegram-user", ...result }; + }, + sendPoll: async ({ to, poll, accountId, threadId, replyToId }) => { + const parsedReplyToId = parseReplyToId(replyToId); + const result = await sendPollTelegramUser(to, poll, { + accountId: accountId ?? undefined, + threadId, + ...(parsedReplyToId ? { replyToId: parsedReplyToId } : {}), + }); + 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 OpenClawConfig, + 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 OpenClawConfig, + 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 ({ accountId }) => { + const { getActiveTelegramUserClient, setActiveTelegramUserClient } = + await import("./active-client.js"); + const active = getActiveTelegramUserClient(accountId); + if (active) { + await active.destroy().catch(() => undefined); + setActiveTelegramUserClient(accountId, null); + } + }, + logoutAccount: async ({ accountId, cfg, runtime }) => { + const sessionPath = resolveTelegramUserSessionPath(accountId); + let cleared = false; + if (fs.existsSync(sessionPath)) { + try { + fs.rmSync(sessionPath, { force: true }); + cleared = true; + } catch (err) { + runtime.error?.(`Failed to remove Telegram user session: ${String(err)}`); + } + } + + const nextCfg = { ...cfg } as OpenClawConfig; + const nextSection = cfg.channels?.["telegram-user"] + ? { ...cfg.channels["telegram-user"] } + : undefined; + let changed = false; + + if (nextSection) { + if (accountId === DEFAULT_ACCOUNT_ID) { + if ("apiId" in nextSection) { + if (nextSection.apiId) cleared = true; + delete nextSection.apiId; + changed = true; + } + if ("apiHash" in nextSection) { + if (nextSection.apiHash) cleared = true; + delete nextSection.apiHash; + changed = true; + } + } + + const accounts = + nextSection.accounts && typeof nextSection.accounts === "object" + ? { ...nextSection.accounts } + : undefined; + if (accounts && accountId in accounts) { + const entry = accounts[accountId]; + if (entry && typeof entry === "object") { + const nextEntry = { ...entry } as Record; + if ("apiId" in nextEntry) { + const apiId = nextEntry.apiId; + if (typeof apiId === "number" && Number.isFinite(apiId)) { + cleared = true; + } + delete nextEntry.apiId; + changed = true; + } + if ("apiHash" in nextEntry) { + const apiHash = nextEntry.apiHash; + if (typeof apiHash === "string" ? apiHash.trim() : apiHash) { + cleared = true; + } + delete nextEntry.apiHash; + changed = true; + } + if (Object.keys(nextEntry).length === 0) { + delete accounts[accountId]; + changed = true; + } else { + accounts[accountId] = nextEntry as typeof entry; + } + } + } + if (accounts) { + if (Object.keys(accounts).length === 0) { + delete nextSection.accounts; + changed = true; + } else { + nextSection.accounts = accounts; + } + } + } + + if (changed) { + if (nextSection && Object.keys(nextSection).length > 0) { + nextCfg.channels = { ...nextCfg.channels, "telegram-user": nextSection }; + } else { + const nextChannels = { ...nextCfg.channels }; + delete nextChannels["telegram-user"]; + if (Object.keys(nextChannels).length > 0) { + nextCfg.channels = nextChannels; + } else { + delete nextCfg.channels; + } + } + await getTelegramUserRuntime().config.writeConfigFile(nextCfg); + } + + const envApiId = process.env.TELEGRAM_USER_API_ID?.trim(); + const envApiHash = process.env.TELEGRAM_USER_API_HASH?.trim(); + const loggedOut = !fs.existsSync(sessionPath); + + return { + cleared, + loggedOut, + envCredentials: Boolean(envApiId && envApiHash), + }; + }, + }, +}; diff --git a/extensions/telegram-user/src/client.ts b/extensions/telegram-user/src/client.ts new file mode 100644 index 000000000..bce7e3b4f --- /dev/null +++ b/extensions/telegram-user/src/client.ts @@ -0,0 +1,44 @@ +type MtcuteNode = typeof import("@mtcute/node"); + +let mtcuteNodePromise: Promise | null = null; + +async function loadMtcuteNode(): Promise { + mtcuteNodePromise ??= import("@mtcute/node"); + return mtcuteNodePromise; +} + +export async function createTelegramUserClient(params: { + apiId: number; + apiHash: string; + storagePath: string; +}): Promise { + // When loaded via jiti (plugin loader), dependencies often resolve through the "require" export + // condition. mtcute prints a deprecation warning from its CommonJS bundle. Dynamic import forces + // the "import" condition (ESM), eliminating the warning. + const { BaseTelegramClient, TelegramClient, NodePlatform } = await loadMtcuteNode(); + + class OpenClawTelegramUserPlatform extends NodePlatform { + // mtcute's default NodePlatform.beforeExit installs SIGINT/SIGTERM handlers that re-send the + // signal, which can race with OpenClaw's graceful shutdown and close sqlite while writes are + // pending. We only hook into process exit events (no signal handlers) and rely on OpenClaw to + // stop cleanly. + override beforeExit(fn: () => void): () => void { + const onBeforeExit = () => fn(); + const onExit = () => fn(); + process.once("beforeExit", onBeforeExit); + process.once("exit", onExit); + return () => { + process.off("beforeExit", onBeforeExit); + process.off("exit", onExit); + }; + } + } + + const client = new BaseTelegramClient({ + apiId: params.apiId, + apiHash: params.apiHash, + storage: params.storagePath, + platform: new OpenClawTelegramUserPlatform(), + }); + return new TelegramClient({ client }); +} diff --git a/extensions/telegram-user/src/config-schema.ts b/extensions/telegram-user/src/config-schema.ts new file mode 100644 index 000000000..a844abae5 --- /dev/null +++ b/extensions/telegram-user/src/config-schema.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +import { + DmPolicySchema, + GroupPolicySchema, + ToolPolicySchema, + requireOpenAllowFrom, +} from "openclaw/plugin-sdk"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +const TelegramUserTopicSchema = z + .object({ + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(allowFromEntry).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + +const TelegramUserGroupSchema = z + .object({ + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + tools: ToolPolicySchema, + topics: z.record(z.string(), TelegramUserTopicSchema.optional()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(allowFromEntry).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + +const TelegramUserAccountSchemaBase = z + .object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + apiId: z.number().int().positive().optional(), + apiHash: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(allowFromEntry).optional(), + replyToMode: z.enum(["off", "first", "all"]).optional(), + textChunkLimit: z.number().int().positive().optional(), + mediaMaxMb: z.number().positive().optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + groups: z.record(z.string(), TelegramUserGroupSchema.optional()).optional(), + }) + .strict(); + +const TelegramUserAccountSchema = TelegramUserAccountSchemaBase.superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.telegram-user.dmPolicy="open" requires channels.telegram-user.allowFrom to include "*"', + }); +}); + +export const TelegramUserConfigSchema = TelegramUserAccountSchemaBase.extend({ + accounts: z.record(z.string(), TelegramUserAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.telegram-user.dmPolicy="open" requires channels.telegram-user.allowFrom to include "*"', + }); +}); diff --git a/extensions/telegram-user/src/directory-config.ts b/extensions/telegram-user/src/directory-config.ts new file mode 100644 index 000000000..182b7618f --- /dev/null +++ b/extensions/telegram-user/src/directory-config.ts @@ -0,0 +1,67 @@ +import type { ChannelDirectoryEntry, OpenClawConfig } from "openclaw/plugin-sdk"; + +import { resolveTelegramUserAccount } from "./accounts.js"; +import type { CoreConfig } from "./types.js"; + +export type TelegramUserDirectoryConfigParams = { + cfg: OpenClawConfig; + accountId?: string | null; + query?: string | null; + limit?: number | null; +}; + +function normalizePeerEntry(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const cleaned = trimmed + .replace(/^(telegram-user|telegram|tg):/i, "") + .replace(/^user:/i, "") + .trim(); + if (!cleaned) return null; + if (/^-?\d+$/.test(cleaned)) return cleaned; + const withoutAt = cleaned.replace(/^@/, ""); + if (!withoutAt) return null; + return `@${withoutAt}`; +} + +export async function listTelegramUserDirectoryPeersFromConfig( + params: TelegramUserDirectoryConfigParams, +): Promise { + const account = resolveTelegramUserAccount({ + cfg: params.cfg as CoreConfig, + accountId: params.accountId, + }); + const q = params.query?.trim().toLowerCase() || ""; + const raw = [ + ...(account.config.allowFrom ?? []).map((entry) => String(entry)), + ...(account.config.groupAllowFrom ?? []).map((entry) => String(entry)), + ]; + return Array.from( + new Set( + raw + .map((entry) => entry.trim()) + .filter((entry) => Boolean(entry) && entry !== "*"), + ), + ) + .map((entry) => normalizePeerEntry(entry)) + .filter((id): id is string => Boolean(id)) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, params.limit && params.limit > 0 ? params.limit : undefined) + .map((id) => ({ kind: "user", id }) as const); +} + +export async function listTelegramUserDirectoryGroupsFromConfig( + params: TelegramUserDirectoryConfigParams, +): Promise { + const account = resolveTelegramUserAccount({ + cfg: params.cfg as CoreConfig, + accountId: params.accountId, + }); + const q = params.query?.trim().toLowerCase() || ""; + return Object.keys(account.config.groups ?? {}) + .map((id) => id.trim()) + .filter((id) => Boolean(id) && id !== "*") + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, params.limit && params.limit > 0 ? params.limit : undefined) + .map((id) => ({ kind: "group", id }) as const); +} diff --git a/extensions/telegram-user/src/login.ts b/extensions/telegram-user/src/login.ts new file mode 100644 index 000000000..778a56cf9 --- /dev/null +++ b/extensions/telegram-user/src/login.ts @@ -0,0 +1,107 @@ +import qrcode from "qrcode-terminal"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { isCancel, select } from "@clack/prompts"; +import type { RuntimeEnv } from "openclaw/plugin-sdk"; + +import { createTelegramUserClient } from "./client.js"; +import { ensureTelegramUserSessionDir } from "./session.js"; + +async function promptText(message: string): Promise { + const rl = createInterface({ input, output }); + try { + const value = await rl.question(message); + return value.trim(); + } finally { + rl.close(); + } +} + +async function promptLoginMode(): Promise<"qr" | "phone"> { + if (!input.isTTY || !output.isTTY) return "qr"; + const response = await select({ + message: "Telegram login method", + options: [ + { value: "qr", label: "QR code (scan with Telegram)" }, + { value: "phone", label: "Phone code (SMS/Telegram)" }, + ], + initialValue: "qr", + }); + if (isCancel(response)) return "qr"; + return response; +} + +export async function loginTelegramUser(params: { + apiId: number; + apiHash: string; + storagePath: string; + runtime: RuntimeEnv; +}) { + const { apiId, apiHash, storagePath, runtime } = params; + ensureTelegramUserSessionDir({ sessionPath: storagePath }); + const client = await createTelegramUserClient({ apiId, apiHash, storagePath }); + let lastUrl = ""; + + const passwordEnv = process.env.TELEGRAM_USER_PASSWORD?.trim() || undefined; + let phoneEnv = process.env.TELEGRAM_USER_PHONE?.trim() || undefined; + const codeEnv = process.env.TELEGRAM_USER_CODE?.trim() || undefined; + + const passwordPrompt = passwordEnv + ? passwordEnv + : async () => await promptText("2FA password: "); + + try { + if (!phoneEnv) { + const mode = await promptLoginMode(); + if (mode === "phone") { + phoneEnv = await promptText("Telegram phone number (E.164): "); + } + } + const user = await client.start( + phoneEnv + ? { + phone: phoneEnv, + code: codeEnv ? codeEnv : async () => await promptText("Telegram code: "), + password: passwordPrompt, + codeSentCallback: (code) => { + runtime.log( + `Telegram code sent via ${code.type}. Check your device and enter it here.`, + ); + }, + invalidCodeCallback: async (type) => { + if (type === "password" && passwordEnv) { + runtime.error?.( + "Telegram 2FA password rejected. Update TELEGRAM_USER_PASSWORD and rerun.", + ); + } + if (type === "code" && codeEnv) { + runtime.error?.( + "Telegram code rejected. Update TELEGRAM_USER_CODE and rerun.", + ); + } + }, + } + : { + 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: passwordPrompt, + invalidCodeCallback: async (type) => { + if (type === "password") { + runtime.error?.( + passwordEnv + ? "Telegram 2FA password rejected. Update TELEGRAM_USER_PASSWORD and rerun." + : "Telegram 2FA password rejected. Try again.", + ); + } + }, + }, + ); + runtime.log(`Telegram user logged in as ${user.displayName}.`); + } finally { + await client.destroy(); + } +} diff --git a/extensions/telegram-user/src/monitor/handler.test.ts b/extensions/telegram-user/src/monitor/handler.test.ts new file mode 100644 index 000000000..41898dc4f --- /dev/null +++ b/extensions/telegram-user/src/monitor/handler.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTelegramUserTimestampMs } from "./handler.js"; + +describe("resolveTelegramUserTimestampMs", () => { + it("uses Date values directly", () => { + const date = new Date("2025-01-02T03:04:05Z"); + expect(resolveTelegramUserTimestampMs(date)).toBe(date.getTime()); + }); + + it("converts seconds to milliseconds", () => { + expect(resolveTelegramUserTimestampMs(1_710_000_000)).toBe(1_710_000_000 * 1000); + }); + + it("passes through millisecond values", () => { + expect(resolveTelegramUserTimestampMs(1_710_000_000_000)).toBe(1_710_000_000_000); + }); + + it("returns undefined for invalid dates", () => { + const invalid = new Date("invalid"); + expect(resolveTelegramUserTimestampMs(invalid)).toBeUndefined(); + }); +}); diff --git a/extensions/telegram-user/src/monitor/handler.ts b/extensions/telegram-user/src/monitor/handler.ts new file mode 100644 index 000000000..1c2545d97 --- /dev/null +++ b/extensions/telegram-user/src/monitor/handler.ts @@ -0,0 +1,811 @@ +import type { TelegramClient } from "@mtcute/node"; +import type { MessageContext } from "@mtcute/dispatcher"; +import type { RuntimeEnv } from "openclaw/plugin-sdk"; + +import { + formatLocationText, + resolveAckReaction, + resolveMentionGatingWithBypass, + toLocationContext, + type NormalizedLocation, +} from "openclaw/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; + abortSignal?: AbortSignal; + self?: { id: number; username?: string | null; name?: string | null }; +}; + +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, hasEntries: normalized.length > 0 }; +} + +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(/^@/, "")); +} + +function resolveTelegramUserPeer(target: string): number | string { + if (/^-?\d+$/.test(target)) { + const parsed = Number.parseInt(target, 10); + if (Number.isFinite(parsed)) return parsed; + } + return target; +} + +function isDestroyedClientError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /client is destroyed/i.test(message); +} + +function isClientDestroyed(client: TelegramClient): boolean { + const candidate = client as TelegramClient & { destroyed?: boolean }; + return candidate.destroyed === true; +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildTelegramUserSelfMentionRegexes(params: { + username?: string | null; + name?: string | null; +}): RegExp[] { + const patterns: string[] = []; + const username = params.username?.trim().replace(/^@/, ""); + if (username) { + patterns.push(String.raw`\b@?${escapeRegExp(username)}\b`); + } + const name = params.name?.trim(); + if (name) { + const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp); + if (parts.length > 0) { + patterns.push(String.raw`\b@?${parts.join(String.raw`\s+`)}\b`); + } + } + return patterns + .map((pattern) => { + try { + return new RegExp(pattern, "i"); + } catch { + return null; + } + }) + .filter((entry): entry is RegExp => Boolean(entry)); +} + +export function resolveTelegramUserTimestampMs( + value: Date | number | null | undefined, +): number | undefined { + if (value == null) return undefined; + if (value instanceof Date) { + const ms = value.getTime(); + return Number.isFinite(ms) ? ms : undefined; + } + if (typeof value === "number" && Number.isFinite(value)) { + return value < 1_000_000_000_000 ? Math.round(value * 1000) : Math.round(value); + } + return undefined; +} + +async function safeSendTyping(params: { + client: TelegramClient; + target: number | string; + status: Parameters[1]; + typingParams?: Parameters[2]; + runtime: TelegramUserHandlerParams["runtime"]; + abortSignal?: AbortSignal; + logLabel: string; +}) { + if (params.abortSignal?.aborted) return; + if (isClientDestroyed(params.client)) return; + try { + await params.client.sendTyping(params.target, params.status, params.typingParams); + } catch (err) { + if (isDestroyedClientError(err)) return; + params.runtime.error?.(`telegram-user ${params.logLabel} failed: ${String(err)}`); + } +} + +function firstDefined(...values: Array): T | undefined { + for (const value of values) { + if (typeof value !== "undefined") return value; + } + return undefined; +} + +function buildTelegramUserGroupPeerId(chatId: number | string, threadId?: number) { + return threadId != null ? `${chatId}:topic:${threadId}` : String(chatId); +} + +function buildTelegramUserGroupFrom(chatId: number | string, threadId?: number) { + return `telegram-user:group:${buildTelegramUserGroupPeerId(chatId, threadId)}`; +} + +function buildTelegramUserGroupLabel( + title: string | undefined, + chatId: number | string, + threadId?: number, +) { + const topicSuffix = threadId != null ? ` topic:${threadId}` : ""; + if (title) return `${title} id:${chatId}${topicSuffix}`; + return `group:${chatId}${topicSuffix}`; +} + +function resolveTelegramUserGroupConfig( + accountConfig: TelegramUserAccountConfig, + chatId: number | string, + threadId?: number, +) { + const groups = accountConfig.groups ?? {}; + const chatKey = String(chatId); + const groupConfig = groups[chatKey] ?? groups["*"]; + if (!threadId) return { groupConfig, topicConfig: undefined }; + const topicKey = String(threadId); + const topicConfig = + groupConfig?.topics?.[topicKey] ?? groups["*"]?.topics?.[topicKey]; + return { groupConfig, topicConfig }; +} + +function extractTelegramUserLocation( + media: MessageContext["media"], +): NormalizedLocation | null { + if (!media) return null; + const typed = media as { type?: string }; + if (typed.type === "venue") { + const venue = media as { + location: { latitude: number; longitude: number; radius?: number }; + title: string; + address: string; + }; + return { + latitude: venue.location.latitude, + longitude: venue.location.longitude, + accuracy: venue.location.radius, + name: venue.title, + address: venue.address, + source: "place", + isLive: false, + }; + } + if (typed.type === "location" || typed.type === "live_location") { + const location = media as { + latitude: number; + longitude: number; + radius?: number; + }; + const isLive = typed.type === "live_location"; + return { + latitude: location.latitude, + longitude: location.longitude, + accuracy: location.radius, + source: isLive ? "live" : "pin", + isLive, + }; + } + return null; +} + +function formatTelegramUserPoll(media: MessageContext["media"]): string | null { + if (!media) return null; + const typed = media as { type?: string }; + if (typed.type !== "poll") return null; + const poll = media as { + question: string; + answers: Array<{ text: string }>; + isMultiple?: boolean; + isQuiz?: boolean; + }; + const mode = poll.isQuiz ? "quiz" : poll.isMultiple ? "multi" : null; + const header = `📊 Poll${mode ? ` (${mode})` : ""}: ${poll.question}`; + const options = poll.answers.map((ans, idx) => `${idx + 1}) ${ans.text}`); + return [header, ...options].join("\n"); +} + +function describeReplySender(sender: unknown): string | undefined { + const typed = sender as { + type?: string; + displayName?: string; + title?: string; + id?: number; + }; + if (!typed || typeof typed !== "object") return undefined; + if (typed.type === "anonymous" && typed.displayName) return typed.displayName; + if (typed.type === "user" && typed.displayName) return typed.displayName; + if (typed.type === "chat") { + if (typed.title) return typed.title; + if (typed.id != null) return `chat:${typed.id}`; + } + return undefined; +} + +async function resolveMediaAttachment(params: { + client: TelegramClient; + mediaMaxMb: number; + media: MessageContext["media"]; +}) { + if (!params.media) return null; + const typed = params.media as { type?: string }; + if ( + typed.type === "location" || + typed.type === "live_location" || + typed.type === "venue" || + typed.type === "poll" + ) { + 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, + }; +} + +async function resolveMediaAttachments(params: { + client: TelegramClient; + mediaMaxMb: number; + messages: MessageContext[]; + runtime: RuntimeEnv; +}): Promise> { + const results: Array<{ path: string; contentType?: string }> = []; + for (const message of params.messages) { + if (!message.media) continue; + const resolved = await resolveMediaAttachment({ + client: params.client, + mediaMaxMb: params.mediaMaxMb, + media: message.media, + }).catch((err) => { + params.runtime.error?.(`telegram-user media download failed: ${String(err)}`); + return null; + }); + if (resolved) results.push(resolved); + } + return results; +} + +export function createTelegramUserMessageHandler(params: TelegramUserHandlerParams) { + const { client, cfg, runtime, accountId, accountConfig, self, abortSignal } = 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 ?? []; + const groupAllowFrom = accountConfig.groupAllowFrom ?? allowFrom; + + return async (msg: MessageContext) => { + if (abortSignal?.aborted) return; + if (isClientDestroyed(client)) return; + try { + if (msg.isOutgoing || msg.isService) return; + const messageGroup = msg.isMessageGroup ? msg.messages : [msg]; + const isDirect = msg.chat.type === "user"; + const isGroup = + msg.chat.type === "chat" && msg.chat.chatType !== "channel"; + if (!isDirect && !isGroup) return; + + const sender = await msg.getCompleteSender().catch(() => msg.sender); + if (sender.type !== "user") return; + if ("isSelf" in sender && sender.isSelf) return; + if (self?.id != null && sender.id === self.id) return; + + const senderId = String(sender.id); + const senderPeer = resolveTelegramUserPeer(senderId); + 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]; + const chatId = msg.chat.type === "chat" ? msg.chat.id : undefined; + const isForum = msg.chat.type === "chat" && msg.chat.isForum === true; + const isTopicMessage = msg.isTopicMessage === true; + const threadId = + isGroup && isForum && isTopicMessage ? msg.replyToMessage?.threadId ?? undefined : undefined; + const { groupConfig, topicConfig } = + isGroup && chatId != null + ? resolveTelegramUserGroupConfig(accountConfig, chatId, threadId) + : { groupConfig: undefined, topicConfig: undefined }; + + const groupAllowOverride = firstDefined( + topicConfig?.allowFrom, + groupConfig?.allowFrom, + ); + const groupAllowEntries = [ + ...((groupAllowOverride ?? groupAllowFrom) as Array), + ...storeAllowFrom, + ]; + const effectiveGroupAllow = parseAllowlist(groupAllowEntries); + const effectiveDmAllow = parseAllowlist(combinedAllowFrom); + + if (isDirect) { + 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; + } + } else if (isGroup) { + if (groupConfig?.enabled === false) return; + if (topicConfig?.enabled === false) return; + if (typeof groupAllowOverride !== "undefined") { + const allowed = isSenderAllowed({ + allowFrom: groupAllowEntries, + senderId, + senderUsername, + }); + if (!allowed) return; + } + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = + accountConfig.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy === "disabled") return; + if (groupPolicy === "allowlist") { + if (!senderId) return; + if (!effectiveGroupAllow.hasEntries) return; + if ( + !isSenderAllowed({ + allowFrom: groupAllowEntries, + senderId, + senderUsername, + }) + ) { + return; + } + } + if (chatId != null) { + const groupAllowlist = core.channel.groups.resolveGroupPolicy({ + cfg, + channel: "telegram-user", + groupId: String(chatId), + accountId, + }); + if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) return; + } + } + + const primaryMessage = + messageGroup.find((entry) => entry.text?.trim()) ?? msg; + const text = primaryMessage.text?.trim() ?? ""; + const locationData = extractTelegramUserLocation(primaryMessage.media); + const locationText = locationData ? formatLocationText(locationData) : undefined; + const pollText = formatTelegramUserPoll(primaryMessage.media); + const allMedia = await resolveMediaAttachments({ + client, + mediaMaxMb, + messages: messageGroup, + runtime, + }); + const media = allMedia[0] ?? null; + const rawBody = [text, locationText, pollText].filter(Boolean).join("\n").trim(); + if (!rawBody && !media) return; + const timestampMs = resolveTelegramUserTimestampMs(msg.date); + const replyInfo = msg.replyToMessage ?? null; + const replyToId = replyInfo?.id != null ? String(replyInfo.id) : undefined; + const replyToSender = replyInfo?.sender + ? describeReplySender(replyInfo.sender) + : undefined; + let replyToBody: string | undefined; + if (replyToId) { + const replyMessage = await msg.getReplyTo().catch(() => null); + replyToBody = replyMessage?.text?.trim() || undefined; + } + + core.channel.activity.record({ + channel: "telegram-user", + accountId, + direction: "inbound", + }); + + const groupPeerId = + isGroup && chatId != null + ? buildTelegramUserGroupPeerId(chatId, threadId) + : null; + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "telegram-user", + accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup && groupPeerId ? groupPeerId : senderId, + }, + }); + const mentionRegexes = [ + ...core.channel.mentions.buildMentionRegexes(cfg, route.agentId), + ...buildTelegramUserSelfMentionRegexes({ username: self?.username, name: self?.name }), + ]; + const entities = msg.entities ?? []; + const hasAnyMention = entities.some( + (ent) => ent.kind === "mention" || ent.kind === "text_mention", + ); + const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg, { + botUsername: self?.username?.trim().toLowerCase(), + }); + const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow; + const senderAllowedForCommands = isSenderAllowed({ + allowFrom: isGroup ? groupAllowEntries : combinedAllowFrom, + senderId, + senderUsername, + }); + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }], + }); + if (isGroup && hasControlCommandInMessage && !commandAuthorized) return; + + const computedWasMentioned = + msg.isMention || core.channel.mentions.matchesMentionPatterns(text, mentionRegexes); + const baseRequireMention = isGroup + ? core.channel.groups.resolveRequireMention({ + cfg, + channel: "telegram-user", + groupId: chatId != null ? String(chatId) : undefined, + accountId, + }) + : false; + const requireMention = firstDefined( + topicConfig?.requireMention, + groupConfig?.requireMention, + baseRequireMention, + ); + const replySenderId = + msg.replyToMessage?.sender?.type === "user" + ? msg.replyToMessage.sender.id + : undefined; + const implicitMention = + isGroup && Boolean(requireMention) && self?.id != null && replySenderId === self.id; + const canDetectMention = + Boolean(self?.username) || mentionRegexes.length > 0 || msg.isMention; + const mentionGate = resolveMentionGatingWithBypass({ + isGroup, + requireMention: Boolean(requireMention), + canDetectMention, + wasMentioned: computedWasMentioned, + implicitMention, + hasAnyMention, + allowTextCommands: true, + hasControlCommand: hasControlCommandInMessage, + commandAuthorized, + }); + const effectiveWasMentioned = mentionGate.effectiveWasMentioned; + if (isGroup && requireMention && canDetectMention && mentionGate.shouldSkip) { + return; + } + + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; + const ackReaction = resolveAckReaction(cfg, route.agentId); + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return !isGroup; + if (ackReactionScope === "group-all") return isGroup; + if (ackReactionScope === "group-mentions") { + return isGroup && Boolean(requireMention) && canDetectMention && effectiveWasMentioned; + } + return false; + }; + const ackReactionPromise = shouldAckReaction() + ? client + .sendReaction({ + chatId: isGroup && chatId != null ? chatId : senderPeer, + message: msg.id, + emoji: ackReaction, + }) + .then(() => true) + .catch((err) => { + runtime.error?.(`telegram-user ack reaction failed: ${String(err)}`); + return false; + }) + : null; + 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 groupTitle = msg.chat.type === "chat" ? msg.chat.title : undefined; + const conversationLabel = isGroup && chatId != null + ? buildTelegramUserGroupLabel(groupTitle, chatId, threadId) + : senderName; + const skillFilter = firstDefined( + topicConfig?.skills, + groupConfig?.skills, + ); + const systemPromptParts = [ + groupConfig?.systemPrompt?.trim() || null, + topicConfig?.systemPrompt?.trim() || null, + ].filter((entry): entry is string => Boolean(entry)); + const groupSystemPrompt = + systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; + const mediaSuffix = + !rawBody && allMedia.length > 1 ? ` (${allMedia.length} items)` : ""; + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Telegram User", + from: senderName, + timestamp: timestampMs, + previousTimestamp, + envelope: envelopeOptions, + body: rawBody || `(media${mediaSuffix})`, + }); + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: text, + CommandBody: text, + From: isGroup && chatId != null ? buildTelegramUserGroupFrom(chatId, threadId) : `telegram-user:${senderId}`, + To: isGroup && chatId != null ? buildTelegramUserGroupFrom(chatId, threadId) : `telegram-user:${senderId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: conversationLabel, + GroupSubject: isGroup ? groupTitle ?? undefined : undefined, + GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, + SenderName: senderName, + SenderId: senderId, + SenderUsername: senderUsername ?? undefined, + Provider: "telegram-user" as const, + Surface: "telegram-user" as const, + MessageSid: String(msg.id), + ReplyToId: replyToId ?? String(msg.id), + ReplyToBody: replyToBody, + ReplyToSender: replyToSender, + Timestamp: timestampMs, + MediaPath: media?.path, + MediaType: media?.contentType, + MediaUrl: media?.path, + MediaPaths: allMedia.length > 0 ? allMedia.map((item) => item.path) : undefined, + MediaUrls: allMedia.length > 0 ? allMedia.map((item) => item.path) : undefined, + MediaTypes: + allMedia.length > 0 + ? (allMedia + .map((item) => item.contentType) + .filter(Boolean) as string[]) + : undefined, + CommandAuthorized: commandAuthorized, + CommandSource: "text" as const, + OriginatingChannel: "telegram-user" as const, + OriginatingTo: + isGroup && chatId != null + ? buildTelegramUserGroupFrom(chatId, threadId) + : `telegram-user:${senderId}`, + WasMentioned: isGroup ? effectiveWasMentioned : undefined, + MessageThreadId: threadId, + IsForum: isForum, + ...(locationData ? toLocationContext(locationData) : undefined), + }); + + 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)}`); + }); + + if (!isGroup) { + 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 replyTarget = + isGroup && chatId != null ? `telegram-user:${chatId}` : `telegram-user:${senderId}`; + const typingTarget = isGroup && chatId != null ? chatId : senderPeer; + const typingParams = isGroup && threadId != null ? { threadId } : undefined; + 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) => { + if (abortSignal?.aborted) return; + if (isClientDestroyed(client)) return; + const replyToId = hasReplied ? undefined : msg.id; + const replyText = payload.text ?? ""; + const mediaUrl = payload.mediaUrl; + if (mediaUrl) { + if (abortSignal?.aborted) return; + if (isClientDestroyed(client)) return; + if (payload.audioAsVoice) { + await safeSendTyping({ + client, + target: typingTarget, + status: "record_voice", + typingParams, + runtime, + abortSignal, + logLabel: "voice typing", + }); + } + try { + await sendMediaTelegramUser(replyTarget, replyText, { + client, + accountId, + replyToId, + threadId, + mediaUrl, + audioAsVoice: payload.audioAsVoice === true, + maxBytes: mediaMaxMb * 1024 * 1024, + }); + } catch (err) { + if (isDestroyedClientError(err)) return; + throw err; + } + 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)) { + if (abortSignal?.aborted) return; + if (isClientDestroyed(client)) return; + const trimmed = chunk.trim(); + if (!trimmed) continue; + try { + await sendMessageTelegramUser(replyTarget, trimmed, { + client, + accountId, + replyToId, + threadId, + }); + } catch (err) { + if (isDestroyedClientError(err)) return; + throw err; + } + hasReplied = true; + core.channel.activity.record({ + channel: "telegram-user", + accountId, + direction: "outbound", + }); + } + } + }, + onReplyStart: async () => { + await safeSendTyping({ + client, + target: typingTarget, + status: "typing", + typingParams, + runtime, + abortSignal, + logLabel: "typing", + }); + }, + onError: (err) => { + runtime.error?.(`telegram-user reply failed: ${String(err)}`); + }, + }); + + await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + skillFilter, + }, + }); + markDispatchIdle(); + + if (removeAckAfterReply && ackReactionPromise) { + const didAck = await ackReactionPromise; + if (didAck) { + await client + .sendReaction({ + chatId: isGroup && chatId != null ? chatId : senderPeer, + message: msg.id, + emoji: null, + }) + .catch((err) => { + runtime.error?.(`telegram-user ack reaction cleanup failed: ${String(err)}`); + }); + } + } + } 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..4e3b2a5c3 --- /dev/null +++ b/extensions/telegram-user/src/monitor/index.ts @@ -0,0 +1,162 @@ +import fs from "node:fs"; +import type { RuntimeEnv } from "openclaw/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"; + +type MtcuteDispatcher = typeof import("@mtcute/dispatcher"); + +let mtcuteDispatcherPromise: Promise | null = null; + +async function loadMtcuteDispatcher(): Promise { + mtcuteDispatcherPromise ??= import("@mtcute/dispatcher"); + return mtcuteDispatcherPromise; +} + +function isDestroyedClientError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /client is destroyed/i.test(message); +} + +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; + + let shuttingDown = false; + + 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 `openclaw channels login --channel telegram-user` first.", + ); + } + const client = await createTelegramUserClient({ apiId, apiHash, storagePath }); + let stopped = false; + + const stop = async () => { + if (stopped) return; + stopped = true; + shuttingDown = true; + setActiveTelegramUserClient(account.accountId, null); + await client.destroy().catch(() => undefined); + }; + + opts.abortSignal?.addEventListener( + "abort", + () => { + shuttingDown = true; + void stop(); + }, + { once: true }, + ); + + try { + await client.start(); + setActiveTelegramUserClient(account.accountId, client); + + const { Dispatcher, filters } = await loadMtcuteDispatcher(); + const dispatcher = Dispatcher.for(client); + const self = await client.getMe().catch(() => undefined); + const selfName = + self && typeof (self as unknown as { displayName?: unknown }).displayName === "string" + ? (self as unknown as { displayName: string }).displayName + : self && typeof (self as unknown as { firstName?: unknown }).firstName === "string" + ? [ + (self as unknown as { firstName?: string }).firstName, + typeof (self as unknown as { lastName?: unknown }).lastName === "string" + ? (self as unknown as { lastName: string }).lastName + : undefined, + ] + .filter((entry): entry is string => Boolean(entry && entry.trim())) + .join(" ") + : undefined; + const handleMessage = createTelegramUserMessageHandler({ + client, + cfg, + runtime, + accountId: account.accountId, + accountConfig: account.config, + abortSignal: opts.abortSignal, + self: self + ? { + id: self.id, + username: "username" in self ? self.username : undefined, + name: selfName, + } + : undefined, + }); + + dispatcher.onNewMessage( + filters.or( + filters.chat("user"), + filters.chat("group"), + filters.chat("supergroup"), + filters.chat("gigagroup"), + ), + handleMessage, + ); + + await new Promise((resolve, reject) => { + let settled = false; + const settleResolve = () => { + if (settled) return; + settled = true; + resolve(); + }; + const settleReject = (err: unknown) => { + if (settled) return; + settled = true; + reject(err); + }; + + client.onError.add((err) => { + if (shuttingDown || opts.abortSignal?.aborted || isDestroyedClientError(err)) { + settleResolve(); + return; + } + runtime.error?.(`telegram-user client error: ${String(err)}`); + settleReject(err); + }); + if (opts.abortSignal?.aborted) { + settleResolve(); + return; + } + opts.abortSignal?.addEventListener("abort", () => settleResolve(), { once: true }); + }); + } finally { + await stop(); + } +} diff --git a/extensions/telegram-user/src/onboarding.ts b/extensions/telegram-user/src/onboarding.ts new file mode 100644 index 000000000..923ea709f --- /dev/null +++ b/extensions/telegram-user/src/onboarding.ts @@ -0,0 +1,375 @@ +import { + addWildcardAllowFrom, + formatDocsLink, + promptAccountId, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, + type OpenClawConfig, + type DmPolicy, + type WizardPrompter, +} from "openclaw/plugin-sdk"; + +import { + listTelegramUserAccountIds, + resolveDefaultTelegramUserAccountId, + resolveTelegramUserAccount, +} from "./accounts.js"; +import { loginTelegramUser } from "./login.js"; +import { resolveTelegramUserSessionPath } from "./session.js"; +import type { CoreConfig } from "./types.js"; + +const channel = "telegram-user" as const; +type TelegramUserChannelConfig = NonNullable["telegram-user"]; + +function setTelegramUserDmPolicy( + cfg: OpenClawConfig, + policy: DmPolicy, + accountId?: string, +): OpenClawConfig { + const resolvedAccountId = normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID; + const current = cfg.channels?.["telegram-user"] as TelegramUserChannelConfig | undefined; + const allowFrom = + policy === "open" + ? addWildcardAllowFrom(current?.allowFrom) + : undefined; + + if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + "telegram-user": { + ...(current ?? {}), + dmPolicy: policy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; + } + + return { + ...cfg, + channels: { + ...cfg.channels, + "telegram-user": { + ...(current ?? {}), + accounts: { + ...(current?.accounts ?? {}), + [resolvedAccountId]: { + ...(current?.accounts?.[resolvedAccountId] ?? {}), + dmPolicy: policy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }, + }, + }; +} + +async function noteTelegramUserAuthHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "Telegram User (MTProto) needs an API ID + API hash from my.telegram.org.", + "You can store them in config or set TELEGRAM_USER_API_ID/TELEGRAM_USER_API_HASH.", + "Login happens via `openclaw channels login --channel telegram-user`.", + `Docs: ${formatDocsLink("/channels/telegram-user", "channels/telegram-user")}`, + ].join("\n"), + "Telegram user setup", + ); +} + +function parseAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => + entry + .trim() + .replace(/^(telegram-user|telegram|tg):/i, "") + .replace(/^user:/i, "") + .trim(), + ) + .filter(Boolean); +} + +async function promptTelegramUserAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID; + const resolved = resolveTelegramUserAccount({ + cfg: params.cfg as CoreConfig, + accountId, + }); + const existingAllowFrom = resolved.config.allowFrom ?? []; + + const entry = await params.prompter.text({ + message: "Telegram user allowFrom (user id or @username)", + placeholder: "@username", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + + const parsed = parseAllowFromInput(String(entry)); + const merged = [ + ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), + ...parsed, + ]; + const unique = [...new Set(merged)]; + const current = params.cfg.channels?.["telegram-user"] as TelegramUserChannelConfig | undefined; + + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + "telegram-user": { + ...(current ?? {}), + enabled: true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }; + } + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + "telegram-user": { + ...(current ?? {}), + enabled: true, + accounts: { + ...(current?.accounts ?? {}), + [accountId]: { + ...(current?.accounts?.[accountId] ?? {}), + enabled: true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Telegram User", + channel, + policyKey: "channels.telegram-user.dmPolicy", + allowFromKey: "channels.telegram-user.allowFrom", + getCurrent: (cfg) => + (cfg as CoreConfig).channels?.["telegram-user"]?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setTelegramUserDmPolicy(cfg, policy), + promptAllowFrom: async ({ cfg, prompter, accountId }) => + await promptTelegramUserAllowFrom({ cfg, prompter, accountId }), +}; + +export const telegramUserOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const configured = listTelegramUserAccountIds(cfg as CoreConfig).some((accountId) => { + const resolved = resolveTelegramUserAccount({ cfg: cfg as CoreConfig, accountId }); + return Boolean(resolved.credentials.apiId && resolved.credentials.apiHash); + }); + return { + channel, + configured, + statusLines: [ + `Telegram User: ${configured ? "configured" : "needs API ID + API hash"}`, + ], + selectionHint: configured ? "configured" : "needs credentials", + }; + }, + configure: async ({ + cfg, + runtime, + prompter, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom, + }) => { + const override = accountOverrides["telegram-user"]?.trim(); + const defaultAccountId = resolveDefaultTelegramUserAccountId(cfg as CoreConfig); + let accountId = override ? normalizeAccountId(override) : defaultAccountId; + if (shouldPromptAccountIds && !override) { + accountId = await promptAccountId({ + cfg: cfg as OpenClawConfig, + prompter, + label: "Telegram User", + currentId: accountId ?? defaultAccountId, + listAccountIds: (next) => listTelegramUserAccountIds(next as CoreConfig), + defaultAccountId, + }); + } + const resolvedAccountId = normalizeAccountId(accountId) ?? defaultAccountId; + + let next = cfg as CoreConfig; + const resolved = resolveTelegramUserAccount({ + cfg: next, + accountId: resolvedAccountId, + }); + const configured = Boolean(resolved.credentials.apiId && resolved.credentials.apiHash); + + if (!configured) { + await noteTelegramUserAuthHelp(prompter); + } + + const envApiId = process.env.TELEGRAM_USER_API_ID?.trim(); + const envApiHash = process.env.TELEGRAM_USER_API_HASH?.trim(); + const canUseEnv = + resolvedAccountId === DEFAULT_ACCOUNT_ID && Boolean(envApiId && envApiHash); + const hasConfig = Boolean(resolved.config.apiId && resolved.config.apiHash); + + let useEnv = false; + if (canUseEnv && !hasConfig) { + useEnv = await prompter.confirm({ + message: "Telegram user env vars detected. Use env values?", + initialValue: true, + }); + } + + let apiId = resolved.config.apiId; + let apiHash = resolved.config.apiHash; + if (!useEnv && (!apiId || !apiHash)) { + if (configured) { + const keep = await prompter.confirm({ + message: "Telegram user credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + apiId = undefined; + apiHash = undefined; + } + } + if (!apiId || !apiHash) { + const apiIdRaw = String( + await prompter.text({ + message: "Telegram API ID", + initialValue: apiId ? String(apiId) : envApiId, + validate: (value) => + Number.isFinite(Number.parseInt(String(value ?? ""), 10)) + ? undefined + : "Enter a numeric API ID", + }), + ); + apiId = Number.parseInt(apiIdRaw, 10); + apiHash = String( + await prompter.text({ + message: "Telegram API hash", + initialValue: apiHash ?? envApiHash, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim(); + } + } + + const current = next.channels?.["telegram-user"] as TelegramUserChannelConfig | undefined; + if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + channels: { + ...next.channels, + "telegram-user": { + ...(current ?? {}), + enabled: true, + ...(useEnv + ? {} + : { + apiId, + apiHash, + }), + }, + }, + }; + } else { + next = { + ...next, + channels: { + ...next.channels, + "telegram-user": { + ...(current ?? {}), + enabled: true, + accounts: { + ...(current?.accounts ?? {}), + [resolvedAccountId]: { + ...(current?.accounts?.[resolvedAccountId] ?? {}), + enabled: true, + ...(useEnv + ? {} + : { + apiId, + apiHash, + }), + }, + }, + }, + }, + }; + } + + if (forceAllowFrom) { + next = await promptTelegramUserAllowFrom({ + cfg: next, + prompter, + accountId: resolvedAccountId, + }); + } + + const wantsLogin = await prompter.confirm({ + message: "Link Telegram user now (QR or phone code)?", + initialValue: !configured, + }); + if (wantsLogin) { + const refreshed = resolveTelegramUserAccount({ + cfg: next, + accountId: resolvedAccountId, + }); + if (!refreshed.credentials.apiId || !refreshed.credentials.apiHash) { + await prompter.note( + "Telegram API ID/hash missing. Add credentials first, then retry login.", + "Telegram user login", + ); + } else { + try { + await loginTelegramUser({ + apiId: refreshed.credentials.apiId, + apiHash: refreshed.credentials.apiHash, + storagePath: resolveTelegramUserSessionPath(resolvedAccountId), + runtime, + }); + } catch (err) { + runtime.error(`Telegram user login failed: ${String(err)}`); + await prompter.note( + `Run \`openclaw channels login --channel telegram-user\` later to link.`, + "Telegram user login", + ); + } + } + } else { + await prompter.note( + [ + "Next: link the account via QR or phone code.", + "Run: openclaw channels login --channel telegram-user", + ].join("\n"), + "Telegram user login", + ); + } + + return { cfg: next, accountId: resolvedAccountId }; + }, + dmPolicy, + disable: (cfg) => ({ + ...(cfg as CoreConfig), + channels: { + ...(cfg as CoreConfig).channels, + "telegram-user": { + ...(cfg as CoreConfig).channels?.["telegram-user"], + enabled: false, + }, + }, + }), +}; diff --git a/extensions/telegram-user/src/runtime.ts b/extensions/telegram-user/src/runtime.ts new file mode 100644 index 000000000..54b4733a0 --- /dev/null +++ b/extensions/telegram-user/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/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.test.ts b/extensions/telegram-user/src/send.test.ts new file mode 100644 index 000000000..ea47c58e0 --- /dev/null +++ b/extensions/telegram-user/src/send.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadWebMedia = vi.fn(); + +vi.mock("./runtime.js", () => { + return { + getTelegramUserRuntime: () => ({ + config: { loadConfig: () => ({}) }, + media: { + loadWebMedia: (...args: unknown[]) => loadWebMedia(...args), + }, + }), + }; +}); + +const inputMediaAuto = vi.fn((file: unknown, params: unknown) => ({ + type: "auto", + file, + ...(params && typeof params === "object" ? params : {}), +})); +const inputMediaVoice = vi.fn((file: unknown, params: unknown) => ({ + type: "voice", + file, + ...(params && typeof params === "object" ? params : {}), +})); + +vi.mock("@mtcute/core", () => { + return { + InputMedia: { + auto: (...args: unknown[]) => inputMediaAuto(...args), + voice: (...args: unknown[]) => inputMediaVoice(...args), + poll: () => ({ type: "poll" }), + }, + }; +}); + +describe("telegram-user send", () => { + beforeEach(() => { + loadWebMedia.mockReset(); + inputMediaAuto.mockClear(); + inputMediaVoice.mockClear(); + }); + + it("sends audio media as voice note when audioAsVoice is set", async () => { + loadWebMedia.mockResolvedValue({ + buffer: Buffer.from("voice"), + contentType: "audio/ogg", + fileName: "note.ogg", + }); + + const sendMedia = vi.fn(async () => ({ id: 123 })); + const { sendMediaTelegramUser } = await import("./send.js"); + await sendMediaTelegramUser("telegram-user:123", "hi", { + client: { sendMedia } as unknown as import("@mtcute/node").TelegramClient, + mediaUrl: "https://example.com/note.ogg", + audioAsVoice: true, + }); + + expect(inputMediaVoice).toHaveBeenCalledTimes(1); + expect(sendMedia).toHaveBeenCalledTimes(1); + const [, media] = sendMedia.mock.calls[0] ?? []; + expect(media).toMatchObject({ type: "voice" }); + }); + + it("falls back to normal media when audioAsVoice is set but media is not voice-compatible", async () => { + loadWebMedia.mockResolvedValue({ + buffer: Buffer.from("img"), + contentType: "image/png", + fileName: "image.png", + }); + + const sendMedia = vi.fn(async () => ({ id: 123 })); + const { sendMediaTelegramUser } = await import("./send.js"); + await sendMediaTelegramUser("telegram-user:123", "hi", { + client: { sendMedia } as unknown as import("@mtcute/node").TelegramClient, + mediaUrl: "https://example.com/image.png", + audioAsVoice: true, + }); + + expect(inputMediaVoice).toHaveBeenCalledTimes(0); + expect(inputMediaAuto).toHaveBeenCalledTimes(1); + }); + + it("falls back to auto when voice messages are forbidden", async () => { + loadWebMedia.mockResolvedValue({ + buffer: Buffer.from("voice"), + contentType: "audio/ogg", + fileName: "note.ogg", + }); + + const sendMedia = vi.fn(async (_to: unknown, media: unknown) => { + if (media && typeof media === "object" && (media as { type?: string }).type === "voice") { + throw new Error("VOICE_MESSAGES_FORBIDDEN"); + } + return { id: 123 }; + }); + + const { sendMediaTelegramUser } = await import("./send.js"); + await sendMediaTelegramUser("telegram-user:123", "hi", { + client: { sendMedia } as unknown as import("@mtcute/node").TelegramClient, + mediaUrl: "https://example.com/note.ogg", + audioAsVoice: true, + }); + + expect(inputMediaVoice).toHaveBeenCalledTimes(1); + expect(inputMediaAuto).toHaveBeenCalledTimes(1); + expect(sendMedia).toHaveBeenCalledTimes(2); + }); +}); + diff --git a/extensions/telegram-user/src/send.ts b/extensions/telegram-user/src/send.ts new file mode 100644 index 000000000..126b88bc1 --- /dev/null +++ b/extensions/telegram-user/src/send.ts @@ -0,0 +1,301 @@ +import fs from "node:fs"; +import type { TelegramClient } from "@mtcute/node"; +import type { PollInput } from "openclaw/plugin-sdk"; + +import { getTelegramUserRuntime } from "./runtime.js"; +import { resolveTelegramUserAccount } from "./accounts.js"; +import { createTelegramUserClient } from "./client.js"; +import { resolveTelegramUserSessionPath } from "./session.js"; +import { getActiveTelegramUserClient } from "./active-client.js"; +import type { CoreConfig } from "./types.js"; + +export type TelegramUserSendResult = { + messageId: string; + chatId: string; +}; + +type MtcuteCore = typeof import("@mtcute/core"); + +let mtcuteCorePromise: Promise | null = null; + +async function loadMtcuteCore(): Promise { + mtcuteCorePromise ??= import("@mtcute/core"); + return mtcuteCorePromise; +} + +type NormalizedPollInput = { + question: string; + options: string[]; + maxSelections: number; +}; + +function isDestroyedClientError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /client is destroyed/i.test(message); +} + +export type TelegramUserSendOpts = { + client?: TelegramClient; + accountId?: string; + replyToId?: number; + threadId?: string | number | null; + mediaUrl?: string; + audioAsVoice?: boolean; +}; + +function normalizeTarget(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) throw new Error("Recipient is required for Telegram User sends"); + const withoutProvider = trimmed.replace(/^(telegram-user|telegram|tg):/i, "").trim(); + const withoutPrefix = withoutProvider.replace(/^(user|group|channel|chat):/i, "").trim(); + if (!withoutPrefix) throw new Error("Recipient is required for Telegram User sends"); + return withoutPrefix; +} + +function parseThreadId(value: string | number | null | undefined): number | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? Math.trunc(value) : undefined; + } + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) return undefined; + const parsed = Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function resolveTargetAndThread(raw: string, threadId?: string | number | null) { + const normalized = normalizeTarget(raw); + const [base, topicRaw] = normalized.split(/:topic:/i); + const parsedThreadId = parseThreadId(threadId ?? topicRaw); + const target = (base ?? normalized).trim(); + if (!target) throw new Error("Recipient is required for Telegram User sends"); + return { target, threadId: parsedThreadId }; +} + +function isVoiceMessagesForbidden(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /VOICE_MESSAGES_FORBIDDEN/i.test(message); +} + +function shouldSendAsVoice(params: { + wantsVoice: boolean; + contentType?: string | null; + fileName?: string | null; +}): boolean { + if (!params.wantsVoice) return false; + const contentType = params.contentType?.toLowerCase() ?? ""; + const fileName = params.fileName?.toLowerCase() ?? ""; + if (/(^|\/)(ogg|opus)(;|$)/.test(contentType)) return true; + if (/\.(ogg|opus|oga)$/.test(fileName)) return true; + return false; +} + +export function normalizeTelegramUserMessagingTarget(raw: string): string { + return normalizeTarget(raw); +} + +export function looksLikeTelegramUserTargetId(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return false; + if (/^telegram-user:/i.test(trimmed)) return true; + if (/^(user|group|channel|chat):/i.test(trimmed)) return true; + if (/^-?\d+:topic:\d+$/i.test(trimmed)) return true; + 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; +} + +function normalizePollInput(input: PollInput): NormalizedPollInput { + const question = input.question.trim(); + if (!question) { + throw new Error("Poll question is required"); + } + const options = (input.options ?? []).map((option) => option.trim()).filter(Boolean); + if (options.length < 2) { + throw new Error("Poll requires at least 2 options"); + } + if (options.length > 10) { + throw new Error("Poll supports at most 10 options"); + } + const maxSelectionsRaw = input.maxSelections; + const maxSelections = + typeof maxSelectionsRaw === "number" && Number.isFinite(maxSelectionsRaw) + ? Math.floor(maxSelectionsRaw) + : 1; + if (maxSelections < 1) { + throw new Error("maxSelections must be at least 1"); + } + if (maxSelections > options.length) { + throw new Error("maxSelections cannot exceed option count"); + } + return { question, options, maxSelections }; +} + +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 active = getActiveTelegramUserClient(account.accountId); + if (active) return { client: active, stopOnDone: false }; + 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 `openclaw channels login --channel telegram-user` first.", + ); + } + const client = await 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 resolved = resolveTargetAndThread(to, opts.threadId); + const target = resolveTelegramUserPeer(resolved.target); + let message: Awaited> | null = null; + try { + message = await client.sendText(target, text, { + ...(opts.replyToId ? { replyTo: opts.replyToId } : {}), + ...(resolved.threadId ? { threadId: resolved.threadId } : {}), + }); + } catch (err) { + if (!isDestroyedClientError(err)) throw err; + } + if (!message) { + return { messageId: "", chatId: String(target) }; + } + 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 { InputMedia } = await loadMtcuteCore(); + const resolved = resolveTargetAndThread(to, opts.threadId); + const target = resolveTelegramUserPeer(resolved.target); + const media = await getTelegramUserRuntime().media.loadWebMedia(opts.mediaUrl, opts.maxBytes); + const wantsVoice = shouldSendAsVoice({ + wantsVoice: opts.audioAsVoice === true, + contentType: media.contentType, + fileName: media.fileName, + }); + const buildAuto = () => + InputMedia.auto(media.buffer, { + fileName: media.fileName ?? undefined, + fileMime: media.contentType, + caption: text, + }); + const buildVoice = () => + InputMedia.voice(media.buffer, { + fileName: media.fileName ?? undefined, + fileMime: media.contentType, + caption: text, + }); + const input = wantsVoice ? buildVoice() : buildAuto(); + let message: Awaited> | null = null; + try { + message = await client.sendMedia(target, input, { + ...(opts.replyToId ? { replyTo: opts.replyToId } : {}), + ...(resolved.threadId ? { threadId: resolved.threadId } : {}), + }); + } catch (err) { + if (wantsVoice && isVoiceMessagesForbidden(err)) { + message = await client.sendMedia(target, buildAuto(), { + ...(opts.replyToId ? { replyTo: opts.replyToId } : {}), + ...(resolved.threadId ? { threadId: resolved.threadId } : {}), + }); + } else if (!isDestroyedClientError(err)) { + throw err; + } + } + if (!message) { + return { messageId: "", chatId: String(target) }; + } + return { messageId: String(message.id), chatId: String(target) }; + } finally { + if (stopOnDone) { + await client.destroy(); + } + } +} + +export async function sendPollTelegramUser( + to: string, + poll: PollInput, + opts: TelegramUserSendOpts = {}, +): Promise { + const cfg = getTelegramUserRuntime().config.loadConfig() as CoreConfig; + const { client, stopOnDone } = await resolveClient({ + client: opts.client, + cfg, + accountId: opts.accountId, + }); + try { + const { InputMedia } = await loadMtcuteCore(); + const resolved = resolveTargetAndThread(to, opts.threadId); + const target = resolveTelegramUserPeer(resolved.target); + const normalized = normalizePollInput(poll); + const input = InputMedia.poll({ + question: normalized.question, + answers: normalized.options, + multiple: normalized.maxSelections > 1, + }); + let message: Awaited> | null = null; + try { + message = await client.sendMedia(target, input, { + ...(opts.replyToId ? { replyTo: opts.replyToId } : {}), + ...(resolved.threadId ? { threadId: resolved.threadId } : {}), + }); + } catch (err) { + if (!isDestroyedClientError(err)) throw err; + } + if (!message) { + return { messageId: "", chatId: String(target) }; + } + 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..6761cf917 --- /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 "openclaw/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..1d6d6953a --- /dev/null +++ b/extensions/telegram-user/src/types.ts @@ -0,0 +1,67 @@ +import type { DmPolicy, GroupPolicy, GroupToolPolicyConfig } from "openclaw/plugin-sdk"; + +export type TelegramUserTopicConfig = { + requireMention?: boolean; + skills?: string[]; + enabled?: boolean; + allowFrom?: Array; + systemPrompt?: string; +}; + +export type TelegramUserGroupConfig = { + requireMention?: boolean; + skills?: string[]; + tools?: GroupToolPolicyConfig; + topics?: Record; + enabled?: boolean; + allowFrom?: Array; + systemPrompt?: string; +}; + +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; + /** Control reply threading when reply tags are present (off|first|all). */ + replyToMode?: "off" | "first" | "all"; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Max outbound media size in MB. */ + mediaMaxMb?: number; + /** Optional allowlist for Telegram group senders (user ids or usernames). */ + groupAllowFrom?: Array; + /** Controls how group messages are handled (open | disabled | allowlist). */ + groupPolicy?: GroupPolicy; + /** Group-specific overrides (keyed by chat id). */ + groups?: Record; +}; + +export type TelegramUserConfig = TelegramUserAccountConfig & { + accounts?: Record; +}; + +export type CoreConfig = { + channels?: { + defaults?: { + groupPolicy?: GroupPolicy; + }; + "telegram-user"?: TelegramUserConfig; + }; + commands?: { + useAccessGroups?: boolean; + }; + messages?: { + ackReactionScope?: "off" | "group-mentions" | "group-all" | "direct" | "all"; + removeAckAfterReply?: boolean; + }; + [key: string]: unknown; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95b940c97..ebe17aee5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,6 +415,31 @@ importers: extensions/telegram: {} + extensions/telegram-user: + dependencies: + '@clack/prompts': + specifier: ^0.8.2 + version: 0.8.2 + '@mtcute/core': + specifier: ^0.27.6 + version: 0.27.6 + '@mtcute/dispatcher': + specifier: ^0.27.6 + version: 0.27.6 + '@mtcute/node': + specifier: ^0.27.6 + version: 0.27.6(ws@8.19.0) + qrcode-terminal: + specifier: ^0.12.0 + version: 0.12.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + openclaw: + specifier: workspace:* + version: link:../.. + extensions/tlon: dependencies: '@urbit/aura': @@ -832,12 +857,18 @@ packages: '@cacheable/utils@2.3.3': resolution: {integrity: sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==} + '@clack/core@0.3.5': + resolution: {integrity: sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==} + '@clack/core@0.5.0': resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} '@clack/prompts@0.11.0': resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@clack/prompts@0.8.2': + resolution: {integrity: sha512-6b9Ab2UiZwJYA9iMyboYyW9yJvAO9V753ZhS+DHKEjZRKAxPPOb7MXXu84lsPFG+vZt6FRFniZ8rXi+zCIw4yQ==} + '@cloudflare/workers-types@4.20260120.0': resolution: {integrity: sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==} @@ -1046,6 +1077,26 @@ packages: '@eshaz/web-worker@1.2.2': resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==} + '@fuman/io@0.0.17': + resolution: {integrity: sha512-VmMnfHtXzBfEddEfptn/oYshUzWqW2XUkdVnwKuHWphEQTQZrOWxC7G12FI9U2EhEYt4nRdrUTYk65U8GVJWYw==} + + '@fuman/net@0.0.17': + resolution: {integrity: sha512-x/kK3kWQ+gy5rfsoS6QVCsodh9n/XJeM3c6m1YHPUiQ0gWWQd4CC1bcQ/rh2UHh9DQyJJeWjCQXWH2xmsVCcFQ==} + + '@fuman/node@0.0.17': + resolution: {integrity: sha512-XXRlJthuCnJBnIrg/tZcqCfv/cPuXuNOVUN521oJgKrW8FyFmt+lAt2MlYw3TROumGNRMtvn3ySjdQRpBT2sLw==} + peerDependencies: + ws: ^8.18.1 + peerDependenciesMeta: + ws: + optional: true + + '@fuman/utils@0.0.15': + resolution: {integrity: sha512-3H3WzkfG7iLKCa/yNV4s80lYD4yr5hgiNzU13ysLY2BcDqFjM08XGYuLd5wFVp4V8+DA/fe8gIDW96To/JwDyA==} + + '@fuman/utils@0.0.17': + resolution: {integrity: sha512-hy1Xu1146nOspVam8FC6p4yakb1FV1V3KrS85RzcHiK7AccFKR43Fgtv8exC8Ybsw6MtMU+MRNyaPqVhA+7TsA==} + '@glideapps/ts-necessities@2.2.3': resolution: {integrity: sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==} @@ -1493,6 +1544,33 @@ packages: resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} + '@mtcute/core@0.27.6': + resolution: {integrity: sha512-OqjQ2hchF15yJAjcAgBuWx4RCnvMED0P3kiUfU3EsDMMIJlh8TgOgm0QspIda/Uz7icZ5+pi0rHyCYblw2MMKw==} + + '@mtcute/dispatcher@0.27.6': + resolution: {integrity: sha512-5ZmI5cmyeWVYY5BtPlGYB0b4oxmF86xiP/c1Wo3VQ0SElYuknf+xZDlFxt6AMlHk9d/v2rxEd+dBXB5kRczpUA==} + + '@mtcute/file-id@0.27.6': + resolution: {integrity: sha512-ZSPxbGjS6YdcZv4xW0zHJ/iR28nEBisG3G6gDTwVS4gU51SJ4vlGcwGjF1uLyEeuGGbPFemQHLCP6CMSzqMRvA==} + + '@mtcute/html-parser@0.27.6': + resolution: {integrity: sha512-zxTuT0nv0CBR4qy7KyKB9vGQ++DxeiofKJEwHFSj5oG/7qUARm21G5GZaVlel/v7oRzx6V3u9mKDzdlbv8BcxA==} + + '@mtcute/markdown-parser@0.27.6': + resolution: {integrity: sha512-YB4HXeDGQi+ilbOp1qDJ/iP3VfBFrsR+gEyQcaQo/PAR4NLtD+rZ5veWM/OSVjbawYl2OpFpfXzQdINAAlaEJg==} + + '@mtcute/node@0.27.6': + resolution: {integrity: sha512-fDnufwcRJyqMr7rpCIiSW6GIRR1j+tgM8Og1Rx38U16Ftmu3gB7Xt5K7lHJJWQHDhv59w8AiU+NksiPdTXbxxQ==} + + '@mtcute/tl-runtime@0.24.3': + resolution: {integrity: sha512-61J3cgYgNOQT532GdIiuezRrSC7v6cc9MfvWv9GO27bRGf7JUKWVbFt4U0KzQ9Tp0J1uMOUfi1EbQKkYKacIKQ==} + + '@mtcute/tl@221.0.0': + resolution: {integrity: sha512-Wp01L9nznTMLl2s9rbKnzQ8pij72eF4HK2XIziOQoJXiObPKZxQdxvMj+C6l0ArxMFmpT0H0/3EL5RB7O6VPwg==} + + '@mtcute/wasm@0.27.0': + resolution: {integrity: sha512-1v4eO1N1BVRQ8L+cyUsMAeLXs5suTGXyVv/tftkbd/mGGHxc+fvOWItp3Fmq+GIwN7m4VX7kztuMMLhHxv2i2Q==} + '@napi-rs/canvas-android-arm64@0.1.88': resolution: {integrity: sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==} engines: {node: '>= 10'} @@ -2701,6 +2779,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/events@3.0.0': + resolution: {integrity: sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==} + '@types/express-serve-static-core@4.19.8': resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} @@ -3100,6 +3181,10 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -3107,6 +3192,12 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -3150,6 +3241,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -3201,6 +3295,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -3374,6 +3471,10 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -3463,6 +3564,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -3531,6 +3635,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3590,6 +3698,9 @@ packages: resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} engines: {node: '>=20'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + filename-reserved-regex@3.0.0: resolution: {integrity: sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3661,6 +3772,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@11.3.3: resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} @@ -3713,6 +3827,9 @@ packages: getpass@0.1.7: resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -4201,6 +4318,9 @@ packages: long@4.0.0: resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + long@5.2.3: + resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -4323,6 +4443,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -4348,6 +4472,9 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -4390,6 +4517,9 @@ packages: engines: {node: ^18 || >=20} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -4398,6 +4528,10 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} + engines: {node: '>=10'} + node-addon-api@8.5.0: resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} engines: {node: ^18 || ^20 || >= 21} @@ -4723,6 +4857,11 @@ packages: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + pretty-bytes@6.1.1: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} @@ -4787,6 +4926,9 @@ packages: psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -5030,6 +5172,12 @@ packages: peerDependencies: signal-polyfill: ^0.2.0 + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-git@3.30.0: resolution: {integrity: sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg==} @@ -5191,6 +5339,13 @@ packages: tailwindcss@4.1.17: resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar@7.5.4: resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==} engines: {node: '>=18'} @@ -6472,6 +6627,11 @@ snapshots: hashery: 1.4.0 keyv: 5.6.0 + '@clack/core@0.3.5': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@clack/core@0.5.0': dependencies: picocolors: 1.1.1 @@ -6483,6 +6643,12 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 + '@clack/prompts@0.8.2': + dependencies: + '@clack/core': 0.3.5 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@cloudflare/workers-types@4.20260120.0': optional: true @@ -6647,6 +6813,27 @@ snapshots: '@eshaz/web-worker@1.2.2': optional: true + '@fuman/io@0.0.17': + dependencies: + '@fuman/utils': 0.0.17 + + '@fuman/net@0.0.17': + dependencies: + '@fuman/io': 0.0.17 + '@fuman/utils': 0.0.17 + + '@fuman/node@0.0.17(ws@8.19.0)': + dependencies: + '@fuman/io': 0.0.17 + '@fuman/net': 0.0.17 + '@fuman/utils': 0.0.17 + optionalDependencies: + ws: 8.19.0 + + '@fuman/utils@0.0.15': {} + + '@fuman/utils@0.0.17': {} + '@glideapps/ts-necessities@2.2.3': {} '@google/genai@1.34.0': @@ -7113,6 +7300,63 @@ snapshots: '@mozilla/readability@0.6.0': {} + '@mtcute/core@0.27.6': + dependencies: + '@fuman/io': 0.0.17 + '@fuman/net': 0.0.17 + '@fuman/utils': 0.0.17 + '@mtcute/file-id': 0.27.6 + '@mtcute/tl': 221.0.0 + '@mtcute/tl-runtime': 0.24.3 + '@types/events': 3.0.0 + long: 5.2.3 + + '@mtcute/dispatcher@0.27.6': + dependencies: + '@fuman/utils': 0.0.17 + '@mtcute/core': 0.27.6 + + '@mtcute/file-id@0.27.6': + dependencies: + '@fuman/utils': 0.0.17 + '@mtcute/tl-runtime': 0.24.3 + long: 5.2.3 + + '@mtcute/html-parser@0.27.6': + dependencies: + '@mtcute/core': 0.27.6 + htmlparser2: 10.1.0 + long: 5.2.3 + + '@mtcute/markdown-parser@0.27.6': + dependencies: + '@mtcute/core': 0.27.6 + long: 5.2.3 + + '@mtcute/node@0.27.6(ws@8.19.0)': + dependencies: + '@fuman/net': 0.0.17 + '@fuman/node': 0.0.17(ws@8.19.0) + '@fuman/utils': 0.0.17 + '@mtcute/core': 0.27.6 + '@mtcute/html-parser': 0.27.6 + '@mtcute/markdown-parser': 0.27.6 + '@mtcute/wasm': 0.27.0 + better-sqlite3: 12.6.2 + transitivePeerDependencies: + - ws + + '@mtcute/tl-runtime@0.24.3': + dependencies: + '@fuman/utils': 0.0.15 + long: 5.2.3 + + '@mtcute/tl@221.0.0': + dependencies: + long: 5.2.3 + + '@mtcute/wasm@0.27.0': {} + '@napi-rs/canvas-android-arm64@0.1.88': optional: true @@ -8464,6 +8708,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/events@3.0.0': {} + '@types/express-serve-static-core@4.19.8': dependencies: '@types/node': 25.0.10 @@ -8955,10 +9201,25 @@ snapshots: before-after-hook@4.0.0: optional: true + better-sqlite3@12.6.2: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bignumber.js@9.3.1: {} binary-extensions@2.3.0: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + bluebird@3.7.2: {} body-parser@1.20.4: @@ -9018,6 +9279,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -9082,6 +9348,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: {} + chownr@3.0.0: {} chromium-bidi@13.0.1(devtools-protocol@0.0.1561482): @@ -9252,8 +9520,11 @@ snapshots: dependencies: ms: 2.1.3 - deep-extend@0.6.0: - optional: true + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} deepmerge@4.3.1: {} @@ -9330,6 +9601,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@4.5.0: {} entities@7.0.1: {} @@ -9403,6 +9678,8 @@ snapshots: events@3.3.0: {} + expand-template@2.0.3: {} + expect-type@1.3.0: {} express@4.22.1: @@ -9521,6 +9798,8 @@ snapshots: transitivePeerDependencies: - supports-color + file-uri-to-path@1.0.0: {} + filename-reserved-regex@3.0.0: optional: true @@ -9606,6 +9885,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fs-extra@11.3.3: dependencies: graceful-fs: 4.2.11 @@ -9680,6 +9961,8 @@ snapshots: dependencies: assert-plus: 1.0.0 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -9860,8 +10143,7 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: - optional: true + ini@1.3.8: {} ipaddr.js@1.9.1: {} @@ -10204,6 +10486,8 @@ snapshots: long@4.0.0: {} + long@5.2.3: {} + long@5.3.2: {} lowdb@1.0.0: @@ -10308,6 +10592,8 @@ snapshots: mimic-function@5.0.1: optional: true + mimic-response@3.1.0: {} + minimalistic-assert@1.0.1: {} minimatch@10.1.1: @@ -10318,8 +10604,7 @@ snapshots: dependencies: brace-expansion: 2.0.2 - minimist@1.2.8: - optional: true + minimist@1.2.8: {} minipass@7.1.2: {} @@ -10329,6 +10614,8 @@ snapshots: mitt@3.0.1: {} + mkdirp-classic@0.5.3: {} + mkdirp@3.0.1: {} module-details-from-path@1.0.4: {} @@ -10380,10 +10667,16 @@ snapshots: nanoid@5.1.6: optional: true + napi-build-utils@2.0.0: {} + negotiator@0.6.3: {} negotiator@1.0.0: {} + node-abi@3.87.0: + dependencies: + semver: 7.7.3 + node-addon-api@8.5.0: optional: true @@ -10749,6 +11042,21 @@ snapshots: postgres@3.4.8: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.87.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + pretty-bytes@6.1.1: optional: true @@ -10834,6 +11142,11 @@ snapshots: dependencies: punycode: 2.3.1 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -10900,7 +11213,6 @@ snapshots: ini: 1.3.8 minimist: 1.2.8 strip-json-comments: 2.0.1 - optional: true readable-stream@2.3.8: dependencies: @@ -10917,7 +11229,6 @@ snapshots: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 - optional: true readable-stream@4.5.2: dependencies: @@ -11225,6 +11536,14 @@ snapshots: dependencies: signal-polyfill: 0.2.2 + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-git@3.30.0: dependencies: '@kwsites/file-exists': 1.1.1 @@ -11365,8 +11684,7 @@ snapshots: dependencies: ansi-regex: 6.2.2 - strip-json-comments@2.0.1: - optional: true + strip-json-comments@2.0.1: {} strnum@2.1.2: {} @@ -11393,6 +11711,21 @@ snapshots: tailwindcss@4.1.17: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar@7.5.4: dependencies: '@isaacs/fs-minipass': 4.0.1 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,