diff --git a/.gitignore b/.gitignore index e30dd0c..2bd5587 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ .idea strimertul_* build/bin -*.log \ No newline at end of file +*.log +api.json \ No newline at end of file diff --git a/README.md b/README.md index 57d5c84..e985e80 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Small broadcasting suite for Twitch, includes: - Loyalty points system with redeems and community goals - Twitch chat integration with custom commands - Support for Twitch alerts and channel point redeems +- Built-in runner for simple extensions to add extra features **Note:** some technical/coding experience is currently required to be able to use this effectively, see the technical overview below for more information. diff --git a/app.go b/app.go index df34ace..29fe35d 100644 --- a/app.go +++ b/app.go @@ -4,6 +4,8 @@ import ( "context" "strconv" + "github.com/strimertul/strimertul/docs" + "git.sr.ht/~hamcha/containers/sync" "github.com/nicklaw5/helix/v2" "github.com/urfave/cli/v2" @@ -137,3 +139,7 @@ func (a *App) GetTwitchLoggedUser() (helix.User, error) { func (a *App) GetLastLogs() []LogEntry { return lastLogs.Get() } + +func (a *App) GetDocumentation() map[string]docs.KeyObject { + return docs.Keys +} diff --git a/docs/cmd/docgen/main.go b/docs/cmd/docgen/main.go new file mode 100644 index 0000000..6961426 --- /dev/null +++ b/docs/cmd/docgen/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "os" + + jsoniter "github.com/json-iterator/go" + "github.com/strimertul/strimertul/docs" +) + +func main() { + enc := jsoniter.ConfigFastest.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(docs.Keys) +} diff --git a/docs/data.go b/docs/data.go new file mode 100644 index 0000000..f918418 --- /dev/null +++ b/docs/data.go @@ -0,0 +1,94 @@ +package docs + +import ( + "fmt" + "reflect" + "strings" + + "github.com/strimertul/strimertul/docs/interfaces" +) + +type DataObject struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Kind Kind `json:"kind"` + Keys []DataObject `json:"keys,omitempty"` + Key *DataObject `json:"key,omitempty"` + Element *DataObject `json:"element,omitempty"` + EnumValues []string `json:"enumValues,omitempty"` +} + +type KeyObject struct { + Description string `json:"description"` + Schema DataObject `json:"schema"` + Tags []interfaces.KeyTag `json:"tags,omitempty"` +} + +type Kind string + +const ( + KindString Kind = "string" + KindInt Kind = "int" + KindFloat Kind = "float" + KindStruct Kind = "object" + KindBoolean Kind = "boolean" + KindEnum Kind = "enum" + KindUnknown Kind = "unknown" + KindArray Kind = "array" + KindDict Kind = "dictionary" +) + +func getKind(typ reflect.Kind) Kind { + switch typ { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uintptr: + return KindInt + case reflect.Float32, reflect.Float64: + return KindFloat + case reflect.String: + return KindString + case reflect.Bool: + return KindBoolean + case reflect.Struct: + return KindStruct + case reflect.Map: + return KindDict + case reflect.Array, reflect.Slice: + return KindArray + } + return KindUnknown +} + +func parseType(typ reflect.Type) (out DataObject) { + out.Name = typ.Name() + if enum, ok := Enums[out.Name]; ok { + out.Kind = KindEnum + for _, it := range enum.Values { + out.EnumValues = append(out.EnumValues, fmt.Sprint(it)) + } + return + } + out.Kind = getKind(typ.Kind()) + if out.Kind == KindArray || out.Kind == KindDict { + elem := parseType(typ.Elem()) + out.Element = &elem + if out.Kind == KindDict { + key := parseType(typ.Key()) + out.Key = &key + } + } + if out.Kind == KindStruct { + for index := 0; index < typ.NumField(); index++ { + field := typ.Field(index) + obj := parseType(field.Type) + if jsonName, ok := field.Tag.Lookup("json"); ok { + parts := strings.SplitN(jsonName, ",", 2) + obj.Name = parts[0] + } else { + obj.Name = field.Name + } + obj.Description = field.Tag.Get("desc") + out.Keys = append(out.Keys, obj) + } + } + return +} diff --git a/docs/init.go b/docs/init.go new file mode 100644 index 0000000..619ef00 --- /dev/null +++ b/docs/init.go @@ -0,0 +1,34 @@ +package docs + +import ( + "github.com/strimertul/strimertul/docs/interfaces" + "github.com/strimertul/strimertul/http" + "github.com/strimertul/strimertul/loyalty" + "github.com/strimertul/strimertul/twitch" + "github.com/strimertul/strimertul/utils" +) + +var ( + Enums = interfaces.EnumMap{} + Keys = map[string]KeyObject{} +) + +func addKeys(keyMap interfaces.KeyMap) { + for key, obj := range keyMap { + Keys[key] = KeyObject{ + Description: obj.Description, + Tags: obj.Tags, + Schema: parseType(obj.Type), + } + } +} + +func init() { + // Put all enums here + utils.MergeMap(Enums, twitch.Enums) + + // Put all keys here + addKeys(twitch.Keys) + addKeys(loyalty.Keys) + addKeys(http.Keys) +} diff --git a/docs/interfaces/common.go b/docs/interfaces/common.go new file mode 100644 index 0000000..df966c0 --- /dev/null +++ b/docs/interfaces/common.go @@ -0,0 +1,26 @@ +package interfaces + +import "reflect" + +type Enum struct { + Values []any +} + +type KeyDef struct { + Description string + Type reflect.Type + Tags []KeyTag +} + +type ( + EnumMap map[string]Enum // Go type-system is too prehistorical to support what I need here + KeyMap map[string]KeyDef +) + +type KeyTag string + +const ( + TagEvent KeyTag = "event" + TagRPC KeyTag = "rpc" + TagHistory KeyTag = "history" +) diff --git a/docs/twitch.md b/docs/twitch.md deleted file mode 100644 index fcfd242..0000000 --- a/docs/twitch.md +++ /dev/null @@ -1,88 +0,0 @@ -# Twitch integration - -## Configuration - -### Enable/disable Twitch integration - -The Twitch integration can be enabled/disabled via `stul-meta/modules`, see [modules.md](./modules.md) for more details. - -### Twitch integration configuration - -The Twitch integration can be configured via `twitch/config` using a JSON object like this: - -```js -{ - "enabled": bool, // Enable Twitch module (required) - "enable_bot": bool, // Enable IRC bot - "api_client_id": string, // Twitch App Client ID - "api_client_secret": string // Twitch App Client Secret -} -``` - -The IRC bot has its own configuration in `twitch/bot-config` as the following JSON object: - -```js -{ - "username": string, // Bot username, probably ignored - "oauth": string, // OAuth token - "channel": string, // Twitch channel to join - "chat_keys": bool, // True to enable chatlog keys - "chat_history": int // How many messages to save in twitch/chat-history -} -``` - -If `chat_keys` is enabled, these keys will be updated every time a new message is written in the specified channel: - -- `twitch/ev/chat-message` containing the message that was just written -- `twitch/chat-history` containing the updated list of the last N messages (N depends on `chat_history`) - -See [this page](https://github.com/strimertul/strimertul/wiki/Extending-the-bot-with-external-modules) for info on chat message schema. - -## Custom commands - -The bot supports user-defined custom commands for basic things like auto-replies, counters and shoutouts. - -The key `twitch/bot-custom-commands` contains a JSON dictionary of custom commands like the following: - -```js -{ - "!command" : { - "description": string, // Command description, for UI only - "access_level": string, // Minimum required access level, see below - "response": string, // Response - "enabled": bool // Must be true for the command to work - }, - ... -} -``` - -### Access levels - -The `access_level` property must be a string determining what kind of users can use the command, it can be one of the following: - -| Access level identifier | Minimum access level | -| ----------------------- | ------------------------- | -| `"streamer"` | Just the broadcaster | -| `"moderators"` | Moderators and up | -| `"vip"` | VIP and up | -| `"subscriber"` | Twitch subscribers and up | -| `"everyone"` | Everyone | - -Every level allows people in the upper tiers to use the command as well (eg. a VIP-only command can be used by the broadcaster and moderators). - -### Response templating - -Responses are fully functional golang templates, please refer to [text/template](https://pkg.go.dev/text/template) for a full reference on syntax and functionality. - -The following functions are available: - -- Every function in [sprig](https://masterminds.github.io/sprig/) -- [`user .`] retrieves the Twitch username of whoever typed the command -- [`param N .`] retrieves the Nth word after the command (ie. "`param 1 .`" on "`!so something awful`" would return "`something`") -- [`randomInt MIN MAX`] returns a random integer between MIN and MAX -- [`game USERNAME`] returns the current game for Twitch user USERNAME -- [`count COUNTER`] increases the counter for key `COUNTER` by 1 and returns it - -## Sending text as the bot - -Writing any string to `twitch/@send-chat-message` will send it as a message in chat from the bot's account diff --git a/http/doc.go b/http/doc.go new file mode 100644 index 0000000..c39a7ab --- /dev/null +++ b/http/doc.go @@ -0,0 +1,15 @@ +package http + +import ( + "github.com/strimertul/strimertul/docs/interfaces" + "reflect" +) + +// Documentation stuff, keep updated at all times + +var Keys = interfaces.KeyMap{ + ServerConfigKey: interfaces.KeyDef{ + Description: "General server configuration", + Type: reflect.TypeOf(ServerConfig{}), + }, +} diff --git a/loyalty/doc.go b/loyalty/doc.go new file mode 100644 index 0000000..2b22de0 --- /dev/null +++ b/loyalty/doc.go @@ -0,0 +1,47 @@ +package loyalty + +import ( + "reflect" + + "github.com/strimertul/strimertul/docs/interfaces" +) + +// Documentation stuff, keep updated at all times + +var Keys = interfaces.KeyMap{ + ConfigKey: interfaces.KeyDef{ + Description: "General configuration for the loyalty subsystem", + Type: reflect.TypeOf(Config{}), + }, + RewardsKey: interfaces.KeyDef{ + Description: "List of available rewards", + Type: reflect.TypeOf([]Reward{}), + }, + GoalsKey: interfaces.KeyDef{ + Description: "List of all goals", + Type: reflect.TypeOf([]Goal{}), + }, + PointsPrefix + "": interfaces.KeyDef{ + Description: "Point entry for a given user", + Type: reflect.TypeOf(PointsEntry{}), + }, + QueueKey: interfaces.KeyDef{ + Description: "All pending redeems", + Type: reflect.TypeOf([]Redeem{}), + }, + RedeemEvent: interfaces.KeyDef{ + Description: "On reward redeemed", + Type: reflect.TypeOf(Redeem{}), + Tags: []interfaces.KeyTag{interfaces.TagEvent}, + }, + CreateRedeemRPC: interfaces.KeyDef{ + Description: "Create a new pending redeem", + Type: reflect.TypeOf(Redeem{}), + Tags: []interfaces.KeyTag{interfaces.TagRPC}, + }, + RemoveRedeemRPC: interfaces.KeyDef{ + Description: "Remove a redeem from the queue", + Type: reflect.TypeOf(Redeem{}), + Tags: []interfaces.KeyTag{interfaces.TagRPC}, + }, +} diff --git a/twitch/bot.alerts.go b/twitch/bot.alerts.go index d7a4010..fbaeb37 100644 --- a/twitch/bot.alerts.go +++ b/twitch/bot.alerts.go @@ -20,17 +20,17 @@ const BotAlertsKey = "twitch/bot-modules/alerts/config" type eventSubNotification struct { Subscription helix.EventSubSubscription `json:"subscription"` Challenge string `json:"challenge"` - Event jsoniter.RawMessage `json:"event"` + Event jsoniter.RawMessage `json:"event" desc:"Event payload, as JSON object"` } type BotAlertsConfig struct { Follow struct { - Enabled bool `json:"enabled"` - Messages []string `json:"messages"` + Enabled bool `json:"enabled" desc:"Enable chat message alert on follow"` + Messages []string `json:"messages" desc:"List of message to write on follow, one at random will be picked"` } `json:"follow"` Subscription struct { - Enabled bool `json:"enabled"` - Messages []string `json:"messages"` + Enabled bool `json:"enabled" desc:"Enable chat message alert on subscription"` + Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"` Variations []struct { MinStreak *int `json:"min_streak,omitempty"` IsGifted *bool `json:"is_gifted,omitempty"` @@ -38,8 +38,8 @@ type BotAlertsConfig struct { } `json:"variations"` } `json:"subscription"` GiftSub struct { - Enabled bool `json:"enabled"` - Messages []string `json:"messages"` + Enabled bool `json:"enabled" desc:"Enable chat message alert on gifted subscription"` + Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"` Variations []struct { MinCumulative *int `json:"min_cumulative,omitempty"` IsAnonymous *bool `json:"is_anonymous,omitempty"` @@ -47,16 +47,16 @@ type BotAlertsConfig struct { } `json:"variations"` } `json:"gift_sub"` Raid struct { - Enabled bool `json:"enabled"` - Messages []string `json:"messages"` + Enabled bool `json:"enabled" desc:"Enable chat message alert on raid"` + Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"` Variations []struct { MinViewers *int `json:"min_viewers,omitempty"` Messages []string `json:"messages"` } `json:"variations"` } `json:"raid"` Cheer struct { - Enabled bool `json:"enabled"` - Messages []string `json:"messages"` + Enabled bool `json:"enabled" desc:"Enable chat message alert on cheer"` + Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"` Variations []struct { MinAmount *int `json:"min_amount,omitempty"` Messages []string `json:"messages"` diff --git a/twitch/bot.timer.go b/twitch/bot.timer.go index 091f82d..df90763 100644 --- a/twitch/bot.timer.go +++ b/twitch/bot.timer.go @@ -14,15 +14,24 @@ import ( const BotTimersKey = "twitch/bot-modules/timers/config" type BotTimersConfig struct { - Timers map[string]BotTimer `json:"timers"` + Timers map[string]BotTimer `json:"timers" desc:"List of timers as a dictionary"` } type BotTimer struct { - Enabled bool `json:"enabled"` // Whether the timer is enabled - Name string `json:"name"` // Timer name (must be unique) - MinimumChatActivity int `json:"minimum_chat_activity"` // Minimum chat messages in the last 5 minutes - MinimumDelay int `json:"minimum_delay"` // In seconds - Messages []string `json:"messages"` // Messages to write (randomly chosen) + // Whether the timer is enabled + Enabled bool `json:"enabled" desc:"Enable the timer"` + + // Timer name (must be unique) + Name string `json:"name" desc:"Timer name (must be unique)"` + + // Minimum chat messages in the last 5 minutes for timer to trigger + MinimumChatActivity int `json:"minimum_chat_activity" desc:"Minimum chat messages in the last 5 minutes for timer to trigger"` + + // Minimum amount of time (in seconds) that needs to pass before it triggers again + MinimumDelay int `json:"minimum_delay" desc:"Minimum amount of time (in seconds) that needs to pass before it triggers again"` + + // Messages to write (randomly chosen) + Messages []string `json:"messages" desc:"Messages to write (randomly chosen)"` } const AverageMessageWindow = 5 diff --git a/twitch/client.eventsub.go b/twitch/client.eventsub.go index d5153d1..89fe7e5 100644 --- a/twitch/client.eventsub.go +++ b/twitch/client.eventsub.go @@ -64,18 +64,21 @@ func (c *Client) connectWebsocket(userClient *helix.Client) { err = json.Unmarshal(wsMessage.Payload, &welcomeData) if err != nil { c.logger.Error("eventsub ws decode error", zap.String("message-type", wsMessage.Metadata.MessageType), zap.Error(err)) + break } c.logger.Info("eventsub ws connection established", zap.String("session-id", welcomeData.Session.Id)) // Add subscription to websocket session err = c.addSubscriptionsForSession(userClient, welcomeData.Session.Id) if err != nil { c.logger.Error("could not add subscriptions", zap.Error(err)) + break } case "session_reconnect": var reconnectData WelcomeMessagePayload err = json.Unmarshal(wsMessage.Payload, &reconnectData) if err != nil { c.logger.Error("eventsub ws decode error", zap.String("message-type", wsMessage.Metadata.MessageType), zap.Error(err)) + break } c.logger.Info("eventsub ws connection reset requested", zap.String("session-id", reconnectData.Session.Id), zap.String("reconnect-url", reconnectData.Session.ReconnectUrl)) @@ -83,6 +86,7 @@ func (c *Client) connectWebsocket(userClient *helix.Client) { newConnection, _, err := websocket.DefaultDialer.Dial(reconnectData.Session.ReconnectUrl, nil) if err != nil { c.logger.Error("eventsub ws reconnect error", zap.Error(err)) + break } else { _ = connection.Close() connection = newConnection diff --git a/twitch/data.go b/twitch/data.go index 758f2e4..bc6a3f5 100644 --- a/twitch/data.go +++ b/twitch/data.go @@ -4,22 +4,38 @@ const CallbackRoute = "/twitch/callback" const ConfigKey = "twitch/config" +// Config is the general configuration for the Twitch subsystem type Config struct { - Enabled bool `json:"enabled"` - EnableBot bool `json:"enable_bot"` - APIClientID string `json:"api_client_id"` - APIClientSecret string `json:"api_client_secret"` + // Enable subsystem + Enabled bool `json:"enabled" desc:"Enable subsystem"` + + // Enable the chatbot + EnableBot bool `json:"enable_bot" desc:"Enable the chatbot"` + + // Twitch API App Client ID + APIClientID string `json:"api_client_id" desc:"Twitch API App Client ID"` + + // Twitch API App Client Secret + APIClientSecret string `json:"api_client_secret" desc:"Twitch API App Client Secret"` } const StreamInfoKey = "twitch/stream-info" const BotConfigKey = "twitch/bot-config" +// BotConfig is the general configuration for the Twitch chatbot type BotConfig struct { - Username string `json:"username"` - Token string `json:"oauth"` - Channel string `json:"channel"` - ChatHistory int `json:"chat_history"` + // Chatbot username (for internal use, ignored by Twitch) + Username string `json:"username" desc:"Chatbot username (for internal use, ignored by Twitch)"` + + // OAuth key for IRC authentication + Token string `json:"oauth" desc:"OAuth key for IRC authentication"` + + // Twitch channel to join and use + Channel string `json:"channel" desc:"Twitch channel to join and use"` + + // How many messages to keep in twitch/chat-history + ChatHistory int `json:"chat_history" desc:"How many messages to keep in twitch/chat-history"` } const ( @@ -28,11 +44,19 @@ const ( ChatActivityKey = "twitch/chat-activity" ) +// BotCustomCommand is a definition of a custom command of the chatbot type BotCustomCommand struct { - Description string `json:"description"` - AccessLevel AccessLevelType `json:"access_level"` - Response string `json:"response"` - Enabled bool `json:"enabled"` + // Command description + Description string `json:"description" desc:"Command description"` + + // Minimum access level needed to use the command + AccessLevel AccessLevelType `json:"access_level" desc:"Minimum access level needed to use the command"` + + // Response template (in Go templating format) + Response string `json:"response" desc:"Response template (in Go templating format)"` + + // Is the command enabled? + Enabled bool `json:"enabled" desc:"Is the command enabled?"` } const CustomCommandsKey = "twitch/bot-custom-commands" diff --git a/twitch/doc.go b/twitch/doc.go new file mode 100644 index 0000000..851a6da --- /dev/null +++ b/twitch/doc.go @@ -0,0 +1,83 @@ +package twitch + +import ( + "reflect" + + irc "github.com/gempir/go-twitch-irc/v4" + "github.com/nicklaw5/helix/v2" + "github.com/strimertul/strimertul/docs/interfaces" +) + +// Documentation stuff, keep updated at all times + +var Keys = interfaces.KeyMap{ + ConfigKey: interfaces.KeyDef{ + Description: "General configuration for the Twitch subsystem", + Type: reflect.TypeOf(Config{}), + }, + StreamInfoKey: interfaces.KeyDef{ + Description: "List of active twitch streams (1 element if live, 0 otherwise)", + Type: reflect.TypeOf([]helix.Stream{}), + }, + BotConfigKey: interfaces.KeyDef{ + Description: "General configuration for the Twitch chatbot", + Type: reflect.TypeOf(BotConfig{}), + }, + ChatEventKey: interfaces.KeyDef{ + Description: "On chat message received", + Type: reflect.TypeOf(irc.PrivateMessage{}), + Tags: []interfaces.KeyTag{interfaces.TagEvent}, + }, + ChatHistoryKey: interfaces.KeyDef{ + Description: "Last chat messages received", + Type: reflect.TypeOf([]irc.PrivateMessage{}), + Tags: []interfaces.KeyTag{interfaces.TagHistory}, + }, + ChatActivityKey: interfaces.KeyDef{ + Description: "Number of chat messages in the last minute", + Type: reflect.TypeOf(0), + }, + CustomCommandsKey: interfaces.KeyDef{ + Description: "Chatbot custom commands", + Type: reflect.TypeOf(map[string]BotCustomCommand{}), + }, + AuthKey: interfaces.KeyDef{ + Description: "User access token for the twitch subsystem", + Type: reflect.TypeOf(AuthResponse{}), + }, + EventSubEventKey: interfaces.KeyDef{ + Description: "On Eventsub event received", + Type: reflect.TypeOf(NotificationMessagePayload{}), + Tags: []interfaces.KeyTag{interfaces.TagEvent}, + }, + EventSubHistoryKey: interfaces.KeyDef{ + Description: "Last eventsub notifications received", + Type: reflect.TypeOf([]NotificationMessagePayload{}), + Tags: []interfaces.KeyTag{interfaces.TagHistory}, + }, + BotAlertsKey: interfaces.KeyDef{ + Description: "Configuration of chat bot alerts", + Type: reflect.TypeOf(BotAlertsConfig{}), + }, + BotTimersKey: interfaces.KeyDef{ + Description: "Configuration of chat bot timers", + Type: reflect.TypeOf(BotTimersConfig{}), + }, + WriteMessageRPC: interfaces.KeyDef{ + Description: "Send plain text chat message", + Type: reflect.TypeOf(""), + Tags: []interfaces.KeyTag{interfaces.TagRPC}, + }, +} + +var Enums = interfaces.EnumMap{ + "AccessLevelType": interfaces.Enum{ + Values: []any{ + ALTEveryone, + ALTSubscribers, + ALTVIP, + ALTModerators, + ALTStreamer, + }, + }, +}