@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.
Files changed (75) hide show
  1. package/README.md +366 -0
  2. package/config/config.default.json +51 -0
  3. package/dist/adapters/base.d.ts +85 -0
  4. package/dist/adapters/base.js +34 -0
  5. package/dist/adapters/base.js.map +1 -0
  6. package/dist/adapters/discord.d.ts +53 -0
  7. package/dist/adapters/discord.js +224 -0
  8. package/dist/adapters/discord.js.map +1 -0
  9. package/dist/adapters/index.d.ts +13 -0
  10. package/dist/adapters/index.js +8 -0
  11. package/dist/adapters/index.js.map +1 -0
  12. package/dist/adapters/slack.d.ts +49 -0
  13. package/dist/adapters/slack.js +231 -0
  14. package/dist/adapters/slack.js.map +1 -0
  15. package/dist/adapters/telegram.d.ts +64 -0
  16. package/dist/adapters/telegram.js +274 -0
  17. package/dist/adapters/telegram.js.map +1 -0
  18. package/dist/adapters/twitch.d.ts +75 -0
  19. package/dist/adapters/twitch.js +222 -0
  20. package/dist/adapters/twitch.js.map +1 -0
  21. package/dist/adapters/websocket.d.ts +30 -0
  22. package/dist/adapters/websocket.js +132 -0
  23. package/dist/adapters/websocket.js.map +1 -0
  24. package/dist/adapters/whatsapp.d.ts +49 -0
  25. package/dist/adapters/whatsapp.js +251 -0
  26. package/dist/adapters/whatsapp.js.map +1 -0
  27. package/dist/background/index.d.ts +1 -0
  28. package/dist/background/index.js +2 -0
  29. package/dist/background/index.js.map +1 -0
  30. package/dist/background/manager.d.ts +70 -0
  31. package/dist/background/manager.js +291 -0
  32. package/dist/background/manager.js.map +1 -0
  33. package/dist/index.d.ts +18 -0
  34. package/dist/index.js +1275 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/logger.d.ts +12 -0
  37. package/dist/logger.js +39 -0
  38. package/dist/logger.js.map +1 -0
  39. package/dist/paths.d.ts +14 -0
  40. package/dist/paths.js +32 -0
  41. package/dist/paths.js.map +1 -0
  42. package/dist/security/auth.d.ts +91 -0
  43. package/dist/security/auth.js +316 -0
  44. package/dist/security/auth.js.map +1 -0
  45. package/dist/security/index.d.ts +1 -0
  46. package/dist/security/index.js +2 -0
  47. package/dist/security/index.js.map +1 -0
  48. package/dist/security/tool-policy.d.ts +66 -0
  49. package/dist/security/tool-policy.js +467 -0
  50. package/dist/security/tool-policy.js.map +1 -0
  51. package/dist/sessions/index.d.ts +1 -0
  52. package/dist/sessions/index.js +2 -0
  53. package/dist/sessions/index.js.map +1 -0
  54. package/dist/sessions/store.d.ts +64 -0
  55. package/dist/sessions/store.js +247 -0
  56. package/dist/sessions/store.js.map +1 -0
  57. package/package.json +57 -0
  58. package/src/adapters/base.ts +124 -0
  59. package/src/adapters/discord.ts +270 -0
  60. package/src/adapters/index.ts +13 -0
  61. package/src/adapters/slack.ts +313 -0
  62. package/src/adapters/telegram.ts +394 -0
  63. package/src/adapters/twitch.ts +316 -0
  64. package/src/adapters/websocket.ts +158 -0
  65. package/src/adapters/whatsapp.ts +296 -0
  66. package/src/background/index.ts +1 -0
  67. package/src/background/manager.ts +395 -0
  68. package/src/index.ts +1665 -0
  69. package/src/logger.ts +43 -0
  70. package/src/paths.ts +40 -0
  71. package/src/security/auth.ts +458 -0
  72. package/src/security/index.ts +1 -0
  73. package/src/security/tool-policy.ts +568 -0
  74. package/src/sessions/index.ts +1 -0
  75. 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
+ }