feat: add whatsapp web qr chat channel (#16238)

Adds a WhatsApp chat channel backed by a QR-based web login flow so users can connect without manual token setup.
This commit is contained in:
buua436
2026-06-23 17:45:31 +08:00
committed by GitHub
parent e15130534f
commit aba5d172bd
16 changed files with 3160 additions and 10 deletions

View File

@@ -33,7 +33,7 @@ def _chat_channel_auth_error(channel_id: str, user_id: str):
@manager.route("/chat-channels", methods=["POST"]) # noqa: F821
@login_required
@validate_request("name", "channel", "config")
@validate_request("name", "channel")
async def create_chat_channel():
"""Create a chat channel bot owned by the current tenant."""
req = await get_request_json()
@@ -42,7 +42,7 @@ async def create_chat_channel():
"tenant_id": current_user.id,
"name": req["name"],
"channel": req["channel"],
"config": req["config"],
"config": req.get("config") or {},
"chat_id": req.get("chat_id") or None
}
ChatChannelService.insert(**channel)
@@ -115,3 +115,43 @@ def rm_chat_channel(channel_id):
ChatChannelService.delete_by_id(channel_id)
return get_json_result(data=True)
@manager.route("/chat-channels/<channel_id>/runtime", methods=["GET"]) # noqa: F821
@login_required
def get_chat_channel_runtime(channel_id):
"""Return live runtime metadata for a running chat channel."""
if not ChatChannelService.accessible(channel_id, current_user.id):
return _chat_channel_auth_error(channel_id, current_user.id)
e, conn = ChatChannelService.get_by_id(channel_id)
if not e:
return get_data_error_result(message="Can't find this chat channel!")
if conn.channel != "whatsapp":
return get_data_error_result(message="Runtime snapshot is only available for WhatsApp.")
try:
from api.channels.whatsapp.channel import get_runtime_snapshot
except Exception as ex:
LOGGER.error("failed to load whatsapp runtime helper: %s", ex, exc_info=True)
return get_data_error_result(message="WhatsApp runtime is unavailable.")
snapshot = get_runtime_snapshot(channel_id)
if snapshot is None:
return get_json_result(
data={
"account_id": channel_id,
"session_key": channel_id,
"status": "waiting",
"connected_at": None,
"qr_updated_at": None,
"qr_data_url": None,
"last_error": None,
"session_id": None,
"last_snapshot_at": None,
"gateway_base_url": None,
"event_cursor": 0,
}
)
return get_json_result(data=snapshot)

View File

@@ -33,7 +33,16 @@ import threading
LOGGER = logging.getLogger(__name__)
# Channel packages bundled under api/channels that self-register on import.
_BUNDLED_CHANNELS = ("feishu", "discord", "telegram", "line", "wecom", "qqbot", "dingtalk")
_BUNDLED_CHANNELS = (
"feishu",
"discord",
"telegram",
"line",
"wecom",
"qqbot",
"dingtalk",
"whatsapp",
)
# How often (seconds) to reconcile running channels against the database.
_RECONCILE_INTERVAL_SECS = 10
@@ -144,7 +153,10 @@ def _make_chat_handler(ch):
answer_text = ""
try:
async for ans in async_chat(dia, history, False, quote=False):
chat_kwargs = {"quote": False}
if "{knowledge}" in (dia.prompt_config or {}).get("system", ""):
chat_kwargs["knowledge"] = ""
async for ans in async_chat(dia, history, False, **chat_kwargs):
structure_answer(conv, ans, message_id, conv.id)
answer_text = (ans or {}).get("answer", "") or ""
ConversationService.update_by_id(conv.id, conv.to_dict())
@@ -228,6 +240,18 @@ async def _reconcile(running: dict, failed: dict) -> None:
if account_id not in desired or desired[account_id][2] != failed[account_id]:
failed.pop(account_id, None)
active_whatsapp = any(channel == "whatsapp" for channel, _, _ in desired.values())
if not active_whatsapp:
active_whatsapp = any(
entry["ch"].channel_id == "whatsapp" for entry in running.values()
)
from api.channels.whatsapp.gateway import sync_whatsapp_gateway
try:
await sync_whatsapp_gateway(active_whatsapp)
except Exception:
LOGGER.exception("failed to sync WhatsApp gateway enabled=%s", active_whatsapp)
# Start channels that are new (skip ones already known to fail with this config).
for account_id, (channel, credential, fp) in desired.items():
if account_id in running or failed.get(account_id) == fp:

View File

@@ -0,0 +1 @@
from . import channel # noqa: F401

View File

