mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 11:43:17 +08:00
Add Telegram and Discord IM channels
This commit is contained in:
parent
7123fce47a
commit
cb74e1c4c7
66 changed files with 8579 additions and 1910 deletions
24
src/channels/telegram/config-store.mjs
Normal file
24
src/channels/telegram/config-store.mjs
Normal 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 });
|
||||
}
|
||||
}
|
||||
3
src/channels/telegram/harness-client.mjs
Normal file
3
src/channels/telegram/harness-client.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { HarnessClient } from '../weixin/harness-client.mjs';
|
||||
|
||||
export class TelegramHarnessClient extends HarnessClient {}
|
||||
3
src/channels/telegram/state-store.mjs
Normal file
3
src/channels/telegram/state-store.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { ConversationStateStore } from '../shared/conversation-state-store.mjs';
|
||||
|
||||
export class TelegramStateStore extends ConversationStateStore {}
|
||||
120
src/channels/telegram/telegram-api.mjs
Normal file
120
src/channels/telegram/telegram-api.mjs
Normal 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),
|
||||
};
|
||||
}
|
||||
15
src/channels/telegram/telegram-bridge.mjs
Normal file
15
src/channels/telegram/telegram-bridge.mjs
Normal 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 };
|
||||
16
src/channels/telegram/telegram-controller.mjs
Normal file
16
src/channels/telegram/telegram-controller.mjs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
278
src/channels/telegram/telegram-runtime.mjs
Normal file
278
src/channels/telegram/telegram-runtime.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue