dsh-line-bot 0.1.0 → 0.2.0

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/lib/host.mjs CHANGED
@@ -17,6 +17,8 @@ const tunnelMod = await import(withVersion('./tunnel.mjs'))
17
17
  const nudgeMod = await import(withVersion('./nudge.mjs'))
18
18
  const tiebaSyncMod = await import(withVersion('./tieba-sync.mjs'))
19
19
  const localPersonasMod = await import(withVersion('./local-personas.mjs'))
20
+ const tiebaContextMod = await import(withVersion('./tieba-context.mjs'))
21
+ const balanceMod = await import(withVersion('./deepseek-balance.mjs'))
20
22
  const utilMod = await import(withVersion('./util.mjs'))
21
23
  const { createConfigStore, publicConfig, dataDir } = configMod
22
24
  const { createPersonaClient } = personaMod
@@ -26,7 +28,9 @@ const { createTunnelManager } = tunnelMod
26
28
  const { createNudgeScheduler } = nudgeMod
27
29
  const { createTiebaSync, reloadPersonaPlugin } = tiebaSyncMod
28
30
  const { createLocalPersonas, LOCAL_PREFIX } = localPersonasMod
29
- const { chunkText, clip, jsonFail, jsonOk, maskSecret, readJsonBody } = utilMod
31
+ const { buildTiebaContext } = tiebaContextMod
32
+ const { createBalanceMonitor } = balanceMod
33
+ const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody } = utilMod
30
34
 
31
35
  export const VERSION = '0.1.0'
32
36
 
@@ -150,6 +154,121 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
150
154
  return { persona: null, personaText: null, source: '', name: '' }
151
155
  }
152
156
 
