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 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.2'
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 convertWebpToPng(filePath) {
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 = convertWebpToPng(filePath)
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 = publicBaseSync()
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
- const url = `${base}/media/${token}`
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
- const url = `${base}/media/${encodeURIComponent(entry.id)}`
1004
- messages.push({ type: 'image', originalContentUrl: url, previewImageUrl: url })
1081
+ imageUrls.push(`${base}/media/${encodeURIComponent(entry.id)}`)
1005
1082
  }
1006
1083
  }
1007
- if (parsed.text) {
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
- await withToken((token) => lineApi.pushMessage(token, userId, messages))
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': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",