@goodandready/dsh-messenger-gateway 0.3.13 → 0.3.18

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.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Normalize any content value to ContentBlock[].
3
+ * DSH core (dsh-llm) calls content.some() without Array.isArray guard,
4
+ * so we must ensure every message we create or copy has array content.
5
+ * Refs: #51
6
+ */
7
+ export function ensureContentArray(content) {
8
+ if (Array.isArray(content)) return content
9
+ if (typeof content === 'string') {
10
+ return content ? [{ type: 'text', text: content }] : [{ type: 'text', text: '(пустое сообщение)' }]
11
+ }
12
+ if (content && typeof content === 'object' && content.type) return [content]
13
+ return [{ type: 'text', text: String(content ?? '(пустое сообщение)') }]
14
+ }
package/lib/gateway.js CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  createEditScheduler, startTypingHeartbeat,
34
34
  } from './stream.js'
35
35
  import { isTopicGoneError } from './telegram-errors.js'
36
+ import { ensureContentArray } from './content-guard.js'
37
+
36
38
 
37
39
  function whenIdleWithTimeout(agent, timeoutMs, signal) {
38
40
  const idle = agent.whenIdle()
@@ -174,11 +176,10 @@ export class Gateway {
174
176
  this.ctx.logger?.warn?.(`dsh-messenger-gateway: ${adapter.name}: ${err.message}`)
175
177
  }
176
178
  }
177
- const idleMs = Number(this.config.agent?.idleTimeoutMs) || 0
178
- if (idleMs > 0) {
179
- this.idleTimer = setInterval(() => this.reapIdle(), Math.min(idleMs, 60_000))
180
- this.idleTimer.unref?.()
181
- }
179
+ const rawIdle = Number(this.config.agent?.idleTimeoutMs)
180
+ const idleMs = Number.isFinite(rawIdle) && rawIdle > 0 ? rawIdle : 86_400_000
181
+ this.idleTimer = setInterval(() => this.reapIdle(), Math.min(idleMs, 60_000))
182
+ this.idleTimer.unref?.()
182
183
  this.scheduler.start()
183
184
  }
184
185
 
@@ -339,8 +340,8 @@ export class Gateway {
339
340
  return
340
341
  }
341
342
  if (action.kind === 'done') {
342
- clearTimeout(pending.timer)
343
343
  this.pendingAsks.delete(askToken)
344
+ clearTimeout(pending.timer)
344
345
  releaseCallbacks(this.callbackIndex, pending.callbackKeys)
345
346
  await cb.answer('OK')
346
347
  try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
@@ -348,8 +349,8 @@ export class Gateway {
348
349
  return
349
350
  }
350
351
  if (action.kind === 'cancel') {
351
- clearTimeout(pending.timer)
352
352
  this.pendingAsks.delete(askToken)
353
+ clearTimeout(pending.timer)
353
354
  releaseCallbacks(this.callbackIndex, pending.callbackKeys)
354
355
  await cb.answer('Отменено')
355
356
  try { await cb.editMessage(cb.message?.text || 'Отменено', REMOVE_KEYBOARD) } catch {}
@@ -357,8 +358,8 @@ export class Gateway {
357
358
  return
358
359
  }
359
360
  }
360
- clearTimeout(pending.timer)
361
361
  this.pendingAsks.delete(askToken)
362
+ clearTimeout(pending.timer)
362
363
  releaseCallbacks(this.callbackIndex, pending.callbackKeys)
363
364
  await cb.answer('OK')
364
365
  try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
@@ -523,7 +524,7 @@ export class Gateway {
523
524
  steer: true,
524
525
  }, undefined)
525
526
  chat.agent.followup(createUserMessage({
526
- content,
527
+ content: ensureContentArray(content),
527
528
  source: { kind: 'plugin', plugin: PLUGIN, form: 'steer' },
528
529
  }))
529
530
  chat.lastUsed = Date.now()
