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/AGENTS.md +2 -0
- package/CHANGELOG.md +13 -0
- package/README.en.md +11 -1
- package/README.ja.md +13 -1
- package/README.ko.md +13 -1
- package/README.md +11 -1
- package/README.zh-CN.md +11 -1
- package/TROUBLESHOOTING.md +9 -0
- package/client.js +131 -2
- package/lib/config.mjs +26 -0
- package/lib/host.mjs +177 -6
- package/lib/tts.mjs +566 -0
- package/package.json +3 -2
- package/tools/mlx_synth.py +60 -0
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.
|
|
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
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
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
|
|
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')
|