@gakr-gakr/twitch 0.1.0 → 0.1.1

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.
@@ -3,13 +3,230 @@
3
3
  "activation": {
4
4
  "onStartup": false
5
5
  },
6
- "channels": ["twitch"],
6
+ "channels": [
7
+ "twitch"
8
+ ],
7
9
  "channelEnvVars": {
8
- "twitch": ["AUTOBOT_TWITCH_ACCESS_TOKEN"]
10
+ "twitch": [
11
+ "AUTOBOT_TWITCH_ACCESS_TOKEN"
12
+ ]
9
13
  },
10
14
  "configSchema": {
11
15
  "type": "object",
12
16
  "additionalProperties": false,
13
17
  "properties": {}
18
+ },
19
+ "channelConfigs": {
20
+ "twitch": {
21
+ "schema": {
22
+ "$schema": "http://json-schema.org/draft-07/schema#",
23
+ "anyOf": [
24
+ {
25
+ "type": "object",
26
+ "properties": {
27
+ "name": {
28
+ "type": "string"
29
+ },
30
+ "enabled": {
31
+ "type": "boolean"
32
+ },
33
+ "markdown": {
34
+ "type": "object",
35
+ "properties": {
36
+ "tables": {
37
+ "type": "string",
38
+ "enum": [
39
+ "off",
40
+ "bullets",
41
+ "code",
42
+ "block"
43
+ ]
44
+ }
45
+ },
46
+ "additionalProperties": false
47
+ },
48
+ "defaultAccount": {
49
+ "type": "string"
50
+ },
51
+ "username": {
52
+ "type": "string"
53
+ },
54
+ "accessToken": {
55
+ "type": "string"
56
+ },
57
+ "clientId": {
58
+ "type": "string"
59
+ },
60
+ "channel": {
61
+ "type": "string",
62
+ "minLength": 1
63
+ },
64
+ "allowFrom": {
65
+ "type": "array",
66
+ "items": {
67
+ "type": "string"
68
+ }
69
+ },
70
+ "allowedRoles": {
71
+ "type": "array",
72
+ "items": {
73
+ "type": "string",
74
+ "enum": [
75
+ "moderator",
76
+ "owner",
77
+ "vip",
78
+ "subscriber",
79
+ "all"
80
+ ]
81
+ }
82
+ },
83
+ "requireMention": {
84
+ "type": "boolean"
85
+ },
86
+ "responsePrefix": {
87
+ "type": "string"
88
+ },
89
+ "clientSecret": {
90
+ "type": "string"
91
+ },
92
+ "refreshToken": {
93
+ "type": "string"
94
+ },
95
+ "expiresIn": {
96
+ "anyOf": [
97
+ {
98
+ "type": "number"
99
+ },
100
+ {
101
+ "type": "null"
102
+ }
103
+ ]
104
+ },
105
+ "obtainmentTimestamp": {
106
+ "type": "number"
107
+ }
108
+ },
109
+ "required": [
110
+ "username",
111
+ "accessToken",
112
+ "channel"
113
+ ],
114
+ "additionalProperties": false
115
+ },
116
+ {
117
+ "type": "object",
118
+ "properties": {
119
+ "name": {
120
+ "type": "string"
121
+ },
122
+ "enabled": {
123
+ "type": "boolean"
124
+ },
125
+ "markdown": {
126
+ "type": "object",
127
+ "properties": {
128
+ "tables": {
129
+ "type": "string",
130
+ "enum": [
131
+ "off",
132
+ "bullets",
133
+ "code",
134
+ "block"
135
+ ]
136
+ }
137
+ },
138
+ "additionalProperties": false
139
+ },
140
+ "defaultAccount": {
141
+ "type": "string"
142
+ },
143
+ "accounts": {
144
+ "type": "object",
145
+ "propertyNames": {
146
+ "type": "string"
147
+ },
148
+ "additionalProperties": {
149
+ "type": "object",
150
+ "properties": {
151
+ "username": {
152
+ "type": "string"
153
+ },
154
+ "accessToken": {
155
+ "type": "string"
156
+ },
157
+ "clientId": {
158
+ "type": "string"
159
+ },
160
+ "channel": {
161
+ "type": "string",
162
+ "minLength": 1
163
+ },
164
+ "enabled": {
165
+ "type": "boolean"
166
+ },
167
+ "allowFrom": {
168
+ "type": "array",
169
+ "items": {
170
+ "type": "string"
171
+ }
172
+ },
173
+ "allowedRoles": {
174
+ "type": "array",
175
+ "items": {
176
+ "type": "string",
177
+ "enum": [
178
+ "moderator",
179
+ "owner",
180
+ "vip",
181
+ "subscriber",
182
+ "all"
183
+ ]
184
+ }
185
+ },
186
+ "requireMention": {
187
+ "type": "boolean"
188
+ },
189
+ "responsePrefix": {
190
+ "type": "string"
191
+ },
192
+ "clientSecret": {
193
+ "type": "string"
194
+ },
195
+ "refreshToken": {
196
+ "type": "string"
197
+ },
198
+ "expiresIn": {
199
+ "anyOf": [
200
+ {
201
+ "type": "number"
202
+ },
203
+ {
204
+ "type": "null"
205
+ }
206
+ ]
207
+ },
208
+ "obtainmentTimestamp": {
209
+ "type": "number"
210
+ }
211
+ },
212
+ "required": [
213
+ "username",
214
+ "accessToken",
215
+ "channel"
216
+ ],
217
+ "additionalProperties": false
218
+ }
219
+ }
220
+ },
221
+ "required": [
222
+ "accounts"
223
+ ],
224
+ "additionalProperties": false
225
+ }
226
+ ]
227
+ },
228
+ "label": "Twitch",
229
+ "description": "Twitch chat integration"
230
+ }
14
231
  }
