Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/desktop/src-sidecar/internal/control/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ type Command struct {
Reason string `json:"reason,omitempty"`
MessageID string `json:"message_id,omitempty"`

// Message body for send_chat_message. Capped at 500 bytes by Twitch's
// Helix POST /chat/messages endpoint; the sidecar enforces the limit
// before issuing the request.
Message string `json:"message,omitempty"`

// RequestID correlates a command with its response notification (e.g.
// `send_chat_result`). Opaque to the sidecar; the host sets it and
// matches it back when the result line arrives.
RequestID uint64 `json:"request_id,omitempty"`

// YouTube fields
VideoID string `json:"video_id,omitempty"`
LiveChatID string `json:"live_chat_id,omitempty"`
Expand Down
80 changes: 80 additions & 0 deletions apps/desktop/src-sidecar/internal/sidecar/sidecar.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ func DispatchCommand(ctx context.Context, cmd control.Command, clients map[strin
HandleTimeoutUser(cmd, logger)
case "delete_message":
HandleDeleteMessage(cmd, logger)
case "send_chat_message":
HandleSendChatMessage(ctx, cmd, notify, logger)
default:
logger.Info().Str("cmd", cmd.Cmd).Str("channel", cmd.Channel).Msg("received command")
}
Expand Down Expand Up @@ -386,6 +388,84 @@ func HandleDeleteMessage(cmd control.Command, logger zerolog.Logger) {
Msg("delete_message (scaffold: no Helix call yet)")
}

// SendChatResultPayload is the body of a `send_chat_result` notification
// emitted to the host after a send_chat_message attempt. The frontend uses
// this to surface failures (drop reasons, auth errors) without having to
// poll any other state.
type SendChatResultPayload struct {
// RequestID echoes back the command's request_id so the host can
// correlate this result with the awaiting Tauri invocation.
RequestID uint64 `json:"request_id,omitempty"`
Ok bool `json:"ok"`
MessageID string `json:"message_id,omitempty"`
DropCode string `json:"drop_code,omitempty"`
DropMessage string `json:"drop_message,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}

// sendChatHelixBase overrides the Helix base URL used by HandleSendChatMessage
// in tests. Empty string falls through to the production Helix endpoint.
var sendChatHelixBase = ""

// HandleSendChatMessage posts the user's message to Twitch via Helix and
// emits a `send_chat_result` notification with either the assigned
// message_id or the drop reason / transport error. Validation mirrors the
// Helix endpoint so obvious misuse (empty fields, oversized body) fails
// without consuming a request.
func HandleSendChatMessage(ctx context.Context, cmd control.Command, notify twitch.Notify, logger zerolog.Logger) {
reply := func(p SendChatResultPayload) {
p.RequestID = cmd.RequestID
notify("send_chat_result", p)
}
if cmd.BroadcasterID == "" || cmd.UserID == "" || cmd.ClientID == "" || cmd.Token == "" {
logger.Warn().
Str("broadcaster", cmd.BroadcasterID).
Str("user", cmd.UserID).
Msg("send_chat_message missing required field; ignoring")
reply(SendChatResultPayload{
ErrorMessage: "missing broadcaster, user, client_id, or token",
})
return
}
if cmd.Message == "" {
reply(SendChatResultPayload{ErrorMessage: "empty message"})
return
}
if len(cmd.Message) > twitch.MaxChatMessageBytes {
reply(SendChatResultPayload{
ErrorMessage: fmt.Sprintf("message exceeds %d bytes", twitch.MaxChatMessageBytes),
})
return
}
client := &twitch.HelixClient{
ClientID: cmd.ClientID,
AccessToken: cmd.Token,
BaseURL: sendChatHelixBase,
}
resp, err := client.SendChatMessage(ctx, cmd.BroadcasterID, cmd.UserID, cmd.Message)
if err != nil {
logger.Warn().Err(err).Str("broadcaster", cmd.BroadcasterID).Msg("send_chat_message failed")
reply(SendChatResultPayload{ErrorMessage: err.Error()})
return
}
if len(resp.Data) == 0 {
reply(SendChatResultPayload{ErrorMessage: "empty response from helix"})
return
}
first := resp.Data[0]
if !first.IsSent {
reply(SendChatResultPayload{
DropCode: first.DropReason.Code,
DropMessage: first.DropReason.Message,
})
return
}
reply(SendChatResultPayload{
Ok: true,
MessageID: first.MessageID,
})
}

// HandleTwitchConnect spawns a Twitch EventSub client for the broadcaster in
// cmd if there isn't already one running. The client writes envelope bytes to
// `out`, which the writer goroutine drains into the ring buffer.
Expand Down
136 changes: 136 additions & 0 deletions apps/desktop/src-sidecar/internal/sidecar/sidecar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1151,3 +1151,139 @@ func TestDispatchCommand_RoutesYouTubeDisconnect(t *testing.T) {
t.Fatal("expected youtube_disconnect to cancel client")
}
}

func TestHandleSendChatMessage_MissingFieldsRepliesWithRequestID(t *testing.T) {
cases := []struct {
name string
cmd control.Command
want string
}{
{
"no broadcaster",
control.Command{Cmd: "send_chat_message", UserID: "u", ClientID: "c", Token: "t", Message: "hi", RequestID: 7},
"missing broadcaster",
},
{
"empty message",
control.Command{Cmd: "send_chat_message", BroadcasterID: "b", UserID: "u", ClientID: "c", Token: "t", RequestID: 8},
"empty message",
},
{
"oversize",
control.Command{Cmd: "send_chat_message", BroadcasterID: "b", UserID: "u", ClientID: "c", Token: "t", Message: strings.Repeat("a", twitch.MaxChatMessageBytes+1), RequestID: 9},
"exceeds",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got SendChatResultPayload
var typ string
notify := func(t string, p any) {
typ = t
got = p.(SendChatResultPayload)
}
HandleSendChatMessage(context.Background(), tc.cmd, notify, zerolog.Nop())
if typ != "send_chat_result" {
t.Fatalf("expected send_chat_result, got %q", typ)
}
if got.RequestID != tc.cmd.RequestID {
t.Errorf("request_id not echoed: want %d got %d", tc.cmd.RequestID, got.RequestID)
}
if got.Ok {
t.Error("expected Ok=false")
}
if !strings.Contains(got.ErrorMessage, tc.want) {
t.Errorf("expected error containing %q, got %q", tc.want, got.ErrorMessage)
}
})
}
}

func TestDispatchCommand_RoutesSendChatMessage(t *testing.T) {
var typ string
notify := func(t string, p any) {
typ = t
_ = p
}
cmd := control.Command{Cmd: "send_chat_message", RequestID: 11}
DispatchCommand(context.Background(), cmd, map[string]context.CancelFunc{}, make(chan []byte, 1), notify, zerolog.Nop())
if typ != "send_chat_result" {
t.Fatalf("expected dispatch to invoke HandleSendChatMessage, got notify type %q", typ)
}
}

func TestHandleSendChatMessage_SuccessEchoesMessageID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/messages" || r.Method != http.MethodPost {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":[{"message_id":"abc-123","is_sent":true}]}`)
}))
defer srv.Close()
prev := sendChatHelixBase
sendChatHelixBase = srv.URL
defer func() { sendChatHelixBase = prev }()

