Files
ragflow/api/channels/telegram/channel.py
Kevin Hu b5a426e6e0 Feat: chat channels — connect assistants to external messaging bots (#15850)
### What problem does this PR solve?

#15844

Adds a **Chat channels** capability so a RAGFlow assistant (Dialog) can
be exposed as a bot on external messaging platforms (Feishu/Lark,
Discord, Telegram, Slack, WeCom, LINE, etc.). An admin configures a bot
in the UI, connects it to an assistant, and inbound messages are
answered from that assistant's knowledge base — replies are delivered
back on the channel.

**Feishu/Lark is implemented and tested end-to-end.** Discord, Telegram,
LINE, and WeCom are scaffolded against the same interface; the remaining
listed channels are tracked as follow-ups.

### Design

**Backend**
- New `chat_channel` table (`tenant_id`, `name`, `channel`, `config`
JSON holding `{credential: {...}}`, `dialog_id`, `status`) +
`ChatChannelService` and RESTful CRUD under `/api/v1/chat_channels`.
- Channel framework under `api/channels/`: a `core` registry +
per-channel packages that self-register a builder and implement a common
`Channel` interface (`start`/`stop`/`send` + inbound normalization) over
`IncomingMessage`/`OutgoingMessage`.
- Embedded **reconcile loop** in `ragflow_server`
(`api/channels/bootstrap.py`): loads enabled bots, and
starts/stops/restarts them as rows change (no server restart needed).
Inbound messages run the connected dialog via the non-streaming
completion path, keeping per-end-user conversation history.
- Missing optional channel SDKs degrade gracefully (channel skipped with
a warning; others unaffected). Channel-level errors are logged, not
crashed.
- Feishu's WebSocket client runs in a dedicated thread with its own
event loop to avoid cross-loop/contextvars conflicts with the channel
runtime.

**Frontend**
- **Settings → Chat channels** panel: available-channels grid +
configured-bots list with add/edit/delete and a **Connect assistant**
popup that binds a bot to a dialog.
- Brand icons via simple-icons / reused shared data-source assets, with
colored fallbacks for brands not available.
- Route, sidebar entry, i18n (en/zh), and a top-nav segment-boundary fix
so the settings page no longer highlights the Chat tab.

### Type of change

- [x] New Feature (non-breaking change which adds functionality)

### Notes
- DB: new `chat_channel` table is auto-created; `chat_channel.dialog_id`
is also covered by a `migrate_db` `alter_db_add_column` for existing
installs.
- Channel SDKs (`lark-oapi`, `discord.py`, `python-telegram-bot`,
`line-bot-sdk`, `wechatpy`, `aiohttp`) added to dependencies.
- Screenshots / per-channel credential docs to follow.

<img width="1338" height="1290" alt="Image"
src="https://github.com/user-attachments/assets/042cb2f9-0dad-4e6a-bcf7-43ced4bbd704"
/>

<img width="1344" height="738" alt="Image"
src="https://github.com/user-attachments/assets/373cd08e-ec40-4c67-9c51-4d948b1ba617"
/>

<img width="672" height="887" alt="Image"
src="https://github.com/user-attachments/assets/5a34953f-a9a3-4c1e-869e-5eff0dc64c84"
/>

---------
2026-06-12 18:21:30 +08:00

119 lines
3.9 KiB
Python

from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
from telegram import ReplyParameters, Update
from telegram.ext import Application, ContextTypes, MessageHandler, filters
from ..core.base import Channel, IncomingMessage, OutgoingMessage
from ..core.registry import register_channel
LOGGER = logging.getLogger(__name__)
@dataclass
class TelegramAccount:
account_id: str
token: str
def _chat_type(chat) -> str:
t = getattr(chat, "type", "")
if t == "private":
return "p2p"
if t in ("group", "supergroup"):
return "group"
if t == "channel":
return "channel"
return str(t) or "unknown"
class TelegramChannel(Channel):
channel_id = "telegram"
def __init__(self, account: TelegramAccount) -> None:
super().__init__()
self.account = account
self.account_id = account.account_id
self._app: Optional[Application] = None
async def start(self) -> None:
self._app = Application.builder().token(self.account.token).build()
self._app.add_handler(MessageHandler(filters.ALL, self._on_update))
LOGGER.info("[telegram:%s] starting long-poll", self.account_id)
await self._app.initialize()
await self._app.start()
await self._app.updater.start_polling(drop_pending_updates=True)
async def stop(self) -> None:
if self._app is None:
return
try:
if self._app.updater and self._app.updater.running:
await self._app.updater.stop()
await self._app.stop()
await self._app.shutdown()
except Exception:
LOGGER.error("[telegram:%s] stop error", self.account_id, exc_info=True)
finally:
self._app = None
async def send(self, message: OutgoingMessage) -> None:
if self._app is None:
return
try:
chat_id = int(message.chat_id)
except (TypeError, ValueError):
LOGGER.error("[telegram:%s] invalid chat_id: %r", self.account_id, message.chat_id)
return
reply_parameters = None
if message.reply_to_message_id:
try:
reply_parameters = ReplyParameters(
message_id=int(message.reply_to_message_id),
allow_sending_without_reply=True,
)
except (TypeError, ValueError):
reply_parameters = None
try:
await self._app.bot.send_message(
chat_id=chat_id,
text=message.text,
reply_parameters=reply_parameters,
)
except Exception:
LOGGER.error("[telegram:%s] send failed", self.account_id, exc_info=True)
async def _on_update(self, update: Update, _ctx: ContextTypes.DEFAULT_TYPE) -> None:
try:
msg = update.effective_message
if msg is None or msg.from_user is None or msg.from_user.is_bot:
return
text = msg.text or msg.caption or ""
incoming = IncomingMessage(
channel=self.channel_id,
account_id=self.account_id,
chat_id=str(msg.chat.id),
chat_type=_chat_type(msg.chat),
message_id=str(msg.message_id),
sender_id=str(msg.from_user.id),
text=text,
raw=update,
)
await self._dispatch(incoming)
except Exception:
LOGGER.error("[telegram:%s] inbound message handling error", self.account_id, exc_info=True)
def _build(account_id: str, cfg: dict) -> Channel:
token = cfg.get("token")
if not token:
raise ValueError(f"telegram account '{account_id}' is missing token")
return TelegramChannel(TelegramAccount(account_id=account_id, token=str(token)))
register_channel("telegram", _build)