Add Telegram and Discord IM channels

This commit is contained in:
xmanrui 2026-08-16 14:47:16 +08:00
parent 7123fce47a
commit cb74e1c4c7
66 changed files with 8579 additions and 1910 deletions

View file

@ -0,0 +1,24 @@
import {
deriveTokenBotIdentity,
maskPlatformId,
TokenBotConfigStore,
} from '../shared/token-config-store.mjs';
const IDENTITY_OPTIONS = Object.freeze({
botPrefix: 'telegram',
tokenRefPrefix: 'DSH_TELEGRAM_BOT_TOKEN',
});
export function deriveTelegramBotIdentity(platformId) {
return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS);
}
export function maskTelegramBotId(platformId) {
return maskPlatformId(platformId, 'Telegram机器人');
}
export class TelegramConfigStore extends TokenBotConfigStore {
constructor(path) {
super(path, { channel: 'Telegram', ...IDENTITY_OPTIONS });
}
}

View file

@ -0,0 +1,3 @@
import { HarnessClient } from '../weixin/harness-client.mjs';
export class TelegramHarnessClient extends HarnessClient {}

View file

@ -0,0 +1,3 @@
import { ConversationStateStore } from '../shared/conversation-state-store.mjs';
export class TelegramStateStore extends ConversationStateStore {}

View file

@ -0,0 +1,120 @@
const DEFAULT_BASE_URL = 'https://api.telegram.org/';
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function requestSignal(signal, timeoutMs) {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
export function validTelegramToken(value) {
return typeof value === 'string' && /^\d{5,20}:[A-Za-z0-9_-]{20,}$/.test(value.trim());
}
export class TelegramApi {
#token;
#fetch;
#baseUrl;
constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid');
if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch');
this.#token = token.trim();
this.#fetch = fetchImpl;
this.#baseUrl = new URL(baseUrl);
}
async getMe(options = {}) {
return this.#call('getMe', {}, options);
}
async getWebhookInfo(options = {}) {
return this.#call('getWebhookInfo', {}, options);
}
async getUpdates({ offset, timeout = 25, signal } = {}) {
const payload = {
timeout,
limit: 100,
allowed_updates: ['message'],
...(Number.isSafeInteger(offset) ? { offset } : {}),
};
return this.#call('getUpdates', payload, {
signal,
timeoutMs: Math.max(10_000, (timeout + 10) * 1_000),
});
}
async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
return this.#call('sendMessage', {
chat_id: chatId,
text,
link_preview_options: { is_disabled: true },
...(replyToMessageId ? {
reply_parameters: { message_id: replyToMessageId, allow_sending_without_reply: true },
} : {}),
...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
}, { signal });
}
async editMessageText({ chatId, messageId, text, signal }) {
return this.#call('editMessageText', {
chat_id: chatId,
message_id: messageId,
text,
link_preview_options: { is_disabled: true },
}, { signal });
}
async sendChatAction({ chatId, messageThreadId, signal }) {
return this.#call('sendChatAction', {
chat_id: chatId,
action: 'typing',
...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
}, { signal });
}
async #call(method, payload, { signal, timeoutMs = 15_000 } = {}) {
const url = new URL(this.#baseUrl);
url.pathname = `${url.pathname.replace(/\/$/, '')}/bot${this.#token}/${method}`;
let response;
try {
response = await this.#fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: requestSignal(signal, timeoutMs),
redirect: 'error',
});
} catch (error) {
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
throw new Error(`Telegram ${method} transport failed`);
}
let body;
try {
body = await response.json();
} catch {
throw new Error(`Telegram ${method} returned invalid JSON`);
}
if (!response.ok || body?.ok !== true) {
const description = cleanString(body?.description);
const error = new Error(description ?? `Telegram ${method} failed`);
error.code = Number.isInteger(body?.error_code) ? `telegram-${body.error_code}` : 'telegram-api-error';
throw error;
}
return body.result;
}
}
export async function inspectTelegramToken(token, options = {}) {
const api = new TelegramApi({ token, ...options });
const bot = await api.getMe();
if (!bot?.id || bot?.is_bot !== true) throw new Error('Telegram token does not belong to a bot');
return {
platformId: String(bot.id),
name: cleanString([bot.first_name, bot.last_name].filter(Boolean).join(' ')) ?? 'Telegram机器人',
username: cleanString(bot.username),
};
}

View file