var got SendChatResultPayload
notify := func(_ string, p any) { got = p.(SendChatResultPayload) }
HandleSendChatMessage(context.Background(), control.Command{
Cmd: "send_chat_message",
BroadcasterID: "b",
UserID: "u",
ClientID: "c",
Token: "t",
Message: "hello",
RequestID: 42,
}, notify, zerolog.Nop())

if !got.Ok || got.MessageID != "abc-123" || got.RequestID != 42 {
t.Fatalf("unexpected payload: %+v", got)
}
}

func TestHandleSendChatMessage_DropReason(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":[{"is_sent":false,"drop_reason":{"code":"msg_duplicate","message":"duplicate"}}]}`)
}))
defer srv.Close()
prev := sendChatHelixBase
sendChatHelixBase = srv.URL
defer func() { sendChatHelixBase = prev }()

var got SendChatResultPayload
notify := func(_ string, p any) { got = p.(SendChatResultPayload) }
HandleSendChatMessage(context.Background(), control.Command{
Cmd: "send_chat_message",
BroadcasterID: "b", UserID: "u", ClientID: "c", Token: "t",
Message: "x", RequestID: 5,
}, notify, zerolog.Nop())

if got.Ok || got.DropCode != "msg_duplicate" || got.RequestID != 5 {
t.Fatalf("unexpected payload: %+v", got)
}
}

