@goodandready/dsh-messenger-gateway 0.3.21 → 0.4.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.
package/lib/locales/zh.js CHANGED
@@ -1,114 +1,123 @@
1
- // Canonical Chinese (zh) locale dictionary
2
- export const zh = {
3
- // Command descriptions
4
- 'cmd.help': '显示帮助信息',
5
- 'cmd.new': '开始新会话',
6
- 'cmd.stop': '中断当前回答',
7
- 'cmd.model': '交互式模型选择器',
8
- 'cmd.role': '切换智能体角色/人设',
9
- 'cmd.rewind': '回退最近N轮对话',
10
- 'cmd.fork': '将会话派生到新分支',
11
- 'cmd.export': '导出历史记录为Markdown',
12
- 'cmd.skills': '列出当前可用工具与技能',
13
- 'cmd.files': '工作区文件浏览器',
14
- 'cmd.get': '从工作区下载文件',
15
- 'cmd.remind': '设置提醒或定时器',
16
- 'cmd.status': '查看网关与模型状态',
17
- 'cmd.top': '查看系统资源(内存、运行时间)',
18
- 'cmd.keyboard': '开关快捷操作键盘',
19
- 'cmd.voice': '设置语音回复模式',
20
- 'cmd.tts': '在此聊天开关语音朗读',
21
- 'cmd.mute': '在此聊天静音通知',
22
- 'cmd.unmute': '在此聊天恢复通知',
23
- 'cmd.whoami': '显示你的用户ID',
24
- 'cmd.pair': '批准配对申请码',
25
- 'cmd.sethome': '设置主通知频道',
26
- 'cmd.setalert': '设置告警通知目标',
27
- 'cmd.bind': '绑定预设或人设到论坛话题',
28
- 'cmd.preset': '查看或切换智能体预设',
29
- 'cmd.cron': '配置定时周期性任务报告',
30
-
31
- // Bot replies & statuses
32
- 'msg.start': '网关已连接。请向智能体发送消息。发送 /help 查看命令。',
33
- 'msg.steer_added': '↪️ 已追加到当前回答',
34
- 'msg.turn_stopped': '回答已中断。',
35
- 'msg.no_active_session': '未找到活跃会话。',
36
- 'msg.unknown_command': '未知命令 {cmd}。发送 /help',
37
- 'msg.not_allowed': '访问受限。请联系管理员将您的ID加入白名单。',
38
- 'msg.pairing_requested': '需要访问配对。\n您的ID: {userId}\n配对码: {code}\n\n管理员请向机器人发送:\n/pair {code}',
39
- 'msg.pairing_rate_limit': '配对码已发出,请稍候或联系管理员。',
40
- 'msg.pairing_approved': '✅ 用户 {userId} 已成功授权!',
41
- 'msg.pairing_invalid': '配对码无效或已过期。',
42
- 'msg.muted_on': '此聊天通知:已静音 (/unmute)',
43
- 'msg.muted_off': '此聊天通知:已开启',
44
- 'msg.no_response': '(无回复)',
45
- 'msg.agent_error': '智能体错误: {code}: {message}',
46
- 'msg.exception': '内部处理异常: {message}',
47
-
48
- // Photo & media
49
- 'photo.received_one': '已收到图片。请输入您的问题 — 例如:“图片中有什么?”',
50
- 'photo.received_many': '已收到 {count} 个附件。请输入您的问题。',
51
- 'photo.hint_photo': '[附加图片]',
52
- 'photo.hint_photos': '[附加了 {count} 张图片]',
53
- 'doc.hint': '[附加文件: {name}]',
54
-
55
- // Interactive buttons & ask
56
- 'ask.confirm_title': '⚠️ 需要授权确认\n工具: <code>{tool}</code>{reason}',
57
- 'ask.allow_once': '✅ 允许单次',
58
- 'ask.allow_session': '🛡️ 本会话始终允许',
59
- 'ask.deny': '❌ 拒绝',
60
- 'ask.chose': '已选择: {choice}',
61
- 'ask.done': '完成',
62
- 'ask.cancel': '取消',
63
- 'ask.canceled': '已取消',
64
- 'ask.expired': '交互已超时,请重新发起。',
65
- 'ask.other_chat': '该按钮属于其他聊天',
66
-
67
- // Model selector
68
- 'model.title': '🤖 <b>选择服务商:</b>',
69
- 'model.current': '当前: <code>{current}</code>',
70
- 'model.provider_models': '🤖 <b>服务商:</b> <code>{provider}</code>\n请选择模型 (第 {page}/{total} 页):',
71
- 'model.no_models': '该服务商暂无可用的模型',
72
- 'model.switched': '✅ 模型已成功切换至: <b>{provider}/{model}</b>',
73
-
74
- // Personas & Presets
75
- 'persona.title': '🎭 <b>可用角色与人设:</b>',
76
- 'persona.usage': '切换角色: <code>/role coder</code> (或 /role reset)',
77
- 'persona.reset': '🎭 角色已重置为默认 (Default)。',
78
- 'persona.switched': '🎭 角色已切换为: {icon} <b>{name}</b>\n{description}',
79
- 'persona.unknown': '未知角色 "{target}"。列表请见: /role list',
80
- 'persona.bound_topic': '📌 话题已绑定到{kind}: <b>{name}</b>',
81
-
82
- // Builtin persona descriptions
83
- 'persona.default.name': '默认',
84
- 'persona.default.desc': '通用智能助手,回答平衡清晰',
85
- 'persona.coder.name': '开发者',
86
- 'persona.coder.desc': '资深全栈工程师,直奔代码核心',
87
- 'persona.writer.name': '文案编辑',
88
- 'persona.writer.desc': '专业编辑润色,文笔优美流畅',
89
- 'persona.analyst.name': '分析师',
90
- 'persona.analyst.desc': '结构化逻辑拆解,深度数据分析',
91
- 'persona.concise.name': '极简',
92
- 'persona.concise.desc': '极速短促回复,1-2句话答复',
93
-
94
- // File explorer & export
95
- 'files.title': '📁 <b>工作区路径:</b> <code>{path}</code>\n文件总计: {count}\n\n',
96
- 'files.empty': '📁 目录为空: <code>{path}</code>',
97
- 'files.download_hint': '\n下载文件命令: <code>/get &lt;path&gt;</code>',
98
- 'files.not_found': '未找到文件: {path}',
99
- 'export.title': '📄 对话导出 ({count} 条消息):',
100
- 'export.empty': '会话为空,无消息可导出。',
101
-
102
- // Reminders & Cron
103
- 'remind.scheduled': '⏰ 提醒已设置在 {time} ({duration} 后):\n{text}',
104
- 'remind.invalid': '时间格式错误。例如: /remind 10m 检查构建',
105
- 'remind.prefix': '⏰ <b>[提醒]</b>\n{text}',
106
- 'cron.scheduled': '⏱️ 定时任务已配置: <code>{id}</code>\n周期表达式: <code>{schedule}</code>\n指令: {prompt}',
107
- 'cron.list_title': '⏱️ <b>当前定时任务列表:</b>\n',
108
- 'cron.none': '暂无活动的定时任务。',
109
- 'cron.cancelled': '✅ 定时任务已取消: <code>{id}</code>',
110
- 'cron.prefix': '⏱️ <b>[定时报告: {id}]</b>\n',
111
-
112
- // Mirror & Forum
113
- 'mirror.created': '🪞 <b>[DSH 会话镜像]</b>\n会话ID: <code>{sessionId}</code>\n标题: <b>{title}</b>\n此话题消息与 DSH 双向同步。',
114
- }
1
+ // Canonical Chinese (zh) locale dictionary
2
+ export const zh = {
3
+ // Command descriptions
4
+ 'cmd.help': '显示帮助信息',
5
+ 'cmd.new': '开始新会话',
6
+ 'cmd.stop': '中断当前回答',
7
+ 'cmd.model': '交互式模型选择器',
8
+ 'cmd.role': '切换智能体角色/人设',
9
+ 'cmd.rewind': '回退最近N轮对话',
10
+ 'cmd.fork': '将会话派生到新分支',
11
+ 'cmd.export': '导出历史记录为Markdown',
12
+ 'cmd.skills': '列出当前可用工具与技能',
13
+ 'cmd.files': '工作区文件浏览器',
14
+ 'cmd.get': '从工作区下载文件',
15
+ 'cmd.remind': '设置提醒或定时器',
16
+ 'cmd.status': '查看网关与模型状态',
17
+ 'cmd.top': '查看系统资源(内存、运行时间)',
18
+ 'cmd.keyboard': '开关快捷操作键盘',
19
+ 'cmd.voice': '设置语音回复模式',
20
+ 'cmd.tts': '在此聊天开关语音朗读',
21
+ 'cmd.mute': '在此聊天静音通知',
22
+ 'cmd.unmute': '在此聊天恢复通知',
23
+ 'cmd.whoami': '显示你的用户ID',
24
+ 'cmd.pair': '批准配对申请码',
25
+ 'cmd.sethome': '设置主通知频道',
26
+ 'cmd.setalert': '设置告警通知目标',
27
+ 'cmd.bind': '绑定预设或人设到论坛话题',
28
+ 'cmd.preset': '查看或切换智能体预设',
29
+ 'cmd.cron': '配置定时周期性任务报告',
30
+
31
+ // Bot replies & statuses
32
+ 'msg.start': '网关已连接。请向智能体发送消息。发送 /help 查看命令。',
33
+ 'msg.steer_added': '↪️ 已追加到当前回答',
34
+ 'msg.turn_stopped': '回答已中断。',
35
+ 'msg.no_active_session': '未找到活跃会话。',
36
+ 'msg.unknown_command': '未知命令 {cmd}。发送 /help',
37
+ 'msg.not_allowed': '访问受限。请联系管理员将您的ID加入白名单。',
38
+ 'msg.pairing_requested': '需要访问配对。\n您的ID: {userId}\n配对码: {code}\n\n管理员请向机器人发送:\n/pair {code}',
39
+ 'msg.pairing_rate_limit': '配对码已发出,请稍候或联系管理员。',
40
+ 'msg.pairing_approved': '✅ 用户 {userId} 已成功授权!',
41
+ 'msg.pairing_invalid': '配对码无效或已过期。',
42
+ 'msg.muted_on': '此聊天通知:已静音 (/unmute)',
43
+ 'msg.muted_off': '此聊天通知:已开启',
44
+ 'msg.no_response': '(无回复)',
45
+ 'msg.agent_error': '智能体错误: {code}: {message}',
46
+ 'msg.exception': '内部处理异常: {message}',
47
+
48
+ // Photo & media
49
+ 'photo.received_one': '已收到图片。请输入您的问题 — 例如:“图片中有什么?”',
50
+ 'photo.received_many': '已收到 {count} 个附件。请输入您的问题。',
51
+ 'photo.hint_photo': '[附加图片]',
52
+ 'photo.hint_photos': '[附加了 {count} 张图片]',
53
+ 'doc.hint': '[附加文件: {name}]',
54
+
55
+ // Interactive buttons & ask
56
+ 'ask.confirm_title': '⚠️ 需要授权确认\n工具: <code>{tool}</code>{reason}',
57
+ 'ask.allow_once': '✅ 允许单次',
58
+ 'ask.allow_session': '🛡️ 本会话始终允许',
59
+ 'ask.deny': '❌ 拒绝',
60
+ 'ask.chose': '已选择: {choice}',
61
+ 'ask.done': '完成',
62
+ 'ask.cancel': '取消',
63
+ 'ask.canceled': '已取消',
64
+ 'ask.expired': '交互已超时,请重新发起。',
65
+ 'ask.other_chat': '该按钮属于其他聊天',
66
+
67
+ // Model selector
68
+ 'model.title': '🤖 <b>选择服务商:</b>',
69
+ 'model.current': '当前: <code>{current}</code>',
70
+ 'model.provider_models': '🤖 <b>服务商:</b> <code>{provider}</code>\n请选择模型 (第 {page}/{total} 页):',
71
+ 'model.no_models': '该服务商暂无可用的模型',
72
+ 'model.switched': '✅ 模型已成功切换至: <b>{provider}/{model}</b>',
73
+
74
+ // Personas & Presets
75
+ 'persona.title': '🎭 <b>可用角色与人设:</b>',
76
+ 'persona.usage': '切换角色: <code>/role coder</code> (或 /role reset)',
77
+ 'persona.reset': '🎭 角色已重置为默认 (Default)。',
78
+ 'persona.switched': '🎭 角色已切换为: {icon} <b>{name}</b>\n{description}',
79
+ 'persona.unknown': '未知角色 "{target}"。列表请见: /role list',
80
+ 'persona.bound_topic': '📌 话题已绑定到{kind}: <b>{name}</b>',
81
+
82
+ // Builtin persona descriptions
83
+ 'persona.default.name': '默认',
84
+ 'persona.default.desc': '通用智能助手,回答平衡清晰',
85
+ 'persona.coder.name': '开发者',
86
+ 'persona.coder.desc': '资深全栈工程师,直奔代码核心',
87
+ 'persona.writer.name': '文案编辑',
88
+ 'persona.writer.desc': '专业编辑润色,文笔优美流畅',
89
+ 'persona.analyst.name': '分析师',
90
+ 'persona.analyst.desc': '结构化逻辑拆解,深度数据分析',
91
+ 'persona.concise.name': '极简',
92
+ 'persona.concise.desc': '极速短促回复,1-2句话答复',
93
+
94
+ // File explorer & export
95
+ 'files.title': '📁 <b>工作区路径:</b> <code>{path}</code>\n文件总计: {count}\n\n',
96
+ 'files.empty': '📁 目录为空: <code>{path}</code>',
97
+ 'files.download_hint': '\n下载文件命令: <code>/get &lt;path&gt;</code>',
98
+ 'files.not_found': '未找到文件: {path}',
99
+ 'export.title': '📄 对话导出 ({count} 条消息):',
100
+ 'export.empty': '会话为空,无消息可导出。',
101
+
102
+ // Reminders & Cron
103
+ 'remind.scheduled': '⏰ 提醒已设置在 {time} ({duration} 后):\n{text}',
104
+ 'remind.invalid': '时间格式错误。例如: /remind 10m 检查构建',
105
+ 'remind.prefix': '⏰ <b>[提醒]</b>\n{text}',
106
+ 'cron.scheduled': '⏱️ 定时任务已配置: <code>{id}</code>\n周期表达式: <code>{schedule}</code>\n指令: {prompt}',
107
+ 'cron.list_title': '⏱️ <b>当前定时任务列表:</b>\n',
108
+ 'cron.none': '暂无活动的定时任务。',
109
+ 'cron.cancelled': '✅ 定时任务已取消: <code>{id}</code>',
110
+ 'cron.prefix': '⏱️ <b>[定时报告: {id}]</b>\n',
111
+
112
+ // Mirror & Forum
113
+ 'mirror.created': '🪞 <b>[DSH 会话镜像]</b>\n会话ID: <code>{sessionId}</code>\n标题: <b>{title}</b>\n此话题消息与 DSH 双向同步。',
114
+ // Updates & Maintenance
115
+ 'cmd.update': '检查或安装插件更新',
116
+ 'update.installing': '⏳ <b>正在更新插件…</b>\n正在后台运行 DSH 软件包安装…',
117
+ 'update.already_latest': '✅ 插件已是最新版本 (<code>v{version}</code>)。',
118
+ 'update.success': '🎉 <b>已成功更新至 v{version}!</b>\n请重启服务以使更改生效:<code>sudo systemctl restart dsh-web.service</code>',
119
+ 'update.failed': '❌ <b>更新失败:</b> {error}',
120
+ 'update.available': '📦 <b>发现新版本!</b>\n当前版本:<code>v{current}</code>\n最新版本:<code>v{latest}</code>\n\n发送 <code>/update now</code> 立即安装。',
121
+ 'update.current': '✅ <b>Messenger Gateway 已是最新版本。</b>\n当前版本:<code>v{version}</code>。',
122
+ 'update.check_failed': '⚠️ 检查更新失败:{error}',
123
+ }
package/lib/media.js CHANGED
@@ -40,14 +40,6 @@ export function classifyDocument(ext, mime) {
40
40
  return 'unsupported'
41
41
  }
