@xmanrui/dsh-im 0.7.0 → 0.7.1

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 (29) hide show
  1. package/lib/index.js +118 -121
  2. package/package.json +1 -1
  3. package/src/channels/dingtalk/dingtalk-bridge.mjs +360 -8
  4. package/src/channels/dingtalk/harness-client.mjs +16 -366
  5. package/src/channels/discord/discord-api.mjs +1 -1
  6. package/src/channels/discord/discord-runtime.mjs +11 -1
  7. package/src/channels/discord/harness-client.mjs +10 -2
  8. package/src/channels/feishu/bridge.mjs +522 -50
  9. package/src/channels/feishu/feishu-runtime.mjs +41 -1
  10. package/src/channels/feishu/harness-client.mjs +16 -335
  11. package/src/channels/qq/harness-client.mjs +10 -2
  12. package/src/channels/qq/qq-bridge.mjs +380 -28
  13. package/src/channels/qq/qq-runtime.mjs +14 -3
  14. package/src/channels/shared/harness-client.mjs +825 -0
  15. package/src/channels/shared/harness-question.mjs +85 -0
  16. package/src/channels/shared/text-harness-bridge.mjs +436 -24
  17. package/src/channels/slack/harness-client.mjs +10 -2
  18. package/src/channels/slack/slack-runtime.mjs +11 -1
  19. package/src/channels/telegram/harness-client.mjs +10 -2
  20. package/src/channels/telegram/telegram-runtime.mjs +15 -4
  21. package/src/channels/wecom/harness-client.mjs +10 -2
  22. package/src/channels/wecom/wecom-bridge.mjs +386 -14
  23. package/src/channels/wecom/wecom-runtime.mjs +6 -0
  24. package/src/channels/weixin/harness-client.mjs +16 -326
  25. package/src/channels/weixin/weixin-api.mjs +1 -1
  26. package/src/channels/weixin/weixin-bridge.mjs +402 -22
  27. package/src/channels/weixin/weixin-runtime.mjs +56 -7
  28. package/src/channels/whatsapp/harness-client.mjs +10 -2
  29. package/src/channels/whatsapp/whatsapp-runtime.mjs +1 -0
@@ -1,6 +1,13 @@
1
1
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
2
+ import {
3
+ harnessAnswerForQuestion,
4
+ harnessQuestionText,
5
+ validHarnessQuestion,
6
+ } from '../shared/harness-question.mjs';
2
7
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
3
8
 