15
232
  }
package/dist/api.js ADDED
@@ -0,0 +1,3 @@
1
+ import { t as twitchPlugin } from "./plugin-CWk30DHp.js";
2
+ import { n as setTwitchRuntime } from "./runtime-DfFBD8zI.js";
3
+ export { setTwitchRuntime, twitchPlugin };
@@ -0,0 +1,2 @@
1
+ import { t as twitchPlugin } from "./plugin-CWk30DHp.js";
2
+ export { twitchPlugin };
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ import { defineBundledChannelEntry } from "autobot/plugin-sdk/channel-entry-contract";
2
+ //#region extensions/twitch/index.ts
3
+ var twitch_default = defineBundledChannelEntry({
4
+ id: "twitch",
5
+ name: "Twitch",
6
+ description: "Twitch IRC chat channel plugin",
7
+ importMetaUrl: import.meta.url,
8
+ plugin: {
9
+ specifier: "./channel-plugin-api.js",
10
+ exportName: "twitchPlugin"
11
+ },
12
+ runtime: {
13
+ specifier: "./api.js",
14
+ exportName: "setTwitchRuntime"
15
+ }
16
+ });
17
+ //#endregion
18
+ export { twitch_default as default };
@@ -0,0 +1,337 @@
1
+ import { n as stripMarkdownForTwitch, r as getOrCreateClientManager } from "./plugin-CWk30DHp.js";
2
+ import { t as getTwitchRuntime } from "./runtime-DfFBD8zI.js";
3
+ import { formatErrorMessage } from "autobot/plugin-sdk/error-runtime";
4
+ import { normalizeLowercaseStringOrEmpty } from "autobot/plugin-sdk/string-coerce-runtime";
5
+ import { createChannelIngressResolver, defineStableChannelIngressIdentity } from "autobot/plugin-sdk/channel-ingress-runtime";
6
+ //#region extensions/twitch/src/access-control.ts
7
+ const twitchUserIdentity = defineStableChannelIngressIdentity({
8
+ key: "sender-id",
9
+ entryIdPrefix: "twitch-user-entry"
10
+ });
11
+ const twitchRoleIdentity = defineStableChannelIngressIdentity({
12
+ key: "role-moderator",
13
+ kind: "role",
14
+ normalizeEntry: normalizeTwitchRole,
15
+ normalizeSubject: normalizeTwitchRole,
16
+ aliases: [
17
+ "owner",
18
+ "vip",
19
+ "subscriber"
20
+ ].map((role) => ({
21
+ key: `role-${role}`,
22
+ kind: "role",
23
+ normalizeEntry: () => null,
24
+ normalizeSubject: normalizeTwitchRole
25
+ })),
26
+ isWildcardEntry: (entry) => normalizeTwitchRole(entry) === "all",
27
+ resolveEntryId: ({ entryIndex }) => `twitch-role-entry-${entryIndex + 1}`
28
+ });
29
+ async function checkTwitchAccessControl(params) {
30
+ const { message, account, botUsername } = params;
31
+ const policyKind = resolveTwitchPolicyKind(account);
32
+ const decision = (await createChannelIngressResolver({
33
+ channelId: "twitch",
34
+ accountId: "default",
35
+ identity: policyKind === "role" ? twitchRoleIdentity : twitchUserIdentity
36
+ }).message({
37
+ subject: policyKind === "role" ? twitchRoleSubject(message) : { stableId: message.userId },
38
+ conversation: {
39
+ kind: "group",
40
+ id: message.channel
41
+ },
42
+ event: { mayPair: false },
43
+ mentionFacts: {
44
+ canDetectMention: true,
45
+ wasMentioned: mentionsBot(message.message, botUsername)
46
+ },
47
+ dmPolicy: "open",
48
+ groupPolicy: policyKind === "open" ? "open" : "allowlist",
49
+ policy: { activation: {
50
+ requireMention: account.requireMention ?? true,
51
+ allowTextCommands: false,
52
+ order: "before-sender"
53
+ } },
54
+ groupAllowFrom: policyKind === "allowFrom" ? account.allowFrom : policyKind === "role" ? account.allowedRoles : void 0
55
+ })).ingress;
56
+ if (decision.decisiveGateId === "activation" && decision.admission !== "dispatch") return {
57
+ allowed: false,
58
+ reason: "message does not mention the bot (requireMention is enabled)"
59
+ };
60
+ if (decision.admission === "dispatch") {
61
+ if (policyKind === "allowFrom") return {
62
+ allowed: true,
63
+ matchKey: params.message.userId,
64
+ matchSource: "allowlist"
65
+ };
66
+ if (policyKind === "role") return {
67
+ allowed: true,
68
+ matchKey: params.account.allowedRoles?.join(","),
69
+ matchSource: "role"
70
+ };
71
+ return { allowed: true };
72
+ }
73
+ if (policyKind === "allowFrom") {
74
+ if (!params.message.userId) return {
75
+ allowed: false,
76
+ reason: "sender user ID not available for allowlist check"
77
+ };
78
+ return {
79
+ allowed: false,
80
+ reason: "sender is not in allowFrom allowlist"
81
+ };
82
+ }
83
+ if (policyKind === "role") return {
84
+ allowed: false,
85
+ reason: `sender does not have any of the required roles: ${params.account.allowedRoles?.join(", ") ?? ""}`
86
+ };
87
+ return {
88
+ allowed: false,
89
+ reason: reasonForTwitchIngressDecision(decision)
90
+ };
91
+ }
92
+ function resolveTwitchPolicyKind(account) {
93
+ if (account.allowFrom !== void 0) return "allowFrom";
94
+ if (account.allowedRoles && account.allowedRoles.length > 0) return "role";
95
+ return "open";
96
+ }
97
+ function twitchRoleSubject(message) {
98
+ return {
99
+ stableId: message.isMod ? "moderator" : void 0,
100
+ aliases: {
101
+ "role-owner": message.isOwner ? "owner" : void 0,
102
+ "role-vip": message.isVip ? "vip" : void 0,
103
+ "role-subscriber": message.isSub ? "subscriber" : void 0
104
+ }
105
+ };
106
+ }
107
+ function normalizeTwitchRole(value) {
108
+ const role = normalizeLowercaseStringOrEmpty(value);
109
+ if (role === "*") return "all";
110
+ return role === "moderator" || role === "owner" || role === "vip" || role === "subscriber" || role === "all" ? role : null;
111
+ }
112
+ function reasonForTwitchIngressDecision(decision) {
113
+ switch (decision.reasonCode) {
114
+ case "activation_skipped": return "message does not mention the bot (requireMention is enabled)";
115
+ case "group_policy_empty_allowlist":
116
+ case "group_policy_not_allowlisted": return "sender is not in allowFrom allowlist";
117
+ default: return decision.reasonCode;
118
+ }
119
+ }
120
+ function mentionsBot(message, botUsername) {
121
+ const expected = normalizeLowercaseStringOrEmpty(botUsername);
122
+ const mentionRegex = /@(\w+)/g;
123
+ let match;
124
+ while ((match = mentionRegex.exec(message)) !== null) if ((match[1] ? normalizeLowercaseStringOrEmpty(match[1]) : "") === expected) return true;
125
+ return false;
126
+ }
127
+ //#endregion
128
+ //#region extensions/twitch/src/monitor.ts
129
+ /**
130
+ * Process an incoming Twitch message and dispatch to agent.
131
+ */
132
+ async function processTwitchMessage(params) {
133
+ const { message, account, accountId, config, runtime, core, statusSink } = params;
134
+ const cfg = config;
135
+ await core.channel.turn.run({
136
+ channel: "twitch",
137
+ accountId,
138
+ raw: message,
139
+ adapter: {
140
+ ingest: (incoming) => ({
141
+ id: incoming.id ?? `${incoming.channel}:${incoming.timestamp?.getTime() ?? Date.now()}`,
142
+ timestamp: incoming.timestamp?.getTime(),
143
+ rawText: incoming.message,
144
+ textForAgent: incoming.message,
145
+ textForCommands: incoming.message,
146
+ raw: incoming
147
+ }),
148
+ resolveTurn: (input) => {
149
+ const route = core.channel.routing.resolveAgentRoute({
150
+ cfg,
151
+ channel: "twitch",
152
+ accountId,
153
+ peer: {
154
+ kind: "group",
155
+ id: message.channel
156
+ }
157
+ });
158
+ const senderId = message.userId ?? message.username;
159
+ const fromLabel = message.displayName ?? message.username;
160
+ const body = core.channel.reply.formatAgentEnvelope({
161
+ channel: "Twitch",
162
+ from: fromLabel,
163
+ timestamp: input.timestamp,
164
+ envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
165
+ body: input.rawText
166
+ });
167
+ const ctxPayload = core.channel.turn.buildContext({
168
+ channel: "twitch",
169
+ accountId,
170
+ messageId: input.id,
171
+ timestamp: input.timestamp,
172
+ from: `twitch:user:${senderId}`,
173
+ sender: {
174
+ id: senderId,
175
+ name: fromLabel,
176
+ username: message.username
177
+ },
178
+ conversation: {
179
+ kind: "group",
180
+ id: message.channel,
181
+ label: message.channel,
182
+ routePeer: {
183
+ kind: "group",
184
+ id: message.channel
185
+ }
186
+ },
187
+ route: {
188
+ agentId: route.agentId,
189
+ accountId: route.accountId,
190
+ routeSessionKey: route.sessionKey
191
+ },
192
+ reply: {
193
+ to: `twitch:channel:${message.channel}`,
194
+ originatingTo: `twitch:channel:${message.channel}`
195
+ },
196
+ message: {
197
+ body,
198
+ rawBody: input.rawText,
199
+ bodyForAgent: input.textForAgent,
200
+ commandBody: input.textForCommands,
201
+ envelopeFrom: fromLabel
202
+ }
203
+ });
204
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId });
205
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
206
+ cfg,
207
+ channel: "twitch",
208
+ accountId
209
+ });
210
+ return {
211
+ cfg,
212
+ channel: "twitch",
213
+ accountId,
214
+ agentId: route.agentId,
215
+ routeSessionKey: route.sessionKey,
216
+ storePath,
217
+ ctxPayload,
218
+ recordInboundSession: core.channel.session.recordInboundSession,
219
+ dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
220
+ delivery: {
221
+ durable: () => ({ to: `twitch:channel:${message.channel}` }),
222
+ deliver: async (payload) => {
223
+ return await deliverTwitchReply({
224
+ payload,
225
+ channel: message.channel,
226
+ account,
227
+ accountId,
228
+ config,
229
+ tableMode,
230
+ runtime
231
+ });
232
+ },
233
+ onDelivered: (_payload, _info, result) => {
234
+ if (result?.visibleReplySent !== false) statusSink?.({ lastOutboundAt: Date.now() });
235
+ },
236
+ onError: (err, info) => {
237
+ runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`);
238
+ }
239
+ },
240
+ replyPipeline: {},
241
+ record: { onRecordError: (err) => {
242
+ runtime.error?.(`Failed updating session meta: ${String(err)}`);
243
+ } }
244
+ };
245
+ }
246
+ }
247
+ });
248
+ }
249
+ /**
250
+ * Deliver a reply to Twitch chat.
251
+ */
252
+ async function deliverTwitchReply(params) {
253
+ const { payload, channel, account, accountId, config, runtime } = params;
254
+ try {
255
+ const client = await getOrCreateClientManager(accountId, {
256
+ info: (msg) => runtime.log?.(msg),
257
+ warn: (msg) => runtime.log?.(msg),
258
+ error: (msg) => runtime.error?.(msg),
259
+ debug: (msg) => runtime.log?.(msg)
260
+ }).getClient(account, config, accountId);
261
+ if (!client) {
262
+ runtime.error?.(`No client available for sending reply`);
263
+ return { visibleReplySent: false };
264
+ }
265
+ if (!payload.text) {
266
+ runtime.error?.(`No text to send in reply payload`);
267
+ return { visibleReplySent: false };
268
+ }
269
+ const textToSend = stripMarkdownForTwitch(payload.text);
270
+ await client.say(channel, textToSend);
271
+ return { visibleReplySent: true };
272
+ } catch (err) {
273
+ runtime.error?.(`Failed to send reply: ${String(err)}`);
274
+ return { visibleReplySent: false };
275
+ }
276
+ }
277
+ /**
278
+ * Main monitor provider for Twitch.
279
+ *
280
+ * Sets up message handlers and processes incoming messages.
281
+ */
282
+ async function monitorTwitchProvider(options) {
283
+ const { account, accountId, config, runtime, abortSignal, statusSink } = options;
284
+ const core = getTwitchRuntime();
285
+ let stopped = false;
286
+ const coreLogger = core.logging.getChildLogger({ module: "twitch" });
287
+ const logVerboseMessage = (message) => {
288
+ if (!core.logging.shouldLogVerbose()) return;
289
+ coreLogger.debug?.(message);
290
+ };
291
+ const clientManager = getOrCreateClientManager(accountId, {
292
+ info: (msg) => coreLogger.info(msg),
293
+ warn: (msg) => coreLogger.warn(msg),
294
+ error: (msg) => coreLogger.error(msg),
295
+ debug: logVerboseMessage
296
+ });
297
+ try {
298
+ await clientManager.getClient(account, config, accountId);
299
+ } catch (error) {
300
+ const errorMsg = formatErrorMessage(error);
301
+ runtime.error?.(`Failed to connect: ${errorMsg}`);
302
+ throw error;
303
+ }
304
+ const unregisterHandler = clientManager.onMessage(account, (message) => {
305
+ if (stopped) return;
306
+ (async () => {
307
+ const botUsername = normalizeLowercaseStringOrEmpty(account.username);
308
+ if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) return;
309
+ const access = await checkTwitchAccessControl({
310
+ message,
311
+ account,
312
+ botUsername
313
+ });
314
+ if (stopped || !access.allowed) return;
315
+ statusSink?.({ lastInboundAt: Date.now() });
316
+ await processTwitchMessage({
317
+ message,
318
+ account,
319
+ accountId,
320
+ config,
321
+ runtime,
322
+ core,
323
+ statusSink
324
+ });
325
+ })().catch((err) => {
326
+ runtime.error?.(`Message processing failed: ${String(err)}`);
327
+ });
328
+ });
329
+ const stop = () => {
330
+ stopped = true;
331
+ unregisterHandler();
332
+ };
333
+ abortSignal.addEventListener("abort", stop, { once: true });
334
+ return { stop };
335
+ }
336
+ //#endregion
337
+ export { monitorTwitchProvider };