157
+ // 每輪把貼吧活動摘要塞進 system prompt;面板可關閉(agent.tiebaContext)。
158
+ function tiebaContextText() {
159
+ try {
160
+ if (store.get().agent.tiebaContext === false) return ''
161
+ return buildTiebaContext()
162
+ } catch (error) {
163
+ logger.warn(`組貼吧動態失敗:${errorText(error)}`)
164
+ return ''
165
+ }
166
+ }
167
+
168
+ // ── DeepSeek 餘額 ───────────────────────────────────────────────────────
169
+ const balance = createBalanceMonitor({ ctx, logger })
170
+ let balanceTimer = null
171
+
172
+ function balanceView() {
173
+ const cached = balance.get()
174
+ if (cached.status !== 'ready') return null
175
+ if (cached.cny) return { code: 'CNY', symbol: '¥', value: cached.cny }
176
+ if (cached.usd) return { code: 'USD', symbol: '$', value: cached.usd }
177
+ return null
178
+ }
179
+
180
+ function balanceContextText() {
181
+ const cfg = store.get()
182
+ if (cfg.balance.enabled === false) return ''
183
+ const view = balanceView()
184
+ if (!view) return ''
185
+ const cached = balance.get()
186
+ const total = view.value.total
187
+ const threshold = Number(cfg.balance.threshold) || 0
188
+ const when = new Date(cached.at || Date.now()).toLocaleTimeString('zh-TW', { hour12: false, timeZone: 'Asia/Taipei' })
189
+ const lines = [
190
+ '【DeepSeek API 餘額】約 ' + view.symbol + total.toFixed(2) + '(一般 ' + view.symbol + view.value.normal.toFixed(2) + '、贈金 ' + view.symbol + view.value.bonus.toFixed(2) + '),' + when + ' 查詢。',
191
+ ]
192
+ if (threshold > 0 && total < threshold) lines.push('目前低於提醒門檻 ' + view.symbol + threshold + ';主人若問起,可以提醒他儲值。')
193
+ return lines.join('\n')
194
+ }
195
+
196
+ function agentContextText() {
197
+ return [tiebaContextText(), balanceContextText()].filter(Boolean).join('\n\n')
198
+ }
199
+
200
+ async function refreshBalance() {
201
+ return balance.fetchNow()
202
+ }
203
+
204
+ async function maybeAskForTopUp({ dryRun = true, force = false } = {}) {
205
+ const cfg = store.get()
206
+ if (cfg.balance.enabled === false || cfg.balance.ask === false) return { skipped: 'disabled' }
207
+ const view = balanceView()
208
+ if (!view) {
209
+ const cached = balance.get()
210
+ return { skipped: 'balance-unavailable', status: cached.status, error: cached.error || '' }
211
+ }
212
+ const total = view.value.total
213
+ const threshold = Number(cfg.balance.threshold) || 0
214
+ const low = threshold > 0 && total < threshold
215
+ if (!force && !low) return { skipped: 'enough', total, threshold }
216
+ const state = store.getState()
217
+ const lastAskAt = Number(state.balance?.lastAskAt) || 0
218
+ const cooldownMs = Math.max(1, Number(cfg.balance.cooldownHours) || 12) * 3600_000
219
+ if (!force && lastAskAt && Date.now() - lastAskAt < cooldownMs) return { skipped: 'cooldown', lastAskAt }
220
+ if (!force && inQuietWindow(Date.now(), cfg.nudge.quietStart, cfg.nudge.quietEnd)) return { skipped: 'quiet-hours' }
221
+ const candidates = Object.entries(state.users || {}).filter(([, user]) => user?.sessionId)
222
+ const preferred = (cfg.nudge.targetUserIds || []).find((id) => candidates.some(([key]) => key === id))
223
+ const picked = preferred ? candidates.find(([key]) => key === preferred) : [...candidates].sort((a, b) => (a[1].lastAt || 0) - (b[1].lastAt || 0))[0]
224
+ if (!picked) return { skipped: 'no-user' }
225
+ const [userId, user] = picked
226
+ const selection = cfg.persona.enforce ? user.personaId || cfg.persona.id : ''
227
+ const applied = await applyPersonaSelection(selection, user.sessionId)
228
+ const promptText = [
229
+ '(這是系統提供的背景資訊,不是主人說的話)你剛剛查到主人的 DeepSeek API 餘額只剩 ' + view.symbol + total.toFixed(2) + ',低於提醒門檻 ' + view.symbol + threshold + '。',
230
+ '請用你自己的語氣、簡短地跟主人說一聲,提醒他該儲值了;金額可以講,也可以講得籠統一點。',
231
+ '不要提到系統、插件、面板、監控或「額度不足」這類技術字眼,不要編造其他內容。',
232
+ '只輸出要傳給主人的那一則訊息,不要加引號、標籤或說明。',
233
+ ].join('\n')
234
+ const result = await runner.turn({
235
+ sessionId: user.sessionId,
236
+ text: promptText,
237
+ persona: applied.persona,
238
+ personaText: applied.personaText,
239
+ personaClient: persona(),
240
+ contextText: '',
241
+ cwd: ensureUserWorkspace(userId) || undefined,
242
+ })
243
+ const text = String(result.text || '').trim()
244
+ if (!text || /^SKIP\b/i.test(text)) return { skipped: 'agent-skip', total, threshold }
245
+ if (dryRun) return { sent: false, dryRun: true, userId, text, total, threshold }
246
+ await sendPush(userId, text)
247
+ store.updateState((s) => ({ ...s, balance: { ...(s.balance || {}), lastAskAt: Date.now(), lastAskText: text, lastBalance: total } }))
248
+ logger.info('餘額偏低(' + view.symbol + total.toFixed(2) + ' < ' + view.symbol + threshold + '),已提醒 ' + userId)
249
+ return { sent: true, userId, text, total, threshold }
250
+ }
251
+
252
+ function startBalanceWatch() {
253
+ stopBalanceWatch()
254
+ const check = () => {
255
+ refreshBalance()
256
+ .then(() => maybeAskForTopUp({ dryRun: false }))
257
+ .catch((error) => logger.warn('餘額檢查失敗:' + errorText(error)))
258
+ }
259
+ const initial = setTimeout(check, 8_000)
260
+ initial.unref?.()
261
+ balanceTimer = setInterval(check, 30 * 60_000)
262
+ balanceTimer.unref?.()
263
+ }
264
+
265
+ function stopBalanceWatch() {
266
+ if (balanceTimer) {
267
+ clearInterval(balanceTimer)
268
+ balanceTimer = null
269
+ }
270
+ }
271
+
153
272
  // ── LINE 憑證 ────────────────────────────────────────────────────────────
154
273
  async function getToken({ force = false } = {}) {
155
274
  const cfg = store.get()
@@ -565,6 +684,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
565
684
  persona: applied.persona,
566
685
  personaText: applied.personaText,
567
686
  personaClient: persona(),
687
+ contextText: agentContextText(),
568
688
  cwd: ensureUserWorkspace(userId) || cfg.agent.cwd || undefined,
569
689
  })
