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 +14 -0
- package/README.en.md +3 -0
- package/README.ja.md +3 -0
- package/README.ko.md +2 -1
- package/README.md +3 -0
- package/README.zh-CN.md +3 -0
- package/lib/agent-runner.mjs +15 -0
- package/lib/host.mjs +70 -19
- package/lib/webhook-server.mjs +1 -1
- package/package.json +1 -1
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에
|
|
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。
|
package/lib/agent-runner.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
'
|
|
1136
|
-
lines.map((
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
2438
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
2388
2439
|
})
|
|
2389
2440
|
res.end(media.bytes)
|
|
2390
2441
|
return
|
package/lib/webhook-server.mjs
CHANGED
|
@@ -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=
|
|
96
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
97
97
|
})
|
|
98
98
|
res.end(media.bytes)
|
|
99
99
|
return
|