diff --git a/cmd/events.go b/cmd/events.go index 53006c6c..94ba765c 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -10,10 +10,11 @@ import ( "github.com/spf13/cobra" "github.com/twitchdev/twitch-cli/internal/events" - "github.com/twitchdev/twitch-cli/internal/events/mock_wss_server" "github.com/twitchdev/twitch-cli/internal/events/trigger" "github.com/twitchdev/twitch-cli/internal/events/types" "github.com/twitchdev/twitch-cli/internal/events/verify" + "github.com/twitchdev/twitch-cli/internal/events/websocket" + "github.com/twitchdev/twitch-cli/internal/events/websocket/mock_server" "github.com/twitchdev/twitch-cli/internal/util" ) @@ -41,9 +42,18 @@ var ( timestamp string charityCurrentValue int charityTargetValue int - debug bool - wssReconnectTest int - sslEnabled bool + clientId string + websocketClient string +) + +// websocketCmd-specific flags +var ( + wsDebug bool + wsStrict bool + wsClient string + wsSubscription string + wsStatus string + wsReason string ) var eventCmd = &cobra.Command{ @@ -56,9 +66,9 @@ var triggerCmd = &cobra.Command{ Short: "Creates mock events that can be forwarded to a local webserver for event testing.", Long: fmt.Sprintf(`Creates mock events that can be forwarded to a local webserver for event testing. Supported: - %s`, types.AllEventTopics()), + %s`, types.AllWebhookTopics()), Args: cobra.MaximumNArgs(1), - ValidArgs: types.AllEventTopics(), + ValidArgs: types.AllWebhookTopics(), Run: triggerCmdRun, Example: `twitch event trigger subscribe`, Aliases: []string{ @@ -71,9 +81,9 @@ var verifyCmd = &cobra.Command{ Short: "Mocks the subscription verification event. Can be forwarded to a local webserver for testing.", Long: fmt.Sprintf(`Mocks the subscription verification event that can be forwarded to a local webserver for testing. Supported: - %s`, types.AllEventTopics()), + %s`, types.AllWebhookTopics()), Args: cobra.MaximumNArgs(1), - ValidArgs: types.AllEventTopics(), + ValidArgs: types.AllWebhookTopics(), Run: verifyCmdRun, Example: `twitch event verify-subscription subscribe`, Aliases: []string{ @@ -81,6 +91,24 @@ var verifyCmd = &cobra.Command{ }, } +var websocketCmd = &cobra.Command{ + Use: "websocket [action]", + Short: `Executes actions regarding the mock EventSub WebSocket server. See "twitch event websocket --help" for usage info.`, + Long: fmt.Sprintf(`Executes actions regarding the mock EventSub WebSocket server.`), + Args: cobra.MaximumNArgs(1), + Run: websocketCmdRun, + Example: fmt.Sprintf(` twitch event websocket start-server + twitch event websocket reconnect + twitch event websocket close --session=e411cc1e_a2613d4e --reason=4006 + twitch event websocket subscription --status=user_removed --subscription=82a855-fae8-93bff0`, + ), + Aliases: []string{ + "websockets", + "ws", + "wss", + }, +} + var retriggerCmd = &cobra.Command{ Use: "retrigger", Short: "Refires events based on the event ID. Can be forwarded to the local webserver for event testing.", @@ -89,24 +117,19 @@ var retriggerCmd = &cobra.Command{ } var startWebsocketServerCmd = &cobra.Command{ - Use: "start-websocket-server", - Short: `Starts a local websocket server at "ws://localhost:8080/eventsub" or at another preferred port.`, - Run: startWebsocketServerCmdRun, - Example: `twitch event start-websocket-server`, - Aliases: []string{ - "ws", - "wss", - }, + Use: "start-websocket-server", + Deprecated: `use "twitch event websocket start-server" instead.`, } func init() { rootCmd.AddCommand(eventCmd) - eventCmd.AddCommand(triggerCmd, retriggerCmd, verifyCmd, startWebsocketServerCmd) + + eventCmd.AddCommand(triggerCmd, retriggerCmd, verifyCmd, websocketCmd, startWebsocketServerCmd) // trigger flags //// flags for forwarding functionality/changing payloads - triggerCmd.Flags().StringVarP(&forwardAddress, "forward-address", "F", "", "Forward address for mock event.") - triggerCmd.Flags().StringVarP(&transport, "transport", "T", "eventsub", fmt.Sprintf("Preferred transport method for event. Defaults to /EventSub.\nSupported values: %s", events.ValidTransports())) + triggerCmd.Flags().StringVarP(&forwardAddress, "forward-address", "F", "", "Forward address for mock event (webhook only).") + triggerCmd.Flags().StringVarP(&transport, "transport", "T", "webhook", fmt.Sprintf("Preferred transport method for event. Defaults to /EventSub.\nSupported values: %s", events.ValidTransports())) triggerCmd.Flags().StringVarP(&secret, "secret", "s", "", "Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length.") // trigger flags @@ -128,26 +151,35 @@ func init() { triggerCmd.Flags().StringVar(×tamp, "timestamp", "", "Sets the timestamp to be used in payloads and headers. Must be in RFC3339Nano format.") triggerCmd.Flags().IntVar(&charityCurrentValue, "charity-current-value", 0, "Only used for \"charity-*\" events. Manually set the current dollar value for charity events.") triggerCmd.Flags().IntVar(&charityTargetValue, "charity-target-value", 1500000, "Only used for \"charity-*\" events. Manually set the target dollar value for charity events.") + triggerCmd.Flags().StringVar(&clientId, "client-id", "", "Manually set the Client ID used in revoke, grant, and bits transaction events.") + triggerCmd.Flags().StringVar(&websocketClient, "session", "", "Defines a specific websocket client/session to forward an event to. Used only with \"websocket\" transport.") // retrigger flags - retriggerCmd.Flags().StringVarP(&forwardAddress, "forward-address", "F", "", "Forward address for mock event.") + retriggerCmd.Flags().StringVarP(&forwardAddress, "forward-address", "F", "", "Forward address for mock event (webhook only).") retriggerCmd.Flags().StringVarP(&eventID, "id", "i", "", "ID of the event to be refired.") retriggerCmd.Flags().StringVarP(&secret, "secret", "s", "", "Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length.") retriggerCmd.MarkFlagRequired("id") // verify-subscription flags - verifyCmd.Flags().StringVarP(&forwardAddress, "forward-address", "F", "", "Forward address for mock event.") - verifyCmd.Flags().StringVarP(&transport, "transport", "T", "eventsub", fmt.Sprintf("Preferred transport method for event. Defaults to EventSub.\nSupported values: %s", events.ValidTransports())) + verifyCmd.Flags().StringVarP(&forwardAddress, "forward-address", "F", "", "Forward address for mock event (webhook only).") + verifyCmd.Flags().StringVarP(&transport, "transport", "T", "webhook", fmt.Sprintf("Preferred transport method for event. Defaults to EventSub.\nSupported values: %s", events.ValidTransports())) verifyCmd.Flags().StringVarP(&secret, "secret", "s", "", "Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length.") verifyCmd.Flags().StringVar(×tamp, "timestamp", "", "Sets the timestamp to be used in payloads and headers. Must be in RFC3339Nano format.") verifyCmd.Flags().StringVarP(&eventID, "subscription-id", "u", "", "Manually set the subscription/event ID of the event itself.") // TODO: This description will need to change with https://github.com/twitchdev/twitch-cli/issues/184 verifyCmd.MarkFlagRequired("forward-address") - // start-websocket-server flags - startWebsocketServerCmd.Flags().IntVarP(&port, "port", "p", 8080, "Defines the port that the mock EventSub websocket server will run on.") - startWebsocketServerCmd.Flags().BoolVar(&debug, "debug", false, "Set on/off for debug messages for the EventSub WebSocket server.") - startWebsocketServerCmd.Flags().BoolVar(&sslEnabled, "ssl", false, "Sets on/off for SSL. Recommended to keep 'false', as most testing does not require this.") - startWebsocketServerCmd.Flags().IntVarP(&wssReconnectTest, "reconnect", "r", -1, "Used to test WebSocket Reconnect message. Sets delay (in seconds) from first client connection until the reconnect occurs.") + // websocket flags + /// flags for start-server + websocketCmd.Flags().IntVarP(&port, "port", "p", 8080, "Defines the port that the mock EventSub websocket server will run on.") + websocketCmd.Flags().BoolVar(&wsDebug, "debug", false, "Set on/off for debug messages for the EventSub WebSocket server.") + websocketCmd.Flags().BoolVarP(&wsStrict, "require-subscription", "S", false, "Requires subscriptions for all events, and activates 10 second subscription requirement.") + + // websocket flags + /// flags for everything else + websocketCmd.Flags().StringVarP(&wsClient, "session", "s", "", "WebSocket client/session to target with your server command. Used in multiple commands.") + websocketCmd.Flags().StringVar(&wsSubscription, "subscription", "", `Subscription to target with your server command. Used with "websocket subscription".`) + websocketCmd.Flags().StringVar(&wsStatus, "status", "", `Changes the status of an existing subscription. Used with "websocket subscription".`) + websocketCmd.Flags().StringVar(&wsReason, "reason", "", `Sets the close reason when sending a Close message to the client. Used with "websocket close".`) } func triggerCmdRun(cmd *cobra.Command, args []string) { @@ -197,6 +229,8 @@ func triggerCmdRun(cmd *cobra.Command, args []string) { Timestamp: timestamp, CharityCurrentValue: charityCurrentValue, CharityTargetValue: charityTargetValue, + ClientID: clientId, + WebSocketClient: websocketClient, }) if err != nil { @@ -286,13 +320,22 @@ https://dev.twitch.tv/docs/eventsub/handling-webhook-events#processing-an-event` } } -func startWebsocketServerCmdRun(cmd *cobra.Command, args []string) { - wsStr := "ws" - if sslEnabled { - wsStr = "wss" +func websocketCmdRun(cmd *cobra.Command, args []string) { + if len(args) == 0 { + cmd.Help() + return } - log.Printf("Starting mock EventSub WebSocket servers on %v://localhost:%v/eventsub (alternate on port %v)", wsStr, port, port+1) - log.Printf("`Ctrl + C` to exit mock servers.") - mock_wss_server.StartServer(port, debug, wssReconnectTest, sslEnabled) + if args[0] == "start-server" || args[0] == "start" { + log.Printf("`Ctrl + C` to exit mock WebSocket servers.") + mock_server.StartWebsocketServer(wsDebug, port, wsStrict) + } else { + // Forward all other commands via RPC + websocket.ForwardWebsocketCommand(args[0], websocket.WebsocketCommandParameters{ + Client: wsClient, + Subscription: wsSubscription, + SubscriptionStatus: wsStatus, + CloseReason: wsReason, + }) + } } diff --git a/docs/event.md b/docs/event.md index bed8be73..f544b0f1 100644 --- a/docs/event.md +++ b/docs/event.md @@ -5,6 +5,7 @@ - [Trigger](#trigger) - [Retrigger](#retrigger) - [Verify-Subscription](#verify-subscription) + - [Websocket](#websocket) ## Description @@ -16,68 +17,85 @@ Used to either create or send mock events for use with local webhooks testing. **Args** -| Argument | Description | -|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------| -| `add-moderator` | Channel moderator add event. | -| `add-redemption` | Channel Points EventSub event for a redemption being performed. | -| `add-reward` | Channel Points EventSub event for a Custom Reward being added. | -| `ban` | Channel ban event. | -| `channel-gift` | Channel gifting event; not to be confused with the `gift` event. This event is a description of the number of gifts given by a user. | -| `cheer` | Only usable with the `eventsub` transport | -| `drop` | Drops Entitlement event. | -| `gift` | A gifted subscription event. Triggers a basic tier 1 sub. | -| `goal-begin` | Channel creator goal start event. | -| `goal-end` | Channel creator goal end event. | -| `goal-progress` | Channel creator goal progress event. | -| `grant` | Authorization grant event. | -| `hype-train-begin` | Channel hype train start event. | -| `hype-train-end` | Channel hype train end event. | -| `hype-train-progress` | Channel hype train progress event. | -| `poll-begin` | Channel poll begin event. | -| `poll-end` | Channel poll end event. | -| `poll-progress` | Channel poll progress event. | -| `prediction-begin` | Channel prediction begin event. | -| `prediction-end` | Channel prediction end event. | -| `prediction-lock` | Channel prediction lock event. | -| `prediction-progress` | Channel prediction progress event. | -| `raid` | Channel Raid event with a random viewer count. | -| `remove-moderator` | Channel moderator removal event. | -| `remove-reward` | Channel Points EventSub event for a Custom Reward being removed. | -| `revoke` | User authorization revoke event. Uses local Client as set in `twitch configure` or generates one randomly. | -| `stream-change` | Stream Changed event. | -| `streamdown` | Stream offline event. | -| `streamup` | Stream online event. | -| `subscribe-message` | Subscription Message event. | -| `subscribe` | A standard subscription event. Triggers a basic tier 1 sub. | -| `transaction` | Bits in Extensions transactions events. | -| `unban` | Channel unban event. | -| `unsubscribe` | A standard unsubscribe event. Triggers a basic tier 1 sub. | -| `update-redemption` | Channel Points EventSub event for a redemption being updated. | -| `update-reward` | Channel Points EventSub event for a Custom Reward being updated. | -| `user.update` | A user updates their account. [User Update](https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#user-update) | +This command can take either the Event or Alias listed as an argument. It is preferred that you work with the Event, but for backwards compatibility Aliases still work. + +| Event | Alias | Description | +|----------------------------------------------------------|-----------------------|-------------| +| `channel.ban` | `ban` | Channel ban event. | +| `channel.channel_points_custom_reward.add` | `add-reward` | Channel Points event for a Custom Reward being added. | +| `channel.channel_points_custom_reward.remove` | `remove-reward` | Channel Points event for a Custom Reward being removed. | +| `channel.channel_points_custom_reward.update` | `update-reward` | Channel Points event for a Custom Reward being updated. | +| `channel.channel_points_custom_reward_redemption.add` | `add-redemption` | Channel Points EventSub event for a redemption being performed. | +| `channel.channel_points_custom_reward_redemption.update` | `add-update` | Channel Points EventSub event for a redemption being performed. | +| `channel.charity_campaign.donate` | `charity-donate` | Charity campaign donation occurance event. | +| `channel.charity_campaign.progress` | `charity-progress` | Charity campaign progress event. | +| `channel.charity_campaign.start` | `charity-start` | Charity campaign start event. | +| `channel.charity_campaign.stop` | `charity-stop` | Charity campaign stop event. | +| `channel.cheer` | `cheer` | Channel event for receiving cheers. | +| `channel.follow` | `follow` | Channel event for receiving a follow. | +| `channel.goal.begin` | `goal-begin` | Channel creator goal start event. | +| `channel.goal.end` | `goal-end` | Channel creator goal end event. | +| `channel.goal.progress` | `goal-progress` | Channel creator goal progress event. | +| `channel.hype_train.begin` | `hype-train-begin` | Channel hype train start event. | +| `channel.hype_train.end` | `hype-train-end` | Channel hype train start event. | +| `channel.hype_train.progress` | `hype-train-progress` | Channel hype train start event. | +| `channel.moderator.add` | `add-moderator` | Channel moderator add event. | +| `channel.moderator.remove` | `remove-moderator` | Channel moderator removal event. | +| `channel.poll.begin` | `poll-begin` | Channel poll begin event. | +| `channel.poll.end` | `poll-end` | Channel poll end event. | +| `channel.poll.progress` | `poll-progress` | Channel poll progress event. | +| `channel.prediction.begin` | `prediction-begin` | Channel prediction begin event. | +| `channel.prediction.end` | `prediction-end` | Channel prediction end event. | +| `channel.prediction.lock` | `prediction-lock` | Channel prediction lock event. | +| `channel.prediction.progress` | `prediction-progress` | Channel prediction progress event. | +| `channel.raid` | `raid` | Channel raid event with a random viewer count. | +| `channel.shield_mode.begin` | `shield-mode-begin` | Channel Shield Mode activate event. | +| `channel.shield_mode.end` | `shield-mode-end` | Channel Shield Mode deactivate event. | +| `channel.shoutout.create` | `shoutout-create` | Channel shoutout created event. This is for outgoing shoutouts, from your channel to another. | +| `channel.shoutout.receive` | `shoutout-received` | Channel shoutout created event. This is for incoming shoutouts, to your channel from anothers. | +| `channel.subscribe` | `subscribe` | A standard subscription event. Triggers a basic tier 1 sub, but can be flexible with --tier | +| `channel.subscribe` | `gift` | A gifted subscription event. Triggers a basic tier 1 sub, but can be flexible with --tier | +| `channel.subscription.end` | `unsubscribe` | A standard subscription end event. Triggers a basic tier 1 sub, but can be flexible with --tier | +| `channel.subscription.gift` | `channel-gift` | Channel gifting event; not to be confused with the `gift` event. This event is a description of the number of gifts given by a user. | +| `channel.subscription.message` | `subscribe-message` | Subscription Message event. | +| `channel.unban` | `unban` | Channel unban event. | +| `channel.update` | `stream-change` | Channel update event. When a broadcaster updates channel properties. | +| `drop.entitlement.grant` | `drop` | Drop Entitlement event. | +| `extension.bits_transaction.create` | `transaction` | Bits in Extensions transactions events. | +| `stream.offline` | `streamdown` | Stream offline event. | +| `stream.online` | `streamup` | Stream online event. | +| `user.authorization.grant` | `grant` | Authorization grant event. | +| `user.authorization.revoke` | `revoke` | User authorization revoke event. Uses local Client as set in `twitch configure` or generates one randomly. | **Flags** -| Flag | Shorthand | Description | Example | Required? (Y/N) | -|---------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------|-----------------| -| `--forward-address` | `-F` | Web server address for where to send mock events. | `-F https://localhost:8080` | N | -| `--transport` | `-T` | The method used to send events. Default is `eventsub`. | `-T eventsub` | N | -| `--to-user` | `-t` | Denotes the receiver's TUID of the event, usually the broadcaster. | `-t 44635596` | N | -| `--from-user` | `-f` | Denotes the sender's TUID of the event, for example the user that follows another user or the subscriber to a broadcaster. | `-f 44635596` | N | -| `--gift-user` | `-g` | Used only for subcription-based events, denotes the gifting user ID | `-g 44635596` | N | -| `--secret` | `-s` | Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length. | `-s testsecret` | N | -| `--count` | `-c` | Count of events to fire. This can be used to simulate an influx of events. | `-c 100` | N | -| `--anonymous` | `-a` | If the event is anonymous. Only applies to `gift` and `cheer` events. | `-a` | N | -| `--status` | `-S` | Status of the event object, currently applies to channel points redemptions. | `-S fulfilled` | N | -| `--item-id` | `-i` | Manually set the ID of the event payload item (for example the reward ID in redemption events or game in stream events). | `-i 032e4a6c-4aef-11eb-a9f5-1f703d1f0b92` | N | -| `--item-name` | `-n` | Manually set the name of the event payload item (for example the reward ID in redemption events or game name in stream events). | `-n "Science & Technology"` | N | -| `--cost` | `-C` | Amount of bits or channel points redeemed/used in the event. | `-C 250` | N | -| `--description` | `-d` | Title the stream should be updated/started with. | `-d Awesome new title!` | N | -| `--game-id` | `-G` | Game ID for Drop or other relevant events. | `-G 1234` | N | -| `--tier` | | Tier of the subscription. | `--tier 3000` | N | +| Flag | Shorthand | Description | Example | Required? (Y/N) | +|---------------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------|-----------------| +| `--anonymous` | `-a` | Denotes if the event is anonymous. Only applies to Gift and Sub events. | `-a` | N | +| `--charity-current-value` | | For charity events, manually set the charity dollar value. | `--charity-current-value 11000` | N | +| `--charity-target-value` | | For charity events, manually set the charity dollar value. | `--charity-current-value 23400` | N | +| `--client-id` | | Manually set the Client ID used for revoke, grant, and bits transactions. | `--client-id 4ofh8m0706jqpholgk00u3xvb4spct` | N | +| `--cost` | `-C` | Amount of bits or channel points redeemed/used in the event. | `-C 250` | N | +| `--count` | `-c` | Count of events to fire. This can be used to simulate an influx of events. | `-c 100` | N | +| `--description` | `-d` | Title the stream should be updated/started with. | `-d Awesome new title!` | N | +| `--event-status` | `-S` | Status of the Event object (.event.status in JSON); Currently applies to channel points redemptions. | `-S fulfilled` | N | +| `--forward-address` | `-F` | Web server address for where to send mock events. | `-F https://localhost:8080` | N | +| `--from-user` | `-f` | Denotes the sender's TUID of the event, for example the user that follows another user or the subscriber to a broadcaster. | `-f 44635596` | N | +| `--game-id` | `-G` | Game ID for Drop or other relevant events. | `-G 1234` | N | +| `--gift-user` | `-g` | Used only for subcription-based events, denotes the gifting user ID. | `-g 44635596` | N | +| `--item-id` | `-i` | Manually set the ID of the event payload item (for example the reward ID in redemption events or game in stream events). | `-i 032e4a6c-4aef-11eb-a9f5-1f703d1f0b92` | N | +| `--item-name` | `-n` | Manually set the name of the event payload item (for example the reward ID in redemption events or game name in stream events). | `-n "Science & Technology"` | N | +| `--secret` | `-s` | Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length. | `-s testsecret` | N | +| `--session` | | WebSocket session to target. Only used when forwarding to WebSocket servers with --transport=websocket | `--session e411cc1e_a2613d4e` | N | +| `--subscription-id` | `-u` | Manually set the subscription/event ID of the event itself. | `-u 5d3aed06-d019-11ed-afa1-0242ac120002` | N | +| `--subscription-status` | `-r` | Status of the Subscription object (.subscription.status in JSON). Defaults to "enabled" | `-r revoked` | N | +| `--tier` | | Tier of the subscription. | `--tier 3000` | N | +| `--timestamp` | | Sets the timestamp to be used in payloads and headers. Must be in RFC3339Nano format. | `--timestamp 2017-04-13T14:34:23` | N | +| `--to-user` | `-t` | Denotes the receiver's TUID of the event, usually the broadcaster. | `-t 44635596` | N | +| `--transport` | `-T` | The method used to send events. Can either be `webhook` or `websocket`. Default is `webhook`. | `-T webhook` | N | ```sh @@ -126,45 +144,7 @@ Allows you to test if your webserver responds to subscription requests properly. **Args** -| Argument | Description | -|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------| -| `add-moderator` | Channel moderator add event. | -| `add-redemption` | Channel Points EventSub event for a redemption being performed. | -| `add-reward` | Channel Points EventSub event for a Custom Reward being added. | -| `ban` | Channel ban event. | -| `channel-gift` | Channel gifting event; not to be confused with the `gift` event. This event is a description of the number of gifts given by a user. | -| `cheer` | Only usable with the `eventsub` transport | -| `drop` | Drops Entitlement event. | -| `gift` | A gifted subscription event. Triggers a basic tier 1 sub. | -| `goal-begin` | Channel creator goal start event. | -| `goal-end` | Channel creator goal end event. | -| `goal-progress` | Channel creator goal progress event. | -| `grant` | Authorization grant event. | -| `hype-train-begin` | Channel hype train start event. | -| `hype-train-end` | Channel hype train end event. | -| `hype-train-progress` | Channel hype train progress event. | -| `poll-begin` | Channel poll begin event. | -| `poll-end` | Channel poll end event. | -| `poll-progress` | Channel poll progress event. | -| `prediction-begin` | Channel prediction begin event. | -| `prediction-end` | Channel prediction end event. | -| `prediction-lock` | Channel prediction lock event. | -| `prediction-progress` | Channel prediction progress event. | -| `raid` | Channel Raid event with a random viewer count. | -| `remove-moderator` | Channel moderator removal event. | -| `remove-reward` | Channel Points EventSub event for a Custom Reward being removed. | -| `revoke` | User authorization revoke event. Uses local Client as set in `twitch configure` or generates one randomly. | -| `stream-change` | Stream Changed event. | -| `streamdown` | Stream offline event. | -| `streamup` | Stream online event. | -| `subscribe-message` | Subscription Message event. | -| `subscribe` | A standard subscription event. Triggers a basic tier 1 sub. | -| `transaction` | Bits in Extensions transactions events. | -| `unban` | Channel unban event. | -| `unsubscribe` | A standard unsubscribe event. Triggers a basic tier 1 sub. | -| `update-redemption` | Channel Points EventSub event for a redemption being updated. | -| `update-reward` | Channel Points EventSub event for a Custom Reward being updated. | - +This command takes the same arguments as [Trigger](#trigger). **Flags** @@ -174,10 +154,45 @@ Allows you to test if your webserver responds to subscription requests properly. | `--secret` | `-s` | Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length. | `-s testsecret` | N | | `--transport` | `-T` | The method used to send events. Default is `eventsub`. | `-T eventsub` | N | - - **Examples** ```sh twitch event verify-subscription cheer -F https://localhost:8080/ // triggers a fake "cheer" EventSub subscription and validates if localhost responds properly ``` + +## WebSocket + +Provides access to a mock EventSub WebSocket server. More information can be found on [Twitch Developers documentation](https://dev.twitch.tv/docs/cli/websocket-event-command/). + +**Args** + +| Arg | Description | +|--------------|-------------| +| start-server | Attempts to start the websocket sever. Default port is 8080. | +| reconnect | Server command. Starts reconnect testing on the active WebSocket server. See documentation for more info. | +| close | Server command. Closes a specific client connection with the provided WebSocket close code. | +| subscription | Server command. Modifies an existing subscription on the WebSocket server. | + +**Flags used with start-server** +| Flag | Shorthand | Description | Example | +|--------------------------|-----------|--------------------------------------------------------------------------------------|---------------| +| `--port` | `-p` | Use to specify the port number to use in the localhost address. The default is 8080. | `--port=8080` | +| `--require-subscription` | `-S` | Prevents the server from allowing subscriptions to be forwarded unless they have a subscription created. Also enables 10 second subscription requirement when a client connects. | `-S` | + + +**Flags used with all other sub-commands** +| Flag | Shorthand | Description | Example | +|------------------|-----------|------------------------------------------------------------------------------------------------------------------------------|---------| +| `--session` | `-s` | Targets a specific client by the session_id given during its Welcome message. | `twitch event websocket close --session=e411cc1e_a2613d4e` | +| `--reason` | | Specifies the Close message code you wish to close a client’s connection with. Only used with "twitch websocket close" | `twitch event websocket close --reason=4006` | +| `--status` | | Specifies the Status code you wish to override an existing subscription’s status to. Only used with "twitch websocket close" | `twitch event websocket subscription --status=user_removed` | +| `--subscription` | | Specifies the subscription ID you wish to target. Only used with “twitch websocket subscription”. | `twitch event websocket subscription --subscription=48d3-b9a-f84c` | + +**Examples** + +```sh +twitch event websocket start-server +twitch event websocket reconnect +twitch event websocket close --session=e411cc1e_a2613d4e --reason=4006 +twitch event websocket subscription --status=user_removed --subscription=82a855-fae8-93bff0 +``` \ No newline at end of file diff --git a/go.mod b/go.mod index 5e724e82..bcf0f2d7 100755 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.18 require ( github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 - github.com/fatih/color v1.12.0 + github.com/fatih/color v1.15.0 github.com/gorilla/websocket v1.5.0 github.com/jmoiron/sqlx v1.3.4 github.com/manifoldco/promptui v0.8.0 @@ -25,8 +25,8 @@ require ( github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/magiconair/properties v1.8.5 // indirect - github.com/mattn/go-colorable v0.1.8 // indirect - github.com/mattn/go-isatty v0.0.13 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/pelletier/go-toml v1.9.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -35,7 +35,7 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.2.0 // indirect - golang.org/x/sys v0.5.0 // indirect + golang.org/x/sys v0.6.0 // indirect golang.org/x/text v0.7.0 // indirect gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 2dc1b3dd..46af993f 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.12.0 h1:mRhaKNwANqRgUBGKmnI5ZxEk7QXmjQeCcuYFMX2bfcc= github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= @@ -144,11 +146,16 @@ github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEX github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA= github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= @@ -299,8 +306,11 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= diff --git a/internal/events/event.go b/internal/events/event.go index dc38b955..32bb35d5 100644 --- a/internal/events/event.go +++ b/internal/events/event.go @@ -26,6 +26,7 @@ type MockEventParameters struct { Timestamp string CharityCurrentValue int CharityTargetValue int + ClientID string } type MockEventResponse struct { diff --git a/internal/events/mock_wss_server/server.go b/internal/events/mock_wss_server/server.go deleted file mode 100644 index 73df82f5..00000000 --- a/internal/events/mock_wss_server/server.go +++ /dev/null @@ -1,557 +0,0 @@ -package mock_wss_server - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net" - "net/http" - "os" - "os/signal" - "path/filepath" - "strconv" - "sync" - "text/template" - "time" - - "github.com/gorilla/websocket" - - "github.com/twitchdev/twitch-cli/internal/database" - "github.com/twitchdev/twitch-cli/internal/mock_api/generate" - "github.com/twitchdev/twitch-cli/internal/util" -) - -/* Minimum time between messages before the server disconnects a client. AKA, "timeout period" - * This is a const for now, but it may need to be a flag in the future. */ -const KEEPALIVE_TIMEOUT_SECONDS = 10 - -var upgrader = websocket.Upgrader{} -var debug = false - -// List of websocket servers. Limited to 2 for now, as allowing more would require rewriting reconnect stuff. -var wsServers = [2]*WebsocketServer{} - -type WebsocketServer struct { - serverId int // Int representing the ID of the server (0, 1, 2, ...) - websocketId string // UUID of the websocket. Used for subscribing via EventSub - connectionUrl string // URL used to connect to the websocket. Used for reconnect messages - connections []*WebsocketConnection // Current clients connected to this websocket - deactivatedStatus bool // Boolean used for preventing connections/messages during deactivation; Used for reconnect testing - reconnectTestTimeout int // Timeout for reconnect testing after first client connects; 0 if reconnect testing not enabled. - firstClientConnected bool // Whether or not the first client has connected (used for reconnect testing) -} - -type WebsocketConnection struct { - clientId string - Conn *websocket.Conn - mu sync.Mutex - connectedAtTimestamp string - pingLoopChan chan struct{} - kaLoopChan chan struct{} - closed bool -} - -func (wc *WebsocketConnection) SendMessage(messageType int, data []byte) error { - wc.mu.Lock() - defer wc.mu.Unlock() - return wc.Conn.WriteMessage(messageType, data) -} - -func eventsubHandle(w http.ResponseWriter, r *http.Request) { - // This next line is required to disable CORS checking. - // If we weren't returning "true", the the /debug page on the default port wouldn't be able to make calls to port+1 - upgrader.CheckOrigin = func(r *http.Request) bool { return true } - - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - log.Print("[[websocket upgrade err]] ", err) - return - } - defer conn.Close() - - // Connection sucessful. WebSocket is open. - - serverId, _ := strconv.Atoi(r.Context().Value("serverId").(string)) - wsSrv := wsServers[serverId] - - // Or is it? Check for websocket set to deactivated (due to reconnect), and kick them out if so - if wsSrv.deactivatedStatus { - log.Printf("Client trying to connect while websocket in reconnect timeout phase. Disconnecting them.") - conn.Close() - return - } - - // Activate reconnect testing upon first client connection - if !wsSrv.firstClientConnected { - wsSrv.firstClientConnected = true - - if wsSrv.reconnectTestTimeout >= 0 { - go func() { - log.Printf("First client connected; Reconnect testing enabled. Notices will be sent in %d seconds.", wsSrv.reconnectTestTimeout) - duration := time.Second * time.Duration(wsSrv.reconnectTestTimeout) - if duration == 0 { - duration = time.Second * 1 - } - - select { - case <-time.After(time.Second * time.Duration(wsSrv.reconnectTestTimeout)): - activateReconnectTest(r.Context()) - } - }() - } - } - - // TODO: Decline websocket if it reached 100 connections from the same application access token - - // RFC3339Nano = "2022-10-04T12:38:15.548912638Z07" ; This is used by Twitch in production - connectedAtTimestamp := time.Now().UTC().Format(time.RFC3339Nano) - conn.SetReadDeadline(time.Now().Add(time.Second * KEEPALIVE_TIMEOUT_SECONDS)) - - // Add to websocket connection list. - wc := &WebsocketConnection{ - clientId: util.RandomGUID(), - Conn: conn, - connectedAtTimestamp: connectedAtTimestamp, - closed: false, - } - wsSrv.connections = append(wsSrv.connections, wc) - printConnections(wsSrv.serverId) - - // Send "websocket_welcome" message - welcomeMsg, _ := json.Marshal( - WelcomeMessage{ - Metadata: MessageMetadata{ - MessageID: util.RandomGUID(), - MessageType: "session_welcome", - MessageTimestamp: time.Now().UTC().Format(time.RFC3339Nano), - }, - Payload: WelcomeMessagePayload{ - Session: WelcomeMessagePayloadSession{ - ID: wsSrv.websocketId, - Status: "connected", - KeepaliveTimeoutSeconds: KEEPALIVE_TIMEOUT_SECONDS, - ReconnectUrl: nil, - ConnectedAt: connectedAtTimestamp, - }, - }, - }, - ) - wc.SendMessage(websocket.TextMessage, welcomeMsg) - if debug { - log.Printf("[DEBUG] Write: %s", welcomeMsg) - } - - // TODO: Look to implement a way to shut off pings. This would be used specifically for testing the timeout feature. - // Set up ping/pong handling - pingTicker := time.NewTicker(5 * time.Second) - wc.pingLoopChan = make(chan struct{}) // Also used for keepalive - go func() { - // Set pong handler - // Weirdly, pongs are not seen as messages read by conn.ReadMessage, so we have to reset the deadline manually - conn.SetPongHandler(func(string) error { - conn.SetReadDeadline(time.Now().Add(time.Second * KEEPALIVE_TIMEOUT_SECONDS)) - return nil - }) - - // Ping loop - for { - select { - case <-wc.pingLoopChan: - pingTicker.Stop() - return - case <-pingTicker.C: - err := wc.SendMessage(websocket.PingMessage, []byte{}) - if err != nil { - onCloseConnection(wsSrv.serverId, wc) - } - } - } - }() - - // Set up keepalive loop - kaTicker := time.NewTicker(10 * time.Second) - wc.kaLoopChan = make(chan struct{}) - go func() { - for { - select { - case <-wc.kaLoopChan: - kaTicker.Stop() - case <-kaTicker.C: - keepAliveMsg, _ := json.Marshal( - KeepaliveMessage{ - Metadata: MessageMetadata{ - MessageID: util.RandomGUID(), - MessageType: "session_keepalive", - MessageTimestamp: time.Now().UTC().Format(time.RFC3339Nano), - }, - Payload: KeepaliveMessagePayload{}, - }, - ) - err := wc.SendMessage(websocket.TextMessage, keepAliveMsg) - if err != nil { - onCloseConnection(wsSrv.serverId, wc) - } - - if debug { - log.Printf("[DEBUG] Write: %s", keepAliveMsg) - } - } - } - }() - - // TODO: Read messages - for { - conn.SetReadDeadline(time.Now().Add(time.Second * KEEPALIVE_TIMEOUT_SECONDS)) - mt, message, err := conn.ReadMessage() - if err != nil { - log.Println("read:", err) - onCloseConnection(wsSrv.serverId, wc) - break - } - if debug { - log.Printf("recv: [%d] %s", mt, message) - } - /*err = wc.SendMessage(mt, message) - if err != nil { - log.Println("write:", err) - onCloseConnection(websocketId) - break - }*/ - } -} - -func onCloseConnection(serverId int, wc *WebsocketConnection) { - // Close ping loop chan - if !wc.closed { - close(wc.pingLoopChan) - close(wc.kaLoopChan) - } - wc.closed = true - - wsSrv := wsServers[serverId] - - // Remove from list - c := 0 - for i := 0; i < len(wsSrv.connections); i++ { - if wsSrv.connections[i].Conn == wsSrv.connections[i].Conn { - log.Printf("Disconnected websocket %s", wsSrv.connections[i].clientId) - c = i - break - } - } - wsSrv.connections = append(wsSrv.connections[:c], wsSrv.connections[c+1:]...) - - printConnections(wsSrv.serverId) -} - -func printConnections(serverId int) { - currentConnections := "" - wsSrv := wsServers[serverId] - for _, s := range wsSrv.connections { - currentConnections += s.clientId + ", " - } - if currentConnections != "" { - currentConnections = string(currentConnections[:len(currentConnections)-2]) - } - log.Printf("[Server %v] Connections: (%d) [ %s ]", serverId, len(wsSrv.connections), currentConnections) -} - -func activateReconnectTest(ctx context.Context) { - timer := 30 // 30 seconds, as used by Twitch - - serverId, _ := strconv.Atoi(ctx.Value("serverId").(string)) - wsSrv := wsServers[serverId] - - log.Printf("Terminating server %v with reconnect notice. Disallowing connections in 30 seconds.", serverId) - - var wsAltSrv *WebsocketServer - if serverId == 0 { - wsAltSrv = wsServers[1] - } else { - wsAltSrv = wsServers[0] - } - - // Stop processing new messages - wsSrv.deactivatedStatus = true // This server - wsAltSrv.deactivatedStatus = false // Other server; We gotta turn it on to accept connections and whatnot - log.Printf("Server \"not accepting connections\" status: [Server 0: %v, Server 1: %v]", wsServers[0].deactivatedStatus, wsServers[1].deactivatedStatus) - - if debug { - log.Printf("Connections at time of close: %v", len(wsSrv.connections)) - } - - // Send reconnect notices - for _, c := range wsSrv.connections { - reconnectMsg, _ := json.Marshal( - ReconnectMessage{ - Metadata: MessageMetadata{ - MessageID: util.RandomGUID(), - MessageType: "session_reconnect", - MessageTimestamp: time.Now().UTC().Format(time.RFC3339Nano), - }, - Payload: ReconnectMessagePayload{ - Session: ReconnectMessagePayloadSession{ - ID: wsSrv.websocketId, - Status: "reconnecting", - KeepaliveTimeoutSeconds: nil, - ReconnectUrl: wsAltSrv.connectionUrl, - ConnectedAt: c.connectedAtTimestamp, - }, - }, - }, - ) - - err := c.SendMessage(websocket.TextMessage, reconnectMsg) - if err != nil { - log.Printf("ERROR (clientId %v): %v", c.clientId, err) - } else { - log.Printf("Sent reconnect notice to %v", c.clientId) - } - } - - log.Printf("Reconnect notices sent for server %v", serverId) - log.Printf("Use this new URL for connections: %v", wsAltSrv.connectionUrl) - - // TODO: Transfer subscriptions to the other websocket server. - - // Wait 30 seconds to close out, just like Twitch production EventSub websockets - select { - case <-time.After(time.Second * time.Duration(timer)): - for _, c := range wsSrv.connections { - c.Conn.Close() - } - - if debug { - log.Printf("[DEBUG] Resetting websocket ID on server %v", wsSrv.websocketId) - } - wsSrv.websocketId = util.RandomGUID() // Change websocket ID - } -} - -func StartServer(port int, enableDebug bool, reconnectTestTimer int, sslEnabled bool) { - debug = enableDebug - - m := http.NewServeMux() - - // Server IDs are 0-index, so we don't have to do any math when calling their referenced arrays - ctx1 := context.Background() - ctx1 = context.WithValue(ctx1, "serverId", "0") - ctx2 := context.Background() - ctx2 = context.WithValue(ctx2, "serverId", "1") - - db, err := database.NewConnection() - if err != nil { - log.Fatalf("Error connecting to database: %v", err.Error()) - return - } - - firstTime := db.IsFirstRun() - - if firstTime { - err := generate.Generate(25) - if err != nil { - log.Fatal(err) - } - } - - ctx1 = context.WithValue(ctx1, "db", db) - ctx2 = context.WithValue(ctx2, "db", db) - - wsSrv1Url := fmt.Sprintf("ws://localhost:%v/eventsub", port) - wsSrv2Url := fmt.Sprintf("ws://localhost:%v/eventsub", port+1) - - // Change to WSS if SSL is enabled via flag - if sslEnabled { - wsSrv1Url = fmt.Sprintf("wss://localhost:%v/eventsub", port) - wsSrv2Url = fmt.Sprintf("wss://localhost:%v/eventsub", port+1) - } - - wsServers[0] = &WebsocketServer{ - serverId: 0, - websocketId: util.RandomGUID(), - connectionUrl: wsSrv1Url, - connections: []*WebsocketConnection{}, - deactivatedStatus: false, - reconnectTestTimeout: reconnectTestTimer, - firstClientConnected: false, - } - wsServers[1] = &WebsocketServer{ - serverId: 1, - websocketId: util.RandomGUID(), - connectionUrl: wsSrv2Url, - connections: []*WebsocketConnection{}, - deactivatedStatus: true, // 2nd server is deactivated by default. Will reactivate for reconnect testing. - reconnectTestTimeout: -1, // No reconnect testing - firstClientConnected: false, - } - - RegisterHandlers(m) - - stop := make(chan os.Signal) - signal.Notify(stop, os.Interrupt) - - s1 := StartIndividualServer(port, sslEnabled, m, ctx1, false) - s2 := StartIndividualServer(port+1, sslEnabled, m, ctx2, true) // Start second server, at a port above. - - <-stop // Wait for ctrl + c - - log.Print("Shutting down EventSub WebSocket server ...\n") - db.DB.Close() - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(time.Second*5)) - defer cancel() - - if err1 := s1.Shutdown(ctx); err1 != nil { - log.Fatal(err1) - } - - if err2 := s2.Shutdown(ctx); err2 != nil { - log.Fatal(err2) - } -} - -func StartIndividualServer(port int, sslEnabled bool, m *http.ServeMux, ctx context.Context, alternateServer bool) http.Server { - s := http.Server{ - Addr: fmt.Sprintf(":%v", port), - Handler: m, - BaseContext: func(l net.Listener) context.Context { - return ctx - }, - } - stop := make(chan os.Signal) - signal.Notify(stop, os.Interrupt) - - go func() { - if !alternateServer { - log.Printf("Mock EventSub websocket server started on port %d", port) - } else { - log.Printf("Started alternate EventSub websocket server on port %d. This one is used for reconnect testing.", port) - } - - if sslEnabled { // Open HTTP server with HTTPS support - home, _ := util.GetApplicationDir() - crtFile := filepath.Join(home, "localhost.crt") - keyFile := filepath.Join(home, "localhost.key") - - if err := s.ListenAndServeTLS(crtFile, keyFile); err != nil { - if err != http.ErrServerClosed { - log.Fatalf(`%v - ** You need to generate localhost.crt and localhost.key for this to work ** - ** Please run these commands (Note: you'll have a cert error in your web browser, but it'll still start): ** - openssl genrsa -out "%v" 2048 - openssl req -new -x509 -sha256 -key "%v" -out "%v" -days 3650`, - err, keyFile, keyFile, crtFile) - } - } - } else { // Open HTTP server without HTTPS support - if err := s.ListenAndServe(); err != nil { - if err != http.ErrServerClosed { - log.Fatalf("%v", err) - } - } - } - }() - - return s -} - -func RegisterHandlers(m *http.ServeMux) { - m.HandleFunc("/debug", debugPageHandler) - m.HandleFunc("/eventsub", eventsubHandle) -} - -func debugPageHandler(w http.ResponseWriter, r *http.Request) { - debugTemplate.Execute(w, DebugServerButtons{Server1: wsServers[0].connectionUrl, Server2: wsServers[1].connectionUrl}) -} - -// Used for template weirdness -type DebugServerButtons struct { - Server1 string - Server2 string -} - -var debugTemplate = template.Must(template.New("").Parse(` - - - - - - - - -
-