42
42
 
43
- export function mediaKindOf(path) {
44
- const ext = extname(path).toLowerCase()
45
- if (IMAGE_EXT_TO_MIME[ext]) return 'photo'
46
- if (['.ogg', '.opus', '.oga'].includes(ext)) return 'voice'
47
- if (['.mp3', '.m4a', '.wav', '.flac'].includes(ext)) return 'audio'
48
- return 'document'
49
- }
50
-
51
43
  export function safeName(name) {
52
44
  return String(name || 'file').replace(/[^\w.\- ]/g, '_').slice(0, 120) || 'file'
53
45
  }
@@ -65,4 +57,4 @@ export function cacheName(prefix, ext, name) {
65
57
  return display ? `${base}-${display}${ext || ''}` : `${base}${ext || ''}`
66
58
  }
67
59
 
68
- export { basename }
60
+ export { basename }
package/lib/models.js CHANGED
@@ -20,11 +20,14 @@ export async function listModelCatalog(ctx, fallback = {}) {
20
20
  pId,
21
21
  (models || []).map((m) => ({ id: m.id || m, name: m.name || m.id || m }))
22
22
  )
23
- } catch {
23
+ } catch (err) {
24
+ /* safe fallback to empty list when provider cannot list models */
24
25
  result.modelsByProvider.set(pId, [])
25
26
  }
