@auxiora/channels 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 (89) hide show
  1. package/LICENSE +191 -0
  2. package/dist/adapters/bluebubbles.d.ts +63 -0
  3. package/dist/adapters/bluebubbles.d.ts.map +1 -0
  4. package/dist/adapters/bluebubbles.js +197 -0
  5. package/dist/adapters/bluebubbles.js.map +1 -0
  6. package/dist/adapters/discord.d.ts +27 -0
  7. package/dist/adapters/discord.d.ts.map +1 -0
  8. package/dist/adapters/discord.js +202 -0
  9. package/dist/adapters/discord.js.map +1 -0
  10. package/dist/adapters/email.d.ts +39 -0
  11. package/dist/adapters/email.d.ts.map +1 -0
  12. package/dist/adapters/email.js +359 -0
  13. package/dist/adapters/email.js.map +1 -0
  14. package/dist/adapters/googlechat.d.ts +77 -0
  15. package/dist/adapters/googlechat.d.ts.map +1 -0
  16. package/dist/adapters/googlechat.js +232 -0
  17. package/dist/adapters/googlechat.js.map +1 -0
  18. package/dist/adapters/matrix.d.ts +37 -0
  19. package/dist/adapters/matrix.d.ts.map +1 -0
  20. package/dist/adapters/matrix.js +262 -0
  21. package/dist/adapters/matrix.js.map +1 -0
  22. package/dist/adapters/signal.d.ts +32 -0
  23. package/dist/adapters/signal.d.ts.map +1 -0
  24. package/dist/adapters/signal.js +216 -0
  25. package/dist/adapters/signal.js.map +1 -0
  26. package/dist/adapters/slack.d.ts +29 -0
  27. package/dist/adapters/slack.d.ts.map +1 -0
  28. package/dist/adapters/slack.js +202 -0
  29. package/dist/adapters/slack.js.map +1 -0
  30. package/dist/adapters/teams.d.ts +66 -0
  31. package/dist/adapters/teams.d.ts.map +1 -0
  32. package/dist/adapters/teams.js +227 -0
  33. package/dist/adapters/teams.js.map +1 -0
  34. package/dist/adapters/telegram.d.ts +28 -0
  35. package/dist/adapters/telegram.d.ts.map +1 -0
  36. package/dist/adapters/telegram.js +170 -0
  37. package/dist/adapters/telegram.js.map +1 -0
  38. package/dist/adapters/twilio.d.ts +63 -0
  39. package/dist/adapters/twilio.d.ts.map +1 -0
  40. package/dist/adapters/twilio.js +193 -0
  41. package/dist/adapters/twilio.js.map +1 -0
  42. package/dist/adapters/whatsapp.d.ts +99 -0
  43. package/dist/adapters/whatsapp.d.ts.map +1 -0
  44. package/dist/adapters/whatsapp.js +218 -0
  45. package/dist/adapters/whatsapp.js.map +1 -0
  46. package/dist/adapters/zalo.d.ts +64 -0
  47. package/dist/adapters/zalo.d.ts.map +1 -0
  48. package/dist/adapters/zalo.js +216 -0
  49. package/dist/adapters/zalo.js.map +1 -0
  50. package/dist/index.d.ts +15 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +16 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/manager.d.ts +35 -0
  55. package/dist/manager.d.ts.map +1 -0
  56. package/dist/manager.js +127 -0
  57. package/dist/manager.js.map +1 -0
  58. package/dist/types.d.ts +71 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +2 -0
  61. package/dist/types.js.map +1 -0
  62. package/package.json +32 -0
  63. package/src/adapters/bluebubbles.ts +294 -0
  64. package/src/adapters/discord.ts +253 -0
  65. package/src/adapters/email.ts +457 -0
  66. package/src/adapters/googlechat.ts +364 -0
  67. package/src/adapters/matrix.ts +376 -0
  68. package/src/adapters/signal.ts +313 -0
  69. package/src/adapters/slack.ts +252 -0
  70. package/src/adapters/teams.ts +320 -0
  71. package/src/adapters/telegram.ts +208 -0
  72. package/src/adapters/twilio.ts +256 -0
  73. package/src/adapters/whatsapp.ts +342 -0
  74. package/src/adapters/zalo.ts +319 -0
  75. package/src/index.ts +78 -0
  76. package/src/manager.ts +180 -0
  77. package/src/types.ts +84 -0
  78. package/tests/bluebubbles.test.ts +438 -0
  79. package/tests/email.test.ts +136 -0
  80. package/tests/googlechat.test.ts +439 -0
  81. package/tests/matrix.test.ts +564 -0
  82. package/tests/signal.test.ts +404 -0
  83. package/tests/slack.test.ts +343 -0
  84. package/tests/teams.test.ts +429 -0
  85. package/tests/twilio.test.ts +269 -0
  86. package/tests/whatsapp.test.ts +530 -0
  87. package/tests/zalo.test.ts +499 -0
  88. package/tsconfig.json +8 -0
  89. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,252 @@
