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