dsh-line-bot 0.9.7 → 0.10.1

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/host.mjs CHANGED
@@ -29,6 +29,7 @@ const stickersMod = await import(withVersion('./stickers.mjs'))
29
29
  const memeSourceMod = await import(withVersion('./meme-source.mjs'))
30
30
  const stickerImageMod = await import(withVersion('./sticker-image.mjs'))
31
31
  const visionMod = await import(withVersion('./vision.mjs'))
32
+ const ttsMod = await import(withVersion('./tts.mjs'))
32
33
  const utilMod = await import(withVersion('./util.mjs'))
33
34
  const { createConfigStore, publicConfig, dataDir, dshHome } = configMod
34
35
  const { createPersonaClient } = personaMod
@@ -47,9 +48,10 @@ const { createStickerStore } = stickersMod
47
48
  const { createMemeSource } = memeSourceMod
48
49
  const { createStickerImages } = stickerImageMod
49
50
  const { createVision } = visionMod
51
+ const { createTts } = ttsMod
50
52
  const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep, readJsonSafe } = utilMod
51
53
 
52
- export const VERSION = '0.9.7'
54
+ export const VERSION = '0.10.1'
53
55
 
54
56
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
55
57
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -401,7 +403,77 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
401
403
  const jokeText = jokes.contextText(() => store.get())
402
404
  // 群組不注入貼吧作息,避免在群組裡突然講起刷貼吧的事。
403
405
  const tieba = kind === 'user' ? tiebaContextText() : ''
404
- return [modelContextText(), jokeText, tieba, balanceContextText(), extra, memoryContextText(ownerKey), memeContextText() || stickerContextText()].filter(Boolean).join('\n\n')
406
+ return [modelContextText(), voiceContextText(), jokeText, tieba, balanceContextText(), extra, memoryContextText(ownerKey), memeContextText() || stickerContextText()].filter(Boolean).join('\n\n')
407
+ }
408
+
409
+ const VOICE_MARKER = /\[\[\s*(?:voice|語音|语音|audio)\s*(?:[::]\s*([^\]\n]{0,200}))?\s*\]\]/gi
410
+ let voiceCache = { at: 0, value: null }
411
+
412
+ function voiceStatusCached() {
413
+ if (!voiceCache.value || Date.now() - voiceCache.at > 10000) {
414
+ try {
415
+ voiceCache = { at: Date.now(), value: tts.status() }
416
+ } catch (error) {
417
+ voiceCache = { at: Date.now(), value: { ready: false, lastError: errorText(error) } }
418
+ }
419
+ }
420
+ return voiceCache.value
421
+ }
422
+
423
+ function voiceReady(cfg = store.get()) {
424
+ if (cfg.voice?.enabled !== true || cfg.voice?.consent !== true) return false
425
+ const st = voiceStatusCached()
426
+ return st?.ready === true && Boolean(st?.reference?.path)
427
+ }
428
+
429
+ function voiceContextText() {
430
+ const cfg = store.get()
431
+ if (cfg.voice?.enabled !== true || cfg.voice?.consent !== true) return ''
432
+ if (!voiceReady(cfg)) return ''
433
+ const max = Math.max(20, Math.min(300, Number(cfg.voice?.maxChars) || 80))
434
+ return [
435
+ '【語音】你可以用語音回覆。',
436
+ `想用時把 [[voice]] 放在回覆最後;如果要指定語氣,寫成 [[voice:用溫柔、帶點撒嬌的語氣,語速慢一點]]。`,
437
+ `語氣、情緒、語速、停頓這些「怎麼說」的指示只能放在標記裡;正文只放要念出來的字,不要把語氣描述、括號動作或舞台指示寫進正文(最多約 ${max} 字)。`,
438
+ '一次最多一則語音;不適合用語音(例如內容太長、需要圖片)就不要加標記。',
439
+ ].join('\n')
440
+ }
441
+
442
+ function splitVoiceMarker(text) {
443
+ let requested = false
444
+ let instruct = ''
445
+ const cleaned = String(text || '')
446
+ .replace(VOICE_MARKER, (_match, tone) => {
447
+ requested = true
448
+ if (tone && String(tone).trim()) instruct = String(tone).trim().slice(0, 200)
449
+ return ''
450
+ })
451
+ .trim()
452
+ return { text: cleaned, requested, instruct }
453
+ }
454
+
455
+ async function synthesizeVoiceMessage(text, instruct = '') {
456
+ const base = publicBaseSync()
457
+ if (!base) throw new Error('目前沒有公開網址,語音訊息送不出去')
458
+ const result = await tts.synthesize(text, { instructText: instruct })
459
+ const token = registerFile(result.m4a, 'audio/mp4')
460
+ if (!token) throw new Error('無法把語音檔存進媒體庫')
461
+ return {
462
+ message: { type: 'audio', originalContentUrl: `${base}/media/${token}`, duration: Math.max(1000, Math.round(result.durationMs || 0)) },
463
+ token,
464
+ provider: result.provider,
465
+ durationMs: result.durationMs,
466
+ }
467
+ }
468
+
469
+ async function replyMessages(userId, replyToken, messages) {
470
+ if (!Array.isArray(messages) || messages.length === 0) return
471
+ try {
472
+ await withToken((token) => lineApi.replyMessage(token, replyToken, messages))
473
+ } catch (error) {
474
+ logger.warn(`reply 失敗,改用 push:${errorText(error)}`)
475
+ await withToken((token) => lineApi.pushMessage(token, userId, messages))
476
+ }
405
477
  }
