dsh-line-bot 0.8.0 → 0.8.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.3
4
+ - 表情包提示詞更明確:貼圖只能從已安裝的 dsh-meme 圖庫挑選,不能自行產生、上傳、貼外部網址或捏造路徑;清單沒有合適的圖就不要硬挑,並告知使用者可到 dsh-meme 加入新圖。
5
+ - 模型被問到「貼圖哪裡來的」時,會照實回答 dsh-meme。
6
+
7
+ ## 0.8.2
8
+ - 送出的圖片改存到 `<DSH_HOME>/line-bot/media/`(含索引,保留 90 天),不再只放記憶體、不再 1 小時過期;LINE 之後才打開聊天室也能抓到。
9
+ - 圖片回應改 `cache-control: public, max-age=31536000, immutable`,讓 LINE/CDN 快取。
10
+ - 注意:quick tunnel 重啟會換網址,舊訊息裡的圖片仍會失效;要永久固定請用 API Token 隧道+自訂網域。
11
+
12
+ ## 0.8.1
13
+ - LINE 代理會濾掉 dsh-meme 外掛的「陪伴模式」提示詞(`dsh-expression:companion`):那套規則叫模型用 `send_meme`/`[表情:]`,在 LINE 沒有對應的配圖器,會和本外掛的標記打架。
14
+ - 表情包提示詞收緊:清單加上情緒與編號、只准 `[[meme:#N]]`、不要呼叫 `send_meme`、不要把標記解釋成文字。
15
+ - 測試:要求表情包時回 `[[meme:#15]]` 並組出 Flex 圖片;一般回覆不會硬加圖。
16
+
3
17
  ## 0.8.0
4
18
 
5
19
  - 內建診斷:`GET /doctor`、`npx dsh-line-bot doctor [--fix] [--json]`,檢查伺服器/tunnel/公開網址/webhook/dsh-meme/表情包設定/轉圖工具/模型/圖片可送達/最近錯誤。
package/README.en.md CHANGED
@@ -199,6 +199,9 @@ The character keeps its own memory at `<DSH_HOME>/line-bot/memories.json`, separ
199
199
  - The "Personal memory" panel card shows what it remembers, lets you delete entries, prune expired ones, or trigger a review right away.
200
200
  ## Memes (dsh-meme)
201
201
 
202
+ - Sent images are copied to `<DSH_HOME>/line-bot/media/` and kept for 90 days, so LINE can still fetch them when the chat is opened later. With a quick tunnel the public URL changes on restart and older images stop loading; use a named tunnel (API token + your own hostname) for permanent URLs.
203
+
204
+
202
205
  The persona can attach a sticker to a text reply, or reply with just an image. Images come from the installed dsh-meme plugin's library (the built-in "大肥鱼" pack has 24 images):
203
206
 
204
207
  - Each turn injects a numbered sample of the library into context; when the persona wants a picture, it appends `[[meme:#N]]` (or a caption) at the end of its reply, and the plugin turns that into a LINE image message.
package/README.ja.md CHANGED
@@ -199,6 +199,9 @@ Bot がグループ(または複数人のルーム)に追加されたとき
199
199
  - パネルの「個人メモリ」カードで、記録内容の確認、手動削除、期限切れの一括削除、今すぐの見直しができます。
200
200
  ## 表情パック(dsh-meme)
201
201
 
202
+ - 送信した画像は `<DSH_HOME>/line-bot/media/` にコピーされ 90 日間保持されます。後からチャットを開いても LINE が取得できます。quick tunnel は再起動で URL が変わるため古い画像は表示できません。固定 URL が必要なら API Token の named tunnel を使ってください。
203
+
204
+
202
205
  キャラクターはテキストの返信にスタンプ画像を添えたり、画像だけを返したりできます。画像はインストール済みの dsh-meme プラグインのライブラリ(内蔵「大肥魚」24 枚)から取得します。
203
206
 
204
207
  - 毎ターン、ライブラリのサンプル(番号+説明)を context に注入します。画像を添えたいときは返信の最後に `[[meme:#番号]]`(または説明)を付けます。プラグインが LINE の image message に変換します。
package/README.ko.md CHANGED
@@ -202,7 +202,8 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
202
202
  캐릭터는 텍스트 답장에 스티커 이미지를 붙이거나 이미지만 보낼 수 있습니다. 이미지는 설치된 dsh-meme 플러그인의 라이브러리(내장 「대비어」 24장)에서 가져옵니다.
203
203
 
