feat: answer/dial/control WhatsApp calls and stream their audio/video over WebSocket - #141
Conversation
Documents the plan to add POST /call/answer, POST /call/hangup, and a dedicated WebSocket bridge (GET /call/stream/:callId) built on the purpshell/meowcaller library, so an accepted call's audio/video can be piped to an external system (AI voice pipeline, recorder, etc).
Task-by-task plan grounded in the actual codebase: exact insertion point for meowcaller.NewClient in whatsmeow.go (before Connect(), per the library's own constraint), a new pkg/call/registry leaf package to avoid an import cycle between call_service and whatsmeow_service, and a pkg/call/stream bridge implementing meowcaller's AudioSink/VideoSink/ AudioSource over a Twilio-Media-Streams-style WebSocket.
Task 1 originally ran `go mod tidy` right after `go get`, before any .go file imports meowcaller. tidy removes require entries nothing imports, so it silently deleted the dependency the get had just added, and go build still passed (nothing references the module). Caught by the Task 1 reviewer, who checked go.mod directly instead of trusting the implementer's report. Moved the real tidy to Task 8, after Tasks 3/4/7 add actual imports.
The previous commit (ad10bc3) claimed to add github.com/purpshell/meowcaller but ran go mod tidy immediately after go get, which silently stripped the require line since nothing imports the module yet. Re-added without running tidy (deferred to Task 8, once real imports exist).
Thread callRegistry through NewWhatsmeowService and NewCallService, and register call_stream.RegisterRoutes so the call-answering stack built in Tasks 2-7 is actually reachable. go mod tidy now keeps meowcaller as a direct dependency since real code imports it.
Live validation against a real WhatsApp call showed the caller's side never learned the call was rejected — it kept ringing until WhatsApp's own ~90s timeout, because the raw whatsmeow client.RejectCall (called from a linked/companion device rather than the primary phone) doesn't reliably propagate as a proper decline over the call-signaling network. meowcaller's own call.Reject() goes through the same signaling path already proven correct for /call/answer and /call/hangup in this same manual validation. Since RejectCall now goes through the call registry like the other two, ensureClientConnected (and the context/time imports it needed) has no remaining caller and is removed as dead code.
Live validation against a real WhatsApp video call showed AcceptVideo() failing with "no pending peer video upgrade" right after a successful Answer(). Per meowcaller's own doc comments, Answer() already negotiates whatever media the offer declared (audio, or audio+video if the call started as video) — AcceptVideo is for a different case: accepting a peer's mid-call request to upgrade an audio call to video (their Call.StartVideo), which isn't wired up here. The call still connects fine with video without it; the removed call was a no-op at best and a confusing logged error at worst.
Needed --parseDependency --parseInternal for swag to resolve external types (go.mau.fi/whatsmeow/types.JID, gin.H) used throughout existing struct definitions -- without them swag aborts entirely and writes nothing, which the Makefile's swagger target doesn't surface (its success message prints unconditionally regardless of swag's exit code, since the command and echo are chained with ; not &&).
Not part of the reviewed answer-call plan/design — user asked to try placing an outbound video call live during manual validation. Exposes meowcaller.Client.CallWithOptions via GetMeowcallerClient (new on WhatsmeowService) + CallService.DialCall, registering the resulting call so /call/hangup and /call/stream work on it too. Kept on its own branch (feature/call-dial-spike) rather than mixed into feature/call-answer-stream.
Continuing experimental call-control coverage on this spike branch: POST /call/react, POST /call/participant/add, POST /call/screenshare, POST /call/handraise, wrapping meowcaller's SendReaction/AddParticipant/ StartScreenShare-StopScreenShare/SetHandRaised. Same pattern as DialCall — registry lookup by callId, thin handler, no new tests (same un-mockable *meowcaller.Call constraint as the rest of this file).
Explicitly out of scope in the reviewed plan (video was inbound-only
there) -- added to test whether content sent via Call.SendVideo
actually reaches the peer during a StartScreenShare-toggled call.
Consumer sends {"event":"video","track":"outbound","payload":"<base64
Annex-B access unit>"} over the same /call/stream socket.
Live test showed SendVideo() calls succeeding with no error but never rendering on the peer's screen. meowcaller's StartVideo doc comment explains why: "outbound video remains gated until the peer acknowledges the transition" -- SendVideo alone doesn't request the upgrade, it just feeds a source that stays gated shut until StartVideo is called and acknowledged. Lazily calls StartVideo() on the first outbound video message so there's no separate step to remember.
Live testing showed calling StartVideo() (an audio->video upgrade request) on a call that already declared video in its offer doesn't just no-op -- it dropped the call entirely. Only wire the lazy StartVideo trigger up when the call actually needs the upgrade (call.IsVideo() is false at stream-attach time).
Live testing showed AddParticipant's internal usync lookup reliably timing out while a call is already active, even for numbers already resolved earlier in the same session -- while the identical resolve succeeds instantly with no call in progress. Retrying 3x with a 2s gap to test whether this is transient network contention on the shared connection rather than a hard block.
POST /call/video/upgrade (Call.StartVideo/StopVideo), POST /call/video/enabled (Call.SetVideoEnabled), POST /call/video/orientation (Call.SetVideoOrientation). Same registry- lookup + thin-handler pattern as the rest of this spike branch.
There was a problem hiding this comment.
Sorry @RamonBritoDev, your pull request is larger than the review limit of 150000 diff characters
Reviewer's GuideAdds full inbound call handling and media streaming for WhatsApp calls by integrating the meowcaller library, tracking active calls in a registry, exposing answer/hangup REST endpoints, and providing a per-call WebSocket that streams audio/inbound video using a Twilio Media Streams-style JSON envelope, alongside assorted swagger/doc and dependency updates. Sequence diagram for answering a WhatsApp call via RESTsequenceDiagram
actor ApiClient
participant GinRouter
participant CallHandler
participant CallService
participant CallRegistry
participant MeowcallerCall
ApiClient->>GinRouter: POST /call/answer
GinRouter->>CallHandler: AnswerCall(ctx)
CallHandler->>CallService: AnswerCall(data, instance)
CallService->>CallRegistry: Get(instance.Id, data.CallID)
CallRegistry-->>CallService: call, ok
alt [call found]
CallService->>MeowcallerCall: Answer()
MeowcallerCall-->>CallService: error?
alt [no error]
CallService-->>CallHandler: *meowcaller.Call, nil
CallHandler-->>ApiClient: 200 {message:"success"}
else [error]
CallService-->>CallHandler: nil, error
CallHandler-->>ApiClient: 500 {error}
end
else [no pending call]
CallService-->>CallHandler: nil, "no pending call with that id"
CallHandler-->>ApiClient: 500 {error}
end
Sequence diagram for streaming call media over WebSocketsequenceDiagram
actor WsClient
participant GinEngine
participant StreamHandler
participant InstanceService
participant CallService
participant MeowcallerCall
participant Bridge
WsClient->>GinEngine: GET /call/stream/:callId?apikey=...
GinEngine->>StreamHandler: serveStream(ctx)
StreamHandler->>InstanceService: GetInstanceByToken(apikey)
InstanceService-->>StreamHandler: instance or error
alt [authorized]
StreamHandler->>CallService: GetActiveCall(instance.Id, callId)
CallService-->>StreamHandler: *meowcaller.Call or error
alt [active call]
StreamHandler->>GinEngine: Upgrade(ctx.Writer, ctx.Request)
GinEngine-->>StreamHandler: *websocket.Conn
StreamHandler->>Bridge: newBridge(conn)
StreamHandler->>MeowcallerCall: OnEnd(callback)
StreamHandler->>MeowcallerCall: Receive(Bridge)
StreamHandler->>MeowcallerCall: ReceiveVideo(Bridge)
StreamHandler->>MeowcallerCall: Play(Bridge)
StreamHandler->>Bridge: writeStart(callId, MeowcallerCall.IsVideo())
par inbound media
MeowcallerCall->>Bridge: WriteFrame(frame)
Bridge->>WsClient: WriteJSON({event:"media", track:"inbound"})
MeowcallerCall->>Bridge: WriteVideo(accessUnit)
Bridge->>WsClient: WriteJSON({event:"video", track:"inbound"})
and outbound media
WsClient->>Bridge: ReadJSON({event:"media", track:"outbound"})
Bridge->>Bridge: float32FromPCM16(payload)
Bridge->>MeowcallerCall: ReadFrame()
end
MeowcallerCall-->>Bridge: OnEnd(reason)
Bridge->>WsClient: WriteJSON({event:"stop"})
Bridge->>GinEngine: Close()
StreamHandler->>MeowcallerCall: Hangup()
else [no active call]
StreamHandler-->>WsClient: 404 {error}
end
else [unauthorized]
StreamHandler-->>WsClient: 401 {error:"not authorized"}
end
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Pull request overview
Adds first-class WhatsApp call control + per-call media streaming to Evolution Go by wiring meowcaller into the instance lifecycle, tracking active calls in a registry, and exposing new REST + WebSocket endpoints for answering/hanging up and streaming audio/video frames.
Changes:
- Add
POST /call/answerandPOST /call/hangupendpoints and wire them into the router. - Introduce an instance-scoped
CallRegistryand integrate incoming-call capture + call termination cleanup. - Add a per-call WebSocket bridge (
GET /call/stream/:callId) that streams inbound audio/video and accepts outbound audio.
Reviewed changes
Copilot reviewed 16 out of 19 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pkg/whatsmeow/service/whatsmeow.go | Initializes meowcaller per instance pre-Connect() and stores/removes calls in the registry. |
| pkg/routes/routes.go | Registers /call/answer and /call/hangup routes under the existing /call group. |
| pkg/call/stream/handler.go | Adds the WebSocket endpoint and attaches a call’s media sinks/sources to the socket. |
| pkg/call/stream/codec.go | Implements float32 ↔ PCM16LE conversion used by the media bridge. |
| pkg/call/stream/codec_test.go | Unit tests for PCM conversion logic. |
| pkg/call/stream/bridge.go | WebSocket ↔ meowcaller media interface adapter (audio/video). |
| pkg/call/service/call_service.go | Implements answer/hangup/get-active-call and switches reject to meowcaller.Call.Reject(). |
| pkg/call/registry/call_registry.go | Adds mutex-guarded registry keyed by callId and scoped by instanceId. |
| pkg/call/registry/call_registry_test.go | Unit tests for call registry behavior and instance scoping. |
| pkg/call/handler/call_handler.go | Adds HTTP handlers for answering and hanging up calls. |
| cmd/evolution-go/main.go | Wires registry/service construction and registers the stream routes on the engine. |
| go.mod | Adds github.com/purpshell/meowcaller and bumps related dependencies. |
| go.sum | Updates module checksums for new/bumped dependencies. |
| docs/swagger.yaml | Documents new call endpoints and updates type references. |
| docs/swagger.json | JSON Swagger output for the new endpoints and updated type references. |
| docs/superpowers/specs/2026-07-29-call-answer-stream-design.md | Design doc for the call answering + media stream architecture. |
| docs/superpowers/plans/2026-07-29-call-answer-stream.md | Implementation plan doc for the feature. |
| .gitignore | Ignores .superpowers/ directory. |
Files not reviewed (1)
- docs/docs.go: Generated file
Comments suppressed due to low confidence (2)
pkg/call/service/call_service.go:56
RejectCallremoves the call from the registry before checking whethercall.Reject()succeeded. IfReject()returns an error but the call is still ringing/active, the registry entry is lost and the caller can no longer retry reject/hangup/stream by callId.
This issue also appears on line 79 of the same file.
func (c *callService) RejectCall(data *RejectCallStruct, instance *instance_model.Instance) error {
call, ok := c.callRegistry.Get(instance.Id, data.CallID)
if !ok {
return errors.New("no pending call with that id")
}
err := call.Reject()
c.callRegistry.Delete(data.CallID)
if err != nil {
logger.LogError("[%s] error reject call: %v", instance.Id, err)
return err
}
pkg/call/service/call_service.go:92
HangupCalldeletes the call from the registry even whencall.Hangup()returns an error. If hangup fails but the call remains active, subsequent hangup/stream operations will incorrectly fail with “no active call with that id”.
func (c *callService) HangupCall(data *HangupCallStruct, instance *instance_model.Instance) error {
call, ok := c.callRegistry.Get(instance.Id, data.CallID)
if !ok {
return errors.New("no active call with that id")
}
err := call.Hangup()
c.callRegistry.Delete(data.CallID)
if err != nil {
logger.LogError("[%s] error hanging up call: %v", instance.Id, err)
return err
}
return nil
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import ( | ||
| "net/http" | ||
|
|
||
| call_service "github.com/evolution-foundation/evolution-go/pkg/call/service" | ||
| instance_service "github.com/evolution-foundation/evolution-go/pkg/instance/service" | ||
| "github.com/gin-gonic/gin" | ||
| "github.com/gorilla/websocket" | ||
| ) | ||
|
|
||
| var upgrader = websocket.Upgrader{ | ||
| ReadBufferSize: 1024, | ||
| WriteBufferSize: 1024, | ||
| CheckOrigin: func(r *http.Request) bool { return true }, | ||
| } |
Summary
POST /call/answerandPOST /call/hangup, alongside the existingPOST /call/reject.GET /call/stream/:callId, a dedicated per-call WebSocket that carries the call's audio (and video) as base64 PCM16LE / H.264 frames, in a JSON envelope modeled on Twilio Media Streams. Sending{"event":"media","track":"outbound",...}(or{"event":"video","track":"outbound",...}) back over the same socket plays audio/video into the call (e.g. TTS output for a voice-AI use case).POST /call/dial(place an outbound call),POST /call/react(emoji reactions),POST /call/participant/add(invite a participant into an active call),POST /call/screenshare(toggle screen-share state),POST /call/handraise,POST /call/video/upgrade(start/stop a mid-call audio→video upgrade),POST /call/video/enabled,POST /call/video/orientation.RejectCallused the rawwhatsmeowclient's reject, which doesn't reliably propagate to the caller when sent from a linked/companion device — the caller's phone kept ringing until WhatsApp's own ~90s timeout. Switched it to go throughmeowcaller's ownCall.Reject(), the same path already proven correct for answer/hangup.AnswerCallwas unconditionally callingAcceptVideo(), which is only for accepting a mid-call audio→video upgrade request — calling it on a call that already has video from the initial offer returns an error and isn't needed (Answer()already negotiates whatever media the offer declared).Why
go.mau.fi/whatsmeow(this project's WhatsApp protocol library) has never implemented accepting a call or handling its media — only rejecting. Building that from scratch is a multi-year reverse-engineering effort (seetulir/whatsmeow#555).purpshell/meowcaller(MIT-licensed, pure Go, actively maintained) already did that work as an independent interoperability research project — see its DISCLAIMER.md for its scope and ground rules. This PR wires it into Evolution Go's existing per-instance client lifecycle.How it's wired
meowcaller.NewClientis constructed the moment each instance's*whatsmeow.Clientis created, before.Connect()(required by the library). Incoming calls are captured viaOnIncomingCallinto a small newpkg/call/registrypackage, scoped by instance so one instance's apikey can't reach another instance's call.pkg/call/serviceanswers/rejects/hangs up/dials/controls calls by looking them up in that registry.pkg/call/streambridges a call's audio/video sinks and source to the WebSocket, including outbound video for screen-share/camera content.Test plan
go build ./...andgo test ./...pass, including new unit tests for the call registry (pkg/call/registry) and the PCM16LE/float32 conversion (pkg/call/stream)make fmt/make lintcleanAcceptVideofix — video negotiated correctly)Rejectfix) the caller's phone stopped ringing immediately instead of waiting out the timeout/call/dial(audio and video), had it accepted, confirmedCallAcceptand clean hangup/call/react; confirmed it appeared on the peer's screen/call/stream/:callIdduring an active call and recorded ~12s of real inbound audio (verified non-silent: RMS ≈ 30, not flat zero) to a WAV file, confirmed intelligible on playbackKnown limitations (real bugs, not yet fixed)
Two of the endpoints added here have real, reproducible bugs that live in
meowcalleritself, not in this integration layer. I'm shipping them anyway per repo maintainer preference, but flagging clearly so reviewers/users know what to expect:POST /call/participant/addreliably times out ("usync query timed out") while the call is already active, even for a number resolved successfully moments earlier in the same process. The identical resolve succeeds instantly when done as a standalone action with no call in progress.AddParticipant/RingParticipantboth call an internalrequireRawCallAdapter()step thatCall/Answer/Hangupdon't, which is my leading suspicion for where the two code paths diverge — but I haven't confirmed the exact mechanism. Retrying with backoff (3x, 2s apart, already implemented incall_service.AddParticipant) does not help — reproduced 4 times against a real account, 4/4 failures.POST /call/video/upgrade+POST /call/screenshare+ outbound video, on a call that started audio-only, doesn't render on the peer's device, even though the upgrade request succeeds and frames are sent without error. Camera video works fine when video is already part of the original call offer (verified: 83 inbound H.264 access units received in one test).StartScreenSharecallsCall.requestVideoKeyframe()internally, which fires anOnVideoKeyframeRequestcallback that isn't wired up on the sending side here — I suspect the peer is discarding video until it sees a keyframe sent in direct response to that request, but I haven't confirmed this.Both look like they belong in
purpshell/meowcallerrather than here — happy to open issues there with full repro steps if that's useful.