From 844ad98142123c5164f21eeffd82d19d31296313 Mon Sep 17 00:00:00 2001 From: Branden J Brown Date: Sun, 6 Apr 2025 08:57:04 -0400 Subject: [PATCH] ingest: add wire format for incoming messages --- go.mod | 5 ++ go.sum | 4 ++ ingest/message.go | 24 ++++++++ ingest/message_test.go | 60 ++++++++++++++++++ ingest/testdata/twitch-message-shared.json | 72 ++++++++++++++++++++++ ingest/testdata/twitch-message.json | 66 ++++++++++++++++++++ ingest/wire.go | 31 ++++++++++ ingest/wire_test.go | 45 ++++++++++++++ twitch/eventsub.go | 13 ++++ 9 files changed, 320 insertions(+) create mode 100644 go.sum create mode 100644 ingest/message.go create mode 100644 ingest/message_test.go create mode 100644 ingest/testdata/twitch-message-shared.json create mode 100644 ingest/testdata/twitch-message.json create mode 100644 ingest/wire.go create mode 100644 ingest/wire_test.go create mode 100644 twitch/eventsub.go diff --git a/go.mod b/go.mod index 4708bee..968ed7a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module git.sunturtle.xyz/zephyr/kaiyan go 1.24.1 + +require ( + github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 + github.com/google/go-cmp v0.7.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..81a0c2f --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY= +github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/ingest/message.go b/ingest/message.go new file mode 100644 index 0000000..3110ad4 --- /dev/null +++ b/ingest/message.go @@ -0,0 +1,24 @@ +package ingest + +// Twitch is the shape required of Twitch EventSub chat message events. +// Fields that Kaiyan does not need are dropped for efficiency. +type Twitch struct { + // Broadcaster is the broadcaster user ID. + Broadcaster string `json:"broadcaster_user_id"` + // Chatter is the user ID of the user who sent the message. + Chatter string `json:"chatter_user_id"` + // ID is the message ID. + ID string `json:"message_id"` + // Message is the message content. + Message TwitchContent `json:"message"` + // SourceBroadcaster is the user ID of the broadcaster whose chat was the + // one to which this message was sent originally, in the case of shared chat. + // If it is not a shared chat message, then this is the empty string. + SourceBroadcaster NullableString `json:"source_broadcaster_user_id"` +} + +// TwitchContent is the content of a Twitch message event. +// Fields that Kaiyan does not need are dropped for efficiency. +type TwitchContent struct { + Text string `json:"text"` +} diff --git a/ingest/message_test.go b/ingest/message_test.go new file mode 100644 index 0000000..05fcfd0 --- /dev/null +++ b/ingest/message_test.go @@ -0,0 +1,60 @@ +package ingest_test + +import ( + _ "embed" + "testing" + + "github.com/go-json-experiment/json" + "github.com/google/go-cmp/cmp" + + "git.sunturtle.xyz/zephyr/kaiyan/ingest" + "git.sunturtle.xyz/zephyr/kaiyan/twitch" +) + +func TestTwitchDecode(t *testing.T) { + cases := []struct { + name string + json string + want ingest.Twitch + }{ + { + name: "message", + json: twitchMessage, + want: ingest.Twitch{ + Broadcaster: "1971641", + Chatter: "4145994", + ID: "cc106a89-1814-919d-454c-f4f2f970aae7", + Message: ingest.TwitchContent{Text: "Hi chat"}, + }, + }, + { + name: "shared", + json: twitchMessageShared, + want: ingest.Twitch{ + Broadcaster: "1971641", + Chatter: "4145994", + ID: "cc106a89-1814-919d-454c-f4f2f970aae7", + Message: ingest.TwitchContent{Text: "Hi chat"}, + SourceBroadcaster: "112233", + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var got twitch.EventSub[ingest.Twitch] + err := json.Unmarshal([]byte(c.json), &got) + if err != nil { + t.Errorf("couldn't decode message: %v", err) + } + if diff := cmp.Diff(c.want, got.Event); diff != "" { + t.Errorf("wrong result (-want/+got):\n%s", diff) + } + }) + } +} + +//go:embed testdata/twitch-message.json +var twitchMessage string + +//go:embed testdata/twitch-message-shared.json +var twitchMessageShared string diff --git a/ingest/testdata/twitch-message-shared.json b/ingest/testdata/twitch-message-shared.json new file mode 100644 index 0000000..48f8901 --- /dev/null +++ b/ingest/testdata/twitch-message-shared.json @@ -0,0 +1,72 @@ +{ + "subscription": { + "id": "0b7f3361-672b-4d39-b307-dd5b576c9b27", + "status": "enabled", + "type": "channel.chat.message", + "version": "1", + "condition": { + "broadcaster_user_id": "1971641", + "user_id": "2914196" + }, + "transport": { + "method": "websocket", + "session_id": "AgoQHR3s6Mb4T8GFB1l3DlPfiRIGY2VsbC1h" + }, + "created_at": "2023-11-06T18:11:47.492253549Z", + "cost": 0 + }, + "event": { + "broadcaster_user_id": "1971641", + "broadcaster_user_login": "streamer", + "broadcaster_user_name": "streamer", + "chatter_user_id": "4145994", + "chatter_user_login": "viewer32", + "chatter_user_name": "viewer32", + "message_id": "cc106a89-1814-919d-454c-f4f2f970aae7", + "message": { + "text": "Hi chat", + "fragments": [ + { + "type": "text", + "text": "Hi chat", + "cheermote": null, + "emote": null, + "mention": null + } + ] + }, + "color": "#00FF7F", + "badges": [ + { + "set_id": "moderator", + "id": "1", + "info": "" + }, + { + "set_id": "subscriber", + "id": "12", + "info": "16" + }, + { + "set_id": "sub-gifter", + "id": "1", + "info": "" + } + ], + "message_type": "text", + "cheer": null, + "reply": null, + "channel_points_custom_reward_id": null, + "source_broadcaster_user_id": "112233", + "source_broadcaster_user_login": "streamer33", + "source_broadcaster_user_name": "streamer33", + "source_message_id": "e03f6d5d-8ec8-4c63-b473-9e5fe61e289b", + "source_badges": [ + { + "set_id": "subscriber", + "id": "3", + "info": "3" + } + ] + } +} \ No newline at end of file diff --git a/ingest/testdata/twitch-message.json b/ingest/testdata/twitch-message.json new file mode 100644 index 0000000..d75d0c9 --- /dev/null +++ b/ingest/testdata/twitch-message.json @@ -0,0 +1,66 @@ +{ + "subscription": { + "id": "0b7f3361-672b-4d39-b307-dd5b576c9b27", + "status": "enabled", + "type": "channel.chat.message", + "version": "1", + "condition": { + "broadcaster_user_id": "1971641", + "user_id": "2914196" + }, + "transport": { + "method": "websocket", + "session_id": "AgoQHR3s6Mb4T8GFB1l3DlPfiRIGY2VsbC1h" + }, + "created_at": "2023-11-06T18:11:47.492253549Z", + "cost": 0 + }, + "event": { + "broadcaster_user_id": "1971641", + "broadcaster_user_login": "streamer", + "broadcaster_user_name": "streamer", + "chatter_user_id": "4145994", + "chatter_user_login": "viewer32", + "chatter_user_name": "viewer32", + "message_id": "cc106a89-1814-919d-454c-f4f2f970aae7", + "message": { + "text": "Hi chat", + "fragments": [ + { + "type": "text", + "text": "Hi chat", + "cheermote": null, + "emote": null, + "mention": null + } + ] + }, + "color": "#00FF7F", + "badges": [ + { + "set_id": "moderator", + "id": "1", + "info": "" + }, + { + "set_id": "subscriber", + "id": "12", + "info": "16" + }, + { + "set_id": "sub-gifter", + "id": "1", + "info": "" + } + ], + "message_type": "text", + "cheer": null, + "reply": null, + "channel_points_custom_reward_id": null, + "source_broadcaster_user_id": null, + "source_broadcaster_user_login": null, + "source_broadcaster_user_name": null, + "source_message_id": null, + "source_badges": null + } +} \ No newline at end of file diff --git a/ingest/wire.go b/ingest/wire.go new file mode 100644 index 0000000..f810220 --- /dev/null +++ b/ingest/wire.go @@ -0,0 +1,31 @@ +package ingest + +import ( + "fmt" + "io" + + "github.com/go-json-experiment/json/jsontext" +) + +// NullableString is a string that decodes JSON null as the empty string. +type NullableString string + +func (n *NullableString) UnmarshalJSONFrom(d *jsontext.Decoder) error { + t, err := d.ReadToken() + switch err { + case nil: // do nothing + case io.EOF: + return io.ErrUnexpectedEOF + default: + return err + } + switch t.Kind() { + case 'n': + *n = "" + case '"': + *n = NullableString(t.String()) + default: + return fmt.Errorf("invalid token for nullable string %q", t.String()) + } + return nil +} diff --git a/ingest/wire_test.go b/ingest/wire_test.go new file mode 100644 index 0000000..a8bcd99 --- /dev/null +++ b/ingest/wire_test.go @@ -0,0 +1,45 @@ +package ingest_test + +import ( + "testing" + + "github.com/go-json-experiment/json" + + "git.sunturtle.xyz/zephyr/kaiyan/ingest" +) + +func TestNullableString(t *testing.T) { + cases := []struct { + name string + json string + want ingest.NullableString + }{ + { + name: "null", + json: "null", + want: "", + }, + { + name: "simple", + json: `"bocchi"`, + want: "bocchi", + }, + { + name: "quotes", + json: `"\"bocchi\""`, + want: `"bocchi"`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var got ingest.NullableString + err := json.Unmarshal([]byte(c.json), &got) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if got != c.want { + t.Errorf("wrong result: want %q, got %q", c.want, got) + } + }) + } +} diff --git a/twitch/eventsub.go b/twitch/eventsub.go new file mode 100644 index 0000000..a67c008 --- /dev/null +++ b/twitch/eventsub.go @@ -0,0 +1,13 @@ +package twitch + +// EventSub is the wire format of incoming payloads from Twitch EventSub. +type EventSub[Event any] struct { + // Subscription is fields relating to the EventSub subscription itself. + Subscription Subscription `json:"subscription"` + // Event is the event payload. + Event Event `json:"event"` +} + +// Subscription is the fields of an EventSub subscription +// which are relevant to Kaiyan. +type Subscription struct{}