204
204
  - 매 턴 라이브러리 샘플(번호+설명)을 context에 넣습니다. 이미지를 붙이려면 답장 끝에 `[[meme:#번호]]`(또는 설명)를 붙입니다. 플러그인이 LINE image message로 바꿔 보냅니다.
205
- - 이미지는 현재 Cloudflare 공개 URL(`<URL>/media/<token>`)을 통해 LINE에 전달되며, quick tunnel이 재시작돼 URL이 바뀌어도 자동으로 따라갑니다.
205
+ - 이미지는 현재 Cloudflare 공개 URL(`<URL>/media/<token>`)을 통해 LINE에 전달됩니다. 보낸 이미지는 `<DSH_HOME>/line-bot/media/`에 90일간 보관되므로 나중에 채팅을 열어도 LINE이 가져갈 수 있습니다.
206
+ - quick tunnel은 재시작하면 URL이 바뀌므로 이전 메시지의 이미지는 표시되지 않습니다. 영구 URL이 필요하면 API Token named tunnel과 사용자 도메인을 사용하세요.
206
207
  - 패널의 「이모티콘」 카드에서 켜고 끄기, 답장당 최대 장수(1~3), 라이브러리 샘플 미리보기를 할 수 있습니다. 이미지 추가나 분류 변경은 dsh-meme 설정에서 하세요.
207
208
  - LINE 스티커를 받으면 스티커 이미지를 가져와 모델에 보여 줍니다(이미지 지원 모델은 바로 읽고, 아니면 LINE 키워드로 대체). 「LINE 자격 증명」 카드에서 「스티커 이해」를 끌 수 있습니다.
208
209
  - dsh-meme이 설치되어 있지 않으면 이 기능은 꺼지고 텍스트만 답장합니다.
package/README.md CHANGED
@@ -199,6 +199,9 @@ Bot 被加進群組(或多人聊天室)後:
199
199
  - 面板的「個人記憶」卡可以看目前記了什麼、手動刪除、立即清理到期、或現在就叫它整理一次。
200
200
  ## 表情包(dsh-meme)
201
201
 
202
+ - 送出的圖片會複製到 `<DSH_HOME>/line-bot/media/`(保留 90 天),LINE 之後才打開聊天室也抓得到。公開網址若用 quick tunnel,重啟後網址會變,舊訊息裡的圖片就會失效;要永久固定請改用自訂網域(API Token 隧道)。
203
+
204
+
202
205
  角色可以回文字時配一張貼圖,也可以只回一張圖。圖片來自已安裝的 dsh-meme 外掛的圖庫(內建「大肥魚」24 張):
203
206
 
204
207
  - 每輪會把圖庫樣本(編號+描述)帶進 context;角色想配圖時在回覆最後加 `[[meme:#編號]]`(或描述),插件再組成 LINE 的 image message。
package/README.zh-CN.md CHANGED
@@ -199,6 +199,9 @@ Bot 被加入群组(或多人聊天室)后:
199
199
  - 面板的「个人记忆」卡可以看目前记了什么、手动删除、立即清理到期、或现在就叫它整理一次。
200
200
  ## 表情包(dsh-meme)
201
201
 
202
+ - 送出的图片会复制到 `<DSH_HOME>/line-bot/media/`(保留 90 天),LINE 之后才打开聊天室也抓得到。公开网址若用 quick tunnel,重启后网址会变,旧消息里的图片会失效;要永久固定请改用自定义域名(API Token 隧道)。
203
+
204
+
202
205
  角色可以在文字后面配一张贴图,也可以只回一张图。图片来自已安装的 dsh-meme 插件的图库(内置「大肥鱼」24 张):
203
206
 
204
207
  - 每轮会把图库样本(编号+描述)带进 context;角色想配图时在回复最后加 `[[meme:#编号]]`(或描述),插件再组成 LINE 的 image message。
@@ -97,6 +97,21 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
97
97
  } catch {
98
98
  /* 用預設順序 */
99
99
  }
