@gamalan/pi-gateway 1.0.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/README.md +366 -0
- package/config/config.default.json +51 -0
- package/dist/adapters/base.d.ts +85 -0
- package/dist/adapters/base.js +34 -0
- package/dist/adapters/base.js.map +1 -0
- package/dist/adapters/discord.d.ts +53 -0
- package/dist/adapters/discord.js +224 -0
- package/dist/adapters/discord.js.map +1 -0
- package/dist/adapters/index.d.ts +13 -0
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/slack.d.ts +49 -0
- package/dist/adapters/slack.js +231 -0
- package/dist/adapters/slack.js.map +1 -0
- package/dist/adapters/telegram.d.ts +64 -0
- package/dist/adapters/telegram.js +274 -0
- package/dist/adapters/telegram.js.map +1 -0
- package/dist/adapters/twitch.d.ts +75 -0
- package/dist/adapters/twitch.js +222 -0
- package/dist/adapters/twitch.js.map +1 -0
- package/dist/adapters/websocket.d.ts +30 -0
- package/dist/adapters/websocket.js +132 -0
- package/dist/adapters/websocket.js.map +1 -0
- package/dist/adapters/whatsapp.d.ts +49 -0
- package/dist/adapters/whatsapp.js +251 -0
- package/dist/adapters/whatsapp.js.map +1 -0
- package/dist/background/index.d.ts +1 -0
- package/dist/background/index.js +2 -0
- package/dist/background/index.js.map +1 -0
- package/dist/background/manager.d.ts +70 -0
- package/dist/background/manager.js +291 -0
- package/dist/background/manager.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +1275 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +39 -0
- package/dist/logger.js.map +1 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.js +32 -0
- package/dist/paths.js.map +1 -0
- package/dist/security/auth.d.ts +91 -0
- package/dist/security/auth.js +316 -0
- package/dist/security/auth.js.map +1 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/tool-policy.d.ts +66 -0
- package/dist/security/tool-policy.js +467 -0
- package/dist/security/tool-policy.js.map +1 -0
- package/dist/sessions/index.d.ts +1 -0
- package/dist/sessions/index.js +2 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/sessions/store.d.ts +64 -0
- package/dist/sessions/store.js +247 -0
- package/dist/sessions/store.js.map +1 -0
- package/package.json +57 -0
- package/src/adapters/base.ts +124 -0
- package/src/adapters/discord.ts +270 -0
- package/src/adapters/index.ts +13 -0
- package/src/adapters/slack.ts +313 -0
- package/src/adapters/telegram.ts +394 -0
- package/src/adapters/twitch.ts +316 -0
- package/src/adapters/websocket.ts +158 -0
- package/src/adapters/whatsapp.ts +296 -0
- package/src/background/index.ts +1 -0
- package/src/background/manager.ts +395 -0
- package/src/index.ts +1665 -0
- package/src/logger.ts +43 -0
- package/src/paths.ts +40 -0
- package/src/security/auth.ts +458 -0
- package/src/security/index.ts +1 -0
- package/src/security/tool-policy.ts +568 -0
- package/src/sessions/index.ts +1 -0
- package/src/sessions/store.ts +360 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Adapter - Hermes-style Telegram platform adapter
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Polling and webhook modes
|
|
6
|
+
* - DM and group chat support
|
|
7
|
+
* - Inline queries
|
|
8
|
+
* - Callback buttons
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
BaseAdapter,
|
|
13
|
+
type PlatformMessage,
|
|
14
|
+
type PlatformConfig,
|
|
15
|
+
} from "./base.js";
|
|
16
|
+
import { logger } from "../logger.js";
|
|
17
|
+
|
|
18
|
+
interface TelegramConfig extends PlatformConfig {
|
|
19
|
+
platform: "telegram";
|
|
20
|
+
token: string;
|
|
21
|
+
/** Public URL Telegram sends updates to (e.g. https://example.com/webhook/telegram).
|
|
22
|
+
* When set, webhook mode is used. When omitted, long polling is used. */
|
|
23
|
+
webhookUrl?: string;
|
|
24
|
+
webhookSecret?: string;
|
|
25
|
+
allowedChats?: string[]; // Whitelist chat IDs
|
|
26
|
+
requireUsername?: boolean; // Require user to have a username
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type { TelegramConfig };
|
|
30
|
+
|
|
31
|
+
interface TelegramUpdate {
|
|
32
|
+
update_id: number;
|
|
33
|
+
message?: TelegramMessage;
|
|
34
|
+
edited_message?: TelegramMessage;
|
|
35
|
+
callback_query?: {
|
|
36
|
+
id: string;
|
|
37
|
+
from: { id: number; username?: string; first_name?: string };
|
|
38
|
+
message?: TelegramMessage;
|
|
39
|
+
data: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TelegramMessage {
|
|
44
|
+
message_id: number;
|
|
45
|
+
from?: { id: number; username?: string; first_name?: string };
|
|
46
|
+
chat: { id: number; type: string; title?: string };
|
|
47
|
+
text?: string;
|
|
48
|
+
caption?: string;
|
|
49
|
+
date: number;
|
|
50
|
+
entities?: Array<{ type: string; offset: number; length: number }>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class TelegramAdapter extends BaseAdapter {
|
|
54
|
+
readonly platform = "telegram" as const;
|
|
55
|
+
config: TelegramConfig;
|
|
56
|
+
|
|
57
|
+
private offset = 0;
|
|
58
|
+
private pollingActive = false;
|
|
59
|
+
private connected = false;
|
|
60
|
+
|
|
61
|
+
constructor(config: TelegramConfig) {
|
|
62
|
+
super();
|
|
63
|
+
this.config = {
|
|
64
|
+
enabled: true,
|
|
65
|
+
platform: "telegram",
|
|
66
|
+
...config,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async initialize(): Promise<void> {
|
|
71
|
+
// Test bot token
|
|
72
|
+
const response = await this.apiRequest("/getMe");
|
|
73
|
+
const data = (await response.json()) as {
|
|
74
|
+
ok: boolean;
|
|
75
|
+
result?: { id: number; username: string; first_name: string };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (!response.ok || !data.ok) {
|
|
79
|
+
throw new Error(`Telegram auth failed: ${response.status}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
logger.info(`[Telegram] Bot initialized: @${data.result?.username}`);
|
|
83
|
+
|
|
84
|
+
// Set webhook if URL configured; otherwise long polling is used
|
|
85
|
+
if (this.config.webhookUrl) {
|
|
86
|
+
await this.apiRequest("/setWebhook", {
|
|
87
|
+
method: "POST",
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
url: this.config.webhookUrl,
|
|
90
|
+
...(this.config.webhookSecret
|
|
91
|
+
? { secret_token: this.config.webhookSecret }
|
|
92
|
+
: {}),
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
logger.info(`[Telegram] Webhook set → ${this.config.webhookUrl}`);
|
|
96
|
+
} else {
|
|
97
|
+
logger.info("[Telegram] No webhookUrl — will use long polling");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async apiRequest(
|
|
102
|
+
endpoint: string,
|
|
103
|
+
options: RequestInit = {},
|
|
104
|
+
): Promise<Response> {
|
|
105
|
+
const url = `https://api.telegram.org/bot${this.config.token}${endpoint}`;
|
|
106
|
+
return fetch(url, {
|
|
107
|
+
...options,
|
|
108
|
+
headers: {
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
...options.headers,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async start(callbacks): Promise<void> {
|
|
116
|
+
await super.start(callbacks);
|
|
117
|
+
|
|
118
|
+
if (!this.config.webhookUrl) {
|
|
119
|
+
// Long polling — keep a persistent connection and receive messages near-real-time
|
|
120
|
+
this.startLongPolling();
|
|
121
|
+
}
|
|
122
|
+
// Webhook mode: gateway's HTTP server calls handleWebhookUpdate() on each POST
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Long polling via getUpdates.
|
|
127
|
+
*
|
|
128
|
+
* Telegram holds the connection open (up to `timeout` seconds) and
|
|
129
|
+
* returns immediately when a message arrives. This is NOT interval-
|
|
130
|
+
* based polling — it is near-real-time, similar to a persistent
|
|
131
|
+
* connection. Used as a fallback when no webhookUrl is configured.
|
|
132
|
+
*/
|
|
133
|
+
private startLongPolling(): void {
|
|
134
|
+
this.connected = true;
|
|
135
|
+
this.pollingActive = true;
|
|
136
|
+
this.longPoll();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async longPoll(): Promise<void> {
|
|
140
|
+
while (this.pollingActive) {
|
|
141
|
+
try {
|
|
142
|
+
const response = await this.apiRequest("/getUpdates", {
|
|
143
|
+
method: "POST",
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
offset: this.offset,
|
|
146
|
+
timeout: 30, // Telegram long-poll timeout (seconds)
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
logger.error(`[Telegram] Poll error: ${response.status}`);
|
|
152
|
+
await this.sleep(5000);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const data = (await response.json()) as {
|
|
157
|
+
ok: boolean;
|
|
158
|
+
result?: TelegramUpdate[];
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (data.ok && data.result && data.result.length > 0) {
|
|
162
|
+
for (const update of data.result) {
|
|
163
|
+
await this.handleUpdate(update);
|
|
164
|
+
this.offset = update.update_id + 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
logger.error("[Telegram] Poll exception:", err);
|
|
169
|
+
await this.sleep(5000);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async handleUpdate(update: any): Promise<void> {
|
|
175
|
+
// Handle messages
|
|
176
|
+
if (update.message || update.edited_message) {
|
|
177
|
+
const msg = update.message || update.edited_message;
|
|
178
|
+
|
|
179
|
+
// Check if chat is allowed
|
|
180
|
+
if (
|
|
181
|
+
this.config.allowedChats &&
|
|
182
|
+
!this.config.allowedChats.includes(String(msg.chat.id))
|
|
183
|
+
) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check if username is required
|
|
188
|
+
if (this.config.requireUsername && !msg.from?.username) {
|
|
189
|
+
// Could send "Please set a username" message here
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const content = msg.text || msg.caption || "";
|
|
194
|
+
|
|
195
|
+
// Skip empty messages
|
|
196
|
+
if (!content) return;
|
|
197
|
+
|
|
198
|
+
const message: PlatformMessage = {
|
|
199
|
+
id: this.generateMessageId(),
|
|
200
|
+
platform: "telegram",
|
|
201
|
+
channelId: String(msg.chat.id),
|
|
202
|
+
userId: String(msg.from?.id || 0),
|
|
203
|
+
content,
|
|
204
|
+
timestamp: msg.date * 1000,
|
|
205
|
+
metadata: {
|
|
206
|
+
username: msg.from?.username,
|
|
207
|
+
firstName: msg.from?.first_name,
|
|
208
|
+
chatType: msg.chat.type,
|
|
209
|
+
chatTitle: msg.chat.title,
|
|
210
|
+
isEdited: !!update.edited_message,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
await this.emitMessage(message);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Handle callback queries (button presses)
|
|
218
|
+
if (update.callback_query) {
|
|
219
|
+
const query = update.callback_query;
|
|
220
|
+
|
|
221
|
+
const message: PlatformMessage = {
|
|
222
|
+
id: this.generateMessageId(),
|
|
223
|
+
platform: "telegram",
|
|
224
|
+
channelId: String(query.message?.chat.id || query.from.id),
|
|
225
|
+
userId: String(query.from.id),
|
|
226
|
+
content: `Callback: ${query.data}`,
|
|
227
|
+
timestamp: query.message?.date ? query.message.date * 1000 : Date.now(),
|
|
228
|
+
metadata: {
|
|
229
|
+
callbackId: query.id,
|
|
230
|
+
callbackData: query.data,
|
|
231
|
+
username: query.from.username,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
await this.emitMessage(message);
|
|
236
|
+
|
|
237
|
+
// Answer callback to remove loading state
|
|
238
|
+
await this.apiRequest("/answerCallbackQuery", {
|
|
239
|
+
method: "POST",
|
|
240
|
+
body: JSON.stringify({ callback_query_id: query.id }),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private sleep(ms: number): Promise<void> {
|
|
246
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async stop(): Promise<void> {
|
|
250
|
+
this.connected = false;
|
|
251
|
+
this.pollingActive = false;
|
|
252
|
+
await super.stop();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async sendMessage(channelId: string, content: string): Promise<string> {
|
|
256
|
+
const response = await this.apiRequest("/sendMessage", {
|
|
257
|
+
method: "POST",
|
|
258
|
+
body: JSON.stringify({
|
|
259
|
+
chat_id: channelId,
|
|
260
|
+
text: content,
|
|
261
|
+
parse_mode: "HTML",
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const data = (await response.json()) as {
|
|
266
|
+
ok: boolean;
|
|
267
|
+
result?: { message_id: number };
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (!data.ok) {
|
|
271
|
+
throw new Error(`Failed to send message: ${data}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return String(data.result?.message_id || 0);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async sendPhoto(
|
|
278
|
+
channelId: string,
|
|
279
|
+
photoUrl: string,
|
|
280
|
+
caption?: string,
|
|
281
|
+
): Promise<string> {
|
|
282
|
+
const response = await this.apiRequest("/sendPhoto", {
|
|
283
|
+
method: "POST",
|
|
284
|
+
body: JSON.stringify({
|
|
285
|
+
chat_id: channelId,
|
|
286
|
+
photo: photoUrl,
|
|
287
|
+
caption,
|
|
288
|
+
parse_mode: "HTML",
|
|
289
|
+
}),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const data = (await response.json()) as {
|
|
293
|
+
ok: boolean;
|
|
294
|
+
result?: { message_id: number };
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
if (!data.ok) {
|
|
298
|
+
throw new Error(`Failed to send photo: ${data}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return String(data.result?.message_id || 0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async sendButtons(
|
|
305
|
+
channelId: string,
|
|
306
|
+
text: string,
|
|
307
|
+
buttons: Array<Array<{ text: string; data: string }>>,
|
|
308
|
+
): Promise<string> {
|
|
309
|
+
const replyMarkup = {
|
|
310
|
+
inline_keyboard: buttons.map((row) =>
|
|
311
|
+
row.map((btn) => ({ text: btn.text, callback_data: btn.data })),
|
|
312
|
+
),
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const response = await this.apiRequest("/sendMessage", {
|
|
316
|
+
method: "POST",
|
|
317
|
+
body: JSON.stringify({
|
|
318
|
+
chat_id: channelId,
|
|
319
|
+
text,
|
|
320
|
+
parse_mode: "HTML",
|
|
321
|
+
reply_markup: replyMarkup,
|
|
322
|
+
}),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const data = (await response.json()) as {
|
|
326
|
+
ok: boolean;
|
|
327
|
+
result?: { message_id: number };
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
if (!data.ok) {
|
|
331
|
+
throw new Error(`Failed to send buttons: ${data}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return String(data.result?.message_id || 0);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async editMessage(
|
|
338
|
+
channelId: string,
|
|
339
|
+
messageId: string,
|
|
340
|
+
content: string,
|
|
341
|
+
): Promise<void> {
|
|
342
|
+
await this.apiRequest("/editMessageText", {
|
|
343
|
+
method: "POST",
|
|
344
|
+
body: JSON.stringify({
|
|
345
|
+
chat_id: channelId,
|
|
346
|
+
message_id: parseInt(messageId),
|
|
347
|
+
text: content,
|
|
348
|
+
parse_mode: "HTML",
|
|
349
|
+
}),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async deleteMessage(channelId: string, messageId: string): Promise<void> {
|
|
354
|
+
await this.apiRequest("/deleteMessage", {
|
|
355
|
+
method: "POST",
|
|
356
|
+
body: JSON.stringify({
|
|
357
|
+
chat_id: channelId,
|
|
358
|
+
message_id: parseInt(messageId),
|
|
359
|
+
}),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async setTyping(channelId: string, isTyping: boolean): Promise<void> {
|
|
364
|
+
const action = isTyping ? "typing" : "cancel";
|
|
365
|
+
await this.apiRequest("/sendChatAction", {
|
|
366
|
+
method: "POST",
|
|
367
|
+
body: JSON.stringify({
|
|
368
|
+
chat_id: channelId,
|
|
369
|
+
action,
|
|
370
|
+
}),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async getStatus(): Promise<{ connected: boolean; latency?: number }> {
|
|
375
|
+
return { connected: this.connected };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async getMe(): Promise<{ id: number; username: string; first_name: string }> {
|
|
379
|
+
const response = await this.apiRequest("/getMe");
|
|
380
|
+
const data = (await response.json()) as {
|
|
381
|
+
ok: boolean;
|
|
382
|
+
result: { id: number; username: string; first_name: string };
|
|
383
|
+
};
|
|
384
|
+
return data.result;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Handle webhook update (called from HTTP handler)
|
|
388
|
+
async handleWebhookUpdate(update: any): Promise<void> {
|
|
389
|
+
if (this.config.webhookSecret) {
|
|
390
|
+
// Verify secret here
|
|
391
|
+
}
|
|
392
|
+
await this.handleUpdate(update);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitch Adapter - Hermes-style Twitch platform adapter
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Helix API integration
|
|
6
|
+
* - EventSub WebSocket for real-time events
|
|
7
|
+
* - Stream notifications (live/offline)
|
|
8
|
+
* - Chat settings and moderation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { BaseAdapter, type PlatformMessage, type PlatformConfig } from "./base.js";
|
|
12
|
+
import { logger } from "../logger.js";
|
|
13
|
+
|
|
14
|
+
export interface TwitchConfig extends PlatformConfig {
|
|
15
|
+
platform: "twitch";
|
|
16
|
+
clientId: string;
|
|
17
|
+
clientSecret: string;
|
|
18
|
+
channels?: string[]; // Channels to monitor
|
|
19
|
+
events?: string[]; // Event types to subscribe
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface TwitchToken {
|
|
23
|
+
access_token: string;
|
|
24
|
+
expires_in: number;
|
|
25
|
+
token_type: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface TwitchStream {
|
|
29
|
+
id: string;
|
|
30
|
+
user_id: string;
|
|
31
|
+
user_login: string;
|
|
32
|
+
user_name: string;
|
|
33
|
+
game_name: string;
|
|
34
|
+
title: string;
|
|
35
|
+
viewer_count: number;
|
|
36
|
+
started_at: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class TwitchAdapter extends BaseAdapter {
|
|
40
|
+
readonly platform = "twitch" as const;
|
|
41
|
+
config: TwitchConfig;
|
|
42
|
+
|
|
43
|
+
private token: TwitchToken | null = null;
|
|
44
|
+
private tokenExpiry = 0;
|
|
45
|
+
private eventsubWs: WebSocket | null = null;
|
|
46
|
+
private eventsubSessionId: string | null = null;
|
|
47
|
+
private subscribedChannels: Set<string> = new Set();
|
|
48
|
+
private streamStatus: Map<string, boolean> = new Map();
|
|
49
|
+
|
|
50
|
+
constructor(config: TwitchConfig) {
|
|
51
|
+
super();
|
|
52
|
+
this.config = {
|
|
53
|
+
enabled: true,
|
|
54
|
+
platform: "twitch",
|
|
55
|
+
channels: [],
|
|
56
|
+
events: ["stream.online", "stream.offline"],
|
|
57
|
+
...config,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Initialize monitored channels
|
|
61
|
+
if (this.config.channels) {
|
|
62
|
+
this.config.channels.forEach(c => this.subscribedChannels.add(c.toLowerCase()));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async initialize(): Promise<void> {
|
|
67
|
+
await this.authenticate();
|
|
68
|
+
logger.info(`[Twitch] Adapter initialized for ${this.subscribedChannels.size} channels`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async authenticate(): Promise<void> {
|
|
72
|
+
const params = new URLSearchParams({
|
|
73
|
+
client_id: this.config.clientId,
|
|
74
|
+
client_secret: this.config.clientSecret,
|
|
75
|
+
grant_type: "client_credentials",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const response = await fetch("https://id.twitch.tv/oauth2/token", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
81
|
+
body: params.toString(),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`Twitch auth failed: ${response.status}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.token = await response.json() as TwitchToken;
|
|
89
|
+
this.tokenExpiry = Date.now() + (this.token!.expires_in * 1000);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async getAccessToken(): Promise<string> {
|
|
93
|
+
if (this.token && Date.now() < this.tokenExpiry - 60000) {
|
|
94
|
+
return this.token.access_token;
|
|
95
|
+
}
|
|
96
|
+
await this.authenticate();
|
|
97
|
+
return this.token!.access_token;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private getHeaders(): Record<string, string> {
|
|
101
|
+
if (!this.token) throw new Error("Not authenticated");
|
|
102
|
+
return {
|
|
103
|
+
"Client-ID": this.config.clientId,
|
|
104
|
+
"Authorization": `Bearer ${this.token.access_token}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async start(callbacks): Promise<void> {
|
|
109
|
+
await super.start(callbacks);
|
|
110
|
+
|
|
111
|
+
// Connect to EventSub WebSocket
|
|
112
|
+
await this.connectEventSub();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async connectEventSub(): Promise<void> {
|
|
116
|
+
this.eventsubWs = new WebSocket("wss://eventsub.wss.twitch.tv/ws");
|
|
117
|
+
|
|
118
|
+
this.eventsubWs.onopen = () => {
|
|
119
|
+
logger.info("[Twitch] EventSub WebSocket connected");
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
this.eventsubWs.onmessage = async (event) => {
|
|
123
|
+
await this.handleEventSubMessage(event.data);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
this.eventsubWs.onclose = () => {
|
|
127
|
+
logger.info("[Twitch] EventSub WebSocket closed");
|
|
128
|
+
this.callbacks?.onDisconnect?.();
|
|
129
|
+
// Reconnect after 5 seconds
|
|
130
|
+
setTimeout(() => this.connectEventSub(), 5000);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
this.eventsubWs.onerror = (error) => {
|
|
134
|
+
logger.error("[Twitch] EventSub error:", error);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async handleEventSubMessage(data: string): Promise<void> {
|
|
139
|
+
try {
|
|
140
|
+
const msg = JSON.parse(data);
|
|
141
|
+
const type = msg.metadata?.message_type;
|
|
142
|
+
|
|
143
|
+
switch (type) {
|
|
144
|
+
case "session_welcome": {
|
|
145
|
+
this.eventsubSessionId = msg.payload.session.id;
|
|
146
|
+
logger.info(`[Twitch] EventSub session: ${this.eventsubSessionId}`);
|
|
147
|
+
// Subscribe to events for each channel
|
|
148
|
+
for (const channel of this.subscribedChannels) {
|
|
149
|
+
await this.subscribeToChannel(channel);
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case "notification": {
|
|
155
|
+
const eventType = msg.payload?.subscription?.type;
|
|
156
|
+
const eventData = msg.payload?.event || {};
|
|
157
|
+
|
|
158
|
+
if (eventType === "stream.online" || eventType === "stream.offline") {
|
|
159
|
+
const broadcaster = eventData.broadcaster_user_login as string;
|
|
160
|
+
const wasLive = this.streamStatus.get(broadcaster) || false;
|
|
161
|
+
const isLive = eventType === "stream.online";
|
|
162
|
+
|
|
163
|
+
this.streamStatus.set(broadcaster, isLive);
|
|
164
|
+
|
|
165
|
+
// Emit message to gateway
|
|
166
|
+
const message: PlatformMessage = {
|
|
167
|
+
id: this.generateMessageId(),
|
|
168
|
+
platform: "twitch",
|
|
169
|
+
channelId: broadcaster,
|
|
170
|
+
userId: broadcaster, // Twitch events don't have a "from" user
|
|
171
|
+
content: isLive
|
|
172
|
+
? `🎮 ${broadcaster} is now LIVE!`
|
|
173
|
+
: `📴 ${broadcaster} went offline`,
|
|
174
|
+
timestamp: Date.now(),
|
|
175
|
+
metadata: { eventType, wasLive, isLive, stream: eventData },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
this.emitMessage(message);
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case "session_keepalive":
|
|
184
|
+
// Heartbeat - no action needed
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
logger.error("[Twitch] EventSub parse error:", err);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async subscribeToChannel(channel: string): Promise<void> {
|
|
193
|
+
// Get broadcaster ID first
|
|
194
|
+
const userResponse = await fetch(
|
|
195
|
+
`https://api.twitch.tv/helix/users?login=${channel}`,
|
|
196
|
+
{ headers: this.getHeaders() }
|
|
197
|
+
);
|
|
198
|
+
const userData = await userResponse.json() as { data: Array<{ id: string; login: string }> };
|
|
199
|
+
const broadcaster = userData.data[0];
|
|
200
|
+
|
|
201
|
+
if (!broadcaster) {
|
|
202
|
+
logger.warn(`[Twitch] Channel not found: ${channel}`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Note: Subscription creation requires webhook transport
|
|
207
|
+
// For WebSocket, events are received if the user has a subscription
|
|
208
|
+
logger.info(`[Twitch] Monitoring channel: ${channel} (${broadcaster.id})`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async stop(): Promise<void> {
|
|
212
|
+
if (this.eventsubWs) {
|
|
213
|
+
this.eventsubWs.close();
|
|
214
|
+
this.eventsubWs = null;
|
|
215
|
+
}
|
|
216
|
+
this.eventsubSessionId = null;
|
|
217
|
+
await super.stop();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async sendMessage(channelId: string, content: string): Promise<string> {
|
|
221
|
+
// Twitch doesn't support chat via API (requires IRC or EventSub Chat)
|
|
222
|
+
// For now, this is a no-op - chat messages must come through EventSub Chat
|
|
223
|
+
logger.warn("[Twitch] sendMessage not supported - use IRC for chat");
|
|
224
|
+
return this.generateMessageId();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async editMessage(channelId: string, messageId: string, content: string): Promise<void> {
|
|
228
|
+
// Not supported via Helix API
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async deleteMessage(channelId: string, messageId: string): Promise<void> {
|
|
232
|
+
// Not supported via Helix API
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async setTyping(channelId: string, isTyping: boolean): Promise<void> {
|
|
236
|
+
// Not supported via Helix API
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async getStatus(): Promise<{ connected: boolean; latency?: number }> {
|
|
240
|
+
return {
|
|
241
|
+
connected: this.eventsubWs?.readyState === WebSocket.OPEN && this.eventsubSessionId !== null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ============ API Methods (for tools) ============
|
|
246
|
+
|
|
247
|
+
async getStream(broadcaster: string): Promise<TwitchStream | null> {
|
|
248
|
+
const response = await fetch(
|
|
249
|
+
`https://api.twitch.tv/helix/streams?user_login=${broadcaster}`,
|
|
250
|
+
{ headers: this.getHeaders() }
|
|
251
|
+
);
|
|
252
|
+
const data = await response.json() as { data: TwitchStream[] };
|
|
253
|
+
return data.data[0] || null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async getUser(login: string): Promise<{ id: string; login: string; display_name: string } | null> {
|
|
257
|
+
const response = await fetch(
|
|
258
|
+
`https://api.twitch.tv/helix/users?login=${login}`,
|
|
259
|
+
{ headers: this.getHeaders() }
|
|
260
|
+
);
|
|
261
|
+
const data = await response.json() as { data: Array<{ id: string; login: string; display_name: string }> };
|
|
262
|
+
return data.data[0] || null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async createClip(broadcaster: string): Promise<{ url: string; title: string }> {
|
|
266
|
+
// Get broadcaster ID first
|
|
267
|
+
const user = await this.getUser(broadcaster);
|
|
268
|
+
if (!user) throw new Error(`Channel not found: ${broadcaster}`);
|
|
269
|
+
|
|
270
|
+
const response = await fetch(
|
|
271
|
+
`https://api.twitch.tv/helix/clips?broadcaster_id=${user.id}`,
|
|
272
|
+
{
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: this.getHeaders()
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
const data = await response.json() as { data: Array<{ edit_url: string; title: string }> };
|
|
278
|
+
return { url: data.data[0]?.edit_url || "", title: data.data[0]?.title || "" };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async getChatSettings(broadcasterId: string): Promise<{
|
|
282
|
+
slow: number;
|
|
283
|
+
follower_delay: number;
|
|
284
|
+
subscriber: boolean;
|
|
285
|
+
emote_mode: boolean;
|
|
286
|
+
}> {
|
|
287
|
+
const response = await fetch(
|
|
288
|
+
`https://api.twitch.tv/helix/chat/settings?broadcaster_id=${broadcasterId}`,
|
|
289
|
+
{ headers: this.getHeaders() }
|
|
290
|
+
);
|
|
291
|
+
const data = await response.json() as { data: Array<{
|
|
292
|
+
slow: number;
|
|
293
|
+
follower_delay: number;
|
|
294
|
+
subscriber: boolean;
|
|
295
|
+
emote_mode: boolean;
|
|
296
|
+
}> };
|
|
297
|
+
return data.data[0] || { slow: 0, follower_delay: -1, subscriber: false, emote_mode: false };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async getModerators(broadcasterId: string): Promise<string[]> {
|
|
301
|
+
const response = await fetch(
|
|
302
|
+
`https://api.twitch.tv/helix/moderation/moderators?broadcaster_id=${broadcasterId}`,
|
|
303
|
+
{ headers: this.getHeaders() }
|
|
304
|
+
);
|
|
305
|
+
const data = await response.json() as { data: Array<{ login: string }> };
|
|
306
|
+
return data.data.map(m => m.login);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
getMonitoredChannels(): string[] {
|
|
310
|
+
return Array.from(this.subscribedChannels);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
isChannelLive(channel: string): boolean {
|
|
314
|
+
return this.streamStatus.get(channel.toLowerCase()) || false;
|
|
315
|
+
}
|
|
316
|
+
}
|