@@ -533,6 +534,7 @@ export class Gateway {
533
534
 
534
535
  // Mark busy BEFORE yielding to the poll loop, otherwise steer/stop never see an active turn.
535
536
  chat.turnActive = true
537
+ try { chat.abort?.abort?.() } catch {}
536
538
  chat.abort = new AbortController()
537
539
  const run = chat.busy.then(() => this.runTurn(chat, turnInput, chat.abort.signal))
538
540
  chat.busy = run.catch(() => {})
@@ -565,30 +567,37 @@ export class Gateway {
565
567
  if (cmd === '/start') return reply('Шлюз подключён. Пишите сообщение агенту. /help — команды.', { replyMarkup: REMOVE_REPLY_KEYBOARD })
566
568
  if (cmd === '/help') {
567
569
  return reply([
568
- 'Команды:',
569
- '/help — справка',
570
- '/new — новая сессия',
571
- '/whoamiваш id',
572
- '/stopпрервать текущий ответ',
573
- '/statusстатус шлюза',
574
- '/model — текущая модель; /model provider model — сменить',
575
- '/role [name] — персоны и роли агента (/role list)',
576
- '/skills / /toolsсписок инструментов и навыков',
577
- '/fork — ответвление текущей сессии',
578
- '/export — выгрузка истории диалога в Markdown',
579
- '/rewind [N] — откат последних N сообщений',
580
- '/files [dir] менеджер файлов в рабочей папке агента',
581
- '/get <path>скачать файл из рабочей папки в Telegram',
582
- '/pair CODEодобрить пользователя',
583
- '/sethome [name]этот чат = home (имя опционально)',
584
- '/homeсписок home',
585
- '/setalert — назначить этот чат каналом алертов',
586
- '/alert [test] — статус канала алертов и тест',
587
- '/remind <время> <текст> напоминание (/remind 10m текст, /remind list)',
588
- '/voice on|off|status голосовые ответы',
589
- '/tts on|off|statusозвучка в этом чате',
590
- '/mute / /unmuteзаглушить уведомления в этот чат',
591
- '/keyboard on|off — быстрая клавиатура',
570
+ '📖 <b>Команды бота:</b>',
571
+ '',
572
+ '💬 <b>Диалог:</b>',
573
+ '/helpэта справка',
574
+ '/newновая сессия',
575
+ '/stopпрервать текущий ответ',
576
+ '/model — интерактивный выбор модели (/model list)',
577
+ '/role [name] — персоны и роли агента (/role list)',
578
+ ' /rewind [N]откат последних N ходов',
579
+ '/fork — форк сессии в новую ветку',
580
+ '/export — выгрузка истории в Markdown',
581
+ '',
582
+ '🛠️ <b>Инструменты и файлы:</b>',
583
+ '/skills / /tools список активных инструментов',
584
+ '/files [dir]проводник рабочей папки',
585
+ '/get <path>скачать файл в Telegram',
586
+ '/remind <время> <текст> напоминание (/remind 10m текст)',
587
+ '',
588
+ '⚙️ <b>Настройки:</b>',
589
+ '/statusстатус шлюза и модели',
590
+ '/topсистемные ресурсы (RAM, uptime)',
591
+ '/keyboard on|off — быстрые кнопки',
592
+ ' /voice on|off|statusголосовые ответы',
593
+ '/tts on|off|statusозвучка в этом чате',
594
+ '• /mute / /unmute — заглушить уведомления в этот чат',
595
+ '',
596
+ '🔒 <b>Доступ и каналы:</b>',
597
+ '• /whoami — ваш Telegram id',
598
+ '• /pair CODE — одобрить код сопряжения',
599
+ '• /sethome [name] — привязать домашний чат',
600
+ '• /setalert — назначить канал алертов',
592
601
  ].join('\n'))
593
602
  }
594
603
  if (cmd === '/role' || cmd === '/persona') {
@@ -619,9 +628,9 @@ export class Gateway {
619
628
  return reply(`🎭 Роль изменена на: ${persona.icon} <b>${persona.name}</b>\n${persona.description}`)
620
629
  }
621
630
  if (cmd === '/skills' || cmd === '/tools') {
622
- const toolsList = []
623
- if (this.ctx.tools?.tools) {
624
- for (const [name, t] of this.ctx.tools.tools.entries()) {
631
+ const tools = this.ctx.get?.('tools') || this.ctx.tools
632
+ if (tools?.tools) {
633
+ for (const [name, t] of tools.tools.entries()) {
625
634
  toolsList.push(`• <b>${name}</b>: ${t.description || '(нет описания)'}`)
626
635
  }
627
636
  }
@@ -649,7 +658,6 @@ export class Gateway {
649
658
  mime: 'text/markdown',
650
659
  kind: 'document',
651
660
  bytes: buffer,
652
- dataBase64: buffer.toString('base64'),
653
661
  }
654
662
  return reply({ text: `📄 Экспорт диалога (${messagesCount} сообщений):`, files: [file] })
655
663
  } catch (err) {
@@ -666,7 +674,8 @@ export class Gateway {
666
674
  if (!res.removed) {
667
675
  return reply('В истории сессии нет сообщений для отката.')
668
676
  }
669
- try { await this.ctx.sessions?.flush(chat.agent.session) } catch {}
677
+ const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
678
+ try { await sessions?.flush(chat.agent.session) } catch {}
670
679
  return reply(`⏪ Откачено сообщений: ${res.removed}. Осталось в контексте: ${res.remaining}.`)
671
680
  }
672
681
  if (cmd === '/fork') {
@@ -676,11 +685,14 @@ export class Gateway {
676
685
  }
677
686
  try {
678
687
  const oldSession = chat.agent.session
679
- const oldMessages = Array.isArray(oldSession.messages) ? [...oldSession.messages] : []
688
+ const oldMessages = Array.isArray(oldSession.messages)
689
+ ? oldSession.messages.map(m => ({ ...m, content: ensureContentArray(m.content) }))
690
+ : []
680
691
  const newChat = await this.createChat(key, input)
681
692
  if (newChat.agent?.session && oldMessages.length) {
682
693
  newChat.agent.session.messages = oldMessages
683
- try { await this.ctx.sessions?.flush(newChat.agent.session) } catch {}
694
+ const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
695
+ try { await sessions?.flush(newChat.agent.session) } catch {}
684
696
  }
685
697
  this.chats.set(key, newChat)
686
698
  return reply(`🔀 Создан форк сессии!\nСтарая сессия: ${oldSession.id}\nНовая активная сессия: ${newChat.agent.session.id}\nКонтекст сохранён (${oldMessages.length} сообщений).`)
@@ -1068,7 +1080,8 @@ export class Gateway {
1068
1080
  const agentCfg = this.config.agent || {}
1069
1081
  const cwd = agentCfg.cwd || process.cwd()
1070
1082
  const self = this
1071
- const handle = await this.ctx.agents.create({
1083
+ const agents = this.ctx.get?.('agents') || this.ctx.agents
1084
+ const handle = await agents.create({
1072
1085
  sessionId: SessionId(`msgw-${randomUUID()}`),
1073
1086
  meta: { cwd },
1074
1087
  agentOptions: { provider, model },
@@ -1218,7 +1231,7 @@ export class Gateway {
1218
1231
 
1219
1232
  const content = await this.buildUserContent(input, signal)
1220
1233
  chat.agent.followup(createUserMessage({
1221
- content,
1234
+ content: ensureContentArray(content),
1222
1235
  source: { kind: 'plugin', plugin: PLUGIN, form: 'relay' },
1223
1236
  }))
1224
1237
  const turnTimeoutMs = Number(this.config.agent?.turnTimeoutMs) || 600_000
@@ -1230,7 +1243,8 @@ export class Gateway {
1230
1243
  else return reply('Прервано.')
1231
1244
  return
1232
1245
  }
1233
- await this.ctx.sessions.flush(chat.agent.session)
1246
+ const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
1247
+ await sessions?.flush?.(chat.agent.session)
1234
1248
  if (progress) try { await progress.remove() } catch {}
1235
1249
  progress = null
1236
1250
  if (typeof react === 'function') react('').catch?.(() => {})
@@ -1318,8 +1332,8 @@ export class Gateway {
1318
1332
  }
1319
1333
 
1320
1334
  reapIdle() {
1321
- const timeout = Number(this.config.agent?.idleTimeoutMs) || 0
1322
- if (timeout <= 0) return
1335
+ const rawTimeout = Number(this.config.agent?.idleTimeoutMs)
1336
+ const timeout = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 86_400_000
1323
1337
  const now = Date.now()
1324
1338
  for (const [key, chat] of this.chats) {
1325
1339
  if (!chat.turnActive && now - chat.lastUsed > timeout) {
@@ -1347,6 +1361,26 @@ export class Gateway {
1347
1361
  return this.pairing.rejectCode(code)
1348
1362
  }
1349
1363
 
1364
+ async probeTelegram(timeoutMs = 10000) {
1365
+ const adapter = this.getAdapter('telegram')
1366
+ if (!adapter) {
1367
+ return { ok: false, error: 'Telegram adapter not initialized' }
1368
+ }
1369
+ if (typeof adapter.probeHealth === 'function') {
1370
+ return adapter.probeHealth(timeoutMs)
1371
+ }
1372
+ return { ok: false, error: 'probeHealth not implemented on adapter' }
1373
+ }
1374
+
1375
+ getBotInfo() {
1376
+ const adapter = this.getAdapter('telegram')
1377
+ return {
1378
+ botId: adapter?.botId || 0,
1379
+ botUsername: adapter?.botUsername || '',
1380
+ pollingConflict: Boolean(adapter?.pollingConflict),
1381
+ }
1382
+ }
1383
+
1350
1384
  async messengerAskFromAgent(agent, payload, timeoutMs) {
1351
1385
  const sessionId = String(agent?.session?.id || '')
1352
1386
  const key = this.sessionToChat.get(sessionId)
@@ -1366,6 +1400,8 @@ export class Gateway {
1366
1400
  send: (target, payload) => this.messengerSend(target, payload),
1367
1401
  ask: (target, payload, timeoutMs) => this.messengerAsk(target, payload, timeoutMs),
1368
1402
  progress: (target, payload) => this.messengerProgress(target, payload),
1403
+ probeTelegram: (timeoutMs) => this.probeTelegram(timeoutMs),
1404
+ getBotInfo: () => this.getBotInfo(),
1369
1405
  }
1370
1406
  }
1371
1407
  }
package/lib/index.js CHANGED
@@ -78,7 +78,7 @@ function publicConfig(cfg) {
78
78
  }
79
79
 
80
80
  function registerMessengerRoute(ctx, getGw, path, action) {
81
- ctx.effect(() => ctx.webServer.register({
81
+ ctx.effect(() => (ctx.get?.('webServer') || ctx.webServer).register({
82
82
  kind: 'exact', path,
83
83
  handler: async (req, res) => {
84
84
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
@@ -112,6 +112,7 @@ export function apply(ctx, config) {
112
112
  let source = () => entry
113
113
  let settingsApi
114
114
  const turnStarts = new Map()
115
+ const getWebServer = () => ctx.get?.('webServer') || ctx.webServer
115
116
 
116
117
  const sync = () => {
117
118
  if (gateway) { gateway.stop(); gateway = undefined }
@@ -154,8 +155,9 @@ export function apply(ctx, config) {
154
155
  gateway.start().catch((err) => ctx.logger?.warn?.(`dsh-messenger-gateway: ${err.message}`))
155
156
  }
156
157
 
157
- if (ctx.settings?.register) {
158
- settingsApi = ctx.settings.register(SETTINGS_NAMESPACE, Config, { base: config || {} })
158
+ const settings = ctx.get?.('settings') || ctx.settings
159
+ if (settings?.register) {
160
+ settingsApi = settings.register(SETTINGS_NAMESPACE, Config, { base: config || {} })
159
161
  source = () => resolveConfig(settingsApi.get() ?? config ?? {})
160
162
  ctx.effect(() => settingsApi.watch(sync), 'dsh-messenger-gateway: settings')
161
163
  }
@@ -165,8 +167,9 @@ export function apply(ctx, config) {
165
167
  ctx.effect(() => ctx.provide('messenger', createMessengerService(getGw)), 'dsh-messenger-gateway: messenger service')
166
168
 
167
169
  // Agent tool: inline buttons in the telegram chat bound to this msgw session
168
- if (ctx.tools?.register) {
169
- ctx.effect(() => ctx.tools.register(defineTool({
170
+ const tools = ctx.get?.('tools') || ctx.tools
171
+ if (tools?.register) {
172
+ ctx.effect(() => tools.register(defineTool({
170
173
  name: 'messenger_ask',
171
174
  description:
172
175
  'Ask the Telegram user a single or multiple-choice question with inline buttons/checkboxes and wait for their choice. '
@@ -267,11 +270,13 @@ export function apply(ctx, config) {
267
270
  .catch((e) => ctx.logger?.warn?.(`notify bridge: ${e.message}`))
268
271
  }), 'dsh-messenger-gateway: notify bridge')
269
272
 
270
- ctx.effect(() => ctx.webServer.register({
273
+ ctx.effect(() => getWebServer().register({
271
274
  kind: 'exact', path: '/dsh-messenger-gateway/status',
272
275
  handler: async (req, res) => {
273
276
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
274
277
  const gw = getGw()
278
+ const botInfo = gw?.getBotInfo?.() || {}
279
+ const uptimeSec = gw?.stats?.startedAt ? Math.max(0, Math.round((Date.now() - gw.stats.startedAt) / 1000)) : 0
275
280
  writeJson(res, 200, {
276
281
  ok: true,
277
282
  running: Boolean(gw),
@@ -279,15 +284,44 @@ export function apply(ctx, config) {
279
284
  activeChats: gw?.messenger.activeChats() || 0,
280
285
  ttsEnabled: Boolean(source().tts?.enabled),
281
286
  messengerService: 'messenger',
282
- home: gateway?.messenger.home?.() || null,
283
- homes: gateway?.messenger.homes?.() || [],
287
+ home: gw?.messenger.home?.() || null,
288
+ homes: gw?.messenger.homes?.() || [],
284
289
  pairingPending: gw?.messenger.pairingPending?.()?.length || 0,
290
+ botId: botInfo.botId || 0,
291
+ botUsername: botInfo.botUsername || '',
292
+ pollingConflict: Boolean(botInfo.pollingConflict),
293
+ uptimeSec,
294
+ stats: {
295
+ sent: gw?.stats?.sent || 0,
296
+ errors: gw?.stats?.errors || 0,
297
+ startedAt: gw?.stats?.startedAt || 0,
298
+ },
285
299
  config: publicConfig(source()),
286
300
  })
287
301
  },
288
302
  }), 'dsh-messenger-gateway: status')
289
303
 
290
- ctx.effect(() => ctx.webServer.register({
304
+ ctx.effect(() => getWebServer().register({
305
+ kind: 'exact', path: '/dsh-messenger-gateway/smoke',
306
+ handler: async (req, res) => {
307
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
308
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
309
+ const gw = getGw()
310
+ if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
311
+ let payload = {}
312
+ try {
313
+ const raw = (await readBody(req)).toString('utf8').trim()
314
+ if (raw) payload = JSON.parse(raw)
315
+ } catch {
316
+ return writeJson(res, 400, { ok: false, error: 'invalid json' })
317
+ }
318
+ const timeoutMs = Number(payload.timeoutMs) || 10000
319
+ const result = await gw.probeTelegram(timeoutMs)
320
+ writeJson(res, result.ok ? 200 : 502, result)
321
+ },
322
+ }), 'dsh-messenger-gateway: smoke test')
323
+
324
+ ctx.effect(() => getWebServer().register({
291
325
  kind: 'exact', path: '/dsh-messenger-gateway/messenger',
292
326
  handler: async (req, res) => {
293
327
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
@@ -295,7 +329,7 @@ export function apply(ctx, config) {
295
329
  },
296
330
  }), 'dsh-messenger-gateway: messenger schema')
297
331
 
298
- ctx.effect(() => ctx.webServer.register({
332
+ ctx.effect(() => getWebServer().register({
299
333
  kind: 'exact', path: '/dsh-messenger-gateway/events',
300
334
  handler: async (req, res) => {
301
335
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
@@ -334,7 +368,7 @@ export function apply(ctx, config) {
334
368
  },
335
369
  }), 'dsh-messenger-gateway: webhook events')
336
370
 
337
- ctx.effect(() => ctx.webServer.register({
371
+ ctx.effect(() => getWebServer().register({
338
372
  kind: 'exact', path: '/dsh-messenger-gateway/config',
339
373
  handler: async (req, res) => {
340
374
  if (req.method === 'GET') return writeJson(res, 200, { ok: true, config: publicConfig(source()) })
@@ -370,7 +404,7 @@ export function apply(ctx, config) {
370
404
  },
371
405
  }), 'dsh-messenger-gateway: config')
372
406
 
373
- ctx.effect(() => ctx.webServer.register({
407
+ ctx.effect(() => getWebServer().register({
374
408
  kind: 'exact', path: '/dsh-messenger-gateway/pairing',
375
409
  handler: async (req, res) => {
376
410
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
@@ -385,7 +419,7 @@ export function apply(ctx, config) {
385
419
  },
386
420
  }), 'dsh-messenger-gateway: pairing list')
387
421
 
388
- ctx.effect(() => ctx.webServer.register({
422
+ ctx.effect(() => getWebServer().register({
389
423
  kind: 'exact', path: '/dsh-messenger-gateway/pairing/approve',
390
424
  handler: async (req, res) => {
391
425
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
@@ -399,7 +433,7 @@ export function apply(ctx, config) {
399
433
  },
400
434
  }), 'dsh-messenger-gateway: pairing approve')
401
435
 
402
- ctx.effect(() => ctx.webServer.register({
436
+ ctx.effect(() => getWebServer().register({
403
437
  kind: 'exact', path: '/dsh-messenger-gateway/pairing/reject',
404
438
  handler: async (req, res) => {
405
439
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
@@ -418,7 +452,7 @@ export function apply(ctx, config) {
418
452
  return p || '/dsh-messenger-gateway/telegram/webhook'
419
453
  }
420
454
 
421
- ctx.effect(() => ctx.webServer.register({
455
+ ctx.effect(() => getWebServer().register({
422
456
  kind: 'exact', path: webhookPath(),
423
457
  handler: async (req, res) => {
424
458
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
package/lib/models.js CHANGED
@@ -7,14 +7,15 @@ export async function listModelCatalog(ctx, fallback = {}) {
7
7
  }
8
8
 
9
9
  // 1. Try ctx.llm if available
10
- if (ctx?.llm?.listProviders) {
10
+ const llm = ctx?.get?.('llm') || ctx?.llm
11
+ if (llm?.listProviders) {
11
12
  try {
12
- const providers = await ctx.llm.listProviders()
13
+ const providers = await llm.listProviders()
13
14
  for (const p of providers || []) {
14
15
  const pId = p.id || p
15
16
  result.providers.push({ id: pId, name: p.name || pId })
16
17
  try {
17
- const models = await ctx.llm.listModels(pId)
18
+ const models = await llm.listModels(pId)
18
19
  result.modelsByProvider.set(
19
20
  pId,
20
21
  (models || []).map((m) => ({ id: m.id || m, name: m.name || m.id || m }))
package/lib/outbound.js CHANGED
@@ -62,7 +62,8 @@ export function fileNameForRef(ref) {
62
62
  }
63
63
 
64
64
  export async function readAttachmentBytes(ctx, ref) {
65
- const stored = await ctx.attachments.readImage(ref)
65
+ const attachments = ctx.get?.('attachments') || ctx.attachments
66
+ const stored = await attachments.readImage(ref)
66
67
  return {
67
68
  bytes: stored.data,
68
69
  mime: ref.mediaType || stored.ref?.mediaType || 'image/png',
package/lib/pairing.js CHANGED
@@ -1,5 +1,5 @@
1
- import { randomBytes } from 'node:crypto'
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
1
+ import { randomBytes, randomUUID } from 'node:crypto'
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'node:fs'
3
3
  import { dirname } from 'node:path'
4
4
 
5
5
  const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
@@ -11,6 +11,19 @@ export function generatePairingCode(length = 8) {
11
11
  return out
12
12
  }
13
13
 
14
+ function writeJsonAtomicSync(filePath, data) {
15
+ const dir = dirname(filePath)
16
+ mkdirSync(dir, { recursive: true })
17
+ const tmpPath = `${filePath}.${randomUUID().slice(0, 8)}.tmp`
18
+ try {
19
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8')
20
+ renameSync(tmpPath, filePath)
21
+ } catch (err) {
22
+ try { unlinkSync(tmpPath) } catch {}
23
+ throw err
24
+ }
25
+ }
26
+
14
27
  export function createPairingStore(filePath) {
15
28
  const state = { pending: {}, /** @type {number[]} */ approved: [] }
16
29
  if (filePath && existsSync(filePath)) {
@@ -22,8 +35,9 @@ export function createPairingStore(filePath) {
22
35
  }
23
36
  const persist = () => {
24
37
  if (!filePath) return
25
- mkdirSync(dirname(filePath), { recursive: true })
26
- writeFileSync(filePath, JSON.stringify({ approved: state.approved, pending: state.pending }, null, 2))
38
+ try {
39
+ writeJsonAtomicSync(filePath, { approved: state.approved, pending: state.pending })
40
+ } catch {}
27
41
  }
28
42
  const prune = () => {
29
43
  const now = Date.now()
@@ -107,4 +121,4 @@ export function createPairingStore(filePath) {
107
121
  return { ok: true, userId: row.userId, username: row.username }
108
122
  },
109
123
  }
110
- }
124
+ }
package/lib/photos.js CHANGED
@@ -12,7 +12,8 @@ export async function attachInboundPhoto(ctx, att, opts = {}) {
12
12
  if (maxBytes > 0 && bytes.length > maxBytes) {
13
13
  throw new Error(`image too large (${bytes.length} bytes, max ${maxBytes})`)
14
14
  }
15
- const ref = await ctx.attachments.saveImage({
15
+ const attachments = ctx.get?.('attachments') || ctx.attachments
16
+ const ref = await attachments.saveImage({
16
17
  data: bytes,
17
18
  mediaType: att.mime || 'image/jpeg',
18
19
  ...(att.name ? { name: att.name } : {}),
package/lib/scheduler.js CHANGED
@@ -1,6 +1,6 @@
1
- import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
- import { dirname } from 'node:path'
1
+ import { readFile } from 'node:fs/promises'
3
2
  import { randomUUID } from 'node:crypto'
3
+ import { writeJsonAtomic } from './storage-atomic.js'
4
4
 
5
5
  export function parseRelativeTime(input) {
6
6
  if (!input) return null
@@ -49,6 +49,7 @@ export function createScheduler(filePath, onDue) {
49
49
  const RETENTION_MS = 7 * 86400 * 1000
50
50
 
51
51
  async function save() {
52
+ if (!filePath) return
52
53
  try {
53
54
  const now = Date.now()
54
55
  tasks = tasks.filter((t) => {
@@ -56,8 +57,7 @@ export function createScheduler(filePath, onDue) {
56
57
  const finishTime = t.firedAt || t.createdAt || 0
57
58
  return (now - finishTime) < RETENTION_MS
58
59
  })
59
- await mkdir(dirname(filePath), { recursive: true })
60
- await writeFile(filePath, JSON.stringify(tasks, null, 2), 'utf8')
60
+ await writeJsonAtomic(filePath, tasks)
61
61
  } catch {}
62
62
  }
63
63
 
@@ -140,4 +140,4 @@ export function createScheduler(filePath, onDue) {
140
140
  checkDue,
141
141
  getTasks: () => tasks,
142
142
  }
143
- }
143
+ }
@@ -87,9 +87,19 @@ export function rewindSession(session, turnsCount = 1) {
87
87
  }
88
88
  }
89
89
 
90
- const removed = messages.splice(messages.length - toRemove, toRemove)
90
+ const keepCount = Math.max(0, messages.length - toRemove)
91
+ const kept = messages.slice(0, keepCount).map(m => {
92
+ // Only wrap non-string invalid/nullish/object content to array
93
+ if (m && m.content && typeof m.content === 'object' && !Array.isArray(m.content) && m.content.type) {
94
+ return { ...m, content: [m.content] }
95
+ }
96
+ return m
97
+ })
98
+ const removedCount = messages.length - keepCount
99
+ session.messages = kept
100
+
91
101
  return {
92
- removed: removed.length,
93
- remaining: messages.length,
102
+ removed: removedCount,
103
+ remaining: session.messages.length,
94
104
  }
95
- }
105
+ }
@@ -0,0 +1,21 @@
1
+ import { writeFile, rename, mkdir, unlink } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ /**
6
+ * Write JSON atomically via temporary file and rename.
7
+ * Guarantees that readers never observe partially written files.
8
+ */
9
+ export async function writeJsonAtomic(filePath, data) {
10
+ const dir = dirname(filePath)
11
+ await mkdir(dir, { recursive: true })
12
+ const tmpPath = `${filePath}.${randomUUID().slice(0, 8)}.tmp`
13
+ const serialized = JSON.stringify(data, null, 2)
14
+ try {
15
+ await writeFile(tmpPath, serialized, 'utf8')
16
+ await rename(tmpPath, filePath)
17
+ } catch (err) {
18
+ await unlink(tmpPath).catch(() => {})
19
+ throw err
20
+ }
21
+ }
package/lib/stream.js CHANGED
@@ -24,22 +24,48 @@ export function buildStreamPreview(streamText, toolName, maxLen = 3500) {
24
24
  return combined.slice(0, maxLen - 1) + '…'
25
25
  }
26
26
 
27
+ export function parseRetryAfter(err) {
28
+ if (!err) return 0
29
+ const msg = err.message || String(err)
30
+ const match = /retry after (\d+)/i.exec(msg)
31
+ if (match) return parseInt(match[1], 10) * 1000
32
+ return 0
33
+ }
34
+
27
35
  export function createEditScheduler(editFn, intervalMs = 1200) {
28
36
  let pending = null
29
37
  let timer = null
30
38
  let lastSent = ''
31
39
  let inFlight = Promise.resolve()
40
+ let retryDelayMs = 0
41
+
32
42
  const flush = () => {
33
43
  timer = null
34
44
  if (pending === null || pending === lastSent) return
35
45
  const text = pending
36
46
  inFlight = inFlight.then(async () => {
37
47
  try {
48
+ if (retryDelayMs > 0) {
49
+ const delay = retryDelayMs
50
+ retryDelayMs = 0
51
+ await new Promise((r) => setTimeout(r, delay))
52
+ }
38
53
  await editFn(text)
39
54
  lastSent = text
40
- } catch {}
55
+ } catch (err) {
56
+ const retry = parseRetryAfter(err)
57
+ if (retry > 0) {
58
+ retryDelayMs = retry
59
+ // Re-schedule flush with retry delay
60
+ if (!timer) {
61
+ timer = setTimeout(flush, retry + 100)
62
+ timer.unref?.()
63
+ }
64
+ }
65
+ }
41
66
  })
42
67
  }
68
+
43
69
  return {
44
70
  push(text) {
45
71
  pending = text
@@ -51,6 +77,17 @@ export function createEditScheduler(editFn, intervalMs = 1200) {
51
77
  if (timer) { clearTimeout(timer); timer = null }
52
78
  flush()
53
79
  await inFlight
80
+ // If there's still a pending difference (e.g. rate limit delay happened), wait and retry
81
+ if (pending !== null && pending !== lastSent) {
82
+ if (retryDelayMs > 0) {
83
+ await new Promise((r) => setTimeout(r, retryDelayMs + 50))
84
+ retryDelayMs = 0
85
+ }
86
+ try {
87
+ await editFn(pending)
88
+ lastSent = pending
89
+ } catch {}
90
+ }
54
91
  },
55
92
  }
56
93
  }
@@ -61,4 +98,4 @@ export function startTypingHeartbeat(typingFn, intervalMs = 4000) {
61
98
  const timer = setInterval(() => { typingFn().catch?.(() => {}) }, intervalMs)
62
99
  timer.unref?.()
63
100
  return () => clearInterval(timer)
64
- }
101
+ }