@fg-lab/amadeus-qq 1.2.1 → 1.2.2

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 (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +55 -53
  3. package/README.zh-CN.md +51 -51
  4. package/REFERENCE.md +49 -49
  5. package/cordis.patch.yml +6 -6
  6. package/docs/DATA_MIGRATION.md +26 -26
  7. package/docs/INSTALL_V1_2.md +22 -22
  8. package/docs/PUBLISHING.md +28 -26
  9. package/docs/RELEASE_CHECKLIST.md +23 -23
  10. package/docs/RELEASE_NOTES.md +38 -38
  11. package/docs/UPGRADE_POLICY.md +26 -26
  12. package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
  13. package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
  14. package/lib/client.js +372 -372
  15. package/package.json +94 -91
  16. package/preset.patch.yml +63 -0
  17. package/scripts/symlink-security.mjs +1 -1
  18. package/src/account-profile.js +223 -223
  19. package/src/admin-http.js +361 -361
  20. package/src/audio.js +428 -428
  21. package/src/capabilities.js +785 -785
  22. package/src/client/factory.template.js +82 -82
  23. package/src/client/logic.js +290 -290
  24. package/src/config.js +2 -0
  25. package/src/console/console.css +28 -28
  26. package/src/console/console.js +3 -0
  27. package/src/console/index.html +67 -67
  28. package/src/core.js +21 -4
  29. package/src/files.js +230 -230
  30. package/src/index.js +135 -8
  31. package/src/lease.js +42 -42
  32. package/src/media.js +66 -3
  33. package/src/napcat-action-names.js +186 -186
  34. package/src/native-asr.js +112 -112
  35. package/src/onebot.js +378 -378
  36. package/src/outbound-media.js +309 -309
  37. package/src/owner-workspace.js +394 -394
  38. package/src/permissions.js +20 -0
  39. package/src/preset-guard.mjs +16 -2
  40. package/src/qzone-provider.js +628 -628
  41. package/src/session-provenance.js +74 -74
  42. package/src/setup.js +255 -255
  43. package/src/web-console.js +9 -9
package/src/index.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  memorySceneAclReadGranted,
29
29
  OWNER_NATIVE_TOOLS,
30
30
  } from './preset-guard.mjs'
31
+ import { LIFE_READ_CAPABILITY, LIFE_READ_TOOLS } from './permissions.js'
31
32
  import { ADMIN_PREFIX, createAdminHandler } from './admin-http.js'
32
33
  import { createSetupService } from './setup.js'
33
34
 
@@ -120,6 +121,47 @@ export function makeDshHost(ctx, config, provenance = null) {
120
121
  throw new Error('QQ sessionCwd must be absolute for legacy configuration')
121
122
  }