26
27
  }
27
- } catch {}
28
+ } catch (err) {
29
+ /* safe fallback when ctx.llm.listProviders is unavailable or throws */
30
+ }
28
31
  }
29
32
 
30
33
  // 2. If no providers from ctx.llm, check settings / fallback
@@ -117,4 +120,4 @@ export function buildModelsKeyboard(providerId, models, currentModel, page = 0,
117
120
  rows.push([{ text: '🔙 Back to providers', callback_data: 'mdl:back' }])
118
121
 
119
122
  return { inline_keyboard: rows, page: curPage, totalPages }
120
- }
123
+ }
package/lib/outbound.js CHANGED
@@ -110,4 +110,4 @@ export async function buildOutboundFiles(ctx, baseUrl, collector, opts = {}) {
110
110
  }
111
111
  }
112
112
  return files
113
- }
113
+ }
package/lib/pairing.js CHANGED
@@ -19,25 +19,29 @@ function writeJsonAtomicSync(filePath, data) {
19
19
  writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8')
20
20
  renameSync(tmpPath, filePath)
21
21
  } catch (err) {
22
- try { unlinkSync(tmpPath) } catch {}
22
+ try { unlinkSync(tmpPath) } catch { /* safe best-effort temp file cleanup */ }
23
23
  throw err
24
24
  }
