@johpaz/hive-core 1.0.6 → 1.0.7

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.
@@ -25,7 +25,8 @@ export class ChannelManager {
25
25
  const channelConfigs = this.config.channels ?? {};
26
26
 
27
27
  for (const [channelName, channelConfig] of Object.entries(channelConfigs)) {
28
- if (!channelConfig.enabled) {
28
+ // If enabled is explicitly false, skip
29
+ if (channelConfig.enabled === false) {
29
30
  this.log.debug(`Channel ${channelName} is disabled`);
30
31
  continue;
31
32
  }
@@ -61,7 +62,7 @@ export class ChannelManager {
61
62
  channel = createTelegramChannel(accountId, {
62
63
  enabled: true,
63
64
  botToken: config.botToken as string,
64
- dmPolicy: (config.dmPolicy as "open" | "pairing" | "allowlist") ?? "pairing",
65
+ dmPolicy: (config.dmPolicy as "open" | "pairing" | "allowlist") ?? "open",
65
66
  allowFrom: (config.allowFrom as string[]) ?? [],
66
67
  groups: (config.groups as boolean) ?? false,
67
68
  } as TelegramConfig);
@@ -173,6 +174,95 @@ export class ChannelManager {
173
174
  return undefined;
174
175
  }
175
176
 
177
+ async removeChannel(channelName: string, accountId: string): Promise<void> {
178
+ const key = `${channelName}:${accountId}`;
179
+ await this.stopChannel(channelName, accountId);
180
+ this.channels.delete(key);
181
+ this.log.info(`Removed channel: ${key}`);
182
+ }
183
+
184
+ getAccountConfig(channelName: string, accountId: string): any {
185
+ const channelConfigs = (this.config.channels ?? {}) as Record<string, any>;
186
+ const channelConfig = channelConfigs[channelName];
187
+ if (!channelConfig) return null;
188
+
189
+ const accounts = channelConfig.accounts ?? { default: channelConfig };
190
+ return accounts[accountId] || null;
191
+ }
192
+
193
+ async startChannel(channelName: string, accountId: string): Promise<void> {
194
+ const key = `${channelName}:${accountId}`;
195
+ let channel = this.channels.get(key);
196
+
197
+ if (!channel) {
198
+ const channelConfigs = (this.config.channels ?? {}) as Record<string, any>;
199
+ const channelConfig = channelConfigs[channelName];
200
+ if (!channelConfig) {
201
+ throw new Error(`Channel configuration not found: ${channelName}`);
202
+ }
203
+
204
+ const accounts = channelConfig.accounts ?? { default: channelConfig };
205
+ const accountConfig = accounts[accountId];
206
+ if (!accountConfig && accountId !== "default") {
207
+ throw new Error(`Account configuration not found: ${accountId} for channel ${channelName}`);
208
+ }
209
+
210
+ const fullConfig = { ...channelConfig, ...(accountConfig ?? {}) };
211
+ await this.createChannel(channelName, accountId, fullConfig as any);
212
+ channel = this.channels.get(key);
213
+ }
214
+
215
+ if (!channel) {
216
+ throw new Error(`Failed to instantiate channel: ${key}`);
217
+ }
218
+
219
+ if (channel.isRunning()) {
220
+ this.log.info(`Channel ${key} is already running`);
221
+ return;
222
+ }
223
+
224
+ await channel.start();
225
+ this.log.info(`Started channel: ${key}`);
226
+ }
227
+
228
+ async stopChannel(channelName: string, accountId: string): Promise<void> {
229
+ const key = `${channelName}:${accountId}`;
230
+ const channel = this.channels.get(key);
231
+
232
+ if (!channel) {
233
+ this.log.warn(`Channel ${key} not found or not instantiated`);
234
+ return;
235
+ }
236
+
237
+ if (!channel.isRunning()) {
238
+ this.log.info(`Channel ${key} is not running`);
239
+ return;
240
+ }
241
+
242
+ await channel.stop();
243
+ this.log.info(`Stopped channel: ${key}`);
244
+ }
245
+
246
+ listAllAvailableChannels(): Array<{ name: string; accountId: string; running: boolean; enabled: boolean }> {
247
+ const available: Array<{ name: string; accountId: string; running: boolean; enabled: boolean }> = [];
248
+ const channelConfigs = (this.config.channels ?? {}) as Record<string, any>;
249
+
250
+ for (const [channelName, channelConfig] of Object.entries(channelConfigs)) {
251
+ const accounts = channelConfig.accounts ?? { default: channelConfig };
252
+ for (const accountId of Object.keys(accounts)) {
253
+ const key = `${channelName}:${accountId}`;
254
+ const channel = this.channels.get(key);
255
+ available.push({
256
+ name: channelName,
257
+ accountId: accountId,
258
+ running: channel ? channel.isRunning() : false,
259
+ enabled: channelConfig.enabled !== false,
260
+ });
261
+ }
262
+ }
263
+ return available;
264
+ }
265
+
176
266
  listChannels(): Array<{ name: string; accountId: string; running: boolean }> {
177
267
  return Array.from(this.channels.entries()).map(([key, channel]) => {
178
268
  const [name, accountId] = key.split(":");
@@ -190,13 +280,34 @@ export class ChannelManager {
190
280
  message: unknown
191
281
  ): Promise<void> {
192
282
  const channel = this.getChannel(channelName);
193
-
283
+
194
284
  if (!channel) {
195
285
  throw new Error(`Channel not found: ${channelName}`);
196
286
  }
197
287
 
198
288
  await channel.send(sessionId, message as any);
199
289
  }
290
+
291
+ async startTyping(channelName: string, sessionId: string): Promise<void> {
292
+ const channel = this.getChannel(channelName);
293
+ if (channel?.startTyping) {
294
+ await channel.startTyping(sessionId);
295
+ }
296
+ }
297
+
298
+ async stopTyping(channelName: string, sessionId: string): Promise<void> {
299
+ const channel = this.getChannel(channelName);
300
+ if (channel?.stopTyping) {
301
+ await channel.stopTyping(sessionId);
302
+ }
303
+ }
304
+
305
+ async markAsRead(channelName: string, sessionId: string, messageId?: string): Promise<void> {
306
+ const channel = this.getChannel(channelName);
307
+ if (channel?.markAsRead) {
308
+ await channel.markAsRead(sessionId, messageId);
309
+ }
310
+ }
200
311
  }
201
312
 
202
313
  export function createChannelManager(config: Config): ChannelManager {
@@ -25,6 +25,7 @@ export class SlackChannel extends BaseChannel {
25
25
  status: "disconnected",
26
26
  };
27
27
  private log = logger.child("slack");
28
+ private pendingMessages: Map<string, { ts: string; channel: string }> = new Map();
28
29
 
29
30
  constructor(config: SlackConfig) {
30
31
  super();
@@ -171,6 +172,29 @@ export class SlackChannel extends BaseChannel {
171
172
  await this.handleMessage(incoming);
172
173
  }
173
174
 
175
+ async startTyping(sessionId: string): Promise<void> {
176
+ if (!this.app) return;
177
+
178
+ const peerId = this.extractPeerId(sessionId);
179
+
180
+ try {
181
+ const result = await this.app.client.chat.postMessage({
182
+ channel: peerId,
183
+ text: "⏳ Procesando...",
184
+ });
185
+
186
+ if (result.ts && result.channel) {
187
+ this.pendingMessages.set(sessionId, { ts: result.ts as string, channel: result.channel as string });
188
+ }
189
+ } catch (error) {
190
+ this.log.debug(`Could not send typing placeholder: ${(error as Error).message}`);
191
+ }
192
+ }
193
+
194
+ async stopTyping(_sessionId: string): Promise<void> {
195
+ // No-op for Slack - we edit the message in send()
196
+ }
197
+
174
198
  async send(sessionId: string, message: OutboundMessage): Promise<void> {
175
199
  if (!this.app) {
176
200
  throw new Error("Slack not connected");
@@ -180,12 +204,22 @@ export class SlackChannel extends BaseChannel {
180
204
  if (!text) return;
181
205
 
182
206
  const peerId = this.extractPeerId(sessionId);
207
+ const pending = this.pendingMessages.get(sessionId);
183
208
 
184
209
  try {
185
- await this.app.client.chat.postMessage({
186
- channel: peerId,
187
- text,
188
- });
210
+ if (pending) {
211
+ await this.app.client.chat.update({
212
+ channel: pending.channel,
213
+ ts: pending.ts,
214
+ text,
215
+ });
216
+ this.pendingMessages.delete(sessionId);
217
+ } else {
218
+ await this.app.client.chat.postMessage({
219
+ channel: peerId,
220
+ text,
221
+ });
222
+ }
189
223
 
190
224
  this.log.debug(`Sent message to ${peerId}`);
191
225
  } catch (error) {
@@ -11,14 +11,21 @@ export class TelegramChannel extends BaseChannel {
11
11
  name = "telegram";
12
12
  accountId: string;
13
13
  config: TelegramConfig;
14
-
14
+
15
15
  private bot?: Bot;
16
16
  private log = logger.child("telegram");
17
+ private chatIdCache: Map<string, number> = new Map();
18
+ private messageIdCache: Map<string, number> = new Map();
17
19
 
18
20
  constructor(accountId: string, config: TelegramConfig) {
19
21
  super();
20
22
  this.accountId = accountId;
21
- this.config = config;
23
+ this.config = {
24
+ ...config,
25
+ dmPolicy: config.dmPolicy ?? "open",
26
+ allowFrom: config.allowFrom ?? [],
27
+ enabled: config.enabled ?? true,
28
+ };
22
29
  }
23
30
 
24
31
  async start(): Promise<void> {
@@ -28,121 +35,331 @@ export class TelegramChannel extends BaseChannel {
28
35
 
29
36
  this.bot = new Bot(this.config.botToken);
30
37
 
31
- this.bot.on("message:text", async (ctx) => {
38
+ this.bot.on("message", async (ctx: Context) => {
32
39
  await this.handleTelegramMessage(ctx);
33
40
  });
34
41
 
35
- this.bot.on("edited_message:text", async (ctx) => {
42
+ this.bot.on("edited_message", async (ctx: Context) => {
36
43
  await this.handleTelegramMessage(ctx);
37
44
  });
38
45
 
39
- this.bot.catch((err) => {
46
+ this.bot.catch((err: Error) => {
40
47
  this.log.error(`Telegram error: ${err.message}`);
41
48
  });
42
49
 
43
- try {
44
- await this.bot.init();
45
- this.running = true;
46
-
47
- this.bot.start({
48
- onStart: () => {
49
- this.log.info(`Telegram bot started: @${this.bot?.botInfo?.username ?? "unknown"}`);
50
- },
51
- });
52
- } catch (error) {
53
- this.log.error(`Failed to start Telegram bot: ${(error as Error).message}`);
54
- throw error;
55
- }
50
+ this.bot.start({
51
+ onStart: () => {
52
+ this.running = true;
53
+ this.log.info(`Telegram bot started: @${this.bot?.botInfo?.username ?? "unknown"}`);
54
+ },
55
+ }).catch((error: Error) => {
56
+ this.log.error(`Telegram bot error: ${error.message}`);
57
+ this.running = false;
58
+ });
56
59
  }
57
60
 
58
61
  private async handleTelegramMessage(ctx: Context): Promise<void> {
59
62
  const message = ctx.message ?? ctx.editedMessage;
60
- if (!message?.text) return;
63
+ if (!message) return;
61
64
 
62
65
  const chatId = message.chat.id.toString();
66
+ const userId = message.from?.id?.toString() ?? "unknown";
63
67
  const isGroup = message.chat.type === "group" || message.chat.type === "supergroup";
64
68
  const kind = isGroup ? "group" : "direct";
65
- const peerId = isGroup
69
+ const peerId = isGroup
66
70
  ? `${message.chat.id}:${message.from?.id ?? "unknown"}`
67
71
  : chatId;
72
+ const messageId = message.message_id;
73
+
74
+
75
+ if (message.from?.is_bot) {
76
+ return;
77
+ }
78
+
79
+ const text = message.text;
80
+ const isCommand = text?.startsWith("/") ?? false;
81
+
82
+ if (text === "/myid" || text?.startsWith("/myid@")) {
83
+ await ctx.reply(
84
+ `🆔 Tu Telegram ID es: <code>${userId}</code>\n\n` +
85
+ `Para autorizarte, añade esto a ~/.hive/hive.yaml:\n` +
86
+ `<pre>channels:\n telegram:\n accounts:\n default:\n allowFrom:\n - "tg:${userId}"</pre>\n\n` +
87
+ `O ejecuta:\n<code>hive config set channels.telegram.accounts.default.allowFrom.+ "tg:${userId}"</code>`,
88
+ { parse_mode: "HTML" }
89
+ );
90
+ return;
91
+ }
92
+
93
+ if (text === "/start" || text?.startsWith("/start@")) {
94
+ const agentName = "Bee";
95
+ await ctx.reply(
96
+ `¡Hola! Soy ${agentName}, tu asistente personal.\n\n` +
97
+ `Tu Telegram ID: <code>${userId}</code>\n\n` +
98
+ `Para empezar a usar el bot, asegúrate de estar autorizado.`,
99
+ { parse_mode: "HTML" }
100
+ );
101
+ return;
102
+ }
103
+
104
+ if (text === "/help" || text?.startsWith("/help@")) {
105
+ await ctx.reply(this.getHelpMessage(userId), { parse_mode: "HTML" });
106
+ return;
107
+ }
108
+
109
+ if (text === "/stop" || text?.startsWith("/stop@")) {
110
+ await ctx.reply("⏹ Detención actual cancelada.", { parse_mode: "HTML" });
111
+ return;
112
+ }
113
+
114
+ if (text === "/new" || text?.startsWith("/new@")) {
115
+ await ctx.reply("🔄 Sesión reiniciada.", { parse_mode: "HTML" });
116
+ return;
117
+ }
68
118
 
69
119
  if (!isGroup && !this.isUserAllowed(chatId)) {
70
120
  this.log.debug(`Message from unauthorized user: ${chatId}`);
71
- await ctx.reply("Sorry, you are not authorized to use this bot.");
121
+ const rejectMsg = this.config.dmPolicy === "allowlist"
122
+ ? `⛔ No estás autorizado.\n\n` +
123
+ `Tu Telegram ID: <code>${userId}</code>\n\n` +
124
+ `Para autorizarte:\n` +
125
+ `1. Ejecuta en el servidor: <code>hive config edit</code>\n` +
126
+ `2. Añade bajo channels.telegram.accounts.default.allowFrom:\n` +
127
+ `<pre> - "tg:${userId}"</pre>\n` +
128
+ `3. Ejecuta: <code>hive reload</code>`
129
+ : `⛔ No estás autorizado para usar este bot.\n\n` +
130
+ `Tu Telegram ID: <code>${userId}</code>`;
131
+ await ctx.reply(rejectMsg, { parse_mode: "HTML" });
72
132
  return;
73
133
  }
74
134
 
75
- if (isGroup && !this.config.groups) {
135
+ if (isGroup && !(this.config.groups ?? false)) {
76
136
  return;
77
137
  }
78
138
 
139
+ let content = text;
140
+ let contentType = "text";
141
+
142
+ if (message.photo && !text) {
143
+ const caption = message.caption ?? "";
144
+ if (caption) {
145
+ content = `[📷 Foto] ${caption}`;
146
+ contentType = "photo";
147
+ } else {
148
+ await ctx.reply(
149
+ "📷 Recibí tu foto. Por favor, añade texto descriptivo para que pueda procesarla.",
150
+ { parse_mode: "HTML" }
151
+ );
152
+ return;
153
+ }
154
+ }
155
+
156
+ if (message.voice) {
157
+ await ctx.reply(
158
+ "🎤 Recibí tu mensaje de voz. El procesamiento de audio no está disponible todavía.",
159
+ { parse_mode: "HTML" }
160
+ );
161
+ return;
162
+ }
163
+
164
+ if (message.sticker) {
165
+ return;
166
+ }
167
+
168
+ if (message.document && !text) {
169
+ const docName = (message.document as any).file_name ?? "documento";
170
+ const caption = message.caption ?? "";
171
+ if (caption) {
172
+ content = `[📎 ${docName}] ${caption}`;
173
+ contentType = "document";
174
+ } else {
175
+ await ctx.reply(
176
+ "📎 Recibí tu documento. Por favor, añade texto descriptivo para que pueda procesarlo.",
177
+ { parse_mode: "HTML" }
178
+ );
179
+ return;
180
+ }
181
+ }
182
+
183
+ const sessionId = this.formatSessionId(peerId, kind);
184
+ this.chatIdCache.set(sessionId, message.chat.id);
185
+ this.messageIdCache.set(sessionId, messageId);
186
+
79
187
  const incomingMessage: IncomingMessage = {
80
- sessionId: this.formatSessionId(peerId, kind),
188
+ sessionId,
81
189
  channel: "telegram",
82
190
  accountId: this.accountId,
83
191
  peerId,
84
192
  peerKind: kind,
85
- content: message.text,
193
+ content: content ?? "",
86
194
  metadata: {
87
195
  telegram: {
88
196
  chatId: message.chat.id,
89
197
  userId: message.from?.id,
90
198
  username: message.from?.username,
91
- messageId: message.message_id,
199
+ messageId,
92
200
  chatType: message.chat.type,
201
+ contentType,
93
202
  },
94
203
  },
95
- replyToId: message.reply_to_message
96
- ? `tg:${message.reply_to_message.message_id}`
204
+ replyToId: message.reply_to_message
205
+ ? `tg:${message.reply_to_message.message_id}`
97
206
  : undefined,
98
207
  };
99
208
 
100
209
  await this.handleMessage(incomingMessage);
101
210
  }
102
211
 
212
+ private getHelpMessage(_userId: string): string {
213
+ return `📚 <b>Comandos disponibles:</b>
214
+
215
+ <code>/myid</code> - Muestra tu Telegram ID
216
+ <code>/start</code> - Iniciar conversación
217
+ <code>/help</code> - Mostrar esta ayuda
218
+ <code>/stop</code> - Detener tarea actual
219
+ <code>/new</code> - Reiniciar sesión
220
+
221
+ 💡 <i>Envía un mensaje para comenzar.</i>`;
222
+ }
223
+
103
224
  async stop(): Promise<void> {
104
225
  if (this.bot) {
105
- this.bot.stop();
226
+ await this.bot.stop();
106
227
  this.running = false;
107
228
  this.log.info("Telegram bot stopped");
108
229
  }
109
230
  }
110
231
 
232
+ private getChatIdFromSession(sessionId: string): number {
233
+ const cached = this.chatIdCache.get(sessionId);
234
+ if (cached) return cached;
235
+
236
+ const parts = sessionId.split(":");
237
+ const peerId = parts.slice(4).join(":");
238
+ const chatIdStr = peerId.split(":")[0];
239
+ return Number(chatIdStr);
240
+ }
241
+
242
+ private getMessageIdFromSession(sessionId: string): number | undefined {
243
+ return this.messageIdCache.get(sessionId);
244
+ }
245
+
246
+ async startTyping(sessionId: string): Promise<void> {
247
+ if (!this.bot) return;
248
+
249
+ const chatId = this.getChatIdFromSession(sessionId);
250
+ if (isNaN(chatId)) return;
251
+
252
+ await this.bot.api.sendChatAction(chatId, "typing");
253
+
254
+ const interval = setInterval(async () => {
255
+ try {
256
+ await this.bot!.api.sendChatAction(chatId, "typing");
257
+ } catch {
258
+ this.stopTyping(sessionId);
259
+ }
260
+ }, 4000);
261
+
262
+ this.typingIntervals.set(sessionId, interval);
263
+ }
264
+
265
+ async stopTyping(sessionId: string): Promise<void> {
266
+ const interval = this.typingIntervals.get(sessionId);
267
+ if (interval) {
268
+ clearInterval(interval);
269
+ this.typingIntervals.delete(sessionId);
270
+ }
271
+ }
272
+
111
273
  async send(sessionId: string, message: OutboundMessage): Promise<void> {
112
274
  if (!this.bot) {
113
275
  throw new Error("Telegram bot not started");
114
276
  }
115
277
 
116
- const parts = sessionId.split(":");
117
- const peerPart = parts.slice(3).join(":");
118
- const chatIdStr = peerPart?.split(":")[0] ?? peerPart;
119
- const chatId = Number(chatIdStr);
278
+ await this.stopTyping(sessionId);
279
+
280
+ const chatId = this.getChatIdFromSession(sessionId);
120
281
 
121
282
  if (isNaN(chatId)) {
122
283
  throw new Error(`Invalid chat ID from session: ${sessionId}`);
123
284
  }
124
285
 
125
286
  const content = message.content ?? "";
287
+
288
+ if (!content || content.trim().length === 0) {
289
+ this.log.warn(`Empty response from agent, skipping send`, { sessionId, chatId });
290
+ return;
291
+ }
292
+
293
+ const replyToId = this.getMessageIdFromSession(sessionId);
126
294
  const maxLength = 4096;
127
295
 
128
296
  try {
129
297
  if (content.length <= maxLength) {
130
- await this.bot.api.sendMessage(chatId, content, { parse_mode: "Markdown" });
298
+ await this.sendWithRetry(chatId, content, replyToId);
131
299
  } else {
132
300
  const chunks = this.chunkMessage(content, maxLength);
133
- for (const chunk of chunks) {
134
- await this.bot.api.sendMessage(chatId, chunk, { parse_mode: "Markdown" });
301
+ for (let i = 0; i < chunks.length; i++) {
302
+ await this.sendWithRetry(chatId, chunks[i]!, i === 0 ? replyToId : undefined);
303
+ if (i < chunks.length - 1) {
304
+ await new Promise((resolve) => setTimeout(resolve, 300));
135
305
  }
136
306
  }
137
- } catch (error) {
138
- if (error instanceof GrammyError) {
139
- this.log.error(`Telegram API error: ${error.description}`);
140
-
141
- if (error.error_code === 403) {
142
- this.log.warn(`Bot was blocked by user: ${chatId}`);
307
+ }
308
+ } catch (error: unknown) {
309
+ if (error instanceof GrammyError) {
310
+ this.log.error(`Telegram API error: ${error.description}`);
311
+
312
+ if (error.error_code === 403) {
313
+ this.log.warn(`Bot was blocked by user: ${chatId}`);
314
+ return;
315
+ }
316
+ } else if (error instanceof Error) {
317
+ this.log.error(`Telegram send error: ${error.message}`);
318
+ } else {
319
+ this.log.error(`Telegram send error: ${String(error)}`);
320
+ }
321
+ throw error;
322
+ }
323
+ }
324
+
325
+ private async sendWithRetry(
326
+ chatId: number,
327
+ text: string,
328
+ replyToId?: number
329
+ ): Promise<void> {
330
+ const maxRetries = 3;
331
+ const backoffMs = [1000, 2000, 4000];
332
+
333
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
334
+ try {
335
+ const options: any = { parse_mode: "Markdown" };
336
+ if (replyToId) {
337
+ options.reply_parameters = { message_id: replyToId };
338
+ }
339
+ await this.bot!.api.sendMessage(chatId, text, options);
340
+ return;
341
+ } catch (error: unknown) {
342
+ const err = error as Error & { error_code?: number; parameters?: { retry_after?: number } };
343
+
344
+ if (err.error_code === 400) {
345
+ this.log.error(`Bad Request: ${err.message}`);
346
+ throw error;
347
+ }
348
+
349
+ if (err.error_code === 429) {
350
+ const retryAfter = err.parameters?.retry_after ?? 1;
351
+ this.log.warn(`Rate limited, waiting ${retryAfter}s`);
352
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
353
+ continue;
354
+ }
355
+
356
+ if (attempt < maxRetries - 1) {
357
+ this.log.warn(`Send failed, retrying in ${backoffMs[attempt]}ms (attempt ${attempt + 1}/${maxRetries})`);
358
+ await new Promise((resolve) => setTimeout(resolve, backoffMs[attempt]));
359
+ } else {
360
+ throw error;
143
361
  }
144
362
  }
145
- throw error;
146
363
  }
147
364
  }
148
365
 
@@ -156,7 +373,10 @@ export class TelegramChannel extends BaseChannel {
156
373
  break;
157
374
  }
158
375
 
159
- let splitPoint = remaining.lastIndexOf("\n", maxLength);
376
+ let splitPoint = remaining.lastIndexOf("\n\n", maxLength);
377
+ if (splitPoint === -1 || splitPoint < maxLength * 0.5) {
378
+ splitPoint = remaining.lastIndexOf("\n", maxLength);
379
+ }
160
380
  if (splitPoint === -1 || splitPoint < maxLength * 0.5) {
161
381
  splitPoint = remaining.lastIndexOf(" ", maxLength);
162
382
  }
@@ -47,6 +47,28 @@ export class WebChatChannel extends BaseChannel {
47
47
  this.log.debug(`WebChat connection unregistered: ${sessionId}`);
48
48
  }
49
49
 
50
+ async startTyping(sessionId: string): Promise<void> {
51
+ const ws = this.connections.get(sessionId);
52
+ if (!ws) return;
53
+
54
+ try {
55
+ ws.send(JSON.stringify({ type: "typing", isTyping: true }));
56
+ } catch {
57
+ // Connection closed
58
+ }
59
+ }
60
+
61
+ async stopTyping(sessionId: string): Promise<void> {
62
+ const ws = this.connections.get(sessionId);
63
+ if (!ws) return;
64
+
65
+ try {
66
+ ws.send(JSON.stringify({ type: "typing", isTyping: false }));
67
+ } catch {
68
+ // Connection closed
69
+ }
70
+ }
71
+
50
72
  async send(sessionId: string, message: OutboundMessage): Promise<void> {
51
73
  const ws = this.connections.get(sessionId);
52
74