@johpaz/hive 1.1.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 (156) hide show
  1. package/CONTRIBUTING.md +44 -0
  2. package/README.md +310 -0
  3. package/package.json +96 -0
  4. package/packages/cli/package.json +28 -0
  5. package/packages/cli/src/commands/agent-run.ts +168 -0
  6. package/packages/cli/src/commands/agents.ts +398 -0
  7. package/packages/cli/src/commands/chat.ts +142 -0
  8. package/packages/cli/src/commands/config.ts +50 -0
  9. package/packages/cli/src/commands/cron.ts +161 -0
  10. package/packages/cli/src/commands/dev.ts +95 -0
  11. package/packages/cli/src/commands/doctor.ts +133 -0
  12. package/packages/cli/src/commands/gateway.ts +443 -0
  13. package/packages/cli/src/commands/logs.ts +57 -0
  14. package/packages/cli/src/commands/mcp.ts +175 -0
  15. package/packages/cli/src/commands/message.ts +77 -0
  16. package/packages/cli/src/commands/onboard.ts +1868 -0
  17. package/packages/cli/src/commands/security.ts +144 -0
  18. package/packages/cli/src/commands/service.ts +50 -0
  19. package/packages/cli/src/commands/sessions.ts +116 -0
  20. package/packages/cli/src/commands/skills.ts +187 -0
  21. package/packages/cli/src/commands/update.ts +25 -0
  22. package/packages/cli/src/index.ts +185 -0
  23. package/packages/cli/src/utils/token.ts +6 -0
  24. package/packages/code-bridge/README.md +78 -0
  25. package/packages/code-bridge/package.json +18 -0
  26. package/packages/code-bridge/src/index.ts +95 -0
  27. package/packages/code-bridge/src/process-manager.ts +212 -0
  28. package/packages/code-bridge/src/schemas.ts +133 -0
  29. package/packages/core/package.json +46 -0
  30. package/packages/core/src/agent/agent-loop.ts +369 -0
  31. package/packages/core/src/agent/compaction.ts +140 -0
  32. package/packages/core/src/agent/context-compiler.ts +378 -0
  33. package/packages/core/src/agent/context-guard.ts +91 -0
  34. package/packages/core/src/agent/context.ts +138 -0
  35. package/packages/core/src/agent/conversation-store.ts +198 -0
  36. package/packages/core/src/agent/curator.ts +158 -0
  37. package/packages/core/src/agent/hooks.ts +166 -0
  38. package/packages/core/src/agent/index.ts +116 -0
  39. package/packages/core/src/agent/llm-client.ts +503 -0
  40. package/packages/core/src/agent/native-tools.ts +505 -0
  41. package/packages/core/src/agent/prompt-builder.ts +532 -0
  42. package/packages/core/src/agent/providers/index.ts +167 -0
  43. package/packages/core/src/agent/providers.ts +1 -0
  44. package/packages/core/src/agent/reflector.ts +170 -0
  45. package/packages/core/src/agent/service.ts +64 -0
  46. package/packages/core/src/agent/stuck-loop.ts +133 -0
  47. package/packages/core/src/agent/supervisor.ts +39 -0
  48. package/packages/core/src/agent/tracer.ts +102 -0
  49. package/packages/core/src/agent/workspace.ts +110 -0
  50. package/packages/core/src/canvas/canvas-manager.test.ts +161 -0
  51. package/packages/core/src/canvas/canvas-manager.ts +319 -0
  52. package/packages/core/src/canvas/canvas-tools.ts +420 -0
  53. package/packages/core/src/canvas/emitter.ts +115 -0
  54. package/packages/core/src/canvas/index.ts +2 -0
  55. package/packages/core/src/channels/base.ts +138 -0
  56. package/packages/core/src/channels/discord.ts +260 -0
  57. package/packages/core/src/channels/index.ts +7 -0
  58. package/packages/core/src/channels/manager.ts +383 -0
  59. package/packages/core/src/channels/slack.ts +287 -0
  60. package/packages/core/src/channels/telegram.ts +502 -0
  61. package/packages/core/src/channels/webchat.ts +128 -0
  62. package/packages/core/src/channels/whatsapp.ts +375 -0
  63. package/packages/core/src/config/index.ts +12 -0
  64. package/packages/core/src/config/loader.ts +529 -0
  65. package/packages/core/src/events/event-bus.ts +169 -0
  66. package/packages/core/src/gateway/index.ts +5 -0
  67. package/packages/core/src/gateway/initializer.ts +290 -0
  68. package/packages/core/src/gateway/lane-queue.ts +169 -0
  69. package/packages/core/src/gateway/resolver.ts +108 -0
  70. package/packages/core/src/gateway/router.ts +124 -0
  71. package/packages/core/src/gateway/server.ts +3317 -0
  72. package/packages/core/src/gateway/session.ts +95 -0
  73. package/packages/core/src/gateway/slash-commands.ts +192 -0
  74. package/packages/core/src/heartbeat/index.ts +157 -0
  75. package/packages/core/src/index.ts +19 -0
  76. package/packages/core/src/integrations/catalog.ts +286 -0
  77. package/packages/core/src/integrations/env.ts +64 -0
  78. package/packages/core/src/integrations/index.ts +2 -0
  79. package/packages/core/src/memory/index.ts +1 -0
  80. package/packages/core/src/memory/notes.ts +68 -0
  81. package/packages/core/src/plugins/api.ts +128 -0
  82. package/packages/core/src/plugins/index.ts +2 -0
  83. package/packages/core/src/plugins/loader.ts +365 -0
  84. package/packages/core/src/resilience/circuit-breaker.ts +225 -0
  85. package/packages/core/src/security/google-chat.ts +269 -0
  86. package/packages/core/src/security/index.ts +192 -0
  87. package/packages/core/src/security/pairing.ts +250 -0
  88. package/packages/core/src/security/rate-limit.ts +270 -0
  89. package/packages/core/src/security/signal.ts +321 -0
  90. package/packages/core/src/state/store.ts +312 -0
  91. package/packages/core/src/storage/bun-sqlite-store.ts +188 -0
  92. package/packages/core/src/storage/crypto.ts +101 -0
  93. package/packages/core/src/storage/db-context.ts +333 -0
  94. package/packages/core/src/storage/onboarding.ts +1087 -0
  95. package/packages/core/src/storage/schema.ts +541 -0
  96. package/packages/core/src/storage/seed.ts +571 -0
  97. package/packages/core/src/storage/sqlite.ts +387 -0
  98. package/packages/core/src/storage/usage.ts +212 -0
  99. package/packages/core/src/tools/bridge-events.ts +74 -0
  100. package/packages/core/src/tools/browser.ts +275 -0
  101. package/packages/core/src/tools/codebridge.ts +421 -0
  102. package/packages/core/src/tools/coordinator-tools.ts +179 -0
  103. package/packages/core/src/tools/cron.ts +611 -0
  104. package/packages/core/src/tools/exec.ts +140 -0
  105. package/packages/core/src/tools/fs.ts +364 -0
  106. package/packages/core/src/tools/index.ts +12 -0
  107. package/packages/core/src/tools/memory.ts +176 -0
  108. package/packages/core/src/tools/notify.ts +113 -0
  109. package/packages/core/src/tools/project-management.ts +376 -0
  110. package/packages/core/src/tools/project.ts +375 -0
  111. package/packages/core/src/tools/read.ts +158 -0
  112. package/packages/core/src/tools/web.ts +436 -0
  113. package/packages/core/src/tools/workspace.ts +171 -0
  114. package/packages/core/src/utils/benchmark.ts +80 -0
  115. package/packages/core/src/utils/crypto.ts +73 -0
  116. package/packages/core/src/utils/date.ts +42 -0
  117. package/packages/core/src/utils/index.ts +4 -0
  118. package/packages/core/src/utils/logger.ts +388 -0
  119. package/packages/core/src/utils/retry.ts +70 -0
  120. package/packages/core/src/voice/index.ts +583 -0
  121. package/packages/core/tsconfig.json +9 -0
  122. package/packages/mcp/package.json +26 -0
  123. package/packages/mcp/src/config.ts +13 -0
  124. package/packages/mcp/src/index.ts +1 -0
  125. package/packages/mcp/src/logger.ts +42 -0
  126. package/packages/mcp/src/manager.ts +434 -0
  127. package/packages/mcp/src/transports/index.ts +67 -0
  128. package/packages/mcp/src/transports/sse.ts +241 -0
  129. package/packages/mcp/src/transports/websocket.ts +159 -0
  130. package/packages/skills/package.json +21 -0
  131. package/packages/skills/src/bundled/agent_management/SKILL.md +24 -0
  132. package/packages/skills/src/bundled/browser_automation/SKILL.md +30 -0
  133. package/packages/skills/src/bundled/context_compact/SKILL.md +35 -0
  134. package/packages/skills/src/bundled/cron_manager/SKILL.md +52 -0
  135. package/packages/skills/src/bundled/file_manager/SKILL.md +76 -0
  136. package/packages/skills/src/bundled/http_client/SKILL.md +24 -0
  137. package/packages/skills/src/bundled/memory/SKILL.md +42 -0
  138. package/packages/skills/src/bundled/project_management/SKILL.md +26 -0
  139. package/packages/skills/src/bundled/shell/SKILL.md +43 -0
  140. package/packages/skills/src/bundled/system_notify/SKILL.md +52 -0
  141. package/packages/skills/src/bundled/voice/SKILL.md +25 -0
  142. package/packages/skills/src/bundled/web_search/SKILL.md +29 -0
  143. package/packages/skills/src/index.ts +1 -0
  144. package/packages/skills/src/loader.ts +282 -0
  145. package/packages/tools/package.json +43 -0
  146. package/packages/tools/src/browser/browser.test.ts +111 -0
  147. package/packages/tools/src/browser/index.ts +272 -0
  148. package/packages/tools/src/canvas/index.ts +220 -0
  149. package/packages/tools/src/cron/cron.test.ts +164 -0
  150. package/packages/tools/src/cron/index.ts +304 -0
  151. package/packages/tools/src/filesystem/filesystem.test.ts +240 -0
  152. package/packages/tools/src/filesystem/index.ts +379 -0
  153. package/packages/tools/src/git/index.ts +239 -0
  154. package/packages/tools/src/index.ts +4 -0
  155. package/packages/tools/src/shell/detect-env.ts +70 -0
  156. package/packages/tools/tsconfig.json +9 -0
