@jerryliang122/openclaw-qqbot 1.0.5 → 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.
package/dist/index.d.cts CHANGED
@@ -960,6 +960,11 @@ declare function resolveQQBotEnvClientSecret(): string;
960
960
  declare function listQQBotAccountIds(cfg: OpenClawConfig): string[];
961
961
  /**
962
962
  * 获取默认账户 ID
963
+ *
964
+ * 优先返回「可运行」的账号:
965
+ * 1. 顶层 default 账号可运行(appId + 凭证来源 + enabled)→ default
966
+ * 2. 否则第一个可运行的命名账号(accounts.<id>)
967
+ * 3. 都不可运行 → 保持旧行为返回 default(错误信息指向配置根因)
963
968
  */
964
969
  declare function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string;
965
970
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jerryliang122/openclaw-qqbot",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "QQ Bot channel plugin for OpenClaw (independently maintained fork)",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ResolvedQQBotAccount, QQBotAccountConfig, ToolPolicy, GroupConfig, GroupCoalesceConfig } from "./types.js";
2
2
  import type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk";
3
+ import { loadCredentialBackup } from "./features/credential-backup.js";
3
4
 
4
5
  // ============ Agent-aware mentionPatterns 解析 ============
5
6
 
@@ -248,22 +249,81 @@ export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
248
249
  return Array.from(ids);
249
250
  }
250
251
 