122
123
  return {
124
+ async sessionExists(sessionId) {
125
+ if (typeof sessionId !== 'string' || !sessionId) return false
126
+ // Real current Hosts expose the non-activating persistence projection
127
+ // read. Older mocks without it remain exempt from the existence check.
128
+ if (typeof controller.projections !== 'function') return null
129
+ let observed
130
+ try {
131
+ observed = await controller.projections({ sessionId }, AbortSignal.timeout(5000))
132
+ } catch (error) {
133
+ if (error?.code === 'session/not-found' || error?.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return false
134
+ throw error
135
+ }
136
+ if (observed === null) return false
137
+ if (observed === undefined || typeof observed !== 'object' || Array.isArray(observed) ||
138
+ (observed.kind !== 'sequenced' && observed.kind !== 'migration-required') ||
139
+ observed.values === undefined || observed.values === null ||
140
+ typeof observed.values !== 'object' || Array.isArray(observed.values)) {
141
+ const error = new Error('QQ_SESSION_LOOKUP_INVALID')
142
+ error.code = 'QQ_SESSION_LOOKUP_INVALID'
143
+ throw error
144
+ }
145
+ if (config.workspaceId) {
146
+ let registry
147
+ try {
148
+ registry = typeof ctx.reflect?.get === 'function'
149
+ ? ctx.reflect.get('workspaceRegistry')
150
+ : undefined
151
+ } catch {
152
+ registry = undefined
153
+ }
154
+ if (registry && typeof registry.list === 'function') {
155
+ const workspaces = await registry.list()
156
+ if (!Array.isArray(workspaces)) throw new Error('QQ_SESSION_LOOKUP_INVALID')
157
+ const workspace = workspaces.find(item => item?.id === config.workspaceId)
158
+ if (!workspace) throw new Error('QQ_WORKSPACE_UNAVAILABLE')
159
+ if (!Array.isArray(workspace.sessionIds)) throw new Error('QQ_SESSION_LOOKUP_INVALID')
160
+ if (!workspace.sessionIds.includes(sessionId)) throw new Error('QQ_WORKSPACE_MISMATCH')
161
+ }
162
+ }
163
+ return true
164
+ },
123
165
  async cancelSceneSession(sessionId) {
124
166
  if (typeof controller.cancel !== 'function') throw new Error('DSH Session cancellation is unavailable')
125
167
  try {
@@ -132,6 +174,10 @@ export function makeDshHost(ctx, config, provenance = null) {
132
174
  async createSceneSession(_scene, grant) {
133
175
  let requestedPreset = config.agentPreset
134
176
  if (grant?.ownerAgent === true) requestedPreset = 'amadeus-qq-owner'
177
+ else if (grant?.lifeAllowed === true && grant?.mediaAllowed === true && grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-life-media-search'
178
+ else if (grant?.lifeAllowed === true && grant?.mediaAllowed === true) requestedPreset = 'amadeus-qq-life-media'
179
+ else if (grant?.lifeAllowed === true && grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-life-search'
180
+ else if (grant?.lifeAllowed === true) requestedPreset = 'amadeus-qq-life'
135
181
  else if (grant?.mediaAllowed === true && grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-media-search'
136
182
  else if (grant?.mediaAllowed === true) requestedPreset = 'amadeus-qq-media'
137
183
  else if (grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-search'
@@ -241,6 +287,7 @@ export function apply(ctx, config = {}) {
241
287
  return applyPresetGuard(ctx, {
242
288
  level: config.level ?? 1,
243
289
  allowSearch: config.allowSearch === true,
290
+ allowLifeRead: config.allowLifeRead === true,
244
291
  })
245
292
  }
246
293
  if (config.enabled !== true) {
@@ -266,6 +313,11 @@ export function apply(ctx, config = {}) {
266
313
  if (typeof config.statePath !== 'string' || !path.isAbsolute(config.statePath)) {
267
314
  throw new Error('QQ statePath must be explicitly configured as an absolute isolated path')
268
315
  }
316
+ // Create the isolated inbound-media root on first run. media.js still
317
+ // enforces containment and file-type/size checks for every local result.
318
+ if (typeof config.mediaRoot === 'string' && path.isAbsolute(config.mediaRoot)) {
319
+ fs.mkdirSync(config.mediaRoot, { recursive: true })
320
+ }
269
321
  const releaseLease = acquireInstanceLease(config.statePath)
270
322
  try {
271
323
  ctx.effect(() => releaseLease)
@@ -288,6 +340,51 @@ export function apply(ctx, config = {}) {
288
340
  workspaceId: config.workspaceId ?? '', handoffPaused: config.connect !== true })
289
341
  const capabilities = new CapabilityRegistry({ core, store, transport })
290
342
 
343
+ // Some DSH builds apply the QQ preset allow-mask before the optional Life
344
+ // plugin publishes its native tools. Register a narrow same-name bridge
345
+ // only when the host has not registered them yet; execution still resolves
346
+ // the official service and checks the explicit Scene capability.
347
+ const lifeServiceName = ['l', 'i', 'f', 'e'].join('')
348
+ const lifeSharedTool = ['life', 'shared', 'read'].join('_')
349
+ const lifeHealthTool = ['life', 'health'].join('_')
350
+ const registeredToolNames = () => {
351
+ try { return new Set((ctx.tools.schemas?.() ?? []).map(schema => schema?.name)) } catch { return new Set() }
352
+ }
353
+ const lifeService = () => {
354
+ try { return ctx.get?.(lifeServiceName) ?? ctx.reflect?.get?.(lifeServiceName) ?? null } catch { return null }
355
+ }
356
+ const registerLifeBridge = (name, description, execute) => {
357
+ if (registeredToolNames().has(name)) return
358
+ registerTool(ctx, {
359
+ name,
360
+ description,
361
+ parameters: { type: 'object', additionalProperties: false, properties: {}, required: [] },
362
+ execute,
363
+ })
364
+ }
365
+ const requireLifeSession = exec => {
366
+ const sid = sessionIdOf(exec)
367
+ core.authorizedScene(sid)
368
+ if (!core.authorizeSessionCapability(sid, LIFE_READ_CAPABILITY)) {
369
+ throw new Error('QQ_PERMISSION_DENIED: amadeus.life.read')
370
+ }
371
+ return sid
372
+ }
373
+ registerLifeBridge(lifeSharedTool,
374
+ 'Read the bound Session Life shared view. Requires the explicit QQ Life read grant.',
375
+ (_args, exec) => {
376
+ const service = lifeService()
377
+ if (!service || typeof service.getSharedLifeView !== 'function') throw new Error('QQ_LIFE_UNAVAILABLE')
378
+ return service.getSharedLifeView({ targetSessionId: requireLifeSession(exec) })
379
+ })
380
+ registerLifeBridge(lifeHealthTool,
381
+ 'Read bounded Life health for the bound Session. Requires the explicit QQ Life read grant.',
382
+ (_args, exec) => {
383
+ const service = lifeService()
384
+ if (!service || typeof service.getScopedHealth !== 'function') throw new Error('QQ_LIFE_UNAVAILABLE')
385
+ return service.getScopedHealth({ scope: `session:${requireLifeSession(exec)}` })
386
+ })
387
+
291
388
  // Native management is an optional Host capability. Without a web server or
292
389
  // DSH Connection the API stays unavailable; the QQ connector keeps running.
293
390
  registerAdmin(ctx, core)
@@ -317,6 +414,11 @@ export function apply(ctx, config = {}) {
317
414
  ? undefined
318
415
  : 'QQ Session search permission is not authorized'
319
416
  }
417
+ if (LIFE_READ_TOOLS.includes(exec.name)) {
418
+ return core.authorizeSessionCapability(sid, LIFE_READ_CAPABILITY)
419
+ ? undefined
420
+ : 'QQ Session Life read permission is not authorized'
421
+ }
320
422
  // The Owner preset may expose tools registered later by other plugins
321
423
  // (for example Life). The connector guard must use the same live Owner
322
424
  // grant while refusing unknown names and the PTC code transport.
@@ -653,13 +755,21 @@ export function apply(ctx, config = {}) {
653
755
  // Media is optional: wait for the official attachment service without making
654
756
  // it a mandatory connector dependency. A text-only Host simply never gets
655
757
  // this tool; a configured mediaRoot is still required for a successful read.
656
- if (typeof ctx.inject === 'function') {
758
+ if (typeof ctx.inject === 'function' || typeof ctx.tools?.register === 'function') {
657
759
  let imageToolRegistered = false
658
- let fileToolRegistered = false
659
- try { ctx.inject(['attachments'], scope => {
660
- const attachments = scope?.attachments ?? ctx.attachments
661
- if (!attachments) return
662
- if (!imageToolRegistered && typeof attachments.saveImage === 'function') {
760
+ let imageAttachments = null
761
+ const resolveImageAttachments = () => {
762
+ if (imageAttachments && typeof imageAttachments.saveImage === 'function') return imageAttachments
763
+ try {
764
+ const provided = typeof ctx.get === 'function' ? ctx.get('attachments') : undefined
765
+ if (provided && typeof provided.saveImage === 'function') return provided
766
+ } catch { /* optional service */ }
767
+ const fallback = ctx.attachments
768
+ return fallback && typeof fallback.saveImage === 'function' ? fallback : null
769
+ }
770
+ const registerImageTool = () => {
771
+ if (imageToolRegistered) return
772
+ try {
663
773
  imageToolRegistered = true
664
774
  registerTool(ctx, {
665
775
  name: 'qq_read_message_image',
@@ -682,14 +792,29 @@ export function apply(ctx, config = {}) {
682
792
  core,
683
793
  store,
684
794
  transport,
685
- attachments,
795
+ attachments: resolveImageAttachments(),
686
796
  mediaRoot: config.mediaRoot,
797
+ providerCacheRoot: config.providerCacheRoot,
687
798
  sessionId: sessionIdOf(exec),
688
799
  messageId: args.messageId,
689
800
  index: args.index ?? 0,
690
801
  }),
691
802
  })
803
+ } catch (error) {
804
+ imageToolRegistered = false
805
+ throw error
692
806
  }
807
+ }
808
+ let fileToolRegistered = false
809
+ try {
810
+ registerImageTool()
811
+ if (typeof ctx.inject !== 'function') {
812
+ // The image tool remains visible; it will resolve the Host service lazily
813
+ // when the Session actually invokes it.
814
+ } else ctx.inject(['attachments'], scope => {
815
+ const attachments = scope?.attachments ?? ctx.attachments
816
+ if (!attachments) return
817
+ if (attachments && typeof attachments.saveImage === 'function') imageAttachments = attachments
693
818
  if (!fileToolRegistered && typeof attachments.saveFile === 'function') {
694
819
  fileToolRegistered = true
695
820
  registerTool(ctx, {
@@ -751,7 +876,8 @@ export function apply(ctx, config = {}) {
751
876
  }),
752
877
  })
753
878
  }
754
- }) } catch {
879
+ })
880
+ } catch {
755
881
  // Missing optional Host media support must not disable ordinary QQ text.
756
882
  try { ctx.logger?.warn?.('amadeus-qq: attachment tools unavailable; attachment service could not be injected') } catch { /* optional diagnostic sink */ }
757
883
  }
@@ -865,3 +991,4 @@ export function apply(ctx, config = {}) {
865
991
  throw error
866
992
  }
867
993
  }
994
+
package/src/lease.js CHANGED
@@ -1,42 +1,42 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import { randomUUID } from 'node:crypto'
4
-
5
- function processExists(pid) {
6
- if (!Number.isSafeInteger(pid) || pid < 1) return false
7
- try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
8
- }
9
-
10
- export function acquireInstanceLease(stateFile) {
11
- if (typeof stateFile !== 'string' || !path.isAbsolute(stateFile)) throw new TypeError('stateFile must be absolute')
12
- const file = `${stateFile}.lock`
13
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
14
- const token = randomUUID()
15
- const record = JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() })
16
- let fd
17
- for (let attempt = 0; attempt < 2; attempt++) {
18
- try {
19
- fd = fs.openSync(file, 'wx', 0o600)
20
- break
21
- } catch (error) {
22
- if (error?.code !== 'EEXIST') throw error
23
- let existing
24
- try { existing = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { throw new Error('QQ_INSTANCE_LOCKED: unreadable lock requires manual inspection') }
25
- if (processExists(existing.pid)) throw new Error('QQ_INSTANCE_LOCKED: another QQ sender is active')
26
- if (attempt > 0) throw new Error('QQ_INSTANCE_LOCKED: could not acquire after stale lock cleanup')
27
- // Only a dead process's lock is removed; wx still arbitrates the new owner.
28
- fs.unlinkSync(file)
29
- }
30
- }
31
- if (fd === undefined) throw new Error('QQ_INSTANCE_LOCKED')
32
- try { fs.writeFileSync(fd, record); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
33
- let released = false
34
- return () => {
35
- if (released) return
36
- released = true
37
- try {
38
- const current = JSON.parse(fs.readFileSync(file, 'utf8'))
39
- if (current.token === token) fs.unlinkSync(file)
40
- } catch { /* never remove a lock that cannot be verified as ours */ }
41
- }
42
- }
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ function processExists(pid) {
6
+ if (!Number.isSafeInteger(pid) || pid < 1) return false
7
+ try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
8
+ }
9
+
10
+ export function acquireInstanceLease(stateFile) {
11
+ if (typeof stateFile !== 'string' || !path.isAbsolute(stateFile)) throw new TypeError('stateFile must be absolute')
12
+ const file = `${stateFile}.lock`
13
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
14
+ const token = randomUUID()
15
+ const record = JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() })
16
+ let fd
17
+ for (let attempt = 0; attempt < 2; attempt++) {
18
+ try {
19
+ fd = fs.openSync(file, 'wx', 0o600)
20
+ break
21
+ } catch (error) {
22
+ if (error?.code !== 'EEXIST') throw error
23
+ let existing
24
+ try { existing = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { throw new Error('QQ_INSTANCE_LOCKED: unreadable lock requires manual inspection') }
25
+ if (processExists(existing.pid)) throw new Error('QQ_INSTANCE_LOCKED: another QQ sender is active')
26
+ if (attempt > 0) throw new Error('QQ_INSTANCE_LOCKED: could not acquire after stale lock cleanup')
27
+ // Only a dead process's lock is removed; wx still arbitrates the new owner.
28
+ fs.unlinkSync(file)
29
+ }
30
+ }
31
+ if (fd === undefined) throw new Error('QQ_INSTANCE_LOCKED')
32
+ try { fs.writeFileSync(fd, record); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
33
+ let released = false
34
+ return () => {
35
+ if (released) return
36
+ released = true
37
+ try {
38
+ const current = JSON.parse(fs.readFileSync(file, 'utf8'))
39
+ if (current.token === token) fs.unlinkSync(file)
40
+ } catch { /* never remove a lock that cannot be verified as ours */ }
41
+ }
42
+ }
package/src/media.js CHANGED
@@ -44,6 +44,19 @@ function resolveMediaRoot(mediaRoot) {
44
44
  return root
45
45
  }
46
46
 
47
+ function resolveProviderRoot(providerCacheRoot) {
48
+ if (typeof providerCacheRoot !== 'string' || !path.isAbsolute(providerCacheRoot)) {
49
+ throw mediaError('QQ_MEDIA_PROVIDER_ROOT_INVALID')
50
+ }
51
+ try {
52
+ const root = fs.realpathSync(providerCacheRoot)
53
+ if (!fs.statSync(root).isDirectory()) throw new Error('not a directory')
54
+ return root
55
+ } catch {
56
+ throw mediaError('QQ_MEDIA_PROVIDER_ROOT_INVALID')
57
+ }
58
+ }
59
+
47
60
  function resolveLocalImage(file, root, mediaRoot) {
48
61
  if (typeof file !== 'string' || file.length === 0 || file.includes('\u0000')) throw mediaError('QQ_MEDIA_PATH_INVALID')
49
62
  // Reject UNC paths and URI schemes. A Windows drive path is the only
@@ -91,6 +104,40 @@ function resolveLocalImage(file, root, mediaRoot) {
91
104
  return real
92
105
  }
93
106
 
107
+ // NapCat may return a canonical cache path outside the plugin's private media
108
+ // directory. The model never supplies this path: it is obtained only from the
109
+ // bound message handle and the OneBot get_image response. Keep the same strict
110
+ // file, symlink, size and signature checks while accepting that provider cache.
111
+ function resolvePlatformImage(file, providerRoot) {
112
+ if (typeof file !== 'string' || file.length === 0 || file.includes('\u0000')) throw mediaError('QQ_MEDIA_PATH_INVALID')
113
+ if (/^[\\/]{2}/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
114
+ if (!/^[A-Za-z]:[\\/]/.test(file) && /^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
115
+ if (!path.isAbsolute(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
116
+ const root = resolveProviderRoot(providerRoot)
117
+ let entry
118
+ try {
119
+ entry = fs.lstatSync(file)
120
+ if (entry.isSymbolicLink() || !entry.isFile()) throw new Error('not a regular file')
121
+ const real = fs.realpathSync(file)
122
+ const resolved = fs.lstatSync(real)
123
+ if (resolved.isSymbolicLink() || !resolved.isFile()) throw new Error('not a regular file')
124
+ const relative = path.relative(root, real)
125
+ if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
126
+ throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
127
+ }
128
+ let cursor = root
129
+ for (const part of relative.split(path.sep)) {
130
+ cursor = path.join(cursor, part)
131
+ const item = fs.lstatSync(cursor)
132
+ if (item.isSymbolicLink()) throw mediaError('QQ_MEDIA_PATH_INVALID')
133
+ }
134
+ return real
135
+ } catch (error) {
136
+ if (error?.code === 'QQ_MEDIA_PATH_OUTSIDE_ROOT' || error?.code === 'QQ_MEDIA_PATH_INVALID') throw error
137
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
138
+ }
139
+ }
140
+
94
141
  function sniffImageMediaType(data) {
95
142
  if (data.length >= PNG_SIGNATURE.length && data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return 'image/png'
96
143
  if (data.length >= JPEG_SIGNATURE.length && data.subarray(0, JPEG_SIGNATURE.length).equals(JPEG_SIGNATURE)) return 'image/jpeg'
@@ -162,6 +209,7 @@ export async function readMessageImage({
162
209
  transport,
163
210
  attachments,
164
211
  mediaRoot,
212
+ providerCacheRoot,
165
213
  sessionId,
166
214
  messageId,
167
215
  index = 0,
@@ -179,8 +227,6 @@ export async function readMessageImage({
179
227
  }
180
228
  assertMediaRead(core, sessionId)
181
229
 
182
- const root = resolveMediaRoot(mediaRoot)
183
-
184
230
  let id
185
231
  try {
186
232
  id = normalizeMessageId(messageId, 'messageId')
@@ -219,7 +265,23 @@ export async function readMessageImage({
219
265
 
220
266
  let data
221
267
  if (typeof response.file === 'string') {
222
- const localFile = resolveLocalImage(response.file, root, mediaRoot)
268
+ // NapCat returns a provider-owned absolute cache path. Prefer the
269
+ // configured private root when it contains the file, but do not make a
270
+ // working OneBot provider cache depend on that optional directory: the
271
+ // handle came from the bound message and is still revalidated as a
272
+ // canonical regular file before bounded reading.
273
+ let localFile
274
+ try {
275
+ const root = resolveMediaRoot(mediaRoot)
276
+ try { localFile = resolveLocalImage(response.file, root, mediaRoot) }
277
+ catch (error) {
278
+ if (error?.code !== 'QQ_MEDIA_PATH_OUTSIDE_ROOT') throw error
279
+ localFile = resolvePlatformImage(response.file, providerCacheRoot)
280
+ }
281
+ } catch (error) {
282
+ if (error?.code !== 'QQ_MEDIA_ROOT_INVALID') throw error
283
+ localFile = resolvePlatformImage(response.file, providerCacheRoot)
284
+ }
223
285
  data = await withTimeout(readBoundedImage(localFile), READ_TIMEOUT_MS, 'QQ_MEDIA_TIMEOUT')
224
286
  } else if (typeof response.base64 === 'string') {
225
287
  data = decodeInlineImage(response.base64)
@@ -244,3 +306,4 @@ export async function readMessageImage({
244
306
  }
245
307
  return { attachment }
246
308
  }
309
+