@xmanrui/dsh-im 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.en.md +18 -0
  2. package/README.md +18 -0
  3. package/assets/logo-dsh-im-chinese-readme-3x2.png +0 -0
  4. package/assets/logo_cn.png +0 -0
  5. package/lib/client.js +603 -560
  6. package/lib/index.js +163 -163
  7. package/package.json +1 -1
  8. package/plugin-src/client/agent-preset.js +15 -6
  9. package/plugin-src/client/channel-card-meta.js +48 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +25 -19
  11. package/plugin-src/client/channels/dingtalk/styles.js +0 -6
  12. package/plugin-src/client/channels/feishu/index.js +41 -35
  13. package/plugin-src/client/channels/feishu/styles.js +0 -5
  14. package/plugin-src/client/channels/qq/index.js +24 -16
  15. package/plugin-src/client/channels/shared/token-channel.js +32 -24
  16. package/plugin-src/client/channels/wecom/index.js +24 -16
  17. package/plugin-src/client/channels/weixin/index.js +29 -23
  18. package/plugin-src/client/channels/weixin/styles.js +0 -5
  19. package/plugin-src/client/channels/whatsapp/index.js +27 -23
  20. package/plugin-src/client/i18n.js +2 -0
  21. package/plugin-src/client/styles.js +23 -8
  22. package/plugin-src/host/index.mjs +14 -1
  23. package/src/channels/dingtalk/dingtalk-api.mjs +215 -2
  24. package/src/channels/dingtalk/dingtalk-bridge.mjs +155 -4
  25. package/src/channels/discord/discord-api.mjs +134 -6
  26. package/src/channels/discord/discord-runtime.mjs +15 -4
  27. package/src/channels/feishu/bridge.mjs +223 -15
  28. package/src/channels/feishu/feishu-channel.mjs +227 -1
  29. package/src/channels/feishu/plugin-controller.mjs +1 -0
  30. package/src/channels/qq/qq-bridge.mjs +217 -10
  31. package/src/channels/shared/editable-message-stream.mjs +18 -1
  32. package/src/channels/shared/harness-client.mjs +99 -7
  33. package/src/channels/shared/semantic/artifact.mjs +748 -0
  34. package/src/channels/shared/semantic/delivery.mjs +153 -0
  35. package/src/channels/shared/text-harness-bridge.mjs +149 -3
  36. package/src/channels/shared/workspace-session.mjs +15 -1
  37. package/src/channels/slack/manifest.mjs +1 -0
  38. package/src/channels/slack/slack-api.mjs +167 -4
  39. package/src/channels/slack/slack-runtime.mjs +21 -5
  40. package/src/channels/telegram/telegram-api.mjs +111 -5
  41. package/src/channels/telegram/telegram-runtime.mjs +18 -4
  42. package/src/channels/wecom/wecom-bridge.mjs +260 -12
  43. package/src/channels/weixin/weixin-api.mjs +268 -2
  44. package/src/channels/weixin/weixin-bridge.mjs +134 -3
  45. package/src/channels/weixin/weixin-controller.mjs +5 -1
  46. package/src/channels/weixin/weixin-runtime.mjs +5 -1
  47. package/src/channels/whatsapp/whatsapp-runtime.mjs +108 -5
@@ -30,6 +30,16 @@ import {
30
30
  promptContentForMessage,
31
31
  } from '../shared/image-prompt.mjs';
32
32
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
33
+ import {
34
+ materializeOutboundArtifact,
35
+ releaseOutboundArtifact,
36
+ } from '../shared/semantic/artifact.mjs';
37
+ import {
38
+ createArtifactFailureReceipt,
39
+ createDeliveryReceipt,
40
+ mergeDeliveryReceipts,
41
+ providerMessageIdsFor,
42
+ } from '../shared/semantic/delivery.mjs';
33
43
 
34
44
  const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
35
45
  const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
@@ -62,6 +72,13 @@ function nonEmptyString(value) {
62
72
  return typeof value === 'string' && value.trim() ? value.trim() : null;
63
73
  }
64
74
 