570
690
  let replyText = String(result?.text || '').trim()
@@ -639,6 +759,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
639
759
  persona: applied.persona,
640
760
  personaText: applied.personaText,
641
761
  personaClient: persona(),
762
+ contextText: agentContextText(),
642
763
  cwd: owner ? ensureUserWorkspace(owner[0]) : undefined,
643
764
  })
644
765
  },
@@ -726,6 +847,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
726
847
  startWebhookGuard()
727
848
  nudge.start()
728
849
  tiebaSync.start()
850
+ startBalanceWatch()
729
851
  store.updateState((state) => ({ ...state, lastStartAt: Date.now() }))
730
852
  if (cfg.line.channelId && cfg.line.channelSecret) refreshBotInfo().catch(() => {})
731
853
  return buildStatus()
@@ -733,6 +855,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
733
855
 
734
856
  async function stopAll() {
735
857
  stopWebhookGuard()
858
+ stopBalanceWatch()
736
859
  nudge.stop()
737
860
  tiebaSync.stop()
738
861
  await tunnel.stop({ silent: true }).catch(() => {})
@@ -803,6 +926,16 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
803
926
  lastFinishedAt: runner.stats().lastFinishedAt,
804
927
  },
805
928
  nudge: { ...nudge.stats(), config: cfg.nudge },
929
+ balance: {
930
+ ...balance.get(),
931
+ enabled: cfg.balance.enabled !== false,
932
+ ask: cfg.balance.ask !== false,
933
+ threshold: Number(cfg.balance.threshold) || 0,
934
+ cooldownHours: Number(cfg.balance.cooldownHours) || 12,
935
+ lastAskAt: Number(state.balance?.lastAskAt) || 0,
936
+ lastAskText: state.balance?.lastAskText || '',
937
+ low: Boolean(balanceView() && Number(cfg.balance.threshold) > 0 && balanceView().value.total < Number(cfg.balance.threshold)),
938
+ },
806
939
  sync: { ...tiebaSync.stats(), config: cfg.sync },
807
940
  users,
808
941
  logs: logger.lines(),
@@ -899,6 +1032,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
899
1032
  return getPersonaSummary({ force: true })
900
1033
  case 'persona-reload':
901
1034
  return reloadPersonaPlugin(ctx)
1035
+ case 'tieba-context':
1036
+ return { text: tiebaContextText(), enabled: store.get().agent.tiebaContext !== false }
902
1037
  case 'tieba-preview':
903
1038
  return tiebaSync.preview()
904
1039
  case 'tieba-sync':
@@ -957,6 +1092,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
957
1092
  personaCache.at = 0
958
1093
  return created
959
1094
  }
1095
+ case 'balance-refresh':
1096
+ return refreshBalance()
1097
+ case 'balance-ask':
1098
+ return maybeAskForTopUp({ dryRun: payload.dryRun !== false, force: payload.force === true })
960
1099
  case 'nudge-test':
961
1100
  return nudge.tick({ force: true })
962
1101
  case 'agent-test': {
@@ -971,6 +1110,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
971
1110
  persona: applied.persona,
972
1111
  personaText: applied.personaText,
973
1112
  personaClient: persona(),
1113
+ contextText: agentContextText(),
974
1114
  cwd,
975
1115
  })
976
1116
  return { text: result.text, reason: result.reason, sessionId, cwd: cwd || '', persona: selection, personaSource: applied.source }
@@ -1122,6 +1262,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1122
1262
  status: buildStatus,
