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