25
25
  }
26
26
 
27
- export function createPairingStore(filePath) {
27
+ export function createPairingStore(filePath, { logger } = {}) {
28
28
  const state = { pending: {}, /** @type {number[]} */ approved: [] }
29
29
  if (filePath && existsSync(filePath)) {
30
30
  try {
31
31
  const raw = JSON.parse(readFileSync(filePath, 'utf8'))
32
32
  if (Array.isArray(raw.approved)) state.approved = raw.approved.map(Number).filter(Number.isFinite)
33
33
  if (raw.pending && typeof raw.pending === 'object') state.pending = raw.pending
34
- } catch {}
34
+ } catch (err) {
35
+ logger?.warn?.('[dsh-messenger-gateway] pairing store read error:', err?.message || err)
36
+ }
35
37
  }
36
38
  const persist = () => {
37
39
  if (!filePath) return
38
40
  try {
39
41
  writeJsonAtomicSync(filePath, { approved: state.approved, pending: state.pending })
40
- } catch {}
42
+ } catch (err) {
43
+ logger?.warn?.('[dsh-messenger-gateway] pairing store persist error:', err?.message || err)
44
+ }
41
45
  }
42
46
  const prune = () => {
43
47
  const now = Date.now()
package/lib/personas.js CHANGED
@@ -70,7 +70,7 @@ export function listPersonas() {
70
70
  return Object.values(BUILTIN_PERSONAS)
71
71
  }
72
72
 
73
- export function createPersonaStore(filePath) {
73
+ export function createPersonaStore(filePath, { logger } = {}) {
74
74
  let cache = { personas: {}, presets: {} }
75
75
  try {
76
76
  const raw = JSON.parse(readFileSync(filePath, 'utf8'))
@@ -81,13 +81,17 @@ export function createPersonaStore(filePath) {
81
81
  cache = { personas: raw, presets: {} }
82
82
  }
83
83
  }
84
- } catch {}
84
+ } catch (err) {
85
+ if (err?.code !== 'ENOENT') logger?.warn?.('[dsh-messenger-gateway] personas load error:', err?.message || err)
86
+ }
85
87
 
86
88
  function save() {
87
89
  try {
88
90
  mkdirSync(dirname(filePath), { recursive: true })
89
91
  writeFileSync(filePath, JSON.stringify(cache, null, 2), 'utf8')
90
- } catch {}
92
+ } catch (err) {
93
+ logger?.warn?.('[dsh-messenger-gateway] personas save error:', err?.message || err)
94
+ }
91
95
  }