func TestHandleSendChatMessage_HelixError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"error":"Unauthorized","status":401,"message":"oops"}`)
}))
defer srv.Close()
prev := sendChatHelixBase
sendChatHelixBase = srv.URL
defer func() { sendChatHelixBase = prev }()

var got SendChatResultPayload
notify := func(_ string, p any) { got = p.(SendChatResultPayload) }
HandleSendChatMessage(context.Background(), control.Command{
Cmd: "send_chat_message",
BroadcasterID: "b", UserID: "u", ClientID: "c", Token: "t",
Message: "x", RequestID: 99,
}, notify, zerolog.Nop())

if got.Ok || got.ErrorMessage == "" || got.RequestID != 99 {
t.Fatalf("unexpected payload: %+v", got)
}
}
52 changes: 52 additions & 0 deletions apps/desktop/src-sidecar/internal/twitch/helix.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,55 @@ type AuthError struct {
func (e *AuthError) Error() string {
return fmt.Sprintf("twitch auth error (%d): %s", e.Status, e.Body)
}

// Helix limit on a single chat message body. Messages longer than this are
// rejected with HTTP 400 by Twitch, so we mirror it locally to fail fast
// without consuming a Helix call.
const MaxChatMessageBytes = 500

// SendChatMessageRequest is the body Twitch's POST /chat/messages expects.
// `BroadcasterID` is the channel; `SenderID` must match the authenticated
// user on the access token. `ReplyParentMessageID` is omitted for top-level
// sends — reply support lives behind a follow-up control field.
type SendChatMessageRequest struct {
BroadcasterID string `json:"broadcaster_id"`
SenderID string `json:"sender_id"`
Message string `json:"message"`
}

// SendChatMessageResponse is the envelope Twitch returns on 200. We surface
// the per-send drop reason (e.g. AutoMod, channel followers-only) so the UI
// can tell the user why a message did not appear.
type SendChatMessageResponse struct {
Data []struct {
MessageID string `json:"message_id"`
IsSent bool `json:"is_sent"`
DropReason struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"drop_reason"`
} `json:"data"`
}

// SendChatMessage posts a chat message via Helix and returns the parsed
// response. Caller is responsible for passing a non-empty message that fits
// in [MaxChatMessageBytes]; this method validates length up front to avoid
// a wasted round-trip.
func (c *HelixClient) SendChatMessage(ctx context.Context, broadcasterID, senderID, message string) (*SendChatMessageResponse, error) {
if message == "" {
return nil, errors.New("twitch helix: empty chat message")
}
if len(message) > MaxChatMessageBytes {
return nil, fmt.Errorf("twitch helix: chat message exceeds %d bytes", MaxChatMessageBytes)
}
req := SendChatMessageRequest{
BroadcasterID: broadcasterID,
SenderID: senderID,
Message: message,
}
var resp SendChatMessageResponse
if err := c.Post(ctx, "/chat/messages", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
85 changes: 85 additions & 0 deletions apps/desktop/src-sidecar/internal/twitch/helix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,88 @@ func TestSubscribeServerError(t *testing.T) {
t.Fatal("500 should not be an AuthError")
}
}

func TestSendChatMessageSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/chat/messages" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var req SendChatMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode body: %v", err)
}
if req.BroadcasterID != "b1" || req.SenderID != "u1" || req.Message != "hello" {
t.Fatalf("unexpected body: %+v", req)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"data":[{"message_id":"abc","is_sent":true}]}`))
}))
defer srv.Close()

c := &HelixClient{BaseURL: srv.URL, ClientID: "cid", AccessToken: "tok"}
resp, err := c.SendChatMessage(context.Background(), "b1", "u1", "hello")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Data) != 1 || !resp.Data[0].IsSent || resp.Data[0].MessageID != "abc" {
t.Fatalf("unexpected response: %+v", resp)
}
}

func TestSendChatMessageDropped(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"data":[{"message_id":"","is_sent":false,"drop_reason":{"code":"msg_duplicate","message":"duplicate"}}]}`))
}))
defer srv.Close()

c := &HelixClient{BaseURL: srv.URL, ClientID: "cid", AccessToken: "tok"}
resp, err := c.SendChatMessage(context.Background(), "b1", "u1", "hello")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Data[0].IsSent {
t.Fatal("expected dropped")
}
if resp.Data[0].DropReason.Code != "msg_duplicate" {
t.Fatalf("unexpected drop code: %q", resp.Data[0].DropReason.Code)
}
}

func TestSendChatMessageEmpty(t *testing.T) {
c := &HelixClient{ClientID: "cid", AccessToken: "tok"}
if _, err := c.SendChatMessage(context.Background(), "b1", "u1", ""); err == nil {
t.Fatal("expected error for empty message")
}
}

func TestSendChatMessageOversize(t *testing.T) {
c := &HelixClient{ClientID: "cid", AccessToken: "tok"}
big := make([]byte, MaxChatMessageBytes+1)
for i := range big {
big[i] = 'a'
}
if _, err := c.SendChatMessage(context.Background(), "b1", "u1", string(big)); err == nil {
t.Fatal("expected error for oversized message")
}
}

func TestSendChatMessageUnauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"Unauthorized","status":401,"message":"Missing scope: user:write:chat"}`))
}))
defer srv.Close()

c := &HelixClient{BaseURL: srv.URL, ClientID: "cid", AccessToken: "tok"}
_, err := c.SendChatMessage(context.Background(), "b1", "u1", "hello")
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
}
Loading
Loading