From b730897368c6304612fc82eaae6c6a3da14051d0 Mon Sep 17 00:00:00 2001 From: rmacomber Date: Sat, 20 Jun 2026 18:54:41 -0400 Subject: [PATCH] feat(daemon): add remote messaging control Add opt-in Telegram and WhatsApp remote control for vixd. Incoming allowlisted messages run isolated headless sessions and reply through the chat provider, with WhatsApp webhook signature verification and Telegram token redaction on provider errors. Co-authored-by: vix <290354907+vix-agent@users.noreply.github.com> --- README.md | 34 +++ cmd/vixd/main.go | 12 + internal/config/config.go | 10 + internal/config/defaults/settings.json | 22 +- internal/daemon/remote_control.go | 251 +++++++++++++++++++++ internal/daemon/remote_control_telegram.go | 129 +++++++++++ internal/daemon/remote_control_test.go | 177 +++++++++++++++ internal/daemon/remote_control_whatsapp.go | 179 +++++++++++++++ 8 files changed, 813 insertions(+), 1 deletion(-) create mode 100644 internal/daemon/remote_control.go create mode 100644 internal/daemon/remote_control_telegram.go create mode 100644 internal/daemon/remote_control_test.go create mode 100644 internal/daemon/remote_control_whatsapp.go diff --git a/README.md b/README.md index 275c918..c90be02 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,40 @@ and then you can start as many instances you want, each of them are isolated: vix ``` +### Remote control + +`vixd` can optionally accept prompts from Telegram or WhatsApp and reply with the +agent's answer. Remote control is disabled by default and requires both a feature +flag and an allowlist in `~/.vix/settings.json`: + +```json +{ + "features": { "remote_control": true }, + "remote_control": { + "enabled": true, + "cwd": "/absolute/project/path", + "telegram": { + "enabled": true, + "bot_token": "", + "allowed_chat_ids": ["123456789"] + }, + "whatsapp": { + "enabled": true, + "access_token": "", + "app_secret": "", + "phone_number_id": "", + "verify_token": "", + "webhook_addr": "127.0.0.1:1340", + "allowed_contacts": ["15551234567"] + } + } +} +``` + +Telegram uses bot long-polling. WhatsApp exposes `GET/POST /whatsapp` on +`webhook_addr` for Cloud API webhook verification and messages. Only allowlisted +chat IDs/contacts can control vix. +
## Why is vix faster and cheaper in plan mode? diff --git a/cmd/vixd/main.go b/cmd/vixd/main.go index e2ba4d0..1d81d9e 100644 --- a/cmd/vixd/main.go +++ b/cmd/vixd/main.go @@ -187,6 +187,18 @@ func main() { server.RegisterHandler(cmd, handler) }, cred, ctx) daemon.RegisterToolHandlers(server) + if config.RemoteControlEnabled() { + remoteCfg, err := daemon.LoadRemoteControlConfig() + if err != nil { + log.Printf("remote control: config load failed: %v", err) + } else if remoteCfg.Enabled { + if err := server.StartRemoteControl(ctx, remoteCfg); err != nil { + log.Printf("remote control: disabled: %v", err) + } + } + } else { + log.Printf("remote control: disabled (features.remote_control=false or VIX_DISABLE_REMOTE_CONTROL)") + } if *webPort > 0 && !*noMissionControl { go daemon.StartWebServer(ctx, server, *webPort) } diff --git a/internal/config/config.go b/internal/config/config.go index 25b69f7..6bc7a7b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -237,6 +237,16 @@ func HooksEnabled() bool { return feature("hooks", true) } +// RemoteControlEnabled reads the remote_control feature flag. Defaults to false +// because chat-service control requires explicit credentials and sender allowlists. +// VIX_DISABLE_REMOTE_CONTROL is an emergency kill switch. +func RemoteControlEnabled() bool { + if v := os.Getenv("VIX_DISABLE_REMOTE_CONTROL"); v == "1" || v == "true" { + return false + } + return feature("remote_control", false) +} + // JobsMaxConcurrentRuns reads jobs.max_concurrent_runs from // ~/.vix/settings.json. Returns 0 when absent/invalid, letting the scheduler // apply its default. diff --git a/internal/config/defaults/settings.json b/internal/config/defaults/settings.json index 6cbbbdd..b017238 100644 --- a/internal/config/defaults/settings.json +++ b/internal/config/defaults/settings.json @@ -5,7 +5,27 @@ "read_claude_md": true, "show_thinking": false, "telemetry": true, - "tool_orchestrator": false + "tool_orchestrator": false, + "remote_control": false + }, + "remote_control": { + "enabled": false, + "cwd": "", + "workflow": "", + "telegram": { + "enabled": false, + "bot_token": "", + "allowed_chat_ids": [] + }, + "whatsapp": { + "enabled": false, + "access_token": "", + "app_secret": "", + "phone_number_id": "", + "verify_token": "", + "webhook_addr": "127.0.0.1:1340", + "allowed_contacts": [] + } }, "elevenlabs": { "agent_id": "agent_7501kqrztj1te17ssqz5wqpnvkf3", diff --git a/internal/daemon/remote_control.go b/internal/daemon/remote_control.go new file mode 100644 index 0000000..04e57a1 --- /dev/null +++ b/internal/daemon/remote_control.go @@ -0,0 +1,251 @@ +package daemon + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/get-vix/vix/internal/config" + "github.com/get-vix/vix/internal/protocol" +) + +// RemoteControlConfig configures opt-in remote control from chat services. +type RemoteControlConfig struct { + Enabled bool `json:"enabled"` + CWD string `json:"cwd"` + Workflow string `json:"workflow,omitempty"` + Telegram TelegramRemoteConfig `json:"telegram,omitempty"` + WhatsApp WhatsAppRemoteConfig `json:"whatsapp,omitempty"` +} + +type TelegramRemoteConfig struct { + Enabled bool `json:"enabled"` + BotToken string `json:"bot_token"` + AllowedChatIDs []string `json:"allowed_chat_ids"` + PollIntervalMs int `json:"poll_interval_ms,omitempty"` +} + +type WhatsAppRemoteConfig struct { + Enabled bool `json:"enabled"` + AccessToken string `json:"access_token"` + AppSecret string `json:"app_secret"` + PhoneNumberID string `json:"phone_number_id"` + VerifyToken string `json:"verify_token"` + WebhookAddr string `json:"webhook_addr,omitempty"` + AllowedContacts []string `json:"allowed_contacts"` +} + +type remoteReplyFunc func(ctx context.Context, text string) error + +type remoteMessage struct { + Provider string + SenderID string + Text string + Reply remoteReplyFunc +} + +type remoteHTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +type remoteControl struct { + server *Server + cfg RemoteControlConfig + http remoteHTTPClient +} + +func LoadRemoteControlConfig() (RemoteControlConfig, error) { + p := filepath.Join(config.HomeVixDir(), "settings.json") + data, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return RemoteControlConfig{}, nil + } + return RemoteControlConfig{}, err + } + var cfg struct { + RemoteControl RemoteControlConfig `json:"remote_control"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + return RemoteControlConfig{}, err + } + return cfg.RemoteControl, nil +} + +func (s *Server) StartRemoteControl(ctx context.Context, cfg RemoteControlConfig) error { + if !cfg.Enabled { + return nil + } + if strings.TrimSpace(cfg.CWD) == "" { + return fmt.Errorf("remote control: missing cwd") + } + rc := &remoteControl{server: s, cfg: cfg, http: http.DefaultClient} + started := false + if cfg.Telegram.Enabled { + if err := rc.startTelegram(ctx); err != nil { + return err + } + started = true + } + if cfg.WhatsApp.Enabled { + if err := rc.startWhatsApp(ctx); err != nil { + return err + } + started = true + } + if !started { + return fmt.Errorf("remote control: enabled but no provider is enabled") + } + return nil +} + +func (rc *remoteControl) handleMessage(ctx context.Context, msg remoteMessage) { + text := strings.TrimSpace(msg.Text) + if text == "" { + return + } + LogInfo("remote control: received %s message from %s", msg.Provider, msg.SenderID) + result, err := rc.server.runRemotePrompt(ctx, rc.cfg.CWD, rc.cfg.Workflow, text) + if err != nil { + result = "vix remote control error: " + err.Error() + LogError("remote control: %s", err) + } + if err := msg.Reply(ctx, result); err != nil { + LogError("remote control: reply to %s %s failed: %v", msg.Provider, msg.SenderID, err) + } +} + +func remoteSessionAutomaticPermissions() (autoWrite, autoDirs bool) { + return false, false +} + +func (s *Server) runRemotePrompt(ctx context.Context, cwd, workflow, prompt string) (string, error) { + runID := generateSessionID() + autoWrite, autoDirs := remoteSessionAutomaticPermissions() + session := NewSession(runID, s, nil, s.model, cwd, "", false, autoWrite, autoDirs, true, ctx) + session.origin = "vix" + session.trigger = &protocol.TriggerInfo{Type: "remote_control", Ref: "remote"} + session.title = "Remote control - " + time.Now().Format(jobTitleTimeFormat) + + s.sessionMu.Lock() + s.sessions[runID] = session + s.sessionMu.Unlock() + s.broadcastSessionsChanged() + defer func() { + s.sessionMu.Lock() + delete(s.sessions, runID) + s.sessionMu.Unlock() + session.cancel() + s.broadcastSessionsChanged() + }() + + go session.Run() + + var startCmd protocol.SessionCommand + if workflow != "" { + data, _ := json.Marshal(protocol.SessionWorkflowData{Name: workflow, Text: prompt}) + startCmd = protocol.SessionCommand{Type: "session.workflow", Data: data} + } else { + data, _ := json.Marshal(protocol.SessionInputData{Text: prompt}) + startCmd = protocol.SessionCommand{Type: "session.input", Data: data} + } + if !session.pushCommand(ctx, startCmd) { + return "", fmt.Errorf("session refused start command") + } + + var final strings.Builder + var hadError bool + var errMsg string + for { + select { + case ev := <-session.eventChan: + switch ev.Type { + case "event.stream_chunk": + final.WriteString(decodeRemoteEvent[protocol.EventStreamChunk](ev.Data).Text) + case "event.confirm_request", "event.user_question", "event.plan_proposed": + cmd, err := remoteCommandForUnattendedEvent(ev) + if err != nil { + session.persist() + return "", err + } + session.pushCommand(ctx, cmd) + case "event.error": + hadError = true + errMsg = decodeRemoteEvent[protocol.EventError](ev.Data).Message + case "event.agent_done": + if hadError && strings.TrimSpace(final.String()) == "" { + return "", errors.New(errMsg) + } + session.persist() + return final.String(), nil + } + case <-ctx.Done(): + session.persist() + return "", ctx.Err() + case <-session.ctx.Done(): + if hadError && strings.TrimSpace(final.String()) == "" { + return "", errors.New(errMsg) + } + return final.String(), nil + } + } +} + +func remoteCommandForUnattendedEvent(ev protocol.SessionEvent) (protocol.SessionCommand, error) { + switch ev.Type { + case "event.confirm_request": + data, _ := json.Marshal(protocol.SessionConfirmData{Approved: false}) + return protocol.SessionCommand{Type: "session.confirm", Data: data}, nil + case "event.user_question": + return protocol.SessionCommand{}, fmt.Errorf("remote control requires an interactive answer") + case "event.plan_proposed": + return protocol.SessionCommand{}, fmt.Errorf("remote control requires interactive approval") + default: + return protocol.SessionCommand{}, fmt.Errorf("unsupported unattended event: %s", ev.Type) + } +} + +func decodeRemoteEvent[T any](data any) T { + var out T + raw, _ := json.Marshal(data) + _ = json.Unmarshal(raw, &out) + return out +} + +func authorizedRemoteID(id string, allowed []string) bool { + if len(allowed) == 0 { + return false + } + for _, v := range allowed { + if strings.TrimSpace(v) == id { + return true + } + } + return false +} + +func postForm(ctx context.Context, hc remoteHTTPClient, endpoint string, form url.Values) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("remote provider returned %s: %s", resp.Status, strings.TrimSpace(string(b))) + } + return nil +} diff --git a/internal/daemon/remote_control_telegram.go b/internal/daemon/remote_control_telegram.go new file mode 100644 index 0000000..b8348d6 --- /dev/null +++ b/internal/daemon/remote_control_telegram.go @@ -0,0 +1,129 @@ +package daemon + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const telegramAPIBase = "https://api.telegram.org" + +type telegramUpdatesResponse struct { + OK bool `json:"ok"` + Result []telegramUpdate `json:"result"` +} + +type telegramUpdate struct { + UpdateID int64 `json:"update_id"` + Message telegramMessage `json:"message"` +} + +type telegramMessage struct { + Text string `json:"text"` + Chat telegramChat `json:"chat"` +} + +type telegramChat struct { + ID int64 `json:"id"` +} + +func (rc *remoteControl) startTelegram(ctx context.Context) error { + cfg := rc.cfg.Telegram + if strings.TrimSpace(cfg.BotToken) == "" { + return fmt.Errorf("remote control telegram: missing bot_token") + } + if len(cfg.AllowedChatIDs) == 0 { + return fmt.Errorf("remote control telegram: missing allowed_chat_ids") + } + interval := time.Duration(cfg.PollIntervalMs) * time.Millisecond + if interval <= 0 { + interval = 2 * time.Second + } + go rc.pollTelegram(ctx, cfg, interval) + LogInfo("remote control: telegram polling enabled") + return nil +} + +func (rc *remoteControl) pollTelegram(ctx context.Context, cfg TelegramRemoteConfig, interval time.Duration) { + var offset int64 + for { + updates, err := rc.getTelegramUpdates(ctx, cfg.BotToken, offset) + if err != nil { + LogError("remote control telegram: getUpdates failed: %v", err) + } else { + for _, upd := range updates { + if upd.UpdateID >= offset { + offset = upd.UpdateID + 1 + } + chatID := strconv.FormatInt(upd.Message.Chat.ID, 10) + if !authorizedRemoteID(chatID, cfg.AllowedChatIDs) || strings.TrimSpace(upd.Message.Text) == "" { + continue + } + msg := remoteMessage{ + Provider: "telegram", + SenderID: chatID, + Text: upd.Message.Text, + Reply: func(replyCtx context.Context, text string) error { + return rc.sendTelegramMessage(replyCtx, cfg.BotToken, chatID, text) + }, + } + go rc.handleMessage(ctx, msg) + } + } + + select { + case <-ctx.Done(): + return + case <-time.After(interval): + } + } +} + +func (rc *remoteControl) getTelegramUpdates(ctx context.Context, token string, offset int64) ([]telegramUpdate, error) { + endpoint := telegramAPIBase + "/bot" + token + "/getUpdates" + q := url.Values{} + q.Set("timeout", "20") + if offset > 0 { + q.Set("offset", strconv.FormatInt(offset, 10)) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+q.Encode(), nil) + if err != nil { + return nil, err + } + resp, err := rc.http.Do(req) + if err != nil { + return nil, redactTelegramError(err, token) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("telegram getUpdates returned %s", resp.Status) + } + var out telegramUpdatesResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + if !out.OK { + return nil, fmt.Errorf("telegram getUpdates returned ok=false") + } + return out.Result, nil +} + +func (rc *remoteControl) sendTelegramMessage(ctx context.Context, token, chatID, text string) error { + endpoint := telegramAPIBase + "/bot" + token + "/sendMessage" + if err := postForm(ctx, rc.http, endpoint, url.Values{"chat_id": {chatID}, "text": {text}}); err != nil { + return redactTelegramError(err, token) + } + return nil +} + +func redactTelegramError(err error, token string) error { + if err == nil || token == "" { + return err + } + return fmt.Errorf("%s", strings.ReplaceAll(err.Error(), token, "[REDACTED]")) +} diff --git a/internal/daemon/remote_control_test.go b/internal/daemon/remote_control_test.go new file mode 100644 index 0000000..ef1fc9d --- /dev/null +++ b/internal/daemon/remote_control_test.go @@ -0,0 +1,177 @@ +package daemon + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/get-vix/vix/internal/protocol" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) Do(req *http.Request) (*http.Response, error) { return f(req) } + +func TestAuthorizedRemoteIDRequiresAllowlist(t *testing.T) { + if authorizedRemoteID("123", nil) { + t.Fatal("empty allowlist must deny") + } + if authorizedRemoteID("123", []string{"456"}) { + t.Fatal("unlisted sender must deny") + } + if !authorizedRemoteID("123", []string{" 123 "}) { + t.Fatal("listed sender should be authorized") + } +} + +func TestRemoteSessionOptionsDisableAutomaticPermissions(t *testing.T) { + autoWrite, autoDirs := remoteSessionAutomaticPermissions() + if autoWrite || autoDirs { + t.Fatalf("remote automatic permissions = write:%v dirs:%v, want both false", autoWrite, autoDirs) + } +} + +func TestRemoteUnattendedEventsDoNotAutoApprove(t *testing.T) { + cmd, err := remoteCommandForUnattendedEvent(protocol.SessionEvent{Type: "event.confirm_request"}) + if err != nil { + t.Fatalf("confirm request handling: %v", err) + } + if cmd.Type != "session.confirm" || !strings.Contains(string(cmd.Data), `"approved":false`) { + t.Fatalf("confirm request command = %+v data=%s, want denial", cmd, string(cmd.Data)) + } + + _, err = remoteCommandForUnattendedEvent(protocol.SessionEvent{Type: "event.plan_proposed"}) + if err == nil || !strings.Contains(err.Error(), "requires interactive approval") { + t.Fatalf("plan proposal err = %v, want interactive approval error", err) + } + + _, err = remoteCommandForUnattendedEvent(protocol.SessionEvent{Type: "event.user_question"}) + if err == nil || !strings.Contains(err.Error(), "requires an interactive answer") { + t.Fatalf("user question err = %v, want interactive answer error", err) + } +} + +func TestTelegramSendMessageRedactsBotTokenFromErrors(t *testing.T) { + secret := "123456:secret-token" + rc := &remoteControl{http: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, &url.Error{Op: "Post", URL: req.URL.String(), Err: fmt.Errorf("dial failed")} + })} + + err := rc.sendTelegramMessage(context.Background(), secret, "42", "hello") + if err == nil { + t.Fatal("sendTelegramMessage error = nil, want redacted error") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("error leaked bot token: %v", err) + } +} + +func TestWhatsAppStartFailsWhenWebhookAddrAlreadyBound(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + rc := &remoteControl{cfg: RemoteControlConfig{WhatsApp: WhatsAppRemoteConfig{ + Enabled: true, + AccessToken: "access", + AppSecret: "secret", + PhoneNumberID: "phone", + VerifyToken: "verify", + WebhookAddr: ln.Addr().String(), + AllowedContacts: []string{"15551234567"}, + }}} + if err := rc.startWhatsApp(context.Background()); err == nil { + t.Fatal("startWhatsApp error = nil, want bind failure") + } +} + +func TestTelegramSendMessagePostsChatIDAndText(t *testing.T) { + var gotPath, gotBody string + rc := &remoteControl{http: roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotPath = req.URL.Path + b, _ := io.ReadAll(req.Body) + gotBody = string(b) + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Body: io.NopCloser(strings.NewReader(`{"ok":true}`))}, nil + })} + + if err := rc.sendTelegramMessage(context.Background(), "token", "42", "hello vix"); err != nil { + t.Fatalf("sendTelegramMessage: %v", err) + } + if gotPath != "/bottoken/sendMessage" { + t.Fatalf("path = %q", gotPath) + } + if !strings.Contains(gotBody, "chat_id=42") || !strings.Contains(gotBody, "text=hello+vix") { + t.Fatalf("unexpected body %q", gotBody) + } +} + +func TestTelegramGetUpdatesRedactsBotTokenFromErrors(t *testing.T) { + secret := "123456:secret-token" + rc := &remoteControl{http: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, &url.Error{Op: "Get", URL: req.URL.String(), Err: fmt.Errorf("dial failed")} + })} + + _, err := rc.getTelegramUpdates(context.Background(), secret, 0) + if err == nil { + t.Fatal("getTelegramUpdates error = nil, want redacted error") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("error leaked bot token: %v", err) + } +} + +func TestWhatsAppWebhookVerificationAndAllowlist(t *testing.T) { + handled := make(chan remoteMessage, 1) + rc := &remoteControl{} + cfg := WhatsAppRemoteConfig{VerifyToken: "verify", AppSecret: "secret", AllowedContacts: []string{"15551234567"}} + h := rc.whatsAppWebhookHandlerWithHandle(context.Background(), cfg, func(_ context.Context, msg remoteMessage) { + handled <- msg + }) + + req := httptest.NewRequest(http.MethodGet, "/whatsapp?hub.mode=subscribe&hub.verify_token=verify&hub.challenge=abc", nil) + w := httptest.NewRecorder() + h(w, req) + if w.Code != http.StatusOK || w.Body.String() != "abc" { + t.Fatalf("verification response = %d %q", w.Code, w.Body.String()) + } + + body := `{"entry":[{"changes":[{"value":{"messages":[{"from":"15551234567","type":"text","text":{"body":"run tests"}},{"from":"blocked","type":"text","text":{"body":"ignore"}}]}}]}]}` + req = httptest.NewRequest(http.MethodPost, "/whatsapp", strings.NewReader(body)) + req.Header.Set("X-Hub-Signature-256", whatsappTestSignature(body, "secret")) + w = httptest.NewRecorder() + h(w, req) + if w.Code != http.StatusOK { + t.Fatalf("post response = %d", w.Code) + } + select { + case msg := <-handled: + if msg.SenderID != "15551234567" || msg.Text != "run tests" { + t.Fatalf("unexpected message: %+v", msg) + } + case <-time.After(time.Second): + t.Fatal("handled 0 messages, want 1") + } + select { + case msg := <-handled: + t.Fatalf("unexpected extra message: %+v", msg) + default: + } +} + +func whatsappTestSignature(body, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(body)) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/daemon/remote_control_whatsapp.go b/internal/daemon/remote_control_whatsapp.go new file mode 100644 index 0000000..a5fb8e1 --- /dev/null +++ b/internal/daemon/remote_control_whatsapp.go @@ -0,0 +1,179 @@ +package daemon + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +type whatsappWebhookPayload struct { + Entry []struct { + Changes []struct { + Value struct { + Messages []struct { + From string `json:"from"` + Text struct { + Body string `json:"body"` + } `json:"text"` + Type string `json:"type"` + } `json:"messages"` + } `json:"value"` + } `json:"changes"` + } `json:"entry"` +} + +func (rc *remoteControl) startWhatsApp(ctx context.Context) error { + cfg := rc.cfg.WhatsApp + if strings.TrimSpace(cfg.AccessToken) == "" { + return fmt.Errorf("remote control whatsapp: missing access_token") + } + if strings.TrimSpace(cfg.AppSecret) == "" { + return fmt.Errorf("remote control whatsapp: missing app_secret") + } + if strings.TrimSpace(cfg.PhoneNumberID) == "" { + return fmt.Errorf("remote control whatsapp: missing phone_number_id") + } + if strings.TrimSpace(cfg.VerifyToken) == "" { + return fmt.Errorf("remote control whatsapp: missing verify_token") + } + if len(cfg.AllowedContacts) == 0 { + return fmt.Errorf("remote control whatsapp: missing allowed_contacts") + } + addr := strings.TrimSpace(cfg.WebhookAddr) + if addr == "" { + addr = "127.0.0.1:1340" + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("remote control whatsapp: listen %s: %w", addr, err) + } + mux := http.NewServeMux() + mux.HandleFunc("/whatsapp", rc.whatsAppWebhookHandler(ctx, cfg)) + server := &http.Server{Handler: mux} + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + go func() { + LogInfo("remote control: whatsapp webhook listening on %s", addr) + if err := server.Serve(ln); err != nil && err != http.ErrServerClosed { + LogError("remote control whatsapp: webhook server failed: %v", err) + } + }() + return nil +} + +func (rc *remoteControl) whatsAppWebhookHandler(ctx context.Context, cfg WhatsAppRemoteConfig) http.HandlerFunc { + return rc.whatsAppWebhookHandlerWithHandle(ctx, cfg, rc.handleMessage) +} + +func (rc *remoteControl) whatsAppWebhookHandlerWithHandle(ctx context.Context, cfg WhatsAppRemoteConfig, handle func(context.Context, remoteMessage)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + mode := r.URL.Query().Get("hub.mode") + verifyToken := r.URL.Query().Get("hub.verify_token") + challenge := r.URL.Query().Get("hub.challenge") + if mode == "subscribe" && verifyToken == cfg.VerifyToken { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(challenge)) + return + } + http.Error(w, "forbidden", http.StatusForbidden) + case http.MethodPost: + defer r.Body.Close() + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if !validWhatsAppSignature(body, r.Header.Get("X-Hub-Signature-256"), cfg.AppSecret) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + var payload whatsappWebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + for _, entry := range payload.Entry { + for _, change := range entry.Changes { + for _, wm := range change.Value.Messages { + if wm.Type != "text" || !authorizedRemoteID(wm.From, cfg.AllowedContacts) || strings.TrimSpace(wm.Text.Body) == "" { + continue + } + from := wm.From + msg := remoteMessage{ + Provider: "whatsapp", + SenderID: from, + Text: wm.Text.Body, + Reply: func(replyCtx context.Context, text string) error { + return rc.sendWhatsAppMessage(replyCtx, cfg, from, text) + }, + } + go handle(ctx, msg) + } + } + } + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + } +} + +func validWhatsAppSignature(body []byte, signature, appSecret string) bool { + if !strings.HasPrefix(signature, "sha256=") || appSecret == "" { + return false + } + got, err := hex.DecodeString(strings.TrimPrefix(signature, "sha256=")) + if err != nil { + return false + } + mac := hmac.New(sha256.New, []byte(appSecret)) + _, _ = mac.Write(body) + want := mac.Sum(nil) + return hmac.Equal(got, want) +} + +func (rc *remoteControl) sendWhatsAppMessage(ctx context.Context, cfg WhatsAppRemoteConfig, to, text string) error { + endpoint := fmt.Sprintf("https://graph.facebook.com/v20.0/%s/messages", cfg.PhoneNumberID) + payload := map[string]any{ + "messaging_product": "whatsapp", + "to": to, + "type": "text", + "text": map[string]string{ + "body": text, + }, + } + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(payload); err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+cfg.AccessToken) + req.Header.Set("Content-Type", "application/json") + resp, err := rc.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("whatsapp send returned %s", resp.Status) + } + return nil +}