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/CHANGELOG.md +6 -0
- package/README.en.md +172 -0
- package/README.ja.md +172 -0
- package/README.ko.md +172 -0
- package/README.md +25 -0
- package/README.zh-CN.md +172 -0
- package/client.js +92 -0
- package/lib/agent-runner.mjs +29 -16
- package/lib/config.mjs +17 -0
- package/lib/deepseek-balance.mjs +65 -0
- package/lib/host.mjs +142 -1
- package/lib/tieba-context.mjs +83 -0
- package/package.json +2 -1
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 {
|
|
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.
|
|
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"
|