dsh-line-bot 0.5.4 → 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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.5
4
+
5
+ - 收到 LINE 貼圖時抓取貼圖圖片,透過 DSH 附件服務交給模型閱讀(模型不支援圖片時自動退回貼圖關鍵字)。
6
+ - 面板「LINE 憑證」新增「看得懂貼圖」開關;README(五語言)補說明。
7
+
3
8
  ## 0.5.4
4
9
 
5
10
  - LINE 不會播放 APNG 動畫(只顯示第一幀),所以 GIF 預設改成靜態 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
@@ -891,6 +891,7 @@ window.__ModuleLoader__.load({
891
891
  ),
892
892
  h(Toggle, { checked: line.useAutoToken !== false, onChange: (value) => update({ line: { useAutoToken: value } }), label: '自動簽發 access token', hint: '有 Channel ID+Secret 時,優先自動取得(建議開啟)' }),
893
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: '收到貼圖時抓圖片給模型看;模型不支援圖片時仍會用貼圖關鍵字' }),
894
895
  h('div', { style: S.row },
895
896
  h(Btn, { onClick: () => act('line-verify'), disabled: !!busy, tone: 'primary' }, busy === 'line-verify' ? '驗證中…' : '驗證並讀取 bot 資訊'),
896
897
  h(Btn, { onClick: () => act('token-refresh'), disabled: !!busy }, '重新簽發 token'),
@@ -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: [{ type: 'text', text }],
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: '(剛剛卡住了,再說一次好嗎?)',
@@ -179,6 +180,7 @@ export function sanitizePatch(current, patch) {
179
180
  next.line.tokenExpiresAt = clampInt(next.line.tokenExpiresAt, 0, Number.MAX_SAFE_INTEGER, 0)
180
181
  next.line.allowedUserIds = safeArray(next.line.allowedUserIds)
181
182
  next.line.loadingEnabled = Boolean(next.line.loadingEnabled)
183
+ next.line.stickerVision = Boolean(next.line.stickerVision)
182
184
  next.line.welcomeText = String(next.line.welcomeText ?? '').slice(0, 1000)
183
185
  next.line.errorReply = String(next.line.errorReply ?? '').slice(0, 500)
184
186
  next.line.emptyReply = String(next.line.emptyReply ?? '').slice(0, 500)
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.4'
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
- entry.models = (models || []).map((item) => ({ id: item.id, name: item.name || item.id }))
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
  }
@@ -1127,11 +1140,38 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1127
1140
  logger.warn(`略過未授權的使用者 ${userId}(allowedUserIds)`)
1128
1141
  return
1129
1142
  }
1130
- if (event.message?.type !== 'text') {
1131
- logger.info(`略過非文字訊息(${event.message?.type})`)
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})`)
1132
1173
  return
1133
1174
  }
1134
- const text = String(event.message.text || '').trim()
1135
1175
  if (!text) return
1136
1176
 
1137
1177
  const user = ensureUser(userId)
@@ -1169,6 +1209,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1169
1209
  personaClient: persona(),
1170
1210
  contextText: agentContextText(userId),
1171
1211
  tools: memoryToolsFor(userId),
1212
+ images: messageImages,
1172
1213
  cwd: ensureUserWorkspace(userId) || cfg.agent.cwd || undefined,
1173
1214
  })
1174
1215
  let replyText = String(result?.text || '').trim()
@@ -1197,6 +1238,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1197
1238
  const runner = createAgentRunner({ ctx, logger, getConfig: () => store.get() })
1198
1239
  const stickers = createStickerStore({ dir, logger })
1199
1240
  const memes = createMemeSource({ ctx, logger })
1241
+ const stickerImages = createStickerImages({ ctx, logger, dir })
1200
1242
  const mediaTokens = new Map()
1201
1243
 
1202
1244
  const server = createWebhookServer({ getConfig: () => store.get(), logger, dispatch: handleEvent, getMedia })
@@ -1628,6 +1670,19 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1628
1670
  return maybeAskForTopUp({ dryRun: payload.dryRun !== false, force: payload.force === true })
1629
1671
  case 'meme-stats':
1630
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
+ }
1631
1686
  case 'meme-preview': {
1632
1687
  if (!payload.text) throw new Error('缺少 text')
1633
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",