75
+ function dingtalkFileProviderIds(result) {
76
+ const ids = providerMessageIdsFor(result);
77
+ const processQueryKey = nonEmptyString(result?.processQueryKey);
78
+ if (processQueryKey && !ids.includes(processQueryKey)) ids.push(processQueryKey);
79
+ return ids;
80
+ }
81
+
65
82
  function safeErrorDiagnostic(error) {
66
83
  const chain = [];
67
84
  const seen = new Set();
@@ -195,6 +212,40 @@ function cardTarget(message, sender) {
195
212
  return { type: 'user', userId: sender };
196
213
  }
197
214
 
215
+ function fileTarget(message, sender, clientId) {
216
+ const robotCode = nonEmptyString(message?.robotCode) ?? clientId;
217
+ if (String(message?.conversationType) === '2') {
218
+ return {
219
+ type: 'group',
220
+ openConversationId: nonEmptyString(message?.conversationId),
221
+ robotCode,
222
+ };
223
+ }
224
+ return { type: 'user', userId: sender, robotCode };
225
+ }
226
+
227
+ function artifactFailureText(fileName, error) {
228
+ const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
229
+ switch (error?.code) {
230
+ case 'artifact-delivery-uncertain':
231
+ return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
232
+ case 'artifact-permission-required':
233
+ return `结果文件「${name}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。`;
234
+ case 'artifact-too-large':
235
+ return `结果文件「${name}」超过当前钉钉机器人可发送的文件大小,未发送。`;
236
+ case 'artifact-rate-limited':
237
+ return `结果文件「${name}」暂时被钉钉限流,未能发送,请稍后重试。`;
238
+ case 'artifact-provider-rejected':
239
+ return `结果文件「${name}」已生成,但钉钉拒绝了该文件消息,请检查文件类型和机器人文件消息配置。`;
240
+ case 'artifact-invalid':
241
+ case 'artifact-changed':
242
+ case 'artifact-unavailable':
243
+ return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
244
+ default:
245
+ return `结果文件「${name}」已生成,但暂时未能通过钉钉发送,请稍后重试。`;
246
+ }
247
+ }
248
+
198
249
  function progressText(update) {
199
250
  if (update?.type === 'text' && nonEmptyString(update.text)) return update.text;
200
251
  if (update?.type === 'tool') {
@@ -596,7 +647,7 @@ export class DingtalkHarnessBridge {
596
647
  });
597
648
  cardStarted = await cardStream.start(CARD_INITIAL_TEXT);
598
649
  }
599
- const { answer } = await askInWorkspaceSession({
650
+ const { answer, artifacts = [] } = await askInWorkspaceSession({
600
651
  harness: this.#harness,
601
652
  state: this.#state,
602
653
  key,
@@ -619,11 +670,41 @@ export class DingtalkHarnessBridge {
619
670
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
620
671
  },
621
672
  });
622
- const streamed = cardStarted && await cardStream.finish(answer);
623
- if (!streamed) await this.#send(sessionWebhook, answer);
673
+ const answerText = typeof answer === 'string' && answer.trim()
674
+ ? answer
675
+ : artifacts.length > 0 ? '结果文件已生成。' : answer;
676
+ let textDeliveryError = null;
677
+ let textReceipt = null;
678
+ let streamed = false;
679
+ try {
680
+ streamed = cardStarted && await cardStream.finish(answerText);
681
+ if (streamed) {
682
+ textReceipt = createDeliveryReceipt({
683
+ deliveryId: messageId,
684
+ presentation: 'dingtalk-card',
685
+ });
686
+ } else {
687
+ textReceipt = createDeliveryReceipt({
688
+ deliveryId: messageId,
689
+ presentation: 'dingtalk-text',
690
+ providerMessageIds: await this.#send(sessionWebhook, answerText),
691
+ });
692
+ }
693
+ } catch (error) {
694
+ textDeliveryError = error;
695
+ }
696
+ const delivery = await this.#deliverArtifacts(
697
+ fileTarget(message, sender, this.#clientId),
698
+ sessionWebhook,
699
+ messageId,
700
+ artifacts,
701
+ textReceipt,
702
+ );
703
+ if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
624
704
  increment(this.#status, 'messagesReplied');
625
705
  this.#status.lastReplyAt = new Date().toISOString();
626
706
  this.#status.lastError = null;
707
+ return delivery.receipt;
627
708
  } catch (error) {
628
709
  if (error?.code === 'turn-stopped') {
629
710
  if (cardStarted) await cardStream.finish('已停止。').catch(() => undefined);
@@ -940,16 +1021,86 @@ export class DingtalkHarnessBridge {
940
1021
  }
941
1022
 
942
1023
  async #send(sessionWebhook, text) {
1024
+ const providerMessageIds = [];
943
1025
  for (const chunk of splitDingtalkText(text, this.#maxMessageChars)) {
944
1026
  this.#signal?.throwIfAborted();
945
- await this.#api.sendText({
1027
+ const result = await this.#api.sendText({
946
1028
  clientId: this.#clientId,
947
1029
  clientSecret: this.#clientSecret,
948
1030
  sessionWebhook,
949
1031
  text: chunk,
950
1032
  signal: this.#signal,
951
1033
  });
1034
+ providerMessageIds.push(...providerMessageIdsFor(result));
1035
+ }
1036
+ return providerMessageIds;
1037
+ }
1038
+
1039
+ async #deliverArtifacts(target, sessionWebhook, replyTo, artifacts, baseReceipt) {
1040
+ const receipts = baseReceipt ? [baseReceipt] : [];
1041
+ let userVisible = Boolean(baseReceipt);
1042
+ for (const artifact of artifacts) {
1043
+ this.#signal?.throwIfAborted();
1044
+ try {
1045
+ if (typeof this.#api.sendFile !== 'function') {
1046
+ const unavailable = new Error('DingTalk file delivery is unavailable');
1047
+ unavailable.code = 'artifact-provider-unavailable';
1048
+ throw unavailable;
1049
+ }
1050
+ const file = await materializeOutboundArtifact(artifact, {
1051
+ signal: this.#signal,
1052
+ });
1053
+ const result = await this.#api.sendFile({
1054
+ clientId: this.#clientId,
1055
+ clientSecret: this.#clientSecret,
1056
+ target,
1057
+ file,
1058
+ signal: this.#signal,
1059
+ });
1060
+ receipts.push(createDeliveryReceipt({
1061
+ deliveryId: file.deliveryKey,
1062
+ presentation: 'dingtalk-file',
1063
+ providerMessageIds: dingtalkFileProviderIds(result),
1064
+ artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
1065
+ }));
1066
+ userVisible = true;
1067
+ this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
1068
+ } catch (error) {
1069
+ if (this.#signal?.aborted) throw error;
1070
+ this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
1071
+ this.#logger.warn?.(
1072
+ `[dsh-dingtalk] result file delivery failed (${error?.code ?? 'unknown'})`,
1073
+ );
1074
+ let noticeSent = false;
1075
+ const providerMessageIds = await this.#send(
1076
+ sessionWebhook,
1077
+ artifactFailureText(artifact?.fileName, error),
1078
+ ).then((ids) => {
1079
+ noticeSent = true;
1080
+ return ids;
1081
+ }).catch(() => []);
1082
+ const failureReceipt = createArtifactFailureReceipt({
1083
+ artifactId: artifact?.artifactId ?? 'unknown',
1084
+ deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
1085
+ error,
1086
+ providerMessageIds,
1087
+ });
1088
+ receipts.push(failureReceipt);
1089
+ if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
1090
+ } finally {
1091
+ releaseOutboundArtifact(artifact);
1092
+ }
952
1093
  }
