merlyn/server/endpoints/telegram.js
Sean Hatfield 192ca411f2
Telegram bot connector (#5190)
* wip telegram bot connector

* encrypt bot token, reorg telegram bot modules, secure pairing codes

* offload telegram chat to background worker, add @agent support with chart png rendering, reconnect ui

* refactor telegram bot settings page into subcomponents

* response.locals for mum, telemetry for connecting to telegram

* simplify telegram command registration

* improve telegram bot ux: rework switch/history/resume commands

* add voice, photo, and TTS support to telegram bot with long message handling

* lint

* rename external_connectors to external_communication_connectors, add voice response mode, persist chat workspace/thread selection

* lint

* fix telegram bot connect/disconnect bugs, kill telegram bot on multiuser mode enable

* add english translations

* fix qr code in light mode

* repatch migration

* WIP checkpoint

* pipeline overhaul for using response obj

* format functions

* fix comment block

* remove conditional dumpENV + lint

* remove .end() from sendStatus calls

* patch broken streaming where streaming only first chunk

* refactor

* use Ephemeral handler now

* show metrics and citations in real GUI

* bugfixes

* prevent MuM persistence, UI cleanup, styling for status

* add new workspace flow in UI
Add thread chat count
fix 69 byte payload callback limit bug

* handle pagination for workspaces, threads, and models

* modularize commands and navigation

* add /proof support for citation recall

* handle backlog message spam

* support abort of response streams

* code cleanup

* spam prevention

* fix translations, update voice typing indicator, fix token bug

* frontend refactor, update tips on /status and voice response improvements

* collapse agent though blocks

* support images

* Fix mime issues with audio from other devices

* fix config issue post server stop

* persist image on agentic chats

* 5189 i18n (#5245)

* i18n translations
connect #5189

* prune translations

* fix errors

* fix translation gaps

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
2026-03-23 15:10:21 -07:00

308 lines
9.4 KiB
JavaScript

const {
ExternalCommunicationConnector,
} = require("../models/externalCommunicationConnector");
const { Telemetry } = require("../models/telemetry");
const { TelegramBotService } = require("../utils/telegramBot");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const { isSingleUserMode } = require("../utils/middleware/multiUserProtected");
const { reqBody } = require("../utils/http");
const { EventLogs } = require("../models/eventLogs");
const { Workspace } = require("../models/workspace");
const { encryptToken } = require("../utils/telegramBot/utils");
function telegramEndpoints(app) {
if (!app) return;
app.get(
"/telegram/config",
[validatedRequest, isSingleUserMode],
async (_request, response) => {
try {
const connector = await ExternalCommunicationConnector.get("telegram");
if (!connector) {
return response.status(200).json({ config: null });
}
const service = new TelegramBotService();
return response.status(200).json({
config: {
active: connector.active,
connected: service.isRunning,
bot_username: connector.config.bot_username || null,
default_workspace: connector.config.default_workspace || null,
voice_response_mode:
connector.config.voice_response_mode || "text_only",
},
});
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
/**
* Verify token, save config, and start the Telegram bot.
*/
app.post(
"/telegram/connect",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const { bot_token, default_workspace = null } = reqBody(request);
if (!bot_token) {
return response.status(400).json({
success: false,
error: "Bot token is required.",
});
}
// Verify the token with Telegram API
const verification = await TelegramBotService.verifyToken(
String(bot_token)
);
if (!verification.valid) {
return response.status(400).json({
success: false,
error: `Invalid bot token: ${verification.error}`,
});
}
let workspaceSlug = null;
if (default_workspace) workspaceSlug = String(default_workspace);
else {
const workspaces = await Workspace.where({}, 1);
if (workspaces.length) workspaceSlug = workspaces[0].slug;
else {
const { workspace } = await Workspace.new(
`${verification.username} Workspace`,
null,
{ chatMode: "automatic" }
);
if (workspace) workspaceSlug = workspace.slug;
}
}
if (!workspaceSlug) {
return response.status(400).json({
success: false,
error: "No workspace found or could be created.",
});
}
// Preserve approved users when reconnecting with a new token
const existing = await ExternalCommunicationConnector.get("telegram");
const storedConfig = {
bot_username: verification.username,
default_workspace: workspaceSlug,
approved_users: existing?.config?.approved_users || [],
voice_response_mode:
existing?.config?.voice_response_mode || "text_only",
};
// Save config with encrypted token
const { error } = await ExternalCommunicationConnector.upsert(
"telegram",
{
...storedConfig,
bot_token: encryptToken(String(bot_token)),
active: true,
}
);
if (error) return response.status(500).json({ success: false, error });
// Start the bot with the plaintext token
const service = new TelegramBotService();
await service.start({ ...storedConfig, bot_token: String(bot_token) });
await EventLogs.logEvent("telegram_bot_connected", {
bot_username: verification.username,
});
await Telemetry.sendTelemetry("telegram_bot_connected");
return response.status(200).json({
success: true,
bot_username: verification.username,
});
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
app.post(
"/telegram/disconnect",
[validatedRequest, isSingleUserMode],
async (_request, response) => {
try {
const service = new TelegramBotService();
service.stop();
await ExternalCommunicationConnector.delete("telegram");
await EventLogs.logEvent("telegram_bot_disconnected");
return response.status(200).json({ success: true });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
app.get(
"/telegram/status",
[validatedRequest, isSingleUserMode],
async (_request, response) => {
try {
const connector = await ExternalCommunicationConnector.get("telegram");
const service = new TelegramBotService();
return response.status(200).json({
active: connector?.active && service.isRunning,
bot_username: connector?.config?.bot_username || null,
});
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
app.get(
"/telegram/pending-users",
[validatedRequest, isSingleUserMode],
async (_request, response) => {
try {
const service = new TelegramBotService();
return response
.status(200)
.json({ users: service.pendingPairings || [] });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
app.get(
"/telegram/approved-users",
[validatedRequest, isSingleUserMode],
async (_request, response) => {
try {
const connector = await ExternalCommunicationConnector.get("telegram");
const approved = connector?.config?.approved_users || [];
return response.status(200).json({ users: approved });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
app.post(
"/telegram/approve-user",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const { chatId } = reqBody(request);
if (!chatId)
return response
.status(400)
.json({ success: false, error: "chatId is required." });
const service = new TelegramBotService();
await service.approvePendingUser(chatId);
await EventLogs.logEvent("telegram_user_approved", { chatId });
return response.status(200).json({ success: true });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
app.post(
"/telegram/deny-user",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const { chatId } = reqBody(request);
if (!chatId)
return response
.status(400)
.json({ success: false, error: "chatId is required." });
const service = new TelegramBotService();
await service.denyPendingUser(chatId);
await EventLogs.logEvent("telegram_user_denied", { chatId });
return response.status(200).json({ success: true });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
app.post(
"/telegram/revoke-user",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const { chatId } = reqBody(request);
if (!chatId)
return response
.status(400)
.json({ success: false, error: "chatId is required." });
const service = new TelegramBotService();
await service.revokeExistingUser(chatId);
await EventLogs.logEvent("telegram_user_revoked", { chatId });
return response.status(200).json({ success: true });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
app.post(
"/telegram/update-config",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const { voice_response_mode } = reqBody(request);
const updates = {};
if (
voice_response_mode &&
["text_only", "mirror", "always_voice"].includes(voice_response_mode)
) {
updates.voice_response_mode = voice_response_mode;
}
if (Object.keys(updates).length === 0) {
return response
.status(400)
.json({ success: false, error: "No valid updates provided." });
}
const { error } = await ExternalCommunicationConnector.updateConfig(
"telegram",
updates
);
if (error) {
return response.status(500).json({ success: false, error });
}
// Update the running bot's config so changes take effect immediately
const service = new TelegramBotService();
if (service.isRunning) service.updateConfig(updates);
return response.status(200).json({ success: true });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
}
module.exports = { telegramEndpoints };