dsh-line-bot 0.5.2 → 0.5.3
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 +6 -0
- package/lib/host.mjs +101 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.3
|
|
4
|
+
|
|
5
|
+
- 表情包改用 Flex Message(hero 圖片+文字),GIF 轉成 APNG 保留動畫;APNG 過大或轉檔失敗才退回靜態 PNG。
|
|
6
|
+
- 送圖前先確認公開網址可用,避免 tunnel 剛換網址時送出抓不到的圖。
|
|
7
|
+
- 新增 `meme-send` 診斷 action,可直接送一則指定文字+表情包到某個使用者。
|
|
8
|
+
|
|
3
9
|
## 0.5.2
|
|
4
10
|
|
|
5
11
|
- 重新發佈 0.5.1 的內容:主動傳訊(含低餘額提醒)可在句子後面加 `[[meme:#編號]]` 配表情包。
|
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.3'
|
|
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,47 @@ 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 = convertGifToApng(filePath) || convertToPng(filePath)
|
|
960
|
+
}
|
|
929
961
|
else if (ext === '.webp') {
|
|
930
962
|
mime = 'image/png'
|
|
931
|
-
serve =
|
|
963
|
+
serve = convertToPng(filePath)
|
|
932
964
|
} else {
|
|
933
965
|
return ''
|
|
934
966
|
}
|
|
@@ -960,12 +992,57 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
960
992
|
return entry && bytes ? { mime: entry.mime, bytes } : null
|
|
961
993
|
}
|
|
962
994
|
|
|
995
|
+
let publicReady = { base: '', at: 0 }
|
|
996
|
+
|
|
997
|
+
async function publicBaseReady() {
|
|
998
|
+
const base = publicBaseSync()
|
|
999
|
+
if (!base) return ''
|
|
1000
|
+
if (publicReady.base === base && Date.now() - publicReady.at < 60 * 1000) return base
|
|
1001
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1002
|
+
try {
|
|
1003
|
+
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(2500) })
|
|
1004
|
+
if (res.ok) {
|
|
1005
|
+
publicReady = { base, at: Date.now() }
|
|
1006
|
+
return base
|
|
1007
|
+
}
|
|
1008
|
+
} catch {
|
|
1009
|
+
/* 還沒準備好,再試一次 */
|
|
1010
|
+
}
|
|
1011
|
+
await sleep(1200)
|
|
1012
|
+
}
|
|
1013
|
+
logger.warn(`公開網址尚未就緒,這次先不附圖:${base}`)
|
|
1014
|
+
return ''
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function buildFlexMessage(text, urls) {
|
|
1018
|
+
const bubbles = urls.map((url, index) => {
|
|
1019
|
+
const bubble = {
|
|
1020
|
+
type: 'bubble',
|
|
1021
|
+
hero: { type: 'image', url, size: 'full', aspectRatio: '1:1', aspectMode: 'cover' },
|
|
1022
|
+
}
|
|
1023
|
+
if (index === 0 && text) {
|
|
1024
|
+
bubble.body = {
|
|
1025
|
+
type: 'box',
|
|
1026
|
+
layout: 'vertical',
|
|
1027
|
+
spacing: 'sm',
|
|
1028
|
+
contents: [{ type: 'text', text: String(text).slice(0, 1800), wrap: true, size: 'sm' }],
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return bubble
|
|
1032
|
+
})
|
|
1033
|
+
return {
|
|
1034
|
+
type: 'flex',
|
|
1035
|
+
altText: (text || '表情包').slice(0, 400),
|
|
1036
|
+
contents: bubbles.length === 1 ? bubbles[0] : { type: 'carousel', contents: bubbles },
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
963
1040
|
async function buildOutgoingMessages(text) {
|
|
964
1041
|
const cfg = store.get()
|
|
965
1042
|
const parsed = splitStickerMarkers(text)
|
|
966
|
-
const messages = []
|
|
967
1043
|
const max = memeMaxPerReply()
|
|
968
|
-
const base =
|
|
1044
|
+
const base = parsed.memes.length > 0 || parsed.names.length > 0 ? await publicBaseReady() : ''
|
|
1045
|
+
const imageUrls = []
|
|
969
1046
|
if (cfg.stickers?.enabled !== false && base && parsed.memes.length > 0 && memes.available()) {
|
|
970
1047
|
const sample = memes.sample({ limit: 20 })
|
|
971
1048
|
for (const desc of parsed.memes.slice(0, max)) {
|
|
@@ -989,8 +1066,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
989
1066
|
logger.warn(`表情包格式不支援(LINE 只收 JPEG/PNG):${file}`)
|
|
990
1067
|
continue
|
|
991
1068
|
}
|
|
992
|
-
|
|
993
|
-
messages.push({ type: 'image', originalContentUrl: url, previewImageUrl: url })
|
|
1069
|
+
imageUrls.push(`${base}/media/${token}`)
|
|
994
1070
|
}
|
|
995
1071
|
}
|
|
996
1072
|
if (cfg.stickers?.enabled !== false && base && parsed.names.length > 0) {
|
|
@@ -1000,11 +1076,13 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1000
1076
|
logger.warn(`找不到表情包:${name}`)
|
|
1001
1077
|
continue
|
|
1002
1078
|
}
|
|
1003
|
-
|
|
1004
|
-
messages.push({ type: 'image', originalContentUrl: url, previewImageUrl: url })
|
|
1079
|
+
imageUrls.push(`${base}/media/${encodeURIComponent(entry.id)}`)
|
|
1005
1080
|
}
|
|
1006
1081
|
}
|
|
1007
|
-
|
|
1082
|
+
const messages = []
|
|
1083
|
+
if (imageUrls.length > 0) {
|
|
1084
|
+
messages.push(buildFlexMessage(parsed.text, imageUrls))
|
|
1085
|
+
} else if (parsed.text) {
|
|
1008
1086
|
for (const chunk of chunkText(parsed.text)) {
|
|
1009
1087
|
if (messages.length >= 5) break
|
|
1010
1088
|
messages.push(chunk)
|
|
@@ -1016,6 +1094,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1016
1094
|
async function replyOrPush(userId, replyToken, texts) {
|
|
1017
1095
|
const { messages } = await buildOutgoingMessages(texts)
|
|
1018
1096
|
if (messages.length === 0) return
|
|
1097
|
+
const imageCount = messages.filter((item) => typeof item !== 'string' && item.type === 'image').length
|
|
1098
|
+
if (imageCount > 0) logger.info(`回覆含 ${imageCount} 張圖片`)
|
|
1019
1099
|
try {
|
|
1020
1100
|
await withToken((token) => lineApi.replyMessage(token, replyToken, messages))
|
|
1021
1101
|
} catch (error) {
|
|
@@ -1142,7 +1222,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1142
1222
|
async function sendPush(userId, text) {
|
|
1143
1223
|
const { messages } = await buildOutgoingMessages(text)
|
|
1144
1224
|
if (messages.length === 0) return
|
|
1145
|
-
|
|
1225
|
+
const imageCount = messages.filter((item) => typeof item !== 'string' && item.type === 'image').length
|
|
1226
|
+
if (imageCount > 0) logger.info(`主動訊息含 ${imageCount} 張圖片`)
|
|
1146
1227
|
store.updateState((state) => ({
|
|
1147
1228
|
...state,
|
|
1148
1229
|
users: {
|
|
@@ -1550,6 +1631,13 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1550
1631
|
const built = await buildOutgoingMessages(String(payload.text))
|
|
1551
1632
|
return { messages: built.messages, parsed: built.parsed }
|
|
1552
1633
|
}
|
|
1634
|
+
case 'meme-send': {
|
|
1635
|
+
const userId = String(payload.userId || '').trim()
|
|
1636
|
+
if (!userId) throw new Error('缺少 userId')
|
|
1637
|
+
if (!payload.text) throw new Error('缺少 text')
|
|
1638
|
+
await sendPush(userId, String(payload.text))
|
|
1639
|
+
return { sent: true, userId, text: String(payload.text) }
|
|
1640
|
+
}
|
|
1553
1641
|
case 'sticker-list':
|
|
1554
1642
|
return { ...stickers.stats(), items: stickers.list(), publicBase: publicBaseSync() }
|
|
1555
1643
|
case 'sticker-add': {
|