Click "Open" to create a connection to the server ({{.Server1}}), -"Send" to send a message to the server and "Close" to close the connection. -You can change the message and send multiple times. -

-

For testing the higher port ({{.Server2}}), hold ctrl OR alt and click "Open"

-
- - -

-

- -

-
-
-
- - -`)) diff --git a/internal/events/models.go b/internal/events/models.go index afadc548..9b769607 100644 --- a/internal/events/models.go +++ b/internal/events/models.go @@ -3,7 +3,7 @@ package events var transportSupported = map[string]bool{ - "websub": false, - "eventsub": true, - "websockets": false, + "websub": false, + "webhook": true, + "websocket": true, } diff --git a/internal/events/trigger/forward_event.go b/internal/events/trigger/forward_event.go index dcf94c6e..1529fedd 100644 --- a/internal/events/trigger/forward_event.go +++ b/internal/events/trigger/forward_event.go @@ -39,7 +39,7 @@ const ( ) var notificationHeaders = map[string][]header{ - models.TransportEventSub: { + models.TransportWebhook: { { HeaderName: `Twitch-Eventsub-Message-Retry`, HeaderValue: `0`, @@ -64,7 +64,7 @@ func ForwardEvent(p ForwardParamters) (*http.Response, error) { } switch p.Transport { - case models.TransportEventSub: + case models.TransportWebhook: req.Header.Set("Twitch-Eventsub-Message-Id", p.ID) req.Header.Set("Twitch-Eventsub-Subscription-Type", p.Event) req.Header.Set("Twitch-Eventsub-Subscription-Version", p.SubscriptionVersion) @@ -101,7 +101,7 @@ func getSignatureHeader(req *http.Request, id string, secret string, transport s ts, _ := time.Parse(time.RFC3339Nano, timestamp) switch transport { - case models.TransportEventSub: + case models.TransportWebhook: req.Header.Set("Twitch-Eventsub-Message-Timestamp", timestamp) prefix := ts.AppendFormat([]byte(id), time.RFC3339Nano) mac.Write(prefix) diff --git a/internal/events/trigger/retrigger_event_test.go b/internal/events/trigger/retrigger_event_test.go index 3ed1d5a1..a634721e 100644 --- a/internal/events/trigger/retrigger_event_test.go +++ b/internal/events/trigger/retrigger_event_test.go @@ -26,7 +26,7 @@ func TestRefireEvent(t *testing.T) { params := *&TriggerParameters{ Event: "gift", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, IsAnonymous: false, FromUser: "", ToUser: "", diff --git a/internal/events/trigger/trigger_event.go b/internal/events/trigger/trigger_event.go index 0dfa5a20..fe20affc 100644 --- a/internal/events/trigger/trigger_event.go +++ b/internal/events/trigger/trigger_event.go @@ -3,16 +3,21 @@ package trigger import ( + "encoding/json" + "errors" "fmt" "io/ioutil" + "net/rpc" "strings" "time" "github.com/fatih/color" + "github.com/spf13/viper" "github.com/twitchdev/twitch-cli/internal/database" "github.com/twitchdev/twitch-cli/internal/events" "github.com/twitchdev/twitch-cli/internal/events/types" "github.com/twitchdev/twitch-cli/internal/models" + rpc_handler "github.com/twitchdev/twitch-cli/internal/rpc" "github.com/twitchdev/twitch-cli/internal/util" ) @@ -40,6 +45,8 @@ type TriggerParameters struct { EventID string // Also serves as subscription ID. See https://github.com/twitchdev/twitch-cli/issues/184 CharityCurrentValue int CharityTargetValue int + ClientID string + WebSocketClient string } type TriggerResponse struct { @@ -55,6 +62,16 @@ func Fire(p TriggerParameters) (string, error) { var resp events.MockEventResponse var err error + if p.ClientID == "" { + p.ClientID = viper.GetString("ClientID") // Get from config + + if p.ClientID == "" { + // --client-id wasn't used, and config file doesn't have a Client ID set. + // Generate a randomized one + p.ClientID = util.RandomClientID() + } + } + if p.ToUser == "" { p.ToUser = util.RandomUserID() } @@ -74,8 +91,8 @@ func Fire(p TriggerParameters) (string, error) { // do nothing, these are valid values default: return "", fmt.Errorf( - `Discarding event: Invalid tier provided. - Valid values are 1000, 2000 or 3000`) + "Discarding event: Invalid tier provided.\n" + + "Valid values are 1000, 2000 or 3000") } if p.EventID == "" { @@ -115,6 +132,7 @@ https://dev.twitch.tv/docs/eventsub/handling-webhook-events#processing-an-event` Timestamp: p.Timestamp, CharityCurrentValue: p.CharityCurrentValue, CharityTargetValue: p.CharityTargetValue, + ClientID: p.ClientID, } e, err := types.GetByTriggerAndTransport(p.Event, p.Transport) @@ -122,11 +140,9 @@ https://dev.twitch.tv/docs/eventsub/handling-webhook-events#processing-an-event` return "", err } - if eventParamaters.Transport == models.TransportEventSub { - newTrigger := e.GetEventSubAlias(p.Event) - if newTrigger != "" { - eventParamaters.Trigger = newTrigger // overwrite the existing trigger with the "correct" one - } + newTrigger := e.GetEventSubAlias(p.Event) + if newTrigger != "" { + eventParamaters.Trigger = newTrigger // overwrite the existing trigger with the "correct" one } resp, err = e.GenerateEvent(eventParamaters) @@ -163,7 +179,7 @@ https://dev.twitch.tv/docs/eventsub/handling-webhook-events#processing-an-event` messageType = EventSubMessageTypeRevocation } - if p.ForwardAddress != "" { + if p.ForwardAddress != "" && strings.EqualFold(p.Transport, "webhook") { // Forwarding to an address requires Webhook, as its done via HTTP resp, err := ForwardEvent(ForwardParamters{ ID: resp.ID, Transport: p.Transport, @@ -195,5 +211,51 @@ https://dev.twitch.tv/docs/eventsub/handling-webhook-events#processing-an-event` } } + // Forward to WebSocket server via RPC + if strings.EqualFold(p.Transport, "websocket") { + client, err := rpc.DialHTTP("tcp", ":44747") + if err != nil { + return "", errors.New("Failed to dial RPC handler for WebSocket server. Is it online?\nError: " + err.Error()) + } + + var reply rpc_handler.RPCResponse + + // Modify transport + modifiedTransportJSON := models.EventsubResponse{} + err = json.Unmarshal([]byte(resp.JSON), &modifiedTransportJSON) + if err != nil { + return "", errors.New("Unexpected error unmarshling JSON before forwarding to WebSocket server: " + err.Error()) + } + modifiedTransportJSON.Subscription.Transport.Method = "websocket" + modifiedTransportJSON.Subscription.Transport.Callback = "" + modifiedTransportJSON.Subscription.Transport.SessionID = "WebSocket-Server-Will-Set" + rawModifiedTransportJSON, _ := json.Marshal(modifiedTransportJSON) + resp.JSON = rawModifiedTransportJSON + + // Trigger any EventSub subscription that's available over 1st party WebSocket connections + variables := make(map[string]string) + variables["ClientName"] = p.WebSocketClient + + args := &rpc_handler.RPCArgs{ + RPCName: "EventSubWebSocketForwardEvent", + Body: string(resp.JSON), + Variables: variables, + } + + err = client.Call("RPCHandler.ExecuteGenericRPC", args, &reply) + + // Error checking for RPC internals + if err != nil { + return "", errors.New("Failed to send via RPC to WebSocket server: " + err.Error()) + } + + // Error checking for everything else + if reply.ResponseCode == 0 { // Zero will always be success + color.New().Add(color.FgGreen).Println(fmt.Sprintf(`✔ Forwarded for use in mock EventSub WebSocket server`)) + } else { + color.New().Add(color.FgRed).Println(fmt.Sprintf(`✗ EventSub WebSocket server failed to process event: [%v] %v`, reply.DetailedInfo, reply.DetailedInfo)) + } + } + return string(resp.JSON), nil } diff --git a/internal/events/trigger/trigger_event_test.go b/internal/events/trigger/trigger_event_test.go index 0a90f1c8..4b0dd29f 100644 --- a/internal/events/trigger/trigger_event_test.go +++ b/internal/events/trigger/trigger_event_test.go @@ -24,7 +24,7 @@ func TestFire(t *testing.T) { params := *&TriggerParameters{ Event: "gift", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, IsAnonymous: false, FromUser: "", ToUser: "", @@ -44,7 +44,7 @@ func TestFire(t *testing.T) { params = *&TriggerParameters{ Event: "cheer", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, IsAnonymous: false, FromUser: "", ToUser: "", @@ -63,7 +63,7 @@ func TestFire(t *testing.T) { params = *&TriggerParameters{ Event: "follow", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, IsAnonymous: false, FromUser: "", ToUser: "", @@ -82,7 +82,7 @@ func TestFire(t *testing.T) { params = *&TriggerParameters{ Event: "cheer", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, IsAnonymous: false, FromUser: "", ToUser: "", @@ -101,7 +101,7 @@ func TestFire(t *testing.T) { params = *&TriggerParameters{ Event: "add-redemption", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, IsAnonymous: false, FromUser: "", ToUser: "", @@ -120,7 +120,7 @@ func TestFire(t *testing.T) { params = *&TriggerParameters{ Event: "add-reward", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, IsAnonymous: false, FromUser: "", ToUser: "", @@ -139,7 +139,7 @@ func TestFire(t *testing.T) { params = *&TriggerParameters{ Event: "transaction", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, IsAnonymous: false, FromUser: "", ToUser: "", diff --git a/internal/events/types/_template/_event_name.go b/internal/events/types/_template/_event_name.go index b2c92c7d..cab2638d 100644 --- a/internal/events/types/_template/_event_name.go +++ b/internal/events/types/_template/_event_name.go @@ -4,19 +4,24 @@ package event_name import ( "encoding/json" + "strings" "github.com/twitchdev/twitch-cli/internal/events" "github.com/twitchdev/twitch-cli/internal/models" ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"trigger_keyword"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "trigger_keyword": "topic_name_es", + }, + models.TransportWebSocket: { "trigger_keyword": "topic_name_es", }, } @@ -28,7 +33,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven var err error switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := &models.EventsubResponse{ // make the eventsub response (if supported) } @@ -36,6 +41,22 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven if err != nil { return events.MockEventResponse{}, err } + + // Delete event info if Subscription.Status is not set to "enabled" + if !strings.EqualFold(params.SubscriptionStatus, "enabled") { + var i interface{} + if err := json.Unmarshal([]byte(event), &i); err != nil { + return events.MockEventResponse{}, err + } + if m, ok := i.(map[string]interface{}); ok { + delete(m, "event") // Matches JSON key defined in body variable above + } + + event, err = json.Marshal(i) + if err != nil { + return events.MockEventResponse{}, err + } + } default: return events.MockEventResponse{}, nil } @@ -65,7 +86,7 @@ func (e Event) GetTopic(transport string, trigger string) string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/_template/_event_name_test.go b/internal/events/types/_template/_event_name_test.go index 9579b64f..79436123 100644 --- a/internal/events/types/_template/_event_name_test.go +++ b/internal/events/types/_template/_event_name_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "subscribe", } @@ -60,7 +60,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -69,6 +69,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "trigger_keyword") + r := Event{}.GetTopic(models.TransportWebhook, "trigger_keyword") a.NotNil(r) } diff --git a/internal/events/types/authorization_grant/grant.go b/internal/events/types/authorization_grant/grant.go new file mode 100644 index 00000000..f51c2f0b --- /dev/null +++ b/internal/events/types/authorization_grant/grant.go @@ -0,0 +1,123 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package authorization_grant + +import ( + "encoding/json" + "strings" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" +) + +var transportsSupported = map[string]bool{ + models.TransportWebhook: true, + models.TransportWebSocket: false, +} + +var triggerSupported = []string{"grant"} + +var triggerMapping = map[string]map[string]string{ + models.TransportWebhook: { + "grant": "user.authorization.grant", + }, +} + +type Event struct{} + +func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEventResponse, error) { + var event []byte + var err error + + switch params.Transport { + case models.TransportWebhook: + body := &models.AuthorizationRevokeEventSubResponse{ + Subscription: models.EventsubSubscription{ + ID: params.ID, + Status: params.SubscriptionStatus, + Type: triggerMapping[params.Transport][params.Trigger], + Version: e.SubscriptionVersion(), + Condition: models.EventsubCondition{ + ClientID: params.ClientID, + }, + Transport: models.EventsubTransport{ + Method: "webhook", + Callback: "null", + }, + Cost: 1, + CreatedAt: params.Timestamp, + }, + Event: &models.AuthorizationRevokeEvent{ + ClientID: params.ClientID, + UserID: params.FromUserID, + UserLogin: params.FromUserName, + UserName: params.FromUserName, + }, + } + event, err = json.Marshal(body) + if err != nil { + return events.MockEventResponse{}, err + } + + // Delete event info if Subscription.Status is not set to "enabled" + if !strings.EqualFold(params.SubscriptionStatus, "enabled") { + var i interface{} + if err := json.Unmarshal([]byte(event), &i); err != nil { + return events.MockEventResponse{}, err + } + if m, ok := i.(map[string]interface{}); ok { + delete(m, "event") // Matches JSON key defined in body variable above + } + + event, err = json.Marshal(i) + if err != nil { + return events.MockEventResponse{}, err + } + } + default: + return events.MockEventResponse{}, nil + } + + return events.MockEventResponse{ + ID: params.ID, + JSON: event, + FromUser: params.FromUserID, + ToUser: params.ToUserID, + }, nil +} + +func (e Event) ValidTransport(t string) bool { + return transportsSupported[t] +} + +func (e Event) ValidTrigger(t string) bool { + for _, ts := range triggerSupported { + if ts == t { + return true + } + } + return false +} +func (e Event) GetTopic(transport string, trigger string) string { + return triggerMapping[transport][trigger] +} +func (e Event) GetAllTopicsByTransport(transport string) []string { + allTopics := []string{} + for _, topic := range triggerMapping[transport] { + allTopics = append(allTopics, topic) + } + return allTopics +} +func (e Event) GetEventSubAlias(t string) string { + // check for aliases + for trigger, topic := range triggerMapping[models.TransportWebhook] { + if topic == t { + return trigger + } + } + return "" +} + +func (e Event) SubscriptionVersion() string { + return "1" +} diff --git a/internal/events/types/authorization_grant/grant_test.go b/internal/events/types/authorization_grant/grant_test.go new file mode 100644 index 00000000..8eb414bb --- /dev/null +++ b/internal/events/types/authorization_grant/grant_test.go @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package authorization_grant + +import ( + "encoding/json" + "testing" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/test_setup" +) + +var fromUser = "1234" +var toUser = "4567" + +func TestEventSub(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportWebhook, + Trigger: "subscribe", + SubscriptionStatus: "enabled", + ClientID: "1234", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + + var body models.AuthorizationRevokeEventSubResponse + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + + a.NotEmpty(body.Event.ClientID) + a.Equal(body.Event.ClientID, body.Subscription.Condition.ClientID) + a.Equal("1234", body.Event.ClientID) +} +func TestFakeTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: "fake_transport", + Trigger: "unsubscribe", + SubscriptionStatus: "enabled", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + a.Empty(r) +} +func TestValidTrigger(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTrigger("grant") + a.Equal(true, r) + + r = Event{}.ValidTrigger("fake_grant") + a.Equal(false, r) +} + +func TestValidTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTransport(models.TransportWebhook) + a.Equal(true, r) + + r = Event{}.ValidTransport("noteventsub") + a.Equal(false, r) +} +func TestGetTopic(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.GetTopic(models.TransportWebhook, "grant") + a.NotNil(r) +} diff --git a/internal/events/types/authorization/authorization.go b/internal/events/types/authorization_revoke/revoke.go similarity index 81% rename from internal/events/types/authorization/authorization.go rename to internal/events/types/authorization_revoke/revoke.go index d683ad56..d0da37d4 100644 --- a/internal/events/types/authorization/authorization.go +++ b/internal/events/types/authorization_revoke/revoke.go @@ -1,27 +1,28 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package authorization +package authorization_revoke import ( "encoding/json" "strings" - "github.com/spf13/viper" "github.com/twitchdev/twitch-cli/internal/events" "github.com/twitchdev/twitch-cli/internal/models" - "github.com/twitchdev/twitch-cli/internal/util" ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } -var triggerSupported = []string{"revoke", "grant"} +var triggerSupported = []string{"revoke"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "revoke": "user.authorization.revoke", + }, + models.TransportWebSocket: { "revoke": "user.authorization.revoke", - "grant": "user.authorization.grant", }, } @@ -30,14 +31,9 @@ type Event struct{} func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEventResponse, error) { var event []byte var err error - clientID := viper.GetString("ClientID") - // if not configured, generate a random one - if clientID == "" { - clientID = util.RandomClientID() - } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := &models.AuthorizationRevokeEventSubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -45,7 +41,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven Type: triggerMapping[params.Transport][params.Trigger], Version: e.SubscriptionVersion(), Condition: models.EventsubCondition{ - ClientID: clientID, + ClientID: params.ClientID, }, Transport: models.EventsubTransport{ Method: "webhook", @@ -54,13 +50,18 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven Cost: 1, CreatedAt: params.Timestamp, }, - Event: models.AuthorizationRevokeEvent{ - ClientID: clientID, + Event: &models.AuthorizationRevokeEvent{ + ClientID: params.ClientID, UserID: params.FromUserID, UserLogin: params.FromUserName, UserName: params.FromUserName, }, } + + if params.Transport == models.TransportWebSocket { + body.Event = nil + } + event, err = json.Marshal(body) if err != nil { return events.MockEventResponse{}, err @@ -117,7 +118,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/authorization/authorization_test.go b/internal/events/types/authorization_revoke/revoke_test.go similarity index 87% rename from internal/events/types/authorization/authorization_test.go rename to internal/events/types/authorization_revoke/revoke_test.go index abcd5e0b..d91f77c0 100644 --- a/internal/events/types/authorization/authorization_test.go +++ b/internal/events/types/authorization_revoke/revoke_test.go @@ -1,12 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package authorization +package authorization_revoke import ( "encoding/json" "testing" - "github.com/spf13/viper" "github.com/twitchdev/twitch-cli/internal/events" "github.com/twitchdev/twitch-cli/internal/models" "github.com/twitchdev/twitch-cli/test_setup" @@ -18,13 +17,13 @@ var toUser = "4567" func TestEventSub(t *testing.T) { a := test_setup.SetupTestEnv(t) - viper.Set("ClientID", "1234") params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "subscribe", SubscriptionStatus: "enabled", + ClientID: "1234", } r, err := Event{}.GenerateEvent(params) @@ -66,7 +65,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -75,6 +74,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "revoke") + r := Event{}.GetTopic(models.TransportWebhook, "revoke") a.NotNil(r) } diff --git a/internal/events/types/ban/ban.go b/internal/events/types/ban/ban.go index 1de92315..fd0e012c 100644 --- a/internal/events/types/ban/ban.go +++ b/internal/events/types/ban/ban.go @@ -13,13 +13,18 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"ban", "unban"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "ban": "channel.ban", + "unban": "channel.unban", + }, + models.TransportWebSocket: { "ban": "channel.ban", "unban": "channel.unban", }, @@ -32,7 +37,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven var err error switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: ban := models.BanEventSubEvent{ UserID: params.FromUserID, UserLogin: params.FromUserName, @@ -140,7 +145,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/ban/ban_test.go b/internal/events/types/ban/ban_test.go index bd688f04..45d4dfd8 100644 --- a/internal/events/types/ban/ban_test.go +++ b/internal/events/types/ban/ban_test.go @@ -19,7 +19,7 @@ func TestEventSubBan(t *testing.T) { params := events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "ban", SubscriptionStatus: "enabled", } @@ -39,7 +39,7 @@ func TestEventSubBan(t *testing.T) { params = events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "unban", SubscriptionStatus: "enabled", } @@ -85,7 +85,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -95,6 +95,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "ban") + r := Event{}.GetTopic(models.TransportWebhook, "ban") a.NotNil(r) } diff --git a/internal/events/types/channel_points_redemption/redemption_event.go b/internal/events/types/channel_points_redemption/redemption_event.go index c5e5174d..89149017 100644 --- a/internal/events/types/channel_points_redemption/redemption_event.go +++ b/internal/events/types/channel_points_redemption/redemption_event.go @@ -12,13 +12,18 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"add-redemption", "update-redemption"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "add-redemption": "channel.channel_points_custom_reward_redemption.add", + "update-redemption": "channel.channel_points_custom_reward_redemption.update", + }, + models.TransportWebSocket: { "add-redemption": "channel.channel_points_custom_reward_redemption.add", "update-redemption": "channel.channel_points_custom_reward_redemption.update", }, @@ -47,7 +52,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := *&models.RedemptionEventSubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -140,7 +145,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/channel_points_redemption/redemption_event_test.go b/internal/events/types/channel_points_redemption/redemption_event_test.go index f2adc22a..619c3708 100644 --- a/internal/events/types/channel_points_redemption/redemption_event_test.go +++ b/internal/events/types/channel_points_redemption/redemption_event_test.go @@ -18,7 +18,7 @@ func TestEventSub(t *testing.T) { a := test_setup.SetupTestEnv(t) params := events.MockEventParameters{ - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "add-redemption", SubscriptionStatus: "enabled", ToUserID: toUser, @@ -44,7 +44,7 @@ func TestEventSub(t *testing.T) { a.Equal(params.ItemName, body.Event.Reward.Title) params = events.MockEventParameters{ - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, ToUserID: toUser, FromUserID: fromUser, } @@ -88,7 +88,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -97,6 +97,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "add-redemption") + r := Event{}.GetTopic(models.TransportWebhook, "add-redemption") a.NotNil(r) } diff --git a/internal/events/types/channel_points_reward/reward_event.go b/internal/events/types/channel_points_reward/reward_event.go index 5e952598..589ed815 100644 --- a/internal/events/types/channel_points_reward/reward_event.go +++ b/internal/events/types/channel_points_reward/reward_event.go @@ -11,13 +11,19 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"add-reward", "update-reward", "remove-reward"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "add-reward": "channel.channel_points_custom_reward.add", + "update-reward": "channel.channel_points_custom_reward.update", + "remove-reward": "channel.channel_points_custom_reward.remove", + }, + models.TransportWebSocket: { "add-reward": "channel.channel_points_custom_reward.add", "update-reward": "channel.channel_points_custom_reward.update", "remove-reward": "channel.channel_points_custom_reward.remove", @@ -39,7 +45,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := models.EventsubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -153,7 +159,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/channel_points_reward/reward_event_test.go b/internal/events/types/channel_points_reward/reward_event_test.go index 8c7ae31d..25ad48b4 100644 --- a/internal/events/types/channel_points_reward/reward_event_test.go +++ b/internal/events/types/channel_points_reward/reward_event_test.go @@ -18,7 +18,7 @@ func TestEventSub(t *testing.T) { a := test_setup.SetupTestEnv(t) params := events.MockEventParameters{ - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "add-redemption", SubscriptionStatus: "enabled", ToUserID: toUser, @@ -70,7 +70,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -79,6 +79,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "add-reward") + r := Event{}.GetTopic(models.TransportWebhook, "add-reward") a.NotNil(r) } diff --git a/internal/events/types/charity/charity_event.go b/internal/events/types/charity/charity_event.go index 15fc1bbb..48dea525 100644 --- a/internal/events/types/charity/charity_event.go +++ b/internal/events/types/charity/charity_event.go @@ -12,12 +12,19 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggers = []string{"charity-donate", "charity-start", "charity-progress", "charity-stop"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "charity-donate": "channel.charity_campaign.donate", + "charity-start": "channel.charity_campaign.start", + "charity-progress": "channel.charity_campaign.progress", + "charity-stop": "channel.charity_campaign.stop", + }, + models.TransportWebSocket: { "charity-donate": "channel.charity_campaign.donate", "charity-start": "channel.charity_campaign.start", "charity-progress": "channel.charity_campaign.progress", @@ -135,7 +142,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := models.EventsubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -228,7 +235,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/charity/charity_event_test.go b/internal/events/types/charity/charity_event_test.go index 85e02e7d..84823df5 100644 --- a/internal/events/types/charity/charity_event_test.go +++ b/internal/events/types/charity/charity_event_test.go @@ -26,7 +26,7 @@ func testEventSubCharity(t *testing.T, trigger string) { params := events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: trigger, SubscriptionStatus: "enabled", } @@ -83,7 +83,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -93,15 +93,15 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "charity-donate") + r := Event{}.GetTopic(models.TransportWebhook, "charity-donate") a.NotNil(r) - r = Event{}.GetTopic(models.TransportEventSub, "charity-start") + r = Event{}.GetTopic(models.TransportWebhook, "charity-start") a.NotNil(r) - r = Event{}.GetTopic(models.TransportEventSub, "charity-progress") + r = Event{}.GetTopic(models.TransportWebhook, "charity-progress") a.NotNil(r) - r = Event{}.GetTopic(models.TransportEventSub, "charity-stop") + r = Event{}.GetTopic(models.TransportWebhook, "charity-stop") a.NotNil(r) } diff --git a/internal/events/types/cheer/cheer_event.go b/internal/events/types/cheer/cheer_event.go index f42d27d3..8d5dfe10 100644 --- a/internal/events/types/cheer/cheer_event.go +++ b/internal/events/types/cheer/cheer_event.go @@ -11,13 +11,17 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"cheer"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "cheer": "channel.cheer", + }, + models.TransportWebSocket: { "cheer": "channel.cheer", }, } @@ -38,7 +42,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := *&models.EventsubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -125,7 +129,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/cheer/cheer_event_test.go b/internal/events/types/cheer/cheer_event_test.go index 33900764..da877981 100644 --- a/internal/events/types/cheer/cheer_event_test.go +++ b/internal/events/types/cheer/cheer_event_test.go @@ -19,7 +19,7 @@ func TestEventsubCheer(t *testing.T) { params := events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "cheer", SubscriptionStatus: "enabled", } @@ -39,7 +39,7 @@ func TestEventsubCheer(t *testing.T) { params = events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, IsAnonymous: true, Trigger: "cheer", SubscriptionStatus: "enabled", @@ -83,7 +83,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -92,6 +92,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "cheer") + r := Event{}.GetTopic(models.TransportWebhook, "cheer") a.NotNil(r) } diff --git a/internal/events/types/drop/drop.go b/internal/events/types/drop/drop.go index 4b5a2543..afa3bd98 100644 --- a/internal/events/types/drop/drop.go +++ b/internal/events/types/drop/drop.go @@ -13,13 +13,13 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, } var triggerSupported = []string{"drop"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { "drop": "drop.entitlement.grant", }, } @@ -38,7 +38,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven params.Description = fmt.Sprintf("%v", util.RandomInt(1000)) } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook: campaignId := util.RandomGUID() body := &models.DropsEntitlementEventSubResponse{ @@ -133,7 +133,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/drop/drop_test.go b/internal/events/types/drop/drop_test.go index 40cdd488..75ff7dad 100644 --- a/internal/events/types/drop/drop_test.go +++ b/internal/events/types/drop/drop_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "drop", SubscriptionStatus: "enabled", } @@ -63,7 +63,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -72,6 +72,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "drop") + r := Event{}.GetTopic(models.TransportWebhook, "drop") a.NotNil(r) } diff --git a/internal/events/types/extension_transaction/transaction_event.go b/internal/events/types/extension_transaction/transaction_event.go index e1d63747..66ff267d 100644 --- a/internal/events/types/extension_transaction/transaction_event.go +++ b/internal/events/types/extension_transaction/transaction_event.go @@ -6,20 +6,18 @@ import ( "encoding/json" "strings" - "github.com/spf13/viper" "github.com/twitchdev/twitch-cli/internal/events" "github.com/twitchdev/twitch-cli/internal/models" - "github.com/twitchdev/twitch-cli/internal/util" ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, } var triggerSupported = []string{"transaction"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { "transaction": "extension.bits_transaction.create", }, } @@ -30,13 +28,6 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven var event []byte var err error - clientID := viper.GetString("clientId") - - // if not configured, generate a random one - if clientID == "" { - clientID = util.RandomClientID() - } - if params.Cost == 0 { params.Cost = 100 } @@ -50,7 +41,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook: body := &models.TransactionEventSubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -58,7 +49,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven Type: triggerMapping[params.Transport][params.Trigger], Version: e.SubscriptionVersion(), Condition: models.EventsubCondition{ - ExtensionClientID: clientID, + ExtensionClientID: params.ClientID, }, Transport: models.EventsubTransport{ Method: "webhook", @@ -69,7 +60,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven }, Event: models.TransactionEventSubEvent{ ID: params.ID, - ExtensionClientID: clientID, + ExtensionClientID: params.ClientID, BroadcasterUserID: params.ToUserID, BroadcasterUserLogin: "testBroadcaster", BroadcasterUserName: "testBroadcaster", @@ -140,7 +131,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/extension_transaction/transaction_event_test.go b/internal/events/types/extension_transaction/transaction_event_test.go index f36f8c1e..6c197443 100644 --- a/internal/events/types/extension_transaction/transaction_event_test.go +++ b/internal/events/types/extension_transaction/transaction_event_test.go @@ -6,7 +6,6 @@ import ( "encoding/json" "testing" - "github.com/spf13/viper" "github.com/twitchdev/twitch-cli/internal/events" "github.com/twitchdev/twitch-cli/internal/models" "github.com/twitchdev/twitch-cli/test_setup" @@ -22,13 +21,12 @@ func TestEventsubTransaction(t *testing.T) { params := events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "transaction", SubscriptionStatus: "enabled", + ClientID: clientId, } - viper.Set("clientId", clientId) - r, err := Event{}.GenerateEvent(params) a.Nil(err) @@ -70,7 +68,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -79,6 +77,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "transaction") + r := Event{}.GetTopic(models.TransportWebhook, "transaction") a.NotNil(r) } diff --git a/internal/events/types/follow/follow_event.go b/internal/events/types/follow/follow_event.go index 98d7f8e5..bd998ba8 100644 --- a/internal/events/types/follow/follow_event.go +++ b/internal/events/types/follow/follow_event.go @@ -11,12 +11,16 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggers = []string{"follow"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "follow": "channel.follow", + }, + models.TransportWebSocket: { "follow": "channel.follow", }, } @@ -28,7 +32,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven var err error switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := models.EventsubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -112,7 +116,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/follow/follow_event_test.go b/internal/events/types/follow/follow_event_test.go index bfb7ba31..e8ede722 100644 --- a/internal/events/types/follow/follow_event_test.go +++ b/internal/events/types/follow/follow_event_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "subscribe", SubscriptionStatus: "enabled", } @@ -64,7 +64,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -73,6 +73,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "follow") + r := Event{}.GetTopic(models.TransportWebhook, "follow") a.NotNil(r) } diff --git a/internal/events/types/gift/channel_gift.go b/internal/events/types/gift/channel_gift.go index 68e6d721..7f3a5cba 100644 --- a/internal/events/types/gift/channel_gift.go +++ b/internal/events/types/gift/channel_gift.go @@ -12,13 +12,17 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"channel-gift"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "channel-gift": "channel.subscription.gift", + }, + models.TransportWebSocket: { "channel-gift": "channel.subscription.gift", }, } @@ -41,7 +45,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := &models.GiftEventSubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -130,7 +134,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/gift/channel_gift_test.go b/internal/events/types/gift/channel_gift_test.go index 6617bc4c..551fddc3 100644 --- a/internal/events/types/gift/channel_gift_test.go +++ b/internal/events/types/gift/channel_gift_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "channel-gift", SubscriptionStatus: "enabled", Cost: 0, @@ -67,7 +67,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -76,6 +76,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "channel-gift") + r := Event{}.GetTopic(models.TransportWebhook, "channel-gift") a.NotNil(r) } diff --git a/internal/events/types/goal/goal_event.go b/internal/events/types/goal/goal_event.go index d7222260..78e1782a 100644 --- a/internal/events/types/goal/goal_event.go +++ b/internal/events/types/goal/goal_event.go @@ -13,13 +13,19 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"goal-begin", "goal-progress", "goal-end"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "goal-progress": "channel.goal.progress", + "goal-begin": "channel.goal.begin", + "goal-end": "channel.goal.end", + }, + models.TransportWebSocket: { "goal-progress": "channel.goal.progress", "goal-begin": "channel.goal.begin", "goal-end": "channel.goal.end", @@ -61,7 +67,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := *&models.EventsubResponse{ Subscription: models.EventsubSubscription{ @@ -153,7 +159,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/goal/goal_event_test.go b/internal/events/types/goal/goal_event_test.go index c213dc2f..8b8d8630 100644 --- a/internal/events/types/goal/goal_event_test.go +++ b/internal/events/types/goal/goal_event_test.go @@ -19,7 +19,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ ToUserID: user, Description: "Twitch Subscriber Goal", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "goal-begin", SubscriptionStatus: "enabled", EventStatus: "subscriber", @@ -39,7 +39,7 @@ func TestEventSub(t *testing.T) { params = *&events.MockEventParameters{ ToUserID: user, Description: "Twitch Follower Goal", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "goal-progress", SubscriptionStatus: "enabled", } @@ -56,7 +56,7 @@ func TestEventSub(t *testing.T) { params = *&events.MockEventParameters{ ToUserID: user, Description: "Twitch Follower Goal", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "goal-end", SubscriptionStatus: "enabled", } @@ -100,7 +100,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -110,6 +110,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "goal-progress") + r := Event{}.GetTopic(models.TransportWebhook, "goal-progress") a.NotNil(r) } diff --git a/internal/events/types/hype_train/hype_train_event.go b/internal/events/types/hype_train/hype_train_event.go index 2c21c5b2..266c2e00 100644 --- a/internal/events/types/hype_train/hype_train_event.go +++ b/internal/events/types/hype_train/hype_train_event.go @@ -13,11 +13,17 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"hype-train-begin", "hype-train-progress", "hype-train-end"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "hype-train-progress": "channel.hype_train.progress", + "hype-train-begin": "channel.hype_train.begin", + "hype-train-end": "channel.hype_train.end", + }, + models.TransportWebSocket: { "hype-train-progress": "channel.hype_train.progress", "hype-train-begin": "channel.hype_train.begin", "hype-train-end": "channel.hype_train.end", @@ -42,7 +48,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven tNow, _ := time.Parse(time.RFC3339Nano, params.Timestamp) switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := models.HypeTrainEventSubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -159,7 +165,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/hype_train/hype_train_event_test.go b/internal/events/types/hype_train/hype_train_event_test.go index ee755f20..bb028b4e 100644 --- a/internal/events/types/hype_train/hype_train_event_test.go +++ b/internal/events/types/hype_train/hype_train_event_test.go @@ -18,7 +18,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "hype-train-begin", SubscriptionStatus: "enabled", } @@ -35,7 +35,7 @@ func TestEventSub(t *testing.T) { params = *&events.MockEventParameters{ ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "hype-train-progress", SubscriptionStatus: "enabled", } @@ -52,7 +52,7 @@ func TestEventSub(t *testing.T) { params = *&events.MockEventParameters{ ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "hype-train-end", } @@ -98,13 +98,13 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) } func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "hype-train-progress") + r := Event{}.GetTopic(models.TransportWebhook, "hype-train-progress") a.Equal("channel.hype_train.progress", r, "Expected %v, got %v", "channel.hype_train.progress", r) } diff --git a/internal/events/types/moderator_change/moderator_change_event.go b/internal/events/types/moderator_change/moderator_change_event.go index 360d7548..9b4aaf45 100644 --- a/internal/events/types/moderator_change/moderator_change_event.go +++ b/internal/events/types/moderator_change/moderator_change_event.go @@ -12,13 +12,18 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"add-moderator", "remove-moderator"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "add-moderator": "channel.moderator.add", + "remove-moderator": "channel.moderator.remove", + }, + models.TransportWebSocket: { "add-moderator": "channel.moderator.add", "remove-moderator": "channel.moderator.remove", }, @@ -31,7 +36,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven var err error switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := *&models.EventsubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -114,7 +119,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/moderator_change/moderator_change_event_test.go b/internal/events/types/moderator_change/moderator_change_event_test.go index 4474c385..4543920f 100644 --- a/internal/events/types/moderator_change/moderator_change_event_test.go +++ b/internal/events/types/moderator_change/moderator_change_event_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "add-moderator", SubscriptionStatus: "enabled", } @@ -68,13 +68,13 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) } func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "remove-moderator") + r := Event{}.GetTopic(models.TransportWebhook, "remove-moderator") a.Equal("channel.moderator.remove", r, "Expected %v, got %v", "channel.moderator.remove", r) } diff --git a/internal/events/types/poll/poll.go b/internal/events/types/poll/poll.go index 961a2ec9..6dc6d918 100644 --- a/internal/events/types/poll/poll.go +++ b/internal/events/types/poll/poll.go @@ -14,13 +14,19 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"poll-begin", "poll-progress", "poll-end"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "poll-begin": "channel.poll.begin", + "poll-progress": "channel.poll.progress", + "poll-end": "channel.poll.end", + }, + models.TransportWebSocket: { "poll-begin": "channel.poll.begin", "poll-progress": "channel.poll.progress", "poll-end": "channel.poll.end", @@ -38,7 +44,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: choices := []models.PollEventSubEventChoice{} for i := 1; i < 5; i++ { c := models.PollEventSubEventChoice{ @@ -157,7 +163,7 @@ func intPointer(i int) *int { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/poll/poll_test.go b/internal/events/types/poll/poll_test.go index c61eb36b..a4c8c287 100644 --- a/internal/events/types/poll/poll_test.go +++ b/internal/events/types/poll/poll_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "poll-begin", SubscriptionStatus: "enabled", } @@ -37,7 +37,7 @@ func TestEventSub(t *testing.T) { params = *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "poll-progress", SubscriptionStatus: "enabled", } @@ -54,7 +54,7 @@ func TestEventSub(t *testing.T) { params = *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "poll-end", SubscriptionStatus: "enabled", } @@ -96,7 +96,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -105,6 +105,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "poll-begin") + r := Event{}.GetTopic(models.TransportWebhook, "poll-begin") a.NotNil(r) } diff --git a/internal/events/types/prediction/prediction.go b/internal/events/types/prediction/prediction.go index 761ba1e4..4b8b7e1d 100644 --- a/internal/events/types/prediction/prediction.go +++ b/internal/events/types/prediction/prediction.go @@ -13,13 +13,20 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"prediction-begin", "prediction-progress", "prediction-end", "prediction-lock"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "prediction-begin": "channel.prediction.begin", + "prediction-progress": "channel.prediction.progress", + "prediction-lock": "channel.prediction.lock", + "prediction-end": "channel.prediction.end", + }, + models.TransportWebSocket: { "prediction-begin": "channel.prediction.begin", "prediction-progress": "channel.prediction.progress", "prediction-lock": "channel.prediction.lock", @@ -37,7 +44,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: var outcomes []models.PredictionEventSubEventOutcomes for i := 0; i < 2; i++ { color := "blue" @@ -182,7 +189,7 @@ func intPointer(i int) *int { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/prediction/prediction_test.go b/internal/events/types/prediction/prediction_test.go index 85975865..91b993bb 100644 --- a/internal/events/types/prediction/prediction_test.go +++ b/internal/events/types/prediction/prediction_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "prediction-begin", SubscriptionStatus: "enabled", } @@ -35,7 +35,7 @@ func TestEventSub(t *testing.T) { params = *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "prediction-progress", SubscriptionStatus: "enabled", } @@ -50,7 +50,7 @@ func TestEventSub(t *testing.T) { params = *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "prediction-lock", } @@ -64,7 +64,7 @@ func TestEventSub(t *testing.T) { params = *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "prediction-end", } @@ -103,7 +103,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -112,6 +112,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "prediction-begin") + r := Event{}.GetTopic(models.TransportWebhook, "prediction-begin") a.NotNil(r) } diff --git a/internal/events/types/raid/raid.go b/internal/events/types/raid/raid.go index 1215fdb4..21bdbe6f 100644 --- a/internal/events/types/raid/raid.go +++ b/internal/events/types/raid/raid.go @@ -12,13 +12,17 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"raid"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "raid": "channel.raid", + }, + models.TransportWebSocket: { "raid": "channel.raid", }, } @@ -30,7 +34,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven var err error switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := &models.RaidEventSubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -113,7 +117,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/raid/raid_test.go b/internal/events/types/raid/raid_test.go index 73e77fcb..80f54b4a 100644 --- a/internal/events/types/raid/raid_test.go +++ b/internal/events/types/raid/raid_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "raid", SubscriptionStatus: "enabled", } @@ -63,7 +63,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -72,6 +72,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "trigger_keyword") + r := Event{}.GetTopic(models.TransportWebhook, "trigger_keyword") a.NotNil(r) } diff --git a/internal/events/types/shield_mode/shield_mode.go b/internal/events/types/shield_mode/shield_mode.go index bccaf603..b87e59cc 100644 --- a/internal/events/types/shield_mode/shield_mode.go +++ b/internal/events/types/shield_mode/shield_mode.go @@ -13,12 +13,17 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggers = []string{"shield-mode-begin", "shield-mode-end"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "shield-mode-begin": "channel.shield_mode.begin", + "shield-mode-end": "channel.shield_mode.end", + }, + models.TransportWebSocket: { "shield-mode-begin": "channel.shield_mode.begin", "shield-mode-end": "channel.shield_mode.end", }, @@ -31,7 +36,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven var err error switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: eventBody := models.ShieldModeEventSubEvent{ BroadcasterUserID: params.ToUserID, BroadcasterUserName: params.ToUserName, @@ -123,7 +128,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/shield_mode/shield_mode_test.go b/internal/events/types/shield_mode/shield_mode_test.go index 1b33435e..3ca0beeb 100644 --- a/internal/events/types/shield_mode/shield_mode_test.go +++ b/internal/events/types/shield_mode/shield_mode_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { beginParams := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "shield-mode-begin", SubscriptionStatus: "enabled", Cost: 0, @@ -28,7 +28,7 @@ func TestEventSub(t *testing.T) { endParams := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "shield-mode-end", SubscriptionStatus: "enabled", Cost: 0, @@ -89,7 +89,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -98,9 +98,9 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "shield-mode-begin") + r := Event{}.GetTopic(models.TransportWebhook, "shield-mode-begin") a.NotNil(r) - r = Event{}.GetTopic(models.TransportEventSub, "shield-mode-end") + r = Event{}.GetTopic(models.TransportWebhook, "shield-mode-end") a.NotNil(r) } diff --git a/internal/events/types/shoutout/shoutout.go b/internal/events/types/shoutout/shoutout.go index e0c673e8..39c2e3b7 100644 --- a/internal/events/types/shoutout/shoutout.go +++ b/internal/events/types/shoutout/shoutout.go @@ -13,12 +13,17 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggers = []string{"shoutout-create", "shoutout-received"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "shoutout-create": "channel.shoutout.create", + "shoutout-received": "channel.shoutout.receive", + }, + models.TransportWebSocket: { "shoutout-create": "channel.shoutout.create", "shoutout-received": "channel.shoutout.receive", }, @@ -31,7 +36,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven var err error switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: viewerCount := util.RandomInt(2000) startedAt := util.GetTimestamp() @@ -163,7 +168,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/shoutout/shoutout_test.go b/internal/events/types/shoutout/shoutout_test.go index b2407fa1..3ce11898 100644 --- a/internal/events/types/shoutout/shoutout_test.go +++ b/internal/events/types/shoutout/shoutout_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { beginParams := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "shoutout-create", SubscriptionStatus: "enabled", Cost: 0, @@ -28,7 +28,7 @@ func TestEventSub(t *testing.T) { endParams := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "shoutout-received", SubscriptionStatus: "enabled", Cost: 0, @@ -90,7 +90,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -99,9 +99,9 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "shoutout-create") + r := Event{}.GetTopic(models.TransportWebhook, "shoutout-create") a.NotNil(r) - r = Event{}.GetTopic(models.TransportEventSub, "shoutout-receieve") + r = Event{}.GetTopic(models.TransportWebhook, "shoutout-receieve") a.NotNil(r) } diff --git a/internal/events/types/stream_change/stream_change_event.go b/internal/events/types/stream_change/stream_change_event.go index 57add539..4488b963 100644 --- a/internal/events/types/stream_change/stream_change_event.go +++ b/internal/events/types/stream_change/stream_change_event.go @@ -11,13 +11,17 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"stream-change"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "stream-change": "channel.update", + }, + models.TransportWebSocket: { "stream-change": "channel.update", }, } @@ -41,7 +45,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := &models.EventsubResponse{ // make the eventsub response (if supported) Subscription: models.EventsubSubscription{ @@ -127,7 +131,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/stream_change/stream_change_event_test.go b/internal/events/types/stream_change/stream_change_event_test.go index 166d341a..c55ea8b4 100644 --- a/internal/events/types/stream_change/stream_change_event_test.go +++ b/internal/events/types/stream_change/stream_change_event_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "stream-change", SubscriptionStatus: "enabled", } @@ -39,7 +39,7 @@ func TestEventSub(t *testing.T) { params = events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "stream_change", SubscriptionStatus: "enabled", GameID: "1234", @@ -84,7 +84,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -93,6 +93,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "stream-change") + r := Event{}.GetTopic(models.TransportWebhook, "stream-change") a.NotNil(r) } diff --git a/internal/events/types/streamdown/streamdown.go b/internal/events/types/streamdown/streamdown.go index d8f55007..c84bab22 100644 --- a/internal/events/types/streamdown/streamdown.go +++ b/internal/events/types/streamdown/streamdown.go @@ -11,13 +11,17 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"streamdown"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "streamdown": "stream.offline", + }, + models.TransportWebSocket: { "streamdown": "stream.offline", }, } @@ -29,7 +33,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven var err error switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := &models.EventsubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -108,7 +112,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/streamdown/streamdown_test.go b/internal/events/types/streamdown/streamdown_test.go index 82c186cd..f28270db 100644 --- a/internal/events/types/streamdown/streamdown_test.go +++ b/internal/events/types/streamdown/streamdown_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "streamdown", SubscriptionStatus: "enabled", } @@ -63,7 +63,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -72,6 +72,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "streamdown") + r := Event{}.GetTopic(models.TransportWebhook, "streamdown") a.NotNil(r) } diff --git a/internal/events/types/streamup/streamup.go b/internal/events/types/streamup/streamup.go index 63dced19..991b5520 100644 --- a/internal/events/types/streamup/streamup.go +++ b/internal/events/types/streamup/streamup.go @@ -12,13 +12,17 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"streamup"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "streamup": "stream.online", + }, + models.TransportWebSocket: { "streamup": "stream.online", }, } @@ -38,7 +42,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := &models.EventsubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -120,7 +124,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/streamup/streamup_test.go b/internal/events/types/streamup/streamup_test.go index 26d97c94..e7ff10b2 100644 --- a/internal/events/types/streamup/streamup_test.go +++ b/internal/events/types/streamup/streamup_test.go @@ -20,7 +20,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "streamup", SubscriptionStatus: "enabled", } @@ -63,7 +63,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -72,6 +72,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "streamup") + r := Event{}.GetTopic(models.TransportWebhook, "streamup") a.NotNil(r) } diff --git a/internal/events/types/subscribe/sub_event.go b/internal/events/types/subscribe/sub_event.go index 549f1f1d..6dff04c8 100644 --- a/internal/events/types/subscribe/sub_event.go +++ b/internal/events/types/subscribe/sub_event.go @@ -11,13 +11,20 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"subscribe", "gift", "unsubscribe", "subscribe-end"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "subscribe": "channel.subscribe", + "unsubscribe": "channel.subscription.end", + "gift": "channel.subscribe", + "subscribe-end": "channel.subscription.end", + }, + models.TransportWebSocket: { "subscribe": "channel.subscribe", "unsubscribe": "channel.subscription.end", "gift": "channel.subscribe", @@ -36,7 +43,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := *&models.EventsubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -122,7 +129,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/subscribe/sub_event_test.go b/internal/events/types/subscribe/sub_event_test.go index 153a7ead..3382a34c 100644 --- a/internal/events/types/subscribe/sub_event_test.go +++ b/internal/events/types/subscribe/sub_event_test.go @@ -21,7 +21,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, SubscriptionStatus: "enabled", Trigger: "subscribe", Tier: tierTwo, @@ -67,7 +67,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -77,6 +77,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "subscribe") + r := Event{}.GetTopic(models.TransportWebhook, "subscribe") a.NotNil(r) } diff --git a/internal/events/types/subscription_message/subscription_message.go b/internal/events/types/subscription_message/subscription_message.go index 789d9182..bb75dc99 100644 --- a/internal/events/types/subscription_message/subscription_message.go +++ b/internal/events/types/subscription_message/subscription_message.go @@ -12,13 +12,17 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggerSupported = []string{"subscribe-message"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "subscribe-message": "channel.subscription.message", + }, + models.TransportWebSocket: { "subscribe-message": "channel.subscription.message", }, } @@ -34,7 +38,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven } switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := &models.SubscribeMessageEventSubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -134,7 +138,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/subscription_message/subscription_message_test.go b/internal/events/types/subscription_message/subscription_message_test.go index c286f9e7..98793811 100644 --- a/internal/events/types/subscription_message/subscription_message_test.go +++ b/internal/events/types/subscription_message/subscription_message_test.go @@ -22,7 +22,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "subscribe-message", SubscriptionStatus: "enabled", Cost: int64(ten), @@ -40,7 +40,7 @@ func TestEventSub(t *testing.T) { params = *&events.MockEventParameters{ FromUserID: fromUser, ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "subscribe-message", SubscriptionStatus: "enabled", Cost: int64(ten), @@ -85,7 +85,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -94,6 +94,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "subscribe-message") + r := Event{}.GetTopic(models.TransportWebhook, "subscribe-message") a.NotNil(r) } diff --git a/internal/events/types/types.go b/internal/events/types/types.go index dac1fb2a..f0999075 100644 --- a/internal/events/types/types.go +++ b/internal/events/types/types.go @@ -5,9 +5,11 @@ package types import ( "errors" "sort" + "strings" "github.com/twitchdev/twitch-cli/internal/events" - "github.com/twitchdev/twitch-cli/internal/events/types/authorization" + "github.com/twitchdev/twitch-cli/internal/events/types/authorization_grant" + "github.com/twitchdev/twitch-cli/internal/events/types/authorization_revoke" "github.com/twitchdev/twitch-cli/internal/events/types/ban" "github.com/twitchdev/twitch-cli/internal/events/types/channel_points_redemption" "github.com/twitchdev/twitch-cli/internal/events/types/channel_points_reward" @@ -36,7 +38,8 @@ import ( func AllEvents() []events.MockEvent { return []events.MockEvent{ - authorization.Event{}, + authorization_grant.Event{}, + authorization_revoke.Event{}, ban.Event{}, channel_points_redemption.Event{}, channel_points_reward.Event{}, @@ -63,12 +66,34 @@ func AllEvents() []events.MockEvent { } } -func AllEventTopics() []string { +func AllWebhookTopics() []string { allEvents := []string{} + allEventsMap := make(map[string]int) for _, e := range AllEvents() { - for _, topic := range e.GetAllTopicsByTransport(models.TransportEventSub) { - allEvents = append(allEvents, topic) + for _, topic := range e.GetAllTopicsByTransport(models.TransportWebhook) { + _, duplicate := allEventsMap[topic] + if !duplicate { + allEvents = append(allEvents, topic) + allEventsMap[topic] = 1 + } + } + } + + // Sort the topics alphabetically + sort.Strings(allEvents) + + return allEvents +} + +func WebSocketCommandTopics() []string { + allEvents := []string{} + + for _, e := range AllEvents() { + for _, topic := range e.GetAllTopicsByTransport(models.TransportWebSocket) { + if strings.HasPrefix(topic, "websocket") { + allEvents = append(allEvents, topic) + } } } @@ -80,7 +105,7 @@ func AllEventTopics() []string { func GetByTriggerAndTransport(trigger string, transport string) (events.MockEvent, error) { for _, e := range AllEvents() { - if transport == models.TransportEventSub { + if transport == models.TransportWebhook || transport == models.TransportWebSocket { newTrigger := e.GetEventSubAlias(trigger) if newTrigger != "" { trigger = newTrigger @@ -91,5 +116,11 @@ func GetByTriggerAndTransport(trigger string, transport string) (events.MockEven } } + // Different error for websocket transport + if strings.EqualFold(transport, "websocket") { + return nil, errors.New("Invalid event, or this event is not available via WebSockets.") + } + + // Default error return nil, errors.New("Invalid event") } diff --git a/internal/events/types/user/user_update.go b/internal/events/types/user/user_update.go index bcd9af56..2f4528d9 100644 --- a/internal/events/types/user/user_update.go +++ b/internal/events/types/user/user_update.go @@ -11,12 +11,16 @@ import ( ) var transportsSupported = map[string]bool{ - models.TransportEventSub: true, + models.TransportWebhook: true, + models.TransportWebSocket: true, } var triggers = []string{"user-update"} var triggerMapping = map[string]map[string]string{ - models.TransportEventSub: { + models.TransportWebhook: { + "user-update": "user.update", + }, + models.TransportWebSocket: { "user-update": "user.update", }, } @@ -28,7 +32,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven var err error switch params.Transport { - case models.TransportEventSub: + case models.TransportWebhook, models.TransportWebSocket: body := models.EventsubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -110,7 +114,7 @@ func (e Event) GetAllTopicsByTransport(transport string) []string { } func (e Event) GetEventSubAlias(t string) string { // check for aliases - for trigger, topic := range triggerMapping[models.TransportEventSub] { + for trigger, topic := range triggerMapping[models.TransportWebhook] { if topic == t { return trigger } diff --git a/internal/events/types/user/user_update_test.go b/internal/events/types/user/user_update_test.go index 92046290..f90a459f 100644 --- a/internal/events/types/user/user_update_test.go +++ b/internal/events/types/user/user_update_test.go @@ -18,7 +18,7 @@ func TestEventSub(t *testing.T) { params := *&events.MockEventParameters{ ToUserID: toUser, - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Trigger: "user-update", } @@ -58,7 +58,7 @@ func TestValidTrigger(t *testing.T) { func TestValidTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.ValidTransport(models.TransportEventSub) + r := Event{}.ValidTransport(models.TransportWebhook) a.Equal(true, r) r = Event{}.ValidTransport("noteventsub") @@ -67,6 +67,6 @@ func TestValidTransport(t *testing.T) { func TestGetTopic(t *testing.T) { a := test_setup.SetupTestEnv(t) - r := Event{}.GetTopic(models.TransportEventSub, "user-update") + r := Event{}.GetTopic(models.TransportWebhook, "user-update") a.NotNil(r) } diff --git a/internal/events/verify/subscription_verify.go b/internal/events/verify/subscription_verify.go index 67d5967e..f1b4ab91 100644 --- a/internal/events/verify/subscription_verify.go +++ b/internal/events/verify/subscription_verify.go @@ -43,7 +43,7 @@ func VerifyWebhookSubscription(p VerifyParameters) (VerifyResponse, error) { return VerifyResponse{}, err } - if p.Transport == models.TransportEventSub { + if p.Transport == models.TransportWebhook { newTrigger := event.GetEventSubAlias(p.Event) if newTrigger != "" { p.Event = newTrigger @@ -137,7 +137,7 @@ func generateWebhookSubscriptionBody(transport string, eventID string, event str var err error ts := util.GetTimestamp().Format(time.RFC3339Nano) switch transport { - case models.TransportEventSub: + case models.TransportWebhook: body := models.EventsubSubscriptionVerification{ Challenge: challenge, Subscription: models.EventsubSubscription{ diff --git a/internal/events/verify/subscription_verify_test.go b/internal/events/verify/subscription_verify_test.go index 37ea48e5..4d015726 100644 --- a/internal/events/verify/subscription_verify_test.go +++ b/internal/events/verify/subscription_verify_test.go @@ -44,7 +44,7 @@ func TestSubscriptionVerify(t *testing.T) { defer ts.Close() p := VerifyParameters{ - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Event: "subscribe", ForwardAddress: ts.URL, Secret: "potatoes", @@ -59,7 +59,7 @@ func TestSubscriptionVerify(t *testing.T) { p = VerifyParameters{ ForwardAddress: ts.URL + "/badendpoint", - Transport: models.TransportEventSub, + Transport: models.TransportWebhook, Event: "subscribe", Secret: "potatoes", } diff --git a/internal/events/websocket/mock_server/client.go b/internal/events/websocket/mock_server/client.go new file mode 100644 index 00000000..d108c2b3 --- /dev/null +++ b/internal/events/websocket/mock_server/client.go @@ -0,0 +1,42 @@ +package mock_server + +import ( + "sync" + "time" + + "github.com/gorilla/websocket" +) + +type Client struct { + clientName string // Unique name for the client. Not the Client ID. + conn *websocket.Conn + mutex sync.Mutex + ConnectedAtTimestamp string + connectionUrl string + + mustSubscribeTimer *time.Timer + keepAliveChanOpen bool + keepAliveLoopChan chan struct{} + keepAliveTimer *time.Ticker + pingChanOpen bool + pingLoopChan chan struct{} + pingTimer *time.Ticker +} + +func (c *Client) SendMessage(messageType int, data []byte) error { + c.mutex.Lock() + defer c.mutex.Unlock() + return c.conn.WriteMessage(messageType, data) +} + +func (c *Client) CloseWithReason(reason *CloseMessage) { + c.conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(reason.code, reason.message), + time.Now().Add(2*time.Second), + ) +} + +func (c *Client) CloseDirty() { + c.conn.Close() +} diff --git a/internal/events/websocket/mock_server/close_messages.go b/internal/events/websocket/mock_server/close_messages.go new file mode 100644 index 00000000..3867595e --- /dev/null +++ b/internal/events/websocket/mock_server/close_messages.go @@ -0,0 +1,71 @@ +package mock_server + +type CloseMessage struct { + code int + message string +} + +var ( + closeInternalServerError = &CloseMessage{ + code: 4000, + message: "internal server error", + } + + closeClientSentInboundTraffic = &CloseMessage{ + code: 4001, + message: "sent inbound traffic", + } + + closeClientFailedPingPong = &CloseMessage{ + code: 4002, + message: "failed ping pong", + } + + closeConnectionUnused = &CloseMessage{ + code: 4003, + message: "connection unused", + } + + closeReconnectGraceTimeExpired = &CloseMessage{ + code: 4004, + message: "client reconnect grace time expired", + } + + closeNetworkTimeout = &CloseMessage{ + code: 4005, + message: "network timeout", + } + + closeNetworkError = &CloseMessage{ + code: 4006, + message: "network error", + } + + closeInvalidReconnect = &CloseMessage{ + code: 4007, + message: "invalid reconnect attempt", + } +) + +func GetCloseMessageFromCode(code int) *CloseMessage { + switch code { + case 4000: + return closeInternalServerError + case 4001: + return closeClientSentInboundTraffic + case 4002: + return closeClientFailedPingPong + case 4003: + return closeConnectionUnused + case 4004: + return closeNetworkTimeout + case 4005: + return closeNetworkTimeout + case 4006: + return closeNetworkError + case 4007: + return closeInvalidReconnect + default: + return nil + } +} diff --git a/internal/events/websocket/mock_server/manager.go b/internal/events/websocket/mock_server/manager.go new file mode 100644 index 00000000..ea497462 --- /dev/null +++ b/internal/events/websocket/mock_server/manager.go @@ -0,0 +1,478 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package mock_server + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "strings" + "time" + + "github.com/fatih/color" + "github.com/gorilla/websocket" + "github.com/twitchdev/twitch-cli/internal/events/types" + rpc_handler "github.com/twitchdev/twitch-cli/internal/rpc" + "github.com/twitchdev/twitch-cli/internal/util" +) + +type ServerManager struct { + serverList *util.List[WebSocketServer] + reconnectTesting bool + primaryServer string + port int + debugEnabled bool + strictMode bool +} + +var serverManager *ServerManager + +func StartWebsocketServer(enableDebug bool, port int, strictMode bool) { + serverManager = &ServerManager{ + serverList: &util.List[WebSocketServer]{ + Elements: make(map[string]*WebSocketServer), + }, + port: port, + reconnectTesting: false, + strictMode: strictMode, + } + + serverManager.debugEnabled = enableDebug + + // Start initial websocket server + initialServer := &WebSocketServer{ + ServerId: util.RandomGUID()[:8], + Status: 2, + Clients: &util.List[Client]{ + Elements: make(map[string]*Client), + }, + Upgrader: websocket.Upgrader{}, + DebugEnabled: serverManager.debugEnabled, + Subscriptions: make(map[string][]Subscription), + StrictMode: serverManager.strictMode, + + ReconnectClients: &util.List[[]Subscription]{ // Empty and irrelevant at this point, but needed to avoid panic + Elements: make(map[string]*[]Subscription), + }, + } + serverManager.serverList.Put(initialServer.ServerId, initialServer) + serverManager.primaryServer = initialServer.ServerId + + // Allow exit with Ctrl + C + stop := make(chan os.Signal) + signal.Notify(stop, os.Interrupt) + + m := http.NewServeMux() + + // Register URL handler + m.HandleFunc("/ws", wsPageHandler) + m.HandleFunc("/eventsub/subscriptions", subscriptionPageHandler) + + // Start HTTP server + go func() { + // Listen to port + listen, err := net.Listen("tcp", fmt.Sprintf(":%v", port)) + if err != nil { + log.Fatalf("Cannot start HTTP server: %v", err) + return + } + + lightBlue := color.New(color.FgHiBlue).SprintFunc() + lightGreen := color.New(color.FgHiGreen).SprintFunc() + lightYellow := color.New(color.FgHiYellow).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + + log.Printf(lightBlue("Started WebSocket server on 127.0.0.1:%v"), port) + if serverManager.strictMode { + log.Printf(lightBlue("--require-subscription enabled. Clients will have 10 seconds to subscribe before being disconnected.")) + } + + fmt.Println() + + log.Printf(yellow("Simulate subscribing to events at: http://127.0.0.1:%v/eventsub/subscriptions"), port) + log.Printf(yellow("POST, GET, and DELETE are supported")) + log.Printf(yellow("For more info: https://dev.twitch.tv/docs/cli/websocket-event-command/#simulate-subscribing-to-mock-eventsub")) + + fmt.Println() + + log.Printf(lightYellow("Events can be forwarded to this server from another terminal with --transport=websocket\nExample: \"twitch event trigger channel.ban --transport=websocket\"")) + fmt.Println() + log.Printf(lightYellow("You can send to a specific client after its connected with --session\nExample: \"twitch event trigger channel.ban --transport=websocket --session=e411cc1e_a2613d4e\"")) + + fmt.Println() + log.Printf(lightGreen("For further usage information, please see our official documentation:\nhttps://dev.twitch.tv/docs/cli/websocket-event-command/")) + fmt.Println() + + log.Printf(lightBlue("Connect to the WebSocket server at: ")+"ws://127.0.0.1:%v/ws", port) + + // Serve HTTP server + if err := http.Serve(listen, m); err != nil { + log.Fatalf("Cannot start HTTP server: %v", err) + return + } + }() + + // Initalize RPC handler, to accept EventSub transports + rpc := rpc_handler.RPCHandler{ + Port: 44747, + Handlers: make(map[string]rpc_handler.HandlerCallback), + } + + rpc.RegisterHandler("EventSubWebSocketReconnect", RPCReconnectHandler) + rpc.RegisterHandler("EventSubWebSocketForwardEvent", RPCFireEventSubHandler) + rpc.RegisterHandler("EventSubWebSocketCloseClient", RPCCloseHandler) + rpc.RegisterHandler("EventSubWebSocketSubscription", RPCSubscriptionHandler) + rpc.StartBackgroundServer() + + // TODO: Interactive shell maybe? + + <-stop // Wait for Ctrl + C +} + +func wsPageHandler(w http.ResponseWriter, r *http.Request) { + server, ok := serverManager.serverList.Get(serverManager.primaryServer) + if !ok { + log.Printf("Failed to find primary server [%v] when new client was accessing ws://127.0.0.1:%v/ws -- Aborting...", serverManager.primaryServer, serverManager.port) + return + } + + server.WsPageHandler(w, r) +} + +func subscriptionPageHandler(w http.ResponseWriter, r *http.Request) { + method := strings.ToUpper(r.Method) + + // OPTIONS method + if method == "OPTIONS" { + w.Header().Set("Access-Control-Allow-Headers", "Accept, Accept-Language, Authorization, Client-Id, Twitch-Api-Token, X-Forwarded-Proto, X-Requested-With, X-Csrf-Token, Content-Type, X-Device-Id, X-Twitch-Vhscf, X-Forwarded-Ip") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Max-Age", "600") + w.WriteHeader(http.StatusOK) + return + } + + // GET method + if method == "GET" { + subscriptionPageHandlerGet(w, r) + return + } + + // POST method + if method == "POST" { + subscriptionPageHandlerPost(w, r) + return + } + + // DELETE method + if method == "DELETE" { + subscriptionPageHandlerDelete(w, r) + return + } + + // Fallback + w.WriteHeader(http.StatusMethodNotAllowed) +} + +func subscriptionPageHandlerGet(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("ratelimit-limit", "800") + w.Header().Set("ratelimit-remaining", "799") + w.Header().Set("ratelimit-reset", fmt.Sprintf("%d", time.Now().Unix()+1)) // 1 second from now + + // Basic error checking + clientID := r.Header.Get("client-id") + if clientID == "" { + handlerResponseErrorUnauthorized(w, "Client-Id header required") + return + } + + server, ok := serverManager.serverList.Get(serverManager.primaryServer) + if !ok { + handlerResponseErrorInternalServerError(w, "Primary server not found in server list.") + return + } + + allSubscriptions := []SubscriptionPostSuccessResponseBody{} + + server.muSubscriptions.Lock() + + for clientName, clientSubscriptions := range server.Subscriptions { + for _, subscription := range clientSubscriptions { + if clientID == "debug" || subscription.ClientID == clientID { + allSubscriptions = append(allSubscriptions, SubscriptionPostSuccessResponseBody{ + ID: subscription.ClientID, + Status: subscription.Status, + Type: subscription.Type, + Version: subscription.Version, + Condition: EmptyStruct{}, + CreatedAt: subscription.CreatedAt, + Transport: SubscriptionTransport{ + Method: "websocket", + SessionID: fmt.Sprintf("%v_%v", server.ServerId, clientName), + }, + Cost: 0, + }) + } + } + } + + server.muSubscriptions.Unlock() + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(&SubscriptionGetSuccessResponse{ + Total: len(allSubscriptions), + Data: allSubscriptions, + TotalCost: 0, + MaxTotalCost: 10, + Pagination: EmptyStruct{}, + }) +} + +func subscriptionPageHandlerPost(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("ratelimit-limit", "800") + w.Header().Set("ratelimit-remaining", "799") + w.Header().Set("ratelimit-reset", fmt.Sprintf("%d", time.Now().Unix()+1)) // 1 second from now + + var body SubscriptionPostRequest + + err := json.NewDecoder(r.Body).Decode(&body) + if err != nil { + handlerResponseErrorBadRequest(w, "error validating json") + return + } + + // Basic error checking + if r.Header.Get("client-id") == "" { + handlerResponseErrorUnauthorized(w, "Client-Id header required") + return + } + if !strings.EqualFold(body.Transport.Method, "websocket") { + handlerResponseErrorBadRequest(w, "The value specified in the 'method' field is not valid") + return + } + if !sessionRegex.MatchString(body.Transport.SessionID) { + handlerResponseErrorBadRequest(w, "The value specified in the 'session_id' field is not valid") + return + } + if body.Type == "" { + handlerResponseErrorBadRequest(w, "The value specified in the 'type' field is not valid") + return + } + if body.Version == "" { + handlerResponseErrorBadRequest(w, "The value specified in the 'version' field is not valid") + return + } + + // Check if the topic exists + _, err = types.GetByTriggerAndTransport(body.Type, body.Transport.Method) + if err != nil { + handlerResponseErrorBadRequest(w, "The combination of values in the type and version fields is not valid") + return + } + + sessionRegexExec := sessionRegex.FindAllStringSubmatch(body.Transport.SessionID, -1) + clientName := sessionRegexExec[0][2] + + // Get client and server + server, ok := serverManager.serverList.Get(sessionRegexExec[0][1]) + if !ok { + handlerResponseErrorBadRequest(w, "non-existent session_id") + return + } + client, ok := server.Clients.Get(clientName) + if !ok { + handlerResponseErrorBadRequest(w, "non-existent session_id") + return + } + + server.muSubscriptions.Lock() + + // Check for duplicate subscription + for _, s := range server.Subscriptions[clientName] { + if s.ClientID == r.Header.Get("client-id") && s.Type == body.Type && s.Version == body.Version { + handlerResponseErrorConflict(w, "Subscription by the specified type and version combination for the specified Client ID already exists") + server.muSubscriptions.Unlock() + return + } + } + + if len(server.Subscriptions[clientName]) >= 100 { + handlerResponseErrorBadRequest(w, "You may only create 100 subscriptions within a single WebSocket connection") + server.muSubscriptions.Unlock() + return + } + + // Add subscription + subscription := Subscription{ + SubscriptionID: util.RandomGUID(), + ClientID: r.Header.Get("client-id"), + Type: body.Type, + Version: body.Version, + CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), + Status: STATUS_ENABLED, // https://dev.twitch.tv/docs/api/reference/#get-eventsub-subscriptions + SessionClientName: clientName, + } + + var subs []Subscription + existingList, ok := server.Subscriptions[clientName] + if ok { + subs = existingList + } else { + subs = []Subscription{} + } + + subs = append(subs, subscription) + server.Subscriptions[clientName] = subs + + server.muSubscriptions.Unlock() + + // Return 202 status code and response body + w.WriteHeader(http.StatusAccepted) + + json.NewEncoder(w).Encode(&SubscriptionPostSuccessResponse{ + Body: SubscriptionPostSuccessResponseBody{ + ID: subscription.SubscriptionID, + Status: subscription.Status, + Type: subscription.Type, + Version: subscription.Version, + Condition: EmptyStruct{}, + CreatedAt: subscription.CreatedAt, + Transport: SubscriptionTransport{ + Method: "websocket", + SessionID: fmt.Sprintf("%v_%v", server.ServerId, clientName), + ConnectedAt: client.ConnectedAtTimestamp, + }, + Cost: 0, + }, + Total: 0, + MaxTotalCost: 10, + TotalCost: 0, + }) + + if serverManager.debugEnabled { + log.Printf( + "Client ID [%v] created subscription [%v/%v] at subscription ID [%v]", + r.Header.Get("client-id"), + subscription.Type, + subscription.Version, + subscription.SubscriptionID, + ) + } + + return +} + +func subscriptionPageHandlerDelete(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("ratelimit-limit", "800") + w.Header().Set("ratelimit-remaining", "799") + w.Header().Set("ratelimit-reset", fmt.Sprintf("%d", time.Now().Unix()+1)) // 1 second from now + + subscriptionId := r.URL.Query().Get("id") + + // Basic error checking + if r.Header.Get("client-id") == "" { + handlerResponseErrorUnauthorized(w, "Client-Id header required") + return + } + if subscriptionId == "" { + handlerResponseErrorBadRequest(w, "The id query parameter is required") + return + } + + server, ok := serverManager.serverList.Get(serverManager.primaryServer) + if !ok { + handlerResponseErrorInternalServerError(w, "Primary server not found in server list.") + return + } + + subFound := false + + server.muSubscriptions.Lock() + + for client, clientSubscriptions := range server.Subscriptions { + for i, subscription := range clientSubscriptions { + if subscription.SubscriptionID == subscriptionId { + subFound = true + subsPart := make([]Subscription, 0) + subsPart = append(subsPart, server.Subscriptions[client][:i]...) + + newSubs := append(subsPart, server.Subscriptions[client][i+1:]...) + server.Subscriptions[client] = newSubs + + if serverManager.debugEnabled { + log.Printf( + "Deleted subscription [%v/%v] of ID [%v] owned by client ID [%v]", + subscription.Type, + subscription.Version, + subscription.SubscriptionID, + r.Header.Get("client-id"), + ) + } + } + } + } + + server.muSubscriptions.Unlock() + + if subFound { + // Return 204 status code + w.WriteHeader(http.StatusNoContent) + } else { + // Return 404 not found + w.WriteHeader(http.StatusNotFound) + + if serverManager.debugEnabled { + log.Printf("Failed to delete subscription ID [%v] from client ID [%v]", subscriptionId, r.Header.Get("client-id")) + } + } +} + +func handlerResponseErrorBadRequest(w http.ResponseWriter, message string) { + w.WriteHeader(http.StatusBadRequest) + bytes, _ := json.Marshal(&SubscriptionPostErrorResponse{ + Error: "Bad Request", + Message: message, + Status: 400, + }) + w.Write(bytes) +} + +func handlerResponseErrorUnauthorized(w http.ResponseWriter, message string) { + w.WriteHeader(http.StatusBadRequest) + bytes, _ := json.Marshal(&SubscriptionPostErrorResponse{ + Error: "Unauthorized", + Message: message, + Status: 401, + }) + w.Write(bytes) +} + +func handlerResponseErrorConflict(w http.ResponseWriter, message string) { + w.WriteHeader(http.StatusConflict) + bytes, _ := json.Marshal(&SubscriptionPostErrorResponse{ + Error: "Unauthorized", + Message: message, + Status: 409, + }) + w.Write(bytes) +} + +func handlerResponseErrorInternalServerError(w http.ResponseWriter, message string) { + w.WriteHeader(http.StatusInternalServerError) + bytes, _ := json.Marshal(&SubscriptionPostErrorResponse{ + Error: "Internal Server Error", + Message: message, + Status: 500, + }) + w.Write(bytes) +} diff --git a/internal/events/mock_wss_server/message_types.go b/internal/events/websocket/mock_server/message_types.go similarity index 74% rename from internal/events/mock_wss_server/message_types.go rename to internal/events/websocket/mock_server/message_types.go index 4b2d6d9f..4f02b701 100644 --- a/internal/events/mock_wss_server/message_types.go +++ b/internal/events/websocket/mock_server/message_types.go @@ -1,99 +1,129 @@ -package mock_wss_server - -// Generic response message Metadata; Always the same - -type MessageMetadata struct { - MessageID string `json:"message_id"` - MessageType string `json:"message_type"` - MessageTimestamp string `json:"message_timestamp"` -} - -/* ** Welcome message ** -{ // <1> - "metadata": { // - "message_id": "befa7b53-d79d-478f-86b9-120f112b044e", - "message_type": "session_welcome", - "message_timestamp": "2019-11-16T10:11:12.123Z" - }, - "payload": { // <2> - "session": { // <3> - "id": "AQoQexAWVYKSTIu4ec_2VAxyuhAB", - "status": "connected", - "keepalive_timeout_seconds": 10, - "reconnect_url": null, - "connected_at": "2019-11-16T10:11:12.123Z" - } - } -} -*/ - -type WelcomeMessage struct { // <1> - Metadata MessageMetadata `json:"metadata"` - Payload WelcomeMessagePayload `json:"payload"` -} - -type WelcomeMessagePayload struct { // <2> - Session WelcomeMessagePayloadSession `json:"session"` -} - -type WelcomeMessagePayloadSession struct { // <3> - ID string `json:"id"` - Status string `json:"status"` - KeepaliveTimeoutSeconds int `json:"keepalive_timeout_seconds"` - ReconnectUrl *string `json:"reconnect_url"` - ConnectedAt string `json:"connected_at"` -} - -/* ** Reconnect message ** -{ // <1> - "metadata": { // - "message_id": "84c1e79a-2a4b-4c13-ba0b-4312293e9308", - "message_type": "session_reconnect", - "message_timestamp": "2019-11-18T09:10:11.234Z" - }, - "payload": { // <2> - "session": { // <3> - "id": "AQoQexAWVYKSTIu4ec_2VAxyuhAB", - "status": "reconnecting", - "keepalive_timeout_seconds": null, - "reconnect_url": "wss://eventsub-experimental.wss.twitch.tv?...", - "connected_at": "2019-11-16T10:11:12.123Z" - } - } -} -*/ - -type ReconnectMessage struct { // <1> - Metadata MessageMetadata `json:"metadata"` - Payload ReconnectMessagePayload `json:"payload"` -} - -type ReconnectMessagePayload struct { // <2> - Session ReconnectMessagePayloadSession `json:"session"` -} - -type ReconnectMessagePayloadSession struct { // <3> - ID string `json:"id"` - Status string `json:"status"` - KeepaliveTimeoutSeconds *int `json:"keepalive_timeout_seconds"` - ReconnectUrl string `json:"reconnect_url"` - ConnectedAt string `json:"connected_at"` -} - -/* ** Keepalive message ** -{ // <1> - "metadata": { // - "message_id": "84c1e79a-2a4b-4c13-ba0b-4312293e9308", - "message_type": "session_keepalive", - "message_timestamp": "2019-11-16T10:11:12.123Z" - }, - "payload": {} // struct{} -} -*/ - -type KeepaliveMessage struct { // <1> - Metadata MessageMetadata `json:"metadata"` - Payload KeepaliveMessagePayload `json:"payload"` -} - -type KeepaliveMessagePayload struct{} +package mock_server + +import "github.com/twitchdev/twitch-cli/internal/models" + +// Generic response message Metadata; Always the same + +type MessageMetadata struct { + MessageID string `json:"message_id"` + MessageType string `json:"message_type"` + MessageTimestamp string `json:"message_timestamp"` + + SubscriptionType string `json:"subscription_type,omitempty"` + SubscriptionVersion string `json:"subscription_version,omitempty"` +} + +/* ** Welcome message ** +{ // <1> + "metadata": { // + "message_id": "befa7b53-d79d-478f-86b9-120f112b044e", + "message_type": "session_welcome", + "message_timestamp": "2019-11-16T10:11:12.123Z" + }, + "payload": { // <2> + "session": { // <3> + "id": "AQoQexAWVYKSTIu4ec_2VAxyuhAB", + "status": "connected", + "keepalive_timeout_seconds": 10, + "reconnect_url": null, + "connected_at": "2019-11-16T10:11:12.123Z" + } + } +} +*/ + +type WelcomeMessage struct { // <1> + Metadata MessageMetadata `json:"metadata"` + Payload WelcomeMessagePayload `json:"payload"` +} + +type WelcomeMessagePayload struct { // <2> + Session WelcomeMessagePayloadSession `json:"session"` +} + +type WelcomeMessagePayloadSession struct { // <3> + ID string `json:"id"` + Status string `json:"status"` + KeepaliveTimeoutSeconds int `json:"keepalive_timeout_seconds"` + ReconnectUrl *string `json:"reconnect_url"` + ConnectedAt string `json:"connected_at"` +} + +/* ** Reconnect message ** +{ // <1> + "metadata": { // + "message_id": "84c1e79a-2a4b-4c13-ba0b-4312293e9308", + "message_type": "session_reconnect", + "message_timestamp": "2019-11-18T09:10:11.234Z" + }, + "payload": { // <2> + "session": { // <3> + "id": "AQoQexAWVYKSTIu4ec_2VAxyuhAB", + "status": "reconnecting", + "keepalive_timeout_seconds": null, + "reconnect_url": "wss://eventsub-experimental.wss.twitch.tv?...", + "connected_at": "2019-11-16T10:11:12.123Z" + } + } +} +*/ + +type ReconnectMessage struct { // <1> + Metadata MessageMetadata `json:"metadata"` + Payload ReconnectMessagePayload `json:"payload"` +} + +type ReconnectMessagePayload struct { // <2> + Session ReconnectMessagePayloadSession `json:"session"` +} + +type ReconnectMessagePayloadSession struct { // <3> + ID string `json:"id"` + Status string `json:"status"` + KeepaliveTimeoutSeconds *int `json:"keepalive_timeout_seconds"` + ReconnectUrl string `json:"reconnect_url"` + ConnectedAt string `json:"connected_at"` +} + +/* ** Keepalive message ** +{ // <1> + "metadata": { // + "message_id": "84c1e79a-2a4b-4c13-ba0b-4312293e9308", + "message_type": "session_keepalive", + "message_timestamp": "2019-11-16T10:11:12.123Z" + }, + "payload": {} // struct{} +} +*/ + +type KeepaliveMessage struct { // <1> + Metadata MessageMetadata `json:"metadata"` + Payload KeepaliveMessagePayload `json:"payload"` +} + +type KeepaliveMessagePayload struct{} + +/* ** Notification message ** +{ // <1> + "metadata": { // + "message_id": "befa7b53-d79d-478f-86b9-120f112b044e", + "message_type": "notification", + "message_timestamp": "2019-11-16T10:11:12.464757833Z", + "subscription_type": "channel.follow", + "subscription_version": "1" + }, + "payload": { // + "subscription": { + ... + }, + "event": { + ... + } + } +} +*/ + +type NotificationMessage struct { // <1> + Metadata MessageMetadata `json:"metadata"` + Payload models.EventsubResponse `json:"payload"` +} diff --git a/internal/events/websocket/mock_server/rpc_handler.go b/internal/events/websocket/mock_server/rpc_handler.go new file mode 100644 index 00000000..dade9444 --- /dev/null +++ b/internal/events/websocket/mock_server/rpc_handler.go @@ -0,0 +1,269 @@ +package mock_server + +import ( + "fmt" + "log" + "regexp" + "strconv" + + "github.com/gorilla/websocket" + rpc "github.com/twitchdev/twitch-cli/internal/rpc" + "github.com/twitchdev/twitch-cli/internal/util" +) + +var sessionRegex = regexp.MustCompile(`(?P.+)_(?P.+)`) + +const ( + COMMAND_RESPONSE_SUCCESS int = 0 + COMMAND_RESPONSE_INVALID_CMD int = 1 + COMMAND_RESPONSE_FAILED_ON_SERVER int = 2 + COMMAND_RESPONSE_MISSING_FLAG int = 3 +) + +// Resolves console commands to their RPC names defined in the server manager +func ResolveRPCName(cmd string) string { + if cmd == "reconnect" { + return "EventSubWebSocketReconnect" + } else if cmd == "close" { + return "EventSubWebSocketCloseClient" + } else if cmd == "subscription" { + return "EventSubWebSocketSubscription" + } else { + return "" + } +} + +// $ twitch event websocket reconnect +func RPCReconnectHandler(args rpc.RPCArgs) rpc.RPCResponse { + // Initiate reconnect testing + log.Printf("Initiating reconnect testing...") + + if serverManager.reconnectTesting { + log.Printf("Error on RPC call (EventSubWebSocketReconnect): Cannot execute reconnect testing while its already in progress. Aborting.") + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + } + } + + // Find current primary server + originalPrimaryServer, ok := serverManager.serverList.Get(serverManager.primaryServer) + if !ok { + log.Printf("Error on RPC call (EventSubWebSocketReconnect): Primary server not in server list.") + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + } + } + + serverManager.reconnectTesting = true + + // Get the list of reconnect clients ready + reconnectClients := originalPrimaryServer.GetCurrentSubscriptionsForReconnect() + + // Spin up new server + newServer := &WebSocketServer{ + ServerId: util.RandomGUID()[:8], + Status: 2, + Clients: &util.List[Client]{ + Elements: make(map[string]*Client), + }, + Upgrader: websocket.Upgrader{}, + DebugEnabled: serverManager.debugEnabled, + Subscriptions: make(map[string][]Subscription), + StrictMode: serverManager.strictMode, + ReconnectClients: reconnectClients, + } + serverManager.serverList.Put(newServer.ServerId, newServer) + + // Switch manager's primary server to new one + // Doing this before sending the reconnect messages emulates the Twitch's production load balancer, which will never send to servers shutting down. + serverManager.primaryServer = newServer.ServerId + + // Notify primary server to restart (includes not accepting new clients) + // This is in a goroutine so it doesn't hang the reconnect command + go func() { + originalPrimaryServer.InitiateRestart() + + // Remove server from server list + serverManager.serverList.Delete(originalPrimaryServer.ServerId) + + if serverManager.debugEnabled { + log.Printf( + "Removed server [%v] from server list. New server list count: %v", + originalPrimaryServer.ServerId, + serverManager.serverList.Length(), + ) + } + + serverManager.reconnectTesting = false + + log.Printf("Reconnect testing successful. Primary server is now [%v]\nYou may now execute reconnect testing again.", serverManager.primaryServer) + }() + + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_SUCCESS, + } +} + +// $ twitch event trigger --transport=websocket +func RPCFireEventSubHandler(args rpc.RPCArgs) rpc.RPCResponse { + server, ok := serverManager.serverList.Get(serverManager.primaryServer) + if !ok { + log.Printf("Error on RPC call (EventSubWebSocketForwardEvent): Primary server not in server list.") + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + } + } + + clientName, exists := args.Variables["ClientName"] + if !exists { + + } + if sessionRegex.MatchString(clientName) { + // Users can include the full session_id given in the response. If they do, subtract it to just the client name + clientName = sessionRegex.FindAllStringSubmatch(clientName, -1)[0][2] + } + + success, failMsg := server.HandleRPCEventSubForwarding(args.Body, clientName) + + if success { + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_SUCCESS, + } + } else { + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + DetailedInfo: failMsg, + } + } +} + +// $ twitch event websocket close +func RPCCloseHandler(args rpc.RPCArgs) rpc.RPCResponse { + closeCode, err := strconv.Atoi(args.Variables["CloseReason"]) + + if err != nil || args.Variables["ClientName"] == "" || args.Variables["CloseReason"] == "" { + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_MISSING_FLAG, + DetailedInfo: "Command \"close\" requires flags --session and --reason" + + "\nThe flag --reason must be one of the number codes defined here:" + + "\nhttps://dev.twitch.tv/docs/eventsub/websocket-reference/#close-message" + + "\n\nExample: twitch event websocket close --session=e411cc1e_a2613d4e --reason=4006", + } + } + + clientName := args.Variables["ClientName"] + + if serverManager.reconnectTesting { + log.Printf("Error on RPC call (EventSubWebSocketCloseClient): Could not activate while reconnect testing is active.") + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + DetailedInfo: "Cannot activate this command while reconnect testing is active.", + } + } + + server, ok := serverManager.serverList.Get(serverManager.primaryServer) + if !ok { + log.Printf("Error on RPC call (EventSubWebSocketCloseClient): Primary server not in server list.") + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + DetailedInfo: "Primary server not in server list.", + } + } + + cn := clientName + if sessionRegex.MatchString(clientName) { + // Client name given was formatted as _. We must extract it + sessionRegexExec := sessionRegex.FindAllStringSubmatch(clientName, -1) + cn = sessionRegexExec[0][2] + } + + server.muClients.Lock() + + client, ok := server.Clients.Get(cn) + if !ok { + server.muClients.Unlock() + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + DetailedInfo: "Client [" + cn + "] does not exist on WebSocket server.", + } + } + + closeMessage := GetCloseMessageFromCode(closeCode) + if closeMessage == nil { + server.muClients.Unlock() + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + DetailedInfo: fmt.Sprintf("Close code [%v] not supported.", closeCode), + } + } + + server.muClients.Unlock() + + client.CloseWithReason(closeMessage) + server.handleClientConnectionClose(client, closeMessage) + + log.Printf("RPC instructed to close client [%v] with code [%v]", clientName, closeCode) + + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_SUCCESS, + } +} + +// $ twitch event websocket subscription +func RPCSubscriptionHandler(args rpc.RPCArgs) rpc.RPCResponse { + if args.Variables["SubscriptionID"] == "" || !IsValidSubscriptionStatus(args.Variables["SubscriptionStatus"]) { + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_MISSING_FLAG, + DetailedInfo: "Command \"subscription\" requires flags --status, --subscription, and --session" + + fmt.Sprintf("\nThe flag --subscription must be the ID of the subscription made at http://localhost:%v/eventsub/subscriptions", serverManager.port) + + "\nThe flag --status must be one of the non-webhook status options defined here:" + + "\nhttps://dev.twitch.tv/docs/api/reference/#get-eventsub-subscriptions" + + "\n\nExample: twitch event websocket subscription --status=user_removed --subscription=82a855-fae8-93bff0", + } + } + + if serverManager.reconnectTesting { + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + DetailedInfo: "Cannot activate this command while reconnect testing is active.", + } + } + + server, ok := serverManager.serverList.Get(serverManager.primaryServer) + if !ok { + log.Printf("Error on RPC call (EventSubWebSocketSubscription): Primary server not in server list.") + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + DetailedInfo: "See server console for more details.", + } + } + + server.muSubscriptions.Lock() + found := false + for client, clientSubscriptions := range server.Subscriptions { + if found { + break + } + + for i, sub := range clientSubscriptions { + if sub.SubscriptionID == args.Variables["SubscriptionID"] { + found = true + + server.Subscriptions[client][i].Status = args.Variables["SubscriptionStatus"] + break + } + } + } + server.muSubscriptions.Unlock() + + if !found { + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + DetailedInfo: fmt.Sprintf("Subscription ID [%v] does not exist", args.Variables["SubscriptionID"]), + } + } + + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_SUCCESS, + } +} diff --git a/internal/events/websocket/mock_server/server.go b/internal/events/websocket/mock_server/server.go new file mode 100644 index 00000000..b9e1a30a --- /dev/null +++ b/internal/events/websocket/mock_server/server.go @@ -0,0 +1,518 @@ +package mock_server + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/internal/util" +) + +// Minimum time between messages before the server disconnects a client. +const KEEPALIVE_TIMEOUT_SECONDS = 10 + +type WebSocketServer struct { + ServerId string // Int representing the ID of the server + //ConnectionUrl string // Server's url for people to connect to. Used for messaging in reconnect testing + DebugEnabled bool // Display debug messages; --debug + StrictMode bool // Force stricter production-like qualities; --strict + Upgrader websocket.Upgrader + + Clients *util.List[Client] // All connected clients + muClients sync.Mutex // Mutex for WebSocketServer.Clients + + Status int // 0 = shut down; 1 = shutting down; 2 = online + muStatus sync.Mutex // Mutex for WebSocketServer.Status + + Subscriptions map[string][]Subscription // Active subscriptions on this server -- Accessed via Subscriptions[clientName] + muSubscriptions sync.Mutex // Mutex for WebSocketServer.Subscriptions + + ReconnectClients *util.List[[]Subscription] // Clients that were part of the last server + muReconnectClients sync.Mutex // Mutex for WebSocketServer.ReconnectClients +} + +func (ws *WebSocketServer) WsPageHandler(w http.ResponseWriter, r *http.Request) { + // This next line is required to disable CORS checking. No sense in caring in a test environment. + ws.Upgrader.CheckOrigin = func(r *http.Request) bool { return true } + + conn, err := ws.Upgrader.Upgrade(w, r, nil) + if err != nil { + log.Print("[[websocket upgrade err]] ", err) + return + } + defer conn.Close() + + // Connection successful. WebSocket is open. + + // Get connected at time and set automatic read timeout + connectedAtTimestamp := time.Now().UTC().Format(time.RFC3339Nano) + conn.SetReadDeadline(time.Now().Add(time.Second * KEEPALIVE_TIMEOUT_SECONDS)) + + client := &Client{ + clientName: util.RandomGUID()[:8], + conn: conn, + ConnectedAtTimestamp: connectedAtTimestamp, + connectionUrl: fmt.Sprintf("http://%v/ws", r.Host), + keepAliveChanOpen: false, + pingChanOpen: false, + } + + if r.URL.Query().Get("reconnect_id") != "" { + reconnectIdBytes, err := base64.StdEncoding.DecodeString(r.URL.Query().Get("reconnect_id") + "=") + if err != nil { + if ws.DebugEnabled { + log.Printf("Could not decode base64 reconnect_id query parameter: '%v'", r.URL.Query().Get("reconnect_id")) + } + } else { + reconnectId := string(reconnectIdBytes) + + ws.muReconnectClients.Lock() + + subscriptions, ok := ws.ReconnectClients.Get(reconnectId) + if ok { // User had subscriptions carry over + ws.Subscriptions[client.clientName] = *subscriptions + } + + ws.ReconnectClients.Delete(reconnectId) + + if ws.DebugEnabled { + log.Printf("Reconnected client [%v] was assigned %v subscriptions", client.clientName, len(ws.Subscriptions[client.clientName])) + } + + ws.muReconnectClients.Unlock() + } + } + + // Disconnect the user if the server is in reconnect phase + ws.muStatus.Lock() + if ws.Status != 2 { + // This is the closest we can get to the production environment, as there's no way to route people to a shutting down server + log.Printf("New client trying to connect while websocket server in reconnect phase. Disconnecting them.") + client.CloseDirty() + // No handleClientConnectionClose because client is not in clients list, and chan loop was not set up yet. + + ws.muStatus.Unlock() + return + } + + // TODO: Check if user is connected to the old server, and if they are then disconnect them from old server with close frame 4004 + + ws.muClients.Lock() + // Add to the client connections list + ws.Clients.Put(client.clientName, client) + ws.muClients.Unlock() + + // This is put after ws.Clients.Put to make sure the client gets included in the list before InitiateRestart() kicks everyone out + // Avoids any possible rare edge cases. This ain't production but I can still be safe :) + ws.muStatus.Unlock() + + log.Printf("Client connected [%v]", client.clientName) + ws.printConnections() + + // Send welcome message + welcomeMsg, _ := json.Marshal( + WelcomeMessage{ + Metadata: MessageMetadata{ + MessageID: util.RandomGUID(), + MessageType: "session_welcome", + MessageTimestamp: time.Now().UTC().Format(time.RFC3339Nano), + }, + Payload: WelcomeMessagePayload{ + Session: WelcomeMessagePayloadSession{ + ID: fmt.Sprintf("%v_%v", ws.ServerId, client.clientName), + Status: "connected", + KeepaliveTimeoutSeconds: KEEPALIVE_TIMEOUT_SECONDS, + ReconnectUrl: nil, + ConnectedAt: connectedAtTimestamp, + }, + }, + }, + ) + client.SendMessage(websocket.TextMessage, welcomeMsg) + + // Check if any subscriptions are sent after 10 seconds. + // Strict mode only + client.mustSubscribeTimer = time.NewTimer(10 * time.Second) + if ws.StrictMode { + go func() { + select { + case <-client.mustSubscribeTimer.C: + if len(ws.Subscriptions[client.clientName]) == 0 { + client.CloseWithReason(closeConnectionUnused) + ws.handleClientConnectionClose(client, closeConnectionUnused) + + return + } + } + }() + } + + // Set up ping/pong and keepalive handling + client.keepAliveTimer = time.NewTicker(10 * time.Second) + client.pingTimer = time.NewTicker(5 * time.Second) + client.keepAliveLoopChan = make(chan struct{}) + client.pingLoopChan = make(chan struct{}) + client.keepAliveChanOpen = true + client.pingChanOpen = true + go func() { + // Set pong handler. Resets the read deadline when pong is received. + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(time.Second * KEEPALIVE_TIMEOUT_SECONDS)) + return nil + }) + + for { + select { + case <-client.keepAliveLoopChan: + client.keepAliveTimer.Stop() + + case <-client.keepAliveTimer.C: // Send KeepAlive message + keepAliveMsg, _ := json.Marshal( + KeepaliveMessage{ + Metadata: MessageMetadata{ + MessageID: util.RandomGUID(), + MessageType: "session_keepalive", + MessageTimestamp: time.Now().UTC().Format(time.RFC3339Nano), + }, + Payload: KeepaliveMessagePayload{}, + }, + ) + err := client.SendMessage(websocket.TextMessage, keepAliveMsg) + if err != nil { + client.CloseWithReason(closeNetworkError) + } + + if ws.DebugEnabled { + log.Printf("Sent session_keepalive to client [%s]", client.clientName) + } + + case <-client.pingLoopChan: + client.pingTimer.Stop() + + case <-client.pingTimer.C: // Send ping + err := client.SendMessage(websocket.PingMessage, []byte{}) + if err != nil { + ws.muClients.Lock() + client.CloseWithReason(closeClientFailedPingPong) + ws.handleClientConnectionClose(client, closeClientFailedPingPong) + ws.muClients.Unlock() + } + + if ws.DebugEnabled { + log.Printf("Sent pong to client [%s]", client.clientName) + } + + } + } + }() + + // Wait for message + for { + // Reset timeout upon every message, no matter what it is. + client.conn.SetReadDeadline(time.Now().Add(time.Second * KEEPALIVE_TIMEOUT_SECONDS)) + + mt, message, err := conn.ReadMessage() + if err != nil && ws.Status != 0 { // If server is shut down, clients should already be disconnectd. + log.Printf("read err [%v]: %v", client.clientName, err) + + ws.muClients.Lock() + client.CloseWithReason(closeNetworkError) + ws.handleClientConnectionClose(client, closeNetworkError) + ws.muClients.Unlock() + break + } + + if ws.Status == 2 { // Only care about this when the server is running + log.Printf("Disconnecting client [%v] due to received inbound traffic.\nMessage[%d]: %s", client.clientName, mt, message) + + ws.muClients.Lock() + client.CloseWithReason(closeClientSentInboundTraffic) + ws.handleClientConnectionClose(client, closeClientSentInboundTraffic) + ws.muClients.Unlock() + } + + break + } +} + +// Gets client subscriptions to be transfered to another server. Used during reconnect testing. +func (ws *WebSocketServer) GetCurrentSubscriptionsForReconnect() *util.List[[]Subscription] { + reconnectClients := &util.List[[]Subscription]{ + Elements: make(map[string]*[]Subscription), + } + + ws.muSubscriptions.Lock() + + for clientName, clientSubscriptions := range ws.Subscriptions { + for _, subscription := range clientSubscriptions { + reconnectReference := fmt.Sprintf("%v_%v", ws.ServerId, clientName) + + oldReconnectSubs, ok := reconnectClients.Get(reconnectReference) + if !ok { + oldReconnectSubs = &[]Subscription{} + } + + // Add to oldReconnectSubs + *oldReconnectSubs = append(*oldReconnectSubs, subscription) + + // Return to list + reconnectClients.Put(reconnectReference, oldReconnectSubs) + } + } + + ws.muSubscriptions.Unlock() + + return reconnectClients +} + +func (ws *WebSocketServer) InitiateRestart() { + // Set status to shutting down; Stop accepting new clients + ws.muStatus.Lock() + ws.Status = 1 + ws.muStatus.Unlock() + + ws.muClients.Lock() + + if ws.DebugEnabled { + log.Printf("Sending reconnect notices to [%v] clients", ws.Clients.Length()) + } + + // Send reconnect messages and disable timers on all clients + for _, client := range ws.Clients.All() { + // Disable keepalive and subscription timers + close(client.keepAliveLoopChan) + client.keepAliveChanOpen = false + client.mustSubscribeTimer.Stop() + + // Send reconnect notice + sessionId := fmt.Sprintf("%v_%v", ws.ServerId, client.clientName) + reconnectId := base64.StdEncoding.EncodeToString([]byte(sessionId)) + reconnectId = reconnectId[:len(reconnectId)-1] + clientConnectionUrl := strings.Replace(client.connectionUrl, "http://", "ws://", -1) + clientConnectionUrl = strings.Replace(clientConnectionUrl, "https://", "wss://", -1) + reconnectMsg, _ := json.Marshal( + ReconnectMessage{ + Metadata: MessageMetadata{ + MessageID: util.RandomGUID(), + MessageType: "session_reconnect", + MessageTimestamp: time.Now().UTC().Format(time.RFC3339Nano), + }, + Payload: ReconnectMessagePayload{ + Session: ReconnectMessagePayloadSession{ + ID: sessionId, + Status: "reconnecting", + KeepaliveTimeoutSeconds: nil, + ReconnectUrl: fmt.Sprintf("%v?reconnect_id=%v", clientConnectionUrl, reconnectId), + ConnectedAt: client.ConnectedAtTimestamp, + }, + }, + }, + ) + + err := client.SendMessage(websocket.TextMessage, reconnectMsg) + if err != nil { + log.Printf("Error building session_reconnect JSON for client [%v]: %v", client.clientName, err.Error()) + } + } + + log.Printf("Reconnect notices sent for server [%v]", ws.ServerId) + log.Printf("Will disconnect all existing clients in 30 seconds...") + + ws.muClients.Unlock() + + // Wait 30 seconds + time.Sleep(30 * time.Second) + + // Change server status to 0 + // This is done before disconnects because the read loop will err out due to the close message, which gets printed unless this is zero. + ws.Status = 0 + + // Disconnect everyone with reconnect close message + for _, client := range ws.Clients.All() { + ws.muClients.Lock() + client.CloseWithReason(closeReconnectGraceTimeExpired) + ws.handleClientConnectionClose(client, closeReconnectGraceTimeExpired) + ws.muClients.Unlock() + } + + log.Printf("All users disconnected from server [%v]", ws.ServerId) +} + +func (ws *WebSocketServer) HandleRPCEventSubForwarding(eventsubBody string, clientName string) (bool, string) { + // If --session is used, make sure the client exists + if clientName != "" { + _, ok := ws.Clients.Get(strings.ToLower(clientName)) + if !ok { + msg := fmt.Sprintf("Error executing remote triggered EventSub: Client [%v] does not exist on server [%v]", clientName, ws.ServerId) + log.Println(msg) + return false, msg + } + } + + if ws.Clients.Length() == 0 { + msg := fmt.Sprintf("Warning for remote triggered EventSub: No clients in server [%v]", ws.ServerId) + log.Println(msg) + return false, msg + } + + // Convert to struct for editing + eventObj := models.EventsubResponse{} + err := json.Unmarshal([]byte(eventsubBody), &eventObj) + if err != nil { + msg := fmt.Sprintf("Error reading JSON forwarded from EventSub: %v\nRaw: %v", err.Error(), eventsubBody) + log.Println(msg) + return false, msg + } + + didSend := false + + for _, client := range ws.Clients.All() { + if clientName != "" && !strings.EqualFold(strings.ToLower(clientName), clientName) { + // When --session is used, only send to that client + continue + } + + // If this is a Revocation message (user.authorization.revoke), set it as revoked + if eventObj.Subscription.Type == "user.authorization.revoke" { + if serverManager.debugEnabled { + log.Printf("Attempting to revoke subscription [%v]", eventObj.Subscription.ID) + } + + ws.muSubscriptions.Lock() + foundClientId := "" + for client, clientSubscriptions := range ws.Subscriptions { + if foundClientId != "" { + break + } + + for i, sub := range clientSubscriptions { + if sub.SubscriptionID == eventObj.Subscription.ID { + foundClientId = sub.ClientID + + ws.Subscriptions[client][i].Status = STATUS_AUTHORIZATION_REVOKED + break + } + } + } + ws.muSubscriptions.Unlock() + + if foundClientId != "" { + log.Printf("Subscription ID [%v], belonging to Client ID [%v], has been revoked.", eventObj.Subscription.ID, foundClientId) + } else { + msg := fmt.Sprintf("Failed to revoke Subscription ID [%v]: Subscription by that ID does not exist.", eventObj.Subscription.ID) + log.Println(msg) + return false, msg + } + } + + // Check for subscriptions when running with --require-subscription + if ws.StrictMode { + found := false + for _, clientSubscriptions := range ws.Subscriptions { + if found { + break + } + + for _, sub := range clientSubscriptions { + if sub.SessionClientName == client.clientName && sub.Type == eventObj.Subscription.Type && sub.Version == eventObj.Subscription.Version { + found = true + } + } + } + + if !found { + continue + } + } + + // Change payload's subscription.transport.session_id to contain the correct Session ID + eventObj.Subscription.Transport.SessionID = fmt.Sprintf("%v_%v", ws.ServerId, client.clientName) + + // Build notification message + notificationMsg, err := json.Marshal( + NotificationMessage{ + Metadata: MessageMetadata{ + MessageID: util.RandomGUID(), + MessageType: "notification", + MessageTimestamp: time.Now().UTC().Format(time.RFC3339Nano), + SubscriptionType: eventObj.Subscription.Type, + SubscriptionVersion: eventObj.Subscription.Version, + }, + Payload: eventObj, + }, + ) + if err != nil { + msg := fmt.Sprintf("Error building JSON for client [%v]: %v", client.clientName, err.Error()) + log.Println(msg) + return false, msg + } + + client.SendMessage(websocket.TextMessage, notificationMsg) + log.Printf("Sent [%v / %v] to client [%v]", eventObj.Subscription.Type, eventObj.Subscription.Version, client.clientName) + + didSend = true + } + + if !didSend { + msg := fmt.Sprintf("Error executing remote triggered EventSub: No clients with the subscribed to [%v / %v]", eventObj.Subscription.Type, eventObj.Subscription.Version) + log.Println(msg) + return false, msg + } + + return true, "" +} + +func (ws *WebSocketServer) handleClientConnectionClose(client *Client, closeReason *CloseMessage) { + // Prevent further looping + client.mustSubscribeTimer.Stop() + if client.keepAliveChanOpen { + close(client.keepAliveLoopChan) + client.keepAliveChanOpen = false + } + if client.pingChanOpen { + close(client.pingLoopChan) + client.pingChanOpen = false + } + + // Remove from clients list + ws.Clients.Delete(client.clientName) + + // Update subscriptions, unless close reason is for reconnect testing. + if ws.Status == 2 { + ws.muSubscriptions.Lock() + subscriptions := ws.Subscriptions[client.clientName] + for _, subscription := range subscriptions { + if subscription.Status == STATUS_ENABLED { + subscription.Status = getStatusFromCloseMessage(closeReason) + } + } + ws.Subscriptions[client.clientName] = subscriptions + ws.muSubscriptions.Unlock() + } + + log.Printf("Disconnected client [%v] with code [%v]", client.clientName, closeReason.code) + + // Print new clients connections list + ws.printConnections() +} + +func (ws *WebSocketServer) printConnections() { + currentConnections := "" + + for _, client := range ws.Clients.All() { + currentConnections += client.clientName + ", " + } + + if currentConnections != "" { + currentConnections = string(currentConnections[:len(currentConnections)-2]) + } + + log.Printf("[%s] Connections: (%d) [ %s ]", ws.ServerId, ws.Clients.Length(), currentConnections) +} diff --git a/internal/events/websocket/mock_server/subscription.go b/internal/events/websocket/mock_server/subscription.go new file mode 100644 index 00000000..39e98f11 --- /dev/null +++ b/internal/events/websocket/mock_server/subscription.go @@ -0,0 +1,132 @@ +package mock_server + +type Subscription struct { + SubscriptionID string // Random GUID for the subscription + ClientID string // Client ID included in headers + Type string // EventSub topic + Version string // EventSub topic version + CreatedAt string // Timestamp of when the subscription was created + Status string // Status of the subscription + SessionClientName string // Client name of the session this is associated with. +} + +// Request - POST /eventsub/subscriptions +type SubscriptionPostRequest struct { + Type string `json:"type"` + Version string `json:"version"` + Condition interface{} `json:"condition"` + + Transport SubscriptionPostRequestTransport `json:"transport"` +} + +// Request - POST /eventsub/subscriptions +type SubscriptionPostRequestTransport struct { + Method string `json:"method"` + SessionID string `json:"session_id"` +} + +// Response (Success) - POST /eventsub/subscriptions +type SubscriptionPostSuccessResponse struct { + Body SubscriptionPostSuccessResponseBody `json:"body"` + + Total int `json:"total"` + MaxTotalCost int `json:"max_total_cost"` + TotalCost int `json:"total_cost"` +} + +// Response (Success) - POST /eventsub/subscriptions +// Response (Success) - GET /eventsub/subscriptions +type SubscriptionPostSuccessResponseBody struct { + ID string `json:"id"` + Status string `json:"status"` + Type string `json:"type"` + Version string `json:"version"` + CreatedAt string `json:"created_at"` + Cost int `json:"cost"` + + Condition EmptyStruct `json:"condition"` + Transport SubscriptionTransport `json:"transport"` +} + +// Response (Error) - POST /eventsub/subscriptions +type SubscriptionPostErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` + Status int `json:"status"` +} + +// Response (Success) - GET /eventsub/subscriptions +type SubscriptionGetSuccessResponse struct { + Total int `json:"total"` + TotalCost int `json:"total_cost"` + MaxTotalCost int `json:"max_total_cost"` + Pagination EmptyStruct `json:"pagination"` + + Data []SubscriptionPostSuccessResponseBody `json:"data"` +} + +// Cross-usage +type SubscriptionTransport struct { + Method string `json:"method"` + SessionID string `json:"session_id"` + ConnectedAt string `json:"connected_at"` +} + +// Cross-usage +type EmptyStruct struct { +} + +// Subscription Statuses +// Only includes status values that apply to WebSocket connections +// https://dev.twitch.tv/docs/api/reference/#get-eventsub-subscriptions +const ( + STATUS_ENABLED = "enabled" + STATUS_AUTHORIZATION_REVOKED = "revoked" + STATUS_MODERATOR_REMOVED = "moderator_removed" + STATUS_USER_REMOVED = "user_removed" + STATUS_VERSION_REMOVED = "version_removed" + STATUS_WEBSOCKET_DISCONNECTED = "websocket_disconnected" + STATUS_WEBSOCKET_FAILED_PING_PONG = "websocket_failed_ping_pong" + STATUS_WEBSOCKET_RECEIVED_INBOUND_TRAFFIC = "websocket_received_inbound_traffic" + STATUS_WEBSOCKET_CONNECTION_UNUSED = "websocket_connection_unused" + STATUS_INTERNAL_ERROR = "websocket_internal_error" + STATUS_NETWORK_TIMEOUT = "network_timeout" + STATUS_NETWORK_ERROR = "websocket_network_error" +) + +func IsValidSubscriptionStatus(status string) bool { + switch status { + case STATUS_ENABLED, STATUS_AUTHORIZATION_REVOKED, + STATUS_MODERATOR_REMOVED, STATUS_USER_REMOVED, + STATUS_VERSION_REMOVED, STATUS_WEBSOCKET_DISCONNECTED, + STATUS_WEBSOCKET_FAILED_PING_PONG, STATUS_WEBSOCKET_RECEIVED_INBOUND_TRAFFIC, + STATUS_WEBSOCKET_CONNECTION_UNUSED, STATUS_INTERNAL_ERROR, + STATUS_NETWORK_TIMEOUT, STATUS_NETWORK_ERROR: + return true + default: + return false + } +} + +func getStatusFromCloseMessage(reason *CloseMessage) string { + code := reason.code + + switch code { + case 4000: + return STATUS_INTERNAL_ERROR + case 4001: + return STATUS_WEBSOCKET_RECEIVED_INBOUND_TRAFFIC + case 4002: + return STATUS_WEBSOCKET_FAILED_PING_PONG + case 4003: + return STATUS_WEBSOCKET_CONNECTION_UNUSED + case 4004: // grace time expired. Subscriptions stay open + return STATUS_ENABLED + case 4005: + return STATUS_NETWORK_TIMEOUT + case 4006: + return STATUS_NETWORK_ERROR + default: + return STATUS_WEBSOCKET_DISCONNECTED + } +} diff --git a/internal/events/websocket/websocket_cmd.go b/internal/events/websocket/websocket_cmd.go new file mode 100644 index 00000000..6d945c83 --- /dev/null +++ b/internal/events/websocket/websocket_cmd.go @@ -0,0 +1,64 @@ +package websocket + +import ( + "fmt" + "net/rpc" + + "github.com/fatih/color" + "github.com/twitchdev/twitch-cli/internal/events/websocket/mock_server" + rpc_handler "github.com/twitchdev/twitch-cli/internal/rpc" +) + +type WebsocketCommandParameters struct { + Client string + Subscription string + SubscriptionStatus string + CloseReason string +} + +func ForwardWebsocketCommand(cmd string, p WebsocketCommandParameters) { + client, err := rpc.DialHTTP("tcp", ":44747") + if err != nil { + println("Failed to dial RPC handler for WebSocket server. Is it online?") + println("Error: " + err.Error()) + return + } + + var reply rpc_handler.RPCResponse + + rpcName := mock_server.ResolveRPCName(cmd) + if rpcName == "" { + println("Invalid websocket command") + return + } + + // Command line flags to be passed with the command + // Add them all, as it wont hurt anything if they're not relevant + variables := make(map[string]string) + variables["ClientName"] = p.Client + variables["SubscriptionID"] = p.Subscription + variables["SubscriptionStatus"] = p.SubscriptionStatus + variables["CloseReason"] = p.CloseReason + + args := &rpc_handler.RPCArgs{ + RPCName: rpcName, + Variables: variables, + } + + err = client.Call("RPCHandler.ExecuteGenericRPC", args, &reply) + + switch reply.ResponseCode { + case mock_server.COMMAND_RESPONSE_SUCCESS: + color.New().Add(color.FgGreen).Println(fmt.Sprintf("✔ Forwarded for use in mock EventSub WebSocket server")) + + case mock_server.COMMAND_RESPONSE_FAILED_ON_SERVER: + color.New().Add(color.FgRed).Println(fmt.Sprintf("✗ EventSub WebSocket server failed to process command:\n%v", reply.DetailedInfo)) + + case mock_server.COMMAND_RESPONSE_MISSING_FLAG: + color.New().Add(color.FgRed).Println(fmt.Sprintf("✗ Command rejected for invalid flags:\n%v", reply.DetailedInfo)) + + case mock_server.COMMAND_RESPONSE_INVALID_CMD: + println("Invalid websocket sub-command: " + cmd) + + } +} diff --git a/internal/models/authorization_revoke.go b/internal/models/authorization_revoke.go index 5a398f2f..a1249854 100644 --- a/internal/models/authorization_revoke.go +++ b/internal/models/authorization_revoke.go @@ -10,6 +10,6 @@ type AuthorizationRevokeEvent struct { } type AuthorizationRevokeEventSubResponse struct { - Subscription EventsubSubscription `json:"subscription"` - Event AuthorizationRevokeEvent `json:"event"` + Subscription EventsubSubscription `json:"subscription"` + Event *AuthorizationRevokeEvent `json:"event,omitempty"` } diff --git a/internal/models/eventsub.go b/internal/models/eventsub.go index 4c3c3f95..fe535033 100644 --- a/internal/models/eventsub.go +++ b/internal/models/eventsub.go @@ -14,8 +14,9 @@ type EventsubSubscription struct { } type EventsubTransport struct { - Method string `json:"method"` - Callback string `json:"callback"` + Method string `json:"method"` + Callback string `json:"callback,omitempty"` + SessionID string `json:"session_id,omitempty"` } type EventsubCondition struct { @@ -33,7 +34,7 @@ type EventsubCondition struct { type EventsubResponse struct { Subscription EventsubSubscription `json:"subscription"` - Event interface{} `json:"event"` + Event interface{} `json:"event,omitempty"` } type EventsubSubscriptionVerification struct { diff --git a/internal/models/transport.go b/internal/models/transport.go index 29636d72..686df58c 100644 --- a/internal/models/transport.go +++ b/internal/models/transport.go @@ -2,5 +2,5 @@ // SPDX-License-Identifier: Apache-2.0 package models -const TransportEventSub = "eventsub" -const TransportWebsockets = "websockets" +const TransportWebhook = "webhook" +const TransportWebSocket = "websocket" diff --git a/internal/rpc/rpc_handler.go b/internal/rpc/rpc_handler.go new file mode 100644 index 00000000..10a95762 --- /dev/null +++ b/internal/rpc/rpc_handler.go @@ -0,0 +1,77 @@ +package rpc_handler + +import ( + "net" + "net/http" + "net/rpc" + "strconv" +) + +var globalHandler RPCHandler + +type HandlerCallback func(RPCArgs) RPCResponse + +type RPCArgs struct { + RPCName string + Body string + Variables map[string]string +} + +type RPCResponse struct { + ResponseCode int + DetailedInfo string +} + +type RPCHandler struct { + Port int + Handlers map[string]HandlerCallback + listener net.Listener +} + +func (rpch *RPCHandler) RegisterHandler(rpcName string, callback HandlerCallback) bool { + _, exists := rpch.Handlers[rpcName] + if exists { + return false + } + + rpch.Handlers[rpcName] = callback + return true +} + +func (rpch *RPCHandler) StartBackgroundServer() error { + rpc.Register(rpch) + rpc.HandleHTTP() + l, err := net.Listen("tcp", ":"+strconv.Itoa(rpch.Port)) + if err != nil { + return err + } + rpch.listener = l + go http.Serve(rpch.listener, nil) + + return nil +} + +func (rpch *RPCHandler) ShutdownServer() { + rpch.listener.Close() +} + +func (rpch *RPCHandler) ExecuteGenericRPC(args RPCArgs, reply *RPCResponse) error { + handler, exists := rpch.Handlers[args.RPCName] + if !exists { + *reply = RPCResponse{ + ResponseCode: -1, // -1 is failed here; 0 is success; anything above zero is special failure code + DetailedInfo: "Given RPCName is not currently registered", + } + return nil + } + + *reply = handler(args) + + return nil +} + +// Used for testing connection +func (rphc *RPCHandler) Verify(args RPCArgs, reply *RPCArgs) error { + *reply = args + return nil +} diff --git a/internal/rpc/rpc_test.go b/internal/rpc/rpc_test.go new file mode 100644 index 00000000..ace962fb --- /dev/null +++ b/internal/rpc/rpc_test.go @@ -0,0 +1,65 @@ +package rpc_handler + +import ( + "encoding/json" + "net/rpc" + "testing" + + "github.com/twitchdev/twitch-cli/test_setup" +) + +type rpcTestStruct struct { + Field1 string `json:"field1"` + Field2 int `json:"field2"` +} + +func TestRPCVerify(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + field1 := "abcd" + field2 := 1234 + variables := make(map[string]string) + variables["Test1"] = "123" + variables["Test2"] = "789" + + r := RPCHandler{ + Port: 44748, + Handlers: make(map[string]HandlerCallback), + } + + err := r.StartBackgroundServer() + a.Nil(err, nil) + + client, err := rpc.DialHTTP("tcp", ":44748") + a.Nil(err) + defer client.Close() + + rpcBodyFormatted := rpcTestStruct{ + Field1: field1, + Field2: field2, + } + b, err := json.Marshal(rpcBodyFormatted) + a.Nil(err) + + args := RPCArgs{ + RPCName: "Test", + Body: string(b), + Variables: variables, + } + + var reply RPCArgs + err = client.Call("RPCHandler.Verify", args, &reply) + a.Nil(err) + + var formattedReply rpcTestStruct + err = json.Unmarshal([]byte(reply.Body), &formattedReply) + a.Nil(err) + + a.Equal(args.RPCName, reply.RPCName) + a.Equal(rpcBodyFormatted.Field1, formattedReply.Field1) + a.Equal(rpcBodyFormatted.Field2, formattedReply.Field2) + a.Equal(args.Variables["Test1"], reply.Variables["Test1"]) + a.Equal(args.Variables["Test2"], reply.Variables["Test2"]) + + r.listener.Close() +} diff --git a/internal/util/generic_list.go b/internal/util/generic_list.go new file mode 100644 index 00000000..ceef6e2c --- /dev/null +++ b/internal/util/generic_list.go @@ -0,0 +1,31 @@ +package util + +type List[T any] struct { + Elements map[string]*T +} + +func (c *List[T]) Get(key string) (*T, bool) { + element, ok := c.Elements[key] + return element, ok +} + +func (c *List[T]) Put(key string, element *T) { + c.Elements[key] = element +} + +func (c *List[T]) Delete(key string) { + delete(c.Elements, key) +} + +func (c *List[T]) Length() int { + n := len(c.Elements) + return n +} + +func (c *List[T]) All() []*T { + elements := []*T{} + for _, el := range c.Elements { + elements = append(elements, el) + } + return elements +}