@kukonce/dsh-loomy 0.0.0-stage → 0.3.2

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.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Loomy accounts: logging in, keeping the session, and adopting an existing one.
3
+ *
4
+ * There is no API key in Loomy. An account is an iFlytek CAccount user, and the
5
+ * `session` string that CAccount hands back after a successful login is the only
6
+ * credential every later call needs. That makes this module the single owner of
7
+ * three things:
8
+ *
9
+ * 1. the login protocol (SMS code, plus the password path for completeness),
10
+ * 2. the on-disk account table (`accounts.json`) with each session in it,
11
+ * 3. importing a session the Loomy desktop app already has on this machine.
12
+ *
13
+ * The session is a bearer string with a long life and no refresh endpoint: CAccount
14
+ * simply answers `100002` once it is retired. So the daily-credit pass is also the
15
+ * natural liveness probe — a session that can read the balance is alive.
16
+ */
17
+
18
+ import { randomUUID } from 'node:crypto'
19
+
20
+ import {
21
+ ACCOUNT_ACCESS_KEY_ID,
22
+ ACCOUNT_ACCESS_KEY_SECRET,
23
+ ACCOUNT_APP_ID,
24
+ ACCOUNT_BASE_URL,
25
+ ACCOUNT_CLIENT_UA,
26
+ API_BASE_URL
27
+ } from '../core/endpoints.js'
28
+ import { requestJson, responseCode, responseData, responseMessage, unwrap, readString } from '../core/http.js'
29
+ import { readJson, writeJson } from '../core/paths.js'
30
+ import { buildAccountHeaders } from '../core/sign.js'
31
+ import { findDesktopSession } from './desktop-session.js'
32
+
33
+ const ACCOUNTS_FILE = 'accounts.json'
34
+ const SESSION_EXPIRE_SECONDS = 14 * 24 * 3600
35
+
36
+ /**
37
+ * An account id is used in file names and as a provider route suffix, so it stays
38
+ * to the characters both tolerate: letters, digits, dash.
39
+ */
40
+ export const ACCOUNT_ID_PATTERN = /^[a-z][a-z0-9-]{0,23}$/u
41
+
42
+ /** The `base` envelope block every CAccount request carries. */
43
+ function baseBlock() {
44
+ return {
45
+ appid: ACCOUNT_APP_ID,
46
+ modelid: 'Web',
47
+ version: '1.0.0',
48
+ devid: 'web',
49
+ ua: ACCOUNT_CLIENT_UA,
50
+ traceid: randomUUID().replace(/-/gu, '')
51
+ }
52
+ }
53
+
54
+ /**
55
+ * One signed CAccount call.
56
+ *
57
+ * The body is serialized once and signed over exactly those bytes: re-serializing
58
+ * between signing and sending is how `010003` happens, because key order and
59
+ * whitespace are part of the MD5.
60
+ */
61
+ async function accountCall(path, param) {
62
+ const body = JSON.stringify({ base: baseBlock(), param })
63
+ const headers = buildAccountHeaders({
64
+ accessKeyId: ACCOUNT_ACCESS_KEY_ID,
65
+ accessKeySecret: ACCOUNT_ACCESS_KEY_SECRET,
66
+ method: 'POST',
67
+ path,
68
+ body
69
+ })
70
+ const { status, json } = await requestJson('POST', `${ACCOUNT_BASE_URL}${path}`, headers, { body })
71
+ if (status !== 200) {
72
+ throw new Error(`${path} 调用失败:HTTP ${status} ${responseMessage(json)}`.trim())
73
+ }
74
+ return json
75
+ }
76
+
77
+ /** `/login/phone/sendMsgCode` — ask for an SMS code; answers `{ msgid, expire }`. */
78
+ export async function sendSmsCode(phone) {
79
+ const normalized = String(phone ?? '').trim()
80
+ if (!/^1[3-9]\d{9}$/u.test(normalized)) {
81
+ throw new Error('手机号格式不正确(需要 11 位大陆手机号)')
82
+ }
83
+ const json = unwrap(
84
+ await accountCall('/login/phone/sendMsgCode', { ccode: '86', phone: normalized, expire: 300 }),
85
+ '发送验证码'
86
+ )
87
+ const data = responseData(json) ?? {}
88
+ return {
89
+ phone: normalized,
90
+ msgid: readString(data, 'msgid'),
91
+ expire: typeof data.expire === 'number' ? data.expire : 300
92
+ }
93
+ }
94
+
95
+ /**
96
+ * `/login/phone/checkCode` — exchange the SMS code for a session.
97
+ *
98
+ * Answers the account record the rest of the plugin uses; `phone` is echoed from
99
+ * the request because CAccount does not always return it.
100
+ */
101
+ export async function loginBySmsCode({ phone, code, msgid, label = '' }) {
102
+ const normalizedPhone = String(phone ?? '').trim()
103
+ const normalizedCode = String(code ?? '').trim()
104
+ const normalizedMsgid = String(msgid ?? '').trim()
105
+ if (!/^1[3-9]\d{9}$/u.test(normalizedPhone)) throw new Error('手机号格式不正确')
106
+ if (!/^\d{4,8}$/u.test(normalizedCode)) throw new Error('验证码格式不正确')
107
+ if (normalizedMsgid.length === 0) throw new Error('缺少 msgid,请先获取验证码')
108
+
109
+ const json = unwrap(
110
+ await accountCall('/login/phone/checkCode', {
111
+ ccode: '86',
112
+ phone: normalizedPhone,
113
+ mcode: normalizedCode,
114
+ msgid: normalizedMsgid,
115
+ expire: SESSION_EXPIRE_SECONDS
116
+ }),
117
+ '验证码登录'
118
+ )
119
+ const data = responseData(json) ?? {}
120
+ const session = readString(data, 'session')
121
+ const userid = readString(data, 'userid')
122
+ if (session.length === 0 || userid.length === 0) {
123
+ throw new Error('登录失败:服务端未返回 session 或 userid')
124
+ }
125
+ return {
126
+ session,
127
+ userid,
128
+ phone: readString(data, 'phone') || normalizedPhone,
129
+ label: String(label ?? '').trim(),
130
+ loggedInAt: Date.now()
131
+ }
132
+ }
133
+
134
+ /** `/userinfo/query/baseInfo` — display metadata for a signed-in account. */
135
+ export async function fetchUserInfo(session) {
136
+ const token = String(session ?? '').trim()
137
+ if (token.length === 0) throw new Error('缺少 session')
138
+ const json = unwrap(await accountCall('/userinfo/query/baseInfo', { session: token }), '获取用户信息')
139
+ const data = responseData(json) ?? {}
140
+ return {
141
+ userid: readString(data, 'userid'),
142
+ phone: readString(data, 'phone'),
143
+ nickname: readString(data, 'nickname'),
144
+ headpic: readString(data, 'headpic')
145
+ }
146
+ }
147
+
148
+ /**
149
+ * `/login/account/logout` — retire a session server-side.
150
+ *
151
+ * Best effort by design: a session the server already forgot is still a session this
152
+ * plugin should drop locally, and a network failure must not block a local logout.
153
+ */
154
+ export async function logoutRemote(session) {
155
+ const token = String(session ?? '').trim()
156
+ if (token.length === 0) return { ok: true, skipped: true }
157
+ try {
158
+ const json = await accountCall('/login/account/logout', { session: token })
159
+ const code = responseCode(json)
160
+ return { ok: code === '000000', code }
161
+ } catch (error) {
162
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
163
+ }
164
+ }
165
+
166
+ /* ── the account table ─────────────────────────────────────────────────────── */
167
+
168
+ /**
169
+ * The stored table: `{ version, accounts: [{ id, label, session, userid, phone, loggedInAt }] }`.
170
+ *
171
+ * A missing or unreadable file reads as "no accounts" rather than throwing: a corrupt
172
+ * file must not stop the host from starting, and the next write repairs it.
173
+ */
174
+ export function readAccounts() {
175
+ const parsed = readJson(ACCOUNTS_FILE)
176
+ const list = parsed?.accounts
177
+ if (!Array.isArray(list)) return []
178
+ return list
179
+ .filter((entry) => typeof entry === 'object' && entry !== null)
180
+ .map((entry) => ({
181
+ id: String(entry.id ?? '').trim(),
182
+ label: String(entry.label ?? '').trim(),
183
+ session: String(entry.session ?? '').trim(),
184
+ userid: String(entry.userid ?? '').trim(),
185
+ phone: String(entry.phone ?? '').trim(),
186
+ loggedInAt: typeof entry.loggedInAt === 'number' ? entry.loggedInAt : 0,
187
+ source: entry.source === 'desktop' ? 'desktop' : 'sms'
188
+ }))
189
+ .filter((entry) => ACCOUNT_ID_PATTERN.test(entry.id))
190
+ }
191
+
192
+ function writeAccounts(accounts) {
193
+ writeJson(ACCOUNTS_FILE, { version: 1, accounts }, { mode: 0o600, pretty: true })
194
+ }
195
+
196
+ /** One account by id, or `undefined`. */
197
+ export function accountOf(id) {
198
+ const key = String(id ?? '').trim().toLowerCase()
199
+ return readAccounts().find((entry) => entry.id === key)
200
+ }
201
+
202
+ /** Pick an unused `loomy-N` id, preferring `loomy` for the first account. */
203
+ function nextAccountId(accounts) {
204
+ if (!accounts.some((entry) => entry.id === 'loomy')) return 'loomy'
205
+ for (let index = 2; index < 100; index += 1) {
206
+ const candidate = `loomy${index}`
207
+ if (!accounts.some((entry) => entry.id === candidate)) return candidate
208
+ }
209
+ throw new Error('账号数量已达上限(99 个)')
210
+ }
211
+
212
+ /**
213
+ * Store a freshly authenticated account.
214
+ *
215
+ * A re-login of the same `userid` updates that account's slot instead of adding a
216
+ * duplicate — otherwise every SMS login would leave the previous session behind as a
217
+ * second, stale entry for the same person.
218
+ */
219
+ export function upsertAccount({ session, userid, phone = '', label = '', source = 'sms' }) {
220
+ const token = String(session ?? '').trim()
221
+ const uid = String(userid ?? '').trim()
222
+ if (token.length === 0 || uid.length === 0) throw new Error('账号记录缺少 session 或 userid')
223
+
224
+ const accounts = readAccounts()
225
+ const existing = accounts.find((entry) => entry.userid === uid)
226
+ const record = {
227
+ id: existing?.id ?? nextAccountId(accounts),
228
+ label: String(label ?? '').trim() || existing?.label || '',
229
+ session: token,
230
+ userid: uid,
231
+ phone: String(phone ?? '').trim() || existing?.phone || '',
232
+ loggedInAt: Date.now(),
233
+ source
234
+ }
235
+ const next = existing === undefined
236
+ ? [...accounts, record]
237
+ : accounts.map((entry) => (entry.id === record.id ? record : entry))
238
+ writeAccounts(next)
239
+ return record
240
+ }
241
+
242
+ /** Drop one account (and its session) from the table. */
243
+ export function removeAccount(id) {
244
+ const key = String(id ?? '').trim().toLowerCase()
245
+ const accounts = readAccounts()
246
+ const next = accounts.filter((entry) => entry.id !== key)
247
+ const removed = next.length !== accounts.length
248
+ if (removed) writeAccounts(next)
249
+ return removed
250
+ }
251
+
252
+ /** Rename one account; the id is what routes and cache files hang off, so it stays. */
253
+ export function renameAccount(id, label) {
254
+ const key = String(id ?? '').trim().toLowerCase()
255
+ const accounts = readAccounts()
256
+ const next = accounts.map((entry) => (entry.id === key ? { ...entry, label: String(label ?? '').trim() } : entry))
257
+ writeAccounts(next)
258
+ return next.find((entry) => entry.id === key)
259
+ }
260
+
261
+ /**
262
+ * Adopt the login the Loomy desktop app already has on this machine.
263
+ *
264
+ * Answers the stored record so the caller can report which account was picked up.
265
+ * Throws when there is nothing to import — that is a normal outcome on a machine
266
+ * where Loomy has never been signed in to, and the panel says so.
267
+ *
268
+ * `directory` is the user naming a location themselves (`/loomy import <directory>`);
269
+ * without it the search never leaves this user's own directories. Importing is always
270
+ * something the user asked for — nothing in this plugin adopts a desktop login on its own.
271
+ */
272
+ export async function importDesktopAccount({ label = '', directory = '' } = {}) {
273
+ const found = findDesktopSession(directory)
274
+ if (found === undefined) {
275
+ throw new Error('未找到 Loomy 桌面端的登录态(请先在 Loomy 客户端登录,或改用短信登录)')
276
+ }
277
+ const record = upsertAccount({
278
+ session: found.session,
279
+ userid: found.userid,
280
+ phone: found.phone,
281
+ label: String(label ?? '').trim() || 'Loomy 桌面端',
282
+ source: 'desktop'
283
+ })
284
+ return { record, path: found.path }
285
+ }
286
+
287
+ /** Validate an account id the panel supplied, or throw with a usable message. */
288
+ export function assertAccountId(id) {
289
+ const key = String(id ?? '').trim().toLowerCase()
290
+ if (!ACCOUNT_ID_PATTERN.test(key)) {
291
+ throw new Error(`账号 id "${key}" 不合法(需匹配 ${String(ACCOUNT_ID_PATTERN)})`)
292
+ }
293
+ return key
294
+ }
295
+
296
+ /** The API host, exported so the panel can show where traffic goes. */
297
+ export const API_HOST = API_BASE_URL
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Points: the balance, the daily 5000 renewal, and the grant ledger.
3
+ *
4
+ * Loomy's metering splits the balance into two pots, and which pot a call spends from
5
+ * is decided server-side:
6
+ *
7
+ * 每日赠送 (daily) 5000 every day, reset by a `first-login` call, spends first
8
+ * 长期 / 永久 (permanent) registration reward, onboarding tasks, redemption codes
9
+ *
10
+ * The important discovery is what actually renews the daily pot: it is
11
+ * `POST /api/v1/points/first-login`, which despite its name is a per-day idempotent
12
+ * "claim today's daily quota" call. It answers `{ alreadyProcessed: true }` on every
13
+ * repeat within the same `dailyCycleDate` and `dailyQuota: 5000` with a full
14
+ * `dailyBalance`. That is why this module claims it at startup and again at each local
15
+ * midnight: the claim is the only thing that keeps 5000 available every day, and it
16
+ * costs nothing to repeat.
17
+ *
18
+ * Onboarding tasks are a separate one-time pool (10000 available per account, keys
19
+ * listed below) and are claimed only when the user asks, never automatically — taking
20
+ * them unasked would spend a resource the user might want to hand out deliberately.
21
+ */
22
+
23
+ import { API_BASE_URL, CLIENT_VERSION, PURPOSE_CHAT_MESSAGE } from '../core/endpoints.js'
24
+ import { requestJson, unwrap, responseData, readString, localDayKey, AUTH_ERROR_CODES } from '../core/http.js'
25
+ import { readJson, writeJson } from '../core/paths.js'
26
+
27
+ const GRANT_FILE = 'daily-grant.json'
28
+
29
+ /** Header set for the points/business API: this one authenticates with `token` alone. */
30
+ function pointsHeaders(session) {
31
+ return {
32
+ token: session,
33
+ Accept: 'application/json',
34
+ 'loomy-version': CLIENT_VERSION,
35
+ 'X-Loomy-Request-Purpose': PURPOSE_CHAT_MESSAGE
36
+ }
37
+ }
38
+
39
+ async function get(session, path, label) {
40
+ const { status, json } = await requestJson('GET', `${API_BASE_URL}${path}`, pointsHeaders(session), {
41
+ timeoutMs: 30_000
42
+ })
43
+ if (status !== 200) {
44
+ const error = new Error(`${label}:HTTP ${status}`)
45
+ error.status = status
46
+ error.isAuthError = status === 401 || status === 403
47
+ throw error
48
+ }
49
+ return unwrap(json, label)
50
+ }
51
+
52
+ async function post(session, path, body, label) {
53
+ const { status, json } = await requestJson('POST', `${API_BASE_URL}${path}`, {
54
+ ...pointsHeaders(session),
55
+ 'Content-Type': 'application/json'
56
+ }, { body: JSON.stringify(body ?? {}), timeoutMs: 30_000 })
57
+ if (status !== 200) {
58
+ const error = new Error(`${label}:HTTP ${status}`)
59
+ error.status = status
60
+ error.isAuthError = status === 401 || status === 403
61
+ throw error
62
+ }
63
+ return unwrap(json, label)
64
+ }
65
+
66
+ const finite = (value, fallback = 0) => (typeof value === 'number' && Number.isFinite(value) ? value : fallback)
67
+
68
+ /**
69
+ * The account's balances, from the first-login claim.
70
+ *
71
+ * This is the one call that returns all four numbers at once (daily, permanent,
72
+ * current, quota) plus today's consumption, so it doubles as the balance read. It is
73
+ * idempotent, which is what makes it safe to call on every panel refresh.
74
+ */
75
+ export async function fetchCredits(session) {
76
+ const json = await post(session, '/api/v1/points/first-login', {}, '读取积分')
77
+ const data = responseData(json) ?? {}
78
+ return {
79
+ ok: true,
80
+ alreadyProcessed: data.alreadyProcessed === true,
81
+ balance: finite(data.currentBalance),
82
+ permanentBalance: finite(data.permanentBalance),
83
+ dailyBalance: finite(data.dailyBalance),
84
+ dailyQuota: finite(data.dailyQuota),
85
+ dailyConsumed: finite(data.dailyConsumed),
86
+ dailyCycleDate: typeof data.dailyCycleDate === 'string' ? data.dailyCycleDate : localDayKey(),
87
+ availableBalance: finite(data.currentBalance) + finite(data.dailyBalance),
88
+ inviteCode: Array.isArray(data.invitationCodes) && data.invitationCodes.length > 0
89
+ ? String(data.invitationCodes[0]?.inviteCode ?? '')
90
+ : ''
91
+ }
92
+ }
93
+
94
+ /** Ledger entries; `direction` is `credit` or `debit`, `granularity` optional. */
95
+ export async function queryRecords(session, { pageNo = 1, pageSize = 20, direction = 'credit', granularity } = {}) {
96
+ const params = new URLSearchParams({
97
+ pageNo: String(Math.max(1, pageNo)),
98
+ pageSize: String(Math.min(100, Math.max(1, pageSize))),
99
+ direction: direction === 'debit' ? 'debit' : 'credit'
100
+ })
101
+ if (typeof granularity === 'string' && granularity.length > 0) params.set('granularity', granularity)
102
+ const json = await get(session, `/api/v2/points/records?${params.toString()}`, '查询积分记录')
103
+ const data = responseData(json) ?? {}
104
+ return {
105
+ balance: finite(data.balance),
106
+ dailyBalance: finite(data.dailyBalance),
107
+ pageNo: finite(data.pageNo, 1),
108
+ pageSize: finite(data.pageSize, 20),
109
+ total: finite(data.total),
110
+ list: Array.isArray(data.list) ? data.list.map(normalizeRecord) : []
111
+ }
112
+ }
113
+
114
+ function normalizeRecord(entry) {
115
+ return {
116
+ ledgerId: readString(entry, 'ledgerId'),
117
+ description: readString(entry, 'description'),
118
+ direction: readString(entry, 'direction'),
119
+ points: finite(entry?.points ?? entry?.pointsActual),
120
+ source: readString(entry, 'source'),
121
+ createdAt: finite(entry?.createdAt)
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Claim today's daily grant — the call that keeps 每日赠送 at 5000.
127
+ *
128
+ * "Granted" means the account now has today's daily pot available, which is what the
129
+ * caller acts on. It is deliberately **not** "the balance equals the quota": any chat
130
+ * turn spends a few points, so a strict equality check reported "领不到" all day on an
131
+ * account that had in fact received its full 5000. A daily balance above zero after the
132
+ * claim is the honest signal, and `available` reports the exact number beside it.
133
+ *
134
+ * Never throws: a claim that is already done, or one the server declines, is a normal
135
+ * outcome the ledger records. The caller decides whether that is worth surfacing.
136
+ */
137
+ export async function claimDailyGrant(session) {
138
+ try {
139
+ const credits = await fetchCredits(session)
140
+ return {
141
+ granted: credits.dailyBalance > 0 && credits.dailyQuota > 0,
142
+ available: credits.dailyBalance,
143
+ alreadyProcessed: credits.alreadyProcessed,
144
+ ...credits
145
+ }
146
+ } catch (error) {
147
+ const code = typeof error?.code === 'string' ? error.code : ''
148
+ if (AUTH_ERROR_CODES.has(code)) {
149
+ return { granted: false, error: '登录已失效', isAuthError: true, code }
150
+ }
151
+ return { granted: false, error: error instanceof Error ? error.message : String(error) }
152
+ }
153
+ }
154
+
155
+ /* ── per-account grant ledger ──────────────────────────────────────────────── */
156
+
157
+ /** `{ [accountId]: { day, at, granted, balance, quota, reason } }`. */
158
+ function readGrantLedger() {
159
+ const parsed = readJson(GRANT_FILE)
160
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {}
161
+ }
162
+
163
+ function writeGrantLedger(ledger) {
164
+ writeJson(GRANT_FILE, ledger, { mode: 0o600, pretty: true })
165
+ }
166
+
167
+ /** The last recorded grant attempt for one account. */
168
+ export function dailyGrantState(accountId) {
169
+ const key = String(accountId ?? '').trim().toLowerCase()
170
+ return readGrantLedger()[key]
171
+ }
172
+
173
+ /** Whether the daily grant for this account has been claimed in the current local day. */
174
+ export function grantedToday(accountId, now = new Date()) {
175
+ const state = dailyGrantState(accountId)
176
+ return state !== undefined && state.day === localDayKey(now)
177
+ }
178
+
179
+ /**
180
+ * Claim and remember, once per local day unless `force` is set.
181
+ *
182
+ * The server is authoritative about whether a claim still counts; this ledger only
183
+ * remembers the outcome so a restart does not re-ask, and so the panel can say when it
184
+ * last ran. `force` exists for the panel's manual button.
185
+ */
186
+ export async function ensureDailyGrant(accountId, session, { force = false, reason = '启动' } = {}) {
187
+ const key = String(accountId ?? '').trim().toLowerCase()
188
+ if (!force && grantedToday(key)) {
189
+ const state = dailyGrantState(key)
190
+ return { skipped: true, day: state.day, ...state }
191
+ }
192
+ const outcome = await claimDailyGrant(session)
193
+ const ledger = readGrantLedger()
194
+ const record = {
195
+ day: localDayKey(),
196
+ at: Date.now(),
197
+ reason,
198
+ granted: outcome.granted === true,
199
+ balance: outcome.dailyBalance,
200
+ quota: outcome.dailyQuota,
201
+ ...(outcome.error === undefined ? {} : { error: outcome.error })
202
+ }
203
+ ledger[key] = record
204
+ try {
205
+ writeGrantLedger(ledger)
206
+ } catch {
207
+ // bookkeeping only — never let it break the claim path
208
+ }
209
+ // A claim that reached the server re-read the remaining daily pot, so it is also the
210
+ // sync point for the spend file below: forget what was recorded since the last claim
211
+ // and let the projection restart from the number the server just confirmed. Any drift
212
+ // (a turn billed outside this process, an onboarding task) self-corrects here. A
213
+ // failed claim leaves both files untouched — the server state is unknown.
214
+ if (outcome.error === undefined) resetAccountUsage(key)
215
+ return { ...outcome, ...record, skipped: false }
216
+ }
217
+
218
+ /* ── chat spend accounting ─────────────────────────────────────────────────── */
219
+
220
+ const USAGE_FILE = 'usage.json'
221
+
222
+ /**
223
+ * What chat has cost, per local day and account:
224
+ * `{ version: 1, byDay: { 'YYYY-MM-DD': { [accountId]: points } } }`.
225
+ *
226
+ * The grant ledger above freezes the daily pot at claim time, and the only place a
227
+ * turn's cost is reported is the gateway's final usage frame (`points_consumed`) —
228
+ * which the adapter records here as the frame arrives. `/state` then projects the
229
+ * claim's balance minus this file's spend for the claim's day, so the composer pill
230
+ * moves while the user chats instead of sticking at the claim-time 5000.
231
+ *
232
+ * Nothing else writes this file: the server's own `dailyConsumed` stays authoritative
233
+ * and every claim (boot, midnight, panel button) re-syncs the projection.
234
+ */
235
+ function readUsageByDay() {
236
+ const parsed = readJson(USAGE_FILE)
237
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
238
+ const byDay = parsed.byDay
239
+ return typeof byDay === 'object' && byDay !== null && !Array.isArray(byDay) ? byDay : {}
240
+ }
241
+
242
+ function usageKey(accountId) {
243
+ return String(accountId ?? '').trim().toLowerCase()
244
+ }
245
+
246
+ /** Points this account has spent from chat on `day` (0 when nothing was recorded). */
247
+ export function consumedOnDay(accountId, day = localDayKey()) {
248
+ const bucket = readUsageByDay()[day]
249
+ const value = typeof bucket === 'object' && bucket !== null ? bucket[usageKey(accountId)] : undefined
250
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : 0
251
+ }
252
+
253
+ /** Add one turn's cost. Bookkeeping only: never throws, never blocks the chat path. */
254
+ export function recordPointsSpent(accountId, points, day = localDayKey()) {
255
+ const key = usageKey(accountId)
256
+ const cost = Number(points)
257
+ if (key.length === 0 || !Number.isFinite(cost) || cost <= 0) return
258
+ try {
259
+ const byDay = readUsageByDay()
260
+ const bucket = typeof byDay[day] === 'object' && byDay[day] !== null ? byDay[day] : {}
261
+ const known = typeof bucket[key] === 'number' && Number.isFinite(bucket[key]) ? bucket[key] : 0
262
+ bucket[key] = Math.round(known + cost)
263
+ byDay[day] = bucket
264
+ // Only the claim's day is ever read, so trimming old days is hygiene, not
265
+ // correctness — the file stays a few hundred bytes for the life of the install.
266
+ const days = Object.keys(byDay).sort()
267
+ while (days.length > 8) delete byDay[days.shift()]
268
+ writeJson(USAGE_FILE, { version: 1, byDay }, { mode: 0o600 })
269
+ } catch {
270
+ // bookkeeping only — a failed write must never break the chat path
271
+ }
272
+ }
273
+
274
+ /** Forget this account's recorded spend; a claim calls this when it re-syncs the pot. */
275
+ export function resetAccountUsage(accountId) {
276
+ const key = usageKey(accountId)
277
+ if (key.length === 0) return
278
+ try {
279
+ const byDay = readUsageByDay()
280
+ let changed = false
281
+ for (const day of Object.keys(byDay)) {
282
+ const bucket = byDay[day]
283
+ if (typeof bucket === 'object' && bucket !== null && key in bucket) {
284
+ delete bucket[key]
285
+ if (Object.keys(bucket).length === 0) delete byDay[day]
286
+ changed = true
287
+ }
288
+ }
289
+ if (changed) writeJson(USAGE_FILE, { version: 1, byDay }, { mode: 0o600 })
290
+ } catch {
291
+ // bookkeeping only
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Re-anchor the ledger from a live `fetchCredits` answer.
297
+ *
298
+ * The claim path above runs a few times a day; this is the manual sync: the panel's
299
+ * refresh button (and the `/loomy credits` command) just read the server, and writing
300
+ * what it answered makes the pill's projection agree immediately instead of waiting
301
+ * for the next claim to discover the drift. `fetchCredits` is the same idempotent
302
+ * endpoint the claim uses, so a re-anchor never double-grants.
303
+ */
304
+ export function anchorLedger(accountId, credits, reason = '刷新') {
305
+ const key = String(accountId ?? '').trim().toLowerCase()
306
+ if (key.length === 0 || credits?.ok !== true) return
307
+ try {
308
+ const ledger = readGrantLedger()
309
+ ledger[key] = {
310
+ // The local day key, not the server's `dailyCycleDate`: both are `YYYY-MM-DD`,
311
+ // but they can disagree for minutes around midnight (different clocks roll at
312
+ // different moments), and every ledger consumer here — `grantedToday`, the
313
+ // projection's day match — compares against the local key. The ledger is a
314
+ // local book about when this process last synced, so it keeps local days.
315
+ day: localDayKey(),
316
+ at: Date.now(),
317
+ reason,
318
+ granted: credits.dailyBalance > 0 && credits.dailyQuota > 0,
319
+ balance: finite(credits.dailyBalance),
320
+ quota: finite(credits.dailyQuota)
321
+ }
322
+ writeGrantLedger(ledger)
323
+ // The anchored balance already includes everything spent so far today — the
324
+ // server counted it — so recorded spend before this moment must not subtract
325
+ // twice. Same contract as the claim path above.
326
+ resetAccountUsage(key)
327
+ } catch {
328
+ // bookkeeping only
329
+ }
330
+ }
331
+
332
+ /**
333
+ * The one-time onboarding pool (`10000` points per account, 8 keys).
334
+ *
335
+ * Kept as data rather than fetched, because the key list is what the completion call
336
+ * needs and the server's `tasks` map is the source of which are still open.
337
+ */
338
+ export const ONBOARDING_TASKS = Object.freeze({
339
+ first_message: 500,
340
+ pick_skill: 1000,
341
+ generate_ppt: 1500,
342
+ set_schedule: 1000,
343
+ install_skill: 1500,
344
+ configure_remote: 1000,
345
+ create_soul: 1500,
346
+ share_soul: 2000
347
+ })
348
+
349
+ /** Which onboarding tasks are still open on this account. */
350
+ export async function fetchOnboardingTasks(session) {
351
+ const json = await get(session, '/api/v1/onboarding/tasks', '读取新手任务')
352
+ const data = responseData(json) ?? {}
353
+ const raw = typeof data.tasks === 'object' && data.tasks !== null ? data.tasks : {}
354
+ const tasks = {}
355
+ for (const key of Object.keys(ONBOARDING_TASKS)) tasks[key] = raw[key] === true
356
+ return {
357
+ tasks,
358
+ earned: finite(data.earned),
359
+ total: finite(data.total, Object.values(ONBOARDING_TASKS).reduce((sum, value) => sum + value, 0))
360
+ }
361
+ }
362
+
363
+ /** Mark one onboarding task complete; the server decides whether it pays out. */
364
+ export async function completeOnboardingTask(session, key) {
365
+ const normalized = String(key ?? '').trim()
366
+ if (!(normalized in ONBOARDING_TASKS)) throw new Error(`未知的新手任务 "${normalized}"`)
367
+ const json = await post(session, '/api/v1/onboarding/tasks/complete', { key: normalized }, '完成新手任务')
368
+ const data = responseData(json) ?? {}
369
+ return {
370
+ alreadyCompleted: data.alreadyCompleted === true,
371
+ balance: finite(data.balance)
372
+ }
373
+ }