252
+ /**
253
+ * 判断一个账号形状是否「可运行」:配置内声明了 appId、未被禁用,且任一
254
+ * 凭证来源可用(配置内 secret / secretFile / 环境变量 / 凭证暂存备份)。
255
+ *
256
+ * appId 必须来自配置——listQQBotAccountIds 只枚举配置里有 appId 的账号,
257
+ * 框架据此决定启动哪些账号;备份只能补 secret(appId 在、secret 丢的
258
+ * 恢复场景,lifecycle 启动时会用备份恢复),不能让一个未被枚举、根本
259
+ * 不会被启动的账号被选为默认(Sourcery 复审:选中≠可运行)。
260
+ *
261
+ * 用于默认账号解析——只看 appId 是否存在会把已登出(secret 被删、appId 残留)
262
+ * 或停用的顶层账号当成默认账号,导致 message 工具等框架侧主动发送解析到
263
+ * 一个永远不会启动的账号(issue #15:Bot "default" not running)。
264
+ */
265
+ function accountShapeRunnable(params: {
266
+ appId: string;
267
+ enabled: boolean;
268
+ hasSecretSource: boolean;
269
+ accountId: string;
270
+ }): boolean {
271
+ if (!params.appId || !params.enabled) return false;
272
+ if (params.hasSecretSource) return true;
273
+ // 配置缺 secret 但备份可恢复(appId 已在配置内,账号会被枚举启动)
274
+ try {
275
+ return loadCredentialBackup(params.accountId) !== null;
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+
251
281
  /**
252
282
  * 获取默认账户 ID
283
+ *
284
+ * 优先返回「可运行」的账号:
285
+ * 1. 顶层 default 账号可运行(appId + 凭证来源 + enabled)→ default
286
+ * 2. 否则第一个可运行的命名账号(accounts.<id>)
287
+ * 3. 都不可运行 → 保持旧行为返回 default(错误信息指向配置根因)
253
288
  */
254
289
  export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
255
290
  const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
256
- // 如果有默认账户配置,返回 default
291
+
292
+ // 1. 如果默认账户可运行,返回 default
257
293
  if (qqbot?.appId || resolveQQBotEnvAppId()) {
258
- return DEFAULT_ACCOUNT_ID;
294
+ const runnable = accountShapeRunnable({
295
+ appId: normalizeAppId(qqbot?.appId) || normalizeAppId(resolveQQBotEnvAppId()),
296
+ enabled: qqbot?.enabled !== false,
297
+ hasSecretSource: Boolean(
298
+ qqbot?.clientSecret
299
+ || qqbot?.clientSecretFile
300
+ || resolveQQBotEnvClientSecret(),
301
+ ),
302
+ accountId: DEFAULT_ACCOUNT_ID,
303
+ });
304
+ if (runnable) return DEFAULT_ACCOUNT_ID;
259
305
  }
260
- // 否则返回第一个配置的账户
306
+
307
+ // 2. 否则返回第一个可运行的命名账户
261
308
  if (qqbot?.accounts) {
309
+ for (const [accountId, account] of Object.entries(qqbot.accounts)) {
310
+ if (!account) continue;
311
+ const runnable = accountShapeRunnable({
312
+ appId: normalizeAppId(account.appId),
313
+ enabled: account.enabled !== false,
314
+ hasSecretSource: Boolean(account.clientSecret || account.clientSecretFile),
315
+ accountId,
316
+ });
317
+ if (runnable) return accountId;
318
+ }
319
+ // 兼容旧形态:命名账号均无 appId(凭 backup 恢复)时保持原顺序返回第一个
262
320
  const ids = Object.keys(qqbot.accounts);
263
- if (ids.length > 0) {
321
+ if (ids.length > 0 && ids.every((id) => !qqbot.accounts?.[id]?.appId)) {
264
322
  return ids[0];
265
323
  }
266
324
  }
325
+
326
+ // 3. 旧回退
267
327
  return DEFAULT_ACCOUNT_ID;
268
328
  }
269
329
 
@@ -89,7 +89,26 @@ export function buildCtxPayload(params: CtxPayloadParams): any {
89
89
  },
90
90
  reply: {
91
91
  to: envelope.targetId,
92
- replyToId: envelope.messageId,
92
+ /**
93
+ * ReplyToId 契约语义 = 当前消息回复(引用)的那条消息的 ID —— 对齐
94
+ * telegram 原生通道(replyHead?.messageId ?? visibleReplyTarget?.id),
95
+ * 无引用时必须为 undefined。框架将其渲染为模型可见 Conversation info
96
+ * 的 reply_to_id 字段。
97
+ *
98
+ * 历史版本误填 envelope.messageId(消息自身 ID),导致每条消息都呈现
99
+ * message_id == reply_to_id —— 被上层 agent 的 AGENTS 规则判为「内部
100
+ * 回灌、非新指令」而拒绝响应(2026-09-22 事故)。
101
+ *
102
+ * QQ 被动回复锚点不依赖此字段:deliverCtx.replyToId / msgid-cache 兜底
103
+ * (resolvePassiveFirstReplyToId)/ attachMsgIdWithQuota 均独立取值,
104
+ * threading resolveReplyToMode='off' 阻断框架隐式回复线程。
105
+ *
106
+ * 防御守卫:即使上游异常传入当前消息 ID(自引用),也强制置空——
107
+ * 「reply_to_id == message_id」这一假回灌签名在任何路径都不允许再现。
108
+ */
109
+ replyToId: envelope.quote?.messageId && envelope.quote.messageId !== envelope.messageId
110
+ ? envelope.quote.messageId
111
+ : undefined,
93
112
  originatingTo: envelope.targetId,
94
113
  },
95
114
  access: {
@@ -100,8 +119,17 @@ export function buildCtxPayload(params: CtxPayloadParams): any {
100
119
  : undefined,
101
120
  media: mediaFacts.length > 0 ? mediaFacts : undefined,
102
121
  supplemental: {
122
+ // quote.id 同样必须是「被引用消息」的真实 ID(ref-index 命中时),
123
+ // 而非当前消息 ID——框架经 applySupplementalContext 映射为
124
+ // ctx.ReplyToId / "Reply target of current user message"。
103
125
  quote: envelope.quote
104
- ? { id: envelope.messageId, body: envelope.quote.content, sender: envelope.quote.senderId }
126
+ ? {
127
+ ...(envelope.quote.messageId && envelope.quote.messageId !== envelope.messageId
128
+ ? { id: envelope.quote.messageId }
129
+ : {}),
130
+ body: envelope.quote.content,
131
+ sender: envelope.quote.senderId,
132
+ }
105
133
  : undefined,
106
134
  groupSystemPrompt: envelope.systemPrompt,
107
135
  },
@@ -20,7 +20,7 @@ export interface OpenClawInboundMessage {
20
20
  messageId: string;
21
21
  content: string;
22
22
  history?: Array<{ role: string; content: string; senderId?: string; senderName?: string }>;
23
- quote?: { content: string; senderId: string; attachments?: unknown[] };
23
+ quote?: { content: string; senderId: string; messageId?: string; attachments?: unknown[] };
24
24
  attachments?: unknown[];
25
25
  /** 入站图片 URL 列表(从附件中提取) */
26
26
  imageUrls?: string[];
@@ -44,7 +44,11 @@ function mapHistory(
44
44
  }
45
45
 
46
46
  /**
47
- * 将 ResolvedQuote 转换为 OpenClaw quote 格式
47
+ * 将 ResolvedQuote 转换为 OpenClaw quote 格式。
48
+ *
49
+ * messageId 为被引用消息的真实平台消息 ID:仅 ref-index store 命中
50
+ * (quote.source === 'store',entry 存在)时可取;msg_elements 兜底
51
+ * 解析拿不到 id,保持 undefined —— 此时框架不渲染 reply_to_id。
48
52
  */
49
53
  function mapQuote(
50
54
  quote: ResolvedQuote | undefined,
@@ -53,6 +57,7 @@ function mapQuote(
53
57
  return {
54
58
  content: quote.text,
55
59
  senderId: quote.entry?.senderId ?? '',
60
+ messageId: quote.entry?.messageId,
56
61
  attachments: quote.attachments as unknown[],
57
62
  };
58
63
  }
@@ -184,7 +184,9 @@ export async function stopAccountGracefully(params: {
184
184
  }
185
185
  }
186
186
 
187
- unregisterGateway(accountId);
187
+ // 3. 注销 gateway(带所有权守卫:只删本实例注册的同一个 gw,
188
+ // 防止延迟 stop 误删新模块实例已注册的替代网关)
189
+ unregisterGateway(accountId, gw);
188
190
  }
189
191
 
190
192
  /**
@@ -20,7 +20,7 @@ import { tryGetQQBotRuntime } from '../runtime.js';
20
20
  import { getAdapters } from '../adapter/resolve.js';
21
21
  import type { PluginLogger } from '../utils/plugin-logger.js';
22
22
  import { validateRemoteUrl } from '../utils/ssrf-guard.js';
23
- import { getGateway } from './outbound-service.js';
23
+ import { resolveGatewayForSend, notRunningError } from './outbound-service.js';
24
24
  import { parseTarget } from './target.js';
25
25
  import { checkAndConsumePassiveReplyQuota } from '../features/quota-manager.js';
26
26
  import {
@@ -158,18 +158,21 @@ export async function sendMedia(params: SendMediaParams): Promise<SendMediaResul
158
158
  ?? (params.mimeType ? inferMediaKindFromMime(params.mimeType) : undefined)
159
159
  ?? inferMediaKind(resolved.path);
160
160
 
161
- // 3. 获取 gateway
162
- const gw = getGateway(accountId);
163
- if (!gw) {
164
- return { error: `Bot "${accountId}" not running` };
161
+ // 3. 获取 gateway(带单账号回退:issue #15——message 工具主动发媒体时
162
+ // 请求键可能解析到未运行的 "default",单账号部署回退到唯一运行中的网关)。
163
+ // 配额记账用 resolved.accountId(实际发送账号),见 resolveGatewayForSend 注释。
164
+ const sendGateway = resolveGatewayForSend(accountId);
165
+ if (!sendGateway) {
166
+ return { error: notRunningError(accountId) };
165
167
  }
168
+ const gw = sendGateway.gw;
166
169
 
167
170
  const target = parseTarget(params.to);
168
171
 
169
172
  const reservation = params.quotaReserved || !params.replyToId
170
173
  ? { canReply: Boolean(params.replyToId), rollback: () => {} }
171
174
  : checkAndConsumePassiveReplyQuota({
172
- accountId,
175
+ accountId: sendGateway.accountId,
173
176
  msgId: params.replyToId,
174
177
  scope: target.scope,
175
178
  log,
@@ -8,6 +8,7 @@ import * as path from 'node:path';
8
8
  import { MediaFileType } from '@tencent-connect/qqbot-nodejs';
9
9
  import type { QQBotGateway } from '../gateway/index.js';
10
10
  import type { ResolvedQQBotAccount } from '../types.js';
11
+ import { createPluginLogger } from '../utils/plugin-logger.js';
11
12
  import { parseTarget } from './target.js';
12
13
  import {
13
14
  checkAndConsumePassiveReplyQuota,
@@ -17,7 +18,41 @@ import {
17
18
 
18
19
  // ── Gateway 注册表(生命周期由 channel.ts 管理)──
19
20
 
20
- const gateways = new Map<string, QQBotGateway>();
21
+ /**
22
+ * 进程级注册表桥(issue #15):
23
+ *
24
+ * openclaw 的 message 工具在部分执行上下文(后台任务 / bootstrap 兜底注册表)
25
+ * 会经 jiti/二次加载产生插件的**另一个模块实例**——模块级 Map 在两个实例里
26
+ * 各持一份,第二实例的注册表为空,getGateway 恒 miss,主动发送全部报
27
+ * `Bot "<accountId>" not running`(账号 id 本身解析是对的)。gateway 是
28
+ * 进程级资源(WebSocket 连接),注册表挂到 globalThis(Symbol.for 跨副本
29
+ * 共享)让所有模块实例看到同一份。
30
+ */
31
+ const GATEWAYS_REGISTRY_KEY = Symbol.for('openclaw-qqbot.gateways');
32
+
33
+ const globalRegistry = globalThis as unknown as Record<symbol, unknown>;
34
+ const gateways: Map<string, QQBotGateway> =
35
+ (globalRegistry[GATEWAYS_REGISTRY_KEY] as Map<string, QQBotGateway> | undefined) ?? new Map();
36
+ globalRegistry[GATEWAYS_REGISTRY_KEY] = gateways;
37
+
38
+ // 契约入口(框架直发 / message 工具 / cron)无调用方 logger 可传,
39
+ // 回退日志用模块级 logger 兜底(与 outbound-adapter.ts 的 alog 同模式)。
40
+ // 注意:本模块处于 bot-instance → outbound-service → plugin-logger → runtime
41
+ // → bot-instance 的循环依赖环上,logger 必须惰性创建(顶层求值会在模块
42
+ // 环半初始化状态下崩溃),且日志失败绝不影响发送。
43
+ let svcLog: ReturnType<typeof createPluginLogger> | undefined;
44
+
45
+ function logFallbackInfo(msg: string): void {
46
+ try {
47
+ svcLog ??= createPluginLogger({ prefix: '[outbound]' });
48
+ svcLog.info(msg);
49
+ } catch {
50
+ /* logging must never break sends */
51
+ }
52
+ }
53
+
54
+ /** 单账号回退的 INFO 日志去重:每个失效账号键只打一次,避免刷屏 */
55
+ const gatewayFallbackLogged = new Set<string>();
21
56
 
22
57
  /**
23
58
  * 为一次实际 API 调用预留被动回复配额。
@@ -66,7 +101,16 @@ export function registerGateway(accountId: string, gw: QQBotGateway): void {
66
101
  gateways.set(accountId, gw);
67
102
  }
68
103
 
69
- export function unregisterGateway(accountId: string): void {
104
+ export function unregisterGateway(accountId: string, expectGw?: QQBotGateway): void {
105
+ if (expectGw) {
106
+ // 所有权守卫(Sourcery 复审):仅当注册表里持有的是**本实例注册的同一个
107
+ // gateway 对象**才注销——旧模块实例延迟执行的 stop 不得删掉新实例已经
108
+ // 注册的替代网关(共享注册表后,两个实例看到同一份 Map)。
109
+ if (gateways.get(accountId) !== expectGw) return;
110
+ gateways.delete(accountId);
111
+ clearQuotaCacheForAccount(accountId);
112
+ return;
113
+ }
70
114
  gateways.delete(accountId);
71
115
  clearQuotaCacheForAccount(accountId);
72
116
  }
@@ -75,6 +119,57 @@ export function getGateway(accountId: string): QQBotGateway | undefined {
75
119
  return gateways.get(accountId);
76
120
  }
77
121
 
122
+ /** 注册表中所有运行中的账号 ID(诊断与单账号回退用) */
123
+ export function getRegisteredAccountIds(): string[] {
124
+ return [...gateways.keys()];
125
+ }
126
+
127
+ /**
128
+ * 发送路径的 gateway 解析(带单账号回退,issue #15):
129
+ * - 精确命中 → 直接返回
130
+ * - 未命中且注册表里恰好只有一个运行中的账号 → 回退到它。单账号部署下
131
+ * 请求键与注册键不一致只可能来自配置残影(如顶层 appId 残留的 "default"),
132
+ * 不存在路由歧义;回退仅打一次 INFO 便于事后取证。
133
+ * - 未命中且运行中账号为 0 或多个 → 返回 undefined 由调用方报错——
134
+ * OpenID 跨账号不通用,多账号下盲目回退会把消息发给错误的 bot。
135
+ *
136
+ * 返回 `accountId` 为**实际发送账号**(回退时 ≠ 请求键):被动回复配额是
137
+ * 平台按「真实发送账号 × msg_id」计的,配额预留/回滚必须用 resolved.accountId
138
+ * 记账,否则会记到从未发送的账号下(绕过或误伤限额)。
139
+ */
140
+ export interface ResolvedSendGateway {
141
+ gw: QQBotGateway;
142
+ /** 实际发送账号 ID(配额记账键) */
143
+ accountId: string;
144
+ /** 回退来源账号键(仅诊断;精确命中时无此字段) */
145
+ fallbackFrom?: string;
146
+ }
147
+
148
+ export function resolveGatewayForSend(accountId: string): ResolvedSendGateway | undefined {
149
+ const gw = gateways.get(accountId);
150
+ if (gw) return { gw, accountId };
151
+ const running = getRegisteredAccountIds();
152
+ if (running.length === 1) {
153
+ const soleAccountId = running[0];
154
+ const soleGw = gateways.get(soleAccountId);
155
+ if (!soleGw) return undefined;
156
+ if (!gatewayFallbackLogged.has(accountId)) {
157
+ gatewayFallbackLogged.add(accountId);
158
+ logFallbackInfo(
159
+ `account "${accountId}" not running; falling back to sole running account "${soleAccountId}"`,
160
+ );
161
+ }
162
+ return { gw: soleGw, accountId: soleAccountId, fallbackFrom: accountId };
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ /** "not running" 错误(多账号时附运行中账号列表,便于排障) */
168
+ export function notRunningError(accountId: string): string {
169
+ const running = getRegisteredAccountIds();
170
+ return `Bot "${accountId}" not running${running.length ? ` (running accounts: ${running.join(', ')})` : ''}`;
171
+ }
172
+
78
173
  // ── 媒体类型映射 ──
79
174
 
80
175
  export type MediaKind = 'image' | 'voice' | 'video' | 'file';
@@ -104,17 +199,17 @@ export async function sendText(params: {
104
199
  quotaReserved?: boolean;
105
200
  }): Promise<SendResult> {
106
201
  const accountId = params.account.accountId;
107
- const gw = gateways.get(accountId);
108
- if (!gw) return { error: `Bot "${accountId}" not running` };
202
+ const resolved = resolveGatewayForSend(accountId);
203
+ if (!resolved) return { error: notRunningError(accountId) };
109
204
  const target = parseTarget(params.to);
110
205
  const reservation = reservePassiveReply({
111
206
  replyToId: params.replyToId,
112
- accountId,
207
+ accountId: resolved.accountId,
113
208
  scope: target.scope,
114
209
  quotaReserved: params.quotaReserved,
115
210
  });
116
211
  try {
117
- const result = await gw.sendText(target, params.text, { msgId: reservation.msgId });
212
+ const result = await resolved.gw.sendText(target, params.text, { msgId: reservation.msgId });
118
213
  return { messageId: result.id };
119
214
  } catch (err: unknown) {
120
215
  reservation.rollback();
@@ -133,12 +228,12 @@ export async function sendMedia(params: {
133
228
  quotaReserved?: boolean;
134
229
  }): Promise<SendResult> {
135
230
  const accountId = params.account.accountId;
136
- const gw = gateways.get(accountId);
137
- if (!gw) return { error: `Bot "${accountId}" not running` };
231
+ const resolved = resolveGatewayForSend(accountId);
232
+ if (!resolved) return { error: notRunningError(accountId) };
138
233
  const target = parseTarget(params.to);
139
234
  const reservation = reservePassiveReply({
140
235
  replyToId: params.replyToId,
141
- accountId,
236
+ accountId: resolved.accountId,
142
237
  scope: target.scope,
143
238
  quotaReserved: params.quotaReserved,
144
239
  });
@@ -147,19 +242,19 @@ export async function sendMedia(params: {
147
242
  const msgId = reservation.msgId;
148
243
  if (kind === 'voice') {
149
244
  const source = resolveVoiceSource(params.mediaUrl);
150
- const result = await gw.sendVoice(target, source, { text: params.text, msgId });
245
+ const result = await resolved.gw.sendVoice(target, source, { text: params.text, msgId });
151
246
  return { messageId: result.id };
152
247
  }
153
248
  if (kind === 'video') {
154
- const result = await gw.sendVideo(target, params.mediaUrl, { text: params.text, msgId });
249
+ const result = await resolved.gw.sendVideo(target, params.mediaUrl, { text: params.text, msgId });
155
250
  return { messageId: result.id };
156
251
  }
157
252
  if (kind === 'file') {
158
- const result = await gw.sendFile(target, params.mediaUrl, { text: params.text, msgId });
253
+ const result = await resolved.gw.sendFile(target, params.mediaUrl, { text: params.text, msgId });
159
254
  return { messageId: result.id };
160
255
  }
161
256
  const fileType = MEDIA_KIND_TO_FILE_TYPE[kind];
162
- const result = await gw.sendMedia(target, params.mediaUrl, { text: params.text, msgId, fileType });
257
+ const result = await resolved.gw.sendMedia(target, params.mediaUrl, { text: params.text, msgId, fileType });
163
258
  return { messageId: result.id };
164
259
  } catch (err: unknown) {
165
260
  reservation.rollback();
@@ -176,17 +271,17 @@ export async function sendVoice(params: {
176
271
  quotaReserved?: boolean;
177
272
  }): Promise<SendResult> {
178
273
  const accountId = params.account.accountId;
179
- const gw = gateways.get(accountId);
180
- if (!gw) return { error: `Bot "${accountId}" not running` };
274
+ const resolved = resolveGatewayForSend(accountId);
275
+ if (!resolved) return { error: notRunningError(accountId) };
181
276
  const target = parseTarget(params.to);
182
277
  const reservation = reservePassiveReply({
183
278
  replyToId: params.replyToId,
184
- accountId,
279
+ accountId: resolved.accountId,
185
280
  scope: target.scope,
186
281
  quotaReserved: params.quotaReserved,
187
282
  });
188
283
  try {
189
- const result = await gw.sendVoice(target, params.source, { msgId: reservation.msgId });
284
+ const result = await resolved.gw.sendVoice(target, params.source, { msgId: reservation.msgId });
190
285
  return { messageId: result.id };
191
286
  } catch (err: unknown) {
192
287
  reservation.rollback();
@@ -203,17 +298,17 @@ export async function sendVideo(params: {
203
298
  quotaReserved?: boolean;
204
299
  }): Promise<SendResult> {
205
300
  const accountId = params.account.accountId;
206
- const gw = gateways.get(accountId);
207
- if (!gw) return { error: `Bot "${accountId}" not running` };
301
+ const resolved = resolveGatewayForSend(accountId);
302
+ if (!resolved) return { error: notRunningError(accountId) };
208
303
  const target = parseTarget(params.to);
209
304
  const reservation = reservePassiveReply({
210
305
  replyToId: params.replyToId,
211
- accountId,
306
+ accountId: resolved.accountId,
212
307
  scope: target.scope,
213
308
  quotaReserved: params.quotaReserved,
214
309
  });
215
310
  try {
216
- const result = await gw.sendVideo(target, params.videoUrl, { msgId: reservation.msgId });
311
+ const result = await resolved.gw.sendVideo(target, params.videoUrl, { msgId: reservation.msgId });
217
312
  return { messageId: result.id };
218
313
  } catch (err: unknown) {
219
314
  reservation.rollback();