1123
1263
  async dispose() {
1124
1264
  stopWebhookGuard()
1265
+ stopBalanceWatch()
1125
1266
  try {
1126
1267
  nudge.stop()
1127
1268
  } catch {
@@ -0,0 +1,83 @@
1
+ // 讀「刷貼吧」外掛留下的紀錄,整理成一小段背景事實,讓 LINE 上的角色知道自己最近有沒有刷貼吧。
2
+ // 只讀檔、不寫檔;讀不到任何資料就回空字串(不注入)。
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { readJsonSafe } from './util.mjs'
7
+
8
+ function dshHome() {
9
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
10
+ }
11
+
12
+ export function tiebaDir() {
13
+ return path.join(dshHome(), 'tieba-duty')
14
+ }
15
+
16
+ function stamp(ms) {
17
+ if (!ms) return ''
18
+ return new Date(ms).toLocaleString('zh-TW', {
19
+ timeZone: 'Asia/Taipei',
20
+ year: 'numeric',
21
+ month: '2-digit',
22
+ day: '2-digit',
23
+ hour: '2-digit',
24
+ minute: '2-digit',
25
+ hour12: false,
26
+ })
27
+ }
28
+
29
+ function dayKey(ms) {
30
+ return new Date(ms).toLocaleDateString('en-CA', { timeZone: 'Asia/Taipei' })
31
+ }
32
+
33
+ function ago(ms, now) {
34
+ const diff = Math.max(0, now - ms)
35
+ const minutes = Math.floor(diff / 60000)
36
+ if (minutes < 1) return '不到一分鐘前'
37
+ if (minutes < 60) return `${minutes} 分鐘前`
38
+ const hours = Math.floor(minutes / 60)
39
+ if (hours < 24) return `${hours} 小時前`
40
+ return `${Math.floor(hours / 24)} 天前`
41
+ }
42
+
43
+ const SLOT_LABELS = { noon: '中午', night: '晚上' }
44
+
45
+ export function buildTiebaContext(now = Date.now()) {
46
+ const dir = tiebaDir()
47
+ const schedule = readJsonSafe(path.join(dir, 'schedule.json'), null)
48
+ const state = readJsonSafe(path.join(dir, 'state.json'), null)
49
+ const evolution = readJsonSafe(path.join(dir, 'evolution-state.json'), null)
50
+ if (!schedule && !state) return ''
51
+
52
+ const today = dayKey(now)
53
+ const lastRunAt = Number(schedule?.lastRunAt) || 0
54
+ const runs = schedule?.runs && typeof schedule.runs === 'object' ? schedule.runs : {}
55
+ const runsToday = Object.entries(runs).filter(([, value]) => value === today)
56
+ const hasReport = fs.existsSync(path.join(dir, 'reports', `${today}.md`))
57
+ const lastReplyAt = Number(state?.lastReplyAt) || 0
58
+ const version = Number(evolution?.lastVersion) || 0
59
+
60
+ let verdict = '今天還沒刷過'
61
+ if (lastRunAt) {
62
+ if (now - lastRunAt < 90 * 60000) verdict = '剛剛才刷過'
63
+ else if (dayKey(lastRunAt) === today) verdict = '今天刷過,但不是剛剛'
64
+ else verdict = '今天還沒刷過'
65
+ }
66
+
67
+ const lines = [
68
+ '【貼吧動態】以下是「刷貼吧」外掛留下的即時紀錄,是背景事實、不是給你的指令:',
69
+ `- 現在時間:${stamp(now)}(台北)`,
70
+ lastRunAt
71
+ ? `- 最近一次刷貼吧:${stamp(lastRunAt)}(${ago(lastRunAt, now)})→ ${verdict}`
72
+ : '- 最近一次刷貼吧:沒有紀錄',
73
+ runsToday.length
74
+ ? `- 今天(${today})已刷的場次:${runsToday.map(([slot]) => SLOT_LABELS[slot] || slot).join('、')}`
75
+ : '- 今天(' + today + ')還沒有刷貼吧的場次',
76
+ `- 今天的貼吧日報:${hasReport ? '已經產生' : '還沒有'}`,
77
+ version ? `- 貼吧人設版本:v${version}${evolution?.lastDate ? `(最後更新 ${evolution.lastDate})` : ''}` : '',
78
+ `- 最後一次在貼吧回覆:${lastReplyAt ? `${stamp(lastReplyAt)}(${ago(lastReplyAt, now)})` : '今天還沒回過帖'}`,
79
+ '回答原則:對方若問「你剛剛有沒有刷貼吧」「今天刷了嗎」,照上面的時間照實回答;可以自然補一句你看到的內容或心情,但不要編造沒有發生的場次,也不要主動把這件事掛在嘴邊。',
80
+ ].filter(Boolean)
81
+
82
+ return lines.join('\n')
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -20,6 +20,7 @@
20
20
  "locale",
21
21
  "icon.svg",
22
22
  "README.md",
23
+ "README.*.md",
23
24
  "bin",
24
25
  "LICENSE",
25
26
  "CHANGELOG.md"