100
+ // dsh-meme 外掛會注入它自己的「陪伴模式」提示詞(叫模型用 send_meme/[表情:])。
101
+ // LINE 沒有那個前端配圖器,這段會和本外掛的標記規則打架,所以在最外層把它濾掉。
102
+ try {
103
+ agentCtx.effect(
104
+ () => agentCtx.on?.('system-prompt/assemble', async (_assembly, _context, next) => {
105
+ const assembled = await next()
106
+ if (!Array.isArray(assembled?.sections)) return assembled
107
+ const sections = assembled.sections.filter((section) => section?.name !== 'dsh-expression:companion')
108
+ return sections.length === assembled.sections.length ? assembled : { ...assembled, sections }
109
+ }, { prepend: true }),
110
+ 'line-bot: drop dsh-meme companion prompt',
111
+ )
112
+ } catch (error) {
113
+ logger?.warn?.(`無法覆寫 dsh-meme 提示詞:${error?.message || error}`)
114
+ }
100
115
  if (personaText) {
101
116
  agentCtx.effect(
102
117
  () => prompt.section({
package/lib/host.mjs CHANGED
@@ -43,7 +43,7 @@ const { createMemeSource } = memeSourceMod
43
43
  const { createStickerImages } = stickerImageMod
44
44
  const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep } = utilMod
45
45
 
46
- export const VERSION = '0.8.0'
46
+ export const VERSION = '0.8.3'
47
47
 
48
48
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
49
49
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -1126,16 +1126,21 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1126
1126
  if (memes.packs().length === 0) return ''
1127
1127
  const sample = memes.sample({ limit: 20 })
1128
1128
  runtime.memeChoices = sample
1129
- const lines = (sample || [])
1130
- .map((item) => String(item.caption || '').trim())
1131
- .filter(Boolean)
1132
- .slice(0, 20)
1129
+ const lines = (sample || []).filter((item) => String(item.caption || '').trim()).slice(0, 20)
1133
1130
  if (lines.length === 0) return ''
1134
1131
  return [
1135
- '【表情包】你的圖庫(dsh-meme)裡有這些圖:',
1136
- lines.map((line, index) => `${index + 1}. ${line}`).join('\n'),
1137
- `想配圖時,在回覆最後加上 [[meme:#編號]](例如 [[meme:#3]],編號照上面清單),也可以寫描述;只回一張圖就只輸出那個標記。一次最多 ${memeMaxPerReply()} 張;氣氛合適才用,不要每句都加;不要呼叫 send_meme。`,
1138
- publicBaseSync() ? '' : '(目前沒有公開網址,圖片送不出去。)',
1132
+ '【表情包】你的貼圖只能從已安裝的 dsh-meme 外掛圖庫取得。下面是這一輪可用的清單(編號.情緒|描述):',
1133
+ lines.map((item, index) => `${index + 1}. ${item.tag || 'daily'}|${String(item.caption || '').trim()}`).join('\n'),
1134
+ '想配圖時,在回覆最後加上 [[meme:#編號]](例如 [[meme:#3]])。規則:',
1135
+ '- 只能挑上面清單裡的編號;不要自己產生圖片、不要上傳、不要貼外部圖片網址、不要捏造檔案路徑、不要用 markdown 圖片語法。',
1136
+ '- 清單裡沒有適合的圖就不要硬挑,改用文字;想新增圖片請主人到 dsh-meme 的設定/圖庫頁加入,你這邊不能新增圖庫內容。',
1137
+ '- 不要呼叫 send_meme(LINE 沒有連到它,呼叫了也不會送出圖片),也不要寫 <meme#N> 或 [表情:…] 這種格式。',
1138
+ '- 標記放最後;不要在旁邊解釋圖片內容,也不要說「我傳了貼圖」。',
1139
+ '- 只回圖也可以:整則回覆只放標記。',
1140
+ `- 一次最多 ${memeMaxPerReply()} 張,編號必須是 1–${lines.length}。`,
1141
+ '- 氣氛明顯(開心、無語、害羞、想睡、被逗笑)才用;一般聊天、正事、難過時不要用;對方明確要表情包時就用。',
1142
+ '- 如果主人問「你的貼圖哪裡來的」,照實說:從 dsh-meme 圖庫挑的。',
1143
+ publicBaseSync() ? '' : '(目前沒有公開網址,圖片暫時送不出去。)',
1139
1144
  ].filter(Boolean).join('\n')
1140
1145
  }
1141
1146
 
@@ -1199,6 +1204,44 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1199
1204
  }
1200
1205
  }
1201
1206
 