@@ -0,0 +1,393 @@
from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import dataclass
from typing import Any, Optional
import aiohttp
from ..core.base import Channel, IncomingMessage, OutgoingMessage
from ..core.registry import register_channel
LOGGER = logging.getLogger(__name__)
WHATSAPP_DEFAULT_TIMEOUT_SECS = 30
WHATSAPP_DEFAULT_SESSION_KEY = "default"
WHATSAPP_MESSAGE_TTL_SECS = 3600
WHATSAPP_GATEWAY_START_RETRY_SECS = 30
WHATSAPP_GATEWAY_RECONNECT_SECS = 3
@dataclass
class WhatsAppAccount:
account_id: str
gateway_base_url: str
gateway_token: str = ""
session_key: str = ""
timeout_secs: int = WHATSAPP_DEFAULT_TIMEOUT_SECS
_live_channels: dict[str, "WhatsAppChannel"] = {}
def _default_gateway_base_url() -> str:
return "http://127.0.0.1:3005"
def get_runtime_snapshot(account_id: str) -> dict[str, Any] | None:
channel = _live_channels.get(account_id)
if channel is None:
return None
return channel.get_status_snapshot()
class WhatsAppChannel(Channel):
channel_id = "whatsapp"
def __init__(self, account: WhatsAppAccount) -> None:
super().__init__()
self.account = account
self.account_id = account.account_id
self._task: Optional[asyncio.Task] = None
self._stop_event = asyncio.Event()
self._lifecycle_lock = asyncio.Lock()
self._http_session: Optional[aiohttp.ClientSession] = None
self._status: str = "stopped"
self._last_error: str = ""
self._qr_data_url: str = ""
self._qr_updated_at: float = 0.0
self._connected_at: float = 0.0
self._last_snapshot_at: float = 0.0
self._session_id: str = ""
self._event_cursor: int = 0
self._seen_message_ids: dict[str, float] = {}
def _session_key(self) -> str:
value = str(self.account.session_key or "").strip()
return value or WHATSAPP_DEFAULT_SESSION_KEY
def _gateway_base_url(self) -> str:
return str(self.account.gateway_base_url or "").strip().rstrip("/")
def _gateway_url(self, path: str) -> str:
base_url = self._gateway_base_url()
if not base_url:
raise ValueError("WhatsApp gateway_base_url is required")
return f"{base_url}/{path.lstrip('/')}"
def _events_ws_url(self) -> str:
base_url = self._gateway_base_url()
if not base_url:
raise ValueError("WhatsApp gateway_base_url is required")
if base_url.startswith("http://"):
ws_base = f"ws://{base_url.removeprefix('http://')}"
elif base_url.startswith("https://"):
ws_base = f"wss://{base_url.removeprefix('https://')}"
else:
ws_base = base_url
return (
f"{ws_base}/whatsapp/{self._session_key()}/events/ws"
f"?after={self._event_cursor}"
)
def _gateway_headers(self) -> dict[str, str]:
token = str(self.account.gateway_token or "").strip()
if not token:
return {}
if token.lower().startswith("bearer "):
return {"Authorization": token}
return {"Authorization": f"Bearer {token}"}
async def _ensure_http_session(self) -> aiohttp.ClientSession:
if self._http_session is not None and not self._http_session.closed:
return self._http_session
timeout = aiohttp.ClientTimeout(total=max(int(self.account.timeout_secs), 1))
self._http_session = aiohttp.ClientSession(timeout=timeout)
return self._http_session
async def _request_json(
self,
method: str,
path: str,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
session = await self._ensure_http_session()
url = self._gateway_url(path)
async with session.request(
method,
url,
json=payload,
headers=self._gateway_headers(),
) as resp:
text = await resp.text()
if resp.status >= 400:
raise RuntimeError(f"status: {resp.status}, response: {text}")
if not text.strip():
return {}
content_type = resp.headers.get("content-type", "")
if "application/json" not in content_type.lower():
raise RuntimeError(
f"unexpected response content-type: {content_type or 'unknown'}, response: {text[:200]}"
)
try:
return await resp.json()
except Exception as ex:
raise RuntimeError(f"invalid json response: {text[:200]}") from ex
async def _request_json_with_retry(
self,
method: str,
path: str,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
deadline = time.time() + WHATSAPP_GATEWAY_START_RETRY_SECS
last_error: Exception | None = None
while time.time() < deadline and not self._stop_event.is_set():
try:
return await self._request_json(method, path, payload)
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as ex:
last_error = ex
await asyncio.sleep(0.5)
if last_error is not None:
raise last_error
return await self._request_json(method, path, payload)
async def start(self) -> None:
async with self._lifecycle_lock:
if self._task and not self._task.done():
return
self._stop_event.clear()
_live_channels[self.account_id] = self
self._task = asyncio.create_task(self._run(), name=f"whatsapp-{self.account_id}")
LOGGER.info("[whatsapp:%s] starting gateway client", self.account_id)
async def stop(self) -> None:
async with self._lifecycle_lock:
self._stop_event.set()
task = self._task
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
try:
await self._request_json(
"POST",
f"whatsapp/{self._session_key()}/stop",
{"account_id": self.account_id, "session_key": self._session_key()},
)
except Exception:
LOGGER.debug("[whatsapp:%s] gateway stop failed", self.account_id, exc_info=True)
try:
if self._http_session is not None and not self._http_session.closed:
await self._http_session.close()
finally:
self._http_session = None
_live_channels.pop(self.account_id, None)
self._task = None
self._status = "stopped"
self._last_error = ""
self._qr_data_url = ""
self._qr_updated_at = 0.0
self._connected_at = 0.0
self._last_snapshot_at = 0.0
self._session_id = ""
self._event_cursor = 0
self._seen_message_ids.clear()
async def send(self, message: OutgoingMessage) -> None:
if not message.chat_id:
LOGGER.error("[whatsapp:%s] missing chat_id; cannot send", self.account_id)
return
try:
LOGGER.info(
"[whatsapp:%s] sending reply chat_id=%s reply_to=%s text_preview=%r",
self.account_id,
message.chat_id,
message.reply_to_message_id,
message.text[:120],
)
await self._request_json(
"POST",
f"whatsapp/{self._session_key()}/send",
{
"account_id": self.account_id,
"session_key": self._session_key(),
"chat_id": message.chat_id,
"text": message.text,
"reply_to_message_id": message.reply_to_message_id,
},
)
LOGGER.info(
"[whatsapp:%s] message sent chat_id=%s reply_to=%s",
self.account_id,
message.chat_id,
message.reply_to_message_id,
)
except Exception:
LOGGER.error("[whatsapp:%s] send failed", self.account_id, exc_info=True)
async def _run(self) -> None:
self._status = "connecting"
self._last_error = ""
try:
await self._request_json_with_retry(
"POST",
f"whatsapp/{self._session_key()}/start",
{
"account_id": self.account_id,
"session_key": self._session_key(),
"gateway_base_url": self._gateway_base_url(),
},
)
while not self._stop_event.is_set():
try:
await self._run_events_ws()
except asyncio.CancelledError:
raise
except Exception as ex:
self._status = "error"
self._last_error = str(ex)
LOGGER.error("[whatsapp:%s] gateway event loop error", self.account_id, exc_info=True)
if not self._stop_event.is_set():
await asyncio.sleep(WHATSAPP_GATEWAY_RECONNECT_SECS)
except asyncio.CancelledError:
pass
except Exception as ex:
self._status = "error"
self._last_error = str(ex)
LOGGER.error("[whatsapp:%s] gateway runtime error", self.account_id, exc_info=True)
finally:
if not self._stop_event.is_set() and self._status != "error":
self._status = "disconnected"
if not self._last_error:
self._last_error = "WhatsApp gateway stopped unexpectedly."
self._last_snapshot_at = time.time()
def _apply_snapshot(self, snapshot: dict[str, Any]) -> None:
self._last_snapshot_at = time.time()
self._status = str(snapshot.get("status") or "connecting")
self._last_error = str(snapshot.get("last_error") or "")
self._qr_data_url = str(snapshot.get("qr_data_url") or "")
self._qr_updated_at = float(snapshot.get("qr_updated_at") or 0.0)
self._connected_at = float(snapshot.get("connected_at") or 0.0)
self._session_id = str(snapshot.get("session_id") or self._session_id or "")
self._event_cursor = max(self._event_cursor, int(snapshot.get("event_cursor") or 0))
if self._status == "connected":
self._last_error = ""
async def _run_events_ws(self) -> None:
session = await self._ensure_http_session()
url = self._events_ws_url()
async with session.ws_connect(url, headers=self._gateway_headers(), heartbeat=None) as ws:
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await self._handle_ws_payload(msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
raise RuntimeError(f"whatsapp events websocket error: {ws.exception()}")
LOGGER.warning("[whatsapp:%s] events websocket closed", self.account_id)
async def _handle_ws_payload(self, payload: str) -> None:
try:
obj = json.loads(payload)
except json.JSONDecodeError:
LOGGER.warning(
"[whatsapp:%s] ignored invalid websocket payload: %r",
self.account_id,
payload[:200],
)
return
kind = str(obj.get("type") or "").strip()
data = obj.get("data")
if kind == "snapshot" and isinstance(data, dict):
self._apply_snapshot(data)
return
if kind == "event" and isinstance(data, dict):
await self._handle_event_item(data)
return
async def _handle_event_item(self, item: dict[str, Any]) -> None:
seq = int(item.get("seq") or 0)
if seq > self._event_cursor:
self._event_cursor = seq
if item.get("kind") != "message":
return
message_id = str(item.get("message_id") or "").strip()
if not message_id:
return
self._prune_seen_message_ids()
if self._is_seen_message(message_id):
return
self._seen_message_ids[message_id] = time.time()
incoming = IncomingMessage(
channel=self.channel_id,
account_id=self.account_id,
chat_id=str(item.get("chat_id") or ""),
chat_type=str(item.get("chat_type") or "p2p"),
message_id=message_id,
sender_id=str(item.get("sender_id") or ""),
text=str(item.get("text") or ""),
raw=item.get("raw"),
)
LOGGER.info(
"[whatsapp:%s] inbound message_id=%s chat_id=%s",
self.account_id,
message_id,
incoming.chat_id,
)
await self._dispatch(incoming)
def _is_seen_message(self, message_id: str) -> bool:
return message_id in self._seen_message_ids
def _prune_seen_message_ids(self) -> None:
cutoff = time.time() - WHATSAPP_MESSAGE_TTL_SECS
stale = [key for key, ts in self._seen_message_ids.items() if ts < cutoff]
for key in stale:
self._seen_message_ids.pop(key, None)
def get_status_snapshot(self) -> dict[str, Any]:
return {
"account_id": self.account_id,
"session_key": self._session_key(),
"status": self._status,
"connected_at": self._connected_at or None,
"qr_updated_at": self._qr_updated_at or None,
"qr_data_url": self._qr_data_url or None,
"last_error": self._last_error or None,
"session_id": self._session_id or None,
"last_snapshot_at": self._last_snapshot_at or None,
"gateway_base_url": self._gateway_base_url() or None,
"event_cursor": self._event_cursor,
}
def _build(account_id: str, cfg: dict) -> Channel:
gateway_base_url = str(
cfg.get("gateway_base_url")
or cfg.get("gateway_url")
or cfg.get("control_url")
or _default_gateway_base_url()
)
gateway_token = str(cfg.get("gateway_token") or cfg.get("token") or "")
session_key = str(cfg.get("session_key") or cfg.get("session_id") or account_id)
timeout_secs = int(cfg.get("timeout_secs") or WHATSAPP_DEFAULT_TIMEOUT_SECS)
return WhatsAppChannel(
WhatsAppAccount(
account_id=account_id,
gateway_base_url=gateway_base_url,
gateway_token=gateway_token,
session_key=session_key,
timeout_secs=timeout_secs,
)
)
register_channel("whatsapp", _build)

View File

@@ -0,0 +1,34 @@
# RAGFlow WhatsApp Gateway
This directory contains a minimal WhatsApp gateway built on top of
`@whiskeysockets/baileys`.
## Install
```bash
cd api/channels/whatsapp/gateway-node
npm install
```
## Run
```bash
WHATSAPP_GATEWAY_PORT=3005 \
WHATSAPP_GATEWAY_DATA_DIR=~/.ragflow/whatsapp-gateway \
npm start
```
## API
- `POST /whatsapp/:sessionKey/start`
- `GET /whatsapp/:sessionKey/status`
- `GET /whatsapp/:sessionKey/events/ws?after=<seq>` (WebSocket)
- `POST /whatsapp/:sessionKey/send`
- `POST /whatsapp/:sessionKey/stop`
## Notes
- Authentication state is persisted under `WHATSAPP_GATEWAY_DATA_DIR`.
- Scan the QR code exposed in `status.qr_data_url`.
- RAGFlow polls `status` and `events` and forwards inbound messages to the
connected assistant.

View File

@@ -0,0 +1,533 @@
import http from 'node:http';
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { mkdir } from 'node:fs/promises';
import makeWASocket, {
Browsers,
DisconnectReason,
fetchLatestBaileysVersion,
useMultiFileAuthState,
} from 'baileys';
import QRCode from 'qrcode';
const PORT = Number.parseInt(process.env.WHATSAPP_GATEWAY_PORT || '3005', 10);
const HOST = process.env.WHATSAPP_GATEWAY_HOST || '127.0.0.1';
const AUTH_TOKEN = String(process.env.WHATSAPP_GATEWAY_TOKEN || '').trim();
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const DATA_DIR =
process.env.WHATSAPP_GATEWAY_DATA_DIR ||
path.join(os.homedir(), '.ragflow', 'whatsapp-gateway');
function now() {
return Date.now() / 1000;
}
function normalizeJid(chatId) {
const raw = String(chatId || '').trim();
if (!raw) {
return '';
}
if (raw.includes('@')) {
return raw;
}
const digits = raw.replace(/\D/g, '');
if (!digits) {
return '';
}
return `${digits}@s.whatsapp.net`;
}
function detectChatType(jid) {
if (jid.endsWith('@g.us')) {
return 'group';
}
if (jid.endsWith('@newsletter')) {
return 'channel';
}
return 'dm';
}
function extractText(message) {
if (!message) {
return '';
}
return (
message.conversation ||
message.extendedTextMessage?.text ||
message.imageMessage?.caption ||
message.videoMessage?.caption ||
message.documentMessage?.caption ||
message.buttonsResponseMessage?.selectedButtonId ||
message.listResponseMessage?.title ||
''
).trim();
}
function safeMessageKey(message) {
return {
remoteJid: message.key?.remoteJid || '',
fromMe: Boolean(message.key?.fromMe),
id: message.key?.id || '',
participant: message.key?.participant || '',
};
}
function buildWsFrame(text) {
const data = Buffer.from(String(text), 'utf8');
let header;
if (data.length < 126) {
header = Buffer.alloc(2);
header[1] = data.length;
} else if (data.length < 65536) {
header = Buffer.alloc(4);
header[1] = 126;
header.writeUInt16BE(data.length, 2);
} else {
header = Buffer.alloc(10);
header[1] = 127;
header.writeBigUInt64BE(BigInt(data.length), 2);
}
header[0] = 0x81;
return Buffer.concat([header, data]);
}
function sendWsText(socket, payload) {
socket.write(buildWsFrame(JSON.stringify(payload)));
}
function isAuthorized(req) {
if (!AUTH_TOKEN) {
return true;
}
const auth = String(req.headers.authorization || '').trim();
return auth === `Bearer ${AUTH_TOKEN}`;
}
class WhatsAppSession {
constructor(sessionKey) {
this.sessionKey = sessionKey;
this.sessionDir = path.join(DATA_DIR, sessionKey);
this.status = 'stopped';
this.lastError = '';
this.qrDataUrl = '';
this.qrUpdatedAt = 0;
this.connectedAt = 0;
this.sessionId = '';
this.authRegistered = false;
this.lastSnapshotAt = 0;
this.eventSeq = 0;
this.events = [];
this.messageStore = new Map();
this.sock = null;
this.saveCreds = null;
this.starting = null;
this.stopping = false;
this.restartTimer = null;
this.subscribers = new Set();
}
addSubscriber(socket, afterSeq) {
const subscriber = { socket };
this.subscribers.add(subscriber);
socket.on('close', () => {
this.subscribers.delete(subscriber);
});
socket.on('error', () => {
this.subscribers.delete(subscriber);
});
sendWsText(socket, { type: 'snapshot', data: this.snapshot() });
const backlog = this.listEvents(afterSeq);
for (const event of backlog.items) {
sendWsText(socket, { type: 'event', data: event });
}
}
broadcast(message) {
for (const subscriber of this.subscribers) {
try {
sendWsText(subscriber.socket, message);
} catch {
this.subscribers.delete(subscriber);
}
}
}
snapshot() {
return {
session_key: this.sessionKey,
status: this.status,
last_error: this.lastError || null,
qr_data_url: this.qrDataUrl || null,
qr_updated_at: this.qrUpdatedAt || null,
connected_at: this.connectedAt || null,
session_id: this.sessionId || null,
auth_registered: this.authRegistered,
last_snapshot_at: this.lastSnapshotAt || null,
event_cursor: this.eventSeq,
event_queue_size: this.events.length,
};
}
listEvents(afterSeq) {
const after = Number.isFinite(afterSeq) ? afterSeq : 0;
return {
next_cursor: this.eventSeq,
items: this.events.filter((event) => event.seq > after),
};
}
async start() {
if (this.starting) {
return this.starting;
}
if (this.sock) {
return;
}
this.stopping = false;
this.starting = this._start().finally(() => {
this.starting = null;
});
return this.starting;
}
async _start() {
await mkdir(this.sessionDir, { recursive: true });
this.status = 'connecting';
this.lastError = '';
const { state, saveCreds } = await useMultiFileAuthState(this.sessionDir);
this.saveCreds = saveCreds;
this.authRegistered = Boolean(state?.creds?.registered);
if (!this.authRegistered) {
this.status = 'qr';
}
const { version } = await fetchLatestBaileysVersion();
const sock = makeWASocket({
auth: state,
version,
browser: Browsers.ubuntu('RAGFlow'),
printQRInTerminal: false,
markOnlineOnConnect: false,
syncFullHistory: false,
getMessage: async (key) => {
if (!key?.id) {
return undefined;
}
return this.messageStore.get(key.id);
},
});
this.sock = sock;
sock.ev.on('creds.update', this.saveCreds);
sock.ev.on('connection.update', (update) => {
void this._handleConnectionUpdate(update);
});
sock.ev.on('messages.upsert', (update) => {
void this._handleMessagesUpsert(update);
});
this.lastSnapshotAt = now();
}
async _handleConnectionUpdate(update) {
if (update.qr) {
this.status = 'qr';
this.lastError = '';
this.qrUpdatedAt = now();
this.qrDataUrl = await QRCode.toDataURL(update.qr, {
errorCorrectionLevel: 'M',
margin: 2,
scale: 8,
});
this.lastSnapshotAt = now();
this.broadcast({ type: 'snapshot', data: this.snapshot() });
}
if (update.connection === 'open') {
this.status = 'connected';
this.lastError = '';
this.connectedAt = now();
this.qrDataUrl = '';
this.sessionId = this.sock?.user?.id || this.sessionId;
this.authRegistered = true;
this.lastSnapshotAt = now();
this.broadcast({ type: 'snapshot', data: this.snapshot() });
return;
}
if (update.connection === 'close') {
const reason = update.lastDisconnect?.error?.output?.statusCode;
const loggedOut = reason === DisconnectReason.loggedOut;
this.status = loggedOut ? 'error' : 'disconnected';
this.lastError =
update.lastDisconnect?.error?.message ||
(loggedOut ? 'WhatsApp session logged out.' : 'WhatsApp session disconnected.');
this.lastSnapshotAt = now();
this.sock = null;
this.saveCreds = null;
this.authRegistered = false;
this.broadcast({ type: 'snapshot', data: this.snapshot() });
if (!this.stopping && !loggedOut) {
clearTimeout(this.restartTimer);
this.restartTimer = setTimeout(() => {
void this.start();
}, 3000);
}
}
}
async _handleMessagesUpsert(update) {
for (const message of update.messages || []) {
const key = safeMessageKey(message);
if (key.fromMe || !key.id || !key.remoteJid) {
continue;
}
const text = extractText(message.message);
if (!text) {
continue;
}
const jid = key.remoteJid;
const event = {
seq: ++this.eventSeq,
kind: 'message',
message_id: key.id,
chat_id: jid,
chat_type: detectChatType(jid),
sender_id: key.participant || jid,
text,
raw: {
key,
message: message.message,
pushName: message.pushName || '',
messageTimestamp: message.messageTimestamp || 0,
},
};
this.events.push(event);
this.messageStore.set(key.id, message);
this.broadcast({ type: 'event', data: event });
if (this.events.length > 1000) {
const dropped = this.events.slice(0, this.events.length - 500);
this.events = this.events.slice(-500);
for (const oldEvent of dropped) {
if (oldEvent.kind === 'message' && oldEvent.message_id) {
this.messageStore.delete(oldEvent.message_id);
}
}
}
this.lastSnapshotAt = now();
}
}
async send(payload) {
if (!this.sock) {
throw new Error('WhatsApp session is not running.');
}
const jid = normalizeJid(payload.chat_id);
if (!jid) {
throw new Error(`Invalid chat_id: ${payload.chat_id}`);
}
const text = String(payload.text || '');
const options = {};
if (payload.reply_to_message_id) {
const quoted = this.messageStore.get(String(payload.reply_to_message_id));
if (quoted) {
options.quoted = quoted;
}
}
await this.sock.sendMessage(jid, { text }, options);
this.lastSnapshotAt = now();
}
async stop() {
this.stopping = true;
clearTimeout(this.restartTimer);
this.restartTimer = null;
const sock = this.sock;
this.sock = null;
this.saveCreds = null;
if (sock) {
try {
sock.end?.(undefined);
} catch {
try {
sock.ws?.close?.();
} catch {
// ignore
}
}
}
this.status = 'stopped';
this.lastError = '';
this.qrDataUrl = '';
this.qrUpdatedAt = 0;
this.connectedAt = 0;
this.sessionId = '';
this.lastSnapshotAt = now();
this.subscribers.clear();
this.events = [];
this.messageStore.clear();
}
}
const sessions = new Map();
function getSession(sessionKey) {
const key = String(sessionKey || '').trim() || 'default';
let session = sessions.get(key);
if (!session) {
session = new WhatsAppSession(key);
sessions.set(key, session);
}
return session;
}
function getExistingSession(sessionKey) {
const key = String(sessionKey || '').trim() || 'default';
return sessions.get(key) || null;
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
if (!chunks.length) {
return {};
}
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw.trim()) {
return {};
}
return JSON.parse(raw);
}
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(body),
});
res.end(body);
}
function sendError(res, statusCode, message) {
sendJson(res, statusCode, { code: statusCode, message, data: null });
}
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const parts = url.pathname.split('/').filter(Boolean);
if (req.method === 'GET' && url.pathname === '/health') {
return sendJson(res, 200, { ok: true });
}
if (parts[0] !== 'whatsapp' || parts.length < 2) {
return sendError(res, 404, 'not found');
}
if (!isAuthorized(req)) {
return sendError(res, 401, 'unauthorized');
}
const sessionKey = decodeURIComponent(parts[1]);
if (req.method === 'POST' && parts.length === 3 && parts[2] === 'start') {
const session = getSession(sessionKey);
await session.start();
return sendJson(res, 200, { code: 0, message: '', data: session.snapshot() });
}
if (req.method === 'GET' && parts.length === 3 && parts[2] === 'status') {
const session = getExistingSession(sessionKey);
if (!session) {
return sendError(res, 404, 'session not found');
}
return sendJson(res, 200, { code: 0, message: '', data: session.snapshot() });
}
if (req.method === 'POST' && parts.length === 3 && parts[2] === 'send') {
const session = getExistingSession(sessionKey);
if (!session) {
return sendError(res, 404, 'session not found');
}
const body = await readBody(req);
await session.send(body);
return sendJson(res, 200, { code: 0, message: '', data: true });
}
if (req.method === 'POST' && parts.length === 3 && parts[2] === 'stop') {
const session = getExistingSession(sessionKey);
if (!session) {
return sendError(res, 404, 'session not found');
}
await session.stop();
sessions.delete(sessionKey);
return sendJson(res, 200, { code: 0, message: '', data: true });
}
return sendError(res, 404, 'not found');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(error);
return sendJson(res, 500, { code: 500, message, data: null });
}
});
server.on('upgrade', (req, socket) => {
try {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const parts = url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'whatsapp' || parts.length !== 4 || parts[2] !== 'events' || parts[3] !== 'ws') {
socket.destroy();
return;
}
if (!isAuthorized(req)) {
socket.destroy();
return;
}
const sessionKey = decodeURIComponent(parts[1]);
const key = req.headers['sec-websocket-key'];
if (!key) {
socket.destroy();
return;
}
const accept = crypto.createHash('sha1').update(`${key}${WS_MAGIC}`).digest('base64');
socket.write(
[
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${accept}`,
'',
'',
].join('\r\n'),
);
socket.setNoDelay(true);
const session = getExistingSession(sessionKey);
if (!session) {
socket.destroy();
return;
}
const after = Number.parseInt(url.searchParams.get('after') || '0', 10) || 0;
session.addSubscriber(socket, after);
socket.on('end', () => {
session.subscribers.forEach((subscriber) => {
if (subscriber.socket === socket) {
session.subscribers.delete(subscriber);
}
});
});
} catch (error) {
console.error(error);
socket.destroy();
}
});
server.listen(PORT, HOST, () => {
console.log(`WhatsApp gateway listening on http://${HOST}:${PORT}`);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
{
"name": "ragflow-whatsapp-gateway",
"private": true,
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"baileys": "7.0.0-rc13",
"qrcode": "^1.5.4"
}
}

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import asyncio
import logging
import os
import shlex
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
LOGGER = logging.getLogger(__name__)
_missing_command_warned = False
_deps_install_warned = False
def _env_flag(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _default_gateway_command() -> list[str]:
raw = os.getenv("WHATSAPP_GATEWAY_COMMAND", "").strip()
if raw:
return shlex.split(raw)
gateway_entry = Path(__file__).resolve().parent / "gateway-node" / "index.js"
node = shutil.which("node")
if node and gateway_entry.exists():
return [node, str(gateway_entry)]
return []
def _gateway_dir() -> Path:
return Path(__file__).resolve().parent / "gateway-node"
@dataclass
class WhatsAppGatewayConfig:
command: list[str]
cwd: str
enabled: bool
class WhatsAppGatewayRuntime:
def __init__(self) -> None:
self._process: Optional[asyncio.subprocess.Process] = None
self._lock = asyncio.Lock()
self._install_lock = asyncio.Lock()
self._sync_generation = 0
def _config(self) -> WhatsAppGatewayConfig:
workdir = os.getenv("WHATSAPP_GATEWAY_WORKDIR", "").strip()
return WhatsAppGatewayConfig(
command=_default_gateway_command(),
cwd=workdir or str(_gateway_dir()),
enabled=_env_flag("WHATSAPP_GATEWAY_ENABLED", True),
)
def is_running(self) -> bool:
return bool(self._process and self._process.returncode is None)
async def sync(self, enabled: bool) -> None:
cfg = self._config()
should_run = bool(enabled and cfg.enabled and cfg.command)
async with self._lock:
self._sync_generation += 1
generation = self._sync_generation
if not should_run:
await self._stop_locked()
return
if self.is_running():
return
await self._ensure_dependencies(cfg)
async with self._lock:
if generation != self._sync_generation:
return
if not should_run:
await self._stop_locked()
return
await self._start_locked(cfg)
async def _ensure_dependencies(self, cfg: WhatsAppGatewayConfig) -> None:
global _deps_install_warned
if not _env_flag("WHATSAPP_GATEWAY_AUTO_INSTALL", True):
return
async with self._install_lock:
gateway_dir = Path(cfg.cwd)
node_modules = gateway_dir / "node_modules"
if node_modules.exists():
return
npm = shutil.which("npm")
if not npm:
if not _deps_install_warned:
LOGGER.warning(
"npm is not available; WhatsApp gateway dependencies cannot be installed automatically"
)
_deps_install_warned = True
return
package_json = gateway_dir / "package.json"
if not package_json.exists():
LOGGER.warning("WhatsApp gateway package.json not found in %s", gateway_dir)
return
LOGGER.info("installing WhatsApp gateway dependencies in %s", gateway_dir)
proc = await asyncio.create_subprocess_exec(
npm,
"install",
"--no-fund",
"--no-audit",
cwd=str(gateway_dir),
)
try:
code = await asyncio.wait_for(proc.wait(), timeout=300)
except asyncio.TimeoutError as ex:
proc.kill()
await proc.wait()
raise RuntimeError("npm install timed out after 300s") from ex
if code != 0:
raise RuntimeError(f"npm install failed with exit code {code}")
_deps_install_warned = False
async def _start_locked(self, cfg: WhatsAppGatewayConfig) -> None:
global _missing_command_warned
if self.is_running():
return
if not cfg.command:
if not _missing_command_warned:
LOGGER.warning("WhatsApp gateway command is not configured; gateway will not start")
_missing_command_warned = True
return
_missing_command_warned = False
env = os.environ.copy()
env.setdefault("PYTHONUNBUFFERED", "1")
LOGGER.info("starting WhatsApp gateway: %s", " ".join(cfg.command))
self._process = await asyncio.create_subprocess_exec(
*cfg.command,
cwd=cfg.cwd,
env=env,
)
async def _stop_locked(self) -> None:
proc = self._process
if proc is None:
return
if proc.returncode is None:
LOGGER.info("stopping WhatsApp gateway")
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=10)
except asyncio.TimeoutError:
LOGGER.warning("WhatsApp gateway did not stop in time; killing it")
proc.kill()
await proc.wait()
except Exception:
LOGGER.debug("WhatsApp gateway stop failed", exc_info=True)
self._process = None
_gateway_runtime = WhatsAppGatewayRuntime()
async def sync_whatsapp_gateway(enabled: bool) -> None:
await _gateway_runtime.sync(enabled)

View File

@@ -1,16 +1,24 @@
import { DynamicForm, FormFieldConfig } from '@/components/dynamic-form';
import { Modal } from '@/components/ui/modal/modal';
import { IModalProps } from '@/interfaces/common';
import { useEffect, useMemo, useState } from 'react';
import { fetchChatChannelRuntime } from '@/services/chat-channel-service';
import { QrCode } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FieldValues } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import {
ChatChannelFormDefaultValues,
ChatChannelKey,
getChatChannelFields,
getChatChannelRuntimeStatusClass,
getChatChannelRuntimeStatusText,
mergeChatChannelFormValues,
} from './constant';
import { IChatChannel, IChatChannelInfo } from './interface';
const getRuntimeSnapshot = (payload: any) =>
payload?.data?.data ?? payload?.data ?? payload?.runtime ?? payload ?? {};
const AddChatChannelModal = ({
visible,
hideModal,
@@ -24,6 +32,10 @@ const AddChatChannelModal = ({
}) => {
const { t } = useTranslation();
const [fields, setFields] = useState<FormFieldConfig[]>([]);
const [runtimeStatus, setRuntimeStatus] = useState('');
const [runtimeError, setRuntimeError] = useState('');
const [runtimeQr, setRuntimeQr] = useState('');
const runtimePollingInFlightRef = useRef(false);
useEffect(() => {
if (channel) {
@@ -31,6 +43,42 @@ const AddChatChannelModal = ({
}
}, [channel]);
const refreshRuntime = useCallback(async () => {
if (channel?.id !== ChatChannelKey.WHATSAPP || !record?.id) {
return;
}
if (runtimePollingInFlightRef.current) {
return;
}
runtimePollingInFlightRef.current = true;
try {
const { data } = await fetchChatChannelRuntime(record.id);
const snapshot = getRuntimeSnapshot(data);
setRuntimeStatus(snapshot?.status || '');
setRuntimeError(snapshot?.last_error || '');
setRuntimeQr(snapshot?.qr_data_url || '');
} catch (error: any) {
setRuntimeError(error?.message || 'Failed to load QR.');
setRuntimeQr('');
} finally {
runtimePollingInFlightRef.current = false;
}
}, [channel?.id, record?.id]);
useEffect(() => {
if (channel?.id !== ChatChannelKey.WHATSAPP || !record?.id) {
return;
}
setRuntimeStatus('');
setRuntimeError('');
setRuntimeQr('');
void refreshRuntime();
const timer = window.setInterval(() => {
void refreshRuntime();
}, 1000);
return () => window.clearInterval(timer);
}, [channel?.id, record?.id, refreshRuntime]);
const defaultValues = useMemo(() => {
const base = channel ? ChatChannelFormDefaultValues[channel.id] : undefined;
return mergeChatChannelFormValues(base, record) as FieldValues;
@@ -63,6 +111,46 @@ const AddChatChannelModal = ({
defaultValues={defaultValues}
labelClassName="font-normal"
>
{channel?.id === ChatChannelKey.WHATSAPP && (
<div className="mb-6 rounded-lg border border-border-button bg-bg-card p-4">
{record?.id ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div
className={`flex items-center gap-2 rounded-full border px-2 py-1 text-sm ${getChatChannelRuntimeStatusClass(runtimeStatus)}`}
>
<QrCode className="size-4" />
<span>
{getChatChannelRuntimeStatusText(runtimeStatus)}
</span>
</div>
</div>
{runtimeError ? (
<div className="text-sm text-state-error">{runtimeError}</div>
) : null}
{runtimeQr && runtimeStatus !== 'connected' ? (
<img
src={runtimeQr}
alt="WhatsApp QR"
className="mx-auto w-56 max-w-full rounded-lg border border-border-button bg-white"
/>
) : runtimeStatus === 'connected' ? (
<div className="text-sm text-state-success">
Channel is connected.
</div>
) : !runtimeStatus ? (
<div className="text-sm text-text-secondary">
QR will appear after the channel starts.
</div>
) : null}
</div>
) : (
<div className="text-sm text-text-secondary">
Save this WhatsApp channel first, then scan the QR code here.
</div>
)}
</div>
)}
<div className="absolute bottom-0 right-0 left-0 flex items-center justify-end w-full gap-2 py-6 px-6">
<DynamicForm.CancelButton
handleCancel={() => {

View File

@@ -1,15 +1,25 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Modal } from '@/components/ui/modal/modal';
import { useSetModalState } from '@/hooks/common-hooks';
import { Link2, Settings, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { fetchChatChannelRuntime } from '@/services/chat-channel-service';
import { Link2, QrCode, RefreshCw, Settings, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatChannelInfo } from '../constant';
import {
ChatChannelKey,
getChatChannelRuntimeStatusClass,
getChatChannelRuntimeStatusText,
useChatChannelInfo,
} from '../constant';
import { useDeleteChatChannel, useFetchChatChannelDetail } from '../hooks';
import { IChatChannel, IChatChannelBase, IChatChannelInfo } from '../interface';
import ConnectDialogModal from './connect-dialog-modal';
import { delChannelModal } from './delete-channel-modal';
const getRuntimeSnapshot = (payload: any) =>
payload?.data?.data ?? payload?.data ?? payload?.runtime ?? payload ?? {};
export type IAddedChannelCardProps = IChatChannelInfo & {
list: IChatChannelBase[];
onEdit: (channel: IChatChannelInfo, record: IChatChannel) => void;
@@ -29,6 +39,13 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
const [connectTarget, setConnectTarget] = useState<
IChatChannelBase | undefined
>(undefined);
const [qrVisible, setQrVisible] = useState(false);
const [qrChannelId, setQrChannelId] = useState<string>('');
const [qrLoading, setQrLoading] = useState(false);
const [qrData, setQrData] = useState<string>('');
const [qrStatus, setQrStatus] = useState<string>('');
const [qrError, setQrError] = useState<string>('');
const qrPollingInFlightRef = useRef(false);
const handleEdit = async (id: string) => {
const record = await fetchDetail(id);
@@ -42,6 +59,42 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
showConnectModal();
};
const loadQr = useCallback(async () => {
if (!qrChannelId) {
return;
}
if (qrPollingInFlightRef.current) {
return;
}
qrPollingInFlightRef.current = true;
setQrLoading(true);
try {
const { data } = await fetchChatChannelRuntime(qrChannelId);
const snapshot = getRuntimeSnapshot(data);
setQrData(snapshot?.qr_data_url || '');
setQrStatus(snapshot?.status || '');
setQrError(snapshot?.last_error || '');
} catch (error: any) {
setQrData('');
setQrStatus('');
setQrError(error?.message || 'Failed to load QR.');
} finally {
qrPollingInFlightRef.current = false;
setQrLoading(false);
}
}, [qrChannelId]);
useEffect(() => {
if (!qrVisible) {
return;
}
void loadQr();
const timer = window.setInterval(() => {
void loadQr();
}, 1000);
return () => window.clearInterval(timer);
}, [qrVisible, loadQr]);
return (
<Card className="bg-transparent border border-border-button px-5 pt-[10px] pb-5 rounded-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-0 pb-3">
@@ -85,6 +138,22 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
>
<Settings size={14} />
</Button>
{channel.channel === ChatChannelKey.WHATSAPP && (
<Button
variant={'ghost'}
className="rounded-lg px-2 py-1 bg-transparent hover:bg-bg-card"
onClick={() => {
setQrChannelId(item.id);
setQrData('');
setQrStatus('');
setQrError('');
setQrVisible(true);
}}
title="Show QR"
>
<QrCode size={14} />
</Button>
)}
<Button
variant={'ghost'}
className="rounded-lg px-2 py-1 bg-transparent hover:bg-state-error-5 hover:text-state-error"
@@ -111,6 +180,46 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
channel={connectTarget}
/>
)}
<Modal
open={qrVisible}
onOpenChange={(open) => !open && setQrVisible(false)}
title="WhatsApp QR Code"
size="large"
footer={
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => void loadQr()}>
<RefreshCw className={qrLoading ? 'animate-spin' : ''} />
Refresh
</Button>
<Button onClick={() => setQrVisible(false)}>
{t('common.close')}
</Button>
</div>
}
>
<div className="flex flex-col gap-3">
<div
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 text-sm ${getChatChannelRuntimeStatusClass(qrStatus)}`}
>
<QrCode className="size-4" />
<span>{getChatChannelRuntimeStatusText(qrStatus)}</span>
</div>
{qrError ? (
<div className="text-sm text-state-error">{qrError}</div>
) : null}
{qrData ? (
<img
src={qrData}
alt="WhatsApp QR"
className="mx-auto w-56 max-w-full rounded-lg border border-border-button bg-white"
/>
) : (
<div className="text-sm text-text-secondary">
QR is not ready yet.
</div>
)}
</div>
</Modal>
</Card>
);
};

View File

@@ -93,6 +93,60 @@ export const useChatChannelInfo = () => {
return { chatChannelInfo };
};
export const getChatChannelRuntimeStatusClass = (status?: string) => {
const normalized = (status || '').toLowerCase();
if (normalized === 'connected') {
return 'bg-state-success/10 text-state-success border-state-success/20';
}
if (
normalized === 'connecting' ||
normalized === 'reconnecting' ||
normalized === 'qr'
) {
return 'bg-state-warning/10 text-state-warning border-state-warning/20';
}
if (normalized === 'waiting') {
return 'bg-state-warning/10 text-state-warning border-state-warning/20';
}
if (
normalized === 'error' ||
normalized === 'disconnected' ||
normalized === 'stopped'
) {
return 'bg-state-error/10 text-state-error border-state-error/20';
}
return 'bg-gray-500/10 text-text-secondary border-border-button';
};
export const getChatChannelRuntimeStatusText = (status?: string) => {
const normalized = (status || '').toLowerCase();
if (normalized === 'connected') {
return 'Connected';
}
if (normalized === 'connecting') {
return 'Connecting...';
}
if (normalized === 'reconnecting') {
return 'Reconnecting...';
}
if (normalized === 'qr') {
return 'Scan the QR code below';
}
if (normalized === 'waiting') {
return 'Waiting for the channel to start';
}
if (normalized === 'error') {
return 'Runtime error';
}
if (normalized === 'disconnected') {
return 'Disconnected';
}
if (normalized === 'stopped') {
return 'Stopped';
}
return 'Waiting for runtime...';
};
const isPlainObject = (value: unknown): value is Record<string, any> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -697,7 +751,6 @@ ChatChannelFormDefaultValues[
].config.credential.connection_type = 'webhook';
ChatChannelFormDefaultValues[ChatChannelKey.FEISHU].config.credential.domain =
'feishu';
export const getChatChannelFields = (
key?: ChatChannelKey,
): FormFieldConfig[] => {

View File

@@ -102,9 +102,16 @@ export const useAddChatChannel = () => {
queryClient.invalidateQueries({
queryKey: ChatChannelKeys.detail(values.id),
});
} else if (values.channel === ChatChannelKey.WHATSAPP) {
setEditingRecord(res.data as IChatChannel);
queryClient.invalidateQueries({
queryKey: ChatChannelKeys.detail(res.data?.id),
});
}
message.success(t('message.operated'));
hideModal();
if (isEdit || values.channel !== ChatChannelKey.WHATSAPP) {
hideModal();
}
}
} finally {
setLoading(false);

View File

@@ -66,6 +66,7 @@ const ChatChannel = () => {
ChatChannelKey.TELEGRAM,
ChatChannelKey.QQBOT,
ChatChannelKey.WECOM,
ChatChannelKey.WHATSAPP,
].includes(id), // Show only selected chat channels
)
.map((id) => ({

View File

@@ -28,4 +28,7 @@ export const updateChatChannel = (id: string, data: Record<string, any>) =>
export const deleteChatChannel = (id: string) =>
request.delete(api.chatChannelDel(id));
export const fetchChatChannelRuntime = (id: string) =>
request.get(api.chatChannelRuntime(id));
export default chatChannelService;

View File

@@ -102,6 +102,8 @@ export default {
chatChannelDetail: (id: string) => `${restAPIv1}/chat-channels/${id}`,
chatChannelUpdate: (id: string) => `${restAPIv1}/chat-channels/${id}`,
chatChannelDel: (id: string) => `${restAPIv1}/chat-channels/${id}`,
chatChannelRuntime: (id: string) =>
`${restAPIv1}/chat-channels/${id}/runtime`,
// plugin
llmTools: `${restAPIv1}/plugin/tools`,