406
478
 
407
479
  async function refreshBalance() {
@@ -1426,6 +1498,25 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1426
1498
  return token
1427
1499
  }
1428
1500
 
1501
+ function registerFile(filePath, mime, ext = '') {
1502
+ if (!filePath || !fs.existsSync(filePath)) return ''
1503
+ const token = `m${randomBytes(6).toString('hex')}`
1504
+ const suffix = ext || path.extname(String(filePath || '')).toLowerCase() || '.bin'
1505
+ const filename = `${token}${suffix}`
1506
+ const target = path.join(mediaDir, filename)
1507
+ try {
1508
+ fs.copyFileSync(filePath, target)
1509
+ } catch (error) {
1510
+ logger.warn(`複製媒體失敗:${error?.message || error}`)
1511
+ return ''
1512
+ }
1513
+ const item = { file: filename, mime: mime || 'application/octet-stream', at: Date.now(), bytes: fs.statSync(target).size }
1514
+ mediaTokens.set(token, item)
1515
+ mediaIndex[token] = item
1516
+ saveMediaIndex()
1517
+ return token
1518
+ }
1519
+
1429
1520
  function getMedia(id) {
1430
1521
  const key = String(id || '')
1431
1522
  if (!key) return null
@@ -1804,10 +1895,28 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1804
1895
  cwd: ensureUserWorkspace(conversationKey) || cfg.agent.cwd || undefined,
1805
1896
  })
1806
1897
  let replyText = String(result?.text || '').trim()
1898
+ const voiceDirective = splitVoiceMarker(replyText)
1899
+ replyText = voiceDirective.text
1807
1900
  const parsedReply = splitStickerMarkers(replyText)
1808
- if (!parsedReply.text && parsedReply.names.length === 0) replyText = cfg.line.emptyReply || '(她沒有說話)'
1809
- await replyOrPush(chatId, event.replyToken, replyText)
1810
- runtime.lastReply = { chatId, kind: sourceKind, at: Date.now(), text: clip(parsedReply.text || (parsedReply.names.length ? `[表情包] ${parsedReply.names.join('、')}` : replyText), 300) }
1901
+ let voiceSent = null
1902
+ if (voiceDirective.requested && voiceReady(cfg)) {
1903
+ const spoken = parsedReply.text || ''
1904
+ if (spoken) {
1905
+ try {
1906
+ voiceSent = await synthesizeVoiceMessage(spoken, voiceDirective.instruct)
1907
+ logger.info(`語音回覆:${spoken.length} 字,${Math.round((voiceSent.durationMs || 0) / 1000)} 秒(${voiceSent.provider};語氣:${voiceDirective.instruct || '預設'})`)
1908
+ } catch (error) {
1909
+ logger.warn(`語音合成失敗,改用文字:${errorText(error)}`)
1910
+ }
1911
+ }
1912
+ }
1913
+ if (voiceSent) {
1914
+ await replyMessages(chatId, event.replyToken, [voiceSent.message])
1915
+ } else {
1916
+ if (!parsedReply.text && parsedReply.names.length === 0) replyText = cfg.line.emptyReply || '(她沒有說話)'
1917
+ await replyOrPush(chatId, event.replyToken, replyText)
1918
+ }
1919
+ runtime.lastReply = { chatId, kind: sourceKind, at: Date.now(), text: clip(voiceSent ? `[語音] ${parsedReply.text || replyText}` : parsedReply.text || (parsedReply.names.length ? `[表情包] ${parsedReply.names.join('、')}` : replyText), 300) }
1811
1920
  store.updateState((state) => {
1812
1921
  const map = state[bucketName] || {}
1813
1922
  const target = map[conversationKey]
@@ -1834,6 +1943,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1834
1943
  const memes = createMemeSource({ ctx, logger })
1835
1944
  const stickerImages = createStickerImages({ ctx, logger, dir })
1836
1945
  const vision = createVision({ logger, cacheDir: path.join(dir, 'cache') })
1946
+ const tts = createTts({ dir, logger, getConfig: () => store.get() })
1837
1947
  const mediaTokens = new Map()
1838
1948
  let imageSupportCache = { at: 0, key: '', value: false }
1839
1949
 
@@ -1949,7 +2059,9 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1949
2059
  })
