Skip to content

Commit 230b247

Browse files
committed
fix: add whatsapp pipe
1 parent 1ebe1ff commit 230b247

11 files changed

Lines changed: 298 additions & 6 deletions

File tree

chat/provider.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type IncomingMessage struct {
3030
Username string
3131
}
3232

33+
// ChatProvider is the core interface every chat platform must implement.
3334
type ChatProvider interface {
3435
SendMessage(chatId string, text string) error
3536
ParseWebhookRequest(body []byte) (*IncomingMessage, error)
@@ -42,22 +43,35 @@ type WebhookResponse struct {
4243
Body []byte
4344
}
4445

46+
// ImmediateWebhookResponder is implemented by providers that must reply to the
47+
// webhook HTTP request before processing the message (e.g. Discord interactions).
4548
type ImmediateWebhookResponder interface {
4649
GetWebhookResponse(body []byte, header http.Header) (*WebhookResponse, error)
4750
}
4851

52+
// WebhookVerifier is implemented by providers that verify webhook ownership via
53+
// a GET request challenge (e.g. WhatsApp Cloud API hub.challenge handshake).
54+
type WebhookVerifier interface {
55+
VerifyWebhook(params map[string]string) (*WebhookResponse, error)
56+
}
57+
4958
func NormalizeChatProviderType(typ string) string {
5059
return strings.ToLower(strings.ReplaceAll(typ, " ", "-"))
5160
}
5261

53-
func GetChatProvider(typ string, token string, providerKey string, lang string) (ChatProvider, error) {
62+
// GetChatProvider returns a ChatProvider for the given type.
63+
// pipeName is passed to providers that use it as part of their configuration
64+
// (e.g. WhatsApp uses it as the webhook verify token).
65+
func GetChatProvider(typ string, token string, providerKey string, pipeName string, lang string) (ChatProvider, error) {
5466
var p ChatProvider
5567
var err error
5668

5769
if typ == "Telegram" {
5870
p, err = NewTelegramChatProvider(token, proxy.ProxyHttpClient)
5971
} else if typ == "Discord" {
6072
p, err = NewDiscordChatProvider(token, providerKey, proxy.ProxyHttpClient)
73+
} else if typ == "WhatsApp" {
74+
p, err = NewWhatsAppChatProvider(token, providerKey, pipeName, proxy.ProxyHttpClient)
6175
} else {
6276
return nil, fmt.Errorf(i18n.Translate(lang, "object:the chat provider type: %s is not supported"), typ)
6377
}

chat/whatsapp.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Copyright 2026 The OpenAgent Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package chat
16+
17+
import (
18+
"encoding/json"
19+
"fmt"
20+
"net/http"
21+
"strings"
22+
)
23+
24+
const whatsAppApiBaseUrl = "https://graph.facebook.com/v19.0"
25+
26+
type WhatsAppChatProvider struct {
27+
accessToken string
28+
phoneNumberId string
29+
verifyToken string
30+
httpClient *http.Client
31+
}
32+
33+
type whatsAppWebhookPayload struct {
34+
Object string `json:"object"`
35+
Entry []whatsAppEntry `json:"entry"`
36+
}
37+
38+
type whatsAppEntry struct {
39+
Id string `json:"id"`
40+
Changes []whatsAppChange `json:"changes"`
41+
}
42+
43+
type whatsAppChange struct {
44+
Value whatsAppValue `json:"value"`
45+
Field string `json:"field"`
46+
}
47+
48+
type whatsAppValue struct {
49+
MessagingProduct string `json:"messaging_product"`
50+
Metadata whatsAppMetadata `json:"metadata"`
51+
Contacts []whatsAppContact `json:"contacts"`
52+
Messages []whatsAppMessage `json:"messages"`
53+
}
54+
55+
type whatsAppMetadata struct {
56+
DisplayPhoneNumber string `json:"display_phone_number"`
57+
PhoneNumberId string `json:"phone_number_id"`
58+
}
59+
60+
type whatsAppContact struct {
61+
Profile whatsAppProfile `json:"profile"`
62+
WaId string `json:"wa_id"`
63+
}
64+
65+
type whatsAppProfile struct {
66+
Name string `json:"name"`
67+
}
68+
69+
type whatsAppMessage struct {
70+
From string `json:"from"`
71+
Id string `json:"id"`
72+
Timestamp string `json:"timestamp"`
73+
Type string `json:"type"`
74+
Text *whatsAppText `json:"text,omitempty"`
75+
}
76+
77+
type whatsAppText struct {
78+
Body string `json:"body"`
79+
}
80+
81+
type whatsAppSendPayload struct {
82+
MessagingProduct string `json:"messaging_product"`
83+
To string `json:"to"`
84+
Type string `json:"type"`
85+
Text whatsAppTextPayload `json:"text"`
86+
}
87+
88+
type whatsAppTextPayload struct {
89+
Body string `json:"body"`
90+
}
91+
92+
func NewWhatsAppChatProvider(accessToken string, phoneNumberId string, verifyToken string, httpClient *http.Client) (*WhatsAppChatProvider, error) {
93+
return &WhatsAppChatProvider{
94+
accessToken: accessToken,
95+
phoneNumberId: strings.TrimSpace(phoneNumberId),
96+
verifyToken: verifyToken,
97+
httpClient: httpClient,
98+
}, nil
99+
}
100+
101+
func (p *WhatsAppChatProvider) authorizationHeaders() map[string]string {
102+
return map[string]string{
103+
"Authorization": fmt.Sprintf("Bearer %s", p.accessToken),
104+
}
105+
}
106+
107+
func (p *WhatsAppChatProvider) SendMessage(chatId string, text string) error {
108+
payload := whatsAppSendPayload{
109+
MessagingProduct: "whatsapp",
110+
To: chatId,
111+
Type: "text",
112+
Text: whatsAppTextPayload{Body: text},
113+
}
114+
_, err := doJSONRequest(
115+
p.httpClient,
116+
"WhatsApp",
117+
http.MethodPost,
118+
fmt.Sprintf("%s/%s/messages", whatsAppApiBaseUrl, p.phoneNumberId),
119+
p.authorizationHeaders(),
120+
payload,
121+
http.StatusOK,
122+
http.StatusCreated,
123+
)
124+
return err
125+
}
126+
127+
func (p *WhatsAppChatProvider) ParseWebhookRequest(body []byte) (*IncomingMessage, error) {
128+
var payload whatsAppWebhookPayload
129+
if err := json.Unmarshal(body, &payload); err != nil {
130+
return nil, err
131+
}
132+
133+
for _, entry := range payload.Entry {
134+
for _, change := range entry.Changes {
135+
if change.Field != "messages" {
136+
continue
137+
}
138+
contacts := change.Value.Contacts
139+
for i, message := range change.Value.Messages {
140+
if message.Type != "text" || message.Text == nil || message.Text.Body == "" {
141+
continue
142+
}
143+
144+
userId := message.From
145+
username := message.From
146+
if i < len(contacts) {
147+
username = contacts[i].Profile.Name
148+
}
149+
150+
return &IncomingMessage{
151+
ChatId: message.From,
152+
UserId: userId,
153+
Text: message.Text.Body,
154+
Username: username,
155+
}, nil
156+
}
157+
}
158+
}
159+
160+
return nil, nil
161+
}
162+
163+
// VerifyWebhook handles the Meta webhook verification challenge (GET request).
164+
// The verify token is the pipe name, which must match what is configured in
165+
// the Meta Developer Console as the webhook verify token.
166+
func (p *WhatsAppChatProvider) VerifyWebhook(params map[string]string) (*WebhookResponse, error) {
167+
mode := params["hub.mode"]
168+
verifyToken := params["hub.verify_token"]
169+
challenge := params["hub.challenge"]
170+
171+
if mode != "subscribe" || verifyToken != p.verifyToken {
172+
return &WebhookResponse{StatusCode: http.StatusForbidden}, nil
173+
}
174+
175+
return &WebhookResponse{
176+
StatusCode: http.StatusOK,
177+
ContentType: "text/plain",
178+
Body: []byte(challenge),
179+
}, nil
180+
}
181+
182+
// SetWebhook returns nil because WhatsApp webhooks are configured manually in
183+
// the Meta Developer Console. The caller displays the webhook URL to the user.
184+
func (p *WhatsAppChatProvider) SetWebhook(webhookUrl string) error {
185+
return nil
186+
}

controllers/chat_webhook.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,53 @@ import (
2323
"github.com/the-open-agent/openagent/object"
2424
)
2525

26+
// ChatWebhookVerify handles the HTTP GET challenge that some platforms (e.g. WhatsApp
27+
// Cloud API) send to verify webhook ownership before they start delivering events.
28+
// The URL format is: /api/chat-webhook/:pipeType/:pipeName
29+
// This endpoint does not require authentication.
30+
// @router /api/chat-webhook/:pipeType/:pipeName [get]
31+
func (c *ApiController) ChatWebhookVerify() {
32+
pipeType := c.Ctx.Input.Param(":pipeType")
33+
pipeName := c.Ctx.Input.Param(":pipeName")
34+
35+
pipe, err := object.GetPipeByName("admin", pipeName)
36+
if err != nil {
37+
c.Ctx.ResponseWriter.WriteHeader(http.StatusInternalServerError)
38+
return
39+
}
40+
if pipe == nil || chat.NormalizeChatProviderType(pipe.Type) != pipeType {
41+
c.Ctx.ResponseWriter.WriteHeader(http.StatusNotFound)
42+
return
43+
}
44+
45+
chatProviderObj, err := pipe.GetChatProvider(c.GetAcceptLanguage())
46+
if err != nil {
47+
c.Ctx.ResponseWriter.WriteHeader(http.StatusInternalServerError)
48+
return
49+
}
50+
51+
verifier, ok := chatProviderObj.(chat.WebhookVerifier)
52+
if !ok {
53+
c.Ctx.ResponseWriter.WriteHeader(http.StatusMethodNotAllowed)
54+
return
55+
}
56+
57+
params := map[string]string{}
58+
for key, values := range c.Ctx.Request.URL.Query() {
59+
if len(values) > 0 {
60+
params[key] = values[0]
61+
}
62+
}
63+
64+
response, err := verifier.VerifyWebhook(params)
65+
if err != nil {
66+
c.Ctx.ResponseWriter.WriteHeader(http.StatusBadRequest)
67+
return
68+
}
69+
70+
writeChatWebhookResponse(c, response)
71+
}
72+
2673
// ChatWebhook receives incoming updates from a chat pipe.
2774
// The URL format is: /api/chat-webhook/:pipeType/:pipeName
2875
// This endpoint does not require authentication because it is called by chat platform servers.

object/pipe.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ func (p *Pipe) GetId() string {
155155
}
156156

157157
func (p *Pipe) GetChatProvider(lang string) (chat.ChatProvider, error) {
158-
pProvider, err := chat.GetChatProvider(p.Type, p.Token, p.ProviderKey, lang)
158+
pProvider, err := chat.GetChatProvider(p.Type, p.Token, p.ProviderKey, p.Name, lang)
159159
if err != nil {
160160
return nil, err
161161
}

object/provider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ func (p *Provider) GetSpeechToTextProvider(lang string) (stt.SpeechToTextProvide
376376
}
377377

378378
func (p *Provider) GetChatProvider(lang string) (chat.ChatProvider, error) {
379-
pProvider, err := chat.GetChatProvider(p.Type, p.ClientSecret, p.ProviderKey, lang)
379+
pProvider, err := chat.GetChatProvider(p.Type, p.ClientSecret, p.ProviderKey, p.Name, lang)
380380
if err != nil {
381381
return nil, err
382382
}

routers/router.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func initAPI() {
9191
beego.Router("/api/delete-pipe", &controllers.ApiController{}, "POST:DeletePipe")
9292
beego.Router("/api/set-pipe-webhook", &controllers.ApiController{}, "POST:SetPipeWebhook")
9393
beego.Router("/api/chat-test", &controllers.ApiController{}, "POST:ChatTest")
94-
beego.Router("/api/chat-webhook/:pipeType/:pipeName", &controllers.ApiController{}, "POST:ChatWebhook")
94+
beego.Router("/api/chat-webhook/:pipeType/:pipeName", &controllers.ApiController{}, "GET:ChatWebhookVerify;POST:ChatWebhook")
9595

9696
beego.Router("/api/get-servers", &controllers.ApiController{}, "GET:GetServers")
9797
beego.Router("/api/get-server", &controllers.ApiController{}, "GET:GetServer")

web/src/PipeEditPage.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ class PipeEditPage extends React.Component {
213213
{[
214214
{id: "Telegram", name: "Telegram"},
215215
{id: "Discord", name: "Discord"},
216+
{id: "WhatsApp", name: "WhatsApp"},
216217
].map((item, index) => (
217218
<Option key={index} value={item.id}>
218219
<img width={20} height={20} style={{marginBottom: "3px", marginRight: "10px"}}
@@ -234,10 +235,13 @@ class PipeEditPage extends React.Component {
234235
</Col>
235236
</Row>
236237

237-
{pipe.type === "Discord" && (
238+
{(pipe.type === "Discord" || pipe.type === "WhatsApp") && (
238239
<Row style={{marginTop: "20px"}}>
239240
<Col style={{marginTop: "5px"}} span={Setting.isMobile() ? 22 : 2}>
240-
{Setting.getLabel(i18next.t("provider:Public key"), i18next.t("provider:Public key - Tooltip"))}
241+
{pipe.type === "WhatsApp"
242+
? Setting.getLabel(i18next.t("pipe:Phone Number ID"), i18next.t("pipe:Phone Number ID - Tooltip"))
243+
: Setting.getLabel(i18next.t("provider:Public key"), i18next.t("provider:Public key - Tooltip"))
244+
}
241245
</Col>
242246
<Col span={22}>
243247
<Input.Password
@@ -249,6 +253,16 @@ class PipeEditPage extends React.Component {
249253
</Row>
250254
)}
251255

256+
{pipe.type === "WhatsApp" && (
257+
<Row style={{marginTop: "20px"}}>
258+
<Col span={22} offset={Setting.isMobile() ? 0 : 2}>
259+
<span style={{color: "var(--ant-color-text-secondary)", fontSize: "13px"}}>
260+
{i18next.t("pipe:WhatsApp verify token hint")}&nbsp;<strong>{pipe.name}</strong>
261+
</span>
262+
</Col>
263+
</Row>
264+
)}
265+
252266
<Row style={{marginTop: "20px"}}>
253267
<Col style={{marginTop: "5px"}} span={Setting.isMobile() ? 22 : 2}>
254268
{Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"))}

web/src/PipeListPage.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ class PipeListPage extends BaseListPage {
118118
filters: [
119119
{text: "Telegram", value: "Telegram"},
120120
{text: "Discord", value: "Discord"},
121+
{text: "WhatsApp", value: "WhatsApp"},
121122
],
122123
sorter: (a, b) => a.type.localeCompare(b.type),
123124
render: (text, record) => (

web/src/Setting.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,6 +1155,10 @@ export function getOtherProviderInfo() {
11551155
logo: `${StaticBaseUrl}/img/social_discord.png`,
11561156
url: "https://discord.com/",
11571157
},
1158+
"WhatsApp": {
1159+
logo: `${StaticBaseUrl}/img/social_whatsapp.png`,
1160+
url: "https://www.whatsapp.com/",
1161+
},
11581162
},
11591163
};
11601164

web/src/locales/en/data.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,19 @@
459459
"Reply to - Tooltip": "The ID of the message to which this message is replied",
460460
"Suggestions": "Suggestions"
461461
},
462+
"pipe": {
463+
"Chat ID": "Chat ID",
464+
"Chat ID placeholder": "Enter the chat ID or phone number",
465+
"Chat Test": "Chat Test",
466+
"Edit Pipe": "Edit Pipe",
467+
"Phone Number ID": "Phone Number ID",
468+
"Phone Number ID - Tooltip": "The WhatsApp phone number ID from the Meta Developer Console.",
469+
"Pipe Test": "Pipe Test",
470+
"Send": "Send",
471+
"Test message": "Test message",
472+
"Test message placeholder": "Enter a test message",
473+
"WhatsApp verify token hint": "Set the webhook verify token in Meta Developer Console to your pipe name:"
474+
},
462475
"provider": {
463476
"AES key": "AES key",
464477
"AES key - Tooltip": "AES encryption key for secure communications",

0 commit comments

Comments
 (0)