@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/http.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+
1
3
  export async function readBody(req) {
2
4
  const chunks = []
3
5
  for await (const chunk of req) chunks.push(chunk)
@@ -15,4 +17,13 @@ export function isTrustedSettingsRequest(req) {
15
17
  const host = String(req.headers?.host || '')
16
18
  if (!origin || !host) return false
17
19
  try { return new URL(origin).host === host } catch { return false }
18
- }
20
+ }
21
+
22
+ export function timingSafeCompare(a, b) {
23
+ if (typeof a !== 'string' || typeof b !== 'string') return false
24
+ const bufA = Buffer.from(a, 'utf8')
25
+ const bufB = Buffer.from(b, 'utf8')
26
+ if (bufA.length !== bufB.length) return false
27
+ return timingSafeEqual(bufA, bufB)
28
+ }
29
+
package/lib/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { homedir } from 'node:os'
2
- import { join } from 'node:path'
2
+ import { join, dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
3
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
5
  import { Gateway } from './gateway.js'
5
- import { readBody, writeJson, isTrustedSettingsRequest } from './http.js'
6
+ import { readBody, writeJson, isTrustedSettingsRequest, timingSafeCompare } from './http.js'
7
+ import { registerPluginUpdater } from './updater.js'
6
8
  import {
7
9
  createMessengerService, dispatchMessenger, httpStatusForError,
8
10
  messengerApiSchema, parseMessengerBody,
@@ -10,7 +12,7 @@ import {
10
12
  import { listHomes } from './homes.js'
11
13
  import { Config } from './config.js'
12
14
 
13
- export const name = 'dsh-messenger-gateway'
15
+ export const name = '@goodandready/dsh-messenger-gateway'
14
16
  export const inject = ['agentDefaultModel', 'agents', 'sessions', 'loader', 'settings', 'webServer', 'attachments', 'tools']
15
17
 
16
18
  export const SETTINGS_NAMESPACE = 'dsh-messenger-gateway'
@@ -63,7 +65,16 @@ function publicConfig(cfg) {
63
65
  forumMirrorEnabled: Boolean(cfg.telegram.forumMirrorEnabled),
64
66
  forumMirrorChatId: cfg.telegram.forumMirrorChatId || '',
65
67
  },
66
- discord: { enabled: cfg.discord.enabled, botTokenConfigured: Boolean(String(cfg.discord.botToken || '').trim()) },
68
+ discord: {
69
+ enabled: Boolean(cfg.discord?.enabled),
70
+ botTokenConfigured: Boolean(String(cfg.discord?.botToken || '').trim()),
71
+ webhookConfigured: Boolean(String(cfg.discord?.webhookUrl || '').trim()),
72
+ },
73
+ slack: {
74
+ enabled: Boolean(cfg.slack?.enabled),
75
+ botTokenConfigured: Boolean(String(cfg.slack?.botToken || '').trim()),
76
+ webhookConfigured: Boolean(String(cfg.slack?.webhookUrl || '').trim()),
77
+ },
67
78
  media: { maxDocBytes: cfg.media.maxDocBytes, maxTextInjectBytes: cfg.media.maxTextInjectBytes, maxImageBytes: cfg.media.maxImageBytes },
68
79
  tts: cfg.tts,
69
80
  agent: {
@@ -251,7 +262,13 @@ export function apply(ctx, config) {
251
262
  const cfg = source().telegram?.notifyBridge
252
263
  if (!cfg?.enabled) return
253
264
  if (event.type === 'turn/start') {
254
- turnStarts.set(String(session.id), Date.now())
265
+ const now = Date.now()
266
+ if (turnStarts.size > 50) {
267
+ for (const [sid, ts] of turnStarts.entries()) {
268
+ if (now - ts > 7_200_000) turnStarts.delete(sid)
269
+ }
270
+ }
271
+ turnStarts.set(String(session.id), now)
255
272
  return
256
273
  }
257
274
  if (event.type !== 'turn/end') return
@@ -289,6 +306,7 @@ export function apply(ctx, config) {
289
306
  const gw = getGw()
290
307
  const botInfo = gw?.getBotInfo?.() || {}
291
308
  const uptimeSec = gw?.stats?.startedAt ? Math.max(0, Math.round((Date.now() - gw.stats.startedAt) / 1000)) : 0
309
+ const scheduledCount = gw?.scheduler?.list ? (await gw.scheduler.list())?.length || 0 : 0
292
310
  writeJson(res, 200, {
293
311
  ok: true,
294
312
  running: Boolean(gw),
@@ -307,6 +325,12 @@ export function apply(ctx, config) {
307
325
  sent: gw?.stats?.sent || 0,
308
326
  errors: gw?.stats?.errors || 0,
309
327
  startedAt: gw?.stats?.startedAt || 0,
328
+ scheduledCount,
329
+ },
330
+ apiHealth: {
331
+ degraded: (gw?.consecutiveApiFailures || 0) >= 3,
332
+ consecutiveFailures: gw?.consecutiveApiFailures || 0,
333
+ lastError: gw?.lastApiError || null,
310
334
  },
311
335
  config: publicConfig(source()),
312
336
  })
@@ -400,10 +424,16 @@ export function apply(ctx, config) {
400
424
  if (payload.telegram?.notifyBridge) {
401
425
  nextTg.notifyBridge = { ...cur.telegram.notifyBridge, ...payload.telegram.notifyBridge }
402
426
  }
427
+ const nextDiscord = { ...(cur.discord || {}), ...(payload.discord || {}) }
428
+ if (payload.discord && !String(payload.discord.botToken || '').trim()) nextDiscord.botToken = cur.discord?.botToken
429
+ const nextSlack = { ...(cur.slack || {}), ...(payload.slack || {}) }
430
+ if (payload.slack && !String(payload.slack.botToken || '').trim()) nextSlack.botToken = cur.slack?.botToken
403
431
  await settingsApi.replace(Config({
404
432
  ...cur,
405
433
  ...payload,
406
434
  telegram: nextTg,
435
+ discord: nextDiscord,
436
+ slack: nextSlack,
407
437
  tts: { ...cur.tts, ...(payload.tts || {}) },
408
438
  agent: { ...cur.agent, ...(payload.agent || {}) },
409
439
  media: { ...cur.media, ...(payload.media || {}) },
@@ -471,9 +501,12 @@ export function apply(ctx, config) {
471
501
  const cfg = source().telegram || {}
472
502
  if (cfg.transport !== 'webhook') return writeJson(res, 404, { ok: false, error: 'webhook transport disabled' })
473
503
  const secret = String(cfg.webhookSecret || '').trim()
474
- if (secret) {
475
- const hdr = String(req.headers['x-telegram-bot-api-secret-token'] || '')
476
- if (hdr !== secret) return writeJson(res, 403, { ok: false, error: 'bad secret' })
504
+ if (!secret) {
505
+ return writeJson(res, 403, { ok: false, error: 'webhook secret not configured' })
506
+ }
507
+ const hdr = String(req.headers['x-telegram-bot-api-secret-token'] || '')
508
+ if (!timingSafeCompare(hdr, secret)) {
509
+ return writeJson(res, 403, { ok: false, error: 'bad secret' })
477
510
  }
478
511
  const gw = getGw()
479
512
  const adapter = gw?.getAdapter?.('telegram')
@@ -495,6 +528,13 @@ export function apply(ctx, config) {
495
528
  registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/progress', 'progress')
496
529
  registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/ask', 'ask')
497
530
 
531
+ const manifestPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json')
532
+ ctx.effect(() => registerPluginUpdater(ctx, {
533
+ manifestPath,
534
+ packageName: '@goodandready/dsh-messenger-gateway',
535
+ endpoint: '/dsh-messenger-gateway/update',
536
+ }), 'dsh-messenger-gateway: plugin updater')
537
+
498
538
  ctx.on('dispose', () => { if (gateway) gateway.stop() })
499
539
  sync()
500
540
  }
@@ -37,4 +37,4 @@ export async function speakText(baseUrl, text, signal) {
37
37
  mime: data.mime || 'audio/mpeg',
38
38
  provider: data.provider,
39
39
  }
40
- }
40
+ }
package/lib/locales/en.js CHANGED
@@ -1,114 +1,123 @@
1
- // Canonical English locale dictionary
2
- export const en = {
3
- // Command descriptions
4
- 'cmd.help': 'Show commands help',
5
- 'cmd.new': 'Start new session',
6
- 'cmd.stop': 'Interrupt current turn',
7
- 'cmd.model': 'Interactive model selector',
8
- 'cmd.role': 'Switch agent persona or role',
9
- 'cmd.rewind': 'Rewind last N turns',
10
- 'cmd.fork': 'Fork session into new branch',
11
- 'cmd.export': 'Export history to Markdown',
12
- 'cmd.skills': 'List active tools & skills',
13
- 'cmd.files': 'Workspace file explorer',
14
- 'cmd.get': 'Download file from workspace',
15
- 'cmd.remind': 'Set a timer or reminder',
16
- 'cmd.status': 'Gateway and model status',
17
- 'cmd.top': 'System resources (RAM, uptime)',
18
- 'cmd.keyboard': 'Toggle quick action buttons',
19
- 'cmd.voice': 'Voice reply mode',
20
- 'cmd.tts': 'Toggle speech in this chat',
21
- 'cmd.mute': 'Mute notifications in this chat',
22
- 'cmd.unmute': 'Unmute notifications in this chat',
23
- 'cmd.whoami': 'Show your messenger user ID',
24
- 'cmd.pair': 'Approve pairing code',
25
- 'cmd.sethome': 'Set primary notification home',
26
- 'cmd.setalert': 'Set alert notification target',
27
- 'cmd.bind': 'Bind preset or persona to forum topic',
28
- 'cmd.preset': 'Show or switch agent preset',
29
- 'cmd.cron': 'Schedule recurring autonomous report',
30
-
31
- // Bot replies & statuses
32
- 'msg.start': 'Gateway connected. Send messages to the agent. /help for commands.',
33
- 'msg.steer_added': '↪️ Added to current turn',
34
- 'msg.turn_stopped': 'Turn interrupted.',
35
- 'msg.no_active_session': 'No active session found.',
36
- 'msg.unknown_command': 'Unknown command {cmd}. /help',
37
- 'msg.not_allowed': 'Access denied. Ask the owner to whitelist your ID.',
38
- 'msg.pairing_requested': 'Access required.\nYour ID: {userId}\nPairing code: {code}\n\nThe owner must send to bot:\n/pair {code}',
39
- 'msg.pairing_rate_limit': 'Pairing code already issued. Please wait or ask owner for /pair.',
40
- 'msg.pairing_approved': '✅ User {userId} successfully approved!',
41
- 'msg.pairing_invalid': 'Invalid or expired pairing code.',
42
- 'msg.muted_on': 'Notifications in this chat: muted (/unmute)',
43
- 'msg.muted_off': 'Notifications in this chat: enabled',
44
- 'msg.no_response': '(no response)',
45
- 'msg.agent_error': 'Agent error: {code}: {message}',
46
- 'msg.exception': 'Internal error: {message}',
47
-
48
- // Photo & media
49
- 'photo.received_one': 'Photo received. Send a question — e.g. "what is in this photo?"',
50
- 'photo.received_many': 'Received {count} attachments. Send your question.',
51
- 'photo.hint_photo': '[Photo attached]',
52
- 'photo.hint_photos': '[{count} photos attached]',
53
- 'doc.hint': '[Document attached: {name}]',
54
-
55
- // Interactive buttons & ask
56
- 'ask.confirm_title': '⚠️ Confirmation Required\nTool: <code>{tool}</code>{reason}',
57
- 'ask.allow_once': '✅ Allow Once',
58
- 'ask.allow_session': '🛡️ Allow for Session',
59
- 'ask.deny': '❌ Deny',
60
- 'ask.chose': 'Chose: {choice}',
61
- 'ask.done': 'Done',
62
- 'ask.cancel': 'Cancel',
63
- 'ask.canceled': 'Canceled',
64
- 'ask.expired': 'Interaction expired. Please try again.',
65
- 'ask.other_chat': 'Button belongs to another chat',
66
-
67
- // Model selector
68
- 'model.title': '🤖 <b>Choose Provider:</b>',
69
- 'model.current': 'Current: <code>{current}</code>',
70
- 'model.provider_models': '🤖 <b>Provider:</b> <code>{provider}</code>\nSelect model (page {page}/{total}):',
71
- 'model.no_models': 'No models available for this provider',
72
- 'model.switched': '✅ Model successfully switched to: <b>{provider}/{model}</b>',
73
-
74
- // Personas & Presets
75
- 'persona.title': '🎭 <b>Available Roles & Personas:</b>',
76
- 'persona.usage': 'Switch role: <code>/role coder</code> (or /role reset)',
77
- 'persona.reset': '🎭 Role reset to default (Default).',
78
- 'persona.switched': '🎭 Role changed to: {icon} <b>{name}</b>\n{description}',
79
- 'persona.unknown': 'Unknown role "{target}". List: /role list',
80
- 'persona.bound_topic': '📌 Topic bound to {kind}: <b>{name}</b>',
81
-
82
- // Builtin persona descriptions
83
- 'persona.default.name': 'Default',
84
- 'persona.default.desc': 'General assistant, concise and balanced',
85
- 'persona.coder.name': 'Developer',
86
- 'persona.coder.desc': 'Senior developer, code focus without fluff',
87
- 'persona.writer.name': 'Editor',
88
- 'persona.writer.desc': 'Copywriter, clean text and clear tone',
89
- 'persona.analyst.name': 'Analyst',
90
- 'persona.analyst.desc': 'Structured decomposition and data analysis',
91
- 'persona.concise.name': 'Brief',
92
- 'persona.concise.desc': 'Ultra-short replies, 1-2 sentences',
93
-
94
- // File explorer & export
95
- 'files.title': '📁 <b>Workspace:</b> <code>{path}</code>\nTotal files: {count}\n\n',
96
- 'files.empty': '📁 Directory is empty: <code>{path}</code>',
97
- 'files.download_hint': '\nDownload file: <code>/get &lt;path&gt;</code>',
98
- 'files.not_found': 'File not found: {path}',
99
- 'export.title': '📄 Dialog export ({count} messages):',
100
- 'export.empty': 'Session is empty, nothing to export.',
101
-
102
- // Reminders & Cron
103
- 'remind.scheduled': '⏰ Reminder set for {time} (in {duration}):\n{text}',
104
- 'remind.invalid': 'Invalid time format. Example: /remind 10m check deploy',
105
- 'remind.prefix': '⏰ <b>[Reminder]</b>\n{text}',
106
- 'cron.scheduled': '⏱️ Autonomous cron task scheduled: <code>{id}</code>\nSchedule: <code>{schedule}</code>\nPrompt: {prompt}',
107
- 'cron.list_title': '⏱️ <b>Scheduled Cron Tasks:</b>\n',
108
- 'cron.none': 'No active cron tasks.',
109
- 'cron.cancelled': '✅ Cron task cancelled: <code>{id}</code>',
110
- 'cron.prefix': '⏱️ <b>[Scheduled Report: {id}]</b>\n',
111
-
112
- // Mirror & Forum
113
- 'mirror.created': '🪞 <b>[DSH Session Mirror]</b>\nSession: <code>{sessionId}</code>\nTitle: <b>{title}</b>\nMessages in this topic are synchronized with DSH.',
114
- }
1
+ // Canonical English locale dictionary
2
+ export const en = {
3
+ // Command descriptions
4
+ 'cmd.help': 'Show commands help',
5
+ 'cmd.new': 'Start new session',
6
+ 'cmd.stop': 'Interrupt current turn',
7
+ 'cmd.model': 'Interactive model selector',
8
+ 'cmd.role': 'Switch agent persona or role',
9
+ 'cmd.rewind': 'Rewind last N turns',
10
+ 'cmd.fork': 'Fork session into new branch',
11
+ 'cmd.export': 'Export history to Markdown',
12
+ 'cmd.skills': 'List active tools & skills',
13
+ 'cmd.files': 'Workspace file explorer',
14
+ 'cmd.get': 'Download file from workspace',
15
+ 'cmd.remind': 'Set a timer or reminder',
16
+ 'cmd.status': 'Gateway and model status',
17
+ 'cmd.top': 'System resources (RAM, uptime)',
18
+ 'cmd.keyboard': 'Toggle quick action buttons',
19
+ 'cmd.voice': 'Voice reply mode',
20
+ 'cmd.tts': 'Toggle speech in this chat',
21
+ 'cmd.mute': 'Mute notifications in this chat',
22
+ 'cmd.unmute': 'Unmute notifications in this chat',
23
+ 'cmd.whoami': 'Show your messenger user ID',
24
+ 'cmd.pair': 'Approve pairing code',
25
+ 'cmd.sethome': 'Set primary notification home',
26
+ 'cmd.setalert': 'Set alert notification target',
27
+ 'cmd.bind': 'Bind preset or persona to forum topic',
28
+ 'cmd.preset': 'Show or switch agent preset',
29
+ 'cmd.cron': 'Schedule recurring autonomous report',
30
+
31
+ // Bot replies & statuses
32
+ 'msg.start': 'Gateway connected. Send messages to the agent. /help for commands.',
33
+ 'msg.steer_added': '↪️ Added to current turn',
34
+ 'msg.turn_stopped': 'Turn interrupted.',
35
+ 'msg.no_active_session': 'No active session found.',
36
+ 'msg.unknown_command': 'Unknown command {cmd}. /help',
37
+ 'msg.not_allowed': 'Access denied. Ask the owner to whitelist your ID.',
38
+ 'msg.pairing_requested': 'Access required.\nYour ID: {userId}\nPairing code: {code}\n\nThe owner must send to bot:\n/pair {code}',
39
+ 'msg.pairing_rate_limit': 'Pairing code already issued. Please wait or ask owner for /pair.',
40
+ 'msg.pairing_approved': '✅ User {userId} successfully approved!',
41
+ 'msg.pairing_invalid': 'Invalid or expired pairing code.',
42
+ 'msg.muted_on': 'Notifications in this chat: muted (/unmute)',
43
+ 'msg.muted_off': 'Notifications in this chat: enabled',
44
+ 'msg.no_response': '(no response)',
45
+ 'msg.agent_error': 'Agent error: {code}: {message}',
46
+ 'msg.exception': 'Internal error: {message}',
47
+
48
+ // Photo & media
49
+ 'photo.received_one': 'Photo received. Send a question — e.g. "what is in this photo?"',
50
+ 'photo.received_many': 'Received {count} attachments. Send your question.',
51
+ 'photo.hint_photo': '[Photo attached]',
52
+ 'photo.hint_photos': '[{count} photos attached]',
53
+ 'doc.hint': '[Document attached: {name}]',
54
+
55
+ // Interactive buttons & ask
56
+ 'ask.confirm_title': '⚠️ Confirmation Required\nTool: <code>{tool}</code>{reason}',
57
+ 'ask.allow_once': '✅ Allow Once',
58
+ 'ask.allow_session': '🛡️ Allow for Session',
59
+ 'ask.deny': '❌ Deny',
60
+ 'ask.chose': 'Chose: {choice}',
61
+ 'ask.done': 'Done',
62
+ 'ask.cancel': 'Cancel',
63
+ 'ask.canceled': 'Canceled',
64
+ 'ask.expired': 'Interaction expired. Please try again.',
65
+ 'ask.other_chat': 'Button belongs to another chat',
66
+
67
+ // Model selector
68
+ 'model.title': '🤖 <b>Choose Provider:</b>',
69
+ 'model.current': 'Current: <code>{current}</code>',
70
+ 'model.provider_models': '🤖 <b>Provider:</b> <code>{provider}</code>\nSelect model (page {page}/{total}):',
71
+ 'model.no_models': 'No models available for this provider',
72
+ 'model.switched': '✅ Model successfully switched to: <b>{provider}/{model}</b>',
73
+
74
+ // Personas & Presets
75
+ 'persona.title': '🎭 <b>Available Roles & Personas:</b>',
76
+ 'persona.usage': 'Switch role: <code>/role coder</code> (or /role reset)',
77
+ 'persona.reset': '🎭 Role reset to default (Default).',
78
+ 'persona.switched': '🎭 Role changed to: {icon} <b>{name}</b>\n{description}',
79
+ 'persona.unknown': 'Unknown role "{target}". List: /role list',
80
+ 'persona.bound_topic': '📌 Topic bound to {kind}: <b>{name}</b>',
81
+
82
+ // Builtin persona descriptions
83
+ 'persona.default.name': 'Default',
84
+ 'persona.default.desc': 'General assistant, concise and balanced',
85
+ 'persona.coder.name': 'Developer',
86
+ 'persona.coder.desc': 'Senior developer, code focus without fluff',
87
+ 'persona.writer.name': 'Editor',
88
+ 'persona.writer.desc': 'Copywriter, clean text and clear tone',
89
+ 'persona.analyst.name': 'Analyst',
90
+ 'persona.analyst.desc': 'Structured decomposition and data analysis',
91
+ 'persona.concise.name': 'Brief',
92
+ 'persona.concise.desc': 'Ultra-short replies, 1-2 sentences',
93
+
94
+ // File explorer & export
95
+ 'files.title': '📁 <b>Workspace:</b> <code>{path}</code>\nTotal files: {count}\n\n',
96
+ 'files.empty': '📁 Directory is empty: <code>{path}</code>',
97
+ 'files.download_hint': '\nDownload file: <code>/get &lt;path&gt;</code>',
98
+ 'files.not_found': 'File not found: {path}',
99
+ 'export.title': '📄 Dialog export ({count} messages):',
100
+ 'export.empty': 'Session is empty, nothing to export.',
101
+
102
+ // Reminders & Cron
103
+ 'remind.scheduled': '⏰ Reminder set for {time} (in {duration}):\n{text}',
104
+ 'remind.invalid': 'Invalid time format. Example: /remind 10m check deploy',
105
+ 'remind.prefix': '⏰ <b>[Reminder]</b>\n{text}',
106
+ 'cron.scheduled': '⏱️ Autonomous cron task scheduled: <code>{id}</code>\nSchedule: <code>{schedule}</code>\nPrompt: {prompt}',
107
+ 'cron.list_title': '⏱️ <b>Scheduled Cron Tasks:</b>\n',
108
+ 'cron.none': 'No active cron tasks.',
109
+ 'cron.cancelled': '✅ Cron task cancelled: <code>{id}</code>',
110
+ 'cron.prefix': '⏱️ <b>[Scheduled Report: {id}]</b>\n',
111
+
112
+ // Mirror & Forum
113
+ 'mirror.created': '🪞 <b>[DSH Session Mirror]</b>\nSession: <code>{sessionId}</code>\nTitle: <b>{title}</b>\nMessages in this topic are synchronized with DSH.',
114
+ // Updates & Maintenance
115
+ 'cmd.update': 'Check or install plugin update',
116
+ 'update.installing': '⏳ <b>Updating plugin…</b>\nRunning DSH package installation in background…',
117
+ 'update.already_latest': '✅ Plugin is already up to date (<code>v{version}</code>).',
118
+ 'update.success': '🎉 <b>Successfully updated to v{version}!</b>\nPlease restart the service to apply changes: <code>sudo systemctl restart dsh-web.service</code>',
119
+ 'update.failed': '❌ <b>Update failed:</b> {error}',
120
+ 'update.available': '📦 <b>Update Available!</b>\nCurrent version: <code>v{current}</code>\nLatest release: <code>v{latest}</code>\n\nRun <code>/update now</code> to install.',
121
+ 'update.current': '✅ <b>Messenger Gateway is up to date.</b>\nCurrent version: <code>v{version}</code>.',
122
+ 'update.check_failed': '⚠️ Failed to check for updates: {error}',
123
+ }
@@ -1,21 +1,21 @@
1
- import { en } from './en.js'
2
- import { zh } from './zh.js'
3
-
4
- export const locales = { en, zh }
5
-
6
- export function getLocaleDictionary(locale = 'en') {
7
- const norm = String(locale || '').toLowerCase()
8
- if (norm.startsWith('zh')) return zh
9
- return en
10
- }
11
-
12
- export function t(key, params = {}, locale = 'en') {
13
- const dict = getLocaleDictionary(locale)
14
- let template = dict[key] || en[key] || key
15
- for (const [k, v] of Object.entries(params)) {
16
- template = template.replaceAll(`{${k}}`, String(v))
17
- }
18
- return template
19
- }
20
-
21
- export { en, zh }
1
+ import { en } from './en.js'
2
+ import { zh } from './zh.js'
3
+
4
+ export const locales = { en, zh }
5
+
6
+ export function getLocaleDictionary(locale = 'en') {
7
+ const norm = String(locale || '').toLowerCase()
8
+ if (norm.startsWith('zh')) return zh
9
+ return en
10
+ }
11
+
12
+ export function t(key, params = {}, locale = 'en') {
13
+ const dict = getLocaleDictionary(locale)
14
+ let template = dict[key] || en[key] || key
15
+ for (const [k, v] of Object.entries(params)) {
16
+ template = template.replaceAll(`{${k}}`, String(v))
17
+ }
18
+ return template
19
+ }
20
+
21
+ export { en, zh }