1094
+ const receipt = receipts.length === 0
1095
+ ? null
1096
+ : receipts.length === 1
1097
+ ? receipts[0]
1098
+ : mergeDeliveryReceipts({
1099
+ deliveryId: replyTo,
1100
+ presentation: baseReceipt ? 'dingtalk-text-and-files' : 'dingtalk-files',
1101
+ receipts,
1102
+ });
1103
+ return { receipt, userVisible };
953
1104
  }
954
1105
  }
955
1106
 
@@ -1,4 +1,9 @@
1
+ import { createHash } from 'node:crypto';
2
+
1
3
  const DEFAULT_BASE_URL = 'https://discord.com/api/v10/';
4
+ const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
5
+ const DISCORD_PERMISSION_ERRORS = new Set([50001, 50013]);
6
+ const DISCORD_TOO_LARGE_ERRORS = new Set([40005]);
2
7
 
3
8
  function cleanString(value) {
4
9
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -9,6 +14,58 @@ function requestSignal(signal, timeoutMs) {
9
14
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
10
15
  }
11
16
 
17
+ function abortReason(signal) {
18
+ return signal?.reason instanceof Error
19
+ ? signal.reason
20
+ : new DOMException('The operation was aborted', 'AbortError');
21
+ }
22
+
23
+ function positiveTimeout(value, name) {
24
+ if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
25
+ return value;
26
+ }
27
+
28
+ function preserveProviderMetadata(target, source) {
29
+ if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
30
+ if (source?.retry_after !== undefined) {
31
+ target.retry_after = source.retry_after;
32
+ target.retryAfter = source.retry_after;
33
+ }
34
+ if (Number.isInteger(source?.status)) target.status = source.status;
35
+ return target;
36
+ }
37
+
38
+ function discordArtifactProviderError(cause) {
39
+ const providerCode = Number(cause?.providerCode);
40
+ const status = Number(cause?.status);
41
+ const message = cleanString(cause?.message) ?? '';
42
+ let code = 'artifact-provider-rejected';
43
+ let summary = 'Discord rejected the attachment.';
44
+ if (status === 401 || status === 403 || DISCORD_PERMISSION_ERRORS.has(providerCode)) {
45
+ code = 'artifact-permission-required';
46
+ summary = 'Discord denied permission to send the attachment.';
47
+ } else if (status === 413 || DISCORD_TOO_LARGE_ERRORS.has(providerCode)
48
+ || /(?:request|attachment|file).{0,24}too large/i.test(message)) {
49
+ code = 'artifact-too-large';
50
+ summary = 'The attachment exceeds Discord\'s size limit.';
51
+ } else if (status === 429) {
52
+ code = 'artifact-rate-limited';
53
+ summary = 'Discord rate-limited attachment delivery.';
54
+ } else if (status >= 500) {
55
+ code = 'artifact-delivery-uncertain';
56
+ summary = 'Discord attachment delivery result is uncertain.';
57
+ }
58
+ const error = new Error(summary, { cause });
59
+ error.code = code;
60
+ return preserveProviderMetadata(error, cause);
61
+ }
62
+
63
+ function uncertainDiscordDelivery(cause) {
64
+ const error = new Error('Discord attachment delivery result is uncertain', { cause });
65
+ error.code = 'artifact-delivery-uncertain';
66
+ return preserveProviderMetadata(error, cause);
67
+ }
68
+
12
69
  function delay(ms, signal) {
13
70
  return new Promise((resolve, reject) => {
14
71
  if (signal?.aborted) {
@@ -39,13 +96,20 @@ export class DiscordApi {
39
96
  #token;
40
97
  #fetch;
41
98
  #baseUrl;
99
+ #fileUploadTimeoutMs;
42
100
 
43
- constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
101
+ constructor({
102
+ token,
103
+ fetchImpl = fetch,
104
+ baseUrl = DEFAULT_BASE_URL,
105
+ fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
106
+ }) {
44
107
  if (!validDiscordToken(token)) throw new TypeError('Discord Bot Token is invalid');
45
108
  if (typeof fetchImpl !== 'function') throw new TypeError('DiscordApi requires fetch');
46
109
  this.#token = token.trim();
47
110
  this.#fetch = fetchImpl;
48
111
  this.#baseUrl = new URL(baseUrl);
112
+ this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
49
113
  }
50
114
 
51
115
  getCurrentUser(options = {}) {
@@ -74,6 +138,54 @@ export class DiscordApi {
74
138
  });
75
139
  }
76
140
 
141
+ async createFileMessage({ channelId, file, replyToMessageId, signal }) {
142
+ if (!file || typeof file !== 'object'
143
+ || typeof file.fileName !== 'string' || !file.fileName
144
+ || !Buffer.isBuffer(file.bytes)) {
145
+ throw new TypeError('A Discord attachment is required');
146
+ }
147
+ const deliverySeed = cleanString(file.deliveryKey) ?? cleanString(file.artifactId);
148
+ const nonce = deliverySeed
149
+ ? createHash('sha256').update(deliverySeed).digest('hex').slice(0, 25)
150
+ : undefined;
151
+ const payload = new FormData();
152
+ payload.append('payload_json', JSON.stringify({
153
+ allowed_mentions: { parse: [], replied_user: false },
154
+ attachments: [{ id: 0, filename: file.fileName }],
155
+ ...(nonce ? { nonce, enforce_nonce: true } : {}),
156
+ ...(replyToMessageId ? {
157
+ message_reference: {
158
+ message_id: snowflake(replyToMessageId, 'message id'),
159
+ channel_id: snowflake(channelId, 'channel id'),
160
+ fail_if_not_exists: false,
161
+ },
162
+ } : {}),
163
+ }));
164
+ payload.append(
165
+ 'files[0]',
166
+ new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
167
+ file.fileName,
168
+ );
169
+ const targetChannelId = snowflake(channelId, 'channel id');
170
+ if (signal?.aborted) throw abortReason(signal);
171
+ const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
172
+ try {
173
+ return await this.#request(`channels/${targetChannelId}/messages`, {
174
+ method: 'POST',
175
+ signal: uploadSignal,
176
+ timeoutMs: this.#fileUploadTimeoutMs,
177
+ body: payload,
178
+ multipart: true,
179
+ });
180
+ } catch (error) {
181
+ if (signal?.aborted) throw abortReason(signal);
182
+ if (error?.code?.startsWith?.('discord-')) {
183
+ throw discordArtifactProviderError(error);
184
+ }
185
+ throw uncertainDiscordDelivery(error);
186
+ }
187
+ }
188
+
77
189
  editMessage({ channelId, messageId, content, signal }) {
78
190
  return this.#request(
79
191
  `channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
@@ -100,6 +212,7 @@ export class DiscordApi {
100
212
  timeoutMs = 15_000,
101
213
  expectBody = true,
102
214
  retry = true,
215
+ multipart = false,
103
216
  }) {
104
217
  let response;
105
218
  try {
@@ -107,10 +220,10 @@ export class DiscordApi {
107
220
  method,
108
221
  headers: {
109
222
  authorization: `Bot ${this.#token}`,
110
- 'content-type': 'application/json',
111
- 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 1.0.2)',
223
+ ...(multipart ? {} : { 'content-type': 'application/json' }),
224
+ 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 1.1.0)',
112
225
  },
113
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
226
+ ...(body === undefined ? {} : { body: multipart ? body : JSON.stringify(body) }),
114
227
  signal: requestSignal(signal, timeoutMs),
115
228
  redirect: 'error',
116
229
  });