1207
+ const mediaDir = path.join(dir, 'media')
1208
+ let mediaIndex = {}
1209
+ try {
1210
+ fs.mkdirSync(mediaDir, { recursive: true })
1211
+ mediaIndex = JSON.parse(fs.readFileSync(path.join(mediaDir, 'index.json'), 'utf8')) || {}
1212
+ } catch {
1213
+ mediaIndex = {}
1214
+ }
1215
+
1216
+ function saveMediaIndex() {
1217
+ try {
1218
+ const file = path.join(mediaDir, 'index.json')
1219
+ const tmp = `${file}.tmp`
1220
+ fs.writeFileSync(tmp, JSON.stringify(mediaIndex), { mode: 0o600 })
1221
+ fs.renameSync(tmp, file)
1222
+ } catch (error) {
1223
+ logger.warn(`儲存媒體索引失敗:${error?.message || error}`)
1224
+ }
1225
+ }
1226
+
1227
+ function pruneMedia() {
1228
+ const cutoff = Date.now() - 90 * 86400000
1229
+ let dropped = 0
1230
+ for (const [key, item] of Object.entries(mediaIndex)) {
1231
+ if (!item || !item.at || item.at < cutoff) {
1232
+ try {
1233
+ fs.unlinkSync(path.join(mediaDir, item?.file || ''))
1234
+ } catch {
1235
+ /* 檔案可能已不在 */
1236
+ }
1237
+ delete mediaIndex[key]
1238
+ dropped += 1
1239
+ }
1240
+ }
1241
+ if (dropped > 0) saveMediaIndex()
1242
+ }
1243
+ pruneMedia()
1244
+
1202
1245
  function registerImageFile(filePath) {
1203
1246
  const ext = path.extname(String(filePath || '')).toLowerCase()
1204
1247
  let serve = filePath
@@ -1210,8 +1253,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1210
1253
  serve = store.get().stickers?.apng === true
1211
1254
  ? (convertGifToApng(filePath) || convertToPng(filePath))
1212
1255
  : convertToPng(filePath)
1213
- }
1214
- else if (ext === '.webp') {
1256
+ } else if (ext === '.webp') {
1215
1257
  mime = 'image/png'
1216
1258
  serve = convertToPng(filePath)
1217
1259
  } else {
@@ -1219,7 +1261,18 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1219
1261
  }
1220
1262
  if (!mime || !serve || !fs.existsSync(serve)) return ''
1221
1263
  const token = `m${randomBytes(6).toString('hex')}`
1222
- mediaTokens.set(token, { path: serve, mime, at: Date.now() })
1264
+ const filename = `${token}${mime === 'image/png' ? '.png' : '.jpg'}`
1265
+ const target = path.join(mediaDir, filename)
1266
+ try {
1267
+ fs.copyFileSync(serve, target)
1268
+ } catch (error) {
1269
+ logger.warn(`複製媒體失敗:${error?.message || error}`)
1270
+ return ''
1271
+ }
1272
+ const item = { file: filename, mime, at: Date.now(), bytes: fs.statSync(target).size }
1273
+ mediaTokens.set(token, item)
1274
+ mediaIndex[token] = item
1275
+ saveMediaIndex()
1223
1276
  return token
1224
1277
  }
1225
1278
 
@@ -1227,14 +1280,12 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1227
1280
  const key = String(id || '')
1228
1281
  if (!key) return null
1229
1282
  if (key.startsWith('m')) {
1230
- const hit = mediaTokens.get(key)
1283
+ const hit = mediaTokens.get(key) || mediaIndex[key]
1231
1284
  if (!hit) return null
1232
- if (Date.now() - hit.at > 60 * 60 * 1000) {
1233
- mediaTokens.delete(key)
1234
- return null
1235
- }
1236
1285
  try {
1237
- return { mime: hit.mime, bytes: fs.readFileSync(hit.path) }
1286
+ const bytes = fs.readFileSync(path.join(mediaDir, hit.file))
1287
+ mediaTokens.set(key, hit)
1288
+ return { mime: hit.mime, bytes }
1238
1289
  } catch {
1239
1290
  mediaTokens.delete(key)
1240
1291
  return null
@@ -2384,7 +2435,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2384
2435
  res.writeHead(200, {
2385
2436
  'content-type': media.mime || 'application/octet-stream',
2386
2437
  'content-length': media.bytes.length,
2387
- 'cache-control': 'public, max-age=300',
2438
+ 'cache-control': 'public, max-age=31536000, immutable',
2388
2439
  })
2389
2440
  res.end(media.bytes)
2390
2441
  return
@@ -93,7 +93,7 @@ export function createWebhookServer({ getConfig, logger, dispatch, getMedia, get
93
93
  res.writeHead(200, {
94
94
  'content-type': media.mime || 'application/octet-stream',
95
95
  'content-length': media.bytes.length,
96
- 'cache-control': 'public, max-age=300',
96
+ 'cache-control': 'public, max-age=31536000, immutable',
97
97
  })
98
98
  res.end(media.bytes)
99
99
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.8.0",
3
+ "version": "0.8.3",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",