92
96
 
93
97
  function makeKey(chatId, threadId = 0) {
@@ -148,4 +152,4 @@ export function createPersonaStore(filePath) {
148
152
  return { ...cache }
149
153
  },
150
154
  }
151
- }
155
+ }
package/lib/photos.js CHANGED
@@ -28,4 +28,4 @@ export function photoOnlyHint(attachments, userText) {
28
28
  if (!photos.length) return ''
29
29
  if (photos.length === 1) return '[User attached a photo]'
30
30
  return `[User attached ${photos.length} photos]`
31
- }
31
+ }
package/lib/scheduler.js CHANGED
@@ -33,7 +33,8 @@ export function formatRemaining(ms, locale = 'en') {
33
33
  return remMin ? `${hr}h ${remMin}m` : `${hr}h`
34
34
  }
35
35
 
36
- export function createScheduler(filePath, onDue) {
36
+ export function createScheduler(filePath, onDue, options = {}) {
37
+ const logger = options?.logger || console
37
38
  let tasks = []
38
39
  let loaded = false
39
40
  let timer = null
@@ -42,7 +43,10 @@ export function createScheduler(filePath, onDue) {
42
43
  try {
43
44
  const raw = await readFile(filePath, 'utf8')
44
45
  tasks = JSON.parse(raw)
45
- } catch {
46
+ } catch (err) {
47
+ if (err?.code !== 'ENOENT') {
48
+ logger?.debug?.('[dsh-messenger-gateway] scheduler load error:', err?.message || err)
49
+ }
46
50
  tasks = []
47
51
  }
48
52
  loaded = true
@@ -60,7 +64,9 @@ export function createScheduler(filePath, onDue) {
60
64
  return (now - finishTime) < RETENTION_MS
61
65
  })
62
66
  await writeJsonAtomic(filePath, tasks)
63
- } catch {}
67
+ } catch (err) {
68
+ logger?.warn?.('[dsh-messenger-gateway] scheduler save failed:', err?.message || err)
69
+ }
64
70
  }
