diff --git a/.env.example b/.env.example index 12032bea..aed25352 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ # ============================================================================== -# AgentDesk Environment Variables Configuration +# Crove Desk Environment Variables Configuration # Copy this file to .env and adjust the configuration values as needed. # ============================================================================== # Server & Branding Configuration PORT=8083 -# COMPANY_NAME="AgentDesk" +# COMPANY_NAME="Crove Desk" # COMPANY_LOGO_URL="/images/logo.svg" # AGENT_DESK_SERVER_CORS_ALLOWEDORIGINS="http://localhost:3000,http://127.0.0.1:8083" @@ -107,10 +107,15 @@ BREVO_API_KEY=xkeysib-your-brevo-api-key # MESSENGER_VERIFY_TOKEN=your-webhook-verify-token # WhatsApp Cloud API Integration (Meta Graph API) -# WHATSAPP_ACCESS_TOKEN=your-whatsapp-system-user-token -# WHATSAPP_PHONE_NUMBER_ID=your-whatsapp-phone-number-id -# WHATSAPP_WABA_ID=your-whatsapp-business-account-id -# WHATSAPP_VERIFY_TOKEN=your-whatsapp-verify-token +# WhatsApp credentials (Phone Number ID, WABA ID, System User Access Token and +# the per-channel webhook verify token) are stored on the channel itself, in +# Dashboard -> Channels, not in the environment. The two variables below are the +# Meta app credentials and are shared with Messenger and Instagram. +# META_APP_ID and META_APP_SECRET are required: the WhatsApp webhook rejects any +# delivery whose X-Hub-Signature-256 it cannot verify, and the OAuth connect flow +# cannot exchange a code without them. +# META_APP_ID=your-meta-app-id +# META_APP_SECRET=your-meta-app-secret # Slack Bot Integration (Slack Web API & Events API) # SLACK_CLIENT_ID=your-slack-client-id diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ca9e37..74279cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [Unreleased] + +### Security + +- **WhatsApp webhook signature verification now fails closed.** A delivery is + rejected unless its `X-Hub-Signature-256` verifies against a configured Meta + App Secret. Previously a missing secret, a missing header, or a header without + the `sha256=` prefix all passed, so anyone who learned a webhook URL could write + into a customer conversation, trigger AI replies and burn paid message quota. + Rejections now return `401` without echoing the reason. **Breaking for + deployments that never set `META_APP_SECRET` or a per-channel `appSecret`: the + WhatsApp webhook stops accepting messages until one is configured.** +- `ChannelGetWhatsAppOAuthURL` no longer falls back to a fabricated app id, which + sent operators to a Meta error page that looked like an application bug. The + authorization URL now includes `response_type=code`; without it Meta returns a + token fragment the server never sees. +- The Channels dialog advertised `/api/third/whatsapp/webhook` as the webhook URL, + but verification requires a bound channel id, so the documented URL always + failed. It now shows the real per-channel URL with a copy action. + +### Added + +- WhatsApp inbound media is stored instead of dropped. Images, documents, audio, + voice notes, videos and stickers are resolved through the Media API, downloaded + server-side under a size cap, and uploaded as assets, so they render in the + workbench and reach the AI agent. When a download or the upload policy rejects a + file, the message degrades to text with the caption rather than disappearing. +- WhatsApp inbound location, shared contact cards, button and list replies, and + emoji reactions now become readable messages. Unsupported types are recorded + with their type name instead of being silently discarded. +- WhatsApp Embedded Signup / OAuth connect: + `POST /api/dashboard/channel/whatsapp_oauth_callback` exchanges the + authorization code for an access token, inspects it with `debug_token`, + discovers the reachable WABAs and sender numbers, and saves the credentials onto + the target channel while preserving its existing webhook verify token. The + Channels dialog opens Meta in a popup and prefills the form from the result. +- Focused tests for signature rejection, structured inbound types, media storage, + media-failure fallback, and the OAuth connect flow including discovery failure. + +### Changed + +- Both READMEs are rewritten for Crove Desk: fork attribution and the upstream + sync model, the 16 channel adapters, the public support portal, PostgreSQL + support, the actual compose topology, and the real task list. Documentation + links now point at `docs/` in this repository instead of the upstream site. +- In-app brand strings in `en-US` and `zh-CN` now read `Crove Desk`; `vi-VN` + already did. The widget SDK's public `AgentDesk*` globals are unchanged, because + renaming them would break every site that has already integrated it. +- `.env.example` no longer documents four `WHATSAPP_*` variables that nothing in + the codebase reads. It points at the Meta app credentials and at the channel + form instead. +- The product backlog marks the WhatsApp integration as shipped, with the + template-message and delivery-receipt gaps listed explicitly. + +### Known issues + +- An inbound WhatsApp document's caption is dropped when the sender also supplied + a file name, because `normalizeMessageContent` replaces an attachment message's + content with the stored asset name. Preserving both needs a change to that shared + code path, which affects every channel. +- WhatsApp outbound still has no template message support, so business-initiated + conversations outside the 24-hour customer service window are not possible, and + the webhook `statuses` field is not consumed, so delivery and read receipts are + not reflected. +- `pnpm lint` fails on pre-existing `react-hooks/set-state-in-effect` and + ref-access errors across the dashboard. The WhatsApp files added here are + lint-clean. + +--- + ## [1.7.0-crove.1] - 2026-09-10 Release tags now follow the upstream `huabeitech/agent-desk` 1.x line instead of diff --git a/README.md b/README.md index 95c03a0f..2912f160 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,30 @@ -# AgentDesk +# Crove Desk English | [简体中文](README_ZH.md) -An open-source AI Agent customer support system with knowledge-based answers, human handoff, ticket workflows, and self-hosted deployment. +An AI Agent customer support platform with knowledge-grounded answers, human handoff, ticket workflows, omnichannel messaging, a public self-service support portal, and self-hosted deployment. -> Built for teams that need online support, knowledge-base Q&A, human collaboration, and service tracking in one system. It is not just an LLM inside a chat box; it is an AI Helpdesk foundation designed around real support operations. +> Crove Desk is built for teams that need online support, knowledge-base Q&A, human collaboration, and service tracking in one system. It is not an LLM inside a chat box; it is an AI Helpdesk designed around real support operations. + +Crove Desk is a production fork of [`huabeitech/agent-desk`](https://github.com/huabeitech/agent-desk). Upstream is tracked as a git remote and merged in continuously; fixes that belong upstream are contributed back as pull requests. See [Relationship to upstream](#relationship-to-upstream). ## Product Preview -Customer chat, agent workspace, knowledge base, model configuration, and AI Agent orchestration are managed in one system. +Customer chat, the agent workspace, the knowledge base, model configuration, AI Agent orchestration, channel integrations, and the public support portal are managed in one system. + +> The screenshots below are inherited from upstream and still show the `AgentDesk` branding. The running product is branded `Crove Desk`. ### Customer Chat ![Customer Chat](screenshots/1.png) -Customers can start a conversation from the web chat page. The AI Agent responds first with knowledge-grounded answers. When the user explicitly asks for a human, the system can start a handoff confirmation flow. +Customers start a conversation from the web chat page or from any connected channel. The AI Agent responds first with knowledge-grounded answers. When the user explicitly asks for a human, the system starts a handoff confirmation flow. ### Agent Workspace ![Agent Workspace](screenshots/2.png) -The support workspace includes conversation lists, message handling, AI-to-human handoff, agent replies, conversation tags, linked customers, and ticket context for daily support work. +The support workspace includes conversation lists, message handling, AI-to-human handoff, agent replies, private internal notes, conversation tags, linked customers, and ticket context for daily support work. ### Knowledge Base and AI Agent Configuration @@ -28,7 +32,7 @@ The support workspace includes conversation lists, message handling, AI-to-human | --- | --- | | ![Knowledge Base FAQ](screenshots/4.png) | ![AI Agent Configuration](screenshots/5.png) | -The knowledge base stores FAQs, documents, and retrievable content. AI Agents can be bound to model configurations, knowledge bases, Skills, and tools to create support agents for specific scenarios. +The knowledge base stores FAQs, documents, and retrievable content. AI Agents bind model configurations, knowledge bases, Skills, MCP tools, and visual workflows to create support agents for specific scenarios. ### Model Configuration @@ -38,24 +42,56 @@ Model configuration supports OpenAI-compatible providers. You can configure LLMs ## Why Use It -- **AI-first support**: Let AI Agents handle common questions, standard procedures, and knowledge-base answers first. -- **Knowledge-constrained replies**: Use RAG and the Answerability Gate to decide whether retrieved knowledge is strong enough to answer, reducing unsupported responses. +- **AI-first support**: AI Agents handle common questions, standard procedures, and knowledge-base answers first. +- **Knowledge-constrained replies**: RAG plus an Answerability Gate decides whether retrieved knowledge is strong enough to answer, reducing unsupported responses. - **Natural human handoff**: Move to human agents when knowledge is insufficient, the user asks for help, or a workflow requires human confirmation. +- **Omnichannel by default**: 16 channel adapters feed one conversation model, so a customer who moves between WhatsApp, email, and web chat keeps one history. - **Conversation-to-ticket loop**: Online chat, support handling, ticket creation, status flow, and progress records stay in one system. -- **Built for extension**: The backend uses Go, the frontend uses Next.js, and the runtime supports Skills, MCP, and OpenAI-compatible model access. -- **Self-host friendly**: Supports SQLite / MySQL and Qdrant for local trials, intranet deployment, and enterprise self-hosting. +- **Self-service portal**: A public support site with docs, community Q&A, and live chat reduces inbound volume before it reaches an agent. +- **Built for extension**: Go backend, Next.js frontend, and a runtime that supports Skills, MCP, and OpenAI-compatible model access. +- **Self-host friendly**: SQLite, MySQL, or PostgreSQL, with Qdrant or an embedded LanceDB build for local trials, intranet deployment, and enterprise self-hosting. ## Core Capabilities +### Support operations + - **AI Agent support**: AI replies first, with fallback, confirmation, tool calling, and human collaboration. -- **Online conversation system**: Visitor sessions, message send/receive, unread status, assignment, transfer, and close flows. -- **Agent workspace**: Agents can take over conversations, reply to users, transfer teammates, link customers, and create tickets. -- **Knowledge-base RAG**: Knowledge bases, documents, FAQs, chunking, vector retrieval, retrieval logs, and quality analysis. -- **Answerability Gate**: Checks whether retrieved content can support an answer; otherwise returns a fallback and recommends human support. +- **Online conversation system**: Visitor sessions, message send/receive, unread state, assignment, transfer, and close flows. +- **Agent workspace**: Take over conversations, reply, transfer teammates, link customers, add private internal notes, and create tickets. - **Ticket system**: Create tickets from conversations, categorize, assign, move through status flows, record progress, and close the loop. - **Support organization management**: Agent profiles, teams, schedules, and automatic assignment. -- **AI extensibility**: Skills, MCP debugging, and external tool integration. -- **Multiple entry points**: Admin dashboard, agent workspace, customer-facing web pages, and embeddable SDK. +- **Customer management**: Unified customer records across channels, contact details, tags, and a merge dialog for combining duplicate omnichannel profiles. + +### Knowledge and AI + +- **Knowledge-base RAG**: Knowledge bases, documents, FAQs, chunking, vector retrieval, retrieval logs, and quality analysis. +- **Answerability Gate**: Checks whether retrieved content can support an answer; otherwise returns a fallback and recommends human support. +- **AI extensibility**: Skills, MCP debugging, external tool integration, and a visual workflow editor for multi-step agent behaviour. +- **Run observability**: AI workflow runs and agent runs are recorded and filterable for debugging live behaviour. + +### Channels + +Every channel maps onto the same conversation and customer model, so a customer who moves between WhatsApp, email, and web chat keeps one history. + +| | | | | +| --- | --- | --- | --- | +| Web chat + embeddable widget | Email (multi-provider) | WhatsApp Business Cloud API | Facebook Messenger | +| Instagram Direct | Meta Threads | Telegram | Discord | +| Slack | X (Twitter) | TikTok | LINE | +| Viber | Zalo OA | WeChat MP | WeCom KF | + +Outbound delivery for the messaging adapters is queued through a retrying outbox that cron drains, so a provider outage delays a reply instead of losing it. The web widget streams directly over WebSocket, and WeChat MP follows that platform's own passive-reply model. + +Multi-tenant inbound email is supported through forwarding addresses of the form `help@.crove.io`, with direct-domain and plus-addressing routing. + +### Public support portal + +A customer-facing site under `/support` with self-service docs, community Q&A categories and threads, live chat, and user profiles — plus the dashboard side needed to moderate and author it. + +## Localization + +- **Dashboard and portal UI**: `zh-CN`, `en-US`, `vi-VN` +- **Backend messages and errors**: `zh-CN`, `en-US` ## Use Cases @@ -64,32 +100,29 @@ Model configuration supports OpenAI-compatible providers. You can configure LLMs - AI + human hybrid support - Internal enterprise service desk - After-sales service, incident reporting, complaints, and operations support -- Support teams that need knowledge-base Q&A with human collaboration +- Support teams that need knowledge-base Q&A with human collaboration across messaging channels ## Quick Start -The fastest way to try the full stack is Docker Compose: +The fastest way to run the full stack is Docker Compose. Compose reads a `.env` file, so create one first: ```bash +cp .env.example .env docker compose up -d --build ``` -For the full English setup guide, see [Docker Compose Quick Start](https://agent-desk.huabei.pro/docs/getting-started/docker-compose.html). - -To embed customer support on your website, see [Web Widget Integration](https://agent-desk.huabei.pro/docs/integration/web-widget.html). - -To connect OpenAI-compatible model providers, see [Model Provider Configuration](https://agent-desk.huabei.pro/docs/config/model-provider.html). - Compose starts: -- `agent-desk`: application service on port `8083` -- `mysql`: MySQL 8.4 with the `mysql-data` volume - `qdrant`: vector database with the `qdrant-data` volume, ports `6333` / `6334` +- `agent-desk`: the application, built from the local `Dockerfile` and tagged `crove-desk:latest`, on port `8083` + +The bundled compose file expects PostgreSQL to be reachable from the container and reads its DSN from `DATABASE_URL`. Point that variable at your own database before starting; the default value targets a locally exposed Supabase instance and will not work as-is on a fresh machine. After startup, open: - Admin dashboard: `http://localhost:8083/dashboard` - Agent workspace: `http://localhost:8083/dashboard/conversations` +- Public support portal: `http://localhost:8083/support` - Customer web integration demo: `http://localhost:8083/support/demo` - Customer chat page: `http://localhost:8083/support/chat` @@ -100,6 +133,28 @@ Default administrator account: > Before exposing the system to the public internet or a team environment, change the default administrator password and configure independent authentication, session, and model secrets. +Two LanceDB compose variants are also available for deployments that prefer an embedded vector store over Qdrant: + +- `docker-compose.lancedb.yml` +- `docker-compose.sqlite-lancedb.yml` + +## Documentation + +Documentation lives in this repository: + +| Document | Contents | +| --- | --- | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System architecture and layer responsibilities | +| [docs/OMNICHANNEL_CONVERSATIONAL_SUPPORT_REFACTOR.md](docs/OMNICHANNEL_CONVERSATIONAL_SUPPORT_REFACTOR.md) | How every channel is mapped onto one conversation model | +| [docs/CROVE_DESK_PRODUCT_BACKLOG.md](docs/CROVE_DESK_PRODUCT_BACKLOG.md) | Product backlog with stable IDs and priorities | +| [docs/CROVE_DESK_AUDIT.html](docs/CROVE_DESK_AUDIT.html) | Security and correctness audit, tracked by ID with verification status | +| [CHANGELOG.md](CHANGELOG.md) | Release notes | +| [AGENTS.md](AGENTS.md) | Working agreement and conventions for contributors and AI agents | + +To embed customer support on your website, load `web/public/sdk/agent-desk-sdk.min.js` and call `AgentDeskWidget.mount({ channelId })`. The `/support/demo` page is a working reference integration. + +> The widget's public globals keep their `AgentDesk*` names. Renaming them would break every site that has already integrated the SDK, so they are a stable contract. + ## Local Development ### Requirements @@ -107,12 +162,13 @@ Default administrator account: - Go `1.26+` - Node.js `20+` - `pnpm` -- Qdrant +- Qdrant (or an embedded LanceDB build) ### Prepare Configuration ```bash cp config/config.example.yaml config/config.yaml +cp .env.example .env ``` The default configuration uses: @@ -132,6 +188,8 @@ Install frontend dependencies: ```bash cd web pnpm install +cd ../flowgram-editor +pnpm install cd .. ``` @@ -145,62 +203,87 @@ Default development URLs: - Admin dashboard: `http://localhost:3000/dashboard` - Agent workspace: `http://localhost:3000/dashboard/conversations` +- Public support portal: `http://localhost:3000/support` - Customer web integration demo: `http://localhost:3000/support/demo` - Customer chat page: `http://localhost:3000/support/chat` +### Checks + +```bash +go test ./... # backend tests +cd web && pnpm typecheck # frontend types +cd web && pnpm lint # frontend lint +``` + ## Tech Stack -- Backend: Golang + Gin + GORM + `github.com/mlogclub/simple` -- Frontend: Next.js 16 + React 19 + shadcn/ui + Tailwind CSS -- Database: SQLite / MySQL -- Vector DB: Qdrant +- Backend: Go + Gin + GORM + `github.com/mlogclub/simple` +- Frontend: Next.js 16 + React 19 + shadcn/ui on Base UI + Tailwind CSS +- Workflow editor: React + rsbuild, built into `web/public/flowgram-editor` +- Database: SQLite / MySQL / PostgreSQL +- Vector DB: Qdrant, or LanceDB through an optional CGO build - AI: OpenAI-compatible LLM / Embedding + RAG + Skills + MCP +- Localization: `zh-CN`, `en-US`, `vi-VN` ## Project Structure ```text . -├── cmd/ # server / migration / generator / testdata +├── cmd/ # server / migration / generator / enums / testdata ├── internal/ │ ├── bootstrap/ # startup, routes, database, and migration initialization │ ├── builders/ # model / aggregate result to response DTO mapping │ ├── handlers/ # dashboard / api / third HTTP handlers │ ├── middleware/ # Gin middleware -│ ├── migration/ # idempotent data migrations +│ ├── migration/ # idempotent versioned data migrations │ ├── models/ # GORM models │ ├── repositories/ # data access layer │ ├── services/ # business orchestration and transaction boundaries │ ├── ai/ # LLM / RAG / Runtime / Skills / MCP -│ └── pkg/ # config / dto / enums / httpx / utils and shared packages +│ ├── pkg/ # config / dto / enums / httpx / i18nx / utils +│ └── / # one package per external channel client ├── web/ # Next.js frontend project -│ ├── app/dashboard/ # admin dashboard and agent workspace -│ ├── app/support/ # customer integration and chat pages -│ ├── components/ # React components +│ ├── app/(dashboard)/ # admin dashboard and agent workspace +│ ├── app/(support)/ # public support portal, chat, docs, community +│ ├── components/ # React components, including shared dashboard CRUD +│ ├── i18n/ # locale configuration and providers +│ ├── messages/ # zh-CN / en-US / vi-VN message catalogs │ ├── lib/ # API client, SDK source, and utilities -│ └── public/sdk/ # built embeddable SDK +│ └── public/sdk/ # built embeddable widget SDK +├── flowgram-editor/ # visual AI workflow editor source ├── config/ # configuration files -├── docker/ # Docker configuration -└── docs/ # documentation site +├── docker/ # in-container configuration +├── docs/ # architecture, backlog, and audit documentation +└── screenshots/ # README images ``` ## Common Commands ```bash -task dev # start backend and frontend development servers -task build # build the frontend SPA and current-platform Go binary into dist/ -task build:lancedb # build the current-platform LanceDB binary into dist/ -task release # build linux/darwin/windows release binaries into dist/ -task release:lancedb # build LanceDB release binaries into dist/ -task generator # run code generation -task enums # generate frontend enums -task --list # show available tasks +task dev # start backend and frontend development servers +task dev:backend # start only the Go server +task dev:frontend # start only the Next.js dev server +task build # build the flowgram editor, frontend SPA, and Go binary into dist/ +task build:lancedb # build the current-platform LanceDB binary into dist/ +task build:flowgram-editor # build the workflow editor into web/public/flowgram-editor +task release # build linux/darwin/windows release binaries into dist/ +task release:lancedb # build LanceDB release binaries into dist/ +task generator # run CRUD code generation +task enums # generate frontend enums from backend definitions +task --list # show available tasks +``` + +The widget SDK is built separately: + +```bash +cd web && pnpm build:sdk ``` ## AI Agent Workflow ```mermaid flowchart TD - A[User starts a support request
Web support entry / Open API] --> B[Create or match a conversation] + A[User starts a support request
Web widget / Channel / Open API] --> B[Create or match a conversation] B --> C[Customer sends a message] C --> D[Trigger AI Reply Runtime] D --> E[Load conversation history / AI configuration] @@ -252,27 +335,45 @@ flowchart LR L --> N ``` +## Channel Message Flow + +```mermaid +flowchart LR + A[Customer on WhatsApp / Telegram / Email / ...] --> B[Channel webhook] + B --> C{Signature verified?} + C -- No --> R[Reject] + C -- Yes --> D[Resolve channel and credentials] + D --> E[Resolve or create customer identity] + E --> F[Create or match conversation] + F --> G[Store inbound message and media as assets] + G --> H[AI Agent replies] + H --> I[Channel outbox queue] + I --> J{Send succeeded?} + J -- Yes --> K[Mark sent] + J -- No --> L[Retry with backoff, then mark failed] + L --> I +``` + ## Docker Image -If you only need to build the application image, prepare MySQL and Qdrant yourself and mount a configuration file: +If you only need the application image and will provide the database and vector store yourself: ```bash -docker build -t mlogclub/agent-desk . +docker build -t crove-desk:latest . docker run --rm -p 8083:8083 \ -v $(pwd)/docker/agent-desk.yaml:/app/config/config.yaml:ro \ - -v agent-desk-data:/app/data \ - mlogclub/agent-desk + -v crove-desk-data:/app/data \ + crove-desk:latest ``` -Compose uses [docker/agent-desk.yaml](docker/agent-desk.yaml) as the in-container configuration. The application reaches `mysql` and `qdrant` through Docker service names. +Compose uses [docker/agent-desk.yaml](docker/agent-desk.yaml) as the in-container configuration, and reaches `qdrant` through the Docker service name. + +## Relationship to upstream -## Open-source Positioning +Crove Desk tracks [`huabeitech/agent-desk`](https://github.com/huabeitech/agent-desk) as the `upstream` remote and merges it into `dev` on an ongoing basis. `.github/workflows/sync-upstream.yml` automates that sync and skips a release when this fork already holds a tag of the same name, which is why release tags carry a `-crove.N` suffix. -`AgentDesk` is useful as an open-source foundation for: +Fixes that are not Crove-specific are contributed back upstream rather than kept local. Crove-specific additions — the omnichannel adapters, the public support portal, multi-tenant email routing, Vietnamese localization, and the hardened upload and webhook handling — live in this repository. -- AI customer support systems -- AI Helpdesk / AI Support Platform projects -- RAG answerability + human handoff implementation references -- Enterprise AI Agent application frameworks +## License -If you are looking for a customer support system centered on AI Agents rather than a simple LLM chat box, this project is designed for that purpose. +See [LICENSE](LICENSE). diff --git a/README_ZH.md b/README_ZH.md index 6a59a116..185f50c6 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -1,26 +1,30 @@ -# AgentDesk +# Crove Desk [English](README.md) | 简体中文 -开源的 AI Agent 客服系统,支持知识库问答、人工接管、工单闭环和私有化部署。 +一套 AI Agent 客服平台,提供知识库约束回答、人工接管、工单闭环、全渠道消息接入、面向客户的自助支持门户,以及私有化部署能力。 -> 面向需要同时处理在线咨询、知识库问答、人工协同和服务跟踪的团队。它不是把 LLM 接进聊天框,而是一套围绕客服场景设计的 AI Helpdesk 基础系统。 +> Crove Desk 面向需要同时处理在线咨询、知识库问答、人工协同和服务跟踪的团队。它不是把 LLM 接进聊天框,而是一套围绕真实客服运营设计的 AI Helpdesk。 + +Crove Desk 是 [`huabeitech/agent-desk`](https://github.com/huabeitech/agent-desk) 的生产环境分支。上游作为 git remote 持续跟踪并合入;属于上游的修复会以 Pull Request 回馈。详见[与上游的关系](#与上游的关系)。 ## 产品预览 -客户侧在线咨询、客服工作台、知识库、模型配置和 AI Agent 编排都在同一套系统中完成。 +客户侧在线咨询、客服工作台、知识库、模型配置、AI Agent 编排、渠道接入和公开支持门户都在同一套系统中完成。 + +> 下方截图沿用自上游,仍显示 `AgentDesk` 品牌。实际运行的产品品牌为 `Crove Desk`。 ### 客户侧在线咨询 ![客户侧在线咨询](screenshots/1.png) -客户可以在 Web 聊天页中直接发起咨询。AI Agent 会先接待,基于知识库回答问题;当用户明确要求人工介入时,会触发转人工确认流程。 +客户可以从 Web 聊天页或任意已接入渠道发起咨询。AI Agent 会先接待,基于知识库回答问题;当用户明确要求人工介入时,会触发转人工确认流程。 ### 客服工作台 ![客服工作台](screenshots/2.png) -客服工作台支持会话列表、消息处理、AI 转人工、客服回复、会话标签、关联客户和工单信息查看,适合客服日常接待使用。 +客服工作台支持会话列表、消息处理、AI 转人工、客服回复、内部私密备注、会话标签、关联客户和工单信息查看,适合客服日常接待使用。 ### 知识库与 AI 配置 @@ -28,7 +32,7 @@ | --- | --- | | ![知识库 FAQ](screenshots/4.png) | ![AI Agent 配置](screenshots/5.png) | -知识库用于沉淀 FAQ、文档和可检索内容;AI Agent 可以绑定模型配置、知识库、Skills 和工具能力,形成面向具体客服场景的智能客服实例。 +知识库用于沉淀 FAQ、文档和可检索内容;AI Agent 可以绑定模型配置、知识库、Skills、MCP 工具和可视化工作流,形成面向具体客服场景的智能客服实例。 ### 模型配置 @@ -41,21 +45,53 @@ - **AI 先接待**:让 AI Agent 优先处理常见问题、标准流程和知识库问答。 - **知识约束回答**:通过 RAG 和 Answerability Gate 判断知识片段是否足以回答,减少超出知识库范围的乱答。 - **自然转人工**:当知识库不足、用户明确要求或流程需要人工确认时,进入人工接管。 +- **默认全渠道**:16 个渠道适配器汇入同一套会话模型,客户在 WhatsApp、邮件和 Web 聊天之间切换时历史不丢失。 - **会话到工单闭环**:在线会话、客服接待、工单创建、状态流转和处理记录在同一套系统里完成。 -- **适合二次开发**:后端使用 Go,前端使用 Next.js,支持 Skills、MCP 和 OpenAI-compatible 模型接入。 -- **可私有化部署**:支持 SQLite / MySQL 和 Qdrant,适合本地体验、内网部署和企业自托管。 +- **自助支持门户**:面向客户的公开站点提供文档、社区问答和在线咨询,在到达客服之前先消化一部分咨询量。 +- **适合二次开发**:后端使用 Go,前端使用 Next.js,运行时支持 Skills、MCP 和 OpenAI-compatible 模型接入。 +- **可私有化部署**:支持 SQLite、MySQL 或 PostgreSQL,向量库可选 Qdrant 或内嵌 LanceDB 构建,适合本地体验、内网部署和企业自托管。 ## 核心能力 +### 客服运营 + - **AI Agent 客服**:AI 优先回复,支持兜底、确认、工具调用和人工协同。 - **在线会话系统**:支持访客会话、消息收发、未读状态、会话分配、转接和关闭。 -- **客服工作台**:客服可接管会话、回复用户、转接同事、关联客户和创建工单。 -- **知识库 RAG**:支持知识库、文档、FAQ、切片、向量检索、检索日志和质量分析。 -- **Answerability Gate**:判断检索内容是否足以支撑回答,不足时返回兜底提示并建议联系人工。 +- **客服工作台**:接管会话、回复用户、转接同事、关联客户、添加内部私密备注和创建工单。 - **工单系统**:支持从会话创建工单、分类、指派、状态流转、进展记录和闭环处理。 - **客服组织管理**:支持客服档案、客服组、排班和自动分配能力。 -- **AI 扩展能力**:支持 Skills、MCP 调试和外部工具接入。 -- **多入口接入**:提供管理后台、客服工作台、客户侧 Web 页面和嵌入式 SDK。 +- **客户管理**:跨渠道统一客户档案、联系方式、标签,以及合并重复全渠道档案的合并对话框。 + +### 知识与 AI + +- **知识库 RAG**:支持知识库、文档、FAQ、切片、向量检索、检索日志和质量分析。 +- **Answerability Gate**:判断检索内容是否足以支撑回答,不足时返回兜底提示并建议联系人工。 +- **AI 扩展能力**:支持 Skills、MCP 调试、外部工具接入,以及用于编排多步 Agent 行为的可视化工作流编辑器。 +- **运行可观测性**:AI 工作流运行和 Agent 运行均有记录并可筛选,便于排查线上行为。 + +### 渠道 + +所有渠道都映射到同一套会话和客户模型,客户在渠道之间切换时历史保持连续: + +| | | | | +| --- | --- | --- | --- | +| Web 聊天 + 可嵌入 Widget | 邮件(多服务商) | WhatsApp Business Cloud API | Facebook Messenger | +| Instagram Direct | Meta Threads | Telegram | Discord | +| Slack | X (Twitter) | TikTok | LINE | +| Viber | Zalo OA | 微信公众号 | 企业微信客服 | + +消息类渠道的出站投递统一进入带重试的 outbox 队列,由定时任务消费,因此服务商故障只会让回复延迟而不会丢失。Web Widget 通过 WebSocket 直连,微信公众号遵循该平台自身的被动回复模型。 + +多租户入站邮件支持 `help@.crove.io` 形式的转发地址,并支持独立域名和 plus-addressing 路由。 + +### 公开支持门户 + +`/support` 下面向客户的站点,包含自助文档、社区问答分类与帖子、在线咨询和用户资料页,以及后台对应的内容创作与审核能力。 + +## 多语言 + +- **后台与门户界面**:`zh-CN`、`en-US`、`vi-VN` +- **后端消息与错误**:`zh-CN`、`en-US` ## 适用场景 @@ -64,32 +100,29 @@ - AI + 人工混合接待 - 企业内部服务台 - 售后、报障、投诉和运营支持 -- 需要知识库问答与人工协同的客服团队 +- 需要跨消息渠道进行知识库问答与人工协同的客服团队 ## 快速开始 -推荐先用 Docker Compose 体验完整服务: +推荐先用 Docker Compose 运行完整服务。Compose 会读取 `.env`,因此需要先创建: ```bash +cp .env.example .env docker compose up -d --build ``` -完整英文配置与排查说明见 [Docker Compose Quick Start](https://agent-desk.huabei.pro/zh/docs/getting-started/docker-compose.html)。 +Compose 会启动: -如需在官网或产品中嵌入客服入口,见 [Web Widget Integration](https://agent-desk.huabei.pro/zh/docs/integration/web-widget.html)。 - -如需接入 OpenAI-compatible 模型供应商,见 [Model Provider Configuration](https://agent-desk.huabei.pro/zh/docs/config/model-provider.html)。 - -Compose 默认会启动: - -- `agent-desk`:应用服务,端口 `8083` -- `mysql`:MySQL 8.4,数据卷 `mysql-data` - `qdrant`:向量数据库,数据卷 `qdrant-data`,端口 `6333` / `6334` +- `agent-desk`:应用服务,由本地 `Dockerfile` 构建并打标签 `crove-desk:latest`,端口 `8083` + +仓库自带的 compose 文件假定容器内可以访问 PostgreSQL,并从 `DATABASE_URL` 读取连接串。启动前请把它指向你自己的数据库;默认值指向一个本地暴露的 Supabase 实例,在全新环境上无法直接使用。 启动后访问: - 管理后台:`http://localhost:8083/dashboard` - 客服工作台:`http://localhost:8083/dashboard/conversations` +- 公开支持门户:`http://localhost:8083/support` - 客户侧 Web 接入示例:`http://localhost:8083/support/demo` - 客户侧聊天页:`http://localhost:8083/support/chat` @@ -100,6 +133,28 @@ Compose 默认会启动: > 首次用于公网或团队环境前,请务必修改默认管理员密码,并配置独立的鉴权、会话和模型密钥。 +如果希望使用内嵌向量库替代 Qdrant,仓库另提供两个 LanceDB compose 变体: + +- `docker-compose.lancedb.yml` +- `docker-compose.sqlite-lancedb.yml` + +## 文档 + +文档都放在本仓库中: + +| 文档 | 内容 | +| --- | --- | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 系统架构与分层职责 | +| [docs/OMNICHANNEL_CONVERSATIONAL_SUPPORT_REFACTOR.md](docs/OMNICHANNEL_CONVERSATIONAL_SUPPORT_REFACTOR.md) | 各渠道如何映射到同一套会话模型 | +| [docs/CROVE_DESK_PRODUCT_BACKLOG.md](docs/CROVE_DESK_PRODUCT_BACKLOG.md) | 带稳定 ID 和优先级的产品 Backlog | +| [docs/CROVE_DESK_AUDIT.html](docs/CROVE_DESK_AUDIT.html) | 安全与正确性审计,按 ID 跟踪并记录验证状态 | +| [CHANGELOG.md](CHANGELOG.md) | 发布说明 | +| [AGENTS.md](AGENTS.md) | 贡献者与 AI Agent 的工作约定和代码规范 | + +如需在官网或产品中嵌入客服入口,加载 `web/public/sdk/agent-desk-sdk.min.js` 并调用 `AgentDeskWidget.mount({ channelId })`。`/support/demo` 页面是一个可运行的接入示例。 + +> Widget 对外的全局变量保留 `AgentDesk*` 命名。改名会破坏所有已经接入 SDK 的站点,因此它是一个稳定契约。 + ## 本地开发 ### 环境要求 @@ -107,12 +162,13 @@ Compose 默认会启动: - Go `1.26+` - Node.js `20+` - `pnpm` -- Qdrant +- Qdrant(或使用内嵌 LanceDB 构建) ### 准备配置 ```bash cp config/config.example.yaml config/config.yaml +cp .env.example .env ``` 默认配置使用: @@ -132,6 +188,8 @@ docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant ```bash cd web pnpm install +cd ../flowgram-editor +pnpm install cd .. ``` @@ -145,87 +203,112 @@ task dev - 管理后台:`http://localhost:3000/dashboard` - 客服工作台:`http://localhost:3000/dashboard/conversations` +- 公开支持门户:`http://localhost:3000/support` - 客户侧 Web 接入示例:`http://localhost:3000/support/demo` - 客户侧聊天页:`http://localhost:3000/support/chat` +### 校验命令 + +```bash +go test ./... # 后端测试 +cd web && pnpm typecheck # 前端类型检查 +cd web && pnpm lint # 前端 lint +``` + ## 技术栈 -- Backend:Golang + Gin + GORM + `github.com/mlogclub/simple` -- Frontend:Next.js 16 + React 19 + shadcn/ui + Tailwind CSS -- Database:SQLite / MySQL -- Vector DB:Qdrant +- Backend:Go + Gin + GORM + `github.com/mlogclub/simple` +- Frontend:Next.js 16 + React 19 + 基于 Base UI 的 shadcn/ui + Tailwind CSS +- 工作流编辑器:React + rsbuild,构建产物输出到 `web/public/flowgram-editor` +- Database:SQLite / MySQL / PostgreSQL +- Vector DB:Qdrant,或通过可选 CGO 构建使用 LanceDB - AI:OpenAI-compatible LLM / Embedding + RAG + Skills + MCP +- 多语言:`zh-CN`、`en-US`、`vi-VN` ## 项目结构 ```text . -├── cmd/ # server / migration / generator / testdata +├── cmd/ # server / migration / generator / enums / testdata ├── internal/ │ ├── bootstrap/ # 启动、路由、数据库和迁移初始化 -│ ├── builders/ # model / 聚合结果到 response DTO 的映射 -│ ├── handlers/ # dashboard / api / third HTTP handlers -│ ├── middleware/ # Gin middleware -│ ├── migration/ # 幂等数据迁移 -│ ├── models/ # GORM models +│ ├── builders/ # model / 聚合结果到响应 DTO 的映射 +│ ├── handlers/ # dashboard / api / third HTTP 处理器 +│ ├── middleware/ # Gin 中间件 +│ ├── migration/ # 幂等的版本化数据迁移 +│ ├── models/ # GORM 模型 │ ├── repositories/ # 数据访问层 -│ ├── services/ # 业务编排和事务边界 +│ ├── services/ # 业务编排与事务边界 │ ├── ai/ # LLM / RAG / Runtime / Skills / MCP -│ └── pkg/ # config / dto / enums / httpx / utils 等基础包 +│ ├── pkg/ # config / dto / enums / httpx / i18nx / utils +│ └── / # 每个外部渠道客户端一个包 ├── web/ # Next.js 前端工程 -│ ├── app/dashboard/ # 管理后台与客服工作台 -│ ├── app/support/ # 客户侧接入和聊天页面 -│ ├── components/ # React 组件 -│ ├── lib/ # API client、SDK 源码和工具函数 -│ └── public/sdk/ # 构建后的嵌入式 SDK +│ ├── app/(dashboard)/ # 管理后台与客服工作台 +│ ├── app/(support)/ # 公开支持门户、聊天、文档、社区 +│ ├── components/ # React 组件,包含共享的 dashboard CRUD +│ ├── i18n/ # 语言配置与 Provider +│ ├── messages/ # zh-CN / en-US / vi-VN 文案 +│ ├── lib/ # API client、SDK 源码和工具 +│ └── public/sdk/ # 构建后的可嵌入 Widget SDK +├── flowgram-editor/ # 可视化 AI 工作流编辑器源码 ├── config/ # 配置文件 -├── docker/ # Docker 配置 -└── docs/ # 项目文档 +├── docker/ # 容器内配置 +├── docs/ # 架构、Backlog 与审计文档 +└── screenshots/ # README 图片 ``` ## 常用命令 ```bash -task dev # 同时启动后端和前端开发服务 -task build # 构建前端 SPA 和当前平台 Go 二进制 -task build:lancedb # 构建当前平台 LanceDB 二进制 -task release # 构建常用平台二进制 -task release:lancedb # 构建 LanceDB 发布二进制 -task generator # 执行代码生成 -task enums # 生成前端枚举 -task --list # 查看可用任务 +task dev # 同时启动后端和前端开发服务 +task dev:backend # 只启动 Go 服务 +task dev:frontend # 只启动 Next.js 开发服务 +task build # 构建工作流编辑器、前端 SPA 和 Go 二进制到 dist/ +task build:lancedb # 构建当前平台的 LanceDB 二进制到 dist/ +task build:flowgram-editor # 构建工作流编辑器到 web/public/flowgram-editor +task release # 构建 linux/darwin/windows 发布二进制到 dist/ +task release:lancedb # 构建 LanceDB 发布二进制到 dist/ +task generator # 运行 CRUD 代码生成 +task enums # 从后端定义生成前端枚举 +task --list # 查看所有可用任务 +``` + +Widget SDK 单独构建: + +```bash +cd web && pnpm build:sdk ``` ## AI Agent 工作流 ```mermaid flowchart TD - A[用户发起咨询
Web 客服入口 / Open API] --> B[创建或匹配会话] + A[用户发起咨询
Web Widget / 渠道 / Open API] --> B[创建或匹配会话] B --> C[客户发送消息] C --> D[触发 AI Reply Runtime] D --> E[加载会话历史 / AI 配置] - E --> F[按绑定知识库执行检索] - F --> G{知识片段是否足以回答?} - G -- 否 --> Z[返回知识库兜底提示
并建议联系人工客服] + E --> F[从绑定知识库检索] + F --> G{检索片段是否足以回答?} + G -- 否 --> Z[返回知识兜底
并建议联系人工] G -- 是 --> H[准备 Skills / MCP Tools] - H --> I[将可信知识上下文交给 Agent] + H --> I[把可信知识上下文交给 Agent] I --> J{直接回复?} - J -- 是 --> K[LLM 基于知识生成回复并返回用户] - J -- 否 --> N{是否调用 Graph / MCP Tool?} + J -- 是 --> K[LLM 生成基于知识的回复] + J -- 否 --> N{调用 Graph / MCP Tool?} N -- 是 --> O[执行 Skill / Graph / MCP Tool] O --> P{需要用户确认?} P -- 否 --> I - P -- 是 --> Q[向用户发起确认] - Q --> R{用户确认结果} - R -- 确认转人工 --> S[会话转人工并进入待接入池] - S --> T[自动分配或人工分配] + P -- 是 --> Q[请用户确认] + Q --> R{确认结果} + R -- 确认转人工 --> S[会话进入人工待接入池] + S --> T[自动或手动分配] T --> U[客服工作台接管] - U --> V{是否需要工单跟踪?} + U --> V{需要工单跟踪?} V -- 是 --> W[创建或关联工单] - V -- 否 --> X[人工继续处理] + V -- 否 --> X[人工客服继续处理] W --> X - X --> Y[问题解决并关闭] - R -- 确认建单 --> AA[从当前会话创建工单] + X --> Y[解决并关闭] + R -- 确认建工单 --> AA[从当前会话创建工单] AA --> I R -- 取消 --> K N -- 否 --> K @@ -235,44 +318,62 @@ flowchart TD ```mermaid flowchart LR - A[客户咨询] --> B[AI Agent 接待] - B --> C{知识库可回答?} - C -- 是 --> D[AI 基于可信知识回复] - C -- 否 --> E[兜底提示 / 建议人工] + A[客户请求] --> B[AI Agent 先接待] + B --> C{知识库能否回答?} + C -- 能 --> D[AI 用可信知识回复] + C -- 不能 --> E[兜底 / 建议人工] D --> F{是否需要人工?} E --> G[人工接管] - F -- 否 --> H[会话结束或沉淀数据] + F -- 否 --> H[会话结束或数据留存] F -- 是 --> G G --> I[客服工作台处理] - I --> J{是否需要跟踪?} + I --> J{需要后续跟踪?} J -- 是 --> K[创建 / 关联工单] J -- 否 --> L[直接解决] - K --> M[工单流转与进展记录] - M --> N[处理完成] + K --> M[工单状态流转与进展记录] + M --> N[完成] L --> N ``` +## 渠道消息流 + +```mermaid +flowchart LR + A[客户在 WhatsApp / Telegram / 邮件等渠道] --> B[渠道 Webhook] + B --> C{签名校验通过?} + C -- 否 --> R[拒绝] + C -- 是 --> D[解析渠道与凭据] + D --> E[解析或创建客户身份] + E --> F[创建或匹配会话] + F --> G[存储入站消息,媒体落为资产] + G --> H[AI Agent 回复] + H --> I[渠道 outbox 队列] + I --> J{发送成功?} + J -- 是 --> K[标记已发送] + J -- 否 --> L[退避重试,超限后标记失败] + L --> I +``` + ## Docker 镜像 -如果只需要构建应用镜像,可以自行准备 MySQL 和 Qdrant,并挂载配置文件: +如果只需要应用镜像,数据库和向量库自行准备: ```bash -docker build -t mlogclub/agent-desk . +docker build -t crove-desk:latest . docker run --rm -p 8083:8083 \ -v $(pwd)/docker/agent-desk.yaml:/app/config/config.yaml:ro \ - -v agent-desk-data:/app/data \ - mlogclub/agent-desk + -v crove-desk-data:/app/data \ + crove-desk:latest ``` -Compose 使用 [docker/agent-desk.yaml](docker/agent-desk.yaml) 作为容器内配置,应用会通过 Docker 内部服务名访问 `mysql` 和 `qdrant`。 +Compose 使用 [docker/agent-desk.yaml](docker/agent-desk.yaml) 作为容器内配置,并通过 Docker 服务名访问 `qdrant`。 + +## 与上游的关系 -## 开源定位 +Crove Desk 以 `upstream` remote 跟踪 [`huabeitech/agent-desk`](https://github.com/huabeitech/agent-desk),并持续合入 `dev` 分支。`.github/workflows/sync-upstream.yml` 负责自动同步,当本分支已存在同名 tag 时会跳过该次发布,这也是发布 tag 带 `-crove.N` 后缀的原因。 -`AgentDesk` 适合作为以下方向的开源基础项目: +非 Crove 专属的修复会回馈上游而不是只留在本地。Crove 专属的新增能力——全渠道适配器、公开支持门户、多租户邮件路由、越南语本地化,以及加固后的上传和 Webhook 处理——都维护在本仓库中。 -- AI 客服系统 -- AI Helpdesk / AI Support Platform -- RAG 可回答性判定 + Human Handoff 的落地样板 -- 面向企业场景的 AI Agent 应用框架 +## 许可证 -如果你在寻找一个以 AI Agent 为中心,而不是仅仅把 LLM 嵌进聊天框的客服系统,这个项目就是为此设计的。 +见 [LICENSE](LICENSE)。 diff --git a/docs/CROVE_DESK_PRODUCT_BACKLOG.md b/docs/CROVE_DESK_PRODUCT_BACKLOG.md index ddc99a11..6e06ff93 100644 --- a/docs/CROVE_DESK_PRODUCT_BACKLOG.md +++ b/docs/CROVE_DESK_PRODUCT_BACKLOG.md @@ -33,14 +33,20 @@ This document defines the complete product backlog and feature roadmap for **Cro - Thread ID parsing (In-Reply-To / References header matching). - Outbound email dispatching with custom support address formatting. -### [Under Consideration] WhatsApp Business API & Cloud Gateway -- **Status**: `Under Consideration` +### [Shipped] WhatsApp Business API & Cloud Gateway +- **Status**: `Shipped` - **Topics**: `Integrations 🔗` -- **Description**: Connect WhatsApp Business Cloud API to Crove Desk. Support template messages, interactive buttons, and real-time chat sync for international customer support. -- **Key Capabilities**: - - Meta Graph API webhook ingestion for incoming WhatsApp chats. - - Message status delivery receipts (sent, delivered, read). - - Pre-approved HSM template message triggers for re-engagement. +- **Description**: Connect WhatsApp Business Cloud API to Crove Desk. Incoming customer chats, media and interactive replies flow into the workbench and trigger AI replies; agent and AI answers are delivered back through a retrying outbox. +- **Key Capabilities**: + - Meta Graph API webhook ingestion for incoming WhatsApp chats, with HMAC-SHA256 signature verification that fails closed. + - Inbound media (image, document, audio, voice, video, sticker) resolved through the Media API and stored as first-class assets. + - Inbound location, contact cards, button and list replies, and reactions rendered as readable message content. + - Embedded Signup / OAuth code exchange that saves the access token and discovers the WABA ID and Phone Number ID. + - Outbound text, image and document delivery queued through the channel outbox with backoff retry. +- **Not shipped yet**: + - Message status delivery receipts (sent, delivered, read) — the webhook `statuses` field is not consumed. + - Pre-approved HSM template message triggers for re-engagement, so business-initiated conversations outside the 24-hour customer service window are not supported. + - Outbound interactive buttons and list messages. ### [Shipped] Live Chat Web Widget SDK with Custom Theming & JWT Verification - **Status**: `Shipped` diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 374c1479..c4d44903 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -236,6 +236,7 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) { group.GET("/messenger_oauth_url", dashboard.ChannelGetMessengerOAuthURL) group.GET("/instagram_oauth_url", dashboard.ChannelGetInstagramOAuthURL) group.GET("/whatsapp_oauth_url", dashboard.ChannelGetWhatsAppOAuthURL) + group.POST("/whatsapp_oauth_callback", dashboard.ChannelPostWhatsAppOAuthCallback) group.GET("/slack_oauth_url", dashboard.ChannelGetSlackOAuthURL) group.GET("/x_oauth_url", dashboard.ChannelGetXOAuthURL) group.GET("/tiktok_oauth_url", dashboard.ChannelGetTikTokOAuthURL) diff --git a/internal/handlers/dashboard/channel_oauth_handler.go b/internal/handlers/dashboard/channel_oauth_handler.go index 5ec87c99..65629424 100644 --- a/internal/handlers/dashboard/channel_oauth_handler.go +++ b/internal/handlers/dashboard/channel_oauth_handler.go @@ -8,7 +8,11 @@ import ( "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/httpx" + "agent-desk/internal/pkg/httpx/params" + "agent-desk/internal/pkg/i18nx" "agent-desk/internal/services" "github.com/gin-gonic/gin" @@ -167,8 +171,15 @@ func ChannelGetWhatsAppOAuthURL(ctx *gin.Context) { } redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) + // A fabricated app id would send the operator to a Meta error page that + // looks like our bug, so an unconfigured deployment says so instead. if appID == "" { - appID = "123456789012345" + httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.e0351")) + return + } + if redirectURI == "" { + httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri")) + return } state := strings.TrimSpace(ctx.Query("state")) @@ -176,20 +187,53 @@ func ChannelGetWhatsAppOAuthURL(ctx *gin.Context) { state = "crove_whatsapp_connect" } - authURL := fmt.Sprintf( - "https://www.facebook.com/v21.0/dialog/oauth?client_id=%s&redirect_uri=%s&scope=whatsapp_business_management,whatsapp_business_messaging&state=%s", - url.QueryEscape(appID), - url.QueryEscape(redirectURI), - url.QueryEscape(state), - ) + query := url.Values{} + query.Set("client_id", appID) + query.Set("redirect_uri", redirectURI) + query.Set("state", state) + // response_type=code is what makes Meta redirect back with an authorization + // code; without it the dialog returns a token fragment the server never sees. + query.Set("response_type", "code") + query.Set("scope", "whatsapp_business_management,whatsapp_business_messaging") httpx.WriteJSON(ctx, web.JsonData(gin.H{ - "authUrl": authURL, + "authUrl": "https://www.facebook.com/v21.0/dialog/oauth?" + query.Encode(), "appId": appID, "redirectUri": redirectURI, })) } +// ChannelPostWhatsAppOAuthCallback exchanges the authorization code Meta +// redirected back with for WhatsApp Cloud API credentials, reports the sender +// numbers that token can reach, and saves everything when channelId names an +// existing channel. +func ChannelPostWhatsAppOAuthCallback(ctx *gin.Context) { + req := request.WhatsAppOAuthCallbackRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + // Saving onto an existing channel is an update; exchanging credentials for a + // channel that does not exist yet is part of creating one. + permission := constants.PermissionChannelCreate + if req.ChannelID > 0 { + permission = constants.PermissionChannelUpdate + } + operator, err := services.AuthService.RequirePermission(ctx, permission) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + + result, err := services.WhatsAppOAuthService.Connect(req, i18nx.Locale(ctx), operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, result) +} + // ChannelGetSlackOAuthURL returns the 1-Click OAuth authorization URL for Slack Workspace Bot. func ChannelGetSlackOAuthURL(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { diff --git a/internal/handlers/third/whatsapp_handler.go b/internal/handlers/third/whatsapp_handler.go index 0837cf30..3596e252 100644 --- a/internal/handlers/third/whatsapp_handler.go +++ b/internal/handlers/third/whatsapp_handler.go @@ -3,11 +3,13 @@ package third import ( "bytes" "crypto/subtle" + "errors" "io" "net/http" "strings" "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" "agent-desk/internal/services" "github.com/gin-gonic/gin" @@ -77,6 +79,13 @@ func WhatsAppPostWebhook(ctx *gin.Context) { ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) if err := services.WhatsAppInboundService.HandleWebhook(ctx.Request.Context(), channelID, sigHeader, bodyBytes); err != nil { + // An unauthenticated delivery is answered with 401 and no detail, so the + // endpoint cannot be used to probe which secret a channel expects. + var i18nErr *errorsx.I18nError + if errors.As(err, &i18nErr) && i18nErr.Code == errorsx.CodeAuthUnauthorized { + ctx.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "signature verification failed"}) + return + } ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) return } diff --git a/internal/handlers/third/whatsapp_slack_handler_test.go b/internal/handlers/third/whatsapp_slack_handler_test.go index ab31a880..8c4452f2 100644 --- a/internal/handlers/third/whatsapp_slack_handler_test.go +++ b/internal/handlers/third/whatsapp_slack_handler_test.go @@ -2,6 +2,9 @@ package third import ( "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "net/http" "net/http/httptest" @@ -19,6 +22,16 @@ import ( "github.com/mlogclub/simple/sqls" ) +// whatsAppTestSecret is the Meta App Secret the test channel is configured with. +const whatsAppTestSecret = "wa_app_secret_test_123" + +// signWhatsAppTestPayload builds the X-Hub-Signature-256 value Meta would send. +func signWhatsAppTestPayload(payload []byte) string { + mac := hmac.New(sha256.New, []byte(whatsAppTestSecret)) + mac.Write(payload) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + func TestWhatsAppWebhook_Handler(t *testing.T) { gin.SetMode(gin.TestMode) db := setupThirdHandlerTestDB(t) @@ -39,6 +52,7 @@ func TestWhatsAppWebhook_Handler(t *testing.T) { WABAID: "waba_445566", AccessToken: "test_wa_token", WebhookVerifyToken: "my_wa_verify_token_999", + AppSecret: whatsAppTestSecret, }) operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} @@ -108,13 +122,31 @@ func TestWhatsAppWebhook_Handler(t *testing.T) { ] }`) + // 2. An unsigned POST must be rejected. Accepting it would let anyone who + // learns the webhook URL write into a customer conversation. + reqUnsigned, _ := http.NewRequest(http.MethodPost, "/api/third/whatsapp/webhook/"+channel.ChannelID, bytes.NewBuffer(payload)) + reqUnsigned.Header.Set("Content-Type", "application/json") + recUnsigned := httptest.NewRecorder() + router.ServeHTTP(recUnsigned, reqUnsigned) + + if recUnsigned.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for an unsigned POST webhook, got: %d", recUnsigned.Code) + } + if unsigned := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceWhatsApp). + Eq("external_id", "1234567890")); unsigned != nil { + t.Fatalf("the unsigned webhook created a customer identity") + } + + // 3. A correctly signed POST is accepted. reqPost, _ := http.NewRequest(http.MethodPost, "/api/third/whatsapp/webhook/"+channel.ChannelID, bytes.NewBuffer(payload)) reqPost.Header.Set("Content-Type", "application/json") + reqPost.Header.Set("X-Hub-Signature-256", signWhatsAppTestPayload(payload)) recPost := httptest.NewRecorder() router.ServeHTTP(recPost, reqPost) if recPost.Code != http.StatusOK { - t.Fatalf("expected 200 OK for POST webhook, got: %d", recPost.Code) + t.Fatalf("expected 200 OK for POST webhook, got: %d (body: %s)", recPost.Code, recPost.Body.String()) } // Verify identity in DB diff --git a/internal/pkg/dto/request/channel_request.go b/internal/pkg/dto/request/channel_request.go index a5095cb3..f2f76a89 100644 --- a/internal/pkg/dto/request/channel_request.go +++ b/internal/pkg/dto/request/channel_request.go @@ -35,3 +35,18 @@ type ResetChannelUserTokenSecretRequest struct { type ChannelMessageOutboxActionRequest struct { ID int64 `json:"id"` } + +// WhatsAppOAuthCallbackRequest carries the authorization code Meta redirected +// back with. ChannelID is optional: when set, the exchanged credentials are +// persisted onto that existing WhatsApp channel; when empty they are only +// returned so the operator can finish creating one. +type WhatsAppOAuthCallbackRequest struct { + Code string `json:"code"` + State string `json:"state"` + ChannelID int64 `json:"channelId"` + RedirectURI string `json:"redirectUri"` + // PhoneNumberID and WabaID let the operator pick one of several discovered + // sender numbers instead of accepting the first. + PhoneNumberID string `json:"phoneNumberId"` + WabaID string `json:"wabaId"` +} diff --git a/internal/pkg/dto/response/channel_whatsapp_oauth_response.go b/internal/pkg/dto/response/channel_whatsapp_oauth_response.go new file mode 100644 index 00000000..cdbfec68 --- /dev/null +++ b/internal/pkg/dto/response/channel_whatsapp_oauth_response.go @@ -0,0 +1,45 @@ +package response + +// WhatsAppOAuthPhoneNumberResponse is one sender number registered on a WABA. +// PhoneNumberID is the value outbound messages are sent from. +type WhatsAppOAuthPhoneNumberResponse struct { + PhoneNumberID string `json:"phoneNumberId"` + DisplayPhoneNumber string `json:"displayPhoneNumber"` + VerifiedName string `json:"verifiedName"` + QualityRating string `json:"qualityRating"` + CodeVerificationStatus string `json:"codeVerificationStatus"` +} + +// WhatsAppOAuthAccountResponse is one WhatsApp Business Account the authorized +// Meta business portfolio owns, together with its sender numbers. +type WhatsAppOAuthAccountResponse struct { + WabaID string `json:"wabaId"` + WabaName string `json:"wabaName"` + BusinessID string `json:"businessId"` + BusinessName string `json:"businessName"` + PhoneNumbers []WhatsAppOAuthPhoneNumberResponse `json:"phoneNumbers"` +} + +// WhatsAppOAuthConnectResponse reports what an authorization code was exchanged +// for. Discovery is best effort, so Accounts may be empty while the token is +// still usable; Warnings explains why in operator-facing terms. +type WhatsAppOAuthConnectResponse struct { + // Connected is true when the credentials were persisted onto a channel. + Connected bool `json:"connected"` + ChannelID int64 `json:"channelId,omitempty"` + + AccessToken string `json:"accessToken"` + TokenMasked string `json:"tokenMasked"` + TokenType string `json:"tokenType,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + Scopes []string `json:"scopes,omitempty"` + + // PhoneNumberID and WabaID are the single obvious choice when discovery + // found exactly one, so the caller can prefill without making the operator + // pick from a list of one. + PhoneNumberID string `json:"phoneNumberId,omitempty"` + WabaID string `json:"wabaId,omitempty"` + + Accounts []WhatsAppOAuthAccountResponse `json:"accounts"` + Warnings []string `json:"warnings,omitempty"` +} diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index d3378370..e7cd89b5 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -347,6 +347,18 @@ error.e0345: "Attachment message is missing assetId." error.e0346: "Attachment message is missing payload." error.e0347: "Default team queue mode requires at least one agent team." error.e0348: "This file type cannot be uploaded for security reasons." +error.e0349: "WhatsApp webhook rejected: no Meta App Secret is configured. Set it on the WhatsApp channel or in META_APP_SECRET." +error.e0350: "WhatsApp webhook rejected: the X-Hub-Signature-256 header is missing." +error.e0351: "META_APP_ID and META_APP_SECRET must be configured before connecting WhatsApp." +error.e0352: "WhatsApp authorization failed: %s" +error.whatsapp.oauth.tokenInspectFailed: "Could not inspect the access token: %s" +error.whatsapp.oauth.tokenInvalid: "Meta reports this access token is not valid." +error.whatsapp.oauth.scopeMissing: "The granted token is missing the \"%s\" permission." +error.whatsapp.oauth.businessesFailed: "Could not list Meta business portfolios: %s" +error.whatsapp.oauth.noBusinesses: "This token cannot see any Meta business portfolio. Enter the WABA ID and Phone Number ID manually." +error.whatsapp.oauth.accountsFailed: "Could not list WhatsApp Business Accounts for %s: %s" +error.whatsapp.oauth.phoneNumbersFailed: "Could not list phone numbers for WABA %s: %s" +error.whatsapp.oauth.noAccounts: "No WhatsApp Business Account is reachable with this token. Grant whatsapp_business_management with advanced access, or enter the WABA ID and Phone Number ID manually." error.profile.nicknameRequired: "Enter a nickname." error.profile.nicknameTooLong: "Nickname cannot exceed 100 characters." error.profile.avatarTooLong: "Avatar link cannot exceed 255 characters." diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml index 85e4ac97..8c84e53c 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -347,6 +347,18 @@ error.e0345: "附件消息缺少 assetId" error.e0346: "附件消息缺少 payload" error.e0347: "默认客服组待接入池模式必须至少选择一个客服组" error.e0348: "出于安全考虑,此文件类型不支持上传" +error.e0349: "WhatsApp 回调已被拒绝:未配置 Meta App Secret。请在 WhatsApp 渠道或 META_APP_SECRET 环境变量中配置。" +error.e0350: "WhatsApp 回调已被拒绝:缺少 X-Hub-Signature-256 请求头。" +error.e0351: "连接 WhatsApp 前必须先配置 META_APP_ID 和 META_APP_SECRET。" +error.e0352: "WhatsApp 授权失败:%s" +error.whatsapp.oauth.tokenInspectFailed: "无法校验 Access Token:%s" +error.whatsapp.oauth.tokenInvalid: "Meta 返回该 Access Token 无效。" +error.whatsapp.oauth.scopeMissing: "授权令牌缺少 \"%s\" 权限。" +error.whatsapp.oauth.businessesFailed: "无法获取 Meta 商务管理平台账号:%s" +error.whatsapp.oauth.noBusinesses: "该令牌无法访问任何 Meta 商务管理平台账号,请手动填写 WABA ID 和 Phone Number ID。" +error.whatsapp.oauth.accountsFailed: "无法获取 %s 下的 WhatsApp 商业账号:%s" +error.whatsapp.oauth.phoneNumbersFailed: "无法获取 WABA %s 的发送号码:%s" +error.whatsapp.oauth.noAccounts: "该令牌无法访问任何 WhatsApp 商业账号。请为 whatsapp_business_management 申请高级权限,或手动填写 WABA ID 和 Phone Number ID。" error.profile.nicknameRequired: "请输入昵称" error.profile.nicknameTooLong: "昵称不能超过 100 个字符" error.profile.avatarTooLong: "头像链接不能超过 255 个字符" diff --git a/internal/services/whatsapp_inbound_service.go b/internal/services/whatsapp_inbound_service.go index 02be7bb8..ebd3dcc3 100644 --- a/internal/services/whatsapp_inbound_service.go +++ b/internal/services/whatsapp_inbound_service.go @@ -7,11 +7,15 @@ import ( "encoding/hex" "encoding/json" "fmt" + "log/slog" "os" "strings" + "time" + "unicode" "agent-desk/internal/models" "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/openidentity" @@ -26,6 +30,41 @@ func newWhatsAppInboundService() *whatsappInboundService { type whatsappInboundService struct{} +// newWhatsAppClient builds the Graph API client used to fetch inbound media. It +// is a variable so tests can point it at a local stub instead of Meta. +var newWhatsAppClient = func(accessToken string) *whatsapp.Client { + return whatsapp.NewClient(accessToken) +} + +// whatsappMediaTimeout bounds resolving and fetching one inbound media object. +// Meta expires a media URL a few minutes after issuing it, so the download has +// to happen inline with webhook processing instead of being deferred to a job. +const whatsappMediaTimeout = 30 * time.Second + +// whatsappCaptionFilenameLimit keeps a caption-derived file name short enough to +// read as a label in the workbench while leaving room for the extension. +const whatsappCaptionFilenameLimit = 80 + +// whatsappInboundRef is the resolved context shared by every message in one +// webhook change. +type whatsappInboundRef struct { + channel *models.Channel + cfg *dto.WhatsAppChannelConfig + phoneNumberID string +} + +// whatsappInboundContent is what one webhook message becomes on our side. +type whatsappInboundContent struct { + messageType enums.IMMessageType + content string + // payload is stored verbatim when set. Image and attachment messages must + // carry the canonical asset payload, which leaves no room for webhook + // metadata, so those set this instead of extra. + payload string + // extra is folded into the webhook metadata payload for text messages. + extra map[string]any +} + // HandleWebhook processes an incoming Webhook event from WhatsApp Cloud API (Meta Graph Platform). func (s *whatsappInboundService) HandleWebhook(ctx context.Context, channelID string, signatureHeader string, rawPayload []byte) error { var event whatsapp.WebhookEvent @@ -43,134 +82,638 @@ func (s *whatsappInboundService) HandleWebhook(ctx context.Context, channelID st continue } - val := change.Value - phoneNumberID := strings.TrimSpace(val.Metadata.PhoneNumberID) + value := change.Value + phoneNumberID := strings.TrimSpace(value.Metadata.PhoneNumberID) - var channel *models.Channel - channelID = strings.TrimSpace(channelID) - if channelID != "" { - channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeWhatsApp, enums.StatusOk) - } - if channel == nil && phoneNumberID != "" { - channel = ChannelService.Take("channel_type = ? AND status = ? AND (channel_id = ? OR config_json LIKE ?)", - enums.ChannelTypeWhatsApp, enums.StatusOk, phoneNumberID, "%"+phoneNumberID+"%") - } - if channel == nil { - channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeWhatsApp, enums.StatusOk) - } + channel := s.resolveChannel(channelID, phoneNumberID) if channel == nil { + slog.Warn("whatsapp webhook dropped, no matching channel", + "channel_id", strings.TrimSpace(channelID), + "phone_number_id", phoneNumberID, + "waba_id", strings.TrimSpace(entry.ID), + ) continue } cfg, err := ChannelService.ParseWhatsAppChannelConfig(channel.ConfigJSON) if err != nil || cfg == nil { + slog.Warn("whatsapp webhook dropped, unreadable channel config", + "channel", channel.ID, + "error", err, + ) continue } - // Signature verification if appSecret configured - appSecret := "" - if cfg != nil { - appSecret = strings.TrimSpace(cfg.AppSecret) - } - if appSecret == "" { - if serverCfg := config.GetCurrent(); serverCfg != nil { - appSecret = strings.TrimSpace(serverCfg.Messenger.AppSecret) - } + if err := verifyWhatsAppWebhook(cfg, signatureHeader, rawPayload); err != nil { + return err } - if appSecret == "" { - appSecret = strings.TrimSpace(os.Getenv("META_APP_SECRET")) + + contactNames := make(map[string]string, len(value.Contacts)) + for _, contact := range value.Contacts { + contactNames[strings.TrimSpace(contact.WaID)] = strings.TrimSpace(contact.Profile.Name) } - if appSecret != "" && strings.TrimSpace(signatureHeader) != "" { - if !verifyWhatsAppSignature(appSecret, signatureHeader, rawPayload) { - return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + ref := whatsappInboundRef{channel: channel, cfg: cfg, phoneNumberID: phoneNumberID} + for i := range value.Messages { + message := value.Messages[i] + if err := s.handleMessage(ctx, ref, &message, contactNames); err != nil { + // One undeliverable message must not drop the rest of the batch. + slog.Error("process whatsapp inbound message failed", + "channel", channel.ID, + "whatsapp_message_id", strings.TrimSpace(message.ID), + "type", strings.TrimSpace(message.Type), + "error", err, + ) } } + } + } - contactNameMap := make(map[string]string) - for _, contact := range val.Contacts { - contactNameMap[contact.WaID] = contact.Profile.Name - } + return nil +} - for _, message := range val.Messages { - senderPhone := strings.TrimSpace(message.From) - if senderPhone == "" { - continue - } +func (s *whatsappInboundService) resolveChannel(channelID string, phoneNumberID string) *models.Channel { + if channelID = strings.TrimSpace(channelID); channelID != "" { + if channel := ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeWhatsApp, enums.StatusOk); channel != nil { + return channel + } + } + if phoneNumberID != "" { + if channel := ChannelService.Take("channel_type = ? AND status = ? AND (channel_id = ? OR config_json LIKE ?)", + enums.ChannelTypeWhatsApp, enums.StatusOk, phoneNumberID, "%"+phoneNumberID+"%"); channel != nil { + return channel + } + } + return ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeWhatsApp, enums.StatusOk) +} - text := "" - if message.Text != nil { - text = strings.TrimSpace(message.Text.Body) - } else if message.Image != nil { - text = strings.TrimSpace(message.Image.Caption) - if text == "" { - text = "[Image Attachment]" - } - } else if message.Document != nil { - text = strings.TrimSpace(message.Document.Caption) - if text == "" { - text = fmt.Sprintf("[%s]", message.Document.Filename) - } - } +func (s *whatsappInboundService) handleMessage(ctx context.Context, ref whatsappInboundRef, message *whatsapp.InboundMessage, contactNames map[string]string) error { + senderPhone := strings.TrimSpace(message.From) + if senderPhone == "" { + return nil + } + if strings.TrimSpace(message.ID) == "" { + return fmt.Errorf("whatsapp message is missing an id") + } - if text == "" { - continue - } + name := contactNames[senderPhone] + if name == "" { + name = fmt.Sprintf("WhatsApp User +%s", senderPhone) + } + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceWhatsApp, + ExternalID: senderPhone, + ExternalName: name, + } - name := contactNameMap[senderPhone] - if name == "" { - name = fmt.Sprintf("WhatsApp User +%s", senderPhone) - } + conversation, err := ConversationService.Create(externalUser, ref.channel.ID, ref.channel.AIAgentID) + if err != nil { + return fmt.Errorf("create whatsapp conversation failed: %w", err) + } - // 1. Resolve customer identity - externalUser := openidentity.ExternalUser{ - ExternalSource: enums.ExternalSourceWhatsApp, - ExternalID: senderPhone, - ExternalName: name, - } + content, err := s.buildContent(ctx, ref, message) + if err != nil { + return err + } - // 2. Create or match Conversation - conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) - if err != nil { - return fmt.Errorf("create whatsapp conversation failed: %w", err) - } + payload := content.payload + if payload == "" { + metadata := map[string]any{ + "whatsapp_message_id": strings.TrimSpace(message.ID), + "whatsapp_from": senderPhone, + "whatsapp_phone_id": ref.phoneNumberID, + "whatsapp_type": strings.TrimSpace(message.Type), + } + for key, value := range content.extra { + metadata[key] = value + } + payloadBytes, err := json.Marshal(metadata) + if err != nil { + return fmt.Errorf("marshal whatsapp message payload failed: %w", err) + } + payload = string(payloadBytes) + } - // 3. Send customer message - clientMsgID := fmt.Sprintf("wa_%s", message.ID) - payloadMap := map[string]any{ - "whatsapp_message_id": message.ID, - "whatsapp_from": senderPhone, - "whatsapp_phone_id": phoneNumberID, - "whatsapp_type": message.Type, - } - payloadBytes, _ := json.Marshal(payloadMap) - - _, err = MessageService.SendCustomerMessage( - conversation.ID, - clientMsgID, - enums.IMMessageTypeText, - text, - string(payloadBytes), - externalUser, - ) - if err != nil { - return fmt.Errorf("send customer message failed: %w", err) - } + _, err = MessageService.SendCustomerMessage( + conversation.ID, + fmt.Sprintf("wa_%s", strings.TrimSpace(message.ID)), + content.messageType, + content.content, + payload, + externalUser, + ) + return err +} + +// buildContent turns one webhook message into the message we store. Structured +// types that carry no prose still become a readable line, so a customer action +// never disappears from the conversation and the AI agent can see it happened. +func (s *whatsappInboundService) buildContent(ctx context.Context, ref whatsappInboundRef, message *whatsapp.InboundMessage) (*whatsappInboundContent, error) { + switch strings.ToLower(strings.TrimSpace(message.Type)) { + case whatsapp.MessageTypeText: + if message.Text == nil { + return nil, fmt.Errorf("whatsapp text message has no body") + } + return s.textContent(message.Text.Body) + + case whatsapp.MessageTypeImage: + if message.Image == nil { + return nil, fmt.Errorf("whatsapp image message has no image payload") + } + return s.mediaContent(ctx, ref, whatsappMediaRequest{ + mediaID: message.Image.ID, + mimeType: message.Image.MimeType, + caption: message.Image.Caption, + label: "Image", + asImage: true, + }) + + case whatsapp.MessageTypeDocument: + if message.Document == nil { + return nil, fmt.Errorf("whatsapp document message has no document payload") + } + return s.mediaContent(ctx, ref, whatsappMediaRequest{ + mediaID: message.Document.ID, + mimeType: message.Document.MimeType, + caption: message.Document.Caption, + filename: message.Document.Filename, + label: "Document", + }) + + case whatsapp.MessageTypeVideo: + if message.Video == nil { + return nil, fmt.Errorf("whatsapp video message has no video payload") + } + return s.mediaContent(ctx, ref, whatsappMediaRequest{ + mediaID: message.Video.ID, + mimeType: message.Video.MimeType, + caption: message.Video.Caption, + label: "Video", + }) + + case whatsapp.MessageTypeAudio: + if message.Audio == nil { + return nil, fmt.Errorf("whatsapp audio message has no audio payload") + } + return s.mediaContent(ctx, ref, whatsappMediaRequest{ + mediaID: message.Audio.ID, + mimeType: message.Audio.MimeType, + label: "Audio", + }) + + case whatsapp.MessageTypeVoice: + if message.Voice == nil { + return nil, fmt.Errorf("whatsapp voice message has no voice payload") + } + return s.mediaContent(ctx, ref, whatsappMediaRequest{ + mediaID: message.Voice.ID, + mimeType: message.Voice.MimeType, + label: "Voice message", + }) + + case whatsapp.MessageTypeSticker: + if message.Sticker == nil { + return nil, fmt.Errorf("whatsapp sticker message has no sticker payload") + } + label := "Sticker" + if message.Sticker.Animated { + label = "Animated sticker" + } + return s.mediaContent(ctx, ref, whatsappMediaRequest{ + mediaID: message.Sticker.ID, + mimeType: message.Sticker.MimeType, + label: label, + // An animated sticker is a WebP animation, which the workbench cannot + // render as a still image, so it is filed as an attachment instead. + asImage: !message.Sticker.Animated, + }) + + case whatsapp.MessageTypeLocation: + if message.Location == nil { + return nil, fmt.Errorf("whatsapp location message has no location payload") + } + return s.locationContent(message.Location), nil + + case whatsapp.MessageTypeContacts: + return s.contactsContent(message.Contacts) + + case whatsapp.MessageTypeInteractive: + return s.interactiveContent(message.Interactive) + + case whatsapp.MessageTypeButton: + if message.Button == nil { + return nil, fmt.Errorf("whatsapp button message has no button payload") + } + return s.textContent(whatsappFirstNonBlank(message.Button.Text, message.Button.Payload)) + + case whatsapp.MessageTypeReaction: + return s.reactionContent(message.Reaction), nil + + default: + return s.unsupportedContent(message), nil + } +} + +func (s *whatsappInboundService) textContent(body string) (*whatsappInboundContent, error) { + body = strings.TrimSpace(body) + if body == "" { + return nil, fmt.Errorf("whatsapp text message is empty") + } + return &whatsappInboundContent{messageType: enums.IMMessageTypeText, content: body}, nil +} + +// whatsappMediaRequest describes one inbound media object to fetch and store. +type whatsappMediaRequest struct { + mediaID string + mimeType string + caption string + filename string + label string + asImage bool +} + +func (s *whatsappInboundService) mediaContent(ctx context.Context, ref whatsappInboundRef, req whatsappMediaRequest) (*whatsappInboundContent, error) { + if strings.TrimSpace(req.mediaID) == "" { + return s.mediaFallback(req, fmt.Errorf("media id is empty")) + } + if strings.TrimSpace(ref.cfg.AccessToken) == "" { + return s.mediaFallback(req, fmt.Errorf("channel has no access token")) + } + + mediaCtx, cancel := context.WithTimeout(ctx, whatsappMediaTimeout) + defer cancel() + + client := newWhatsAppClient(ref.cfg.AccessToken) + meta, err := client.GetMediaMetadata(mediaCtx, req.mediaID) + if err != nil { + return s.mediaFallback(req, err) + } + + var limit int64 + if cfg := config.GetCurrent(); cfg != nil { + limit = cfg.Storage.MaxUploadSizeBytes() + } + data, contentType, err := client.DownloadMedia(mediaCtx, meta.URL, limit) + if err != nil { + return s.mediaFallback(req, err) + } + + mimeType := whatsappFirstNonBlank(contentType, meta.MimeType, req.mimeType) + filename := whatsappMediaFilename(req, mimeType) + + asset, err := AssetService.UploadBytes(data, whatsappAssetPrefix(req.asImage), filename, nil) + if err != nil { + // A blocked or oversized file is a policy decision, not a lost message: + // the agent still has to see that the customer sent something. + return s.mediaFallback(req, err) + } + + assetPayload, err := buildIMMessageAssetPayload(asset) + if err != nil { + return s.mediaFallback(req, err) + } + + messageType := enums.IMMessageTypeAttachment + if req.asImage { + messageType = enums.IMMessageTypeImage + } + return &whatsappInboundContent{ + messageType: messageType, + // normalizeMessageContent replaces this with the stored file name, which + // is why the caption is carried in the file name instead. + content: whatsappFirstNonBlank(asset.Filename, "["+req.label+" Attachment]"), + payload: assetPayload, + }, nil +} + +// mediaFallback records the message as text when the media itself could not be +// stored, so the customer's turn is never silently dropped from the conversation. +func (s *whatsappInboundService) mediaFallback(req whatsappMediaRequest, cause error) (*whatsappInboundContent, error) { + slog.Warn("whatsapp inbound media not stored, falling back to text", + "media_id", strings.TrimSpace(req.mediaID), + "label", req.label, + "error", cause, + ) + label := "[" + req.label + " Attachment]" + content := strings.TrimSpace(req.caption) + if content == "" { + content = label + } else { + content = content + " " + label + } + return &whatsappInboundContent{ + messageType: enums.IMMessageTypeText, + content: content, + extra: map[string]any{ + "whatsapp_media_id": strings.TrimSpace(req.mediaID), + "whatsapp_media_unavailable": true, + }, + }, nil +} + +// whatsappMediaFilename picks the stored file name, which is also the label the +// workbench shows and the text the AI agent reads for this message. +// +// A sender-supplied document name always wins. Otherwise a caption becomes the +// name, because WhatsApp gives images, video, audio and stickers no filename and +// the caption is routinely the customer's actual question. +func whatsappMediaFilename(req whatsappMediaRequest, mimeType string) string { + extension := whatsappExtensionForMimeType(mimeType) + if filename := whatsappSlugFilename(req.filename); filename != "" { + if whatsappExtensionFor(filename) == "" { + return filename + extension + } + return filename + } + if caption := whatsappSlugFilename(strings.TrimSpace(req.caption)); caption != "" { + return caption + extension + } + slug := strings.Map(func(r rune) rune { + if r == ':' || r == '/' || r == '\\' || unicode.IsSpace(r) { + return '_' + } + return r + }, strings.TrimSpace(req.mediaID)) + if len(slug) > 48 { + slug = slug[len(slug)-48:] + } + if slug == "" { + slug = "media" + } + return "whatsapp_" + slug + extension +} + +// whatsappSlugFilename reduces free-form WhatsApp text to a usable basename. +func whatsappSlugFilename(value string) string { + value = strings.Map(func(r rune) rune { + switch { + case r == '\n' || r == '\r' || r == '\t': + return ' ' + case strings.ContainsRune(`/\:*?"<>|`, r): + return '-' + case r < 0x20 || r == 0x7f: + return -1 + default: + return r + } + }, value) + value = strings.TrimSpace(strings.Join(strings.Fields(value), " ")) + value = strings.Trim(value, ". ") + if runes := []rune(value); len(runes) > whatsappCaptionFilenameLimit { + value = strings.TrimSpace(string(runes[:whatsappCaptionFilenameLimit])) + } + return value +} + +func whatsappExtensionFor(filename string) string { + if index := strings.LastIndex(filename, "."); index > 0 && index < len(filename)-1 { + return strings.ToLower(filename[index:]) + } + return "" +} + +func whatsappExtensionForMimeType(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/webp": + return ".webp" + case "image/gif": + return ".gif" + case "image/heic": + return ".heic" + case "video/mp4": + return ".mp4" + case "video/3gpp": + return ".3gp" + case "audio/mpeg": + return ".mp3" + case "audio/mp4", "audio/x-m4a": + return ".m4a" + case "audio/aac": + return ".aac" + case "audio/ogg", "audio/opus": + return ".ogg" + case "audio/amr": + return ".amr" + case "audio/wav": + return ".wav" + case "application/pdf": + return ".pdf" + case "text/plain": + return ".txt" + default: + return ".bin" + } +} + +func whatsappAssetPrefix(asImage bool) string { + if asImage { + return "images" + } + return "attachments" +} + +func (s *whatsappInboundService) locationContent(location *whatsapp.LocationPayload) *whatsappInboundContent { + parts := []string{fmt.Sprintf("%.6f,%.6f", location.Latitude, location.Longitude)} + if name := strings.TrimSpace(location.Name); name != "" { + parts = append(parts, name) + } + if address := strings.TrimSpace(location.Address); address != "" { + parts = append(parts, address) + } + return &whatsappInboundContent{ + messageType: enums.IMMessageTypeText, + content: "[Location] " + strings.Join(parts, " · "), + extra: map[string]any{ + "whatsapp_latitude": location.Latitude, + "whatsapp_longitude": location.Longitude, + "whatsapp_location_name": strings.TrimSpace(location.Name), + "whatsapp_location_address": strings.TrimSpace(location.Address), + }, + } +} + +func (s *whatsappInboundService) contactsContent(contacts []whatsapp.ContactRef) (*whatsappInboundContent, error) { + if len(contacts) == 0 { + return nil, fmt.Errorf("whatsapp contacts message has no contact card") + } + lines := make([]string, 0, len(contacts)) + for _, contact := range contacts { + name := strings.TrimSpace(contact.Name.FormattedName) + if name == "" { + name = strings.TrimSpace(contact.Name.FirstName + " " + contact.Name.LastName) + } + fields := []string{"[Contact] " + name} + for _, phone := range contact.Phones { + if value := strings.TrimSpace(phone.Phone); value != "" { + fields = append(fields, value) } } + for _, email := range contact.Emails { + if value := strings.TrimSpace(email.Email); value != "" { + fields = append(fields, value) + } + } + lines = append(lines, strings.Join(fields, " · ")) } + return &whatsappInboundContent{ + messageType: enums.IMMessageTypeText, + content: strings.Join(lines, "\n"), + extra: map[string]any{"whatsapp_contact_count": len(contacts)}, + }, nil +} + +func (s *whatsappInboundService) interactiveContent(interactive *whatsapp.InteractivePayload) (*whatsappInboundContent, error) { + if interactive == nil { + return nil, fmt.Errorf("whatsapp interactive message has no payload") + } + var kind, title, value string + switch interactive.Type { + case "button_reply": + kind = "button_reply" + if interactive.ButtonReply != nil { + title = interactive.ButtonReply.Title + value = interactive.ButtonReply.ID + } + case "list_reply": + kind = "list_reply" + if interactive.ListReply != nil { + title = interactive.ListReply.Title + value = interactive.ListReply.ID + } + case "nfm_reply": + kind = "nfm_reply" + if interactive.NFMReply != nil { + title = interactive.NFMReply.Body + value = interactive.NFMReply.Name + } + default: + kind = strings.TrimSpace(interactive.Type) + } + + // The button label is what the customer actually chose and is the useful + // signal for both the agent and the AI; the id is kept for correlation. + content := strings.TrimSpace(whatsappFirstNonBlank(title, value)) + if content == "" { + content = "[" + kind + "]" + } + return &whatsappInboundContent{ + messageType: enums.IMMessageTypeText, + content: content, + extra: map[string]any{ + "whatsapp_interactive_type": kind, + "whatsapp_interactive_id": strings.TrimSpace(value), + }, + }, nil +} + +func (s *whatsappInboundService) reactionContent(reaction *whatsapp.ReactionPayload) *whatsappInboundContent { + extra := map[string]any{"whatsapp_reaction": true} + if reaction == nil { + return &whatsappInboundContent{ + messageType: enums.IMMessageTypeText, + content: "[Reaction]", + extra: extra, + } + } + extra["whatsapp_reaction_message_id"] = strings.TrimSpace(reaction.MessageID) + extra["whatsapp_reaction_emoji"] = reaction.Emoji + // An empty emoji is how WhatsApp reports a removed reaction. + content := strings.TrimSpace(reaction.Emoji) + if content == "" { + content = "[Reaction removed]" + } + return &whatsappInboundContent{ + messageType: enums.IMMessageTypeText, + content: content, + extra: extra, + } +} +func (s *whatsappInboundService) unsupportedContent(message *whatsapp.InboundMessage) *whatsappInboundContent { + messageType := strings.TrimSpace(message.Type) + if messageType == "" { + messageType = "unknown" + } + content := "[" + messageType + "]" + for _, webhookError := range message.Errors { + if detail := strings.TrimSpace(webhookError.Message); detail != "" { + content += " " + detail + } + } + slog.Warn("whatsapp inbound message type is not supported", + "type", messageType, + "whatsapp_message_id", strings.TrimSpace(message.ID), + ) + return &whatsappInboundContent{ + messageType: enums.IMMessageTypeText, + content: content, + extra: map[string]any{"whatsapp_unsupported": true}, + } +} + +// verifyWhatsAppWebhook authenticates an inbound delivery. It fails closed: a +// payload is accepted only when its signature was verified against a configured +// Meta App Secret. Skipping the check when either side is missing would let +// anyone who learns a webhook URL write into a customer conversation, trigger AI +// replies and burn paid message quota. +func verifyWhatsAppWebhook(cfg *dto.WhatsAppChannelConfig, signatureHeader string, rawPayload []byte) error { + appSecret := resolveMetaAppSecret(cfg.AppSecret) + if appSecret == "" { + slog.Error("whatsapp webhook rejected, no meta app secret configured", + "hint", "set appSecret on the WhatsApp channel or META_APP_SECRET in the environment", + ) + return errorsx.UnauthorizedI18n("error.e0349") + } + if strings.TrimSpace(signatureHeader) == "" { + return errorsx.UnauthorizedI18n("error.e0350") + } + if !verifyWhatsAppSignature(appSecret, signatureHeader, rawPayload) { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } return nil } +// resolveMetaAppSecret finds the Meta App Secret used to sign webhook payloads, +// preferring the channel's own credential over the deployment-wide one. +func resolveMetaAppSecret(channelAppSecret string) string { + if secret := strings.TrimSpace(channelAppSecret); secret != "" { + return secret + } + if serverCfg := config.GetCurrent(); serverCfg != nil { + if secret := strings.TrimSpace(serverCfg.Messenger.AppSecret); secret != "" { + return secret + } + } + // config already binds META_APP_SECRET, but a deployment may export it after + // configuration was loaded. + return strings.TrimSpace(os.Getenv("META_APP_SECRET")) +} + func verifyWhatsAppSignature(appSecret string, signatureHeader string, payload []byte) bool { signature := strings.TrimSpace(signatureHeader) - if strings.HasPrefix(signature, "sha256=") { - expectedSig := signature[len("sha256="):] - mac := hmac.New(sha256.New, []byte(appSecret)) - mac.Write(payload) - actualSig := hex.EncodeToString(mac.Sum(nil)) - return hmac.Equal([]byte(actualSig), []byte(expectedSig)) - } - return true + const prefix = "sha256=" + if !strings.HasPrefix(signature, prefix) { + // A header we cannot interpret is not a header that proved anything. + return false + } + expected := strings.TrimSpace(signature[len(prefix):]) + if expected == "" { + return false + } + mac := hmac.New(sha256.New, []byte(appSecret)) + mac.Write(payload) + return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(expected)) +} + +func whatsappFirstNonBlank(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" } diff --git a/internal/services/whatsapp_inbound_service_test.go b/internal/services/whatsapp_inbound_service_test.go index ab398e93..bab266eb 100644 --- a/internal/services/whatsapp_inbound_service_test.go +++ b/internal/services/whatsapp_inbound_service_test.go @@ -2,14 +2,24 @@ package services import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" "testing" "time" "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" "agent-desk/internal/repositories" + "agent-desk/internal/whatsapp" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" @@ -17,9 +27,12 @@ import ( "gorm.io/gorm/schema" ) +const whatsAppTestAppSecret = "test_meta_app_secret" + func setupWhatsAppTestDB(t *testing.T) *gorm.DB { t.Helper() - db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + name := strings.ReplaceAll(t.Name(), "/", "_") + db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{ TablePrefix: "t_", SingularTable: true, @@ -40,6 +53,7 @@ func setupWhatsAppTestDB(t *testing.T) *gorm.DB { &models.ConversationInterrupt{}, &models.ConversationEventLog{}, &models.Message{}, + &models.Asset{}, &models.AIAgent{}, &models.User{}, &models.Role{}, @@ -54,9 +68,18 @@ func setupWhatsAppTestDB(t *testing.T) *gorm.DB { return db } -func TestWhatsAppInboundAndOutbound(t *testing.T) { - db := setupWhatsAppTestDB(t) +func signWhatsAppPayload(t *testing.T, secret string, payload []byte) string { + t.Helper() + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} +// seedWhatsAppChannel creates a usable AI agent plus a WhatsApp channel and +// returns the channel. appSecret is stored on the channel so signature +// verification has a credential to check against. +func seedWhatsAppChannel(t *testing.T, db *gorm.DB, appSecret string) *models.Channel { + t.Helper() now := time.Now() aiAgent := &models.AIAgent{ Name: "WhatsApp AI Agent", @@ -73,8 +96,12 @@ func TestWhatsAppInboundAndOutbound(t *testing.T) { WABAID: "waba_id_8888", AccessToken: "test_wa_access_token", WebhookVerifyToken: "verify_token_wa_123", + AppSecret: appSecret, + } + cfgBytes, err := json.Marshal(waConfig) + if err != nil { + t.Fatalf("marshal channel config: %v", err) } - cfgBytes, _ := json.Marshal(waConfig) channel := &models.Channel{ ChannelType: enums.ChannelTypeWhatsApp, @@ -89,8 +116,11 @@ func TestWhatsAppInboundAndOutbound(t *testing.T) { if err := db.Create(channel).Error; err != nil { t.Fatalf("create whatsapp channel: %v", err) } + return channel +} - payload := `{ +func whatsAppWebhookPayload(messages string) string { + return `{ "object": "whatsapp_business_account", "entry": [ { @@ -105,34 +135,347 @@ func TestWhatsAppInboundAndOutbound(t *testing.T) { "phone_number_id": "phone_id_9999" }, "contacts": [ - { - "profile": { "name": "Anh Le" }, - "wa_id": "84901234567" - } + { "profile": { "name": "Anh Le" }, "wa_id": "84901234567" } ], - "messages": [ - { - "from": "84901234567", - "id": "wamid.HBgLODQ5MDEyMzQ1NjcVAgASGBQz", - "timestamp": "1725260000", - "type": "text", - "text": { "body": "Xin chào, tôi cần hỗ trợ!" } - } - ] + "messages": [` + messages + `] } } ] } ] }` +} + +func assertUnauthorized(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatalf("expected an unauthorized error, got nil") + } + var i18nErr *errorsx.I18nError + if !errors.As(err, &i18nErr) || i18nErr.Code != errorsx.CodeAuthUnauthorized { + t.Fatalf("expected an unauthorized error, got %v", err) + } +} + +// i18nErrorKey returns the translation key an application error carries. +func i18nErrorKey(t *testing.T, err error) string { + t.Helper() + var i18nErr *errorsx.I18nError + if !errors.As(err, &i18nErr) { + return "" + } + return i18nErr.Key +} + +func TestVerifyWhatsAppSignatureRejectsUnverifiableHeaders(t *testing.T) { + payload := []byte(`{"object":"whatsapp_business_account"}`) + mac := hmac.New(sha256.New, []byte(whatsAppTestAppSecret)) + mac.Write(payload) + valid := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + cases := []struct { + name string + secret string + header string + want bool + }{ + {name: "valid signature", secret: whatsAppTestAppSecret, header: valid, want: true}, + {name: "tampered payload", secret: "another_secret", header: valid, want: false}, + {name: "missing algorithm prefix", secret: whatsAppTestAppSecret, header: hex.EncodeToString(mac.Sum(nil)), want: false}, + {name: "empty header", secret: whatsAppTestAppSecret, header: "", want: false}, + {name: "empty digest", secret: whatsAppTestAppSecret, header: "sha256=", want: false}, + {name: "sha1 prefix is not sha256", secret: whatsAppTestAppSecret, header: "sha1=" + hex.EncodeToString(mac.Sum(nil)), want: false}, + } + + for _, tc := range cases { + if got := verifyWhatsAppSignature(tc.secret, tc.header, payload); got != tc.want { + t.Errorf("%s: verifyWhatsAppSignature() = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestWhatsAppWebhookRejectsUnauthenticatedDelivery(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, whatsAppTestAppSecret) + + payload := []byte(whatsAppWebhookPayload(`{ + "from": "84901234567", + "id": "wamid.FORGED0001", + "timestamp": "1725260000", + "type": "text", + "text": { "body": "forged message" } + }`)) - ctx := context.Background() - err := WhatsAppInboundService.HandleWebhook(ctx, "", "", []byte(payload)) + t.Run("missing signature header", func(t *testing.T) { + err := WhatsAppInboundService.HandleWebhook(context.Background(), channel.ChannelID, "", payload) + assertUnauthorized(t, err) + assertNoWhatsAppMessage(t, db, "wamid.FORGED0001") + }) + + t.Run("invalid signature", func(t *testing.T) { + err := WhatsAppInboundService.HandleWebhook(context.Background(), channel.ChannelID, "sha256=deadbeef", payload) + assertUnauthorized(t, err) + assertNoWhatsAppMessage(t, db, "wamid.FORGED0001") + }) + + t.Run("valid signature is accepted", func(t *testing.T) { + signature := signWhatsAppPayload(t, whatsAppTestAppSecret, payload) + if err := WhatsAppInboundService.HandleWebhook(context.Background(), channel.ChannelID, signature, payload); err != nil { + t.Fatalf("HandleWebhook with a valid signature failed: %v", err) + } + if countWhatsAppMessages(t, db, "wamid.FORGED0001") != 1 { + t.Fatalf("expected the signed message to be stored") + } + }) +} + +func TestWhatsAppWebhookRejectsWhenNoAppSecretIsConfigured(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, "") + + previousConfig := config.GetCurrent() + config.SetCurrent(&config.Config{}) + t.Setenv("META_APP_SECRET", "") + defer config.SetCurrent(previousConfig) + + payload := []byte(whatsAppWebhookPayload(`{ + "from": "84901234567", + "id": "wamid.NOSECRET01", + "timestamp": "1725260000", + "type": "text", + "text": { "body": "should not be stored" } + }`)) + + err := WhatsAppInboundService.HandleWebhook(context.Background(), channel.ChannelID, "sha256=anything", payload) + assertUnauthorized(t, err) + assertNoWhatsAppMessage(t, db, "wamid.NOSECRET01") +} + +func TestWhatsAppInboundStructuredMessageTypes(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, whatsAppTestAppSecret) + + payload := []byte(whatsAppWebhookPayload(` + { + "from": "84901234567", "id": "wamid.LOC01", "timestamp": "1725260000", "type": "location", + "location": { "latitude": 10.823099, "longitude": 106.629662, "name": "Ben Thanh Market", "address": "Quang Trung, Ho Chi Minh" } + }, + { + "from": "84901234567", "id": "wamid.BTN01", "timestamp": "1725260001", "type": "interactive", + "interactive": { "type": "button_reply", "button_reply": { "id": "yes", "title": "Talk to a human" } } + }, + { + "from": "84901234567", "id": "wamid.RXN01", "timestamp": "1725260002", "type": "reaction", + "reaction": { "message_id": "wamid.EARLIER", "emoji": "👍" } + }, + { + "from": "84901234567", "id": "wamid.ORD01", "timestamp": "1725260003", "type": "order", + "errors": [ { "code": 131047, "title": "Message undeliverable", "message": "Order messages are not supported" } ] + } + `)) + + signature := signWhatsAppPayload(t, whatsAppTestAppSecret, payload) + if err := WhatsAppInboundService.HandleWebhook(context.Background(), channel.ChannelID, signature, payload); err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + expected := map[string]string{ + "wamid.LOC01": "[Location] 10.823099,106.629662 · Ben Thanh Market · Quang Trung, Ho Chi Minh", + "wamid.BTN01": "Talk to a human", + "wamid.RXN01": "👍", + "wamid.ORD01": "[order] Order messages are not supported", + } + for messageID, wantContent := range expected { + message := findWhatsAppMessage(t, db, messageID) + if message == nil { + t.Fatalf("%s: expected a message to be stored", messageID) + } + if message.MessageType != enums.IMMessageTypeText { + t.Errorf("%s: message type = %s, want text", messageID, message.MessageType) + } + if message.Content != wantContent { + t.Errorf("%s: content = %q, want %q", messageID, message.Content, wantContent) + } + } +} + +func TestWhatsAppInboundImageStoresAsset(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, whatsAppTestAppSecret) + + previousConfig := config.GetCurrent() + cfg := &config.Config{} + cfg.Storage.Default = enums.AssetProviderLocal + cfg.Storage.MaxUploadSizeMB = 5 + cfg.Storage.Local.Root = t.TempDir() + cfg.Storage.Local.BaseURL = "https://cdn.test/assets" + config.SetCurrent(cfg) + defer config.SetCurrent(previousConfig) + + // A minimal JFIF so net/http.DetectContentType identifies it as an image. + jpeg := []byte{ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9, + } + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/media/") { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(jpeg) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "id": strings.TrimPrefix(r.URL.Path, "/"), + "url": "https://" + r.Host + "/media/" + strings.TrimPrefix(r.URL.Path, "/"), + "mime_type": "image/jpeg", + }) + })) + defer server.Close() + + restore := stubWhatsAppClient(server) + defer restore() + + payload := []byte(whatsAppWebhookPayload(`{ + "from": "84901234567", "id": "wamid.IMG01", "timestamp": "1725260000", "type": "image", + "image": { "id": "media-img-01", "mime_type": "image/jpeg", "caption": "Báo giá giúp mình" } + }`)) + + signature := signWhatsAppPayload(t, whatsAppTestAppSecret, payload) + if err := WhatsAppInboundService.HandleWebhook(context.Background(), channel.ChannelID, signature, payload); err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + message := findWhatsAppMessage(t, db, "wamid.IMG01") + if message == nil { + t.Fatalf("expected the image message to be stored") + } + if message.MessageType != enums.IMMessageTypeImage { + t.Fatalf("message type = %s, want image", message.MessageType) + } + // The caption is carried in the file name because normalizeMessageContent + // replaces an image message's content with the stored asset name. + if !strings.Contains(message.Content, "Báo giá giúp mình") { + t.Fatalf("content = %q, want it to carry the caption", message.Content) + } + + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + if err != nil { + t.Fatalf("message payload is not a valid asset payload: %v", err) + } + asset := AssetService.GetByAssetID(assetPayload.AssetID) + if asset == nil { + t.Fatalf("expected an asset row for %s", assetPayload.AssetID) + } + if asset.Status != enums.AssetStatusSuccess { + t.Fatalf("asset status = %v, want success", asset.Status) + } + if asset.FileSize != int64(len(jpeg)) { + t.Fatalf("asset size = %d, want %d", asset.FileSize, len(jpeg)) + } + if !strings.HasSuffix(asset.StorageKey, ".jpg") { + t.Fatalf("storage key = %q, want a .jpg suffix", asset.StorageKey) + } +} + +func TestWhatsAppInboundMediaFailureFallsBackToText(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, whatsAppTestAppSecret) + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":{"message":"media expired","code":131047}}`, http.StatusGone) + })) + defer server.Close() + + restore := stubWhatsAppClient(server) + defer restore() + + payload := []byte(whatsAppWebhookPayload(`{ + "from": "84901234567", "id": "wamid.IMG02", "timestamp": "1725260000", "type": "image", + "image": { "id": "media-img-02", "mime_type": "image/jpeg", "caption": "Ảnh lỗi sản phẩm" } + }`)) + + signature := signWhatsAppPayload(t, whatsAppTestAppSecret, payload) + if err := WhatsAppInboundService.HandleWebhook(context.Background(), channel.ChannelID, signature, payload); err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + message := findWhatsAppMessage(t, db, "wamid.IMG02") + if message == nil { + t.Fatalf("expected the failed media message to still be stored as text") + } + if message.MessageType != enums.IMMessageTypeText { + t.Fatalf("message type = %s, want text", message.MessageType) + } + want := "Ảnh lỗi sản phẩm [Image Attachment]" + if message.Content != want { + t.Fatalf("content = %q, want %q", message.Content, want) + } +} + +// stubWhatsAppClient points the inbound Graph client at a local stub and returns +// a restore func. The inbound media download insists on https, so the stub is a +// TLS server and the client is given the test transport that trusts it. +func stubWhatsAppClient(server *httptest.Server) func() { + previous := newWhatsAppClient + newWhatsAppClient = func(accessToken string) *whatsapp.Client { + client := whatsapp.NewClient(accessToken) + client.SetBaseURL(server.URL) + client.SetHTTPClient(server.Client()) + return client + } + return func() { newWhatsAppClient = previous } +} + +// findWhatsAppMessage looks a stored message up by its client message id. The +// payload cannot be used: media messages carry the canonical asset payload, not +// the webhook metadata. +func findWhatsAppMessage(t *testing.T, db *gorm.DB, whatsAppMessageID string) *models.Message { + t.Helper() + var message models.Message + err := db.Table("t_message").Where("client_msg_id = ?", "wa_"+whatsAppMessageID).First(&message).Error if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + t.Fatalf("query message: %v", err) + } + return &message +} + +func countWhatsAppMessages(t *testing.T, db *gorm.DB, whatsAppMessageID string) int64 { + t.Helper() + var count int64 + if err := db.Table("t_message").Where("client_msg_id = ?", "wa_"+whatsAppMessageID).Count(&count).Error; err != nil { + t.Fatalf("count messages: %v", err) + } + return count +} + +func assertNoWhatsAppMessage(t *testing.T, db *gorm.DB, whatsAppMessageID string) { + t.Helper() + if count := countWhatsAppMessages(t, db, whatsAppMessageID); count != 0 { + t.Fatalf("expected no stored message for %s, found %d", whatsAppMessageID, count) + } +} + +func TestWhatsAppInboundAndOutbound(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, whatsAppTestAppSecret) + + payload := []byte(whatsAppWebhookPayload(`{ + "from": "84901234567", + "id": "wamid.HBgLODQ5MDEyMzQ1NjcVAgASGBQz", + "timestamp": "1725260000", + "type": "text", + "text": { "body": "Xin chào, tôi cần hỗ trợ!" } + }`)) + + signature := signWhatsAppPayload(t, whatsAppTestAppSecret, payload) + if err := WhatsAppInboundService.HandleWebhook(context.Background(), channel.ChannelID, signature, payload); err != nil { t.Fatalf("HandleWebhook failed: %v", err) } - // Verify customer identity identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). Eq("external_source", enums.ExternalSourceWhatsApp). Eq("external_id", "84901234567")) @@ -140,7 +483,6 @@ func TestWhatsAppInboundAndOutbound(t *testing.T) { t.Fatalf("expected customer identity for 84901234567") } - // Verify conversation conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). Eq("customer_id", identity.CustomerID). Eq("channel_id", channel.ID)) @@ -148,7 +490,6 @@ func TestWhatsAppInboundAndOutbound(t *testing.T) { t.Fatalf("expected conversation to be created") } - // Verify message msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). Eq("conversation_id", conv.ID). Eq("sender_type", enums.IMSenderTypeCustomer)) @@ -161,8 +502,7 @@ func TestWhatsAppInboundAndOutbound(t *testing.T) { operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} - // Test Outbound enqueue - replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_wa_reply_1", enums.IMMessageTypeText, "Chào bạn! Crove Desk có thể giúp gì cho bạn?", "", operator) + replyMsg, err := MessageService.SendAIMessage(conv.ID, channel.AIAgentID, "ai_wa_reply_1", enums.IMMessageTypeText, "Chào bạn! Crove Desk có thể giúp gì cho bạn?", "", operator) if err != nil { t.Fatalf("MessageService.SendAIMessage failed: %v", err) } diff --git a/internal/services/whatsapp_oauth_service.go b/internal/services/whatsapp_oauth_service.go new file mode 100644 index 00000000..a704702c --- /dev/null +++ b/internal/services/whatsapp_oauth_service.go @@ -0,0 +1,323 @@ +package services + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "time" + + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/whatsapp" +) + +var WhatsAppOAuthService = newWhatsAppOAuthService() + +func newWhatsAppOAuthService() *whatsappOAuthService { + return &whatsappOAuthService{} +} + +type whatsappOAuthService struct{} + +// newWhatsAppOAuthClient builds the Graph API client for the connect flow. It is +// a variable so tests can point it at a local stub instead of Meta. +var newWhatsAppOAuthClient = func(accessToken string) *whatsapp.Client { + return whatsapp.NewClient(accessToken) +} + +const ( + // whatsappOAuthTimeout bounds the whole connect flow: one token exchange, + // one token inspection and a bounded set of discovery calls. + whatsappOAuthTimeout = 45 * time.Second + // Discovery fan-out is capped so a business portfolio with many WABAs cannot + // turn one operator click into a long tail of Graph API calls. + whatsappOAuthMaxBusinesses = 5 + whatsappOAuthMaxAccounts = 10 +) + +// Connect exchanges a Meta OAuth authorization code for WhatsApp Cloud API +// credentials, discovers the sender numbers the token can reach, and — when the +// operator is editing an existing channel — persists everything onto it. +// +// Discovery is deliberately best effort. A token obtained through Embedded +// Signup is often a business token that cannot list the business portfolio's +// accounts, and Meta answers that with an empty list or error code 200 rather +// than a hard failure. Failing the whole connect over it would throw away a +// perfectly usable access token, so partial results are reported in Warnings. +func (s *whatsappOAuthService) Connect(req request.WhatsAppOAuthCallbackRequest, locale string, operator *dto.AuthPrincipal) (*response.WhatsAppOAuthConnectResponse, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + code := strings.TrimSpace(req.Code) + if code == "" { + return nil, errorsx.InvalidParamI18n("error.param.required", "code") + } + + appID, appSecret := s.resolveAppCredentials() + if appID == "" || appSecret == "" { + return nil, errorsx.InvalidParamI18n("error.e0351") + } + + ctx, cancel := context.WithTimeout(context.Background(), whatsappOAuthTimeout) + defer cancel() + + token, err := newWhatsAppOAuthClient("").ExchangeCodeForToken(ctx, appID, appSecret, code, req.RedirectURI) + if err != nil { + slog.Warn("whatsapp oauth code exchange failed", "error", err) + return nil, errorsx.InvalidParamI18n("error.e0352", err.Error()) + } + + result := &response.WhatsAppOAuthConnectResponse{ + AccessToken: token.AccessToken, + TokenMasked: maskWhatsAppToken(token.AccessToken), + TokenType: strings.TrimSpace(token.TokenType), + Accounts: []response.WhatsAppOAuthAccountResponse{}, + } + + s.inspectToken(ctx, token.AccessToken, locale, result) + s.discoverAccounts(ctx, token.AccessToken, locale, result) + s.applySingleCandidate(result) + + if req.ChannelID > 0 { + if err := s.persist(req, result, operator); err != nil { + return nil, err + } + } + return result, nil +} + +func (s *whatsappOAuthService) resolveAppCredentials() (string, string) { + var appID, appSecret string + if cfg := config.GetCurrent(); cfg != nil { + appID = strings.TrimSpace(cfg.Messenger.AppID) + appSecret = strings.TrimSpace(cfg.Messenger.AppSecret) + } + return appID, appSecret +} + +// inspectToken records expiry and granted scopes so the operator can see whether +// the authorization actually covers sending and receiving messages. +func (s *whatsappOAuthService) inspectToken(ctx context.Context, accessToken string, locale string, result *response.WhatsAppOAuthConnectResponse) { + debug, err := newWhatsAppOAuthClient(accessToken).DebugToken(ctx, accessToken) + if err != nil { + result.Warnings = append(result.Warnings, i18nx.TLocale(locale, "error.whatsapp.oauth.tokenInspectFailed", err.Error())) + return + } + if !debug.IsValid { + result.Warnings = append(result.Warnings, i18nx.TLocale(locale, "error.whatsapp.oauth.tokenInvalid")) + } + if debug.ExpiresAt > 0 { + result.ExpiresAt = time.Unix(debug.ExpiresAt, 0).Format(time.RFC3339) + } + result.Scopes = debug.Scopes + for _, required := range []string{"whatsapp_business_messaging", "whatsapp_business_management"} { + if !containsWhatsAppScope(debug.Scopes, required) { + result.Warnings = append(result.Warnings, i18nx.TLocale(locale, "error.whatsapp.oauth.scopeMissing", required)) + } + } +} + +func containsWhatsAppScope(scopes []string, wanted string) bool { + for _, scope := range scopes { + if strings.EqualFold(strings.TrimSpace(scope), wanted) { + return true + } + } + return false +} + +func (s *whatsappOAuthService) discoverAccounts(ctx context.Context, accessToken string, locale string, result *response.WhatsAppOAuthConnectResponse) { + client := newWhatsAppOAuthClient(accessToken) + + businesses, err := client.ListBusinesses(ctx) + if err != nil { + result.Warnings = append(result.Warnings, i18nx.TLocale(locale, "error.whatsapp.oauth.businessesFailed", err.Error())) + return + } + if len(businesses) == 0 { + result.Warnings = append(result.Warnings, i18nx.TLocale(locale, "error.whatsapp.oauth.noBusinesses")) + return + } + if len(businesses) > whatsappOAuthMaxBusinesses { + businesses = businesses[:whatsappOAuthMaxBusinesses] + } + + for _, business := range businesses { + accounts, err := client.ListOwnedWhatsAppBusinessAccounts(ctx, business.ID) + if err != nil { + result.Warnings = append(result.Warnings, i18nx.TLocale(locale, "error.whatsapp.oauth.accountsFailed", business.ID, err.Error())) + continue + } + if len(accounts) > whatsappOAuthMaxAccounts { + accounts = accounts[:whatsappOAuthMaxAccounts] + } + for _, account := range accounts { + entry := response.WhatsAppOAuthAccountResponse{ + WabaID: strings.TrimSpace(account.ID), + WabaName: strings.TrimSpace(account.Name), + BusinessID: strings.TrimSpace(business.ID), + BusinessName: strings.TrimSpace(business.Name), + PhoneNumbers: []response.WhatsAppOAuthPhoneNumberResponse{}, + } + numbers, err := client.ListPhoneNumbers(ctx, account.ID) + if err != nil { + result.Warnings = append(result.Warnings, i18nx.TLocale(locale, "error.whatsapp.oauth.phoneNumbersFailed", account.ID, err.Error())) + } + for _, number := range numbers { + entry.PhoneNumbers = append(entry.PhoneNumbers, response.WhatsAppOAuthPhoneNumberResponse{ + PhoneNumberID: strings.TrimSpace(number.ID), + DisplayPhoneNumber: strings.TrimSpace(number.DisplayPhoneNumber), + VerifiedName: strings.TrimSpace(number.VerifiedName), + QualityRating: strings.TrimSpace(number.QualityRating), + CodeVerificationStatus: strings.TrimSpace(number.CodeVerificationStatus), + }) + } + result.Accounts = append(result.Accounts, entry) + } + } + + if len(result.Accounts) == 0 { + result.Warnings = append(result.Warnings, i18nx.TLocale(locale, "error.whatsapp.oauth.noAccounts")) + } +} + +// applySingleCandidate preselects the obvious choice when discovery found +// exactly one sender number, so the operator is not asked to pick from a list +// of one. +func (s *whatsappOAuthService) applySingleCandidate(result *response.WhatsAppOAuthConnectResponse) { + var ( + wabaID string + phoneNumber string + candidates int + ) + for _, account := range result.Accounts { + if wabaID == "" { + wabaID = account.WabaID + } + for _, number := range account.PhoneNumbers { + candidates++ + if phoneNumber == "" { + phoneNumber = number.PhoneNumberID + } + } + } + if candidates == 1 { + result.PhoneNumberID = phoneNumber + result.WabaID = wabaID + } +} + +// persist writes the exchanged credentials onto an existing WhatsApp channel, +// preserving the webhook verify token and welcome message it already has. +func (s *whatsappOAuthService) persist(req request.WhatsAppOAuthCallbackRequest, result *response.WhatsAppOAuthConnectResponse, operator *dto.AuthPrincipal) error { + channel := ChannelService.Get(req.ChannelID) + if channel == nil || channel.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0208") + } + if strings.TrimSpace(channel.ChannelType) != enums.ChannelTypeWhatsApp { + return errorsx.InvalidParamI18n("error.e0250") + } + + cfg, err := ChannelService.ParseWhatsAppChannelConfig(channel.ConfigJSON) + if err != nil { + return errorsx.InvalidParam("invalid whatsapp configuration") + } + if cfg == nil { + cfg = &dto.WhatsAppChannelConfig{} + } + + cfg.AccessToken = strings.TrimSpace(result.AccessToken) + if wabaID := s.pickWabaID(req, result); wabaID != "" { + cfg.WABAID = wabaID + } + if phoneNumberID := s.pickPhoneNumberID(req, result, cfg.WABAID); phoneNumberID != "" { + cfg.PhoneNumberID = phoneNumberID + } + // Without an app secret the inbound webhook cannot be authenticated, so the + // deployment-wide secret is recorded on the channel when it has none. + if cfg.AppSecret == "" { + if cfgSecret := config.GetCurrent(); cfgSecret != nil { + cfg.AppSecret = strings.TrimSpace(cfgSecret.Messenger.AppSecret) + } + } + + configBytes, err := json.Marshal(cfg) + if err != nil { + return err + } + if err := ChannelService.Updates(channel.ID, map[string]any{ + "config_json": string(configBytes), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { + return err + } + + result.Connected = true + result.ChannelID = channel.ID + slog.Info("whatsapp credentials connected to channel", + "channel", channel.ID, + "waba_id", cfg.WABAID, + "phone_number_id", cfg.PhoneNumberID, + "operator", operator.Username, + ) + return nil +} + +func (s *whatsappOAuthService) pickWabaID(req request.WhatsAppOAuthCallbackRequest, result *response.WhatsAppOAuthConnectResponse) string { + if wabaID := strings.TrimSpace(req.WabaID); wabaID != "" { + return wabaID + } + return strings.TrimSpace(result.WabaID) +} + +// pickPhoneNumberID resolves the sender number to store. An explicit choice wins, +// then a single discovered candidate, then the only number under the selected +// WABA. Ambiguous cases are left untouched rather than guessed at, because +// sending from the wrong number is not recoverable. +func (s *whatsappOAuthService) pickPhoneNumberID(req request.WhatsAppOAuthCallbackRequest, result *response.WhatsAppOAuthConnectResponse, wabaID string) string { + if phoneNumberID := strings.TrimSpace(req.PhoneNumberID); phoneNumberID != "" { + return phoneNumberID + } + if result.PhoneNumberID != "" { + return result.PhoneNumberID + } + wabaID = strings.TrimSpace(wabaID) + if wabaID == "" { + return "" + } + var found string + count := 0 + for _, account := range result.Accounts { + if account.WabaID != wabaID { + continue + } + for _, number := range account.PhoneNumbers { + count++ + if found == "" { + found = number.PhoneNumberID + } + } + } + if count == 1 { + return found + } + return "" +} + +// maskWhatsAppToken keeps enough of a token to recognise it in a list without +// making the masked value usable. +func maskWhatsAppToken(token string) string { + token = strings.TrimSpace(token) + if len(token) <= 8 { + return "********" + } + return token[:4] + "…" + token[len(token)-4:] +} diff --git a/internal/services/whatsapp_oauth_service_test.go b/internal/services/whatsapp_oauth_service_test.go new file mode 100644 index 00000000..8d4011a0 --- /dev/null +++ b/internal/services/whatsapp_oauth_service_test.go @@ -0,0 +1,343 @@ +package services + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/whatsapp" +) + +// newGraphStub returns an httptest server that answers the WhatsApp connect flow +// with a single business, a single WABA and a single sender number. +func newGraphStub(t *testing.T, accessToken string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + + mux.HandleFunc("/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("code"); got != "auth-code-1" { + t.Errorf("code = %q, want auth-code-1", got) + } + if got := r.URL.Query().Get("client_id"); got != "app-1" { + t.Errorf("client_id = %q, want app-1", got) + } + if got := r.URL.Query().Get("client_secret"); got != "secret-1" { + t.Errorf("client_secret = %q, want secret-1", got) + } + writeJSONStub(w, map[string]any{ + "access_token": accessToken, + "token_type": "bearer", + "expires_in": 5184000, + }) + }) + + mux.HandleFunc("/debug_token", func(w http.ResponseWriter, r *http.Request) { + writeJSONStub(w, map[string]any{ + "data": map[string]any{ + "app_id": "app-1", + "type": "USER", + "is_valid": true, + "expires_at": 1893456000, + "scopes": []string{"whatsapp_business_management", "whatsapp_business_messaging"}, + "user_id": "user-1", + }, + }) + }) + + mux.HandleFunc("/me/businesses", func(w http.ResponseWriter, r *http.Request) { + writeJSONStub(w, map[string]any{ + "data": []map[string]any{{"id": "biz-1", "name": "Crove Business"}}, + }) + }) + + mux.HandleFunc("/biz-1/owned_whatsapp_business_accounts", func(w http.ResponseWriter, r *http.Request) { + writeJSONStub(w, map[string]any{ + "data": []map[string]any{{"id": "waba-1", "name": "Crove WABA"}}, + }) + }) + + mux.HandleFunc("/waba-1/phone_numbers", func(w http.ResponseWriter, r *http.Request) { + writeJSONStub(w, map[string]any{ + "data": []map[string]any{{ + "id": "phone-1", + "display_phone_number": "+1 555 026 9999", + "verified_name": "Crove Desk", + "quality_rating": "GREEN", + "code_verification_status": "VERIFIED", + }}, + }) + }) + + return httptest.NewServer(mux) +} + +func writeJSONStub(w http.ResponseWriter, body any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) +} + +// stubWhatsAppOAuthClient points the connect flow at a local stub and returns a +// restore func. +func stubWhatsAppOAuthClient(server *httptest.Server) func() { + previous := newWhatsAppOAuthClient + newWhatsAppOAuthClient = func(accessToken string) *whatsapp.Client { + client := whatsapp.NewClient(accessToken) + client.SetBaseURL(server.URL) + client.SetHTTPClient(server.Client()) + return client + } + return func() { newWhatsAppOAuthClient = previous } +} + +// setMetaAppCredentials installs the Meta app credentials the connect flow reads +// and restores whatever was configured before. +func setMetaAppCredentials(t *testing.T, appID, appSecret string) { + t.Helper() + previous := config.GetCurrent() + cfg := &config.Config{} + if previous != nil { + *cfg = *previous + } + cfg.Messenger.AppID = appID + cfg.Messenger.AppSecret = appSecret + config.SetCurrent(cfg) + t.Cleanup(func() { config.SetCurrent(previous) }) +} + +func TestWhatsAppOAuthConnectPersistsCredentials(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, whatsAppTestAppSecret) + setMetaAppCredentials(t, "app-1", "secret-1") + + server := newGraphStub(t, "EAAtest-business-token") + defer server.Close() + defer stubWhatsAppOAuthClient(server)() + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + result, err := WhatsAppOAuthService.Connect(request.WhatsAppOAuthCallbackRequest{ + Code: "auth-code-1", + ChannelID: channel.ID, + State: "crove_whatsapp_connect:1", + }, "en-US", operator) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + + if !result.Connected { + t.Errorf("Connected = false, want true") + } + if result.ChannelID != channel.ID { + t.Errorf("ChannelID = %d, want %d", result.ChannelID, channel.ID) + } + if result.AccessToken != "EAAtest-business-token" { + t.Errorf("AccessToken = %q, want the exchanged token", result.AccessToken) + } + if result.WabaID != "waba-1" || result.PhoneNumberID != "phone-1" { + t.Errorf("discovered waba=%q phone=%q, want waba-1/phone-1", result.WabaID, result.PhoneNumberID) + } + if len(result.Accounts) != 1 || len(result.Accounts[0].PhoneNumbers) != 1 { + t.Fatalf("accounts = %+v, want one account with one phone number", result.Accounts) + } + if len(result.Warnings) != 0 { + t.Errorf("unexpected warnings: %v", result.Warnings) + } + if strings.Contains(result.TokenMasked, "business-token") { + t.Errorf("TokenMasked = %q leaks the token", result.TokenMasked) + } + + saved := ChannelService.Get(channel.ID) + if saved == nil { + t.Fatalf("channel %d disappeared", channel.ID) + } + cfg, err := ChannelService.ParseWhatsAppChannelConfig(saved.ConfigJSON) + if err != nil { + t.Fatalf("parse saved config: %v", err) + } + if cfg.AccessToken != "EAAtest-business-token" { + t.Errorf("saved access token = %q", cfg.AccessToken) + } + if cfg.WABAID != "waba-1" { + t.Errorf("saved waba id = %q, want waba-1", cfg.WABAID) + } + if cfg.PhoneNumberID != "phone-1" { + t.Errorf("saved phone number id = %q, want phone-1", cfg.PhoneNumberID) + } + // The webhook verify token was already set and must survive the merge, or the + // existing Meta webhook subscription would stop verifying. + if cfg.WebhookVerifyToken != "verify_token_wa_123" { + t.Errorf("webhook verify token = %q, want it preserved", cfg.WebhookVerifyToken) + } + if saved.UpdateUserName != "joy" { + t.Errorf("update_user_name = %q, want the operator", saved.UpdateUserName) + } +} + +func TestWhatsAppOAuthConnectRequiresAppCredentials(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, whatsAppTestAppSecret) + setMetaAppCredentials(t, "", "") + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + _, err := WhatsAppOAuthService.Connect(request.WhatsAppOAuthCallbackRequest{ + Code: "auth-code-1", + ChannelID: channel.ID, + }, "en-US", operator) + if err == nil { + t.Fatalf("expected an error when the Meta app credentials are missing") + } + if i18nErrorKey(t, err) != "error.e0351" { + t.Fatalf("error = %v, want error.e0351", err) + } + + // The channel must be untouched. + saved := ChannelService.Get(channel.ID) + cfg, parseErr := ChannelService.ParseWhatsAppChannelConfig(saved.ConfigJSON) + if parseErr != nil { + t.Fatalf("parse config: %v", parseErr) + } + if cfg.AccessToken != "test_wa_access_token" { + t.Errorf("access token was modified to %q", cfg.AccessToken) + } +} + +func TestWhatsAppOAuthConnectReportsFailedExchange(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, whatsAppTestAppSecret) + setMetaAppCredentials(t, "app-1", "secret-1") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + writeJSONStub(w, map[string]any{ + "error": map[string]any{ + "message": "Error validating verification code.", + "type": "OAuthException", + "code": 100, + }, + }) + })) + defer server.Close() + defer stubWhatsAppOAuthClient(server)() + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + _, err := WhatsAppOAuthService.Connect(request.WhatsAppOAuthCallbackRequest{ + Code: "auth-code-1", + ChannelID: channel.ID, + }, "en-US", operator) + if err == nil { + t.Fatalf("expected the failed exchange to surface as an error") + } + if !strings.Contains(err.Error(), "verification code") { + t.Errorf("error = %v, want Meta's reason to be carried through", err) + } + + // A failed exchange must not write credentials onto the channel. + saved := ChannelService.Get(channel.ID) + cfg, parseErr := ChannelService.ParseWhatsAppChannelConfig(saved.ConfigJSON) + if parseErr != nil { + t.Fatalf("parse config: %v", parseErr) + } + if cfg.AccessToken != "test_wa_access_token" { + t.Errorf("access token was modified to %q", cfg.AccessToken) + } +} + +// A business token from Embedded Signup often cannot enumerate the business +// portfolio. The token is still usable, so discovery failure must degrade to a +// warning instead of failing the connect. +func TestWhatsAppOAuthConnectToleratesDiscoveryFailure(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, whatsAppTestAppSecret) + setMetaAppCredentials(t, "app-1", "secret-1") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/oauth/access_token"): + writeJSONStub(w, map[string]any{"access_token": "EAAtest-business-token", "token_type": "bearer"}) + default: + w.WriteHeader(http.StatusForbidden) + writeJSONStub(w, map[string]any{ + "error": map[string]any{"message": "(#200) Requires business_management", "code": 200}, + }) + } + })) + defer server.Close() + defer stubWhatsAppOAuthClient(server)() + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + result, err := WhatsAppOAuthService.Connect(request.WhatsAppOAuthCallbackRequest{ + Code: "auth-code-1", + ChannelID: channel.ID, + }, "en-US", operator) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Connected { + t.Errorf("Connected = false, want the token to be saved despite failed discovery") + } + if len(result.Accounts) != 0 { + t.Errorf("accounts = %+v, want none", result.Accounts) + } + if len(result.Warnings) == 0 { + t.Errorf("expected warnings explaining why discovery found nothing") + } + + saved := ChannelService.Get(channel.ID) + cfg, parseErr := ChannelService.ParseWhatsAppChannelConfig(saved.ConfigJSON) + if parseErr != nil { + t.Fatalf("parse config: %v", parseErr) + } + if cfg.AccessToken != "EAAtest-business-token" { + t.Errorf("saved access token = %q", cfg.AccessToken) + } + // Nothing was discovered and nothing was requested, so the pre-existing + // sender number must not be guessed over. + if cfg.PhoneNumberID != "phone_id_9999" { + t.Errorf("phone number id = %q, want the existing value preserved", cfg.PhoneNumberID) + } +} + +func TestWhatsAppOAuthConnectRejectsNonWhatsAppChannel(t *testing.T) { + db := setupWhatsAppTestDB(t) + setMetaAppCredentials(t, "app-1", "secret-1") + + telegram := &models.Channel{ + ChannelType: enums.ChannelTypeTelegram, + ChannelID: "tg-1", + AIAgentID: 1, + AIAgentRolloutPercent: 100, + Name: "Telegram", + ConfigJSON: "{}", + Status: enums.StatusOk, + } + if err := db.Create(telegram).Error; err != nil { + t.Fatalf("create telegram channel: %v", err) + } + + server := newGraphStub(t, "EAAtest-business-token") + defer server.Close() + defer stubWhatsAppOAuthClient(server)() + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + _, err := WhatsAppOAuthService.Connect(request.WhatsAppOAuthCallbackRequest{ + Code: "auth-code-1", + ChannelID: telegram.ID, + }, "en-US", operator) + if err == nil { + t.Fatalf("expected an error when the target channel is not WhatsApp") + } + if key := i18nErrorKey(t, err); key != "error.e0250" { + t.Fatalf("error key = %q, want error.e0250", key) + } + + untouched := ChannelService.Take("id = ?", telegram.ID) + if untouched == nil || untouched.ConfigJSON != "{}" { + t.Errorf("the telegram channel config was modified") + } +} diff --git a/internal/whatsapp/client.go b/internal/whatsapp/client.go index da6e0a11..57915fd0 100644 --- a/internal/whatsapp/client.go +++ b/internal/whatsapp/client.go @@ -7,12 +7,18 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) const defaultBaseURL = "https://graph.facebook.com/v21.0" +// maxMediaReadBytes caps a media download when the caller does not supply a +// project limit, so a malformed Content-Length or an oversized payload cannot +// exhaust memory while it is being buffered for storage. +const maxMediaReadBytes = 64 << 20 + type Client struct { accessToken string baseURL string @@ -33,6 +39,15 @@ func (c *Client) SetBaseURL(url string) { } } +// SetHTTPClient replaces the transport and timeouts used for Graph API and media +// calls. It exists so a deployment can supply a proxy, and so tests can trust a +// local TLS stub. +func (c *Client) SetHTTPClient(client *http.Client) { + if client != nil { + c.httpClient = client + } +} + func (c *Client) SendTextMessage(ctx context.Context, phoneNumberID string, recipientPhone string, text string) (*SendMessageResponse, error) { phoneNumberID = strings.TrimSpace(phoneNumberID) if phoneNumberID == "" { @@ -110,7 +125,14 @@ func (c *Client) SendMediaMessage(ctx context.Context, phoneNumberID string, rec } func (c *Client) doRequest(ctx context.Context, method, path string, payload any, result any) error { - if c.accessToken == "" { + return c.do(ctx, method, path, payload, result, true) +} + +// do issues one Graph API call. authorize is false only for the OAuth code +// exchange, which runs before a token exists and authenticates with the app +// secret instead. +func (c *Client) do(ctx context.Context, method, path string, payload any, result any, authorize bool) error { + if authorize && c.accessToken == "" { return fmt.Errorf("whatsapp access token is required") } @@ -130,7 +152,9 @@ func (c *Client) doRequest(ctx context.Context, method, path string, payload any return fmt.Errorf("create whatsapp request failed: %w", err) } - req.Header.Set("Authorization", "Bearer "+c.accessToken) + if authorize { + req.Header.Set("Authorization", "Bearer "+c.accessToken) + } if payload != nil { req.Header.Set("Content-Type", "application/json") } @@ -157,3 +181,195 @@ func (c *Client) doRequest(ctx context.Context, method, path string, payload any } return nil } + +// get issues a GET against a Graph edge with the supplied query parameters. +func (c *Client) get(ctx context.Context, path string, query url.Values, result any) error { + if encoded := query.Encode(); encoded != "" { + path = path + "?" + encoded + } + return c.do(ctx, http.MethodGet, path, nil, result, true) +} + +// GetMediaMetadata resolves an inbound media id into the short-lived download +// URL Meta issues for it. +func (c *Client) GetMediaMetadata(ctx context.Context, mediaID string) (*MediaMetadata, error) { + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return nil, fmt.Errorf("media id is required") + } + + query := url.Values{} + query.Set("fields", "id,url,mime_type") + + var meta MediaMetadata + if err := c.get(ctx, "/"+url.PathEscape(mediaID), query, &meta); err != nil { + return nil, err + } + if strings.TrimSpace(meta.URL) == "" { + return nil, fmt.Errorf("whatsapp media %s returned no download url", mediaID) + } + return &meta, nil +} + +// DownloadMedia fetches the bytes behind a URL returned by GetMediaMetadata. +// +// maxBytes caps the buffered payload; when it is not positive the built-in +// ceiling is used. The URL is Meta-issued: it is read from a Graph API response +// we requested ourselves, so it is trusted to be an https endpoint, and anything +// else is rejected rather than followed. +func (c *Client) DownloadMedia(ctx context.Context, mediaURL string, maxBytes int64) ([]byte, string, error) { + if c.accessToken == "" { + return nil, "", fmt.Errorf("whatsapp access token is required") + } + parsed, err := url.Parse(strings.TrimSpace(mediaURL)) + if err != nil { + return nil, "", fmt.Errorf("invalid whatsapp media url: %w", err) + } + if parsed.Scheme != "https" || parsed.Host == "" { + return nil, "", fmt.Errorf("whatsapp media url must be https, got %q", parsed.Scheme) + } + if maxBytes <= 0 { + maxBytes = maxMediaReadBytes + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return nil, "", fmt.Errorf("create whatsapp media request failed: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.accessToken) + + res, err := c.httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("whatsapp media download failed: %w", err) + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, "", fmt.Errorf("whatsapp media download error (%d)", res.StatusCode) + } + if res.ContentLength > maxBytes { + return nil, "", fmt.Errorf("whatsapp media is %d bytes, over the %d byte limit", res.ContentLength, maxBytes) + } + + data, err := io.ReadAll(io.LimitReader(res.Body, maxBytes+1)) + if err != nil { + return nil, "", fmt.Errorf("read whatsapp media failed: %w", err) + } + if int64(len(data)) > maxBytes { + return nil, "", fmt.Errorf("whatsapp media exceeds the %d byte limit", maxBytes) + } + if len(data) == 0 { + return nil, "", fmt.Errorf("whatsapp media download returned an empty payload") + } + + contentType := strings.TrimSpace(res.Header.Get("Content-Type")) + if contentType == "" { + contentType = strings.TrimSpace(http.DetectContentType(data)) + } + return data, contentType, nil +} + +// ExchangeCodeForToken swaps an OAuth authorization code for an access token. +// +// redirectURI must be empty or byte-identical to the one used to build the +// authorization URL; Meta rejects the exchange otherwise. It is omitted when +// blank because the Embedded Signup code exchange does not require it. +func (c *Client) ExchangeCodeForToken(ctx context.Context, clientID, clientSecret, code, redirectURI string) (*OAuthToken, error) { + clientID = strings.TrimSpace(clientID) + clientSecret = strings.TrimSpace(clientSecret) + code = strings.TrimSpace(code) + if clientID == "" || clientSecret == "" { + return nil, fmt.Errorf("meta app id and app secret are required to exchange an oauth code") + } + if code == "" { + return nil, fmt.Errorf("oauth code is required") + } + + query := url.Values{} + query.Set("client_id", clientID) + query.Set("client_secret", clientSecret) + query.Set("code", code) + if redirectURI = strings.TrimSpace(redirectURI); redirectURI != "" { + query.Set("redirect_uri", redirectURI) + } + + var token OAuthToken + if err := c.do(ctx, http.MethodGet, "/oauth/access_token?"+query.Encode(), nil, &token, false); err != nil { + return nil, err + } + if token.Error != nil { + return nil, fmt.Errorf("whatsapp oauth error (%d): %s", token.Error.Code, token.Error.Message) + } + if strings.TrimSpace(token.AccessToken) == "" { + return nil, fmt.Errorf("whatsapp oauth exchange returned no access token") + } + return &token, nil +} + +// DebugToken reports what an access token is authorised for. The app-scoped +// token authenticates the call, so this uses the client's own token. +func (c *Client) DebugToken(ctx context.Context, inputToken string) (*DebugTokenData, error) { + inputToken = strings.TrimSpace(inputToken) + if inputToken == "" { + return nil, fmt.Errorf("input token is required") + } + + query := url.Values{} + query.Set("input_token", inputToken) + + var resp DebugTokenResponse + if err := c.get(ctx, "/debug_token", query, &resp); err != nil { + return nil, err + } + return &resp.Data, nil +} + +// ListBusinesses returns the Meta business portfolios reachable with this token. +func (c *Client) ListBusinesses(ctx context.Context) ([]Business, error) { + query := url.Values{} + query.Set("fields", "id,name") + + var list GraphList[Business] + if err := c.get(ctx, "/me/businesses", query, &list); err != nil { + return nil, err + } + return list.Data, nil +} + +// ListOwnedWhatsAppBusinessAccounts returns the WhatsApp Business Accounts a +// business portfolio owns. It needs an admin system user token and the +// whatsapp_business_management permission; without advanced access Meta answers +// with error code 200 and an empty list rather than a failure. +func (c *Client) ListOwnedWhatsAppBusinessAccounts(ctx context.Context, businessID string) ([]WhatsAppBusinessAccount, error) { + businessID = strings.TrimSpace(businessID) + if businessID == "" { + return nil, fmt.Errorf("business id is required") + } + + query := url.Values{} + query.Set("fields", "id,name,message_template_namespace,phone_number") + + var list GraphList[WhatsAppBusinessAccount] + if err := c.get(ctx, "/"+url.PathEscape(businessID)+"/owned_whatsapp_business_accounts", query, &list); err != nil { + return nil, err + } + return list.Data, nil +} + +// ListPhoneNumbers returns the sender numbers registered on a WhatsApp Business +// Account, which is where the phone_number_id needed to send messages comes from. +func (c *Client) ListPhoneNumbers(ctx context.Context, wabaID string) ([]WhatsAppPhoneNumber, error) { + wabaID = strings.TrimSpace(wabaID) + if wabaID == "" { + return nil, fmt.Errorf("whatsapp business account id is required") + } + + query := url.Values{} + query.Set("fields", "id,display_phone_number,verified_name,quality_rating,code_verification_status") + + var list GraphList[WhatsAppPhoneNumber] + if err := c.get(ctx, "/"+url.PathEscape(wabaID)+"/phone_numbers", query, &list); err != nil { + return nil, err + } + return list.Data, nil +} diff --git a/internal/whatsapp/types.go b/internal/whatsapp/types.go index 1b5829ed..d9cae81d 100644 --- a/internal/whatsapp/types.go +++ b/internal/whatsapp/types.go @@ -38,45 +38,259 @@ type SendMessageResponse struct { } `json:"messages"` } +// MediaMetadata is the result of resolving an inbound media id. The URL Meta +// hands back is a short-lived lookaside link that only answers a request +// carrying the same access token, so it has to be downloaded server-side +// immediately instead of being stored on the message. +type MediaMetadata struct { + ID string `json:"id"` + URL string `json:"url"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` +} + +// Inbound message types as they appear in the webhook `type` field. +const ( + MessageTypeText = "text" + MessageTypeImage = "image" + MessageTypeDocument = "document" + MessageTypeAudio = "audio" + MessageTypeVoice = "voice" + MessageTypeVideo = "video" + MessageTypeSticker = "sticker" + MessageTypeLocation = "location" + MessageTypeContacts = "contacts" + MessageTypeInteractive = "interactive" + MessageTypeReaction = "reaction" + MessageTypeButton = "button" + MessageTypeOrder = "order" + MessageTypeUnsupported = "unsupported" +) + +// TextRef carries the body of an inbound text message. +type TextRef struct { + Body string `json:"body"` +} + +// MediaRef identifies a stored media object for image, audio, voice and video. +type MediaRef struct { + ID string `json:"id"` + MimeType string `json:"mime_type,omitempty"` + Caption string `json:"caption,omitempty"` + Sha256 string `json:"sha256,omitempty"` +} + +// DocumentRef is MediaRef plus the sender-supplied file name. +type DocumentRef struct { + ID string `json:"id"` + MimeType string `json:"mime_type,omitempty"` + Filename string `json:"filename,omitempty"` + Caption string `json:"caption,omitempty"` + Sha256 string `json:"sha256,omitempty"` +} + +// StickerRef describes an inbound sticker. Animated stickers arrive as +// image/webp and cannot be rendered as a still image. +type StickerRef struct { + ID string `json:"id"` + MimeType string `json:"mime_type,omitempty"` + Sha256 string `json:"sha256,omitempty"` + Animated bool `json:"animated,omitempty"` +} + +// LocationPayload is a shared live or static location. +type LocationPayload struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Name string `json:"name,omitempty"` + Address string `json:"address,omitempty"` +} + +// InteractivePayload covers replies to template and list buttons we sent. +type InteractivePayload struct { + Type string `json:"type"` + ButtonReply *struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"button_reply,omitempty"` + ListReply *struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"list_reply,omitempty"` + NFMReply *struct { + Name string `json:"name"` + Body string `json:"body"` + } `json:"nfm_reply,omitempty"` +} + +// ReactionPayload is an emoji reaction to one of our own messages. +type ReactionPayload struct { + MessageID string `json:"message_id"` + Emoji string `json:"emoji"` +} + +// ButtonPayload is the legacy interactive button reply. +type ButtonPayload struct { + Payload string `json:"payload"` + Text string `json:"text"` +} + +// ContactRef is one shared contact card. Only the fields a support agent can +// act on are decoded; the rest of the card is preserved in the raw webhook. +type ContactRef struct { + Name struct { + FormattedName string `json:"formatted_name"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + } `json:"name"` + Phones []struct { + Phone string `json:"phone,omitempty"` + Type string `json:"type,omitempty"` + } `json:"phones,omitempty"` + Emails []struct { + Email string `json:"email,omitempty"` + Type string `json:"type,omitempty"` + } `json:"emails,omitempty"` +} + +// WebhookError accompanies a message Meta could not deliver or decode. +type WebhookError struct { + Code int `json:"code"` + Title string `json:"title"` + Message string `json:"message"` + Details string `json:"details,omitempty"` +} + +// InboundMessage is a single customer message inside a webhook change. +type InboundMessage struct { + From string `json:"from"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + Type string `json:"type"` + + Text *TextRef `json:"text,omitempty"` + Image *MediaRef `json:"image,omitempty"` + Document *DocumentRef `json:"document,omitempty"` + Audio *MediaRef `json:"audio,omitempty"` + Voice *MediaRef `json:"voice,omitempty"` + Video *MediaRef `json:"video,omitempty"` + Sticker *StickerRef `json:"sticker,omitempty"` + Location *LocationPayload `json:"location,omitempty"` + Contacts []ContactRef `json:"contacts,omitempty"` + Interactive *InteractivePayload `json:"interactive,omitempty"` + Reaction *ReactionPayload `json:"reaction,omitempty"` + Button *ButtonPayload `json:"button,omitempty"` + Errors []WebhookError `json:"errors,omitempty"` + Context *InboundMessageRef `json:"context,omitempty"` +} + +// InboundMessageRef links a reply back to the message it answers. +type InboundMessageRef struct { + ID string `json:"id"` + From string `json:"from,omitempty"` + ReferredProduct string `json:"referred_product,omitempty"` +} + +// InboundContact is the sender profile Meta attaches alongside messages. +type InboundContact struct { + Profile struct { + Name string `json:"name"` + } `json:"profile"` + WaID string `json:"wa_id"` +} + +// InboundMetadata identifies which of our phone numbers received the message. +type InboundMetadata struct { + DisplayPhoneNumber string `json:"display_phone_number"` + PhoneNumberID string `json:"phone_number_id"` +} + +// InboundChangeValue is the `value` object of a `messages` webhook change. +type InboundChangeValue struct { + MessagingProduct string `json:"messaging_product"` + Metadata InboundMetadata `json:"metadata"` + Contacts []InboundContact `json:"contacts"` + Messages []InboundMessage `json:"messages"` + Errors []WebhookError `json:"errors"` +} + +// InboundChange is one field/value pair in a webhook entry. +type InboundChange struct { + Field string `json:"field"` + Value InboundChangeValue `json:"value"` +} + +// InboundEntry is one WhatsApp Business Account in a webhook delivery. +type InboundEntry struct { + ID string `json:"id"` + Changes []InboundChange `json:"changes"` +} + // WebhookEvent represents incoming WhatsApp Webhook payload from Meta. type WebhookEvent struct { - Object string `json:"object"` - Entry []struct { - ID string `json:"id"` - Changes []struct { - Field string `json:"field"` - Value struct { - MessagingProduct string `json:"messaging_product"` - Metadata struct { - DisplayPhoneNumber string `json:"display_phone_number"` - PhoneNumberID string `json:"phone_number_id"` - } `json:"metadata"` - Contacts []struct { - Profile struct { - Name string `json:"name"` - } `json:"profile"` - WaID string `json:"wa_id"` - } `json:"contacts"` - Messages []struct { - From string `json:"from"` - ID string `json:"id"` - Timestamp string `json:"timestamp"` - Type string `json:"type"` - Text *struct { - Body string `json:"body"` - } `json:"text,omitempty"` - Image *struct { - ID string `json:"id"` - MimeType string `json:"mime_type"` - Caption string `json:"caption,omitempty"` - } `json:"image,omitempty"` - Document *struct { - ID string `json:"id"` - Filename string `json:"filename"` - Caption string `json:"caption,omitempty"` - } `json:"document,omitempty"` - } `json:"messages"` - } `json:"value"` - } `json:"changes"` - } `json:"entry"` + Object string `json:"object"` + Entry []InboundEntry `json:"entry"` +} + +// OAuthToken is the response of the code exchange. Embedded Signup returns a +// business token that expires roughly 60 days out; expires_in is in seconds and +// absent for tokens that do not expire. +type OAuthToken struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + Code int `json:"code"` + ErrorSubcode int `json:"error_subcode"` + } `json:"error,omitempty"` +} + +// DebugTokenData reports what a token is actually authorised for. +type DebugTokenData struct { + AppID string `json:"app_id"` + Type string `json:"type"` + Application string `json:"application"` + DataAccessExpiresAt int64 `json:"data_access_expires_at"` + ExpiresAt int64 `json:"expires_at"` + IsValid bool `json:"is_valid"` + Scopes []string `json:"scopes"` + UserID string `json:"user_id"` + Error *struct { + Message string `json:"message"` + Code int `json:"code"` + } `json:"error,omitempty"` +} + +type DebugTokenResponse struct { + Data DebugTokenData `json:"data"` +} + +// Business is a Meta business portfolio that owns WhatsApp Business Accounts. +type Business struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// WhatsAppBusinessAccount is a WABA under a business portfolio. +type WhatsAppBusinessAccount struct { + ID string `json:"id"` + Name string `json:"name"` + MessageTemplateNamespace string `json:"message_template_namespace"` + PhoneNumber string `json:"phone_number"` +} + +// WhatsAppPhoneNumber is a sender number registered on a WABA. +type WhatsAppPhoneNumber struct { + ID string `json:"id"` + DisplayPhoneNumber string `json:"display_phone_number"` + VerifiedName string `json:"verified_name"` + QualityRating string `json:"quality_rating"` + CodeVerificationStatus string `json:"code_verification_status"` +} + +// GraphList wraps the `data` envelope every Graph API list edge returns. +type GraphList[T any] struct { + Data []T `json:"data"` } diff --git a/scripts/publish_frill_backlog.ps1 b/scripts/publish_frill_backlog.ps1 index 94906d23..1238bde7 100644 --- a/scripts/publish_frill_backlog.ps1 +++ b/scripts/publish_frill_backlog.ps1 @@ -36,8 +36,8 @@ $backlog = @( }, @{ name = "WhatsApp Business API & Cloud Gateway" - description = "Connect WhatsApp Business Cloud API to Crove Desk. Support template messages, interactive buttons, and real-time chat sync for international customer support." - status_idx = "status_xz33599z" # Under consideration + description = "Connect WhatsApp Business Cloud API to Crove Desk. Incoming chats, media and interactive replies flow into the workbench and trigger AI answers; outbound delivery runs through a retrying queue. Template messages and delivery receipts are not shipped yet." + status_idx = "status_p47lj9oz" # Shipped topic_idxs = @("topic_63pxlq1v") }, @{ diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index 009a22b4..c8dda47f 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -1,10 +1,10 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import { zodResolver } from "@hookform/resolvers/zod" import { Controller, Resolver, useForm, useWatch } from "react-hook-form" import { z } from "zod/v4" -import { CopyIcon, ExternalLinkIcon, RotateCcwIcon } from "lucide-react" +import { CopyIcon, ExternalLinkIcon, Loader2Icon, RotateCcwIcon } from "lucide-react" import { toast } from "sonner" import { getWidgetDemoPath } from "@/components/support-chat/demo-navigation" @@ -23,15 +23,23 @@ import { type AIAgent, type AdminChannel, type CreateAdminChannelPayload, + type WhatsAppOAuthConnectResult, type WxWorkKFAccount, fetchAIAgentsAll, fetchChannel, + fetchWhatsAppOAuthURL, fetchWxWorkKFAccounts, rollbackChannelAIAgentRollout, resetChannelUserTokenSecret, } from "@/lib/api/admin" import { listMyOrganizations } from "@/lib/api/organization" import { useI18n } from "@/i18n/provider" +import { + WHATSAPP_OAUTH_CALLBACK_PATH, + WHATSAPP_OAUTH_STATE_PREFIX, + isWhatsAppOAuthMessage, + whatsAppWebhookPath, +} from "./whatsapp-oauth" type ChannelFormDialogProps = { open: boolean @@ -1133,6 +1141,8 @@ function ChannelFormBody({ const [channelDetail, setChannelDetail] = useState(null) const [rollingBackRollout, setRollingBackRollout] = useState(false) const [currentStatus, setCurrentStatus] = useState(0) + const [whatsAppConnecting, setWhatsAppConnecting] = useState(false) + const whatsAppPopup = useRef(null) const form = useForm< z.input, undefined, @@ -1337,6 +1347,102 @@ function ChannelFormBody({ await onSubmit(buildPayload(values, currentStatus, t)) } + // Meta redirects the popup back to our landing page, which exchanges the code + // and posts the credentials here. The origin check keeps another site from + // writing an access token into this form. + useEffect(() => { + function handleMessage(event: MessageEvent) { + if (event.origin !== window.location.origin) { + return + } + if (!isWhatsAppOAuthMessage(event.data)) { + return + } + const payload: WhatsAppOAuthConnectResult = event.data.payload + whatsAppPopup.current = null + setWhatsAppConnecting(false) + + if (payload.accessToken) { + setValue("whatsAppAccessToken", payload.accessToken, { shouldDirty: true }) + } + if (payload.wabaId) { + setValue("whatsAppWabaId", payload.wabaId, { shouldDirty: true }) + } + if (payload.phoneNumberId) { + setValue("whatsAppPhoneNumberId", payload.phoneNumberId, { shouldDirty: true }) + } + toast.success(t("channel.whatsappFilledFromOAuth")) + for (const warning of payload.warnings ?? []) { + toast.error(warning) + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [setValue, t]) + + // Release the button if the operator closes the Meta window without finishing. + useEffect(() => { + if (!whatsAppConnecting) { + return + } + const timer = window.setInterval(() => { + if (whatsAppPopup.current?.closed) { + whatsAppPopup.current = null + setWhatsAppConnecting(false) + } + }, 600) + return () => window.clearInterval(timer) + }, [whatsAppConnecting]) + + async function handleConnectWhatsApp() { + if (whatsAppConnecting) { + return + } + setWhatsAppConnecting(true) + try { + const redirectUri = window.location.origin + WHATSAPP_OAUTH_CALLBACK_PATH + const state = itemId + ? `${WHATSAPP_OAUTH_STATE_PREFIX}:${itemId}` + : WHATSAPP_OAUTH_STATE_PREFIX + const { authUrl } = await fetchWhatsAppOAuthURL(redirectUri, state) + // No noopener: the landing page needs window.opener to hand the + // credentials back to this form. + const popup = window.open( + authUrl, + "crove-whatsapp-oauth", + "width=760,height=820,menubar=no,toolbar=no,location=yes" + ) + if (!popup) { + setWhatsAppConnecting(false) + toast.error(t("channel.whatsappPopupBlocked")) + return + } + whatsAppPopup.current = popup + } catch (error) { + whatsAppPopup.current = null + setWhatsAppConnecting(false) + toast.error( + t("channel.whatsappConnectFailed", { + error: error instanceof Error ? error.message : String(error), + }) + ) + } + } + + async function copyWhatsAppWebhookUrl() { + const webhookPath = whatsAppWebhookPath(channelDetail?.channelId) + if (!webhookPath) { + return + } + try { + await navigator.clipboard.writeText(window.location.origin + webhookPath) + toast.success(t("channel.copySecretSuccess")) + } catch { + toast.error(t("channel.copyFailed")) + } + } + async function handleResetUserTokenSecret() { if (!itemId) { return @@ -1375,6 +1481,8 @@ function ChannelFormBody({ } } + const whatsAppWebhookUrl = whatsAppWebhookPath(channelDetail?.channelId) + return ( { - const redirectUri = window.location.origin + "/dashboard/channels" - window.open(`/api/dashboard/channel/whatsapp_oauth_url?redirect_uri=${encodeURIComponent(redirectUri)}`, "_blank") - }} + disabled={whatsAppConnecting} + onClick={() => void handleConnectWhatsApp()} > - - {t("channel.connectWhatsAppButton")} + {whatsAppConnecting ? ( + + ) : ( + + )} + {whatsAppConnecting + ? t("channel.whatsappConnecting") + : t("channel.connectWhatsAppButton")} -
- {t("channel.inboundWebhookUrl")}: /api/third/whatsapp/webhook +
+ {whatsAppWebhookUrl ? ( + <> +
+ + {t("channel.inboundWebhookUrl")}: {whatsAppWebhookUrl} + + +
+
{t("channel.whatsappWebhookHint")}
+ + ) : ( +
{t("channel.whatsappWebhookNeedsChannel")}
+ )}
diff --git a/web/app/(dashboard)/dashboard/channels/_components/whatsapp-oauth.ts b/web/app/(dashboard)/dashboard/channels/_components/whatsapp-oauth.ts new file mode 100644 index 00000000..9cd97f88 --- /dev/null +++ b/web/app/(dashboard)/dashboard/channels/_components/whatsapp-oauth.ts @@ -0,0 +1,34 @@ +import type { WhatsAppOAuthConnectResult } from "@/lib/api/admin" + +// Contract between the Meta OAuth landing page and the channel form. The landing +// page runs in a popup opened by the form, so the exchanged credentials travel +// back over postMessage with the origin pinned to this app. +export const WHATSAPP_OAUTH_MESSAGE = "crove:whatsapp-oauth" + +export const WHATSAPP_OAUTH_CALLBACK_PATH = "/dashboard/channels/whatsapp-callback" + +export const WHATSAPP_OAUTH_STATE_PREFIX = "crove_whatsapp_connect" + +export type WhatsAppOAuthMessage = { + type: typeof WHATSAPP_OAUTH_MESSAGE + payload: WhatsAppOAuthConnectResult +} + +export function isWhatsAppOAuthMessage(data: unknown): data is WhatsAppOAuthMessage { + if (typeof data !== "object" || data === null) { + return false + } + const candidate = data as Partial + return ( + candidate.type === WHATSAPP_OAUTH_MESSAGE && + typeof candidate.payload === "object" && + candidate.payload !== null + ) +} + +// The webhook routes require a bound channel id: an unbound URL would let anyone +// confirm a Meta webhook subscription they do not own. +export function whatsAppWebhookPath(channelId: string | undefined) { + const bound = channelId?.trim() + return bound ? `/api/third/whatsapp/webhook/${bound}` : "" +} diff --git a/web/app/(dashboard)/dashboard/channels/whatsapp-callback/page.tsx b/web/app/(dashboard)/dashboard/channels/whatsapp-callback/page.tsx new file mode 100644 index 00000000..be8e400f --- /dev/null +++ b/web/app/(dashboard)/dashboard/channels/whatsapp-callback/page.tsx @@ -0,0 +1,246 @@ +"use client" + +import { Suspense, useEffect, useRef, useState, useSyncExternalStore } from "react" +import Link from "next/link" +import { useSearchParams } from "next/navigation" +import { + AlertTriangleIcon, + CheckCircle2Icon, + Loader2Icon, + PhoneIcon, +} from "lucide-react" + +import { Button, buttonVariants } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + connectWhatsAppOAuth, + type WhatsAppOAuthConnectResult, +} from "@/lib/api/admin" +import { cn } from "@/lib/utils" +import { useI18n } from "@/i18n/provider" +import { WHATSAPP_OAUTH_MESSAGE } from "../_components/whatsapp-oauth" + +type Exchange = { + status: "exchanging" | "success" | "error" + result: WhatsAppOAuthConnectResult | null + failure: string +} + +const IDLE_EXCHANGE: Exchange = { status: "exchanging", result: null, failure: "" } + +function parseChannelId(state: string | null): number | undefined { + if (!state) { + return undefined + } + const separator = state.indexOf(":") + if (separator < 0) { + return undefined + } + const parsed = Number.parseInt(state.slice(separator + 1), 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined +} + +function subscribeNoop() { + return () => {} +} + +function getIsPopup() { + return window.opener !== null && window.opener !== window +} + +function getIsPopupOnServer() { + return false +} + +function WhatsAppOAuthCallback() { + const searchParams = useSearchParams() + const t = useI18n() + const [exchange, setExchange] = useState(IDLE_EXCHANGE) + const started = useRef(false) + // window.opener only exists in a browser, and this page is statically + // exported, so the server snapshot has to differ from the client one. + const openedAsPopup = useSyncExternalStore( + subscribeNoop, + getIsPopup, + getIsPopupOnServer + ) + + // A rejection or a missing code is knowable from the URL alone, so it is + // derived during render instead of being written into state from an effect. + const oauthError = searchParams.get("error") + const code = searchParams.get("code") + const precheckFailure = oauthError + ? oauthError === "access_denied" + ? t("channel.whatsappCallbackDenied") + : searchParams.get("error_description") || oauthError + : code + ? "" + : t("channel.whatsappCallbackMissingCode") + + useEffect(() => { + if (precheckFailure) { + return + } + // StrictMode mounts effects twice and Meta accepts an authorization code + // only once, so the exchange must run a single time per window. + if (started.current) { + return + } + started.current = true + + const oauthState = searchParams.get("state") + // This page's own URL without the query is exactly the redirect_uri that + // built the authorization link, and Meta requires the two to match. + const redirectUri = window.location.origin + window.location.pathname + + void (async () => { + try { + const result = await connectWhatsAppOAuth({ + code: code as string, + state: oauthState ?? undefined, + channelId: parseChannelId(oauthState), + redirectUri, + }) + setExchange({ status: "success", result, failure: "" }) + window.opener?.postMessage( + { type: WHATSAPP_OAUTH_MESSAGE, payload: result }, + window.location.origin + ) + } catch (error) { + setExchange({ + status: "error", + result: null, + failure: error instanceof Error ? error.message : String(error), + }) + } + })() + }, [code, precheckFailure, searchParams]) + + const status = precheckFailure ? "error" : exchange.status + const failure = precheckFailure || exchange.failure + const result = precheckFailure ? null : exchange.result + + const senderNumbers = + result?.accounts.flatMap((account) => + account.phoneNumbers.map((number) => ({ + key: `${account.wabaId}:${number.phoneNumberId}`, + wabaName: account.wabaName || account.wabaId, + displayPhoneNumber: number.displayPhoneNumber, + phoneNumberId: number.phoneNumberId, + })) + ) ?? [] + + return ( +
+ + + + {status === "success" ? ( + + ) : status === "error" ? ( + + ) : ( + + )} + {status === "success" + ? t("channel.whatsappCallbackSuccessTitle") + : status === "error" + ? t("channel.whatsappCallbackFailedTitle") + : t("channel.whatsappCallbackTitle")} + + + {status === "success" + ? result?.connected + ? t("channel.whatsappCallbackSuccessSaved") + : t("channel.whatsappCallbackSuccessForm") + : status === "error" + ? failure + : t("channel.whatsappCallbackExchanging")} + + + + + {status === "success" && senderNumbers.length > 0 ? ( +
+
+ {t("channel.whatsappCallbackAccountsTitle")} +
+
    + {senderNumbers.map((item) => ( +
  • + + + {item.displayPhoneNumber || item.phoneNumberId} + + {item.wabaName} + + {item.phoneNumberId} + +
  • + ))} +
+
+ ) : null} + + {status === "success" && senderNumbers.length === 0 ? ( +

+ {t("channel.whatsappCallbackNoAccounts")} +

+ ) : null} + + {result?.warnings && result.warnings.length > 0 ? ( +
+
+ {t("channel.whatsappCallbackWarningsTitle")} +
+
    + {result.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ ) : null} + +
+ {openedAsPopup ? ( + + ) : null} + + {t("channel.whatsappCallbackBack")} + +
+
+
+
+ ) +} + +export default function WhatsAppOAuthCallbackPage() { + return ( + + + + ) +} diff --git a/web/e2e/workbench-function.spec.ts b/web/e2e/workbench-function.spec.ts index fd006247..d286fe27 100644 --- a/web/e2e/workbench-function.spec.ts +++ b/web/e2e/workbench-function.spec.ts @@ -90,7 +90,7 @@ test.describe("support workbench", () => { await conversationsEntry.hover(); await expect(page.getByText(/会话|Conversations/).last()).toBeVisible(); await screenshot(page, "04-workbench-rail-tooltip"); - await page.getByRole("button", { name: /贝壳AGENT|Agent Desk/i }).first().hover(); + await page.getByRole("button", { name: /贝壳AGENT|Agent Desk|Crove Desk/i }).first().hover(); await expect(page.getByRole("menuitem", { name: /管理后台|Admin Dashboard/ })).toBeVisible(); await page.waitForTimeout(300); await screenshot(page, "05-workbench-switcher-open"); @@ -101,7 +101,7 @@ test.describe("support workbench", () => { await screenshot(page, "06-dashboard-after-switch"); await expect(page.getByText(/管理后台|Admin Dashboard/).first()).toBeVisible(); - await page.getByRole("button", { name: /贝壳AGENT|Agent Desk/i }).first().click(); + await page.getByRole("button", { name: /贝壳AGENT|Agent Desk|Crove Desk/i }).first().click(); await expect(page.getByRole("menuitem", { name: /客服工作台|Support Workbench/ })).toBeVisible(); await page.waitForTimeout(300); await screenshot(page, "07-dashboard-switcher-open"); diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 8f6da064..800618a5 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -235,6 +235,51 @@ export type ResetChannelUserTokenSecretResult = { userTokenSecret: string } +export type WhatsAppOAuthURLResult = { + authUrl: string + appId: string + redirectUri: string +} + +export type WhatsAppOAuthPhoneNumber = { + phoneNumberId: string + displayPhoneNumber: string + verifiedName: string + qualityRating: string + codeVerificationStatus: string +} + +export type WhatsAppOAuthAccount = { + wabaId: string + wabaName: string + businessId: string + businessName: string + phoneNumbers: WhatsAppOAuthPhoneNumber[] +} + +export type WhatsAppOAuthConnectResult = { + connected: boolean + channelId?: number + accessToken: string + tokenMasked: string + tokenType?: string + expiresAt?: string + scopes?: string[] + phoneNumberId?: string + wabaId?: string + accounts: WhatsAppOAuthAccount[] + warnings?: string[] +} + +export type ConnectWhatsAppOAuthPayload = { + code: string + state?: string + channelId?: number + redirectUri?: string + phoneNumberId?: string + wabaId?: string +} + export type AIAgent = { id: number name: string @@ -1000,6 +1045,25 @@ export function deleteChannel(id: number) { }) } +export function fetchWhatsAppOAuthURL(redirectUri: string, state?: string) { + return request( + `/api/dashboard/channel/whatsapp_oauth_url${toQueryString({ + redirect_uri: redirectUri, + state, + })}` + ) +} + +export function connectWhatsAppOAuth(payload: ConnectWhatsAppOAuthPayload) { + return request( + "/api/dashboard/channel/whatsapp_oauth_callback", + { + method: "POST", + body: JSON.stringify(payload), + } + ) +} + export function fetchAIAgents( query?: Record ) { diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 13669f73..c89738bd 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -1,6 +1,6 @@ { "app": { - "brand": "Agent Desk", + "brand": "Crove Desk", "metadataTitle": "AI Customer Support Admin", "metadataDescription": "AI Customer Support Admin" }, @@ -703,6 +703,25 @@ "whatsappPhoneId": "Phone Number ID", "whatsappWabaId": "WABA ID (Business Account ID)", "whatsappAccessToken": "System User Access Token", + "whatsappConnecting": "Opening Meta authorization…", + "whatsappConnectFailed": "Could not start WhatsApp authorization: {error}", + "whatsappPopupBlocked": "Your browser blocked the Meta sign-in window. Allow pop-ups for this site and try again.", + "whatsappWebhookHint": "Paste this URL into Meta App → WhatsApp → Configuration → Webhook. The channel id is required, so save the channel first.", + "whatsappWebhookNeedsChannel": "Save the channel first to get its webhook URL.", + "whatsappFilledFromOAuth": "WhatsApp credentials were filled in from Meta. Review them and save the channel.", + "whatsappCallbackTitle": "WhatsApp connection", + "whatsappCallbackExchanging": "Exchanging the authorization code with Meta…", + "whatsappCallbackSuccessTitle": "WhatsApp account authorized", + "whatsappCallbackSuccessSaved": "Credentials were saved to the channel. You can close this window.", + "whatsappCallbackSuccessForm": "Credentials were sent back to the channel form. You can close this window.", + "whatsappCallbackFailedTitle": "WhatsApp connection failed", + "whatsappCallbackDenied": "The Meta authorization was cancelled.", + "whatsappCallbackMissingCode": "Meta did not return an authorization code.", + "whatsappCallbackAccountsTitle": "Sender numbers found", + "whatsappCallbackNoAccounts": "No sender number could be discovered automatically. Enter the WABA ID and Phone Number ID on the channel form.", + "whatsappCallbackWarningsTitle": "Warnings", + "whatsappCallbackClose": "Close window", + "whatsappCallbackBack": "Back to Channels", "slackConnectTitle": "1-Click Slack App / Bot Connection", "slackConnectDescription": "Connect your company Slack workspace to Crove Desk. Channel mentions and direct messages will create tickets and trigger AI agent support.", "connectSlackButton": "Add to Slack", @@ -2879,7 +2898,7 @@ "loadFailed": "Failed to load outbox messages" }, "supportPublic": { - "brand": "AgentDesk Support", + "brand": "Crove Desk Support", "nav": { "home": "Home", "help": "Docs", diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json index a2174d11..5519de15 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -704,6 +704,25 @@ "whatsappPhoneId": "Phone Number ID", "whatsappWabaId": "WABA ID (Mã tài khoản doanh nghiệp)", "whatsappAccessToken": "System User Access Token", + "whatsappConnecting": "Đang mở trang ủy quyền Meta…", + "whatsappConnectFailed": "Không thể bắt đầu ủy quyền WhatsApp: {error}", + "whatsappPopupBlocked": "Trình duyệt đã chặn cửa sổ đăng nhập Meta. Vui lòng cho phép cửa sổ bật lên cho trang này rồi thử lại.", + "whatsappWebhookHint": "Dán đường dẫn này vào Meta App → WhatsApp → Configuration → Webhook. Bắt buộc phải có channel id, nên hãy lưu kênh trước.", + "whatsappWebhookNeedsChannel": "Hãy lưu kênh trước để lấy đường dẫn webhook.", + "whatsappFilledFromOAuth": "Đã lấy thông tin WhatsApp từ Meta và điền vào biểu mẫu. Hãy kiểm tra rồi lưu kênh.", + "whatsappCallbackTitle": "Kết nối WhatsApp", + "whatsappCallbackExchanging": "Đang trao mã ủy quyền với Meta…", + "whatsappCallbackSuccessTitle": "Đã ủy quyền tài khoản WhatsApp", + "whatsappCallbackSuccessSaved": "Thông tin xác thực đã được lưu vào kênh. Bạn có thể đóng cửa sổ này.", + "whatsappCallbackSuccessForm": "Thông tin xác thực đã được gửi về biểu mẫu kênh. Bạn có thể đóng cửa sổ này.", + "whatsappCallbackFailedTitle": "Kết nối WhatsApp thất bại", + "whatsappCallbackDenied": "Bạn đã hủy ủy quyền Meta.", + "whatsappCallbackMissingCode": "Meta không trả về mã ủy quyền.", + "whatsappCallbackAccountsTitle": "Các số gửi tin đã tìm thấy", + "whatsappCallbackNoAccounts": "Không tự động lấy được số gửi tin. Hãy nhập WABA ID và Phone Number ID trong biểu mẫu kênh.", + "whatsappCallbackWarningsTitle": "Cảnh báo", + "whatsappCallbackClose": "Đóng cửa sổ", + "whatsappCallbackBack": "Về danh sách Kênh", "slackConnectTitle": "Kết nối Slack Workspace / Bot 1-Click", "slackConnectDescription": "Kết nối không gian làm việc Slack của công ty với Crove Desk. Tin nhắn nhắc tên bot hoặc DM sẽ tự động tạo Ticket và nhận phản hồi từ AI Agent.", "connectSlackButton": "Thêm vào Slack (Add to Slack)", @@ -2880,7 +2899,7 @@ "loadFailed": "Không thể tải hàng đợi hộp thư đi" }, "supportPublic": { - "brand": "AgentDesk Support", + "brand": "Crove Desk Support", "nav": { "home": "Trang chủ", "help": "Tài liệu", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 7d3d4441..7d517565 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -1,6 +1,6 @@ { "app": { - "brand": "贝壳AGENT", + "brand": "Crove Desk", "metadataTitle": "AI 客服后台管理系统", "metadataDescription": "AI 客服后台管理系统" }, @@ -703,6 +703,25 @@ "whatsappPhoneId": "Phone Number ID", "whatsappWabaId": "WABA ID (商业账号 ID)", "whatsappAccessToken": "System User Access Token", + "whatsappConnecting": "正在打开 Meta 授权页面…", + "whatsappConnectFailed": "无法发起 WhatsApp 授权:{error}", + "whatsappPopupBlocked": "浏览器拦截了 Meta 登录窗口。请允许本站弹出窗口后重试。", + "whatsappWebhookHint": "将此地址填入 Meta App → WhatsApp → Configuration → Webhook。必须带渠道 ID,因此请先保存渠道。", + "whatsappWebhookNeedsChannel": "请先保存渠道,才能获取 Webhook 地址。", + "whatsappFilledFromOAuth": "已从 Meta 获取并填入 WhatsApp 凭据,请确认后保存渠道。", + "whatsappCallbackTitle": "WhatsApp 连接", + "whatsappCallbackExchanging": "正在与 Meta 交换授权码…", + "whatsappCallbackSuccessTitle": "WhatsApp 账号已授权", + "whatsappCallbackSuccessSaved": "凭据已保存到渠道,可以关闭此窗口。", + "whatsappCallbackSuccessForm": "凭据已返回渠道表单,可以关闭此窗口。", + "whatsappCallbackFailedTitle": "WhatsApp 连接失败", + "whatsappCallbackDenied": "已取消 Meta 授权。", + "whatsappCallbackMissingCode": "Meta 未返回授权码。", + "whatsappCallbackAccountsTitle": "已找到的发送号码", + "whatsappCallbackNoAccounts": "未能自动获取发送号码。请在渠道表单中手动填写 WABA ID 和 Phone Number ID。", + "whatsappCallbackWarningsTitle": "提示", + "whatsappCallbackClose": "关闭窗口", + "whatsappCallbackBack": "返回渠道列表", "slackConnectTitle": "Slack Workspace 一键授权连接", "slackConnectDescription": "将 Crove Desk 机器人应用添加至您的 Slack 工作区,频道提及与私聊消息将自动同步至工作台。", "connectSlackButton": "添加到 Slack (Add to Slack)", @@ -2879,7 +2898,7 @@ "loadFailed": "加载企业微信 outbox 失败" }, "supportPublic": { - "brand": "AgentDesk 支持中心", + "brand": "Crove Desk 支持中心", "nav": { "home": "首页", "help": "文档",