@@ -0,0 +1,502 @@
1
+ import { Bot, GrammyError, InputFile, type Context } from "grammy";
2
+ import { BaseChannel, type ChannelConfig, type IncomingMessage, type OutboundMessage } from "./base.ts";
3
+ import { logger } from "../utils/logger.ts";
4
+
5
+ export interface TelegramConfig extends ChannelConfig {
6
+ botToken: string;
7
+ groups?: boolean;
8
+ }
9
+
10
+ export class TelegramChannel extends BaseChannel {
11
+ name = "telegram";
12
+ accountId: string;
13
+ config: TelegramConfig;
14
+
15
+ private bot?: Bot;
16
+ private log = logger.child("telegram");
17
+ private chatIdCache: Map<string, number> = new Map();
18
+ private messageIdCache: Map<string, number> = new Map();
19
+ // Deduplication: records recently processed message_ids to avoid double sends
20
+ private recentlyProcessed: Map<number, number> = new Map();
21
+
22
+ constructor(accountId: string, config: TelegramConfig) {
23
+ super();
24
+ this.accountId = accountId;
25
+ this.config = {
26
+ ...config,
27
+ dmPolicy: config.dmPolicy ?? "open",
28
+ allowFrom: config.allowFrom ?? [],
29
+ enabled: config.enabled ?? true,
30
+ };
31
+ }
32
+
33
+ async start(): Promise<void> {
34
+ if (this.running) {
35
+ this.log.warn("Telegram bot is already running, skipping start");
36
+ return;
37
+ }
38
+
39
+ if (!this.config.botToken) {
40
+ throw new Error("Telegram bot token not configured");
41
+ }
42
+
43
+ this.bot = new Bot(this.config.botToken);
44
+
45
+ this.bot.on("message", async (ctx: Context) => {
46
+ await this.handleTelegramMessage(ctx);
47
+ });
48
+
49
+ // Note: edited_message intentionally NOT handled — editing a message
50
+ // should not trigger a new agent response (was causing double sends).
51
+
52
+ this.bot.catch((err: Error) => {
53
+ this.log.error(`Telegram error: ${err.message}`);
54
+ });
55
+
56
+ this.bot.start({
57
+ onStart: () => {
58
+ this.running = true;
59
+ this.log.info(`Telegram bot started: @${this.bot?.botInfo?.username ?? "unknown"}`);
60
+ },
61
+ }).catch((error: Error) => {
62
+ this.log.error(`Telegram bot error: ${error.message}`);
63
+ this.running = false;
64
+ });
65
+ }
66
+
67
+ private async handleTelegramMessage(ctx: Context): Promise<void> {
68
+ const message = ctx.message;
69
+ if (!message) return;
70
+
71
+ const chatId = message.chat.id.toString();
72
+ const userId = message.from?.id?.toString() ?? "unknown";
73
+ const isGroup = message.chat.type === "group" || message.chat.type === "supergroup";
74
+ const kind = isGroup ? "group" : "direct";
75
+ const peerId = isGroup
76
+ ? `${message.chat.id}:${message.from?.id ?? "unknown"}`
77
+ : chatId;
78
+ const messageId = message.message_id;
79
+
80
+ if (message.from?.is_bot) {
81
+ return;
82
+ }
83
+
84
+ // Deduplication: ignore message_ids already processed in the last 60 seconds
85
+ const now = Date.now();
86
+ if (this.recentlyProcessed.has(messageId)) {
87
+ this.log.debug(`Duplicate message_id ${messageId} ignored`);
88
+ return;
89
+ }
90
+ this.recentlyProcessed.set(messageId, now);
91
+ // Clean up old entries (> 60s) to prevent unbounded growth
92
+ for (const [id, ts] of this.recentlyProcessed) {
93
+ if (now - ts > 60_000) this.recentlyProcessed.delete(id);
94
+ }
95
+
96
+ const text = message.text;
97
+ const isCommand = text?.startsWith("/") ?? false;
98
+
99
+ if (text === "/myid" || text?.startsWith("/myid@")) {
100
+ await ctx.reply(
101
+ `🆔 Tu Telegram ID es: <code>${userId}</code>\n\n` +
102
+ `Para autorizarte, ejecuta:\n` +
103
+ `<code>hive config set channels.telegram.accounts.default.allowFrom.+ "tg:${userId}"</code>`,
104
+ { parse_mode: "HTML" }
105
+ );
106
+ return;
107
+ }
108
+
109
+ if (text === "/start" || text?.startsWith("/start@")) {
110
+ const agentName = "Bee";
111
+ await ctx.reply(
112
+ `¡Hola! Soy ${agentName}, tu asistente personal.\n\n` +
113
+ `Tu Telegram ID: <code>${userId}</code>\n\n` +
114
+ `Para empezar a usar el bot, asegúrate de estar autorizado.`,
115
+ { parse_mode: "HTML" }
116
+ );
117
+ return;
118
+ }
119
+
120
+ if (text === "/help" || text?.startsWith("/help@")) {
121
+ await ctx.reply(this.getHelpMessage(userId), { parse_mode: "HTML" });
122
+ return;
123
+ }
124
+
125
+ if (text === "/stop" || text?.startsWith("/stop@")) {
126
+ await ctx.reply("⏹ Detención actual cancelada.", { parse_mode: "HTML" });
127
+ return;
128
+ }
129
+
130
+ if (text === "/new" || text?.startsWith("/new@")) {
131
+ await ctx.reply("🔄 Sesión reiniciada.", { parse_mode: "HTML" });
132
+ return;
133
+ }
134
+
135
+ if (!isGroup && !this.isUserAllowed(chatId)) {
136
+ this.log.debug(`Message from unauthorized user: ${chatId}`);
137
+ const rejectMsg = this.config.dmPolicy === "allowlist"
138
+ ? `⛔ No estás autorizado.\n\n` +
139
+ `Tu Telegram ID: <code>${userId}</code>\n\n` +
140
+ `Para autorizarte:\n` +
141
+ `1. Ejecuta en el servidor: <code>hive config edit</code>\n` +
142
+ `2. Añade bajo channels.telegram.accounts.default.allowFrom:\n` +
143
+ `<pre> - "tg:${userId}"</pre>\n` +
144
+ `3. Ejecuta: <code>hive reload</code>`
145
+ : `⛔ No estás autorizado para usar este bot.\n\n` +
146
+ `Tu Telegram ID: <code>${userId}</code>`;
147
+ await ctx.reply(rejectMsg, { parse_mode: "HTML" });
148
+ return;
149
+ }
150
+
151
+ if (isGroup && !(this.config.groups ?? false)) {
152
+ return;
153
+ }
154
+
155
+ let content = text;
156
+ let contentType = "text";
157
+
158
+ if (message.photo && !text) {
159
+ const caption = message.caption ?? "";
160
+ if (caption) {
161
+ content = `[📷 Foto] ${caption}`;
162
+ contentType = "photo";
163
+ } else {
164
+ await ctx.reply(
165
+ "📷 Recibí tu foto. Por favor, añade texto descriptivo para que pueda procesarla.",
166
+ { parse_mode: "HTML" }
167
+ );
168
+ return;
169
+ }
170
+ }
171
+
172
+ if (message.voice) {
173
+ const voice = message.voice;
174
+ const fileId = voice.file_id;
175
+
176
+ let audioBuffer: Buffer | undefined;
177
+ let audioUrl: string | undefined;
178
+
179
+ try {
180
+ const file = await this.bot!.api.getFile(fileId);
181
+ const filePath = file.file_path;
182
+ if (filePath) {
183
+ audioUrl = `https://api.telegram.org/file/bot${this.config.botToken}/${filePath}`;
184
+ }
185
+ } catch (error) {
186
+ this.log.error(`Failed to get voice file: ${(error as Error).message}`);
187
+ }
188
+
189
+ const msgSessionId = this.formatSessionId(peerId, kind);
190
+
191
+ const incomingMessage: IncomingMessage = {
192
+ sessionId: msgSessionId,
193
+ channel: "telegram",
194
+ accountId: this.accountId,
195
+ peerId,
196
+ peerKind: kind,
197
+ content: "",
198
+ audio: audioBuffer ? { buffer: audioBuffer } : audioUrl ? { url: audioUrl, mimeType: "audio/ogg" } : undefined,
199
+ metadata: {
200
+ telegram: {
201
+ chatId: message.chat.id,
202
+ userId: message.from?.id,
203
+ username: message.from?.username,
204
+ messageId,
205
+ chatType: message.chat.type,
206
+ contentType: "voice",
207
+ },
208
+ },
209
+ replyToId: message.reply_to_message
210
+ ? `tg:${message.reply_to_message.message_id}`
211
+ : undefined,
212
+ };
213
+
214
+ await this.handleMessage(incomingMessage);
215
+ return;
216
+ }
217
+
218
+ if (message.sticker) {
219
+ return;
220
+ }
221
+
222
+ if (message.document && !text) {
223
+ const docName = (message.document as any).file_name ?? "documento";
224
+ const caption = message.caption ?? "";
225
+ if (caption) {
226
+ content = `[📎 ${docName}] ${caption}`;
227
+ contentType = "document";
228
+ } else {
229
+ await ctx.reply(
230
+ "📎 Recibí tu documento. Por favor, añade texto descriptivo para que pueda procesarlo.",
231
+ { parse_mode: "HTML" }
232
+ );
233
+ return;
234
+ }
235
+ }
236
+
237
+ const sessionId = this.formatSessionId(peerId, kind);
238
+ this.chatIdCache.set(sessionId, message.chat.id);
239
+ this.messageIdCache.set(sessionId, messageId);
240
+
241
+ const incomingMessage: IncomingMessage = {
242
+ sessionId,
243
+ channel: "telegram",
244
+ accountId: this.accountId,
245
+ peerId,
246
+ peerKind: kind,
247
+ content: content ?? "",
248
+ metadata: {
249
+ telegram: {
250
+ chatId: message.chat.id,
251
+ userId: message.from?.id,
252
+ username: message.from?.username,
253
+ messageId,
254
+ chatType: message.chat.type,
255
+ contentType,
256
+ },
257
+ },
258
+ replyToId: message.reply_to_message
259
+ ? `tg:${message.reply_to_message.message_id}`
260
+ : undefined,
261
+ };
262
+
263
+ await this.handleMessage(incomingMessage);
264
+ }
265
+
266
+ private getHelpMessage(_userId: string): string {
267
+ return `📚 <b>Comandos disponibles:</b>
268
+
269
+ <code>/myid</code> - Muestra tu Telegram ID
270
+ <code>/start</code> - Iniciar conversación
271
+ <code>/help</code> - Mostrar esta ayuda
272
+ <code>/stop</code> - Detener tarea actual
273
+ <code>/new</code> - Reiniciar sesión
274
+
275
+ 💡 <i>Envía un mensaje para comenzar.</i>`;
276
+ }
277
+
278
+ async stop(): Promise<void> {
279
+ if (this.bot) {
280
+ await this.bot.stop();
281
+ this.running = false;
282
+ this.log.info("Telegram bot stopped");
283
+ }
284
+ }
285
+
286
+ private getChatIdFromSession(sessionId: string): number {
287
+ const cached = this.chatIdCache.get(sessionId);
288
+ if (cached) return cached;
289
+
290
+ // Group format: "chatId:userId" (e.g. "-1001234567890:123456789")
291
+ // The chat ID is the first segment before the colon.
292
+ const colonIdx = sessionId.indexOf(":");
293
+ if (colonIdx > 0) {
294
+ const parsed = Number(sessionId.slice(0, colonIdx));
295
+ if (!isNaN(parsed) && parsed !== 0) return parsed;
296
+ }
297
+
298
+ // Direct format: sessionId is the raw chatId (e.g. stored in user_identities)
299
+ const direct = Number(sessionId);
300
+ if (!isNaN(direct) && direct !== 0) return direct;
301
+
302
+ return 0;
303
+ }
304
+
305
+ private getMessageIdFromSession(sessionId: string): number | undefined {
306
+ return this.messageIdCache.get(sessionId);
307
+ }
308
+
309
+ async startTyping(sessionId: string): Promise<void> {
310
+ if (!this.bot) return;
311
+
312
+ const chatId = this.getChatIdFromSession(sessionId);
313
+ if (isNaN(chatId)) return;
314
+
315
+ await this.bot.api.sendChatAction(chatId, "typing");
316
+
317
+ const interval = setInterval(async () => {
318
+ try {
319
+ await this.bot!.api.sendChatAction(chatId, "typing");
320
+ } catch {
321
+ this.stopTyping(sessionId);
322
+ }
323
+ }, 4000);
324
+
325
+ this.typingIntervals.set(sessionId, interval);
326
+ }
327
+
328
+ async stopTyping(sessionId: string): Promise<void> {
329
+ const interval = this.typingIntervals.get(sessionId);
330
+ if (interval) {
331
+ clearInterval(interval);
332
+ this.typingIntervals.delete(sessionId);
333
+ }
334
+ }
335
+
336
+ async send(sessionId: string, message: OutboundMessage): Promise<void> {
337
+ if (!this.bot) {
338
+ throw new Error("Telegram bot not started");
339
+ }
340
+
341
+ await this.stopTyping(sessionId);
342
+
343
+ const chatId = this.getChatIdFromSession(sessionId);
344
+
345
+ if (isNaN(chatId)) {
346
+ throw new Error(`Invalid chat ID from session: ${sessionId}`);
347
+ }
348
+
349
+ const content = message.content ?? "";
350
+
351
+ if (!content || content.trim().length === 0) {
352
+ this.log.warn(`Empty response from agent, skipping send`, { sessionId, chatId });
353
+ return;
354
+ }
355
+
356
+ const replyToId = this.getMessageIdFromSession(sessionId);
357
+ const maxLength = 4096;
358
+
359
+ try {
360
+ if (content.length <= maxLength) {
361
+ await this.sendWithRetry(chatId, content, replyToId);
362
+ } else {
363
+ const chunks = this.chunkMessage(content, maxLength);
364
+ for (let i = 0; i < chunks.length; i++) {
365
+ await this.sendWithRetry(chatId, chunks[i]!, i === 0 ? replyToId : undefined);
366
+ if (i < chunks.length - 1) {
367
+ await new Promise((resolve) => setTimeout(resolve, 300));
368
+ }
369
+ }
370
+ }
371
+ } catch (error: unknown) {
372
+ if (error instanceof GrammyError) {
373
+ this.log.error(`Telegram API error: ${error.description}`);
374
+
375
+ if (error.error_code === 403) {
376
+ this.log.warn(`Bot was blocked by user: ${chatId}`);
377
+ return;
378
+ }
379
+ } else if (error instanceof Error) {
380
+ this.log.error(`Telegram send error: ${error.message}`);
381
+ } else {
382
+ this.log.error(`Telegram send error: ${String(error)}`);
383
+ }
384
+ throw error;
385
+ }
386
+ }
387
+
388
+ async sendAudio(sessionId: string, audio: Buffer, mimeType: string): Promise<void> {
389
+ if (!this.bot) {
390
+ throw new Error("Telegram bot not started");
391
+ }
392
+
393
+ const chatId = this.getChatIdFromSession(sessionId);
394
+
395
+ if (isNaN(chatId)) {
396
+ throw new Error(`Invalid chat ID from session: ${sessionId}`);
397
+ }
398
+
399
+ try {
400
+ const inputFile = new InputFile(audio, "voice.ogg");
401
+ await this.bot!.api.sendVoice(chatId, inputFile);
402
+ } catch (error: unknown) {
403
+ if (error instanceof Error) {
404
+ this.log.error(`Telegram sendAudio error: ${error.message}`);
405
+ }
406
+ throw error;
407
+ }
408
+ }
409
+
410
+ private async sendWithRetry(
411
+ chatId: number,
412
+ text: string,
413
+ replyToId?: number
414
+ ): Promise<void> {
415
+ const maxRetries = 3;
416
+ const backoffMs = [1000, 2000, 4000];
417
+
418
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
419
+ try {
420
+ const html = this.markdownToHTML(text);
421
+ const options: any = { parse_mode: "HTML" };
422
+ if (replyToId) {
423
+ options.reply_parameters = { message_id: replyToId };
424
+ }
425
+ await this.bot!.api.sendMessage(chatId, html, options);
426
+ return;
427
+ } catch (error: unknown) {
428
+ const err = error as Error & { error_code?: number; parameters?: { retry_after?: number } };
429
+
430
+ if (err.error_code === 400 && err.message.includes("can't parse entities")) {
431
+ this.log.warn(`Markdown parsing failed, falling back to plain text for chatId: ${chatId}`);
432
+ await this.bot!.api.sendMessage(chatId, text, {
433
+ reply_parameters: replyToId ? { message_id: replyToId } : undefined
434
+ });
435
+ return;
436
+ }
437
+
438
+ if (err.error_code === 400) {
439
+ this.log.error(`Bad Request: ${err.message}`);
440
+ throw error;
441
+ }
442
+
443
+ if (err.error_code === 429) {
444
+ const retryAfter = err.parameters?.retry_after ?? 1;
445
+ this.log.warn(`Rate limited, waiting ${retryAfter}s`);
446
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
447
+ continue;
448
+ }
449
+
450
+ if (attempt < maxRetries - 1) {
451
+ this.log.warn(`Send failed, retrying in ${backoffMs[attempt]}ms (attempt ${attempt + 1}/${maxRetries})`);
452
+ await new Promise((resolve) => setTimeout(resolve, backoffMs[attempt]));
453
+ } else {
454
+ throw error;
455
+ }
456
+ }
457
+ }
458
+ }
459
+
460
+ private chunkMessage(content: string, maxLength: number): string[] {
461
+ const chunks: string[] = [];
462
+ let remaining = content;
463
+
464
+ while (remaining.length > 0) {
465
+ if (remaining.length <= maxLength) {
466
+ chunks.push(remaining);
467
+ break;
468
+ }
469
+
470
+ let splitPoint = remaining.lastIndexOf("\n\n", maxLength);
471
+ if (splitPoint === -1 || splitPoint < maxLength * 0.5) {
472
+ splitPoint = remaining.lastIndexOf("\n", maxLength);
473
+ }
474
+ if (splitPoint === -1 || splitPoint < maxLength * 0.5) {
475
+ splitPoint = remaining.lastIndexOf(" ", maxLength);
476
+ }
477
+ if (splitPoint === -1 || splitPoint < maxLength * 0.5) {
478
+ splitPoint = maxLength;
479
+ }
480
+
481
+ chunks.push(remaining.slice(0, splitPoint));
482
+ remaining = remaining.slice(splitPoint).trim();
483
+ }
484
+
485
+ return chunks;
486
+ }
487
+
488
+ private markdownToHTML(text: string): string {
489
+ return text
490
+ .replace(/&/g, "&amp;")
491
+ .replace(/</g, "&lt;")
492
+ .replace(/>/g, "&gt;")
493
+ .replace(/```([\s\S]*?)```/g, (match, code) => `<pre>${code.trim()}</pre>`)
494
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
495
+ .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
496
+ .replace(/_([^_]+)_/g, "<i>$1</i>");
497
+ }
498
+ }
499
+
500
+ export function createTelegramChannel(accountId: string, config: TelegramConfig): TelegramChannel {
501
+ return new TelegramChannel(accountId, config);
502
+ }
@@ -0,0 +1,128 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import { BaseChannel, type ChannelConfig, type IncomingMessage, type OutboundMessage } from "./base.ts";
3
+ import { logger } from "../utils/logger.ts";
4
+
5
+ export interface WebChatConfig extends ChannelConfig {
6
+ accountId?: string;
7
+ // WebChat doesn't need extra config, it's served from the gateway
8
+ }
9
+
10
+ interface WebSocketData {
11
+ sessionId: string;
12
+ peerId: string;
13
+ authenticatedAt: number;
14
+ }
15
+
16
+ export class WebChatChannel extends BaseChannel {
17
+ name = "webchat";
18
+ accountId: string;
19
+ config: WebChatConfig;
20
+
21
+ private connections: Map<string, ServerWebSocket<WebSocketData>> = new Map();
22
+ private log = logger.child("webchat");
23
+
24
+ constructor(config: WebChatConfig) {
25
+ super();
26
+ this.config = config;
27
+ this.accountId = config.accountId || process.env.HIVE_USER_ID || "webchat";
28
+ }
29
+
30
+ async start(): Promise<void> {
31
+ this.running = true;
32
+ this.log.info("WebChat channel ready");
33
+ }
34
+
35
+ async stop(): Promise<void> {
36
+ this.connections.clear();
37
+ this.running = false;
38
+ this.log.info("WebChat channel stopped");
39
+ }
40
+
41
+ registerConnection(ws: ServerWebSocket<WebSocketData>): void {
42
+ const data = ws.data as WebSocketData;
43
+ this.connections.set(data.sessionId, ws);
44
+ this.log.debug(`WebChat connection registered: ${data.sessionId}`);
45
+ }
46
+
47
+ unregisterConnection(sessionId: string): void {
48
+ this.connections.delete(sessionId);
49
+ this.log.debug(`WebChat connection unregistered: ${sessionId}`);
50
+ }
51
+
52
+ async startTyping(sessionId: string): Promise<void> {
53
+ const ws = this.connections.get(sessionId);
54
+ if (!ws) return;
55
+
56
+ try {
57
+ ws.send(JSON.stringify({ type: "typing", isTyping: true }));
58
+ } catch {
59
+ // Connection closed
60
+ }
61
+ }
62
+
63
+ async stopTyping(sessionId: string): Promise<void> {
64
+ const ws = this.connections.get(sessionId);
65
+ if (!ws) return;
66
+
67
+ try {
68
+ ws.send(JSON.stringify({ type: "typing", isTyping: false }));
69
+ } catch {
70
+ // Connection closed
71
+ }
72
+ }
73
+
74
+ async send(sessionId: string, message: OutboundMessage): Promise<void> {
75
+ const ws = this.connections.get(sessionId);
76
+
77
+ if (!ws) {
78
+ this.log.warn(`No WebChat connection for session: ${sessionId}`);
79
+ return;
80
+ }
81
+
82
+ try {
83
+ ws.send(JSON.stringify(message));
84
+ } catch (error) {
85
+ this.log.error(`Failed to send WebChat message: ${(error as Error).message}`);
86
+ }
87
+ }
88
+
89
+ async sendAudio(sessionId: string, audio: Buffer, mimeType: string): Promise<void> {
90
+ const ws = this.connections.get(sessionId);
91
+
92
+ if (!ws) {
93
+ this.log.warn(`No WebChat connection for session: ${sessionId}`);
94
+ return;
95
+ }
96
+
97
+ try {
98
+ const base64Audio = audio.toString("base64");
99
+ ws.send(JSON.stringify({
100
+ type: "audio",
101
+ sessionId,
102
+ audio: base64Audio,
103
+ mimeType,
104
+ }));
105
+ } catch (error) {
106
+ this.log.error(`Failed to send WebChat audio: ${(error as Error).message}`);
107
+ }
108
+ }
109
+
110
+ createIncomingMessage(
111
+ sessionId: string,
112
+ content: string,
113
+ peerId: string
114
+ ): IncomingMessage {
115
+ return {
116
+ sessionId,
117
+ channel: "webchat",
118
+ accountId: this.accountId,
119
+ peerId,
120
+ peerKind: "direct",
121
+ content,
122
+ };
123
+ }
124
+ }
125
+
126
+ export function createWebChatChannel(config: WebChatConfig): WebChatChannel {
127
+ return new WebChatChannel(config);
128
+ }