dsh-line-bot 0.5.3 → 0.5.5
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 +10 -0
- package/README.en.md +2 -0
- package/README.ja.md +2 -0
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/client.js +4 -0
- package/lib/agent-runner.mjs +6 -4
- package/lib/config.mjs +5 -0
- package/lib/host.mjs +63 -6
- package/lib/sticker-image.mjs +53 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.5
|
|
4
|
+
|
|
5
|
+
- 收到 LINE 貼圖時抓取貼圖圖片,透過 DSH 附件服務交給模型閱讀(模型不支援圖片時自動退回貼圖關鍵字)。
|
|
6
|
+
- 面板「LINE 憑證」新增「看得懂貼圖」開關;README(五語言)補說明。
|
|
7
|
+
|
|
8
|
+
## 0.5.4
|
|
9
|
+
|
|
10
|
+
- LINE 不會播放 APNG 動畫(只顯示第一幀),所以 GIF 預設改成靜態 PNG,檔案更小、載入更穩。
|
|
11
|
+
- 面板「表情包」卡新增「GIF 保留 APNG 動畫」開關(預設關閉);打開才會送 APNG。
|
|
12
|
+
|
|
3
13
|
## 0.5.3
|
|
4
14
|
|
|
5
15
|
- 表情包改用 Flex Message(hero 圖片+文字),GIF 轉成 APNG 保留動畫;APNG 過大或轉檔失敗才退回靜態 PNG。
|
package/README.en.md
CHANGED
|
@@ -78,6 +78,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
|
|
|
78
78
|
| Tieba learning sync | Writes the tone samples learned while browsing Tieba back into the persona's "Tieba tone" section, preview diff, sync now |
|
|
79
79
|
| Personal memory | Per-user long-term memory, expiry cleanup, daily self-review, manual delete |
|
|
80
80
|
| Memes | Reply with images/stickers from the dsh-meme library, enable/disable, image limit, and sample previews |
|
|
81
|
+
| Sticker vision | Fetch incoming LINE stickers and show them to the model (can be disabled) |
|
|
81
82
|
| DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown (deepseek-account only) |
|
|
82
83
|
| Proactive messages | Toggle, probability, interval, daily/monthly caps, minimum gap, quiet hours, idle, target users, prompt, send a test now, statistics |
|
|
83
84
|
| Users | The DSH session each LINE user maps to, their own workspace folder, their current persona (editable), message count, last interaction, clear memory / reset session |
|
|
@@ -172,6 +173,7 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
|
|
|
172
173
|
- 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.
|
|
173
174
|
- Images are served to LINE through the current Cloudflare public URL (`<url>/media/<token>`), and the URL follows quick-tunnel restarts automatically.
|
|
174
175
|
- The panel's "Memes" card can disable the feature, limit images per reply (1–3), and preview library samples. To add or re-tag images, use dsh-meme's own settings.
|
|
176
|
+
- When a LINE sticker arrives, the bot fetches the sticker image and shows it to the model (vision-capable models read the image; otherwise the bot falls back to the keywords LINE provides), so the persona can react to the mood. Turn "Understand stickers" off in the LINE credentials card to use keywords only.
|
|
175
177
|
- Without dsh-meme installed, the feature stays off and replies are text only.
|
|
176
178
|
## Troubleshooting
|
|
177
179
|
|
package/README.ja.md
CHANGED
|
@@ -78,6 +78,7 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
78
78
|
| 貼吧学習の同期 | 貼吧で学んだ口調をペルソナの「貼吧学来的口气」節に書き戻す、差分プレビュー、今すぐ同期 |
|
|
79
79
|
| 個人メモリ | ユーザーごとの長期記憶、期限切れの自動削除、毎日の自己整理、手動削除 |
|
|
80
80
|
| 表情パック | dsh-meme の画像ライブラリで画像/スタンプを返信、オンオフ、1 回の上限、サンプル表示 |
|
|
81
|
+
| スタンプ理解 | LINE スタンプを受信したら画像を取得してモデルに見せる(オフ可) |
|
|
81
82
|
| DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン(deepseek-account のみ) |
|
|
82
83
|
| 自発メッセージ | オン/オフ、確率、間隔、1 日/1 か月の上限、最短間隔、静かな時間帯、idle、対象ユーザー、プロンプト、今すぐ試送、統計 |
|
|
83
84
|
| ユーザー | LINE ユーザーごとに対応する DSH セッション、専用ワークスペース、現在のペルソナ(直接変更可)、メッセージ数、最終やりとり、メモリ消去/セッション再作成 |
|
|
@@ -172,6 +173,7 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
172
173
|
- 毎ターン、ライブラリのサンプル(番号+説明)を context に注入します。画像を添えたいときは返信の最後に `[[meme:#番号]]`(または説明)を付けます。プラグインが LINE の image message に変換します。
|
|
173
174
|
- 画像は現在の Cloudflare 公開 URL(`<URL>/media/<token>`)経由で LINE に渡されます。quick tunnel の再起動で URL が変わっても自動で追従します。
|
|
174
175
|
- パネルの「表情パック」カードでオンオフ、1 回あたりの上限(1〜3)、ライブラリのサンプル表示ができます。画像の追加や分類の変更は dsh-meme 側の設定で行います。
|
|
176
|
+
- LINE スタンプを受信すると、スタンプ画像を取得してモデルに見せます(画像対応モデルはそのまま読み、非対応なら LINE のキーワードを使います)。「LINE 認証情報」カードの「スタンプを理解する」でオフにできます。
|
|
175
177
|
- dsh-meme がインストールされていない場合、この機能は無効になりテキストのみ返します。
|
|
176
178
|
## トラブルシューティング
|
|
177
179
|
|
package/README.ko.md
CHANGED
|
@@ -78,6 +78,7 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
78
78
|
| 貼吧 학습 동기화 | 貼吧에서 배운 말투를 페르소나의 「貼吧学来的口气」 섹션으로 기록, 차이 미리보기, 지금 동기화 |
|
|
79
79
|
| 개인 기억 | 사용자별 장기 기억, 만료 자동 삭제, 매일 자기 정리, 수동 삭제 |
|
|
80
80
|
| 이모티콘 | dsh-meme 라이브러리로 이미지/스티커 답장, 켜기/끄기, 1회 최대 장수, 샘플 미리보기 |
|
|
81
|
+
| 스티커 이해 | LINE 스티커를 받으면 이미지를 가져와 모델에 보여 줌 (끌 수 있음) |
|
|
81
82
|
| DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운(deepseek-account 전용) |
|
|
82
83
|
| 능동 메시지 | 켜기/끄기, 확률, 간격, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 대상 사용자, 프롬프트, 지금 시험 발송, 통계 |
|
|
83
84
|
| 사용자 | LINE 사용자별 DSH 세션, 전용 작업 폴더, 현재 페르소나(직접 변경 가능), 메시지 수, 마지막 상호작용, 기억 지우기/세션 재설정 |
|
|
@@ -172,6 +173,7 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
172
173
|
- 매 턴 라이브러리 샘플(번호+설명)을 context에 넣습니다. 이미지를 붙이려면 답장 끝에 `[[meme:#번호]]`(또는 설명)를 붙입니다. 플러그인이 LINE image message로 바꿔 보냅니다.
|
|
173
174
|
- 이미지는 현재 Cloudflare 공개 URL(`<URL>/media/<token>`)을 통해 LINE에 전달되며, quick tunnel이 재시작돼 URL이 바뀌어도 자동으로 따라갑니다.
|
|
174
175
|
- 패널의 「이모티콘」 카드에서 켜고 끄기, 답장당 최대 장수(1~3), 라이브러리 샘플 미리보기를 할 수 있습니다. 이미지 추가나 분류 변경은 dsh-meme 설정에서 하세요.
|
|
176
|
+
- LINE 스티커를 받으면 스티커 이미지를 가져와 모델에 보여 줍니다(이미지 지원 모델은 바로 읽고, 아니면 LINE 키워드로 대체). 「LINE 자격 증명」 카드에서 「스티커 이해」를 끌 수 있습니다.
|
|
175
177
|
- dsh-meme이 설치되어 있지 않으면 이 기능은 꺼지고 텍스트만 답장합니다.
|
|
176
178
|
## 문제 해결
|
|
177
179
|
|
package/README.md
CHANGED
|
@@ -78,6 +78,7 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
78
78
|
| 貼吧學習同步 | 把刷貼吧學到的語氣寫回人設的「貼吧學來的口氣」段、預覽差異、立即同步 |
|
|
79
79
|
| 個人記憶 | 每位使用者分開的長期記憶、到期自動刪、每日請角色整理、手動刪除 |
|
|
80
80
|
| 表情包 | 使用 dsh-meme 圖庫回圖片/貼圖、開關、一次最多幾張、圖庫樣本 |
|
|
81
|
+
| 貼圖理解 | 收到 LINE 貼圖時抓圖給模型看(可關閉) |
|
|
81
82
|
| DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻(僅 deepseek-account) |
|
|
82
83
|
| 主動傳訊 | 開關、機率、間隔、每日/每月上限、最短間隔、安靜時段、idle、指定對象、提示詞、立即試傳、統計 |
|
|
83
84
|
| 使用者 | 每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
|
|
@@ -172,6 +173,7 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
172
173
|
- 每輪會把圖庫樣本(編號+描述)帶進 context;角色想配圖時在回覆最後加 `[[meme:#編號]]`(或描述),插件再組成 LINE 的 image message。
|
|
173
174
|
- 圖片透過目前的 Cloudflare 公開網址(`<網址>/media/<token>`)提供給 LINE;quick tunnel 換網址後會自動跟上。
|
|
174
175
|
- 面板「表情包」卡可以開關、限制一次最多幾張(1~3),並預覽圖庫樣本。要新增圖片或改分類,請用 dsh-meme 自己的設定。
|
|
176
|
+
- 收到 LINE 貼圖時,會抓貼圖圖片交給模型看(支援圖片的模型直接讀圖,否則用 LINE 提供的關鍵字),角色才接得住貼圖的情緒。可在「LINE 憑證」卡關閉「看得懂貼圖」。
|
|
175
177
|
- 沒安裝 dsh-meme 時這個功能會自動停用,只回文字。
|
|
176
178
|
## 疑難排解
|
|
177
179
|
|
package/README.zh-CN.md
CHANGED
|
@@ -78,6 +78,7 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
78
78
|
| 贴吧学习同步 | 把刷贴吧学到的语气写回人设的「贴吧学来的口气」段、预览差异、立即同步 |
|
|
79
79
|
| 个人记忆 | 每位用户分开的长期记忆、到期自动删除、每日请角色整理、手动删除 |
|
|
80
80
|
| 表情包 | 使用 dsh-meme 图库回图片/贴图、开关、每次最多几张、图库样本 |
|
|
81
|
+
| 贴图理解 | 收到 LINE 贴图时抓图给模型看(可关闭) |
|
|
81
82
|
| DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却(仅 deepseek-account) |
|
|
82
83
|
| 主动传讯 | 开关、概率、间隔、每日/每月上限、最短间隔、安静时段、idle、指定对象、提示词、立即试传、统计 |
|
|
83
84
|
| 用户 | 每位 LINE 用户对应的 DSH session、专属工作文件夹、目前人设(可直接改)、消息数、上次互动、清空记忆/重设 session |
|
|
@@ -172,6 +173,7 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
172
173
|
- 每轮会把图库样本(编号+描述)带进 context;角色想配图时在回复最后加 `[[meme:#编号]]`(或描述),插件再组成 LINE 的 image message。
|
|
173
174
|
- 图片通过当前的 Cloudflare 公开网址(`<网址>/media/<token>`)提供给 LINE;quick tunnel 换网址后会自动跟上。
|
|
174
175
|
- 面板「表情包」卡可以开关、限制每次最多几张(1~3),并预览图库样本。要新增图片或改分类,请用 dsh-meme 自己的设置。
|
|
176
|
+
- 收到 LINE 贴图时,会抓贴图图片交给模型看(支持图片的模型直接读图,否则用 LINE 提供的关键词),角色才能接住贴图的情绪。可在「LINE 凭证」卡关闭「看得懂贴图」。
|
|
175
177
|
- 没安装 dsh-meme 时该功能会自动停用,只回文字。
|
|
176
178
|
## 疑难排解
|
|
177
179
|
|
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: '啟動',
|
|
@@ -889,6 +891,7 @@ window.__ModuleLoader__.load({
|
|
|
889
891
|
),
|
|
890
892
|
h(Toggle, { checked: line.useAutoToken !== false, onChange: (value) => update({ line: { useAutoToken: value } }), label: '自動簽發 access token', hint: '有 Channel ID+Secret 時,優先自動取得(建議開啟)' }),
|
|
891
893
|
h(Toggle, { checked: line.loadingEnabled !== false, onChange: (value) => update({ line: { loadingEnabled: value } }), label: '顯示「正在輸入」動畫', hint: '回覆前呼叫 LINE 的 loading 動畫' }),
|
|
894
|
+
h(Toggle, { checked: line.stickerVision !== false, onChange: (value) => update({ line: { stickerVision: value } }), label: '看得懂貼圖', hint: '收到貼圖時抓圖片給模型看;模型不支援圖片時仍會用貼圖關鍵字' }),
|
|
892
895
|
h('div', { style: S.row },
|
|
893
896
|
h(Btn, { onClick: () => act('line-verify'), disabled: !!busy, tone: 'primary' }, busy === 'line-verify' ? '驗證中…' : '驗證並讀取 bot 資訊'),
|
|
894
897
|
h(Btn, { onClick: () => act('token-refresh'), disabled: !!busy }, '重新簽發 token'),
|
|
@@ -1151,6 +1154,7 @@ window.__ModuleLoader__.load({
|
|
|
1151
1154
|
),
|
|
1152
1155
|
h(Toggle, { checked: memeCfg.enabled !== false, onChange: (value) => update({ stickers: { enabled: value } }), label: '允許角色使用表情包', hint: '關掉後只回文字' }),
|
|
1153
1156
|
h(Field, { label: '一次最多幾張', hint: 'LINE 一次回覆最多 5 則訊息,圖片也算在內' }, h(Num, { value: memeCfg.maxPerReply, min: 1, max: 3, step: 1, onChange: (value) => update({ stickers: { maxPerReply: value } }) })),
|
|
1157
|
+
h(Toggle, { checked: memoCfgApng(memeCfg), onChange: (value) => update({ stickers: { apng: value } }), label: 'GIF 保留 APNG 動畫', hint: 'LINE 目前不會播放 APNG,只會顯示第一幀;開啟會讓檔案變大' }),
|
|
1154
1158
|
h('div', { style: S.row },
|
|
1155
1159
|
h(Btn, { onClick: () => act('meme-stats'), disabled: !!busy }, busy === 'meme-stats' ? '讀取中…' : '重新讀取圖庫'),
|
|
1156
1160
|
),
|
package/lib/agent-runner.mjs
CHANGED
|
@@ -5,11 +5,13 @@ import os from 'node:os'
|
|
|
5
5
|
import { randomUUID } from 'node:crypto'
|
|
6
6
|
import { sleep } from './util.mjs'
|
|
7
7
|
|
|
8
|
-
function userMessage(text) {
|
|
8
|
+
function userMessage(text, images = []) {
|
|
9
|
+
const content = [{ type: 'text', text }]
|
|
10
|
+
for (const attachment of images) content.push({ type: 'image', attachment })
|
|
9
11
|
return {
|
|
10
12
|
id: randomUUID(),
|
|
11
13
|
role: 'user',
|
|
12
|
-
content
|
|
14
|
+
content,
|
|
13
15
|
source: { kind: 'user' },
|
|
14
16
|
}
|
|
15
17
|
}
|
|
@@ -186,7 +188,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
186
188
|
}
|
|
187
189
|
}
|
|
188
190
|
|
|
189
|
-
async function turn({ sessionId, text, persona, personaClient, personaText, contextText, tools = [], cwd: cwdOverride }) {
|
|
191
|
+
async function turn({ sessionId, text, persona, personaClient, personaText, contextText, tools = [], images = [], cwd: cwdOverride }) {
|
|
190
192
|
const cfg = getConfig()
|
|
191
193
|
const timeoutMs = cfg.agent.timeoutMs || 180000
|
|
192
194
|
const { options } = agentOptions()
|
|
@@ -227,7 +229,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
227
229
|
})
|
|
228
230
|
|
|
229
231
|
const firstSeq = agent.session.seq
|
|
230
|
-
agent.followup(userMessage(text))
|
|
232
|
+
agent.followup(userMessage(text, images))
|
|
231
233
|
|
|
232
234
|
let timedOut = false
|
|
233
235
|
const timer = new Promise((_, reject) => {
|
package/lib/config.mjs
CHANGED
|
@@ -25,6 +25,7 @@ export const DEFAULT_CONFIG = {
|
|
|
25
25
|
tokenExpiresAt: 0,
|
|
26
26
|
useAutoToken: true,
|
|
27
27
|
loadingEnabled: true,
|
|
28
|
+
stickerVision: true,
|
|
28
29
|
allowedUserIds: [],
|
|
29
30
|
welcomeText: '(已加好友)嗨,我是大肥魚~想聊什麼都可以跟我說。',
|
|
30
31
|
errorReply: '(剛剛卡住了,再說一次好嗎?)',
|
|
@@ -115,6 +116,8 @@ export const DEFAULT_CONFIG = {
|
|
|
115
116
|
// 角色可以用圖片/表情包回話;圖片由面板上傳,存在 <dataDir>/stickers。
|
|
116
117
|
enabled: true,
|
|
117
118
|
maxPerReply: 1,
|
|
119
|
+
// LINE 的圖片訊息不會播放 APNG 動畫,預設只送第一幀(靜態 PNG);要保留 APNG 檔可打開。
|
|
120
|
+
apng: false,
|
|
118
121
|
},
|
|
119
122
|
log: {
|
|
120
123
|
maxLines: 400,
|
|
@@ -177,6 +180,7 @@ export function sanitizePatch(current, patch) {
|
|
|
177
180
|
next.line.tokenExpiresAt = clampInt(next.line.tokenExpiresAt, 0, Number.MAX_SAFE_INTEGER, 0)
|
|
178
181
|
next.line.allowedUserIds = safeArray(next.line.allowedUserIds)
|
|
179
182
|
next.line.loadingEnabled = Boolean(next.line.loadingEnabled)
|
|
183
|
+
next.line.stickerVision = Boolean(next.line.stickerVision)
|
|
180
184
|
next.line.welcomeText = String(next.line.welcomeText ?? '').slice(0, 1000)
|
|
181
185
|
next.line.errorReply = String(next.line.errorReply ?? '').slice(0, 500)
|
|
182
186
|
next.line.emptyReply = String(next.line.emptyReply ?? '').slice(0, 500)
|
|
@@ -240,6 +244,7 @@ export function sanitizePatch(current, patch) {
|
|
|
240
244
|
const stickers = next.stickers || (next.stickers = {})
|
|
241
245
|
stickers.enabled = Boolean(stickers.enabled)
|
|
242
246
|
stickers.maxPerReply = clampInt(stickers.maxPerReply, 1, 3, 1)
|
|
247
|
+
stickers.apng = Boolean(stickers.apng)
|
|
243
248
|
|
|
244
249
|
const nudge = next.nudge
|
|
245
250
|
nudge.enabled = Boolean(nudge.enabled)
|
package/lib/host.mjs
CHANGED
|
@@ -23,6 +23,7 @@ const balanceMod = await import(withVersion('./deepseek-balance.mjs'))
|
|
|
23
23
|
const memoryMod = await import(withVersion('./memory.mjs'))
|
|
24
24
|
const stickersMod = await import(withVersion('./stickers.mjs'))
|
|
25
25
|
const memeSourceMod = await import(withVersion('./meme-source.mjs'))
|
|
26
|
+
const stickerImageMod = await import(withVersion('./sticker-image.mjs'))
|
|
26
27
|
const utilMod = await import(withVersion('./util.mjs'))
|
|
27
28
|
const { createConfigStore, publicConfig, dataDir } = configMod
|
|
28
29
|
const { createPersonaClient } = personaMod
|
|
@@ -37,9 +38,10 @@ const { createBalanceMonitor } = balanceMod
|
|
|
37
38
|
const { createMemoryStore } = memoryMod
|
|
38
39
|
const { createStickerStore } = stickersMod
|
|
39
40
|
const { createMemeSource } = memeSourceMod
|
|
41
|
+
const { createStickerImages } = stickerImageMod
|
|
40
42
|
const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep } = utilMod
|
|
41
43
|
|
|
42
|
-
export const VERSION = '0.5.
|
|
44
|
+
export const VERSION = '0.5.5'
|
|
43
45
|
|
|
44
46
|
// 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
|
|
45
47
|
// 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
|
|
@@ -228,7 +230,18 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
228
230
|
const entry = { id: info.id, name: info.name || info.id, models: [] }
|
|
229
231
|
try {
|
|
230
232
|
const models = await llm.listModels(info.id)
|
|
231
|
-
|
|
233
|
+
const mapped = []
|
|
234
|
+
for (const item of models || []) {
|
|
235
|
+
const model = { id: item.id, name: item.name || item.id }
|
|
236
|
+
if (typeof llm.resolveModelInfo === 'function') {
|
|
237
|
+
try {
|
|
238
|
+
const info2 = await llm.resolveModelInfo(info.id, item.id)
|
|
239
|
+
if (Array.isArray(info2?.inputModalities)) model.inputModalities = info2.inputModalities
|
|
240
|
+
} catch { /* 有些 provider 不支援查詢,略過 */ }
|
|
241
|
+
}
|
|
242
|
+
mapped.push(model)
|
|
243
|
+
}
|
|
244
|
+
entry.models = mapped
|
|
232
245
|
} catch (error) {
|
|
233
246
|
entry.error = errorText(error)
|
|
234
247
|
}
|
|
@@ -956,7 +969,9 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
956
969
|
else if (ext === '.png') mime = 'image/png'
|
|
957
970
|
else if (ext === '.gif') {
|
|
958
971
|
mime = 'image/png'
|
|
959
|
-
serve =
|
|
972
|
+
serve = store.get().stickers?.apng === true
|
|
973
|
+
? (convertGifToApng(filePath) || convertToPng(filePath))
|
|
974
|
+
: convertToPng(filePath)
|
|
960
975
|
}
|
|
961
976
|
else if (ext === '.webp') {
|
|
962
977
|
mime = 'image/png'
|
|
@@ -1125,11 +1140,38 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1125
1140
|
logger.warn(`略過未授權的使用者 ${userId}(allowedUserIds)`)
|
|
1126
1141
|
return
|
|
1127
1142
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1143
|
+
const messageType = event.message?.type
|
|
1144
|
+
const messageImages = []
|
|
1145
|
+
let text = ''
|
|
1146
|
+
if (messageType === 'text') {
|
|
1147
|
+
text = String(event.message.text || '').trim()
|
|
1148
|
+
} else if (messageType === 'sticker') {
|
|
1149
|
+
const keywords = Array.isArray(event.message.keywords)
|
|
1150
|
+
? event.message.keywords.filter(Boolean).slice(0, 8).join('、')
|
|
1151
|
+
: ''
|
|
1152
|
+
let hasImage = false
|
|
1153
|
+
if (cfg.line?.stickerVision !== false) {
|
|
1154
|
+
try {
|
|
1155
|
+
const ref = await stickerImages.toAttachment(event.message.stickerId, event.message.stickerResourceType)
|
|
1156
|
+
if (ref) {
|
|
1157
|
+
messageImages.push(ref)
|
|
1158
|
+
hasImage = true
|
|
1159
|
+
}
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
logger.warn(`取得貼圖圖片失敗,只用關鍵字:${errorText(error)}`)
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
text = [
|
|
1165
|
+
`(使用者沒有打字,傳了一個 LINE 貼圖:packageId=${event.message.packageId ?? '?'}, stickerId=${event.message.stickerId ?? '?'}`,
|
|
1166
|
+
keywords ? `,貼圖關鍵字:${keywords}` : '',
|
|
1167
|
+
hasImage ? ',貼圖圖片已附在這一則訊息裡(請直接看圖)' : '',
|
|
1168
|
+
'。請用你自己的語氣,一兩句自然回應這個貼圖;不要說「你傳了貼圖」這種話,依圖片內容或關鍵字猜情緒,不確定就輕鬆帶過。)',
|
|
1169
|
+
].join('')
|
|
1170
|
+
logger.info(`收到貼圖(packageId=${event.message.packageId ?? '?'}, stickerId=${event.message.stickerId ?? '?'}${keywords ? `,${keywords}` : ''})`)
|
|
1171
|
+
} else {
|
|
1172
|
+
logger.info(`略過非文字訊息(${messageType})`)
|
|
1130
1173
|
return
|
|
1131
1174
|
}
|
|
1132
|
-
const text = String(event.message.text || '').trim()
|
|
1133
1175
|
if (!text) return
|
|
1134
1176
|
|
|
1135
1177
|
const user = ensureUser(userId)
|
|
@@ -1167,6 +1209,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1167
1209
|
personaClient: persona(),
|
|
1168
1210
|
contextText: agentContextText(userId),
|
|
1169
1211
|
tools: memoryToolsFor(userId),
|
|
1212
|
+
images: messageImages,
|
|
1170
1213
|
cwd: ensureUserWorkspace(userId) || cfg.agent.cwd || undefined,
|
|
1171
1214
|
})
|
|
1172
1215
|
let replyText = String(result?.text || '').trim()
|
|
@@ -1195,6 +1238,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1195
1238
|
const runner = createAgentRunner({ ctx, logger, getConfig: () => store.get() })
|
|
1196
1239
|
const stickers = createStickerStore({ dir, logger })
|
|
1197
1240
|
const memes = createMemeSource({ ctx, logger })
|
|
1241
|
+
const stickerImages = createStickerImages({ ctx, logger, dir })
|
|
1198
1242
|
const mediaTokens = new Map()
|
|
1199
1243
|
|
|
1200
1244
|
const server = createWebhookServer({ getConfig: () => store.get(), logger, dispatch: handleEvent, getMedia })
|
|
@@ -1626,6 +1670,19 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1626
1670
|
return maybeAskForTopUp({ dryRun: payload.dryRun !== false, force: payload.force === true })
|
|
1627
1671
|
case 'meme-stats':
|
|
1628
1672
|
return { ...memes.stats(), enabled: store.get().stickers?.enabled !== false, maxPerReply: memeMaxPerReply(), sample: memes.sample({ limit: 20 }) }
|
|
1673
|
+
case 'sticker-look': {
|
|
1674
|
+
const stickerId = String(payload.stickerId || '').trim()
|
|
1675
|
+
if (!stickerId) throw new Error('缺少 stickerId')
|
|
1676
|
+
const ref = await stickerImages.toAttachment(stickerId, payload.stickerResourceType || 'STATIC')
|
|
1677
|
+
const result = await runner.turn({
|
|
1678
|
+
sessionId: `session-sticker-look-${Date.now()}`,
|
|
1679
|
+
text: '這是一張 LINE 貼圖。請用一句繁體中文說出圖片裡的圖案內容與情緒。',
|
|
1680
|
+
images: [ref],
|
|
1681
|
+
contextText: '',
|
|
1682
|
+
cwd: store.get().agent.cwd || undefined,
|
|
1683
|
+
})
|
|
1684
|
+
return { text: result.text, attachmentId: String(ref.attachmentId || ''), mediaType: ref.mediaType }
|
|
1685
|
+
}
|
|
1629
1686
|
case 'meme-preview': {
|
|
1630
1687
|
if (!payload.text) throw new Error('缺少 text')
|
|
1631
1688
|
const built = await buildOutgoingMessages(String(payload.text))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// 抓 LINE 貼圖圖片,存進 DSH 附件庫,供多模態模型閱讀。
|
|
2
|
+
const STICKER_BASE = 'https://stickershop.line-scdn.net/stickershop/v1/sticker'
|
|
3
|
+
|
|
4
|
+
function animationType(resourceType) {
|
|
5
|
+
return resourceType === 'ANIMATION' || resourceType === 'ANIMATION_SOUND'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createStickerImages({ ctx, logger, dir }) {
|
|
9
|
+
const attachments = ctx?.get?.('attachments')
|
|
10
|
+
const cache = new Map()
|
|
11
|
+
|
|
12
|
+
function available() {
|
|
13
|
+
return Boolean(attachments && typeof attachments.saveImages === 'function')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function fetchBytes(stickerId, resourceType) {
|
|
17
|
+
const id = String(stickerId || '').replace(/[^0-9]/g, '')
|
|
18
|
+
if (!id) throw new Error('沒有 stickerId')
|
|
19
|
+
const urls = [`${STICKER_BASE}/${id}/android/sticker.png`]
|
|
20
|
+
if (animationType(resourceType)) urls.push(`${STICKER_BASE}/${id}/android/sticker_animation.png`)
|
|
21
|
+
let lastError
|
|
22
|
+
for (const url of urls) {
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) })
|
|
25
|
+
if (!response.ok) throw new Error(`LINE 貼圖 HTTP ${response.status}`)
|
|
26
|
+
const data = new Uint8Array(await response.arrayBuffer())
|
|
27
|
+
if (data.byteLength === 0) throw new Error('LINE 貼圖是空檔案')
|
|
28
|
+
if (data.byteLength > 6 * 1024 * 1024) throw new Error('LINE 貼圖太大')
|
|
29
|
+
return { data, url }
|
|
30
|
+
} catch (error) {
|
|
31
|
+
lastError = error
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw lastError || new Error('抓不到 LINE 貼圖')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function toAttachment(stickerId, resourceType) {
|
|
38
|
+
if (!available()) throw new Error('這個 profile 沒有附件服務(attachments)')
|
|
39
|
+
const key = `${stickerId}:${resourceType || ''}`
|
|
40
|
+
if (cache.has(key)) return cache.get(key)
|
|
41
|
+
const { data } = await fetchBytes(stickerId, resourceType)
|
|
42
|
+
const [ref] = await attachments.saveImages([
|
|
43
|
+
{ data, mediaType: 'image/png', name: `line-sticker-${stickerId}.png` },
|
|
44
|
+
])
|
|
45
|
+
if (!ref) throw new Error('附件服務沒有回傳圖片參照')
|
|
46
|
+
cache.set(key, ref)
|
|
47
|
+
if (cache.size > 300) cache.delete(cache.keys().next().value)
|
|
48
|
+
logger?.info?.(`已取得 LINE 貼圖圖片(stickerId=${stickerId},${data.byteLength} bytes)`)
|
|
49
|
+
return ref
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { available, toAttachment }
|
|
53
|
+
}
|