9
+ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
10
+
4
11
  const HELP_TEXT = [
5
12
  'QQ 机器人已连接 DeepSeek Harness。',
6
13
  '',
@@ -22,6 +29,17 @@ function safeText(message) {
22
29
  return typeof message?.content === 'string' ? message.content.trim() : '';
23
30
  }
24
31
 
32
+ function nonEmptyString(value) {
33
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
34
+ }
35
+
36
+ function canClaimInteractionReply(message, pending) {
37
+ return pending.questions[pending.index]
38
+ && nonEmptyString(message?.senderId) === pending.actor
39
+ && (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
40
+ && nonEmptyString(safeText(message));
41
+ }
42
+
25
43
  export function createQqBridgeStatus() {
26
44
  return {
27
45
  messagesReceived: 0,
@@ -42,7 +60,11 @@ export class QqHarnessBridge {
42
60
  #status;
43
61
  #logger;
44
62
  #replyTimeoutMs;
63
+ #signal;
45
64
  #queues = new Map();
65
+ #pendingInteractions = new Map();
66
+ #interactionKeys = new Map();
67
+ #acceptedMessageIds = new Set();
46
68
 
47
69
  constructor({
48
70
  bot,
@@ -52,6 +74,7 @@ export class QqHarnessBridge {
52
74
  status = createQqBridgeStatus(),
53
75
  logger = console,
54
76
  replyTimeoutMs = 600_000,
77
+ signal,
55
78
  }) {
56
79
  if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
57
80
  if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
@@ -63,6 +86,7 @@ export class QqHarnessBridge {
63
86
  this.#status = status;
64
87
  this.#logger = logger;
65
88
  this.#replyTimeoutMs = replyTimeoutMs;
89
+ this.#signal = signal;
66
90
  }
67
91
 
68
92
  get status() {
@@ -70,12 +94,52 @@ export class QqHarnessBridge {
70
94
  }
71
95
 
72
96
  accept(message) {
97
+ if (this.#signal?.aborted) return Promise.resolve();
98
+ const messageId = nonEmptyString(message?.messageId);
99
+ const sender = nonEmptyString(message?.senderId);
100
+ if (!messageId || !sender || message?.senderIsBot === true
101
+ || !['c2c', 'group'].includes(message?.kind)
102
+ || this.#state.hasSeen(messageId)
103
+ || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
73
104
  const key = conversationKey(message);
105
+ this.#acceptedMessageIds.add(messageId);
106
+ const pending = this.#pendingInteractions.get(key);
107
+ if (pending && sender !== pending.actor) {
108
+ return this.#enqueueMessage(message, messageId, key);
109
+ }
110
+ if (pending?.submitting || pending?.claimedReplyMessageId) {
111
+ return this.#enqueueMessage(message, messageId, key);
112
+ }
113
+ if (pending) {
114
+ if (canClaimInteractionReply(message, pending)) {
115
+ pending.claimedReplyMessageId = messageId;
116
+ }
117
+ const previous = pending.queue ?? Promise.resolve();
118
+ const current = previous
119
+ .catch(() => undefined)
120
+ .then(() => this.#processInteractionReply(message, messageId, key, pending))
121
+ .catch((error) => this.#handleInteractionFailure(message, messageId, error))
122
+ .finally(() => {
123
+ this.#acceptedMessageIds.delete(messageId);
124
+ if (pending.claimedReplyMessageId === messageId) pending.claimedReplyMessageId = null;
125
+ if (pending.queue === current) pending.queue = null;
126
+ });
127
+ pending.queue = current;
128
+ return current;
129
+ }
130
+ return this.#enqueueMessage(message, messageId, key);
131
+ }
132
+
133
+ #enqueueMessage(message, messageId, key, {
134
+ releaseMessageId = true,
135
+ alreadyRecorded = false,
136
+ } = {}) {
74
137
  const previous = this.#queues.get(key) ?? Promise.resolve();
75
138
  const current = previous
76
139
  .catch(() => undefined)
77
- .then(() => this.#process(message))
140
+ .then(() => this.#process(message, key, { alreadyRecorded }))
78
141
  .finally(() => {
142
+ if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
79
143
  if (this.#queues.get(key) === current) this.#queues.delete(key);
80
144
  });
81
145
  this.#queues.set(key, current);
@@ -83,17 +147,25 @@ export class QqHarnessBridge {
83
147
  }
84
148
 
85
149
  async waitForIdle() {
86
- await Promise.allSettled([...this.#queues.values()]);
150
+ await Promise.allSettled([
151
+ ...this.#queues.values(),
152
+ ...[...this.#pendingInteractions.values()].flatMap((pending) => (
153
+ pending.queue ? [pending.queue] : []
154
+ )),
155
+ ]);
87
156
  }
88
157
 
89
- async #process(message) {
90
- const messageId = typeof message?.messageId === 'string' ? message.messageId : '';
91
- const sender = typeof message?.senderId === 'string' ? message.senderId : '';
158
+ async #process(message, key, { alreadyRecorded = false } = {}) {
159
+ if (this.#signal?.aborted) return;
160
+ const messageId = nonEmptyString(message?.messageId);
161
+ const sender = nonEmptyString(message?.senderId);
92
162
  if (!messageId || !sender || message.senderIsBot === true) return;
93
- if (!['c2c', 'group'].includes(message.kind) || this.#state.hasSeen(messageId)) return;
94
-
95
- this.#status.messagesReceived += 1;
96
- this.#status.lastMessageAt = new Date().toISOString();
163
+ if (!['c2c', 'group'].includes(message.kind)) return;
164
+ if (!alreadyRecorded) {
165
+ if (this.#state.hasSeen(messageId)) return;
166
+ this.#status.messagesReceived += 1;
167
+ this.#status.lastMessageAt = new Date().toISOString();
168
+ }
97
169
  if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) {
98
170
  this.#status.messagesRejected += 1;
99
171
  this.#status.lastRejectedAt = new Date().toISOString();
@@ -116,12 +188,11 @@ export class QqHarnessBridge {
116
188
  return;
117
189
  }
118
190
  if (command === '/status') {
119
- await this.#harness.ensureRunning();
191
+ await this.#harness.ensureRunning({ signal: this.#signal });
120
192
  await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
121
193
  await this.#state.markSeen(messageId);
122
194
  return;
123
195
  }
124
- const key = conversationKey(message);
125
196
  if (command === '/new') {
126
197
  await this.#state.clearSession(key);
127
198
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
@@ -146,23 +217,38 @@ export class QqHarnessBridge {
146
217
  this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', error);
147
218
  }
148
219
  }
149
- const { answer } = await askInWorkspaceSession({
150
- harness: this.#harness,
151
- state: this.#state,
152
- key,
153
- text,
154
- askOptions: {
155
- timeoutMs: this.#replyTimeoutMs,
156
- onUpdate: stream ? async (update) => {
157
- const progress = update.type === 'text'
158
- ? update.text
159
- : update.type === 'tool'
160
- ? `正在使用${update.name}…`
161
- : update.text;
162
- if (progress) await stream.update(progress);
163
- } : undefined,
164
- },
165
- });
220
+ let answer;
221
+ try {
222
+ ({ answer } = await askInWorkspaceSession({
223
+ harness: this.#harness,
224
+ state: this.#state,
225
+ key,
226
+ text,
227
+ createOptions: { signal: this.#signal },
228
+ existsOptions: { signal: this.#signal },
229
+ askOptions: {
230
+ timeoutMs: this.#replyTimeoutMs,
231
+ signal: this.#signal,
232
+ onUpdate: stream ? async (update) => {
233
+ const progress = update.type === 'text'
234
+ ? update.text
235
+ : update.type === 'tool'
236
+ ? `正在使用${update.name}…`
237
+ : update.text;
238
+ if (progress) await stream.update(progress);
239
+ } : undefined,
240
+ onInteraction: (interaction) => this.#handleInteraction(interaction, {
241
+ key,
242
+ actor: sender,
243
+ target,
244
+ requiresMention: message.kind === 'group',
245
+ }),
246
+ onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
247
+ },
248
+ }));
249
+ } finally {
250
+ await this.#cancelPendingInteraction(key);
251
+ }
166
252
  if (stream) {
167
253
  try {
168
254
  await stream.update(answer);
@@ -179,6 +265,7 @@ export class QqHarnessBridge {
179
265
  this.#status.lastReplyAt = new Date().toISOString();
180
266
  this.#status.lastError = null;
181
267
  } catch (error) {
268
+ if (this.#signal?.aborted) return;
182
269
  this.#status.lastError = error?.message ?? String(error);
183
270
  this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
184
271
  try {
@@ -189,4 +276,269 @@ export class QqHarnessBridge {
189
276
  }
190
277
  }
191
278
  }
279
+
280
+ async #processInteractionReply(message, messageId, key, expected) {
281
+ this.#signal?.throwIfAborted();
282
+ const current = this.#pendingInteractions.get(key);
283
+ const claimed = expected.claimedReplyMessageId === messageId;
284
+ if (!current || current !== expected || current.submitting) {
285
+ if (claimed && (!current || current !== expected)) {
286
+ return this.#discardResolvedInteractionReply(message, messageId);
287
+ }
288
+ return this.#enqueueMessage(message, messageId, key, { releaseMessageId: false });
289
+ }
290
+ if (this.#state.hasSeen(messageId)) return;
291
+ await this.#state.markSeen(messageId);
292
+ this.#status.messagesReceived += 1;
293
+ this.#status.lastMessageAt = new Date().toISOString();
294
+
295
+ if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
296
+ const text = nonEmptyString(safeText(message));
297
+ if (!text) {
298
+ await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
299
+ return;
300
+ }
301
+
302
+ const pending = this.#pendingInteractions.get(key);
303
+ if (!pending || pending !== expected || pending.submitting) {
304
+ if (claimed && (!pending || pending !== expected)) {
305
+ await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT);
306
+ return;
307
+ }
308
+ return this.#enqueueMessage(message, messageId, key, {
309
+ releaseMessageId: false,
310
+ alreadyRecorded: true,
311
+ });
312
+ }
313
+ pending.target = message.replyTarget;
314
+ if (pending.needsPresentation) {
315
+ try {
316
+ await this.#presentInteraction(pending);
317
+ } catch {
318
+ this.#status.lastError = 'QQ 交互问题发送失败。';
319
+ this.#logger.error?.('[dsh-im:qq] failed to retry an interaction question');
320
+ pending.interaction.reconnect?.();
321
+ return;
322
+ }
323
+ const presentedPending = this.#pendingInteractions.get(key);
324
+ if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
325
+ if (claimed && (!presentedPending || presentedPending !== expected)) {
326
+ await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT)
327
+ .catch(() => undefined);
328
+ return;
329
+ }
330
+ return this.#enqueueMessage(message, messageId, key, {
331
+ releaseMessageId: false,
332
+ alreadyRecorded: true,
333
+ });
334
+ }
335
+ }
336
+
337
+ const question = pending.questions[pending.index];
338
+ if (!question) return;
339
+ pending.answers.push(harnessAnswerForQuestion(question, text));
340
+ pending.index += 1;
341
+ if (pending.index < pending.questions.length) {
342
+ if (pending.claimedReplyMessageId === messageId) {
343
+ pending.claimedReplyMessageId = null;
344
+ }
345
+ pending.needsPresentation = true;
346
+ try {
347
+ await this.#presentInteraction(pending);
348
+ } catch {
349
+ this.#status.lastError = 'QQ 交互问题发送失败。';
350
+ this.#logger.error?.('[dsh-im:qq] failed to send the next interaction question');
351
+ pending.interaction.reconnect?.();
352
+ }
353
+ return;
354
+ }
355
+
356
+ pending.submitting = true;
357
+ try {
358
+ await pending.interaction.respond({
359
+ ok: true,
360
+ value: {
361
+ sessionId: pending.sessionId,
362
+ answer: { answers: pending.answers },
363
+ },
364
+ });
365
+ this.#clearPendingInteraction(key, pending.interactionId);
366
+ this.#status.lastError = null;
367
+ } catch (error) {
368
+ if (this.#signal?.aborted) return;
369
+ if (error?.code === 'interaction-not-pending') {
370
+ this.#clearPendingInteraction(key, pending.interactionId);
371
+ await this.#bot.sendText(pending.target, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
372
+ return;
373
+ }
374
+ if (this.#pendingInteractions.get(key) !== pending) return;
375
+ pending.submitting = false;
376
+ pending.answers.pop();
377
+ pending.index -= 1;
378
+ this.#status.lastError = '回答提交失败。';
379
+ this.#logger.error?.('[dsh-im:qq] failed to answer a Harness interaction');
380
+ await this.#bot.sendText(pending.target, '回答提交失败,请重新发送当前问题的答案。')
381
+ .catch(() => undefined);
382
+ }
383
+ }
384
+
385
+ async #handleInteraction(interaction, {
386
+ key,
387
+ actor,
388
+ target,
389
+ requiresMention,
390
+ }) {
391
+ // Approval remains fail-closed until #5 adds an authenticated policy.
392
+ if (interaction?.kind !== 'question') return;
393
+ const questions = interaction?.payload?.questions;
394
+ const interactionId = typeof interaction?.interactionId === 'string'
395
+ ? interaction.interactionId
396
+ : interaction?.rpcId;
397
+ if (typeof interaction?.rpcId !== 'string'
398
+ || typeof interactionId !== 'string'
399
+ || typeof interaction.sessionId !== 'string'
400
+ || !Array.isArray(questions)
401
+ || questions.length === 0
402
+ || questions.some((question) => !validHarnessQuestion(question))) {
403
+ this.#logger.warn?.('[dsh-im:qq] ignored an invalid Harness question interaction');
404
+ return;
405
+ }
406
+
407
+ if (interaction.recovered === true) {
408
+ await interaction.respond({
409
+ ok: false,
410
+ error: {
411
+ code: 'cancelled',
412
+ message: 'QQ safely cancelled an interaction left by an earlier client.',
413
+ details: {},
414
+ },
415
+ });
416
+ await this.#bot.sendText(
417
+ target,
418
+ '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
419
+ ).catch(() => undefined);
420
+ return;
421
+ }
422
+
423
+ const existing = this.#pendingInteractions.get(key);
424
+ if (existing?.interactionId === interactionId) {
425
+ existing.interaction = interaction;
426
+ if (existing.needsPresentation) await this.#presentInteraction(existing);
427
+ return;
428
+ }
429
+ if (this.#interactionKeys.has(interactionId)) return;
430
+ if (existing) {
431
+ await interaction.respond({
432
+ ok: false,
433
+ error: {
434
+ code: 'cancelled',
435
+ message: 'QQ is already handling another user interaction.',
436
+ details: {},
437
+ },
438
+ });
439
+ return;
440
+ }
441
+
442
+ const pending = {
443
+ kind: 'question',
444
+ interactionId,
445
+ sessionId: interaction.sessionId,
446
+ interaction,
447
+ actor,
448
+ requiresMention,
449
+ questions,
450
+ answers: [],
451
+ index: 0,
452
+ target,
453
+ queue: null,
454
+ claimedReplyMessageId: null,
455
+ presentationPromise: null,
456
+ submitting: false,
457
+ needsPresentation: true,
458
+ };
459
+ this.#pendingInteractions.set(key, pending);
460
+ this.#interactionKeys.set(interactionId, key);
461
+ await this.#presentInteraction(pending);
462
+ }
463
+
464
+ #handleInteractionResolved(resolution) {
465
+ const interactionId = resolution?.interactionId;
466
+ if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
467
+ const key = this.#interactionKeys.get(interactionId);
468
+ if (!key) return;
469
+ this.#clearPendingInteraction(key, interactionId);
470
+ }
471
+
472
+ #presentInteraction(pending) {
473
+ if (!pending.needsPresentation) return Promise.resolve();
474
+ if (pending.presentationPromise) return pending.presentationPromise;
475
+ const question = pending.questions[pending.index];
476
+ if (!question) return Promise.resolve();
477
+ const presentation = this.#bot.sendText(
478
+ pending.target,
479
+ harnessQuestionText(
480
+ question,
481
+ pending.index,
482
+ pending.questions.length,
483
+ { requiresMention: pending.requiresMention },
484
+ ),
485
+ ).then(() => {
486
+ pending.needsPresentation = false;
487
+ }).finally(() => {
488
+ if (pending.presentationPromise === presentation) pending.presentationPromise = null;
489
+ });
490
+ pending.presentationPromise = presentation;
491
+ return presentation;
492
+ }
493
+
494
+ async #discardResolvedInteractionReply(message, messageId) {
495
+ if (this.#state.hasSeen(messageId)) return;
496
+ await this.#state.markSeen(messageId);
497
+ this.#status.messagesReceived += 1;
498
+ this.#status.lastMessageAt = new Date().toISOString();
499
+ await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
500
+ }
501
+
502
+ #takePendingInteraction(key, interactionId) {
503
+ const pending = this.#pendingInteractions.get(key);
504
+ if (!pending
505
+ || (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
506
+ this.#pendingInteractions.delete(key);
507
+ this.#interactionKeys.delete(pending.interactionId);
508
+ return pending;
509
+ }
510
+
511
+ #clearPendingInteraction(key, interactionId) {
512
+ return this.#takePendingInteraction(key, interactionId) !== null;
513
+ }
514
+
515
+ async #cancelPendingInteraction(key) {
516
+ const pending = this.#takePendingInteraction(key);
517
+ if (!pending || pending.kind !== 'question') return;
518
+ try {
519
+ await pending.interaction.respond({
520
+ ok: false,
521
+ error: {
522
+ code: 'cancelled',
523
+ message: 'The QQ interaction ended before the user answered.',
524
+ details: {},
525
+ },
526
+ }, { signal: AbortSignal.timeout(5_000) });
527
+ } catch (error) {
528
+ if (error?.code !== 'interaction-not-pending') {
529
+ this.#logger.warn?.('[dsh-im:qq] failed to cancel a pending Harness interaction');
530
+ }
531
+ }
532
+ }
533
+
534
+ async #handleInteractionFailure(message, messageId, error) {
535
+ if (this.#signal?.aborted) return;
536
+ this.#status.lastError = error?.message ?? String(error);
537
+ this.#logger.error?.('[dsh-im:qq] failed to process an interaction reply:', error);
538
+ if (!this.#state.hasSeen(messageId)) {
539
+ await this.#state.markSeen(messageId).catch(() => undefined);
540
+ }
541
+ await this.#bot.sendText(message.replyTarget, '消息处理失败,请稍后重试。')
542
+ .catch(() => undefined);
543
+ }
192
544
  }
@@ -101,6 +101,8 @@ export class QqRuntime {
101
101
  if (!bot || typeof bot.start !== 'function' || typeof bot.stop !== 'function') {
102
102
  throw new TypeError('QQ bot factory returned an invalid client');
103
103
  }
104
+ const controller = new AbortController();
105
+ this.#abortController = controller;
104
106
  this.#bot = bot;
105
107
  this.#bridge = new QqHarnessBridge({
106
108
  bot,
@@ -110,6 +112,7 @@ export class QqRuntime {
110
112
  status: this.#status,
111
113
  logger: this.#logger,
112
114
  replyTimeoutMs: this.#replyTimeoutMs,
115
+ signal: controller.signal,
113
116
  });
114
117
  bot.use?.(this.#typingMiddleware({
115
118
  keepAlive: true,
@@ -117,8 +120,6 @@ export class QqRuntime {
117
120
  || ctx?.message?.senderId === this.#config.ownerUserOpenid,
118
121
  }));
119
122
 
120
- const controller = new AbortController();
121
- this.#abortController = controller;
122
123
  let readyResolve;
123
124
  let readyReject;
124
125
  const ready = new Promise((resolve, reject) => {
@@ -141,7 +142,17 @@ export class QqRuntime {
141
142
  this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} connection error:`, error);
142
143
  }
143
144
  };
144
- const onMessage = (_ctx, message) => this.#bridge?.accept(message);
145
+ const onMessage = (_ctx, message) => {
146
+ const task = this.#bridge?.accept(message);
147
+ if (!task) return;
148
+ void task.catch((error) => {
149
+ if (controller.signal.aborted) return;
150
+ this.#logger.error?.(
151
+ `[dsh-im:qq] bot ${this.#config.botId} message handling failed:`,
152
+ error,
153
+ );
154
+ });
155
+ };
145
156
  bot.on('ready', onReady);
146
157
  bot.on('resumed', onReady);
147
158
  bot.on('error', onError);