1950
2060
 
1951
2061
  async function sendPush(userId, text) {
1952
- const { messages } = await buildOutgoingMessages(text)
2062
+ const voiceDirective = splitVoiceMarker(text)
2063
+ if (voiceDirective.requested) logger.info('主動訊息中的語音標記已忽略,改用文字')
2064
+ const { messages } = await buildOutgoingMessages(voiceDirective.text)
1953
2065
  if (messages.length === 0) return { sent: false, reason: 'empty' }
1954
2066
  const imageCount = messages.filter((item) => typeof item !== 'string' && item.type === 'image').length
1955
2067
  if (imageCount > 0) logger.info(`主動訊息含 ${imageCount} 張圖片`)
@@ -2388,6 +2500,18 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2388
2500
  imageVision: cfg.line?.imageVision !== false,
2389
2501
  stickerVision: cfg.line?.stickerVision !== false,
2390
2502
  },
2503
+ voice: {
2504
+ ...voiceStatusCached(),
2505
+ enabled: cfg.voice?.enabled === true,
2506
+ consent: cfg.voice?.consent === true,
2507
+ provider: cfg.voice?.provider || 'auto',
2508
+ cloneMode: cfg.voice?.cloneMode || 'instruct',
2509
+ instructText: cfg.voice?.instructText || '',
2510
+ maxChars: Number(cfg.voice?.maxChars) || 80,
2511
+ referencePath: cfg.voice?.referencePath || '',
2512
+ ready: voiceReady(cfg),
2513
+ publicBase: publicBaseSync(),
2514
+ },
2391
2515
  memory: {
2392
2516
  ...memory.stats(),
2393
2517
  enabled: cfg.memory?.enabled !== false,
@@ -2676,6 +2800,53 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2676
2800
  const built = await buildOutgoingMessages(String(payload.text))
2677
2801
  return { messages: built.messages, parsed: built.parsed }
2678
2802
  }
2803
+ case 'voice-detect':
2804
+ return tts.detect()
2805
+ case 'voice-install': {
2806
+ if (tts.status().installing) return { started: false, already: true }
2807
+ tts.install()
2808
+ .then((result) => {
2809
+ if (result?.provider) store.patch({ voice: { provider: result.provider } }, { silent: true })
2810
+ logger.info(`語音環境安裝完成(${result?.provider || 'unknown'})`)
2811
+ voiceCache = { at: 0, value: null }
2812
+ })
2813
+ .catch((error) => logger.error(`語音環境安裝失敗:${errorText(error)}`))
2814
+ return { started: true }
2815
+ }
2816
+ case 'voice-preview':
2817
+ case 'voice-test': {
2818
+ const text = String(payload.text || '主人,這是語音測試,聽得到嗎?').trim()
2819
+ if (!text) throw new Error('缺少文字')
2820
+ const result = await tts.synthesize(text)
2821
+ const token = registerFile(result.m4a, 'audio/mp4')
2822
+ const base = publicBaseSync()
2823
+ voiceCache = { at: 0, value: null }
2824
+ return {
2825
+ provider: result.provider,
2826
+ durationMs: result.durationMs,
2827
+ bytes: result.bytes,
2828
+ chars: result.chars,
2829
+ processingMs: result.processingMs,
2830
+ url: base ? `${base}/media/${token}` : '',
2831
+ localUrl: `/api/line-bot/media/${token}`,
2832
+ token,
2833
+ }
2834
+ }
2835
+ case 'voice-save-reference': {
2836
+ const dataUrl = String(payload.dataUrl || '')
2837
+ const match = /^data:([^;]+);base64,([\s\S]+)$/.exec(dataUrl)
2838
+ if (!match) throw new Error('缺少音檔資料')
2839
+ const buffer = Buffer.from(match[2], 'base64')
2840
+ if (buffer.length > 20 * 1024 * 1024) throw new Error('音檔超過 20MB')
2841
+ const saved = await tts.saveReferenceBuffer(buffer, String(payload.filename || 'reference'), String(payload.ext || '.wav'))
2842
+ store.patch({ voice: { referencePath: saved.path } }, { silent: true })
2843
+ voiceCache = { at: 0, value: null }
2844
+ return { path: saved.path, seconds: saved.seconds }
2845
+ }
2846
+ case 'voice-reset-reference':
2847
+ store.patch({ voice: { referencePath: '' } }, { silent: true })
2848
+ voiceCache = { at: 0, value: null }
2849
+ return { path: tts.referencePath() }
2679
2850
  case 'meme-send': {
2680
2851
  const userId = String(payload.userId || '').trim()
2681
2852
  if (!userId) throw new Error('缺少 userId')