@@ -124,17 +237,32 @@ export class DiscordApi {
124
237
  try {
125
238
  parsed = await response.json();
126
239
  } catch {
127
- if (expectBody) throw new Error(`Discord ${method} returned invalid JSON`);
240
+ if (expectBody) {
241
+ const error = new Error(`Discord ${method} returned invalid JSON`);
242
+ error.status = response?.status;
243
+ throw error;
244
+ }
128
245
  }
129
246
  }
130
247
  if (response.status === 429 && retry) {
131
248
  const retryAfterMs = Math.min(10_000, Math.max(50, Number(parsed?.retry_after) * 1_000 || 1_000));
132
249
  await delay(retryAfterMs, signal);
133
- return this.#request(path, { method, body, signal, timeoutMs, expectBody, retry: false });
250
+ return this.#request(path, {
251
+ method, body, signal, timeoutMs, expectBody, retry: false, multipart,
252
+ });
134
253
  }
135
254
  if (!response.ok) {
136
255
  const error = new Error(cleanString(parsed?.message) ?? `Discord API failed with HTTP ${response.status}`);
137
256
  error.code = `discord-${response.status}`;
257
+ error.status = response.status;
258
+ if (Number.isInteger(parsed?.code) || typeof parsed?.code === 'string') {
259
+ error.providerCode = parsed.code;
260
+ }
261
+ const retryAfter = Number(parsed?.retry_after);
262
+ if (Number.isFinite(retryAfter) && retryAfter >= 0) {
263
+ error.retry_after = retryAfter;
264
+ error.retryAfter = retryAfter;
265
+ }
138
266
  throw error;
139
267
  }
