@overlaysymphony/twitch 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@overlaysymphony/twitch",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Twitch module for the OverlaySymphony interactive streaming framework.",
5
5
  "homepage": "https://github.com/OverlaySymphony/overlaysymphony",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/OverlaySymphony/overlaysymphony"
9
+ },
6
10
  "type": "module",
7
11
  "exports": {
8
12
  "./authentication": "./src/authentication/index.ts",
package/src/chat/chat.ts CHANGED
@@ -1,161 +1,96 @@
1
- import createDefer from "@overlaysymphony/core/libs/defer"
2
- import createPubSub from "@overlaysymphony/core/libs/pubsub"
3
-
4
1
  import { type Authentication } from "../authentication/index.ts"
2
+ import { type EventPayload } from "../eventsub/events-helpers.ts"
3
+ import createEventSub, { type TwitchEventSub } from "../eventsub/index.ts"
4
+ import { sendChatAnnouncement, sendChatMessage } from "../helix/chat/index.ts"
5
+
6
+ type ChatMessage = EventPayload<"channel.chat.message">["event"]
7
+ type ChatCommand = ChatMessage & {
8
+ message: {
9
+ command: string
10
+ parameters?: string[]
11
+ } & ChatMessage["message"]
12
+ }
5
13
 
6
- import {
7
- type TwitchChatEvent,
8
- type TwitchChatEventType,
9
- } from "./interfaces/index.ts"
10
- import parseCommand from "./parser.ts"
11
-
12
- type ChatListener = (callback: (event: TwitchChatEvent) => void) => () => void
13
-
14
- type ChatSubscriber = <
15
- EventType extends TwitchChatEventType,
16
- Event extends TwitchChatEvent<EventType>,
17
- >(
18
- types: EventType[],
19
- callback: (event: Event) => void,
20
- ) => () => void
21
-
22
- type ChatSender = (message: string) => void
14
+ type ChatSender = (message: string) => Promise<void>
15
+ type ChatAnnouncer = (message: string, color?: string) => Promise<void>
23
16
 
24
17
  type ChatMessageSubscriber = (
25
- callback: (event: TwitchChatEvent<"PRIVMSG">) => void,
18
+ callback: (event: ChatMessage) => void,
26
19
  ) => () => void
27
20
 
28
- interface ChatCommandSubscriber {
29
- (
30
- callback: (event: TwitchChatEvent<"PRIVMSG-COMMAND">) => void,
31
- name?: never,
32
- ): () => void
33
- (
34
- name: string,
35
- callback: (event: TwitchChatEvent<"PRIVMSG-COMMAND">) => void,
36
- ): () => void
37
- }
21
+ type ChatCommandSubscriber = (
22
+ name: string,
23
+ callback: (event: ChatCommand) => void,
24
+ ___?: never,
25
+ ) => () => void
26
+ // type ChatCommandSubscriber = (
27
+ // name: string,
28
+ // pattern: string,
29
+ // callback: (event: ChatCommand) => void,
30
+ // ) => () => void
38
31
 
39
32
  export interface TwitchChat {
40
- listen: ChatListener
41
- subscribe: ChatSubscriber
42
33
  send: ChatSender
34
+ announce: ChatAnnouncer
43
35
  onMessage: ChatMessageSubscriber
44
36
  onCommand: ChatCommandSubscriber
45
37
  }
46
38
 
47
39
  export default async function createChat(
48
40
  authentication: Authentication,
49
- channel: string = authentication.user.login,
41
+ eventsub?: TwitchEventSub,
50
42
  ): Promise<TwitchChat> {
51
- const { promise, resolve } = createDefer()
43
+ eventsub ??= await createEventSub(authentication)
52
44
 
53
- const pubsub = createPubSub<TwitchChatEvent>()
54
- const socket = new WebSocket("wss://irc-ws.chat.twitch.tv:443")
45
+ const send: ChatSender = async (message) => {
46
+ await sendChatMessage(authentication, message)
47
+ }
55
48
 
56
- socket.addEventListener("open", (connection) => {
57
- socket.send("CAP REQ :twitch.tv/tags twitch.tv/commands")
58
- })
49
+ const announce: ChatAnnouncer = async (message, color) => {
50
+ await sendChatAnnouncement(authentication, message, color)
51
+ }
59
52
 
60
- socket.addEventListener("message", ({ data }: { data: string }) => {
61
- const messages = data.split("\r\n").filter(Boolean)
62
- for (const input of messages) {
63
- const command = parseCommand(input)
64
- if (!command) {
65
- continue
66
- }
53
+ const onMessage: ChatMessageSubscriber = (callback) => {
54
+ return eventsub.on(["channel.chat.message"], (payload) => {
55
+ callback(payload.event)
56
+ })
57
+ }
67
58
 
68
- if (
69
- command.type === "001" ||
70
- command.type === "JOIN" ||
71
- command.type === "USERSTATE" ||
72
- command.type === "HOSTTARGET" ||
73
- command.type === "NOTICE"
74
- ) {
75
- continue
76
- }
77
-
78
- if (command.type === "PING") {
79
- socket.send(`PONG :${command.message}`)
80
- continue
81
- }
82
-
83
- if (command.type === "CAP") {
84
- socket.send(`PASS oauth:${authentication.accessToken}`)
85
- socket.send(`NICK ${authentication.user.login}`)
86
- continue
87
- }
88
-
89
- if (command.type === "RECONNECT") {
90
- console.warn("The server is about to terminate for maintenance.")
91
- continue
92
- }
59
+ const onCommand: ChatCommandSubscriber = (name, ...args) => {
60
+ const pattern = typeof args[0] === "string" ? args[0] : undefined
93
61
 
94
- if (command.type === "GLOBALUSERSTATE") {
95
- socket.send(`JOIN #${channel}`)
96
- continue
97
- }
98
-
99
- if (command.type === "ROOMSTATE") {
100
- resolve()
101
- continue
102
- }
103
-
104
- pubsub.dispatch(command)
105
- }
106
- })
107
-
108
- return promise.then(() => {
109
- const listen: ChatListener = (callback) => {
110
- return pubsub.subscribe((event) => {
111
- callback(event)
112
- })
113
- }
114
-
115
- const subscribe: ChatSubscriber = (types, callback) => {
116
- return pubsub.subscribe((event) => {
117
- // @ts-expect-error: generic events are complicated
118
- if (types.includes(event.type)) {
119
- // @ts-expect-error: generic events are complicated
120
- callback(event)
121
- }
122
- })
62
+ const callback = typeof args[0] === "function" ? args[0] : args[1]
63
+ if (!callback) {
64
+ throw new Error("onCommand: Missing callback.")
123
65
  }
124
66
 
125
- const send: ChatSender = (message) => {
126
- socket.send(`PRIVMSG #${authentication.user.login} :${message}`)
127
- }
67
+ const regex = new RegExp(`^\\s*!([a-z0-9])(?:\\s+(.+))$`, "i")
128
68
 
129
- const onMessage: ChatMessageSubscriber = (callback) => {
130
- return pubsub.subscribe((event) => {
131
- if (event.type === "PRIVMSG") {
132
- callback(event)
133
- }
134
- })
135
- }
69
+ return onMessage((payload) => {
70
+ const [command, text] =
71
+ payload.message.text.match(regex) ?? ([] as Array<string | undefined>)
136
72
 
137
- const onCommand: ChatCommandSubscriber = (...args) => {
138
- const name = typeof args[0] === "string" ? args[0] : undefined
139
- const callback = typeof args[0] === "function" ? args[0] : args[1]
140
- if (!callback) {
141
- throw new Error("onCommand: Missing callback.")
73
+ if (command !== name) {
74
+ return
142
75
  }
143
76
 
144
- return pubsub.subscribe((event) => {
145
- if (event.type === "PRIVMSG-COMMAND") {
146
- if (typeof name === "undefined" || event.command === name) {
147
- callback(event)
148
- }
149
- }
150
- })
151
- }
77
+ const parameters = pattern ? undefined : text?.split(" ")
152
78
 
153
- return {
154
- listen,
155
- subscribe,
156
- send,
157
- onMessage,
158
- onCommand,
159
- }
160
- })
79
+ callback({
80
+ ...payload,
81
+ message: {
82
+ command: "",
83
+ parameters,
84
+ ...payload.message,
85
+ },
86
+ })
87
+ })
88
+ }
89
+
90
+ return {
91
+ send,
92
+ announce,
93
+ onMessage,
94
+ onCommand,
95
+ }
161
96
  }
@@ -1,36 +1,45 @@
1
1
  interface ApplicableChatEvent {
2
- tags?: {
3
- mod?: boolean
4
- badges?: {
5
- broadcaster?: boolean
6
- }
7
- }
2
+ badges: Array<{
3
+ set_id: string
4
+ id: string
5
+ info: string
6
+ }>
8
7
  }
9
8
 
10
- export function isBroadcaster(event: ApplicableChatEvent): boolean | null {
11
- const broadcaster = event.tags?.badges?.broadcaster
12
-
13
- if (typeof broadcaster === "undefined") return null
9
+ export function isBroadcaster(event: ApplicableChatEvent): boolean {
10
+ const broadcaster = !!event.badges.find((badge) => {
11
+ return badge.set_id === "broadcaster"
12
+ })
14
13
 
15
14
  return broadcaster
16
15
  }
17
16
 
18
- export function isMod(event: ApplicableChatEvent): boolean | null {
19
- const broadcaster = event.tags?.badges?.broadcaster
20
- const mod = event.tags?.mod
21
-
22
- if (typeof broadcaster === "undefined" || typeof mod === "undefined")
23
- return null
17
+ export function isModerator(event: ApplicableChatEvent): boolean {
18
+ const broadcaster = !!event.badges.find((badge) => {
19
+ return badge.set_id === "broadcaster"
20
+ })
21
+ const moderator = !!event.badges.find((badge) => {
22
+ return badge.set_id === "moderator"
23
+ })
24
24
 
25
- return broadcaster || mod
25
+ return broadcaster || moderator
26
26
  }
27
27
 
28
- export function isModOnly(event: ApplicableChatEvent): boolean | null {
29
- const broadcaster = event.tags?.badges?.broadcaster
30
- const mod = event.tags?.mod
28
+ export function isModeratorOnly(event: ApplicableChatEvent): boolean {
29
+ const broadcaster = !!event.badges.find((badge) => {
30
+ return badge.set_id === "broadcaster"
31
+ })
32
+ const moderator = !!event.badges.find((badge) => {
33
+ return badge.set_id === "moderator"
34
+ })
35
+
36
+ return !broadcaster && moderator
37
+ }
31
38
 
32
- if (typeof broadcaster === "undefined" || typeof mod === "undefined")
33
- return null
39
+ export function isSubscriber(event: ApplicableChatEvent): false | number {
40
+ const subscriber = event.badges.find((badge) => {
41
+ return badge.set_id === "subscriber"
42
+ })
34
43
 
35
- return mod && !broadcaster
44
+ return +(subscriber?.info ?? "0") || false
36
45
  }
package/src/chat/index.ts CHANGED
@@ -1,8 +1,5 @@
1
- export type {
2
- TwitchChatEventType,
3
- TwitchChatEvent,
4
- } from "./interfaces/index.ts"
5
-
6
1
  export { default } from "./chat.ts"
7
2
  export * from "./chat.ts"
3
+
4
+ export * from "./pronouns.ts"
8
5
  export * from "./helpers.ts"
@@ -0,0 +1,141 @@
1
+ type AllPronounsResponse = Array<{
2
+ name: string
3
+ display: string
4
+ }>
5
+
6
+ type UserPronounsResponse = Array<{
7
+ id: string
8
+ login: string
9
+ pronoun_id: string
10
+ }>
11
+
12
+ type Pronouns = {
13
+ subject: string
14
+ object: string
15
+ posessive: string
16
+ }
17
+
18
+ const pronouns: Record<string, Pronouns> = {
19
+ hehim: {
20
+ subject: "he",
21
+ object: "him",
22
+ posessive: "his",
23
+ },
24
+ sheher: {
25
+ subject: "she",
26
+ object: "her",
27
+ posessive: "her",
28
+ },
29
+ theythem: {
30
+ subject: "they",
31
+ object: "them",
32
+ posessive: "their",
33
+ },
34
+ shethem: {
35
+ subject: "she",
36
+ object: "they",
37
+ posessive: "their",
38
+ },
39
+ hethem: {
40
+ subject: "he",
41
+ object: "they",
42
+ posessive: "their",
43
+ },
44
+ heshe: {
45
+ subject: "he",
46
+ object: "she",
47
+ posessive: "their",
48
+ },
49
+ xexem: {
50
+ subject: "xe",
51
+ object: "xem",
52
+ posessive: "xeir",
53
+ },
54
+ faefaer: {
55
+ subject: "fae",
56
+ object: "faer",
57
+ posessive: "faer",
58
+ },
59
+ vever: {
60
+ subject: "ve",
61
+ object: "ver",
62
+ posessive: "ver",
63
+ },
64
+ aeaer: {
65
+ subject: "ae",
66
+ object: "aer",
67
+ posessive: "aer",
68
+ },
69
+ ziehir: {
70
+ subject: "zie",
71
+ object: "hir",
72
+ posessive: "hir",
73
+ },
74
+ perper: {
75
+ subject: "per",
76
+ object: "per",
77
+ posessive: "per",
78
+ },
79
+ eem: {
80
+ subject: "e",
81
+ object: "em",
82
+ posessive: "eir",
83
+ },
84
+ itits: {
85
+ subject: "it",
86
+ object: "its",
87
+ posessive: "its",
88
+ },
89
+ }
90
+
91
+ const cache: Partial<Record<string, keyof typeof pronouns>> = {}
92
+
93
+ export async function getAllPronouns(): Promise<AllPronounsResponse> {
94
+ const response = await fetch("https://pronouns.alejo.io/api/pronouns")
95
+
96
+ if (!response.ok) {
97
+ throw new Error(await response.text())
98
+ }
99
+
100
+ const data = (await response.json()) as AllPronounsResponse
101
+
102
+ return data
103
+ }
104
+
105
+ export async function getUserPronouns(
106
+ login: string,
107
+ ): Promise<string | undefined> {
108
+ const response = await fetch(`https://pronouns.alejo.io/api/users/${login}`)
109
+
110
+ if (!response.ok) {
111
+ return undefined
112
+ }
113
+
114
+ try {
115
+ const data = (await response.json()) as UserPronounsResponse
116
+
117
+ return data[0].pronoun_id
118
+ } catch {
119
+ return undefined
120
+ }
121
+ }
122
+
123
+ export async function getPronouns(
124
+ login: string,
125
+ fallback: keyof typeof pronouns = "theythem",
126
+ ): Promise<{
127
+ subject: string
128
+ object: string
129
+ }> {
130
+ if (!(login in cache)) {
131
+ try {
132
+ const id = await getUserPronouns(login)
133
+ cache[login] = id
134
+ } catch {
135
+ cache[login] = undefined
136
+ }
137
+ }
138
+
139
+ const id = cache[login] ?? fallback
140
+ return pronouns[id]
141
+ }
@@ -1,3 +1,9 @@
1
+ export interface EventConfigs {}
2
+
3
+ export type EventType = keyof EventConfigs
4
+ export type EventPayload<Type extends EventType = EventType> =
5
+ EventConfigs[Type]["Payload"]
6
+
1
7
  export type EventConfig<
2
8
  Config extends {
3
9
  Type: string
@@ -23,12 +29,6 @@ export type EventConfig<
23
29
  }
24
30
  }
25
31
 
26
- export interface EventConfigs {}
27
-
28
- export type EventType = keyof EventConfigs
29
- export type EventPayload<Type extends EventType = EventType> =
30
- EventConfigs[Type]["Payload"]
31
-
32
32
  const events: {
33
33
  [Type in EventType]?: {
34
34
  scopes: string[]
@@ -2,51 +2,83 @@ import { type Authentication } from "../../authentication/index.ts"
2
2
  import { helix } from "../helix.ts"
3
3
 
4
4
  interface CustomReward {
5
- broadcaster_id: string
6
- broadcaster_login: string
7
- broadcaster_name: string
8
- id: string
9
- title: string
10
- prompt: string
11
- cost: number
5
+ broadcaster_id: string /* The ID that uniquely identifies the broadcaster. */
6
+ broadcaster_login: string /* The broadcaster’s login name. */
7
+ broadcaster_name: string /* The broadcaster’s display name. */
8
+ id: string /* The ID that uniquely identifies this custom reward. */
9
+ title: string /* The title of the reward. */
10
+ prompt: string /* The prompt shown to the viewer when they redeem the reward if user input is required (see the is_user_input_required field). */
11
+ cost: number /* The cost of the reward in Channel Points. */
12
+ /** A set of custom images for the reward. This field is null if the broadcaster didn’t upload images. */
12
13
  image: {
13
- url_1x: string
14
- url_2x: string
15
- url_4x: string
14
+ url_1x: string /* The URL to a small version of the image. */
15
+ url_2x: string /* The URL to a medium version of the image. */
16
+ url_4x: string /* The URL to a large version of the image. */
16
17
  }
18
+ /** A set of default images for the reward. */
17
19
  default_image: {
18
- url_1x: string
19
- url_2x: string
20
- url_4x: string
20
+ url_1x: string /* The URL to a small version of the image. */
21
+ url_2x: string /* The URL to a medium version of the image. */
22
+ url_4x: string /* The URL to a large version of the image. */
21
23
  }
22
- background_color: string
23
- is_enabled: boolean
24
- is_user_input_required: boolean
24
+ background_color: string /* The background color to use for the reward. The color is in Hex format (for example, #00E5CB). */
25
+ is_enabled: boolean /* A Boolean value that determines whether the reward is enabled. Is true if enabled; otherwise, false. Disabled rewards aren’t shown to the user. */
26
+ is_user_input_required: boolean /* A Boolean value that determines whether the user must enter information when redeeming the reward. Is true if the user is prompted. */
27
+ /** The settings used to determine whether to apply a maximum to the number of redemptions allowed per live stream. */
25
28
  max_per_stream_setting: {
26
- is_enabled: boolean
27
- max_per_stream: number
29
+ is_enabled: boolean /* A Boolean value that determines whether the reward applies a limit on the number of redemptions allowed per live stream. Is true if the reward applies a limit. */
30
+ max_per_stream: number /* The maximum number of redemptions allowed per live stream. */
28
31
  }
32
+ /** The settings used to determine whether to apply a maximum to the number of redemptions allowed per user per live stream. */
29
33
  max_per_user_per_stream_setting: {
30
- is_enabled: boolean
31
- max_per_user_per_stream: number
34
+ is_enabled: boolean /* A Boolean value that determines whether the reward applies a limit on the number of redemptions allowed per user per live stream. Is true if the reward applies a limit. */
35
+ max_per_user_per_stream: number /* The maximum number of redemptions allowed per user per live stream. */
32
36
  }
37
+ /** The settings used to determine whether to apply a cooldown period between redemptions and the length of the cooldown. */
33
38
  global_cooldown_setting: {
34
- is_enabled: boolean
35
- global_cooldown_seconds: number
39
+ is_enabled: boolean /* A Boolean value that determines whether to apply a cooldown period. Is true if a cooldown period is enabled. */
40
+ global_cooldown_seconds: number /* The cooldown period, in seconds. */
36
41
  }
37
- is_paused: boolean
38
- is_in_stock: boolean
39
- should_redemptions_skip_request_queue: boolean
40
- redemptions_redeemed_current_stream: number
41
- cooldown_expires_at: string
42
+ is_paused: boolean /* A Boolean value that determines whether the reward is currently paused. Is true if the reward is paused. Viewers can’t redeem paused rewards. */
43
+ is_in_stock: boolean /* A Boolean value that determines whether the reward is currently in stock. Is true if the reward is in stock. Viewers can’t redeem out of stock rewards. */
44
+ should_redemptions_skip_request_queue: boolean /* A Boolean value that determines whether redemptions should be set to FULFILLED status immediately when a reward is redeemed. If false, status is set to UNFULFILLED and follows the normal request queue process. */
45
+ redemptions_redeemed_current_stream: number /* The number of redemptions redeemed during the current live stream. The number counts against the max_per_stream_setting limit. This field is null if the broadcaster’s stream isn’t live or max_per_stream_setting isn’t enabled. */
46
+ cooldown_expires_at: string /* The timestamp of when the cooldown period expires. Is null if the reward isn’t in a cooldown state. See the global_cooldown_setting field. */
47
+ }
48
+
49
+ interface CustomRewardResponse {
50
+ data: CustomReward[]
51
+ }
52
+
53
+ interface Redemption {
54
+ broadcaster_id: string /** The ID that uniquely identifies the broadcaster. */
55
+ broadcaster_login: string /** The broadcaster’s login name. */
56
+ broadcaster_name: string /** The broadcaster’s display name. */
57
+ id: string /** The ID that uniquely identifies this redemption. */
58
+ user_login: string /** The user’s login name. */
59
+ user_id: string /** The ID that uniquely identifies the user that redeemed the reward. */
60
+ user_name: string /** The user’s display name. */
61
+ user_input: string /** The text the user entered at the prompt when they redeemed the reward; otherwise, an empty string if user input was not required. */
62
+ status: string /** The state of the redemption. Possible values are:\n - CANCELED\n - FULFILLED\n - UNFULFILLED */
63
+ redeemed_at: string /** The date and time of when the reward was redeemed, in RFC3339 format. */
64
+ /** The reward that the user redeemed. */
65
+ reward: {
66
+ id: string /** The ID that uniquely identifies the redeemed reward. */
67
+ title: string /** The reward’s title. */
68
+ prompt: string /** The prompt displayed to the viewer if user input is required. */
69
+ cost: number /** The reward’s cost, in Channel Points. */
70
+ }
71
+ }
72
+
73
+ interface RedemptionResponse {
74
+ data: Redemption[]
42
75
  }
43
76
 
44
77
  export async function getCustomRewards(
45
78
  authentication: Authentication,
46
79
  ): Promise<CustomReward[]> {
47
- const subscriptions = await helix<
48
- CustomReward,
49
- never,
80
+ const { data: subscriptions } = await helix<
81
+ CustomRewardResponse,
50
82
  {
51
83
  broadcaster_id: string
52
84
  }
@@ -60,3 +92,58 @@ export async function getCustomRewards(
60
92
 
61
93
  return subscriptions
62
94
  }
95
+
96
+ export async function getRedemptions(
97
+ authentication: Authentication,
98
+ reward_id: string,
99
+ id: string,
100
+ ): Promise<Redemption[]> {
101
+ const { data: redemptions } = await helix<
102
+ RedemptionResponse,
103
+ {
104
+ broadcaster_id: string
105
+ reward_id: string
106
+ id?: string
107
+ status?: "CANCELED" | "FULFILLED" | "UNFULFILLED"
108
+ }
109
+ >(authentication, {
110
+ method: "GET",
111
+ path: "/channel_points/custom_rewards",
112
+ params: {
113
+ broadcaster_id: authentication.user.id,
114
+ reward_id,
115
+ id,
116
+ status: id ? undefined : "UNFULFILLED",
117
+ },
118
+ })
119
+
120
+ return redemptions
121
+ }
122
+
123
+ export async function updateRedemption(
124
+ authentication: Authentication,
125
+ reward_id: string,
126
+ id: string,
127
+ status: "CANCELED" | "FULFILLED",
128
+ ): Promise<Redemption[]> {
129
+ const { data: redemptions } = await helix<
130
+ RedemptionResponse,
131
+ {
132
+ broadcaster_id: string
133
+ reward_id: string
134
+ id: string
135
+ status: "CANCELED" | "FULFILLED"
136
+ }
137
+ >(authentication, {
138
+ method: "GET",
139
+ path: "/channel_points/custom_rewards",
140
+ params: {
141
+ broadcaster_id: authentication.user.id,
142
+ reward_id,
143
+ id,
144
+ status,
145
+ },
146
+ })
147
+
148
+ return redemptions
149
+ }