@ -0,0 +1,15 @@
import { TextHarnessBridge, createTextBridgeStatus } from '../shared/text-harness-bridge.mjs';
export const TELEGRAM_DESCRIPTOR = Object.freeze({
key: 'telegram',
label: 'Telegram',
connectionLabel: ' Bot API 长轮询',
});
export class TelegramHarnessBridge extends TextHarnessBridge {
constructor(options) {
super({ descriptor: TELEGRAM_DESCRIPTOR, ...options });
}
}
export { createTextBridgeStatus as createTelegramBridgeStatus };

View file

@ -0,0 +1,16 @@
import { TokenBotController } from '../shared/token-bot-controller.mjs';
import { deriveTelegramBotIdentity, maskTelegramBotId } from './config-store.mjs';
import { inspectTelegramToken } from './telegram-api.mjs';
import { TELEGRAM_DESCRIPTOR } from './telegram-bridge.mjs';
export class TelegramController extends TokenBotController {
constructor(options) {
super({
...options,
descriptor: TELEGRAM_DESCRIPTOR,
inspectToken: options.inspectToken ?? inspectTelegramToken,
deriveIdentity: deriveTelegramBotIdentity,
maskPlatformId: maskTelegramBotId,
});
}
}

View file

@ -0,0 +1,278 @@
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
import { TelegramApi } from './telegram-api.mjs';
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
function escaped(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function mentionedUsername(message, username) {
if (!username || typeof message?.text !== 'string' || !Array.isArray(message.entities)) return false;
return message.entities.some((entity) => {
if (entity?.type !== 'mention' || !Number.isInteger(entity.offset) || !Number.isInteger(entity.length)) {
return false;
}
return message.text.slice(entity.offset, entity.offset + entity.length).toLowerCase()
=== `@${username.toLowerCase()}`;
});
}
function withoutBotMention(text, username) {
if (!username || typeof text !== 'string') return text;
return text.replace(new RegExp(`@${escaped(username)}\\b`, 'ig'), '').trim();
}
export function normalizeTelegramUpdate(update, { botId, username }) {
const message = update?.message;
const chatId = message?.chat?.id;
const senderId = message?.from?.id;
const messageId = message?.message_id;
if (!Number.isSafeInteger(update?.update_id) || chatId === undefined || senderId === undefined
|| !Number.isSafeInteger(messageId)) return null;
if (!['private', 'group', 'supergroup'].includes(message.chat?.type)) return null;
const direct = message.chat.type === 'private';
const addressed = direct
|| String(message.reply_to_message?.from?.id ?? '') === String(botId)
|| mentionedUsername(message, username);
return {
messageId: String(update.update_id),
senderId: String(senderId),
senderIsBot: message.from?.is_bot === true,
kind: direct ? 'direct' : 'group',
conversationId: String(chatId),
content: withoutBotMention(message.text ?? message.caption ?? '', username),
addressed,
replyTarget: {
chatId,
replyToMessageId: messageId,
messageThreadId: Number.isSafeInteger(message.message_thread_id)
? message.message_thread_id : undefined,
},
};
}
class TelegramBotClient {
#api;
#signal;
constructor({ api, signal }) {
this.#api = api;
this.#signal = signal;
}
async sendText(target, text) {
const chunks = splitMessageText(text, 4_000);
let result = null;
for (const [index, chunk] of chunks.entries()) {
result = await this.#api.sendMessage({
chatId: target.chatId,
text: chunk,
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
messageThreadId: target.messageThreadId,
signal: this.#signal,
});
}
return result;
}
sendTyping(target) {
return this.#api.sendChatAction({
chatId: target.chatId,
messageThreadId: target.messageThreadId,
signal: this.#signal,
});
}
async openStream(target) {
const stream = createEditableMessageStream({
limit: 4_000,
create: async (text) => {
const message = await this.#api.sendMessage({
chatId: target.chatId,
text,
replyToMessageId: target.replyToMessageId,
messageThreadId: target.messageThreadId,
signal: this.#signal,
});
return message.message_id;
},
edit: (messageId, text) => this.#api.editMessageText({
chatId: target.chatId,
messageId,
text,
signal: this.#signal,
}),
sendRemainder: (text) => this.#api.sendMessage({
chatId: target.chatId,
text,
messageThreadId: target.messageThreadId,
signal: this.#signal,
}),
});
return stream.start();
}
}
export function createTelegramRuntimeStatus() {
return {
startedAt: null,
ready: false,
connectionState: 'idle',
harnessReachable: false,
lastCheckedAt: null,
lastConnectedAt: null,
lastError: null,
...createTelegramBridgeStatus(),
};
}
export class TelegramRuntime {
#config;
#token;
#harness;
#state;
#logger;
#replyTimeoutMs;
#createApi;
#status = createTelegramRuntimeStatus();
#api = null;
#bridge = null;
#abortController = null;
#pollTask = null;
#starting = null;
constructor({
config,
token,
harness,
state,
logger = console,
replyTimeoutMs = 600_000,
createApi = (options) => new TelegramApi(options),
}) {
if (!config || !token || !harness || !state) {
throw new TypeError('TelegramRuntime requires config, token, Harness, and state');
}
this.#config = config;
this.#token = token;
this.#harness = harness;
this.#state = state;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#createApi = createApi;
}
get status() {
return structuredClone(this.#status);
}
async start() {
if (this.#status.ready && this.#pollTask) return this.status;
if (this.#starting) return this.#starting;
this.#starting = this.#start().finally(() => {
this.#starting = null;
});
return this.#starting;
}
async #start() {
await this.stop();
this.#status.startedAt = new Date().toISOString();
this.#status.connectionState = 'connecting';
this.#status.lastError = null;
await this.#harness.ensureRunning();
this.#status.harnessReachable = true;
const controller = new AbortController();
this.#abortController = controller;
const api = this.#createApi({ token: this.#token });
this.#api = api;
try {
const bot = await api.getMe({ signal: controller.signal });
if (String(bot?.id ?? '') !== this.#config.platformId || bot?.is_bot !== true) {
throw new Error('Telegram token identity does not match the saved bot');
}
const webhook = await api.getWebhookInfo({ signal: controller.signal });
if (typeof webhook?.url === 'string' && webhook.url) {
const error = new Error('该 Telegram 机器人已配置 Webhook,请先在原服务中移除 Webhook 后重试。');
error.code = 'webhook-configured';
throw error;
}
const client = new TelegramBotClient({ api, signal: controller.signal });
this.#bridge = new TelegramHarnessBridge({
bot: client,
harness: this.#harness,
state: this.#state,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
});
let cursor = this.#state.cursor();
if (cursor === null) {
const latest = await api.getUpdates({ offset: -1, timeout: 0, signal: controller.signal });
cursor = latest.length > 0 ? latest.at(-1).update_id + 1 : 0;
await this.#state.setCursor(cursor);
}
const now = Date.now();
this.#status.ready = true;
this.#status.connectionState = 'connected';
this.#status.lastCheckedAt = now;
this.#status.lastConnectedAt = now;
this.#pollTask = this.#poll(cursor, controller.signal);
this.#pollTask.catch((error) => {
if (controller.signal.aborted) return;
this.#status.ready = false;
this.#status.connectionState = 'failed';
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.(`[dsh-im:telegram] bot ${this.#config.botId} polling stopped:`, error);
});
return this.status;
} catch (error) {
this.#status.ready = false;
this.#status.connectionState = 'failed';
this.#status.lastError = error?.message ?? String(error);
await this.stop();
throw error;
}
}
async #poll(initialCursor, signal) {
let cursor = initialCursor;
while (!signal.aborted) {
const updates = await this.#api.getUpdates({ offset: cursor, timeout: 25, signal });
this.#status.lastCheckedAt = Date.now();
for (const update of updates) {
if (signal.aborted) return;
const message = normalizeTelegramUpdate(update, {
botId: this.#config.platformId,
username: this.#config.username,
});
if (message) await this.#bridge.accept(message);
cursor = update.update_id + 1;
await this.#state.setCursor(cursor);
}
}
}
async stop() {
const pollTask = this.#pollTask;
const bridge = this.#bridge;
this.#abortController?.abort();
this.#abortController = null;
this.#pollTask = null;
this.#api = null;
this.#bridge = null;
await Promise.race([
pollTask?.catch(() => undefined) ?? Promise.resolve(),
new Promise((resolve) => setTimeout(resolve, 2_000)),
]);
await Promise.race([
bridge?.waitForIdle() ?? Promise.resolve(),
new Promise((resolve) => setTimeout(resolve, 2_000)),
]);
this.#status.ready = false;
this.#status.connectionState = 'idle';
return this.status;
}
}