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 +}