dsh-line-bot 0.5.2 → 0.5.4
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/CHANGELOG.md +11 -0
- package/client.js +3 -0
- package/lib/config.mjs +3 -0
- package/lib/host.mjs +103 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.4
|
|
4
|
+
|
|
5
|
+
- LINE 不會播放 APNG 動畫(只顯示第一幀),所以 GIF 預設改成靜態 PNG,檔案更小、載入更穩。
|
|
6
|
+
- 面板「表情包」卡新增「GIF 保留 APNG 動畫」開關(預設關閉);打開才會送 APNG。
|
|
7
|
+
|
|
8
|
+
## 0.5.3
|
|
9
|
+
|
|
10
|
+
- 表情包改用 Flex Message(hero 圖片+文字),GIF 轉成 APNG 保留動畫;APNG 過大或轉檔失敗才退回靜態 PNG。
|
|
11
|
+
- 送圖前先確認公開網址可用,避免 tunnel 剛換網址時送出抓不到的圖。
|
|
12
|
+
- 新增 `meme-send` 診斷 action,可直接送一則指定文字+表情包到某個使用者。
|
|
13
|
+
|
|
3
14
|
## 0.5.2
|
|
4
15
|
|
|
5
16
|
- 重新發佈 0.5.1 的內容:主動傳訊(含低餘額提醒)可在句子後面加 `[[meme:#編號]]` 配表情包。
|
package/client.js
CHANGED
|
@@ -298,6 +298,8 @@ window.__ModuleLoader__.load({
|
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
// ── 主面板 ─────────────────────────────────────────────────────────────
|
|
301
|
+
const memoCfgApng = (cfgValue) => cfgValue?.apng === true
|
|
302
|
+
|
|
301
303
|
const ACTION_LABELS = {
|
|
302
304
|
'meme-stats': '讀取表情包圖庫',
|
|
303
305
|
start: '啟動',
|
|
@@ -1151,6 +1153,7 @@ window.__ModuleLoader__.load({
|
|
|
1151
1153
|
),
|
|
1152
1154
|
h(Toggle, { checked: memeCfg.enabled !== false, onChange: (value) => update({ stickers: { enabled: value } }), label: '允許角色使用表情包', hint: '關掉後只回文字' }),
|
|
1153
1155
|
h(Field, { label: '一次最多幾張', hint: 'LINE 一次回覆最多 5 則訊息,圖片也算在內' }, h(Num, { value: memeCfg.maxPerReply, min: 1, max: 3, step: 1, onChange: (value) => update({ stickers: { maxPerReply: value } }) })),
|
|
1156
|
+
h(Toggle, { checked: memoCfgApng(memeCfg), onChange: (value) => update({ stickers: { apng: value } }), label: 'GIF 保留 APNG 動畫', hint: 'LINE 目前不會播放 APNG,只會顯示第一幀;開啟會讓檔案變大' }),
|
|
1154
1157
|
h('div', { style: S.row },
|
|
1155
1158
|
h(Btn, { onClick: () => act('meme-stats'), disabled: !!busy }, busy === 'meme-stats' ? '讀取中…' : '重新讀取圖庫'),
|
|
1156
1159
|
),
|
package/lib/config.mjs
CHANGED
|
@@ -115,6 +115,8 @@ export const DEFAULT_CONFIG = {
|
|
|
115
115
|
// 角色可以用圖片/表情包回話;圖片由面板上傳,存在 <dataDir>/stickers。
|
|
116
116
|
enabled: true,
|
|
117
117
|
maxPerReply: 1,
|
|
118
|
+
// LINE 的圖片訊息不會播放 APNG 動畫,預設只送第一幀(靜態 PNG);要保留 APNG 檔可打開。
|
|
119
|
+
apng: false,
|
|
118
120
|
},
|
|
119
121
|
log: {
|
|
120
122
|
maxLines: 400,
|
|
@@ -240,6 +242,7 @@ export function sanitizePatch(current, patch) {
|
|
|
240
242
|
const stickers = next.stickers || (next.stickers = {})
|
|
241
243
|
stickers.enabled = Boolean(stickers.enabled)
|
|
242
244
|
stickers.maxPerReply = clampInt(stickers.maxPerReply, 1, 3, 1)
|
|
245
|
+
stickers.apng = Boolean(stickers.apng)
|
|
243
246
|
|
|
244
247
|
const nudge = next.nudge
|
|
245
248
|
nudge.enabled = Boolean(nudge.enabled)
|
package/lib/host.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { randomBytes, randomUUID, createHash } from 'node:crypto'
|
|
6
|
-
import { execFileSync } from 'node:child_process'
|
|
6
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
7
7
|
// 用 ?v= 載入:dev/reload 重新 import host.mjs 時,這整組模組也會跟著重新求值,
|
|
8
8
|
// 不用重開 DSH 就能套用 lib/*.mjs 的修改。
|
|
9
9
|
const LOAD_VERSION = new URL(import.meta.url).searchParams.get('v') || ''
|
|
@@ -37,9 +37,9 @@ const { createBalanceMonitor } = balanceMod
|
|
|
37
37
|
const { createMemoryStore } = memoryMod
|
|
38
38
|
const { createStickerStore } = stickersMod
|
|
39
39
|
const { createMemeSource } = memeSourceMod
|
|
40
|
-
const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray } = utilMod
|
|
40
|
+
const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep } = utilMod
|
|
41
41
|
|
|
42
|
-
export const VERSION = '0.5.
|
|
42
|
+
export const VERSION = '0.5.4'
|
|
43
43
|
|
|
44
44
|
// 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
|
|
45
45
|
// 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
|
|
@@ -902,7 +902,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
902
902
|
].filter(Boolean).join('\n')
|
|
903
903
|
}
|
|
904
904
|
|
|
905
|
-
function
|
|
905
|
+
function convertToPng(filePath) {
|
|
906
906
|
if (process.platform !== 'darwin') return ''
|
|
907
907
|
try {
|
|
908
908
|
const cacheDir = path.join(dir, 'cache')
|
|
@@ -920,15 +920,49 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
920
920
|
}
|
|
921
921
|
}
|
|
922
922
|
|
|
923
|
+
function convertGifToApng(filePath) {
|
|
924
|
+
try {
|
|
925
|
+
const cacheDir = path.join(dir, 'cache')
|
|
926
|
+
fs.mkdirSync(cacheDir, { recursive: true })
|
|
927
|
+
const stat = fs.statSync(filePath)
|
|
928
|
+
const key = createHash('sha1').update(`apng:${filePath}:${stat.mtimeMs}`).digest('hex').slice(0, 16)
|
|
929
|
+
const dest = path.join(cacheDir, `${key}.apng.png`)
|
|
930
|
+
if (!fs.existsSync(dest)) {
|
|
931
|
+
const result = spawnSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-i', filePath, '-plays', '0', '-f', 'apng', dest], { timeout: 25000 })
|
|
932
|
+
if (result.status !== 0 || !fs.existsSync(dest)) {
|
|
933
|
+
logger.warn(`轉換 GIF 成 APNG 失敗:${String(result.stderr || '').slice(0, 200)}`)
|
|
934
|
+
return ''
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const size = fs.statSync(dest).size
|
|
938
|
+
if (size > 8 * 1024 * 1024) {
|
|
939
|
+
logger.warn(`APNG 太大(${Math.round(size / 1024)} KB),改用靜態 PNG`)
|
|
940
|
+
return ''
|
|
941
|
+
}
|
|
942
|
+
const head = fs.readFileSync(dest).subarray(0, 200)
|
|
943
|
+
if (!head.includes(Buffer.from('acTL'))) return ''
|
|
944
|
+
return dest
|
|
945
|
+
} catch (error) {
|
|
946
|
+
logger.warn(`轉換 GIF 成 APNG 失敗:${errorText(error)}`)
|
|
947
|
+
return ''
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
923
951
|
function registerImageFile(filePath) {
|
|
924
952
|
const ext = path.extname(String(filePath || '')).toLowerCase()
|
|
925
953
|
let serve = filePath
|
|
926
954
|
let mime = ''
|
|
927
955
|
if (ext === '.jpg' || ext === '.jpeg') mime = 'image/jpeg'
|
|
928
956
|
else if (ext === '.png') mime = 'image/png'
|
|
957
|
+
else if (ext === '.gif') {
|
|
958
|
+
mime = 'image/png'
|
|
959
|
+
serve = store.get().stickers?.apng === true
|
|
960
|
+
? (convertGifToApng(filePath) || convertToPng(filePath))
|
|
961
|
+
: convertToPng(filePath)
|
|
962
|
+
}
|
|
929
963
|
else if (ext === '.webp') {
|
|
930
964
|
mime = 'image/png'
|
|
931
|
-
serve =
|
|
965
|
+
serve = convertToPng(filePath)
|
|
932
966
|
} else {
|
|
933
967
|
return ''
|
|
934
968
|
}
|
|
@@ -960,12 +994,57 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
960
994
|
return entry && bytes ? { mime: entry.mime, bytes } : null
|
|
961
995
|
}
|
|
962
996
|
|
|
997
|
+
let publicReady = { base: '', at: 0 }
|
|
998
|
+
|
|
999
|
+
async function publicBaseReady() {
|
|
1000
|
+
const base = publicBaseSync()
|
|
1001
|
+
if (!base) return ''
|
|
1002
|
+
if (publicReady.base === base && Date.now() - publicReady.at < 60 * 1000) return base
|
|
1003
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1004
|
+
try {
|
|
1005
|
+
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(2500) })
|
|
1006
|
+
if (res.ok) {
|
|
1007
|
+
publicReady = { base, at: Date.now() }
|
|
1008
|
+
return base
|
|
1009
|
+
}
|
|
1010
|
+
} catch {
|
|
1011
|
+
/* 還沒準備好,再試一次 */
|
|
1012
|
+
}
|
|
1013
|
+
await sleep(1200)
|
|
1014
|
+
}
|
|
1015
|
+
logger.warn(`公開網址尚未就緒,這次先不附圖:${base}`)
|
|
1016
|
+
return ''
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function buildFlexMessage(text, urls) {
|
|
1020
|
+
const bubbles = urls.map((url, index) => {
|
|
1021
|
+
const bubble = {
|
|
1022
|
+
type: 'bubble',
|
|
1023
|
+
hero: { type: 'image', url, size: 'full', aspectRatio: '1:1', aspectMode: 'cover' },
|
|
1024
|
+
}
|
|
1025
|
+
if (index === 0 && text) {
|
|
1026
|
+
bubble.body = {
|
|
1027
|
+
type: 'box',
|
|
1028
|
+
layout: 'vertical',
|
|
1029
|
+
spacing: 'sm',
|
|
1030
|
+
contents: [{ type: 'text', text: String(text).slice(0, 1800), wrap: true, size: 'sm' }],
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return bubble
|
|
1034
|
+
})
|
|
1035
|
+
return {
|
|
1036
|
+
type: 'flex',
|
|
1037
|
+
altText: (text || '表情包').slice(0, 400),
|
|
1038
|
+
contents: bubbles.length === 1 ? bubbles[0] : { type: 'carousel', contents: bubbles },
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
963
1042
|
async function buildOutgoingMessages(text) {
|
|
964
1043
|
const cfg = store.get()
|
|
965
1044
|
const parsed = splitStickerMarkers(text)
|
|
966
|
-
const messages = []
|
|
967
1045
|
const max = memeMaxPerReply()
|
|
968
|
-
const base =
|
|
1046
|
+
const base = parsed.memes.length > 0 || parsed.names.length > 0 ? await publicBaseReady() : ''
|
|
1047
|
+
const imageUrls = []
|
|
969
1048
|
if (cfg.stickers?.enabled !== false && base && parsed.memes.length > 0 && memes.available()) {
|
|
970
1049
|
const sample = memes.sample({ limit: 20 })
|
|
971
1050
|
for (const desc of parsed.memes.slice(0, max)) {
|
|
@@ -989,8 +1068,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
989
1068
|
logger.warn(`表情包格式不支援(LINE 只收 JPEG/PNG):${file}`)
|
|
990
1069
|
continue
|
|
991
1070
|
}
|
|
992
|
-
|
|
993
|
-
messages.push({ type: 'image', originalContentUrl: url, previewImageUrl: url })
|
|
1071
|
+
imageUrls.push(`${base}/media/${token}`)
|
|
994
1072
|
}
|
|
995
1073
|
}
|
|
996
1074
|
if (cfg.stickers?.enabled !== false && base && parsed.names.length > 0) {
|
|
@@ -1000,11 +1078,13 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1000
1078
|
logger.warn(`找不到表情包:${name}`)
|
|
1001
1079
|
continue
|
|
1002
1080
|
}
|
|
1003
|
-
|
|
1004
|
-
messages.push({ type: 'image', originalContentUrl: url, previewImageUrl: url })
|
|
1081
|
+
imageUrls.push(`${base}/media/${encodeURIComponent(entry.id)}`)
|
|
1005
1082
|
}
|
|
1006
1083
|
}
|
|
1007
|
-
|
|
1084
|
+
const messages = []
|
|
1085
|
+
if (imageUrls.length > 0) {
|
|
1086
|
+
messages.push(buildFlexMessage(parsed.text, imageUrls))
|
|
1087
|
+
} else if (parsed.text) {
|
|
1008
1088
|
for (const chunk of chunkText(parsed.text)) {
|
|
1009
1089
|
if (messages.length >= 5) break
|
|
1010
1090
|
messages.push(chunk)
|
|
@@ -1016,6 +1096,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1016
1096
|
async function replyOrPush(userId, replyToken, texts) {
|
|
1017
1097
|
const { messages } = await buildOutgoingMessages(texts)
|
|
1018
1098
|
if (messages.length === 0) return
|
|
1099
|
+
const imageCount = messages.filter((item) => typeof item !== 'string' && item.type === 'image').length
|
|
1100
|
+
if (imageCount > 0) logger.info(`回覆含 ${imageCount} 張圖片`)
|
|
1019
1101
|
try {
|
|
1020
1102
|
await withToken((token) => lineApi.replyMessage(token, replyToken, messages))
|
|
1021
1103
|
} catch (error) {
|
|
@@ -1142,7 +1224,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1142
1224
|
async function sendPush(userId, text) {
|
|
1143
1225
|
const { messages } = await buildOutgoingMessages(text)
|
|
1144
1226
|
if (messages.length === 0) return
|
|
1145
|
-
|
|
1227
|
+
const imageCount = messages.filter((item) => typeof item !== 'string' && item.type === 'image').length
|
|
1228
|
+
if (imageCount > 0) logger.info(`主動訊息含 ${imageCount} 張圖片`)
|
|
1146
1229
|
store.updateState((state) => ({
|
|
1147
1230
|
...state,
|
|
1148
1231
|
users: {
|
|
@@ -1550,6 +1633,13 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1550
1633
|
const built = await buildOutgoingMessages(String(payload.text))
|
|
1551
1634
|
return { messages: built.messages, parsed: built.parsed }
|
|
1552
1635
|
}
|
|
1636
|
+
case 'meme-send': {
|
|
1637
|
+
const userId = String(payload.userId || '').trim()
|
|
1638
|
+
if (!userId) throw new Error('缺少 userId')
|
|
1639
|
+
if (!payload.text) throw new Error('缺少 text')
|
|
1640
|
+
await sendPush(userId, String(payload.text))
|
|
1641
|
+
return { sent: true, userId, text: String(payload.text) }
|
|
1642
|
+
}
|
|
1553
1643
|
case 'sticker-list':
|
|
1554
1644
|
return { ...stickers.stats(), items: stickers.list(), publicBase: publicBaseSync() }
|
|
1555
1645
|
case 'sticker-add': {
|