1
+ import { App, LogLevel } from '@slack/bolt';
2
+ import { audit } from '@auxiora/audit';
3
+ import type {
4
+ ChannelAdapter,
5
+ InboundMessage,
6
+ OutboundMessage,
7
+ SendResult,
8
+ } from '../types.js';
9
+
10
+ export interface SlackAdapterConfig {
11
+ botToken: string;
12
+ appToken: string;
13
+ signingSecret?: string;
14
+ allowedChannels?: string[];
15
+ allowedUsers?: string[];
16
+ }
17
+
18
+ const MAX_MESSAGE_LENGTH = 40000;
19
+
20
+ export class SlackAdapter implements ChannelAdapter {
21
+ readonly type = 'slack' as const;
22
+ readonly name = 'Slack';
23
+
24
+ private app: App;
25
+ private config: SlackAdapterConfig;
26
+ private messageHandler?: (message: InboundMessage) => Promise<void>;
27
+ private errorHandler?: (error: Error) => void;
28
+ private connected = false;
29
+ private botUserId?: string;
30
+
31
+ constructor(config: SlackAdapterConfig) {
32
+ this.config = config;
33
+ this.app = new App({
34
+ token: config.botToken,
35
+ appToken: config.appToken,
36
+ socketMode: true,
37
+ logLevel: LogLevel.WARN,
38
+ });
39
+
40
+ this.setupEventHandlers();
41
+ }
42
+
43
+ private setupEventHandlers(): void {
44
+ // Listen to all messages
45
+ this.app.message(async ({ message, say, client }) => {
46
+ // Type guard for regular messages
47
+ if (!('user' in message) || !('text' in message)) return;
48
+ if (message.subtype) return; // Ignore edited, deleted, etc.
49
+
50
+ // Get bot user ID if we don't have it
51
+ if (!this.botUserId) {
52
+ const auth = await client.auth.test();
53
+ this.botUserId = auth.user_id;
54
+ }
55
+
56
+ // Ignore bot's own messages
57
+ if (message.user === this.botUserId) return;
58
+
59
+ // Check allowed channels
60
+ if (this.config.allowedChannels?.length && message.channel && !this.config.allowedChannels.includes(message.channel as string)) {
61
+ audit('message.filtered', { channelType: 'slack', senderId: message.user as string, channelId: message.channel as string, reason: 'channel_not_allowed' });
62
+ return;
63
+ }
64
+
65
+ // Check allowed users
66
+ if (this.config.allowedUsers?.length && !this.config.allowedUsers.includes(message.user as string)) {
67
+ audit('message.filtered', { channelType: 'slack', senderId: message.user as string, channelId: message.channel as string, reason: 'user_not_allowed' });
68
+ return;
69
+ }
70
+
71
+ const inbound = this.toInboundMessage(message);
72
+
73
+ audit('message.received', {
74
+ channelType: 'slack',
75
+ senderId: inbound.senderId,
76
+ channelId: inbound.channelId,
77
+ });
78
+
79
+ if (this.messageHandler) {
80
+ try {
81
+ await this.messageHandler(inbound);
82
+ } catch (error) {
83
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
84
+ }
85
+ }
86
+ });
87
+
88
+ // Handle mentions specifically
89
+ this.app.event('app_mention', async ({ event, client }) => {
90
+ // Get bot user ID if we don't have it
91
+ if (!this.botUserId) {
92
+ const auth = await client.auth.test();
93
+ this.botUserId = auth.user_id;
94
+ }
95
+
96
+ // Check allowed channels
97
+ if (this.config.allowedChannels?.length && event.channel && !this.config.allowedChannels.includes(event.channel)) {
98
+ audit('message.filtered', { channelType: 'slack', senderId: event.user || '', channelId: event.channel, reason: 'channel_not_allowed' });
99
+ return;
100
+ }
101
+
102
+ // Check allowed users
103
+ if (this.config.allowedUsers?.length && event.user && !this.config.allowedUsers.includes(event.user)) {
104
+ audit('message.filtered', { channelType: 'slack', senderId: event.user, channelId: event.channel || '', reason: 'user_not_allowed' });
105
+ return;
106
+ }
107
+
108
+ // Strip bot mention from text
109
+ let content = event.text || '';
110
+ if (this.botUserId) {
111
+ content = content.replace(new RegExp(`<@${this.botUserId}>`, 'g'), '').trim();
112
+ }
113
+
114
+ const inbound: InboundMessage = {
115
+ id: event.ts,
116
+ channelType: 'slack',
117
+ channelId: event.channel || '',
118
+ senderId: event.user || '',
119
+ content,
120
+ timestamp: parseFloat(event.ts) * 1000,
121
+ replyToId: event.thread_ts,
122
+ raw: event,
123
+ };
124
+
125
+ audit('message.received', {
126
+ channelType: 'slack',
127
+ senderId: inbound.senderId,
128
+ channelId: inbound.channelId,
129
+ isMention: true,
130
+ });
131
+
132
+ if (this.messageHandler) {
133
+ try {
134
+ await this.messageHandler(inbound);
135
+ } catch (error) {
136
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
137
+ }
138
+ }
139
+ });
140
+
141
+ this.app.error(async (error) => {
142
+ audit('channel.error', { channelType: 'slack', error: error.message });
143
+ this.errorHandler?.(error);
144
+ });
145
+ }
146
+
147
+ private toInboundMessage(message: {
148
+ ts: string;
149
+ channel?: string;
150
+ user?: string;
151
+ text?: string;
152
+ thread_ts?: string;
153
+ }): InboundMessage {
154
+ return {
155
+ id: message.ts,
156
+ channelType: 'slack',
157
+ channelId: message.channel || '',
158
+ senderId: message.user || '',
159
+ content: message.text || '',
160
+ timestamp: parseFloat(message.ts) * 1000,
161
+ replyToId: message.thread_ts,
162
+ raw: message,
163
+ };
164
+ }
165
+
166
+ async connect(): Promise<void> {
167
+ await this.app.start();
168
+ this.connected = true;
169
+ audit('channel.connected', { channelType: 'slack' });
170
+ }
171
+
172
+ async disconnect(): Promise<void> {
173
+ await this.app.stop();
174
+ this.connected = false;
175
+ audit('channel.disconnected', { channelType: 'slack' });
176
+ }
177
+
178
+ isConnected(): boolean {
179
+ return this.connected;
180
+ }
181
+
182
+ async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
183
+ try {
184
+ // Chunk long messages
185
+ const chunks = this.chunkMessage(message.content);
186
+ let lastTs: string | undefined;
187
+
188
+ for (const chunk of chunks) {
189
+ const result = await this.app.client.chat.postMessage({
190
+ channel: channelId,
191
+ text: chunk,
192
+ thread_ts: message.replyToId,
193
+ mrkdwn: message.formatting?.markdown !== false,
194
+ });
195
+ lastTs = result.ts;
196
+ }
197
+
198
+ audit('message.sent', {
199
+ channelType: 'slack',
200
+ channelId,
201
+ messageId: lastTs,
202
+ });
203
+
204
+ return { success: true, messageId: lastTs };
205
+ } catch (error) {
206
+ const errorMessage = error instanceof Error ? error.message : String(error);
207
+ audit('channel.error', {
208
+ channelType: 'slack',
209
+ action: 'send',
210
+ error: errorMessage,
211
+ });
212
+ return { success: false, error: errorMessage };
213
+ }
214
+ }
215
+
216
+ private chunkMessage(content: string): string[] {
217
+ if (content.length <= MAX_MESSAGE_LENGTH) {
218
+ return [content];
219
+ }
220
+
221
+ const chunks: string[] = [];
222
+ let remaining = content;
223
+
224
+ while (remaining.length > 0) {
225
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
226
+ chunks.push(remaining);
227
+ break;
228
+ }
229
+
230
+ let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
231
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
232
+ breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
233
+ }
234
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
235
+ breakPoint = MAX_MESSAGE_LENGTH;
236
+ }
237
+
238
+ chunks.push(remaining.slice(0, breakPoint));
239
+ remaining = remaining.slice(breakPoint).trimStart();
240
+ }
241
+
242
+ return chunks;
243
+ }
244
+
245
+ onMessage(handler: (message: InboundMessage) => Promise<void>): void {
246
+ this.messageHandler = handler;
247
+ }
248
+
249
+ onError(handler: (error: Error) => void): void {
250
+ this.errorHandler = handler;
251
+ }
252
+ }
@@ -0,0 +1,320 @@
1
+ import { audit } from '@auxiora/audit';
2
+ import type {
3
+ ChannelAdapter,
4
+ InboundMessage,
5
+ OutboundMessage,
6
+ SendResult,
7
+ } from '../types.js';
8
+
9
+ export interface TeamsAdapterConfig {
10
+ microsoftAppId: string;
11
+ microsoftAppPassword: string;
12
+ allowedTenants?: string[];
13
+ allowedUsers?: string[];
14
+ }
15
+
16
+ interface TeamsActivity {
17
+ type: string;
18
+ id: string;
19
+ timestamp: string;
20
+ channelId: string;
21
+ from: {
22
+ id: string;
23
+ name?: string;
24
+ aadObjectId?: string;
25
+ };
26
+ conversation: {
27
+ id: string;
28
+ conversationType?: string;
29
+ tenantId?: string;
30
+ isGroup?: boolean;
31
+ };
32
+ recipient?: {
33
+ id: string;
34
+ name?: string;
35
+ };
36
+ text?: string;
37
+ serviceUrl: string;
38
+ channelData?: Record<string, unknown>;
39
+ replyToId?: string;
40
+ attachments?: Array<{
41
+ contentType: string;
42
+ contentUrl?: string;
43
+ name?: string;
44
+ content?: unknown;
45
+ }>;
46
+ }
47
+
48
+ interface TeamsTokenResponse {
49
+ access_token: string;
50
+ expires_in: number;
51
+ token_type: string;
52
+ }
53
+
54
+ interface TeamsSendResponse {
55
+ id: string;
56
+ }
57
+
58
+ const MAX_MESSAGE_LENGTH = 28000;
59
+ const TOKEN_URL = 'https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token';
60
+
61
+ export class TeamsAdapter implements ChannelAdapter {
62
+ readonly type = 'teams' as const;
63
+ readonly name = 'Microsoft Teams';
64
+
65
+ private config: TeamsAdapterConfig;
66
+ private messageHandler?: (message: InboundMessage) => Promise<void>;
67
+ private errorHandler?: (error: Error) => void;
68
+ private connected = false;
69
+ private accessToken?: string;
70
+ private tokenExpiry = 0;
71
+
72
+ constructor(config: TeamsAdapterConfig) {
73
+ this.config = config;
74
+ }
75
+
76
+ async connect(): Promise<void> {
77
+ // Verify credentials by obtaining a token
78
+ await this.getAccessToken();
79
+ this.connected = true;
80
+
81
+ audit('channel.connected', {
82
+ channelType: 'teams',
83
+ appId: this.config.microsoftAppId,
84
+ });
85
+ }
86
+
87
+ async disconnect(): Promise<void> {
88
+ this.connected = false;
89
+ this.accessToken = undefined;
90
+ this.tokenExpiry = 0;
91
+ audit('channel.disconnected', { channelType: 'teams' });
92
+ }
93
+
94
+ isConnected(): boolean {
95
+ return this.connected;
96
+ }
97
+
98
+ private async getAccessToken(): Promise<string> {
99
+ if (this.accessToken && Date.now() < this.tokenExpiry) {
100
+ return this.accessToken;
101
+ }
102
+
103
+ const params = new URLSearchParams({
104
+ grant_type: 'client_credentials',
105
+ client_id: this.config.microsoftAppId,
106
+ client_secret: this.config.microsoftAppPassword,
107
+ scope: 'https://api.botframework.com/.default',
108
+ });
109
+
110
+ const response = await fetch(TOKEN_URL, {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
113
+ body: params.toString(),
114
+ });
115
+
116
+ if (!response.ok) {
117
+ throw new Error(`Failed to obtain Teams token: ${response.status} ${response.statusText}`);
118
+ }
119
+
120
+ const data = await response.json() as TeamsTokenResponse;
121
+ this.accessToken = data.access_token;
122
+ // Expire 5 minutes early to avoid edge cases
123
+ this.tokenExpiry = Date.now() + (data.expires_in - 300) * 1000;
124
+
125
+ return this.accessToken;
126
+ }
127
+
128
+ /**
129
+ * Handle incoming webhook from Microsoft Bot Framework.
130
+ * Call this from your HTTP server when receiving POST to /api/messages.
131
+ */
132
+ async handleWebhook(activity: TeamsActivity): Promise<void> {
133
+ if (activity.type !== 'message') return;
134
+ if (!activity.text) return;
135
+
136
+ // Check allowed tenants
137
+ const tenantId = activity.conversation.tenantId;
138
+ if (tenantId && this.config.allowedTenants?.length && !this.config.allowedTenants.includes(tenantId)) {
139
+ audit('message.filtered', { channelType: 'teams', senderId: activity.from.id, tenantId, reason: 'tenant_not_allowed' });
140
+ return;
141
+ }
142
+
143
+ // Check allowed users
144
+ if (this.config.allowedUsers?.length && !this.config.allowedUsers.includes(activity.from.id)) {
145
+ audit('message.filtered', { channelType: 'teams', senderId: activity.from.id, reason: 'user_not_allowed' });
146
+ return;
147
+ }
148
+
149
+ const inbound = this.toInboundMessage(activity);
150
+
151
+ audit('message.received', {
152
+ channelType: 'teams',
153
+ senderId: inbound.senderId,
154
+ channelId: inbound.channelId,
155
+ });
156
+
157
+ if (this.messageHandler) {
158
+ try {
159
+ await this.messageHandler(inbound);
160
+ } catch (error) {
161
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
162
+ }
163
+ }
164
+ }
165
+
166
+ private toInboundMessage(activity: TeamsActivity): InboundMessage {
167
+ // Strip bot mention from content
168
+ let content = activity.text || '';
169
+ if (activity.recipient?.name) {
170
+ content = content.replace(
171
+ new RegExp(`<at>${activity.recipient.name}</at>`, 'gi'),
172
+ '',
173
+ ).trim();
174
+ }
175
+
176
+ return {
177
+ id: activity.id,
178
+ channelType: 'teams',
179
+ channelId: activity.conversation.id,
180
+ senderId: activity.from.id,
181
+ senderName: activity.from.name,
182
+ content,
183
+ timestamp: new Date(activity.timestamp).getTime(),
184
+ replyToId: activity.replyToId,
185
+ attachments: activity.attachments?.map((a) => ({
186
+ type: a.contentType.startsWith('image/')
187
+ ? 'image' as const
188
+ : 'file' as const,
189
+ url: a.contentUrl,
190
+ mimeType: a.contentType,
191
+ filename: a.name,
192
+ })),
193
+ raw: activity,
194
+ };
195
+ }
196
+
197
+ async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
198
+ try {
199
+ const token = await this.getAccessToken();
200
+ const chunks = this.chunkMessage(message.content);
201
+ let lastMessageId: string | undefined;
202
+
203
+ // We need the serviceUrl from the incoming activity
204
+ // For now, use the default Bot Framework service URL
205
+ const serviceUrl = 'https://smba.trafficmanager.net/teams/';
206
+
207
+ for (const chunk of chunks) {
208
+ const activity = {
209
+ type: 'message',
210
+ text: chunk,
211
+ ...(message.replyToId ? { replyToId: message.replyToId } : {}),
212
+ };
213
+
214
+ const url = `${serviceUrl}v3/conversations/${encodeURIComponent(channelId)}/activities`;
215
+ const response = await fetch(url, {
216
+ method: 'POST',
217
+ headers: {
218
+ 'Authorization': `Bearer ${token}`,
219
+ 'Content-Type': 'application/json',
220
+ },
221
+ body: JSON.stringify(activity),
222
+ });
223
+
224
+ if (!response.ok) {
225
+ const errorText = await response.text().catch(() => response.statusText);
226
+ throw new Error(`Teams API error ${response.status}: ${errorText}`);
227
+ }
228
+
229
+ const result = await response.json() as TeamsSendResponse;
230
+ lastMessageId = result.id;
231
+ }
232
+
233
+ audit('message.sent', {
234
+ channelType: 'teams',
235
+ channelId,
236
+ messageId: lastMessageId,
237
+ });
238
+
239
+ return { success: true, messageId: lastMessageId };
240
+ } catch (error) {
241
+ const errorMessage = error instanceof Error ? error.message : String(error);
242
+ audit('channel.error', {
243
+ channelType: 'teams',
244
+ action: 'send',
245
+ error: errorMessage,
246
+ });
247
+ return { success: false, error: errorMessage };
248
+ }
249
+ }
250
+
251
+ private chunkMessage(content: string): string[] {
252
+ if (content.length <= MAX_MESSAGE_LENGTH) {
253
+ return [content];
254
+ }
255
+
256
+ const chunks: string[] = [];
257
+ let remaining = content;
258
+
259
+ while (remaining.length > 0) {
260
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
261
+ chunks.push(remaining);
262
+ break;
263
+ }
264
+
265
+ let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
266
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
267
+ breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
268
+ }
269
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
270
+ breakPoint = MAX_MESSAGE_LENGTH;
271
+ }
272
+
273
+ chunks.push(remaining.slice(0, breakPoint));
274
+ remaining = remaining.slice(breakPoint).trimStart();
275
+ }
276
+
277
+ return chunks;
278
+ }
279
+
280
+ async startTyping(channelId: string): Promise<() => void> {
281
+ let token: string;
282
+ try {
283
+ token = await this.getAccessToken();
284
+ } catch {
285
+ return () => {};
286
+ }
287
+ const serviceUrl = 'https://smba.trafficmanager.net/teams/';
288
+ const url = `${serviceUrl}v3/conversations/${encodeURIComponent(channelId)}/activities`;
289
+
290
+ // Send typing activity immediately, repeat every 3s
291
+ let stopped = false;
292
+ const sendTyping = () =>
293
+ fetch(url, {
294
+ method: 'POST',
295
+ headers: {
296
+ 'Authorization': `Bearer ${token}`,
297
+ 'Content-Type': 'application/json',
298
+ },
299
+ body: JSON.stringify({ type: 'typing' }),
300
+ }).catch(() => {});
301
+
302
+ sendTyping();
303
+ const interval = setInterval(() => {
304
+ if (stopped) return;
305
+ sendTyping();
306
+ }, 3000);
307
+ return () => {
308
+ stopped = true;
309
+ clearInterval(interval);
310
+ };
311
+ }
312
+
313
+ onMessage(handler: (message: InboundMessage) => Promise<void>): void {
314
+ this.messageHandler = handler;
315
+ }
316
+
317
+ onError(handler: (error: Error) => void): void {
318
+ this.errorHandler = handler;
319
+ }
320
+ }