65
71
 
66
72
  async function checkDue() {
@@ -77,7 +83,11 @@ export function createScheduler(filePath, onDue) {
77
83
  task.firedAt = now
78
84
  }
79
85
  if (typeof onDue === 'function') {
80
- try { await onDue(task) } catch {}
86
+ try {
87
+ await onDue(task)
88
+ } catch (err) {
89
+ logger?.warn?.('[dsh-messenger-gateway] scheduler task execution failed:', err?.message || err)
90
+ }
81
91
  }
82
92
  }
83
93
 
package/lib/stream.js CHANGED
@@ -1,116 +1,116 @@
1
- /** Helpers for Telegram streaming edits + progress. */
2
-
3
- export function extractTextDelta(chunk) {
4
- if (!chunk || typeof chunk !== 'object') return ''
5
- if (chunk.type === 'text-delta' && typeof chunk.text === 'string') return chunk.text
6
- return ''
7
- }
8
-
9
- export function extractToolName(eventData) {
10
- const name = eventData?.tool?.name || eventData?.toolName || eventData?.name
11
- return name ? String(name) : ''
12
- }
13
-
14
- export function formatProgressLine(toolName, locale = 'en') {
15
- if (!toolName) {
16
- return String(locale || '').toLowerCase().startsWith('zh') ? '⏳ 思考中…' : '⏳ Thinking…'
17
- }
18
- return `🔧 ${toolName}…`
19
- }
20
-
21
- export function buildStreamPreview(streamText, toolName, maxLen = 3500) {
22
- const body = String(streamText || '').trimEnd()
23
- const head = toolName ? `${formatProgressLine(toolName)}\n\n` : ''
24
- const combined = head + (body || '…')
25
- if (combined.length <= maxLen) return combined
26
- return combined.slice(0, maxLen - 1) + '…'
27
- }
28
-
29
- export function parseRetryAfter(err) {
30
- if (!err) return 0
31
- const msg = err.message || String(err)
32
- const match = /retry after (\d+)/i.exec(msg)
33
- if (match) return parseInt(match[1], 10) * 1000
34
- return 0
35
- }
36
-
37
- export function createEditScheduler(editFn, intervalMs = 1200, firstChunkDelayMs = 250) {
38
- let pending = null
39
- let timer = null
40
- let lastSent = ''
41
- let inFlight = Promise.resolve()
42
- let retryDelayMs = 0
43
-
44
- const flush = () => {
45
- timer = null
46
- if (pending === null || pending === lastSent) return
47
- const text = pending
48
- inFlight = inFlight.then(async () => {
49
- try {
50
- if (retryDelayMs > 0) {
51
- const delay = retryDelayMs
52
- retryDelayMs = 0
53
- await new Promise((r) => setTimeout(r, delay))
54
- }
55
- await editFn(text)
56
- lastSent = text
57
- } catch (err) {
58
- const retry = parseRetryAfter(err)
59
- if (retry > 0) {
60
- retryDelayMs = retry
61
- // Re-schedule flush with retry delay
62
- if (!timer) {
63
- timer = setTimeout(flush, retry + 100)
64
- timer.unref?.()
65
- }
66
- }
67
- }
68
- })
69
- }
70
-
71
- return {
72
- push(text) {
73
- pending = text
74
- if (timer) return
75
- // Fast first chunk: when lastSent is empty, dispatch quickly so user sees immediate response.
76
- // Subsequent edits throttle to intervalMs to respect Telegram rate limits.
77
- const delay = !lastSent ? Math.min(firstChunkDelayMs, intervalMs) : Math.max(200, intervalMs)
78
- timer = setTimeout(flush, delay)
79
- timer.unref?.()
80
- },
81
- async flush() {
82
- if (timer) { clearTimeout(timer); timer = null }
83
- flush()
84
- await inFlight
85
- // If there's still a pending difference (e.g. rate limit delay happened), wait and retry
86
- if (pending !== null && pending !== lastSent) {
87
- if (retryDelayMs > 0) {
88
- await new Promise((r) => setTimeout(r, retryDelayMs + 50))
89
- retryDelayMs = 0
90
- }
91
- try {
92
- await editFn(pending)
93
- lastSent = pending
94
- } catch {}
95
- }
96
- },
97
- }
98
- }
99
-
100
- export function startTypingHeartbeat(typingFn, intervalMs = 4000) {
101
- if (typeof typingFn !== 'function') return () => {}
102
- let active = true
103
- const safeInvoke = () => {
104
- if (!active) return
105
- try {
106
- typingFn()?.catch?.(() => {})
107
- } catch {}
108
- }
109
- safeInvoke()
110
- const timer = setInterval(safeInvoke, intervalMs)
111
- timer.unref?.()
112
- return () => {
113
- active = false
114
- clearInterval(timer)
115
- }
1
+ /** Helpers for Telegram streaming edits + progress. */
2
+
3
+ export function extractTextDelta(chunk) {
4
+ if (!chunk || typeof chunk !== 'object') return ''
5
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') return chunk.text
6
+ return ''
7
+ }
8
+
9
+ export function extractToolName(eventData) {
10
+ const name = eventData?.tool?.name || eventData?.toolName || eventData?.name
11
+ return name ? String(name) : ''
12
+ }
13
+
14
+ export function formatProgressLine(toolName, locale = 'en') {
15
+ if (!toolName) {
16
+ return String(locale || '').toLowerCase().startsWith('zh') ? '⏳ 思考中…' : '⏳ Thinking…'
17
+ }
18
+ return `🔧 ${toolName}…`
19
+ }
20
+
21
+ export function buildStreamPreview(streamText, toolName, maxLen = 3500) {
22
+ const body = String(streamText || '').trimEnd()
23
+ const head = toolName ? `${formatProgressLine(toolName)}\n\n` : ''
24
+ const combined = head + (body || '…')
25
+ if (combined.length <= maxLen) return combined
26
+ return combined.slice(0, maxLen - 1) + '…'
27
+ }
28
+
29
+ export function parseRetryAfter(err) {
30
+ if (!err) return 0
31
+ const msg = err.message || String(err)
32
+ const match = /retry after (\d+)/i.exec(msg)
33
+ if (match) return parseInt(match[1], 10) * 1000
34
+ return 0
35
+ }
36
+
37
+ export function createEditScheduler(editFn, intervalMs = 1200, firstChunkDelayMs = 250) {
38
+ let pending = null
39
+ let timer = null
40
+ let lastSent = ''
41
+ let inFlight = Promise.resolve()
42
+ let retryDelayMs = 0
43
+
44
+ const flush = () => {
45
+ timer = null
46
+ if (pending === null || pending === lastSent) return
47
+ const text = pending
48
+ inFlight = inFlight.then(async () => {
49
+ try {
50
+ if (retryDelayMs > 0) {
51
+ const delay = retryDelayMs
52
+ retryDelayMs = 0
53
+ await new Promise((r) => setTimeout(r, delay))
54
+ }
55
+ await editFn(text)
56
+ lastSent = text
57
+ } catch (err) {
58
+ const retry = parseRetryAfter(err)
59
+ if (retry > 0) {
60
+ retryDelayMs = retry
61
+ // Re-schedule flush with retry delay
62
+ if (!timer) {
63
+ timer = setTimeout(flush, retry + 100)
64
+ timer.unref?.()
65
+ }
66
+ }
67
+ }
68
+ })
69
+ }
70
+
71
+ return {
72
+ push(text) {
73
+ pending = text
74
+ if (timer) return
75
+ // Fast first chunk: when lastSent is empty, dispatch quickly so user sees immediate response.
76
+ // Subsequent edits throttle to intervalMs to respect Telegram rate limits.
77
+ const delay = !lastSent ? Math.min(firstChunkDelayMs, intervalMs) : Math.max(200, intervalMs)
78
+ timer = setTimeout(flush, delay)
79
+ timer.unref?.()
80
+ },
81
+ async flush() {
82
+ if (timer) { clearTimeout(timer); timer = null }
83
+ flush()
84
+ await inFlight
85
+ // If there's still a pending difference (e.g. rate limit delay happened), wait and retry
86
+ if (pending !== null && pending !== lastSent) {
87
+ if (retryDelayMs > 0) {
88
+ await new Promise((r) => setTimeout(r, retryDelayMs + 50))
89
+ retryDelayMs = 0
90
+ }
91
+ try {
92
+ await editFn(pending)
93
+ lastSent = pending
94
+ } catch { /* safe best-effort final stream update delivery */ }
95
+ }
96
+ },
97
+ }
98
+ }
99
+
100
+ export function startTypingHeartbeat(typingFn, intervalMs = 4000) {
101
+ if (typeof typingFn !== 'function') return () => {}
102
+ let active = true
103
+ const safeInvoke = () => {
104
+ if (!active) return
105
+ try {
106
+ typingFn()?.catch?.(() => { /* safe best-effort typing indicator */ })
107
+ } catch { /* safe best-effort typing indicator */ }
108
+ }
109
+ safeInvoke()
110
+ const timer = setInterval(safeInvoke, intervalMs)
111
+ timer.unref?.()
112
+ return () => {
113
+ active = false
114
+ clearInterval(timer)
115
+ }
116
116
  }