@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.
- package/CONTRIBUTING.md +44 -0
- package/README.md +310 -0
- package/package.json +96 -0
- package/packages/cli/package.json +28 -0
- package/packages/cli/src/commands/agent-run.ts +168 -0
- package/packages/cli/src/commands/agents.ts +398 -0
- package/packages/cli/src/commands/chat.ts +142 -0
- package/packages/cli/src/commands/config.ts +50 -0
- package/packages/cli/src/commands/cron.ts +161 -0
- package/packages/cli/src/commands/dev.ts +95 -0
- package/packages/cli/src/commands/doctor.ts +133 -0
- package/packages/cli/src/commands/gateway.ts +443 -0
- package/packages/cli/src/commands/logs.ts +57 -0
- package/packages/cli/src/commands/mcp.ts +175 -0
- package/packages/cli/src/commands/message.ts +77 -0
- package/packages/cli/src/commands/onboard.ts +1868 -0
- package/packages/cli/src/commands/security.ts +144 -0
- package/packages/cli/src/commands/service.ts +50 -0
- package/packages/cli/src/commands/sessions.ts +116 -0
- package/packages/cli/src/commands/skills.ts +187 -0
- package/packages/cli/src/commands/update.ts +25 -0
- package/packages/cli/src/index.ts +185 -0
- package/packages/cli/src/utils/token.ts +6 -0
- package/packages/code-bridge/README.md +78 -0
- package/packages/code-bridge/package.json +18 -0
- package/packages/code-bridge/src/index.ts +95 -0
- package/packages/code-bridge/src/process-manager.ts +212 -0
- package/packages/code-bridge/src/schemas.ts +133 -0
- package/packages/core/package.json +46 -0
- package/packages/core/src/agent/agent-loop.ts +369 -0
- package/packages/core/src/agent/compaction.ts +140 -0
- package/packages/core/src/agent/context-compiler.ts +378 -0
- package/packages/core/src/agent/context-guard.ts +91 -0
- package/packages/core/src/agent/context.ts +138 -0
- package/packages/core/src/agent/conversation-store.ts +198 -0
- package/packages/core/src/agent/curator.ts +158 -0
- package/packages/core/src/agent/hooks.ts +166 -0
- package/packages/core/src/agent/index.ts +116 -0
- package/packages/core/src/agent/llm-client.ts +503 -0
- package/packages/core/src/agent/native-tools.ts +505 -0
- package/packages/core/src/agent/prompt-builder.ts +532 -0
- package/packages/core/src/agent/providers/index.ts +167 -0
- package/packages/core/src/agent/providers.ts +1 -0
- package/packages/core/src/agent/reflector.ts +170 -0
- package/packages/core/src/agent/service.ts +64 -0
- package/packages/core/src/agent/stuck-loop.ts +133 -0
- package/packages/core/src/agent/supervisor.ts +39 -0
- package/packages/core/src/agent/tracer.ts +102 -0
- package/packages/core/src/agent/workspace.ts +110 -0
- package/packages/core/src/canvas/canvas-manager.test.ts +161 -0
- package/packages/core/src/canvas/canvas-manager.ts +319 -0
- package/packages/core/src/canvas/canvas-tools.ts +420 -0
- package/packages/core/src/canvas/emitter.ts +115 -0
- package/packages/core/src/canvas/index.ts +2 -0
- package/packages/core/src/channels/base.ts +138 -0
- package/packages/core/src/channels/discord.ts +260 -0
- package/packages/core/src/channels/index.ts +7 -0
- package/packages/core/src/channels/manager.ts +383 -0
- package/packages/core/src/channels/slack.ts +287 -0
- package/packages/core/src/channels/telegram.ts +502 -0
- package/packages/core/src/channels/webchat.ts +128 -0
- package/packages/core/src/channels/whatsapp.ts +375 -0
- package/packages/core/src/config/index.ts +12 -0
- package/packages/core/src/config/loader.ts +529 -0
- package/packages/core/src/events/event-bus.ts +169 -0
- package/packages/core/src/gateway/index.ts +5 -0
- package/packages/core/src/gateway/initializer.ts +290 -0
- package/packages/core/src/gateway/lane-queue.ts +169 -0
- package/packages/core/src/gateway/resolver.ts +108 -0
- package/packages/core/src/gateway/router.ts +124 -0
- package/packages/core/src/gateway/server.ts +3317 -0
- package/packages/core/src/gateway/session.ts +95 -0
- package/packages/core/src/gateway/slash-commands.ts +192 -0
- package/packages/core/src/heartbeat/index.ts +157 -0
- package/packages/core/src/index.ts +19 -0
- package/packages/core/src/integrations/catalog.ts +286 -0
- package/packages/core/src/integrations/env.ts +64 -0
- package/packages/core/src/integrations/index.ts +2 -0
- package/packages/core/src/memory/index.ts +1 -0
- package/packages/core/src/memory/notes.ts +68 -0
- package/packages/core/src/plugins/api.ts +128 -0
- package/packages/core/src/plugins/index.ts +2 -0
- package/packages/core/src/plugins/loader.ts +365 -0
- package/packages/core/src/resilience/circuit-breaker.ts +225 -0
- package/packages/core/src/security/google-chat.ts +269 -0
- package/packages/core/src/security/index.ts +192 -0
- package/packages/core/src/security/pairing.ts +250 -0
- package/packages/core/src/security/rate-limit.ts +270 -0
- package/packages/core/src/security/signal.ts +321 -0
- package/packages/core/src/state/store.ts +312 -0
- package/packages/core/src/storage/bun-sqlite-store.ts +188 -0
- package/packages/core/src/storage/crypto.ts +101 -0
- package/packages/core/src/storage/db-context.ts +333 -0
- package/packages/core/src/storage/onboarding.ts +1087 -0
- package/packages/core/src/storage/schema.ts +541 -0
- package/packages/core/src/storage/seed.ts +571 -0
- package/packages/core/src/storage/sqlite.ts +387 -0
- package/packages/core/src/storage/usage.ts +212 -0
- package/packages/core/src/tools/bridge-events.ts +74 -0
- package/packages/core/src/tools/browser.ts +275 -0
- package/packages/core/src/tools/codebridge.ts +421 -0
- package/packages/core/src/tools/coordinator-tools.ts +179 -0
- package/packages/core/src/tools/cron.ts +611 -0
- package/packages/core/src/tools/exec.ts +140 -0
- package/packages/core/src/tools/fs.ts +364 -0
- package/packages/core/src/tools/index.ts +12 -0
- package/packages/core/src/tools/memory.ts +176 -0
- package/packages/core/src/tools/notify.ts +113 -0
- package/packages/core/src/tools/project-management.ts +376 -0
- package/packages/core/src/tools/project.ts +375 -0
- package/packages/core/src/tools/read.ts +158 -0
- package/packages/core/src/tools/web.ts +436 -0
- package/packages/core/src/tools/workspace.ts +171 -0
- package/packages/core/src/utils/benchmark.ts +80 -0
- package/packages/core/src/utils/crypto.ts +73 -0
- package/packages/core/src/utils/date.ts +42 -0
- package/packages/core/src/utils/index.ts +4 -0
- package/packages/core/src/utils/logger.ts +388 -0
- package/packages/core/src/utils/retry.ts +70 -0
- package/packages/core/src/voice/index.ts +583 -0
- package/packages/core/tsconfig.json +9 -0
- package/packages/mcp/package.json +26 -0
- package/packages/mcp/src/config.ts +13 -0
- package/packages/mcp/src/index.ts +1 -0
- package/packages/mcp/src/logger.ts +42 -0
- package/packages/mcp/src/manager.ts +434 -0
- package/packages/mcp/src/transports/index.ts +67 -0
- package/packages/mcp/src/transports/sse.ts +241 -0
- package/packages/mcp/src/transports/websocket.ts +159 -0
- package/packages/skills/package.json +21 -0
- package/packages/skills/src/bundled/agent_management/SKILL.md +24 -0
- package/packages/skills/src/bundled/browser_automation/SKILL.md +30 -0
- package/packages/skills/src/bundled/context_compact/SKILL.md +35 -0
- package/packages/skills/src/bundled/cron_manager/SKILL.md +52 -0
- package/packages/skills/src/bundled/file_manager/SKILL.md +76 -0
- package/packages/skills/src/bundled/http_client/SKILL.md +24 -0
- package/packages/skills/src/bundled/memory/SKILL.md +42 -0
- package/packages/skills/src/bundled/project_management/SKILL.md +26 -0
- package/packages/skills/src/bundled/shell/SKILL.md +43 -0
- package/packages/skills/src/bundled/system_notify/SKILL.md +52 -0
- package/packages/skills/src/bundled/voice/SKILL.md +25 -0
- package/packages/skills/src/bundled/web_search/SKILL.md +29 -0
- package/packages/skills/src/index.ts +1 -0
- package/packages/skills/src/loader.ts +282 -0
- package/packages/tools/package.json +43 -0
- package/packages/tools/src/browser/browser.test.ts +111 -0
- package/packages/tools/src/browser/index.ts +272 -0
- package/packages/tools/src/canvas/index.ts +220 -0
- package/packages/tools/src/cron/cron.test.ts +164 -0
- package/packages/tools/src/cron/index.ts +304 -0
- package/packages/tools/src/filesystem/filesystem.test.ts +240 -0
- package/packages/tools/src/filesystem/index.ts +379 -0
- package/packages/tools/src/git/index.ts +239 -0
- package/packages/tools/src/index.ts +4 -0
- package/packages/tools/src/shell/detect-env.ts +70 -0
- 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, "&")
|
|
491
|
+
.replace(/</g, "<")
|
|
492
|
+
.replace(/>/g, ">")
|
|
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
|
+
}
|