Compare commits

...

3 Commits

Author SHA1 Message Date
rdmclin2 7d0d569f39 feat: support WhatsApp Baileys 2026-04-26 23:22:56 +07:00
rdmclin2 c42f3c65d8 feat: use official whatsapp adapter 2026-04-26 23:22:43 +07:00
rdmclin2 f2ef7858a1 feat: support whatsApp integration 2026-04-25 22:01:26 +07:00
26 changed files with 2336 additions and 95 deletions
+22 -19
View File
@@ -2,8 +2,8 @@
title: Channels Overview
description: >-
Connect your LobeHub agents to external messaging platforms like Discord,
Slack, Telegram, QQ, WeChat, Feishu, and Lark, allowing users to interact with
AI assistants directly in their favorite chat apps.
Slack, Telegram, WhatsApp, QQ, WeChat, Feishu, and Lark, allowing users to
interact with AI assistants directly in their favorite chat apps.
tags:
- Channels
- Message Channels
@@ -11,6 +11,7 @@ tags:
- Discord
- Slack
- Telegram
- WhatsApp
- QQ
- WeChat
- Feishu
@@ -21,28 +22,29 @@ tags:
Channels allow you to connect your LobeHub agents to external messaging platforms. Once connected, users can interact with your AI assistant directly in the chat apps they already use — no need to visit LobeHub.
> [!NOTE]
> \[!NOTE]
>
> WeChat currently requires an active subscription. If you are using the community edition without a subscription, the WeChat channel option may not appear in the Channels settings yet.
## Supported Platforms
| Platform | Description |
| ------------------------------------------ | --------------------------------------------------------------- |
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
| Platform | Description |
| ------------------------------------------ | --------------------------------------------------------------------------------------------- |
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
| [WhatsApp](/docs/usage/channels/whatsapp) | Connect to WhatsApp via the Meta Cloud API for direct user chats |
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
| [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats (requires an active subscription) |
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
| [Lark](/docs/usage/channels/lark) | Connect to Lark for team collaboration (international version) |
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
| [Lark](/docs/usage/channels/lark) | Connect to Lark for team collaboration (international version) |
## How It Works
Each channel integration works by linking a bot account on the target platform to a LobeHub agent. When a user sends a message to the bot, LobeHub processes it through the agent and sends the response back to the same conversation.
- **Per-agent configuration** — Each agent can have its own set of channel connections, so different agents can serve different platforms or communities.
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Slack, Telegram, QQ, WeChat, Feishu, and Lark at the same time. LobeHub routes messages to the correct agent automatically.
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Slack, Telegram, WhatsApp, QQ, WeChat, Feishu, and Lark at the same time. LobeHub routes messages to the correct agent automatically.
- **Secure credential storage** — All bot tokens and app secrets are encrypted before being stored.
## Getting Started
@@ -52,6 +54,7 @@ Each channel integration works by linking a bot account on the target platform t
- [Discord](/docs/usage/channels/discord)
- [Slack](/docs/usage/channels/slack)
- [Telegram](/docs/usage/channels/telegram)
- [WhatsApp](/docs/usage/channels/whatsapp)
- [QQ](/docs/usage/channels/qq)
- [WeChat (微信)](/docs/usage/channels/wechat)
- [Feishu (飞书)](/docs/usage/channels/feishu)
@@ -63,10 +66,10 @@ If you do not see **WeChat** in the channel list, check that your account has an
Text messages are supported across all platforms. Some features vary by platform:
| Feature | Discord | Slack | Telegram | QQ | WeChat | Feishu | Lark |
| ---------------------- | ------- | ----- | -------- | --- | ------ | ------- | ------- |
| Text messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Direct messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Reactions | Yes | Yes | Yes | No | No | Partial | Partial |
| Image/file attachments | Yes | Yes | Yes | Yes | No | Yes | Yes |
| Feature | Discord | Slack | Telegram | WhatsApp | QQ | WeChat | Feishu | Lark |
| ---------------------- | ------- | ----- | -------- | -------- | --- | ------ | ------- | ------- |
| Text messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Direct messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Group chats | Yes | Yes | Yes | No | Yes | Yes | Yes | Yes |
| Reactions | Yes | Yes | Yes | No | No | No | Partial | Partial |
| Image/file attachments | Yes | Yes | Yes | Inbound | Yes | No | Yes | Yes |
+22 -19
View File
@@ -1,7 +1,7 @@
---
title: 渠道概览
description: >-
将 LobeHub 代理连接到外部消息平台,如 Discord、Slack、Telegram、QQ、微信、飞书和
将 LobeHub 代理连接到外部消息平台,如 Discord、Slack、Telegram、WhatsApp、QQ、微信、飞书和
Lark,让用户可以直接在他们喜欢的聊天应用中与 AI 助手互动。
tags:
- 渠道
@@ -10,6 +10,7 @@ tags:
- Discord
- Slack
- Telegram
- WhatsApp
- QQ
- 微信
- 飞书
@@ -20,28 +21,29 @@ tags:
渠道功能允许您将 LobeHub 代理连接到外部消息平台。一旦连接,用户可以直接在他们已经使用的聊天应用中与您的 AI 助手互动,无需访问 LobeHub。
> [!NOTE]
> \[!NOTE]
>
> 微信渠道目前需要有效订阅。如果您使用的是没有订阅的社区版,**渠道**设置中可能暂时不会显示微信选项。
## 支持的平台
| 平台 | 描述 |
| ----------------------------------------- | -------------------------- |
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于聊和群聊(需要有效订阅) |
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
| 平台 | 描述 |
| ----------------------------------------- | ------------------------------------- |
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
| [WhatsApp](/docs/usage/channels/whatsapp) | 通过 Meta Cloud API 连接到 WhatsApp,用于用户私聊 |
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于聊和私信 |
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
## 工作原理
每个渠道集成都通过将目标平台上的机器人账户与 LobeHub 代理连接来实现。当用户向机器人发送消息时,LobeHub 会通过代理处理消息并将响应发送回同一对话。
- **按代理配置** — 每个代理可以拥有自己的一组渠道连接,因此不同的代理可以服务于不同的平台或社区。
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Slack、Telegram、QQ、微信、飞书和 Lark。LobeHub 会自动将消息路由到正确的代理。
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Slack、Telegram、WhatsApp、QQ、微信、飞书和 Lark。LobeHub 会自动将消息路由到正确的代理。
- **安全的凭据存储** — 所有机器人令牌和应用密钥在存储前都会被加密。
## 快速开始
@@ -51,6 +53,7 @@ tags:
- [Discord](/docs/usage/channels/discord)
- [Slack](/docs/usage/channels/slack)
- [Telegram](/docs/usage/channels/telegram)
- [WhatsApp](/docs/usage/channels/whatsapp)
- [QQ](/docs/usage/channels/qq)
- [微信](/docs/usage/channels/wechat)
- [飞书](/docs/usage/channels/feishu)
@@ -62,10 +65,10 @@ tags:
所有平台均支持文本消息。某些功能因平台而异:
| 功能 | Discord | Slack | Telegram | QQ | 微信 | 飞书 | Lark |
| --------- | ------- | ----- | -------- | -- | -- | ---- | ---- |
| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 部分支持 | 部分支持 |
| 图片 / 文件附件 | 是 | 是 | 是 | 是 | 否 | 是 | 是 |
| 功能 | Discord | Slack | Telegram | WhatsApp | QQ | 微信 | 飞书 | Lark |
| --------- | ------- | ----- | -------- | -------- | -- | -- | ---- | ---- |
| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 群组聊天 | 是 | 是 | 是 | 否 | 是 | 是 | 是 | 是 |
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 否 | 部分支持 | 部分支持 |
| 图片 / 文件附件 | 是 | 是 | 是 | 仅入站 | 是 | 否 | 是 | 是 |
+170
View File
@@ -0,0 +1,170 @@
---
title: Connect LobeHub to WhatsApp
description: >-
Learn how to connect a WhatsApp Business bot to your LobeHub agent via the
Meta Cloud API, enabling your AI assistant to chat with users in WhatsApp
direct conversations.
tags:
- WhatsApp
- Message Channels
- Bot Setup
- Integration
---
# Connect LobeHub to WhatsApp
By connecting a WhatsApp channel to your LobeHub agent, users can interact with the AI assistant directly through WhatsApp. WhatsApp integration uses the official **Meta Cloud API** for WhatsApp Business — there is no third-party broker between Meta and LobeHub.
> \[!NOTE]
>
> WhatsApp Cloud API only delivers 1:1 (direct) conversations to bots today. Group chats are not supported by the platform itself.
## Prerequisites
- A LobeHub account with an active subscription
- A [Meta for Developers](https://developers.facebook.com/) account
- A Meta Business Portfolio (created automatically the first time you add the WhatsApp product to an app)
- A phone number you control. You can either:
- Use the **test phone number** Meta provisions for free with every new WhatsApp app (recommended for evaluation), or
- Register a **real business phone number** that is not currently active on the WhatsApp consumer app
## Step 1: Create a Meta App with the WhatsApp Product
<Steps>
### Open the Meta Developers dashboard
Go to [developers.facebook.com/apps](https://developers.facebook.com/apps) and click **Create App**. Choose **Business** as the app type and give it a name.
### Add the WhatsApp product
From the app dashboard, find the **WhatsApp** product card and click **Set up**. Meta will provision a test business account, a test phone number, and a temporary access token.
### Note your Phone Number ID
Open **WhatsApp → API setup**. The page lists a **Phone number ID** (a numeric value, not the phone number itself). Copy it — you will paste it into LobeHub as the **Phone Number ID**.
> **Tip:** The display phone number (e.g. `+1 555-555-1234`) is for human reference only. LobeHub needs the numeric Phone Number ID right next to it.
</Steps>
## Step 2: Generate a Long-Lived Access Token
The temporary token Meta gives you on the API setup page expires after 24 hours. For a production deployment you need a **System User access token** that does not expire.
<Steps>
### Create a System User
In **Business Settings → Users → System Users**, click **Add** and create a system user with the **Admin** role. Name it something recognizable (e.g. `lobehub-whatsapp`).
### Assign the WhatsApp asset
Still in Business Settings, open the new system user, click **Add Assets**, choose **Apps**, select your WhatsApp app, and grant **Full control**.
### Generate the token
Back on the system user page, click **Generate New Token**. Select your app, then check both:
- `whatsapp_business_messaging`
- `whatsapp_business_management`
Choose **Never** for token expiration. Copy the token immediately — Meta only shows it once.
</Steps>
## Step 3: Pick a Verify Token and Note the App Secret
<Steps>
### Choose your Verify Token
Pick any random string (for example, the output of `openssl rand -hex 16`). This is a shared secret only you and Meta know. You will paste the **same value** in two places: LobeHub's channel settings, and Meta's webhook configuration.
### Copy the App Secret
In your Meta App's **Settings → Basic** page, click **Show** next to **App Secret** and copy it. The App Secret is used by LobeHub to verify the `X-Hub-Signature-256` header on every inbound webhook delivery.
> **Important:** The Access Token, Verify Token, and App Secret are all sensitive credentials. Never commit them to source control or share them in screenshots.
</Steps>
## Step 4: Configure WhatsApp in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **WhatsApp** from the platform list.
### Fill in the credentials
| Field | Value |
| ------------------- | -------------------------------------------------- |
| **Phone Number ID** | The numeric Phone Number ID from Meta's API setup |
| **Access Token** | The long-lived System User token from Step 2 |
| **Verify Token** | The random string you chose in Step 3 |
| **App Secret** | The App Secret from Settings → Basic (recommended) |
### Save Configuration
Click **Save Configuration**. LobeHub will encrypt your credentials, call the Cloud API once to verify the token works, and surface a **Webhook URL** for the next step.
> **Note:** Unlike Telegram, the WhatsApp Cloud API does not allow programmatic webhook registration. LobeHub cannot wire the URL for you — you must paste it in the Meta dashboard yourself in Step 5.
</Steps>
## Step 5: Wire the Webhook in the Meta Dashboard
<Steps>
### Copy the Webhook URL
In LobeHub's WhatsApp channel detail page, copy the **Webhook URL** displayed under the credentials section. It looks like `https://app.lobehub.com/api/agent/webhooks/whatsapp/<your-phone-number-id>`.
### Paste it in Meta
Back in your Meta App's dashboard, go to **WhatsApp → Configuration → Webhook**, click **Edit**, and:
- **Callback URL:** paste the LobeHub Webhook URL.
- **Verify token:** paste the same value you entered in LobeHub.
Click **Verify and Save**. Meta sends a `GET` request with `hub.challenge`; LobeHub responds with the challenge if the verify token matches.
### Subscribe to the `messages` field
After the verification handshake succeeds, Meta shows a list of webhook fields. Click **Manage** and turn on at least the **`messages`** field. This is the only field LobeHub needs.
</Steps>
## Step 6: Test the Connection
<Steps>
### Run Test Connection
Click **Test Connection** in LobeHub's channel settings. LobeHub re-runs the credential verification against the Cloud API and reports any errors with the exact Meta error message.
### Send a real message
- **Test phone number:** in **WhatsApp → API setup**, add your personal WhatsApp account to the recipient allow-list, then send a message from your phone to the test number.
- **Production number:** open WhatsApp on any phone, send a message to your business number.
Within a few seconds your LobeHub agent should reply.
</Steps>
## Configuration Reference
| Field | Required | Description |
| ------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **Phone Number ID** | Yes | The numeric ID shown next to your phone number in **WhatsApp → API setup**. Used both as the bot identity and webhook path. |
| **Access Token** | Yes | A System User access token with `whatsapp_business_messaging` + `whatsapp_business_management` scopes. Use a token that does not expire. |
| **Verify Token** | Yes | A random string you choose. Must be entered identically in LobeHub and in the Meta webhook configuration. |
| **App Secret** | Recommended | Meta App Secret. When set, every inbound webhook is rejected unless its `X-Hub-Signature-256` header matches. |
## Feature Notes
WhatsApp Cloud API has a few limitations LobeHub maps as follows:
- **Markdown** — outbound messages are converted to WhatsApp's lightweight markup (`*bold*`, `_italic_`, `~strike~`, `` `code` ``).
- **Message editing** — Cloud API does not support editing sent messages, so LobeHub only sends the **final reply**, not per-step progress edits. The agent will still appear to be "typing" while it works.
- **Reactions** — the 👀 / ✏️ status reactions used on Discord and Slack are not surfaced on WhatsApp.
- **Attachments** — inbound images, video, audio, voice notes, documents and stickers are downloaded on demand and forwarded to the model. Outbound replies are text-only today.
- **Group chats** — not delivered to bots by Cloud API.
## Troubleshooting
- **"Verify and Save" fails in the Meta dashboard.** The Verify Token in LobeHub and Meta must match exactly (no trailing whitespace). Re-paste both values, save, and try again.
- **`Invalid OAuth access token` on Save / Test Connection.** Your access token expired or has the wrong scopes. Generate a new System User token with the two `whatsapp_business_*` permissions and **Never** as the expiration.
- **Webhook delivery is rejected with `401 Invalid signature`.** The App Secret in LobeHub doesn't match the one in the Meta App. Either update LobeHub with the correct App Secret, or remove it (and rely on the Verify Token alone — less secure but functional).
- **Bot doesn't respond on the test number.** The test phone number only delivers to numbers explicitly added to its allow-list in **WhatsApp → API setup → To**. Add your phone there and try again.
- **Bot doesn't respond on a real number.** Check that the **`messages`** webhook field is subscribed in **WhatsApp → Configuration → Webhook → Manage**. Without it Meta never calls LobeHub.
+169
View File
@@ -0,0 +1,169 @@
---
title: 将 LobeHub 连接到 WhatsApp
description: >-
学习如何通过 Meta Cloud API 将 WhatsApp Business 机器人连接到 LobeHub
代理,使您的 AI 助手能够在 WhatsApp 私信对话中与用户互动。
tags:
- WhatsApp
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到 WhatsApp
通过将 WhatsApp 渠道连接到您的 LobeHub 代理,用户可以直接在 WhatsApp 中与 AI 助手互动。WhatsApp 集成使用官方的 **Meta Cloud API**Meta 与 LobeHub 之间没有第三方中转。
> \[!NOTE]
>
> WhatsApp Cloud API 当前仅向机器人投递 1:1(私聊)会话,平台本身不支持群聊。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个 [Meta for Developers](https://developers.facebook.com/) 账户
- 一个 Meta 商业组合(首次为应用添加 WhatsApp 产品时会自动创建)
- 一个您可控制的电话号码。可以二选一:
- 使用 Meta 在每个新 WhatsApp 应用中免费提供的 **测试号码**(推荐用于评估)
- 注册一个 **真实的企业电话号码**,且该号码当前未在 WhatsApp 个人版中激活
## 第一步:创建带有 WhatsApp 产品的 Meta 应用
<Steps>
### 打开 Meta Developers 控制台
访问 [developers.facebook.com/apps](https://developers.facebook.com/apps) 并点击 **Create App**。应用类型选择 **Business** 并填写名称。
### 添加 WhatsApp 产品
在应用控制台中,找到 **WhatsApp** 产品卡片并点击 **Set up**。Meta 会自动配置一个测试商业账户、一个测试电话号码和一个临时访问令牌。
### 记录 Phone Number ID
打开 **WhatsApp → API setup**。页面会列出一个 **Phone number ID**(一串数字,不是手机号本身)。复制该值 —— 稍后将作为 **Phone Number ID** 粘贴到 LobeHub。
> **提示:** 显示的电话号码(例如 `+1 555-555-1234`)只是给人看的;LobeHub 需要的是它旁边的数字 Phone Number ID。
</Steps>
## 第二步:生成长期 Access Token
API setup 页面给出的临时令牌仅有 24 小时有效期。生产部署需要一个 **永不过期的 System User Access Token**。
<Steps>
### 创建 System User
在 **Business Settings → Users → System Users** 中点击 **Add**,创建一个角色为 **Admin** 的系统用户。建议起一个能识别的名字(如 `lobehub-whatsapp`)。
### 关联 WhatsApp 资产
仍在 Business Settings 中打开新创建的系统用户,点击 **Add Assets**,选择 **Apps**,挑选您的 WhatsApp 应用,授予 **Full control** 权限。
### 生成令牌
回到系统用户页面,点击 **Generate New Token**。选择您的应用,并勾选两项权限:
- `whatsapp_business_messaging`
- `whatsapp_business_management`
令牌过期时间选择 **Never**。立即复制令牌 ——Meta 只会展示一次。
</Steps>
## 第三步:选择 Verify Token 并记录 App Secret
<Steps>
### 选择 Verify Token
随机选择一串字符串(例如 `openssl rand -hex 16` 的输出)。这是只有您和 Meta 知道的共享密钥。**完全相同的值** 需要分别填写在两处:LobeHub 渠道设置中和 Meta 的 Webhook 配置中。
### 复制 App Secret
在 Meta 应用的 **Settings → Basic** 页面,点击 **App Secret** 旁的 **Show** 并复制。LobeHub 使用 App Secret 校验每条入站 Webhook 的 `X-Hub-Signature-256` 头。
> **重要提示:** Access Token、Verify Token 与 App Secret 都是敏感凭据,切勿提交到代码仓库或在截图中泄露。
</Steps>
## 第四步:在 LobeHub 中配置 WhatsApp
<Steps>
### 打开渠道设置
在 LobeHub 中,进入您的代理设置,选择 **渠道** 标签。在平台列表中点击 **WhatsApp**。
### 填写凭据
| 字段 | 值 |
| ------------------- | ------------------------------------- |
| **Phone Number ID** | 来自 Meta API setup 的数字 Phone Number ID |
| **Access Token** | 第二步中生成的长期 System User Token |
| **Verify Token** | 第三步中选择的随机字符串 |
| **App Secret** | Settings → Basic 页面的 App Secret(推荐填写) |
### 保存配置
点击 **保存配置**。LobeHub 会加密您的凭据,调用一次 Cloud API 验证 Token 可用,并在凭据下方显示 **Webhook URL** 供下一步使用。
> **注意:** 与 Telegram 不同,WhatsApp Cloud API 不支持程序化注册 WebhookLobeHub 无法替您在 Meta 控制台填写 URL,需要您在第五步中自行粘贴。
</Steps>
## 第五步:在 Meta 控制台配置 Webhook
<Steps>
### 复制 Webhook URL
在 LobeHub 的 WhatsApp 渠道详情页中,复制凭据区域下方显示的 **Webhook URL**。形如 `https://app.lobehub.com/api/agent/webhooks/whatsapp/<your-phone-number-id>`。
### 粘贴到 Meta
回到 Meta 应用控制台的 **WhatsApp → Configuration → Webhook**,点击 **Edit**,并填写:
- **Callback URL** 粘贴 LobeHub 的 Webhook URL。
- **Verify token** 粘贴与 LobeHub 中完全相同的值。
点击 **Verify and Save**。Meta 会发送一个带 `hub.challenge` 的 GET 请求;如果 Verify Token 匹配,LobeHub 会回显该挑战值。
### 订阅 `messages` 字段
校验握手成功后,Meta 会展示一组可订阅的 Webhook 字段。点击 **Manage** 至少打开 **`messages`** 字段。这是 LobeHub 唯一需要的字段。
</Steps>
## 第六步:测试连接
<Steps>
### 运行测试连接
在 LobeHub 渠道设置中点击 **测试连接**。LobeHub 会再次调用 Cloud API 验证凭据,并把 Meta 返回的具体错误信息透传给你。
### 发送一条真实消息
- **测试号码:** 在 **WhatsApp → API setup** 里把您的个人 WhatsApp 账号加入收件人允许列表,然后用手机给测试号码发消息。
- **生产号码:** 用任意手机的 WhatsApp 给您的企业号码发消息。
几秒内 LobeHub 代理就会回复。
</Steps>
## 配置参考
| 字段 | 是否必需 | 描述 |
| ------------------- | ---- | -------------------------------------------------------------------------------------------------------- |
| **Phone Number ID** | 是 | **WhatsApp → API setup** 中显示在号码旁的数字 ID。同时作为机器人标识与 Webhook 路径。 |
| **Access Token** | 是 | 拥有 `whatsapp_business_messaging` + `whatsapp_business_management` 两个权限的 System User Access Token,建议永不过期。 |
| **Verify Token** | 是 | 您自行选择的随机字符串。LobeHub 与 Meta Webhook 配置中的值必须完全一致。 |
| **App Secret** | 推荐 | Meta App Secret。配置后每条入站 Webhook 都会校验 `X-Hub-Signature-256`,签名不匹配将被拒绝。 |
## 能力说明
WhatsApp Cloud API 有一些平台层面的限制,LobeHub 的对接行为如下:
- **Markdown** —— 出站消息会转换为 WhatsApp 的轻量级语法(`*粗体*`、`_斜体_`、`~删除线~`、`` `代码` ``)。
- **消息编辑** —— Cloud API 不支持编辑已发送消息,因此 LobeHub 只发送 **最终回复**,不会逐步刷新中间进度。Agent 工作时仍会显示 "输入中" 状态。
- **表情反应** —— Discord/Slack 上使用的 👀 / ✏️ 状态反应在 WhatsApp 上不会显示。
- **附件** —— 入站的图片、视频、音频、语音备忘、文件、贴纸会按需下载并送给模型。出站消息当前仅支持文本。
- **群聊** —— Cloud API 不向机器人投递群聊,无法支持。
## 故障排除
- **Meta 控制台 "Verify and Save" 失败。** LobeHub 与 Meta 的 Verify Token 必须完全一致(不能有尾部空格)。重新粘贴两边的值,保存后再试。
- **保存或测试连接时出现 `Invalid OAuth access token`。** 您的 Access Token 过期或权限不足。重新生成一个 System User Token,勾选两个 `whatsapp_business_*` 权限并将过期时间设为 **Never**。
- **Webhook 投递返回 `401 Invalid signature`。** LobeHub 中的 App Secret 与 Meta 应用中的不一致。要么在 LobeHub 中更新为正确的 App Secret,要么清空它(仅依赖 Verify Token,安全性较低但仍可工作)。
- **测试号码下机器人不回复。** 测试号码只会向 **WhatsApp → API setup → To** 中明确添加的号码投递。把您的手机号加进去再试。
- **生产号码下机器人不回复。** 检查 **WhatsApp → Configuration → Webhook → Manage** 是否已订阅 **`messages`** 字段。未订阅时 Meta 不会调用 LobeHub。
+16 -1
View File
@@ -139,5 +139,20 @@
"channel.wechatScanToConnect": "Scan QR Code to Connect",
"channel.wechatTips": "Please update WeChat to the latest version and restart it. The ClawBot plugin is in gradual rollout, so check Settings > Plugins to confirm access.",
"channel.wechatUserId": "WeChat User ID",
"channel.wechatUserIdHint": "WeChat account identifier returned by the authorization flow."
"channel.wechatUserIdHint": "WeChat account identifier returned by the authorization flow.",
"channel.whatsapp.accessToken": "Access Token",
"channel.whatsapp.accessTokenHint": "Long-lived System User access token with `whatsapp_business_messaging` and `whatsapp_business_management` scopes. Token will be encrypted and stored securely.",
"channel.whatsapp.appSecret": "App Secret",
"channel.whatsapp.appSecretHint": "Meta App Secret. Required — used to verify the X-Hub-Signature-256 header on every inbound webhook. Webhooks with a missing or mismatched signature are rejected.",
"channel.whatsapp.description": "Connect this assistant to WhatsApp via the Meta Cloud API for direct user chats.",
"channel.whatsapp.phoneNumberId": "Phone Number ID",
"channel.whatsapp.phoneNumberIdHint": "Numeric Phone Number ID from the WhatsApp tab in your Meta App dashboard. This is the bot identifier and is used in the webhook URL.",
"channel.whatsapp.phoneNumberIdPlaceholder": "e.g. 123456789012345",
"channel.whatsapp.verifyToken": "Verify Token",
"channel.whatsapp.verifyTokenHint": "Operator-defined secret. Paste the same value into the Meta Cloud API webhook configuration so the GET verification handshake succeeds.",
"channel.whatsapp.webhookManualSetup": "WhatsApp does not allow programmatic webhook registration. Copy this URL into the Meta App dashboard (\"WhatsApp → Configuration → Webhooks → Callback URL\"), set the Verify Token to match, and subscribe to the `messages` field.",
"channel.whatsappBaileys.connectionLabel": "Connection Label",
"channel.whatsappBaileys.connectionLabelHint": "Operator-chosen identifier for this WhatsApp account (e.g. a phone number or alias). Used as the connection id in the gateway and the bot status dashboard.",
"channel.whatsappBaileys.connectionLabelPlaceholder": "e.g. +15551234567 or my-whatsapp",
"channel.whatsappBaileys.description": "Connect a personal WhatsApp account via QR pairing. Available on LobeHub Cloud only. WhatsApp Terms of Service prohibit unofficial clients; Meta may ban numbers exhibiting automated behavior — use a dedicated phone number for this bot."
}
+16 -1
View File
@@ -139,5 +139,20 @@
"channel.wechatScanToConnect": "扫码连接",
"channel.wechatTips": "请将微信更新至最新版本并重新启动。ClawBot 插件正在逐步推广,请前往设置 > 插件确认是否已获得访问权限。",
"channel.wechatUserId": "微信用户 ID",
"channel.wechatUserIdHint": "通过授权流程返回的微信账号标识符。"
"channel.wechatUserIdHint": "通过授权流程返回的微信账号标识符。",
"channel.whatsapp.accessToken": "Access Token",
"channel.whatsapp.accessTokenHint": "拥有 `whatsapp_business_messaging` 和 `whatsapp_business_management` 权限的系统用户长期 Access Token,将被加密存储。",
"channel.whatsapp.appSecret": "App Secret",
"channel.whatsapp.appSecretHint": "Meta App Secret,必填。用于校验每条入站 Webhook 的 X-Hub-Signature-256 头,签名缺失或不匹配将被拒绝。",
"channel.whatsapp.description": "通过 Meta Cloud API 将助手接入 WhatsApp,与用户进行 1 对 1 对话。",
"channel.whatsapp.phoneNumberId": "Phone Number ID",
"channel.whatsapp.phoneNumberIdHint": "Meta App 控制台 WhatsApp 选项卡中的数字 Phone Number ID。它同时作为机器人标识符,用于 Webhook URL。",
"channel.whatsapp.phoneNumberIdPlaceholder": "例如 123456789012345",
"channel.whatsapp.verifyToken": "Verify Token",
"channel.whatsapp.verifyTokenHint": "由你自己定义的校验串。在 Meta Cloud API Webhook 配置里填入完全相同的值,GET 校验握手才能通过。",
"channel.whatsapp.webhookManualSetup": "WhatsApp 不支持自动注册 Webhook,请将此 URL 粘贴到 Meta App 控制台的 “WhatsApp → Configuration → Webhooks → Callback URL”,把 Verify Token 设置为与上面一致,并订阅 `messages` 字段。",
"channel.whatsappBaileys.connectionLabel": "连接标签",
"channel.whatsappBaileys.connectionLabelHint": "为此 WhatsApp 账号自定义的标识(例如手机号或别名),将作为网关连接 ID 和机器人状态面板上的唯一标识。",
"channel.whatsappBaileys.connectionLabelPlaceholder": "例如 +15551234567 或 my-whatsapp",
"channel.whatsappBaileys.description": "通过扫码配对接入个人 WhatsApp 账号。仅在 LobeHub Cloud 提供。WhatsApp 服务条款禁止非官方客户端,Meta 可能封禁存在自动化行为的号码 —— 请使用专用手机号给此 bot。"
}
+1
View File
@@ -182,6 +182,7 @@
"@chat-adapter/slack": "^4.23.0",
"@chat-adapter/state-ioredis": "^4.23.0",
"@chat-adapter/telegram": "^4.23.0",
"@chat-adapter/whatsapp": "^4.26.0",
"@codesandbox/sandpack-react": "^2.20.0",
"@discordjs/rest": "^2.6.0",
"@dnd-kit/core": "^6.3.1",
@@ -396,6 +396,47 @@ describe('AgentBotProviderModel', () => {
expect(results).toHaveLength(0);
});
it('should return whatsapp-baileys providers even with empty credentials', async () => {
// Pre-pairing state: row exists but no auth-state blob has been
// written back yet by the Node gateway.
await serverDB.insert(agentBotProviders).values({
agentId,
applicationId: '+15551234567',
credentials: null,
enabled: true,
platform: 'whatsapp-baileys',
userId,
});
const results = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
'whatsapp-baileys',
);
expect(results).toHaveLength(1);
expect(results[0].applicationId).toBe('+15551234567');
expect(results[0].credentials).toEqual({});
});
it('should return whatsapp-baileys providers with only baileysAuthState', async () => {
// Post-pairing state: credentials JSON only contains the auth blob,
// no botToken/appSecret. The legacy field check would reject this row
// for any other platform.
const model = new AgentBotProviderModel(serverDB, userId);
await model.create({
agentId,
applicationId: 'my-whatsapp',
credentials: { baileysAuthState: { v: 1, data: 'xxx' } as any },
platform: 'whatsapp-baileys',
});
const results = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
'whatsapp-baileys',
);
expect(results).toHaveLength(1);
expect(results[0].credentials.baileysAuthState).toBeDefined();
});
it('should skip disabled providers', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
const created = await model.create({
@@ -165,14 +165,30 @@ export class AgentBotProviderModel {
const decrypted: DecryptedBotProvider[] = [];
for (const r of results) {
if (!r.credentials) continue;
if (!r.credentials) {
// whatsapp-baileys has no static credentials — pairing happens via QR
// and the auth-state blob is written back to this row by the Node
// gateway. The "missing credentials" check below would skip it
// forever, so we accept empty rows for this platform only.
if (platform === 'whatsapp-baileys') {
decrypted.push({ ...r, credentials: {} });
}
continue;
}
try {
const credentials = gateKeeper
? JSON.parse((await gateKeeper.decrypt(r.credentials)).plaintext)
: JSON.parse(r.credentials);
if (!credentials.botToken && !credentials.appSecret) continue;
// Legacy guard for platforms whose credentials carry a static API
// token (Discord, QQ, Slack, Telegram, etc.). Newer platforms with
// different credential shapes — including whatsapp-baileys whose
// "credentials" only contain a `baileysAuthState` blob after
// pairing — bypass this check.
if (platform !== 'whatsapp-baileys' && !credentials.botToken && !credentials.appSecret) {
continue;
}
decrypted.push({ ...r, credentials });
} catch {
@@ -0,0 +1,201 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { agentBotProviders } from '@lobechat/database/schemas';
import debug from 'debug';
import { eq } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { getServerDB } from '@/database/core/db-adaptor';
import { gatewayEnv } from '@/envs/gateway';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
const log = debug('lobe-server:bot:baileys-auth-state');
/**
* Internal endpoint used by the private message-gateway-node service to
* persist Baileys auth state (Signal session creds + ratchet keys) on
* lobehub's side. The Node container is stateless — restarting it doesn't
* trigger a re-pair because the encrypted blob lives here.
*
* Auth: HMAC-SHA256 over the canonical string
* `${connectionId}:${timestamp}:${sha256-of-body-hex}`
* keyed with `BAILEYS_AUTH_STATE_SECRET`. Mirrors `signAuthStateRequest`
* in the message-gateway-node repo (kept in a separate, private repo so
* the WhatsApp Baileys integration can ship without entering the
* open-source LobeHub codebase).
*
* Storage: the gzipped+base64 blob is written into the encrypted
* `agent_bot_providers.credentials` JSON under the `baileysAuthState` key.
* This piggybacks on the existing per-row encryption so we don't need a
* second secret manager. The row is keyed by `id` (UUID), which is the
* `connectionId` the gateway sees.
*/
// 5-minute timestamp skew window — long enough to tolerate clock drift
// between the Node gateway and lobehub, short enough that a leaked
// signature can't be replayed indefinitely.
const MAX_TIMESTAMP_SKEW_MS = 5 * 60 * 1000;
interface VerifyResult {
ok: boolean;
reason?: string;
}
function verifySignature(
connectionId: string,
body: string,
headerSig: string | null,
headerTs: string | null,
secret: string,
): VerifyResult {
if (!headerSig || !headerTs) return { ok: false, reason: 'missing signature headers' };
const ts = Number(headerTs);
if (!Number.isFinite(ts)) return { ok: false, reason: 'invalid timestamp' };
if (Math.abs(Date.now() - ts) > MAX_TIMESTAMP_SKEW_MS) {
return { ok: false, reason: 'timestamp skew too large' };
}
const bodyHash = createHmac('sha256', '').update(body, 'utf8').digest('hex');
const canonical = `${connectionId}:${ts}:${bodyHash}`;
const expected = createHmac('sha256', secret).update(canonical, 'utf8').digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(headerSig, 'hex');
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return { ok: false, reason: 'signature mismatch' };
}
return { ok: true };
}
function authSecret(): string | null {
return gatewayEnv.BAILEYS_AUTH_STATE_SECRET ?? null;
}
/**
* Read+decrypt+mutate+re-encrypt the `credentials` JSON for a given
* provider row. We do this in the route handler rather than the model so
* the model contract stays user-scoped — this endpoint is system-scoped
* (no userId is available from the gateway request).
*/
async function loadCredentials(connectionId: string): Promise<Record<string, unknown> | null> {
const db = await getServerDB();
const [row] = await db
.select({ credentials: agentBotProviders.credentials })
.from(agentBotProviders)
.where(eq(agentBotProviders.id, connectionId))
.limit(1);
if (!row) return null;
if (!row.credentials) return {};
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
try {
const { plaintext } = await gateKeeper.decrypt(row.credentials);
return JSON.parse(plaintext) as Record<string, unknown>;
} catch {
// Row exists but credentials are unreadable — surface as "no state"
// rather than 500, matching the behavior the gateway expects when the
// row was just created without a saved auth state yet.
return {};
}
}
async function saveCredentials(
connectionId: string,
credentials: Record<string, unknown>,
): Promise<boolean> {
const db = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const ciphertext = await gateKeeper.encrypt(JSON.stringify(credentials));
const result = await db
.update(agentBotProviders)
.set({ credentials: ciphertext, updatedAt: new Date() })
.where(eq(agentBotProviders.id, connectionId))
.returning({ id: agentBotProviders.id });
return result.length > 0;
}
export const GET = async (
req: Request,
{ params }: { params: Promise<{ connectionId: string }> },
): Promise<Response> => {
const { connectionId } = await params;
const secret = authSecret();
if (!secret) {
return NextResponse.json({ error: 'auth-state secret not configured' }, { status: 503 });
}
const verify = verifySignature(
connectionId,
'', // GET has no body
req.headers.get('x-baileys-signature'),
req.headers.get('x-baileys-timestamp'),
secret,
);
if (!verify.ok) {
log('GET %s rejected: %s', connectionId, verify.reason);
return NextResponse.json({ error: verify.reason }, { status: 401 });
}
const credentials = await loadCredentials(connectionId);
if (!credentials) {
return NextResponse.json({ error: 'connection not found' }, { status: 404 });
}
const blob = credentials.baileysAuthState;
if (!blob || typeof blob !== 'object') {
// No persisted auth state yet — gateway will fall through to pairing.
return NextResponse.json({ error: 'no auth state stored' }, { status: 404 });
}
return NextResponse.json(blob);
};
export const PUT = async (
req: Request,
{ params }: { params: Promise<{ connectionId: string }> },
): Promise<Response> => {
const { connectionId } = await params;
const secret = authSecret();
if (!secret) {
return NextResponse.json({ error: 'auth-state secret not configured' }, { status: 503 });
}
const body = await req.text();
const verify = verifySignature(
connectionId,
body,
req.headers.get('x-baileys-signature'),
req.headers.get('x-baileys-timestamp'),
secret,
);
if (!verify.ok) {
log('PUT %s rejected: %s', connectionId, verify.reason);
return NextResponse.json({ error: verify.reason }, { status: 401 });
}
let blob: unknown;
try {
blob = JSON.parse(body);
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (!blob || typeof blob !== 'object') {
return NextResponse.json({ error: 'expected JSON object' }, { status: 400 });
}
const credentials = await loadCredentials(connectionId);
if (!credentials) {
return NextResponse.json({ error: 'connection not found' }, { status: 404 });
}
credentials.baileysAuthState = blob;
const ok = await saveCredentials(connectionId, credentials);
if (!ok) {
return NextResponse.json({ error: 'connection not found' }, { status: 404 });
}
log('PUT %s persisted (size=%d)', connectionId, body.length);
return new Response(null, { status: 204 });
};
@@ -8,23 +8,36 @@ const log = debug('lobe-server:bot:webhook-route');
* Unified webhook endpoint for Chat SDK bot platforms.
*
* Handles both generic and bot-specific webhook URLs:
* - GET /api/agent/webhooks/[platform]/[appId] (e.g. WhatsApp `hub.challenge` handshake)
* - POST /api/agent/webhooks/[platform]
* - POST /api/agent/webhooks/[platform]/[appId]
*
* Using an optional catch-all `[[...appId]]` ensures both patterns are served
* by a single serverless function, avoiding deployment issues with nested
* dynamic segments on Vercel.
*
* Both verbs delegate to the same `BotMessageRouter` handler — adapters that
* need to differentiate (WhatsApp does GET verification) inspect `request.method`.
*/
export const POST = async (
const dispatch = async (
req: Request,
{ params }: { params: Promise<{ appId?: string[]; platform: string }> },
): Promise<Response> => {
const { platform, appId: appIdSegments } = await params;
const appId = appIdSegments?.[0];
log('Received webhook: platform=%s, appId=%s, url=%s', platform, appId ?? '(none)', req.url);
log(
'Received webhook: method=%s, platform=%s, appId=%s, url=%s',
req.method,
platform,
appId ?? '(none)',
req.url,
);
const router = getBotMessageRouter();
const handler = router.getWebhookHandler(platform, appId);
return handler(req);
};
export const GET = dispatch;
export const POST = dispatch;
+12
View File
@@ -4,17 +4,29 @@ import { z } from 'zod';
export const getGatewayConfig = () => {
return createEnv({
runtimeEnv: {
// Pre-shared HMAC secret used by `message-gateway-node` to sign
// Baileys auth-state callback requests (PUT/GET on the internal
// `/api/agent/internal/baileys-auth-state/:connectionId` endpoint).
BAILEYS_AUTH_STATE_SECRET: process.env.BAILEYS_AUTH_STATE_SECRET,
DEVICE_GATEWAY_SERVICE_TOKEN: process.env.DEVICE_GATEWAY_SERVICE_TOKEN,
DEVICE_GATEWAY_URL: process.env.DEVICE_GATEWAY_URL,
MESSAGE_GATEWAY_ENABLED: process.env.MESSAGE_GATEWAY_ENABLED,
// Optional: dedicated Node-runtime gateway for protocols that need
// libsignal / native deps (today: WhatsApp Baileys). When unset the
// `whatsapp-baileys` platform is hidden in the UI.
MESSAGE_GATEWAY_NODE_SERVICE_TOKEN: process.env.MESSAGE_GATEWAY_NODE_SERVICE_TOKEN,
MESSAGE_GATEWAY_NODE_URL: process.env.MESSAGE_GATEWAY_NODE_URL,
MESSAGE_GATEWAY_SERVICE_TOKEN: process.env.MESSAGE_GATEWAY_SERVICE_TOKEN,
MESSAGE_GATEWAY_URL: process.env.MESSAGE_GATEWAY_URL,
},
server: {
BAILEYS_AUTH_STATE_SECRET: z.string().optional(),
DEVICE_GATEWAY_SERVICE_TOKEN: z.string().optional(),
DEVICE_GATEWAY_URL: z.string().url().optional(),
MESSAGE_GATEWAY_ENABLED: z.string().optional(),
MESSAGE_GATEWAY_NODE_SERVICE_TOKEN: z.string().optional(),
MESSAGE_GATEWAY_NODE_URL: z.string().url().optional(),
MESSAGE_GATEWAY_SERVICE_TOKEN: z.string().optional(),
MESSAGE_GATEWAY_URL: z.string().url().optional(),
},
+23
View File
@@ -101,6 +101,29 @@ export default {
'channel.secretTokenHint': 'Optional. Used to verify webhook requests from Telegram.',
'channel.secretTokenPlaceholder': 'Optional secret for webhook verification',
'channel.telegram.description': 'Connect this assistant to Telegram for private and group chats.',
'channel.whatsapp.description':
'Connect this assistant to WhatsApp via the Meta Cloud API for direct user chats.',
'channel.whatsapp.phoneNumberId': 'Phone Number ID',
'channel.whatsapp.phoneNumberIdHint':
'Numeric Phone Number ID from the WhatsApp tab in your Meta App dashboard. This is the bot identifier and is used in the webhook URL.',
'channel.whatsapp.phoneNumberIdPlaceholder': 'e.g. 123456789012345',
'channel.whatsapp.accessToken': 'Access Token',
'channel.whatsapp.accessTokenHint':
'Long-lived System User access token with `whatsapp_business_messaging` and `whatsapp_business_management` scopes. Token will be encrypted and stored securely.',
'channel.whatsapp.verifyToken': 'Verify Token',
'channel.whatsapp.verifyTokenHint':
'Operator-defined secret. Paste the same value into the Meta Cloud API webhook configuration so the GET verification handshake succeeds.',
'channel.whatsapp.appSecret': 'App Secret',
'channel.whatsapp.appSecretHint':
'Meta App Secret. Required — used to verify the X-Hub-Signature-256 header on every inbound webhook. Webhooks with a missing or mismatched signature are rejected.',
'channel.whatsapp.webhookManualSetup':
'WhatsApp does not allow programmatic webhook registration. Copy this URL into the Meta App dashboard ("WhatsApp → Configuration → Webhooks → Callback URL"), set the Verify Token to match, and subscribe to the `messages` field.',
'channel.whatsappBaileys.description':
'Connect a personal WhatsApp account via QR pairing. Available on LobeHub Cloud only. WhatsApp Terms of Service prohibit unofficial clients; Meta may ban numbers exhibiting automated behavior — use a dedicated phone number for this bot.',
'channel.whatsappBaileys.connectionLabel': 'Connection Label',
'channel.whatsappBaileys.connectionLabelHint':
'Operator-chosen identifier for this WhatsApp account (e.g. a phone number or alias). Used as the connection id in the gateway and the bot status dashboard.',
'channel.whatsappBaileys.connectionLabelPlaceholder': 'e.g. +15551234567 or my-whatsapp',
'channel.testConnection': 'Test Connection',
'channel.testFailed': 'Connection test failed',
'channel.testSuccess': 'Connection test passed',
@@ -8,6 +8,8 @@ import { PlatformRegistry } from './registry';
import { slack } from './slack/definition';
import { telegram } from './telegram/definition';
import { wechat } from './wechat/definition';
import { whatsapp } from './whatsapp/definition';
import { whatsappBaileys } from './whatsapp-baileys/definition';
export {
displayToolCallsField,
@@ -57,6 +59,8 @@ export { qq } from './qq/definition';
export { slack } from './slack/definition';
export { telegram } from './telegram/definition';
export { wechat } from './wechat/definition';
export { whatsapp } from './whatsapp/definition';
export { whatsappBaileys } from './whatsapp-baileys/definition';
export const platformRegistry = new PlatformRegistry();
@@ -67,3 +71,5 @@ platformRegistry.register(feishu);
platformRegistry.register(lark);
platformRegistry.register(qq);
platformRegistry.register(wechat);
platformRegistry.register(whatsapp);
platformRegistry.register(whatsappBaileys);
@@ -0,0 +1,228 @@
import debug from 'debug';
import { gatewayEnv } from '@/envs/gateway';
import { getMessageGatewayClient } from '@/server/services/gateway/MessageGatewayClient';
import {
BOT_RUNTIME_STATUSES,
getRuntimeStatusErrorMessage,
updateBotRuntimeStatus,
} from '@/server/services/gateway/runtimeStatus';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type UsageStats,
type ValidationResult,
} from '../types';
import { formatUsageStats } from '../utils';
const log = debug('bot-platform:whatsapp-baileys:bot');
const PLATFORM_ID = 'whatsapp-baileys';
/**
* Decode lobehub's composite thread id format `whatsapp-baileys:<jid>` into
* the Baileys-native JID (e.g. `15551234567@s.whatsapp.net` for 1:1 chats,
* `12345-6789@g.us` for groups). The same format is produced by the Node
* gateway when it forwards inbound events to lobehub's webhook.
*/
function decodeThread(platformThreadId: string): string {
const parts = platformThreadId.split(':');
if (parts.length < 2 || parts[0] !== PLATFORM_ID) return platformThreadId;
return parts.slice(1).join(':');
}
class WhatsAppBaileysClient implements PlatformClient {
readonly id = PLATFORM_ID;
readonly applicationId: string;
private readonly config: BotProviderConfig;
private readonly context: BotPlatformRuntimeContext;
/**
* Connection id used by `MessageGatewayClient`. We reuse the
* `agentBotProvider.id` shape elsewhere in the codebase, but at this
* layer we only have `applicationId` — and applicationId is already
* unique per provider for this platform (operators pick a label they
* own). Sufficient for the MVP single-replica gateway.
*/
private get connectionId(): string {
return this.applicationId;
}
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
this.config = config;
this.context = context;
this.applicationId = config.applicationId;
}
// ─── Lifecycle ───
async start(): Promise<void> {
log('Starting WhatsAppBaileysBot connectionId=%s', this.connectionId);
await updateBotRuntimeStatus({
applicationId: this.applicationId,
platform: this.id,
status: BOT_RUNTIME_STATUSES.starting,
});
const gateway = getMessageGatewayClient();
if (!gateway.isNodeBackendConfigured) {
const err = new Error(
'whatsapp-baileys requires MESSAGE_GATEWAY_NODE_URL + MESSAGE_GATEWAY_NODE_SERVICE_TOKEN',
);
await updateBotRuntimeStatus({
applicationId: this.applicationId,
errorMessage: err.message,
platform: this.id,
status: BOT_RUNTIME_STATUSES.failed,
});
throw err;
}
try {
await gateway.connect({
applicationId: this.applicationId,
connectionId: this.connectionId,
connectionMode: 'websocket',
// Baileys does not need pre-shared credentials — pairing happens
// through QR. The gateway-side store keeps the resulting Signal
// session keys, and on subsequent boots restores them via the
// `/api/agent/internal/baileys-auth-state/:connectionId` callback.
credentials: {},
platform: this.id,
userId: '',
webhookPath: `/api/agent/webhooks/${this.id}/${encodeURIComponent(this.connectionId)}`,
});
// The Node gateway transitions through `pairing` → `connected` async.
// We mark the runtime status as `starting`; the bot status is updated
// to `connected` by the connection.opened webhook handler.
log('whatsapp-baileys connect requested, awaiting pairing/open event');
} catch (error) {
await updateBotRuntimeStatus({
applicationId: this.applicationId,
errorMessage: getRuntimeStatusErrorMessage(error),
platform: this.id,
status: BOT_RUNTIME_STATUSES.failed,
});
throw error;
}
}
async stop(): Promise<void> {
log('Stopping WhatsAppBaileysBot connectionId=%s', this.connectionId);
try {
await getMessageGatewayClient().disconnect(this.connectionId, this.id);
} catch (err) {
log('disconnect threw (will continue tear-down): %O', err);
}
await updateBotRuntimeStatus({
applicationId: this.applicationId,
platform: this.id,
status: BOT_RUNTIME_STATUSES.disconnected,
});
}
// ─── Inbound adapter ───
/**
* Inbound events are forwarded by the Node gateway as raw Baileys
* `messages.upsert` payloads. The chat-sdk adapter that parses these into
* Chat-SDK Messages and dispatches them through `BotMessageRouter` is
* intentionally NOT shipped in this MVP — it's tracked as a follow-up so
* the message-gateway-node skeleton can land for review first.
*
* Returning an empty adapter map is safe because the platform is gated
* behind `MESSAGE_GATEWAY_NODE_URL`: no production caller will reach
* `BotMessageRouter.handleWebhook` for `whatsapp-baileys` until the
* follow-up PR lands.
*/
createAdapter(): Record<string, any> {
return {};
}
// ─── Outbound messenger ───
getMessenger(platformThreadId: string): PlatformMessenger {
const jid = decodeThread(platformThreadId);
const gateway = getMessageGatewayClient();
const platform = this.id;
const connectionId = this.connectionId;
return {
createMessage: async (content) => {
await gateway.sendText(connectionId, `${PLATFORM_ID}:${jid}`, content, platform);
},
// WhatsApp Cloud API and Baileys do not support editing a sent
// message — `supportsMessageEdit: false` already tells the bridge to
// skip step-progress edits, but we keep this defensive impl in case
// an unexpected caller invokes it.
editMessage: async (_messageId, content) => {
await gateway.sendText(connectionId, `${PLATFORM_ID}:${jid}`, content, platform);
},
// Reactions: Baileys supports them via `sock.sendMessage(jid,
// { react: { text: emoji, key } })`. The Node gateway does not yet
// expose a reaction endpoint; we leave these unwired for MVP so the
// bridge falls through (optional methods).
removeReaction: async () => {
// no-op in MVP
},
triggerTyping: async () => {
try {
await gateway.startTyping(connectionId, `${PLATFORM_ID}:${jid}`, platform);
} catch (err) {
log('triggerTyping failed: %O', err);
}
},
};
}
// ─── Helpers ───
extractChatId(platformThreadId: string): string {
return decodeThread(platformThreadId);
}
formatReply(body: string, stats?: UsageStats): string {
if (!stats || !this.config.settings?.showUsageStats) return body;
return `${body}\n\n${formatUsageStats(stats)}`;
}
parseMessageId(compositeId: string): string {
return compositeId;
}
}
export class WhatsAppBaileysClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new WhatsAppBaileysClient(config, context);
}
/**
* No external API to check against — pairing happens through QR scan
* after the bot starts. We only validate that the operator-provided
* label is non-empty and that the Node gateway is configured.
*/
async validateCredentials(
_credentials: Record<string, string>,
_settings?: Record<string, unknown>,
applicationId?: string,
): Promise<ValidationResult> {
const errors: Array<{ field: string; message: string }> = [];
if (!applicationId || !applicationId.trim()) {
errors.push({ field: 'applicationId', message: 'Connection label is required' });
}
if (!gatewayEnv.MESSAGE_GATEWAY_NODE_URL || !gatewayEnv.MESSAGE_GATEWAY_NODE_SERVICE_TOKEN) {
errors.push({
field: 'applicationId',
message:
'message-gateway-node is not configured: set MESSAGE_GATEWAY_NODE_URL + MESSAGE_GATEWAY_NODE_SERVICE_TOKEN',
});
}
if (errors.length > 0) return { errors, valid: false };
return { valid: true };
}
}
@@ -0,0 +1,35 @@
import type { PlatformDefinition } from '../types';
import { WhatsAppBaileysClientFactory } from './client';
import { schema } from './schema';
/**
* Personal WhatsApp via Baileys.
*
* **Hosting**: a private long-running Node gateway (kept in a separate,
* non-open-source repo). The lobehub server itself never opens a WhatsApp
* Web socket — it delegates connect/disconnect/sendText/typing to the Node
* gateway via `MessageGatewayClient`, and receives inbound events at the
* platform's webhook URL.
*
* **ToS warning**: WhatsApp's Terms of Service prohibit the use of
* unofficial reverse-engineered clients. This platform is intentionally
* gated behind `MESSAGE_GATEWAY_NODE_URL` so it only appears in
* deployments that have the gateway wired up (today: LobeHub Cloud).
*/
export const whatsappBaileys: PlatformDefinition = {
clientFactory: new WhatsAppBaileysClientFactory(),
connectionMode: 'websocket',
description: 'Connect a personal WhatsApp account via QR pairing (LobeHub Cloud only).',
documentation: {
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/whatsapp-baileys',
},
id: 'whatsapp-baileys',
name: 'WhatsApp (Baileys)',
schema,
// Gateway forwards events to /api/agent/webhooks/whatsapp-baileys/<connectionId>;
// we don't expose this URL in the UI because pairing flows through QR.
showWebhookUrl: false,
supportsMarkdown: true,
// WhatsApp does not support editing sent messages.
supportsMessageEdit: false,
};
@@ -0,0 +1,70 @@
import { DEFAULT_BOT_DEBOUNCE_MS, MAX_BOT_DEBOUNCE_MS } from '@lobechat/const';
import { displayToolCallsField, userIdField } from '../const';
import type { FieldSchema } from '../types';
/**
* Schema for the WhatsApp Baileys (personal account) platform.
*
* Unlike the Cloud API path, Baileys does not need pre-shared credentials —
* pairing happens through a QR code emitted at runtime by the Node gateway.
* The `applicationId` is left for operators who want a stable identifier
* for the connection (a phone number or label); it is not validated against
* any external API.
*/
export const schema: FieldSchema[] = [
{
key: 'applicationId',
description: 'channel.whatsappBaileys.connectionLabelHint',
label: 'channel.whatsappBaileys.connectionLabel',
placeholder: 'channel.whatsappBaileys.connectionLabelPlaceholder',
required: true,
type: 'string',
},
{
key: 'settings',
label: 'channel.settings',
properties: [
{
key: 'charLimit',
// Baileys has no hard cap, but WhatsApp's UI truncates long bubbles
// and adds a "View more" affordance — keep replies tight.
default: 4000,
description: 'channel.charLimitHint',
label: 'channel.charLimit',
maximum: 8000,
minimum: 100,
type: 'number',
},
{
key: 'concurrency',
default: 'queue',
description: 'channel.concurrencyHint',
enum: ['queue', 'debounce'],
enumLabels: ['channel.concurrencyQueue', 'channel.concurrencyDebounce'],
label: 'channel.concurrency',
type: 'string',
},
{
key: 'debounceMs',
default: DEFAULT_BOT_DEBOUNCE_MS,
description: 'channel.debounceMsHint',
label: 'channel.debounceMs',
maximum: MAX_BOT_DEBOUNCE_MS,
minimum: 100,
type: 'number',
visibleWhen: { field: 'concurrency', value: 'debounce' },
},
{
key: 'showUsageStats',
default: false,
description: 'channel.showUsageStatsHint',
label: 'channel.showUsageStats',
type: 'boolean',
},
displayToolCallsField,
userIdField,
],
type: 'object',
},
];
@@ -0,0 +1,192 @@
/**
* Thin server-side WhatsApp Cloud API client.
*
* The official `@chat-adapter/whatsapp` adapter handles inbound webhooks,
* but it does not expose a separately-importable HTTP client for outbound
* calls. We therefore keep this minimal wrapper for the server-side code
* paths that don't have an initialized `Chat` instance — chiefly:
*
* - `start()` lifecycle credential check
* - `validateCredentials()` UI flow
* - `messenger.createMessage` / `triggerTyping` / reactions outbound
* - `extractFiles` two-step media download
*
* Stateless — instances are cheap to create and reuse.
*/
export const DEFAULT_GRAPH_API_BASE_URL = 'https://graph.facebook.com';
export const DEFAULT_GRAPH_API_VERSION = 'v21.0';
export interface WhatsAppApiClientOptions {
accessToken: string;
baseUrl?: string;
phoneNumberId: string;
version?: string;
}
interface CloudApiErrorEnvelope {
error?: {
code?: number;
error_data?: { details?: string };
fbtrace_id?: string;
message?: string;
type?: string;
};
}
export interface WhatsAppSendResponse extends CloudApiErrorEnvelope {
contacts?: Array<{ input: string; wa_id: string }>;
messages?: Array<{ id: string; message_status?: string }>;
}
export interface WhatsAppMediaUrlResponse extends CloudApiErrorEnvelope {
file_size?: number;
id?: string;
messaging_product?: 'whatsapp';
mime_type?: string;
sha256?: string;
url?: string;
}
export class WhatsAppApiClient {
readonly accessToken: string;
readonly phoneNumberId: string;
readonly baseUrl: string;
readonly version: string;
constructor(options: WhatsAppApiClientOptions) {
this.accessToken = options.accessToken;
this.phoneNumberId = options.phoneNumberId;
this.baseUrl = stripTrailingSlashes(options.baseUrl || DEFAULT_GRAPH_API_BASE_URL);
this.version = options.version || DEFAULT_GRAPH_API_VERSION;
}
private get root(): string {
return `${this.baseUrl}/${this.version}`;
}
private get authHeaders(): Record<string, string> {
return {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
};
}
/** Send a plain-text message. */
async sendText(to: string, body: string, previewUrl = false): Promise<WhatsAppSendResponse> {
return this.postMessages({
messaging_product: 'whatsapp',
recipient_type: 'individual',
text: { body, preview_url: previewUrl },
to,
type: 'text',
});
}
/**
* Mark an inbound user message as read. When `typingIndicator` is true the
* client UI shows a "typing…" bubble until the next outbound message
* (max ~25s). This is the only typing primitive Cloud API exposes.
* @see https://developers.facebook.com/docs/whatsapp/cloud-api/guides/mark-message-as-read
*/
async markRead(messageId: string, typingIndicator = false): Promise<void> {
const payload: Record<string, unknown> = {
message_id: messageId,
messaging_product: 'whatsapp',
status: 'read',
};
if (typingIndicator) payload.typing_indicator = { type: 'text' };
await this.postMessages(payload);
}
/**
* Send a reaction to a previously-received message.
* @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages/reaction-messages
*/
async sendReaction(to: string, messageId: string, emoji: string): Promise<void> {
await this.postMessages({
messaging_product: 'whatsapp',
reaction: { emoji, message_id: messageId },
to,
type: 'reaction',
});
}
/** Remove a reaction by sending an empty `emoji` string per Cloud API spec. */
async removeReaction(to: string, messageId: string): Promise<void> {
return this.sendReaction(to, messageId, '');
}
/**
* Resolve a media id into a short-lived signed URL plus metadata. The url
* must be downloaded with the same `Authorization` bearer header.
*/
async getMediaUrl(mediaId: string): Promise<WhatsAppMediaUrlResponse> {
const res = await fetch(`${this.root}/${encodeURIComponent(mediaId)}`, {
headers: { Authorization: `Bearer ${this.accessToken}` },
method: 'GET',
});
return parseResponse<WhatsAppMediaUrlResponse>(res, 'getMediaUrl');
}
/** Two-step media download: resolve URL then GET the bytes with the bearer header. */
async downloadMedia(mediaId: string): Promise<Buffer> {
const meta = await this.getMediaUrl(mediaId);
if (!meta.url) {
throw new Error(`WhatsApp media ${mediaId} has no resolvable url`);
}
const res = await fetch(meta.url, {
headers: { Authorization: `Bearer ${this.accessToken}` },
});
if (!res.ok) {
throw new Error(`downloadMedia ${mediaId} failed with HTTP ${res.status}`);
}
return Buffer.from(await res.arrayBuffer());
}
/**
* Verify the credentials with a cheap GET against the phone-number node.
* Used by `start()` and `validateCredentials` to fail fast on bad tokens.
*/
async verifyCredentials(): Promise<{ display_phone_number?: string; verified_name?: string }> {
const res = await fetch(`${this.root}/${encodeURIComponent(this.phoneNumberId)}`, {
headers: { Authorization: `Bearer ${this.accessToken}` },
method: 'GET',
});
return parseResponse(res, 'verifyCredentials');
}
private async postMessages(payload: Record<string, unknown>): Promise<WhatsAppSendResponse> {
const res = await fetch(`${this.root}/${encodeURIComponent(this.phoneNumberId)}/messages`, {
body: JSON.stringify(payload),
headers: this.authHeaders,
method: 'POST',
});
return parseResponse<WhatsAppSendResponse>(res, 'sendMessage');
}
}
function stripTrailingSlashes(url: string): string {
let end = url.length;
while (end > 0 && url[end - 1] === '/') end--;
return url.slice(0, end);
}
async function parseResponse<T>(response: Response, label: string): Promise<T> {
const text = await response.text();
let payload: T | undefined;
try {
payload = text ? (JSON.parse(text) as T) : undefined;
} catch {
payload = undefined;
}
if (!response.ok) {
const errMsg =
(payload as CloudApiErrorEnvelope | undefined)?.error?.message ??
`${label} failed with HTTP ${response.status}`;
throw new Error(errMsg);
}
return (payload ?? ({} as T)) as T;
}
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WhatsAppClientFactory } from './client';
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const PHONE_NUMBER_ID = '111222';
const USER_WA_ID = '15551234567';
const THREAD_ID = `whatsapp:${PHONE_NUMBER_ID}:${USER_WA_ID}`;
const createClient = () =>
new WhatsAppClientFactory().createClient(
{
applicationId: PHONE_NUMBER_ID,
credentials: {
accessToken: 'token-test',
appSecret: 'app-secret',
verifyToken: 'verify-token',
},
platform: 'whatsapp',
settings: {},
},
{},
);
beforeEach(() => {
vi.mock('@/server/services/gateway/runtimeStatus', () => ({
BOT_RUNTIME_STATUSES: {
connected: 'connected',
disconnected: 'disconnected',
failed: 'failed',
starting: 'starting',
},
getRuntimeStatusErrorMessage: (e: unknown) => (e instanceof Error ? e.message : 'unknown'),
updateBotRuntimeStatus: vi.fn().mockResolvedValue(undefined),
}));
});
afterEach(() => {
fetchSpy.mockReset();
});
describe('WhatsAppWebhookClient', () => {
it('formatMarkdown converts CommonMark bold to WhatsApp single-asterisk', () => {
const client = createClient();
expect(client.formatMarkdown!('**hi**')).toBe('*hi*');
});
it('extractChatId pulls userWaId out of the official threadId format', () => {
const client = createClient();
expect(client.extractChatId(THREAD_ID)).toBe(USER_WA_ID);
});
it('parseMessageId returns the composite id verbatim (wamid pass-through)', () => {
const client = createClient();
expect(client.parseMessageId('wamid.HBgM12345')).toBe('wamid.HBgM12345');
});
it('createAdapter wires the official @chat-adapter/whatsapp adapter', () => {
const client = createClient();
const adapter = client.createAdapter();
expect(adapter.whatsapp).toBeDefined();
// The official adapter exposes `name = "whatsapp"` as a readonly field.
expect((adapter.whatsapp as any).name).toBe('whatsapp');
});
it('messenger.createMessage POSTs text to /{phoneNumberId}/messages', async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ messages: [{ id: 'wamid.OUT' }] }), { status: 200 }),
);
const client = createClient();
const messenger = client.getMessenger(THREAD_ID);
await messenger.createMessage('hi back');
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/messages`);
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer token-test');
const body = JSON.parse(init.body as string);
expect(body.to).toBe(USER_WA_ID);
expect(body.text.body).toBe('hi back');
});
it('messenger.addReaction POSTs a reaction message to the recipient', async () => {
fetchSpy.mockResolvedValue(new Response('{}', { status: 200 }));
const client = createClient();
const messenger = client.getMessenger(THREAD_ID);
await messenger.addReaction!('wamid.IN', '👀');
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body).toEqual({
messaging_product: 'whatsapp',
reaction: { emoji: '👀', message_id: 'wamid.IN' },
to: USER_WA_ID,
type: 'reaction',
});
});
it('messenger.removeReaction sends an empty-emoji reaction (Cloud API spec)', async () => {
fetchSpy.mockResolvedValue(new Response('{}', { status: 200 }));
const client = createClient();
const messenger = client.getMessenger(THREAD_ID);
await messenger.removeReaction('wamid.IN', '👀');
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.reaction).toEqual({ emoji: '', message_id: 'wamid.IN' });
});
it('messenger.replaceReaction sends next emoji or removes when next is null', async () => {
// mockImplementation returns a fresh Response each call (mockResolvedValue
// would return the same instance, whose body gets consumed on the first .text()).
fetchSpy.mockImplementation(async () => new Response('{}', { status: 200 }));
const client = createClient();
const messenger = client.getMessenger(THREAD_ID);
await messenger.replaceReaction!('wamid.IN', '👀', '✏️');
let body = JSON.parse((fetchSpy.mock.calls[0] as [string, RequestInit])[1].body as string);
expect(body.reaction.emoji).toBe('✏️');
fetchSpy.mockClear();
await messenger.replaceReaction!('wamid.IN', '✏️', null);
body = JSON.parse((fetchSpy.mock.calls[0] as [string, RequestInit])[1].body as string);
expect(body.reaction.emoji).toBe('');
});
it('formatReply appends usage stats only when showUsageStats=true', () => {
const factory = new WhatsAppClientFactory();
const baseConfig = {
applicationId: PHONE_NUMBER_ID,
credentials: { accessToken: 't', appSecret: 'a', verifyToken: 'v' },
platform: 'whatsapp',
};
const off = factory.createClient({ ...baseConfig, settings: {} }, {});
const on = factory.createClient({ ...baseConfig, settings: { showUsageStats: true } }, {});
const stats = { elapsedMs: 1234, totalCost: 0.01, totalTokens: 42 };
expect(off.formatReply!('body', stats)).toBe('body');
expect(on.formatReply!('body', stats).startsWith('body\n\n')).toBe(true);
});
});
describe('WhatsAppClientFactory.validateCredentials', () => {
it('reports all four required fields when none are supplied', async () => {
const factory = new WhatsAppClientFactory();
const result = await factory.validateCredentials({});
expect(result.valid).toBe(false);
const fields = (result.errors ?? []).map((e) => e.field).sort();
expect(fields).toEqual(['accessToken', 'appSecret', 'applicationId', 'verifyToken']);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('returns valid=true when Cloud API verifyCredentials succeeds', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ display_phone_number: '+1 555 1234' }), { status: 200 }),
);
const factory = new WhatsAppClientFactory();
const result = await factory.validateCredentials(
{ accessToken: 'good', appSecret: 'a', verifyToken: 'v' },
undefined,
'phone-1',
);
expect(result.valid).toBe(true);
});
it('surfaces Cloud API error message when token is rejected', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ error: { message: 'Invalid OAuth access token.' } }), {
status: 401,
}),
);
const factory = new WhatsAppClientFactory();
const result = await factory.validateCredentials(
{ accessToken: 'bad', appSecret: 'a', verifyToken: 'v' },
undefined,
'phone-1',
);
expect(result.valid).toBe(false);
expect(result.errors?.[0]?.message).toContain('Invalid OAuth access token.');
});
});
@@ -0,0 +1,355 @@
import { createWhatsAppAdapter, type WhatsAppRawMessage } from '@chat-adapter/whatsapp';
import type { Message } from 'chat';
import debug from 'debug';
import type { AttachmentSource } from '@/server/services/aiAgent/ingestAttachment';
import {
BOT_RUNTIME_STATUSES,
getRuntimeStatusErrorMessage,
updateBotRuntimeStatus,
} from '@/server/services/gateway/runtimeStatus';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type UsageStats,
type ValidationResult,
} from '../types';
import { formatUsageStats } from '../utils';
import { WhatsAppApiClient } from './api';
import { markdownToWhatsApp } from './markdownToWhatsApp';
const log = debug('bot-platform:whatsapp:bot');
/**
* Decoded thread id parts used by the official adapter:
* `whatsapp:{phoneNumberId}:{userWaId}`.
*/
function decodeThread(platformThreadId: string): { phoneNumberId: string; userWaId: string } {
const parts = platformThreadId.split(':');
if (parts.length < 3 || parts[0] !== 'whatsapp') {
return { phoneNumberId: '', userWaId: platformThreadId };
}
// Tolerate ids that include extra colons in the user-side segment.
return {
phoneNumberId: parts[1] ?? '',
userWaId: parts.slice(2).join(':'),
};
}
function buildApi(config: BotProviderConfig): WhatsAppApiClient {
return new WhatsAppApiClient({
accessToken: config.credentials.accessToken,
phoneNumberId: config.applicationId,
});
}
/**
* Resolve the inbound media id from a `WhatsAppRawMessage`. Mirrors the
* adapter's internal switch on `message.type`.
*/
function resolveMediaId(raw: WhatsAppRawMessage | undefined): {
filename?: string;
id?: string;
mimeType?: string;
} {
const inbound = raw?.message;
if (!inbound) return {};
switch (inbound.type) {
case 'image': {
return { id: inbound.image?.id, mimeType: inbound.image?.mime_type };
}
case 'video': {
return { id: inbound.video?.id, mimeType: inbound.video?.mime_type };
}
case 'audio': {
return { id: inbound.audio?.id, mimeType: inbound.audio?.mime_type };
}
case 'voice': {
return { id: inbound.voice?.id, mimeType: inbound.voice?.mime_type };
}
case 'document': {
return {
filename: inbound.document?.filename,
id: inbound.document?.id,
mimeType: inbound.document?.mime_type,
};
}
case 'sticker': {
return { id: inbound.sticker?.id, mimeType: inbound.sticker?.mime_type };
}
default: {
return {};
}
}
}
function defaultMimeForType(type: string | undefined): string {
switch (type) {
case 'image':
case 'sticker': {
return 'image/jpeg';
}
case 'video': {
return 'video/mp4';
}
case 'audio':
case 'voice': {
return 'audio/ogg';
}
default: {
return 'application/octet-stream';
}
}
}
function defaultNameForType(type: string | undefined, mimeType?: string): string {
const ext = (mimeType ?? '').split('/')[1]?.split(';')[0]?.split('+')[0];
switch (type) {
case 'image':
case 'sticker': {
return `image.${ext || 'jpg'}`;
}
case 'video': {
return `video.${ext || 'mp4'}`;
}
case 'audio':
case 'voice': {
return `audio.${ext || 'ogg'}`;
}
default: {
return `file${ext ? `.${ext}` : ''}`;
}
}
}
class WhatsAppWebhookClient implements PlatformClient {
readonly id = 'whatsapp';
readonly applicationId: string;
private config: BotProviderConfig;
private context: BotPlatformRuntimeContext;
private api: WhatsAppApiClient;
/**
* Cache of the most recent inbound `wamid` per recipient (userWaId). The
* Cloud API needs this id to surface the typing indicator via `markRead`.
*/
private lastInboundMessageId = new Map<string, string>();
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
this.config = config;
this.context = context;
this.applicationId = config.applicationId;
this.api = buildApi(config);
}
// --- Lifecycle ---
async start(): Promise<void> {
log('Starting WhatsAppBot appId=%s', this.applicationId);
await updateBotRuntimeStatus({
applicationId: this.applicationId,
platform: this.id,
status: BOT_RUNTIME_STATUSES.starting,
});
try {
// Cloud API has no programmatic webhook registration — operators paste
// the URL into the Meta dashboard. We can still verify the access
// token / phone number id pair is usable so a clearly-broken provider
// doesn't reach the connected state silently.
await this.api.verifyCredentials();
await updateBotRuntimeStatus({
applicationId: this.applicationId,
platform: this.id,
status: BOT_RUNTIME_STATUSES.connected,
});
log(
'WhatsAppBot appId=%s ready (operator must wire webhook in Meta dashboard)',
this.applicationId,
);
} catch (error) {
await updateBotRuntimeStatus({
applicationId: this.applicationId,
errorMessage: getRuntimeStatusErrorMessage(error),
platform: this.id,
status: BOT_RUNTIME_STATUSES.failed,
});
throw error;
}
}
async stop(): Promise<void> {
log('Stopping WhatsAppBot appId=%s', this.applicationId);
await updateBotRuntimeStatus({
applicationId: this.applicationId,
platform: this.id,
status: BOT_RUNTIME_STATUSES.disconnected,
});
}
// --- Runtime Operations ---
/**
* Inbound webhook handling is delegated to the official
* `@chat-adapter/whatsapp` adapter. It owns:
* - GET hub.challenge verification
* - X-Hub-Signature-256 HMAC validation against the App Secret
* - parsing webhook payload into Chat SDK Messages
* - reactions / interactive button replies / media metadata
*/
createAdapter(): Record<string, any> {
return {
whatsapp: createWhatsAppAdapter({
accessToken: this.config.credentials.accessToken,
appSecret: this.config.credentials.appSecret,
phoneNumberId: this.applicationId,
userName: 'whatsapp-bot',
verifyToken: this.config.credentials.verifyToken,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const { userWaId: recipient } = decodeThread(platformThreadId);
return {
addReaction: (messageId, emoji) => this.api.sendReaction(recipient, messageId, emoji),
createMessage: async (content) => {
await this.api.sendText(recipient, content);
},
// WhatsApp Cloud API does not support editing a sent message;
// `supportsMessageEdit: false` makes the bridge skip step-progress
// edits, but we still implement this path with a fresh send so any
// unexpected caller behaves consistently.
editMessage: async (_messageId, content) => {
await this.api.sendText(recipient, content);
},
removeReaction: (messageId) => this.api.removeReaction(recipient, messageId),
replaceReaction: async (messageId, _prevEmoji, nextEmoji) => {
if (nextEmoji) {
await this.api.sendReaction(recipient, messageId, nextEmoji);
} else {
await this.api.removeReaction(recipient, messageId);
}
},
triggerTyping: async () => {
const lastId = this.lastInboundMessageId.get(recipient);
if (!lastId) return;
try {
await this.api.markRead(lastId, true);
} catch (err) {
log('triggerTyping failed: %O', err);
}
},
};
}
/**
* Resolve attachments on an inbound WhatsApp message into `AttachmentSource[]`.
*
* The official adapter ships a `fetchData` lazy closure on each attachment,
* but `Message.toJSON` strips closures during the chat-sdk Redis
* round-trip used by debounce/queue concurrency. We therefore re-derive
* the media id from `message.raw.message` and re-download via Graph API.
*/
async extractFiles(message: Message): Promise<AttachmentSource[] | undefined> {
const raw = (message as any).raw as WhatsAppRawMessage | undefined;
const media = resolveMediaId(raw);
if (!media.id) return undefined;
const messageId = (message as any).id as string | undefined;
log('extractFiles: msgId=%s mediaId=%s', messageId, media.id);
try {
const buffer = await this.api.downloadMedia(media.id);
const inboundType = raw?.message?.type;
return [
{
buffer,
mimeType: media.mimeType ?? defaultMimeForType(inboundType),
name: media.filename ?? defaultNameForType(inboundType, media.mimeType),
size: buffer.length,
},
];
} catch (err) {
log('extractFiles: downloadMedia failed for mediaId=%s: %O', media.id, err);
return undefined;
}
}
extractChatId(platformThreadId: string): string {
return decodeThread(platformThreadId).userWaId;
}
formatMarkdown(markdown: string): string {
return markdownToWhatsApp(markdown);
}
formatReply(body: string, stats?: UsageStats): string {
if (!stats || !this.config.settings?.showUsageStats) return body;
return `${body}\n\n${formatUsageStats(stats)}`;
}
parseMessageId(compositeId: string): string {
return compositeId;
}
/**
* Updated by the bridge whenever a new inbound message arrives so that
* the next `triggerTyping` call has a target message id to mark read.
*/
recordInboundMessage(threadId: string, messageId: string): void {
const { userWaId } = decodeThread(threadId);
this.lastInboundMessageId.set(userWaId, messageId);
}
}
export class WhatsAppClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new WhatsAppWebhookClient(config, context);
}
async validateCredentials(
credentials: Record<string, string>,
_settings?: Record<string, unknown>,
applicationId?: string,
): Promise<ValidationResult> {
const errors: Array<{ field: string; message: string }> = [];
if (!credentials.accessToken) {
errors.push({ field: 'accessToken', message: 'Access Token is required' });
}
if (!credentials.appSecret) {
errors.push({ field: 'appSecret', message: 'App Secret is required' });
}
if (!credentials.verifyToken) {
errors.push({ field: 'verifyToken', message: 'Verify Token is required' });
}
if (!applicationId) {
errors.push({ field: 'applicationId', message: 'Phone Number ID is required' });
}
if (errors.length > 0) {
return { errors, valid: false };
}
try {
const api = new WhatsAppApiClient({
accessToken: credentials.accessToken,
phoneNumberId: applicationId!,
});
await api.verifyCredentials();
return { valid: true };
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to authenticate with WhatsApp Cloud API';
return {
errors: [{ field: 'accessToken', message }],
valid: false,
};
}
}
}
@@ -0,0 +1,18 @@
import type { PlatformDefinition } from '../types';
import { WhatsAppClientFactory } from './client';
import { schema } from './schema';
export const whatsapp: PlatformDefinition = {
id: 'whatsapp',
name: 'WhatsApp',
connectionMode: 'webhook',
description: 'Connect a WhatsApp Cloud API bot for direct messages.',
documentation: {
portalUrl: 'https://developers.facebook.com/apps',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/whatsapp',
},
schema,
showWebhookUrl: true,
supportsMessageEdit: false,
clientFactory: new WhatsAppClientFactory(),
};
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { markdownToWhatsApp } from './markdownToWhatsApp';
describe('markdownToWhatsApp', () => {
it('converts bold markers to single asterisk', () => {
expect(markdownToWhatsApp('**hello**')).toBe('*hello*');
expect(markdownToWhatsApp('__hello__')).toBe('*hello*');
});
it('converts italic single-asterisk markers to underscore', () => {
expect(markdownToWhatsApp('*hello*')).toBe('_hello_');
expect(markdownToWhatsApp('an *emphasized* word')).toBe('an _emphasized_ word');
});
it('converts ~~strike~~ to ~strike~', () => {
expect(markdownToWhatsApp('~~gone~~')).toBe('~gone~');
});
it('converts headings to bold', () => {
expect(markdownToWhatsApp('## Title here')).toBe('*Title here*');
expect(markdownToWhatsApp('# H1\ncontent')).toBe('*H1*\ncontent');
});
it('preserves fenced code blocks verbatim', () => {
const input = 'before\n```\nconst x = **not bold**\n```\nafter';
const expected = 'before\n```\nconst x = **not bold**\n```\nafter';
expect(markdownToWhatsApp(input)).toBe(expected);
});
it('preserves inline code spans verbatim', () => {
expect(markdownToWhatsApp('use `**flag**` here')).toBe('use `**flag**` here');
});
it('renders markdown links as `text (url)`', () => {
expect(markdownToWhatsApp('see [docs](https://example.com)')).toBe(
'see docs (https://example.com)',
);
expect(markdownToWhatsApp('[https://x.com](https://x.com)')).toBe('https://x.com');
});
it('returns empty string for empty input', () => {
expect(markdownToWhatsApp('')).toBe('');
});
});
@@ -0,0 +1,68 @@
/**
* Convert CommonMark-ish markdown into the lightweight markup WhatsApp
* understands.
*
* Translation table:
* `**bold**` / `__bold__` -> `*bold*`
* `*italic*` / `_italic_` -> `_italic_`
* `~~strike~~` -> `~strike~`
* ```code``` preserved (WhatsApp uses the same triple backticks)
* `inline` code preserved
* Headings (`## title`) -> `*title*` (WhatsApp has no heading concept)
* Bullet lists (`- item`) kept as-is, WhatsApp renders them literally
* Links `[text](url)` -> `text (url)` — WhatsApp doesn't render link syntax
*
* The conversion is intentionally conservative: WhatsApp's renderer is
* lenient with stray `*` / `_` chars, but we still want a recognizable result
* for both the user and the LLM.
*/
const FENCE_RE = /```[\s\S]*?```/g;
const INLINE_CODE_RE = /`[^`\n]+`/g;
const HEADING_RE = /^#{1,6}[ \t]+(\S(?:.*\S)?)[ \t]*$/gm;
const STAR_BOLD_RE = /\*\*([^*\n]+)\*\*/g;
const UNDERSCORE_BOLD_RE = /__([^_\n]+)__/g;
const ITALIC_RE = /(?<![*\\])\*([^*\n]+)\*(?!\*)/g;
const STRIKE_RE = /~~([^~\n]+)~~/g;
const LINK_RE = /\[([^\]]+)\]\((https?:[^\s)]+)\)/g;
const FENCE_TOKEN_RE = /__FENCE_(\d+)__/g;
const INLINE_TOKEN_RE = /__INLINE_(\d+)__/g;
const BOLD_TOKEN_RE = /__BOLD_(\d+)__/g;
export function markdownToWhatsApp(markdown: string): string {
if (!markdown) return '';
const fences: string[] = [];
let work = markdown.replaceAll(FENCE_RE, (block) => {
fences.push(block);
return `__FENCE_${fences.length - 1}__`;
});
const inlines: string[] = [];
work = work.replaceAll(INLINE_CODE_RE, (span) => {
inlines.push(span);
return `__INLINE_${inlines.length - 1}__`;
});
// Stash bold/heading runs as opaque tokens so the italic pass below can't
// mistake `*X*` (WhatsApp bold) for an italic. We restore them after italics.
const bolds: string[] = [];
const stashBold = (text: string): string => {
bolds.push(text);
return `__BOLD_${bolds.length - 1}__`;
};
work = work.replaceAll(HEADING_RE, (_m, title) => stashBold(`*${title}*`));
work = work.replaceAll(STAR_BOLD_RE, (_m, inner) => stashBold(`*${inner}*`));
work = work.replaceAll(UNDERSCORE_BOLD_RE, (_m, inner) => stashBold(`*${inner}*`));
work = work.replaceAll(ITALIC_RE, '_$1_');
work = work.replaceAll(STRIKE_RE, '~$1~');
work = work.replaceAll(LINK_RE, (_m, text, url) => (text === url ? url : `${text} (${url})`));
work = work.replaceAll(BOLD_TOKEN_RE, (_m, idx) => bolds[Number(idx)] ?? '');
work = work.replaceAll(INLINE_TOKEN_RE, (_m, idx) => inlines[Number(idx)] ?? '');
work = work.replaceAll(FENCE_TOKEN_RE, (_m, idx) => fences[Number(idx)] ?? '');
return work;
}
@@ -0,0 +1,106 @@
# WhatsApp Cloud API Bot Integration Notes
Quick orientation map for engineers working on the WhatsApp adapter.
The inbound webhook handler is the **official `@chat-adapter/whatsapp`**
package (no fork, no custom adapter). The lobehub server-side platform
client is a thin wrapper that:
1. instantiates the official adapter inside `createAdapter()` so the Chat
SDK owns the GET handshake / signature verification / event dispatch
2. owns its own minimal `WhatsAppApiClient` (see `./api.ts`) for outbound
calls that must work without an initialized `Chat` instance — namely
`start()` credential checks, `getMessenger`, and `extractFiles`
Authoritative documentation:
- Chat SDK adapter docs: <https://chat-sdk.dev/adapters/whatsapp>
- Cloud API overview: <https://developers.facebook.com/docs/whatsapp/cloud-api>
- Webhook payload schema: <https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/payload-examples>
- Send Messages API: <https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages>
## Credentials
| Field | Source | Notes |
| --------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `applicationId` (Phone Number ID) | "WhatsApp" tab in the Meta App dashboard | Numeric. Used as the `applicationId` for routing webhooks (`/api/agent/webhooks/whatsapp/<phoneNumberId>`). |
| `accessToken` | System User → "Generate token" with `whatsapp_business_messaging` + `whatsapp_business_management` scopes | Long-lived. Bearer header for every Graph call. |
| `verifyToken` | Operator-chosen secret that they paste into Meta when configuring the webhook | Echoed in `hub.verify_token` during the GET handshake. |
| `appSecret` | Meta App → Basic Settings | **Required by `@chat-adapter/whatsapp`**. Validates `X-Hub-Signature-256` on every inbound POST. |
## Thread ID format
`whatsapp:{phoneNumberId}:{userWaId}` — matches the official adapter's
`encodeThreadId` / `decodeThreadId`. The lobehub `extractChatId` returns
the `userWaId` segment (the recipient's wa_id).
## Webhook lifecycle
1. **GET handshake** Meta sends `?hub.mode=subscribe&hub.verify_token=…&hub.challenge=…`.
The official adapter responds `200 text/plain` with the verbatim
challenge if and only if the verify token matches.
2. **POST notification** Meta sends a JSON body with `object: "whatsapp_business_account"`
and one or more `entry[].changes[]`. The adapter only handles
`field === "messages"` and dispatches each `messages[]` entry to the
Chat SDK via `processMessage`.
3. **Signature validation** every POST must carry an `X-Hub-Signature-256`
header equal to `sha256=` + HMAC-SHA256(rawBody, appSecret). The
adapter rejects mismatches with HTTP 401. `appSecret` is **required**
in the official adapter config — there is no fallback.
The catch-all webhook route at
`src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts`
exposes both `GET` and `POST`. The GET verb dispatches to the same
router so the official adapter's `handleWebhook` can pick up the
`hub.challenge` parameters.
## Outbound (server-side, this package's `api.ts`)
`POST /v21.0/<phoneNumberId>/messages`:
```jsonc
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "<userWaId>",
"type": "text",
"text": { "body": "…", "preview_url": false },
}
```
For reactions, the adapter and the server-side client both POST a
`type: "reaction"` payload with `{ emoji, message_id }`. An empty
`emoji` string removes the bot's previous reaction.
For the typing indicator the adapter exposes `startTyping(threadId)`,
which the server-side messenger replicates via
`markRead(messageId, typingIndicator=true)` — the only Cloud API
primitive available. The indicator displays for \~25s or until the next
outbound message.
## Capabilities
- **Edit / delete** not supported by Cloud API. `supportsMessageEdit: false`
on the platform definition makes the bridge skip per-step progress
edits and only emit the final reply.
- **Markdown** outbound markdown is normalized to WhatsApp's
lightweight family (`*bold*` / `_italic_` / `~strike~` / `` `code` ``)
by `markdownToWhatsApp`.
- **Reactions** `addReaction` / `removeReaction` / `replaceReaction`
are wired against the Cloud API reactions endpoint. The 👀 → ✏️
transition flow used elsewhere works.
- **Group chats** Cloud API does not deliver group conversations to
bots. WhatsApp threads are always 1:1.
- **Attachments** inbound media metadata is parsed by the official
adapter; bytes are downloaded on demand by `extractFiles` via
`WhatsAppApiClient.downloadMedia` (two-step: resolve URL, then GET
with bearer header) because `Message.toJSON` strips lazy `fetchData`
closures across the chat-sdk Redis queue.
## Operator-facing setup
Webhook URL must be configured manually in the Meta App dashboard
(`WhatsApp → Configuration → Webhooks`). Paste the channel detail
page's "Webhook URL" into the _Callback URL_ field and the
`verifyToken` into the _Verify token_ field, then subscribe to the
`messages` field. Operators must also paste the Meta App Secret into
LobeHub — the official adapter requires it.
@@ -0,0 +1,87 @@
import { DEFAULT_BOT_DEBOUNCE_MS, MAX_BOT_DEBOUNCE_MS } from '@lobechat/const';
import { displayToolCallsField, userIdField } from '../const';
import type { FieldSchema } from '../types';
export const schema: FieldSchema[] = [
{
key: 'applicationId',
description: 'channel.whatsapp.phoneNumberIdHint',
label: 'channel.whatsapp.phoneNumberId',
placeholder: 'channel.whatsapp.phoneNumberIdPlaceholder',
required: true,
type: 'string',
},
{
key: 'credentials',
label: 'channel.credentials',
properties: [
{
key: 'accessToken',
description: 'channel.whatsapp.accessTokenHint',
label: 'channel.whatsapp.accessToken',
required: true,
type: 'password',
},
{
key: 'verifyToken',
description: 'channel.whatsapp.verifyTokenHint',
label: 'channel.whatsapp.verifyToken',
required: true,
type: 'password',
},
{
key: 'appSecret',
description: 'channel.whatsapp.appSecretHint',
label: 'channel.whatsapp.appSecret',
required: true,
type: 'password',
},
],
type: 'object',
},
{
key: 'settings',
label: 'channel.settings',
properties: [
{
key: 'charLimit',
default: 4000,
description: 'channel.charLimitHint',
label: 'channel.charLimit',
maximum: 4096,
minimum: 100,
type: 'number',
},
{
key: 'concurrency',
default: 'queue',
description: 'channel.concurrencyHint',
enum: ['queue', 'debounce'],
enumLabels: ['channel.concurrencyQueue', 'channel.concurrencyDebounce'],
label: 'channel.concurrency',
type: 'string',
},
{
key: 'debounceMs',
default: DEFAULT_BOT_DEBOUNCE_MS,
description: 'channel.debounceMsHint',
label: 'channel.debounceMs',
maximum: MAX_BOT_DEBOUNCE_MS,
minimum: 100,
type: 'number',
visibleWhen: { field: 'concurrency', value: 'debounce' },
},
{
key: 'showUsageStats',
default: false,
description: 'channel.showUsageStatsHint',
label: 'channel.showUsageStats',
type: 'boolean',
},
displayToolCallsField,
userIdField,
],
type: 'object',
},
];
@@ -39,6 +39,19 @@ export interface MessageGatewayStats {
total: number;
}
// ─── Per-platform routing ───
/**
* Platforms that need a Node-runtime gateway (libsignal, native deps).
* Routed to `MESSAGE_GATEWAY_NODE_URL` instead of the default CF gateway.
*/
const NODE_GATEWAY_PLATFORMS = new Set(['whatsapp-baileys']);
interface GatewayBackend {
baseUrl: string;
serviceToken: string;
}
// ─── Client ───
/**
@@ -48,23 +61,48 @@ export interface MessageGatewayStats {
* connections (WebSocket/long-polling) and forwards inbound events to
* LobeHub's webhook. Outbound messaging is NOT routed through the gateway;
* LobeHub calls platform REST APIs directly.
*
* Two backend pools exist: the Cloudflare Workers `message-gateway` (for
* Discord/QQ/etc.) and an optional Node-runtime `message-gateway-node` for
* libsignal-based platforms (today: `whatsapp-baileys`). Routing is
* driven by `NODE_GATEWAY_PLATFORMS` and the `MESSAGE_GATEWAY_NODE_URL` env.
*/
export class MessageGatewayClient {
private baseUrl: string;
private serviceToken: string;
private cf: GatewayBackend;
private node: GatewayBackend;
constructor(baseUrl?: string, serviceToken?: string) {
if (baseUrl !== undefined) {
this.baseUrl = baseUrl;
this.serviceToken = serviceToken || '';
// Test / manual override — the explicit (url, token) pair targets the
// CF backend only. The Node backend stays unconfigured so tests don't
// double-count when methods that fan out across backends (getStats,
// disconnectAll) aggregate results.
this.cf = { baseUrl, serviceToken: serviceToken || '' };
this.node = { baseUrl: '', serviceToken: '' };
} else {
this.baseUrl = gatewayEnv.MESSAGE_GATEWAY_URL || '';
this.serviceToken = gatewayEnv.MESSAGE_GATEWAY_SERVICE_TOKEN || '';
this.cf = {
baseUrl: gatewayEnv.MESSAGE_GATEWAY_URL || '',
serviceToken: gatewayEnv.MESSAGE_GATEWAY_SERVICE_TOKEN || '',
};
this.node = {
baseUrl: gatewayEnv.MESSAGE_GATEWAY_NODE_URL || '',
serviceToken: gatewayEnv.MESSAGE_GATEWAY_NODE_SERVICE_TOKEN || '',
};
}
}
/** Pick the right backend for a given platform. */
private backendFor(platform: string | undefined): GatewayBackend {
if (platform && NODE_GATEWAY_PLATFORMS.has(platform)) return this.node;
return this.cf;
}
/** True when at least one backend (CF or Node) has URL + token configured. */
get isConfigured(): boolean {
return !!(this.baseUrl && this.serviceToken);
return (
Boolean(this.cf.baseUrl && this.cf.serviceToken) ||
Boolean(this.node.baseUrl && this.node.serviceToken)
);
}
/**
@@ -77,12 +115,17 @@ export class MessageGatewayClient {
return gatewayEnv.MESSAGE_GATEWAY_ENABLED === '1' && this.isConfigured;
}
/** True when the Node gateway is configured — used to gate UI for `whatsapp-baileys`. */
get isNodeBackendConfigured(): boolean {
return Boolean(this.node.baseUrl && this.node.serviceToken);
}
// ─── Connection Management ───
async connect(config: MessageGatewayConnectionConfig): Promise<{ status: string }> {
log('Connecting %s:%s (platform=%s)', config.connectionId, config.userId, config.platform);
const res = await this.post('/api/connections', { config });
const res = await this.post('/api/connections', { config }, this.backendFor(config.platform));
if (!res.ok) {
const error = await res.text();
@@ -93,55 +136,108 @@ export class MessageGatewayClient {
return res.json();
}
/**
* Disconnect every active connection on **both** backends. Each call is
* fire-and-forget per backend; failures on one backend don't block the
* other.
*/
async disconnectAll(): Promise<{ total: number }> {
log('Disconnecting all connections');
const res = await this.fetch('/api/connections', { method: 'DELETE' });
const results = await Promise.allSettled(
this.allBackends().map(async (b) => {
const res = await this.fetch('/api/connections', { method: 'DELETE' }, b);
if (!res.ok) {
throw new Error(`disconnect-all (${b.baseUrl}) failed: ${res.status}`);
}
return (await res.json()) as { total: number };
}),
);
if (!res.ok) {
const error = await res.text();
throw new Error(`message-gateway disconnect-all failed (${res.status}): ${error}`);
let total = 0;
for (const r of results) {
if (r.status === 'fulfilled') total += r.value.total;
}
return res.json();
return { total };
}
async disconnect(connectionId: string): Promise<{ status: string }> {
log('Disconnecting %s', connectionId);
/**
* Disconnect a connection by id. Without a `platform` hint we don't know
* which backend owns it, so we try the Node backend first (smaller pool,
* cheaper miss) then fall back to CF. Both 404 paths are tolerated.
*/
async disconnect(connectionId: string, platform?: string): Promise<{ status: string }> {
log('Disconnecting %s (platform=%s)', connectionId, platform ?? '?');
const res = await this.fetch(`/api/connections/${encodeURIComponent(connectionId)}`, {
method: 'DELETE',
});
if (!res.ok) {
const error = await res.text();
log('Disconnect failed: %s', error);
throw new Error(`message-gateway disconnect failed (${res.status}): ${error}`);
if (platform) {
const backend = this.backendFor(platform);
const res = await this.fetch(
`/api/connections/${encodeURIComponent(connectionId)}`,
{ method: 'DELETE' },
backend,
);
if (!res.ok) {
const error = await res.text();
throw new Error(`message-gateway disconnect failed (${res.status}): ${error}`);
}
return res.json();
}
return res.json();
// Unknown platform — try every configured backend, return the first
// 2xx response. This branch is only hit during emergency cleanup
// where the calling site has lost the platform context.
for (const backend of this.allBackends()) {
const res = await this.fetch(
`/api/connections/${encodeURIComponent(connectionId)}`,
{ method: 'DELETE' },
backend,
);
if (res.ok) return res.json();
}
return { status: 'not_found' };
}
// ─── Typing ───
async startTyping(connectionId: string, platformThreadId: string): Promise<void> {
await this.post(`/api/connections/${encodeURIComponent(connectionId)}/typing`, {
platformThreadId,
});
async startTyping(
connectionId: string,
platformThreadId: string,
platform?: string,
): Promise<void> {
await this.post(
`/api/connections/${encodeURIComponent(connectionId)}/typing`,
{ platformThreadId },
this.backendFor(platform),
);
}
async stopTyping(connectionId: string, platformThreadId: string): Promise<void> {
await this.fetch(`/api/connections/${encodeURIComponent(connectionId)}/typing`, {
body: JSON.stringify({ platformThreadId }),
headers: { 'Content-Type': 'application/json' },
method: 'DELETE',
});
async stopTyping(
connectionId: string,
platformThreadId: string,
platform?: string,
): Promise<void> {
await this.fetch(
`/api/connections/${encodeURIComponent(connectionId)}/typing`,
{
body: JSON.stringify({ platformThreadId }),
headers: { 'Content-Type': 'application/json' },
method: 'DELETE',
},
this.backendFor(platform),
);
}
// ─── Status & Admin ───
async getStatus(connectionId: string): Promise<MessageGatewayConnectionStatus> {
const res = await this.fetch(`/api/connections/${encodeURIComponent(connectionId)}/status`);
async getStatus(
connectionId: string,
platform?: string,
): Promise<MessageGatewayConnectionStatus> {
const res = await this.fetch(
`/api/connections/${encodeURIComponent(connectionId)}/status`,
undefined,
this.backendFor(platform),
);
if (!res.ok) {
throw new Error(`message-gateway status failed (${res.status})`);
@@ -151,41 +247,111 @@ export class MessageGatewayClient {
}
async getStats(): Promise<MessageGatewayStats> {
const res = await this.fetch('/api/admin/stats');
// Aggregate across both backends so admin dashboards see a unified view.
const responses = await Promise.allSettled(
this.allBackends().map(async (b) => {
const res = await this.fetch('/api/admin/stats', undefined, b);
if (!res.ok) throw new Error(`stats (${b.baseUrl}) failed: ${res.status}`);
return (await res.json()) as MessageGatewayStats;
}),
);
if (!res.ok) {
throw new Error(`message-gateway stats failed (${res.status})`);
const merged: MessageGatewayStats = { byPlatform: {}, connections: [], total: 0 };
for (const r of responses) {
if (r.status !== 'fulfilled') continue;
merged.total += r.value.total;
merged.connections.push(...r.value.connections);
for (const [k, v] of Object.entries(r.value.byPlatform)) {
merged.byPlatform[k] = (merged.byPlatform[k] ?? 0) + v;
}
}
return merged;
}
/**
* Send a plain-text outbound message via the gateway. Used by the
* lobehub-side messenger for platforms whose outbound REST API is not
* publicly exposed — today only `whatsapp-baileys`, where the WhatsApp
* Web socket lives in the Node gateway and lobehub server has no direct
* way to call WhatsApp.
*/
async sendText(
connectionId: string,
platformThreadId: string,
text: string,
platform?: string,
): Promise<{ messageId?: string }> {
const res = await this.post(
`/api/connections/${encodeURIComponent(connectionId)}/send`,
{ platformThreadId, text },
this.backendFor(platform),
);
if (!res.ok) {
const error = await res.text();
throw new Error(`message-gateway sendText failed (${res.status}): ${error}`);
}
return res.json();
}
/**
* Latest QR data URL for a connection in `pairing` state, or null when
* none is currently active. Polled by the lobehub QR pairing UI.
*/
async getPairingQr(connectionId: string, platform: string): Promise<{ dataUrl: string } | null> {
const res = await this.fetch(
`/api/connections/${encodeURIComponent(connectionId)}/pairing-qr`,
undefined,
this.backendFor(platform),
);
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`message-gateway pairing-qr failed (${res.status})`);
}
return res.json();
}
// ─── Internal HTTP ───
private async fetch(path: string, init?: RequestInit): Promise<Response> {
if (!this.isConfigured) {
/** All configured backends (in CF → Node order so the CF gateway dominates). */
private allBackends(): GatewayBackend[] {
const out: GatewayBackend[] = [];
if (this.cf.baseUrl && this.cf.serviceToken) out.push(this.cf);
if (this.node.baseUrl && this.node.serviceToken) out.push(this.node);
return out;
}
private async fetch(
path: string,
init: RequestInit | undefined,
backend: GatewayBackend,
): Promise<Response> {
if (!backend.baseUrl || !backend.serviceToken) {
throw new Error(
'MessageGatewayClient not configured: set MESSAGE_GATEWAY_URL and MESSAGE_GATEWAY_SERVICE_TOKEN',
'MessageGatewayClient backend not configured for this platform: set the matching MESSAGE_GATEWAY_URL / MESSAGE_GATEWAY_NODE_URL env vars',
);
}
const url = `${this.baseUrl}${path}`;
const url = `${backend.baseUrl}${path}`;
return globalThis.fetch(url, {
...init,
headers: {
...init?.headers,
Authorization: `Bearer ${this.serviceToken}`,
Authorization: `Bearer ${backend.serviceToken}`,
},
});
}
private async post(path: string, body: unknown): Promise<Response> {
return this.fetch(path, {
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
private async post(path: string, body: unknown, backend: GatewayBackend): Promise<Response> {
return this.fetch(
path,
{
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
},
backend,
);
}
}