140
268
  return expectBody ? parsed : null;
@@ -112,7 +112,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
112
112
  };
113
113
  }
114
114
 
115
- class DiscordBotClient {
115
+ export class DiscordBotClient {
116
116
  #api;
117
117
  #signal;
118
118
 
@@ -123,22 +123,32 @@ class DiscordBotClient {
123
123
 
124
124
  async sendText(target, text) {
125
125
  const chunks = splitMessageText(text, 1_900);
126
- let result = null;
126
+ const providerMessageIds = [];
127
127
  for (const [index, chunk] of chunks.entries()) {
128
- result = await this.#api.createMessage({
128
+ const result = await this.#api.createMessage({
129
129
  channelId: target.channelId,
130
130
  content: chunk,
131
131
  replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
132
132
  signal: this.#signal,
133
133
  });
134
+ if (typeof result?.id === 'string' && result.id) providerMessageIds.push(result.id);
134
135
  }
135
- return result;
136
+ return { providerMessageIds };
136
137
  }
137
138
 
138
139
  sendTyping(target) {
139
140
  return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal });
140
141
  }
141
142
 
143
+ sendFile(target, file) {
144
+ return this.#api.createFileMessage({
145
+ channelId: target.channelId,
146
+ file,
147
+ replyToMessageId: target.replyToMessageId,
148
+ signal: this.#signal,
149
+ });
150
+ }
151
+
142
152
  async openStream(target) {
143
153
  const stream = createEditableMessageStream({
144
154
  limit: 1_900,
@@ -162,6 +172,7 @@ class DiscordBotClient {
162
172
  content,
163
173
  signal: this.#signal,
164
174
  }),
175
+ messageIdForResult: (message) => message?.id,
165
176
  });
166
177
  return stream.start();
167
178
  }