@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.
- package/CHANGELOG.md +125 -0
- package/LICENSE +26 -0
- package/README.md +223 -2
- package/client/index.js +1092 -0
- package/cordis.patch.yml +28 -0
- package/docs/PUBLISHING.md +87 -0
- package/docs/community-entry.yml +21 -0
- package/docs/models.md +74 -0
- package/docs/pill-composer.png +0 -0
- package/docs/protocol.md +403 -0
- package/docs/verify/live-check.mjs +406 -0
- package/icon-mask.png +0 -0
- package/icon.png +0 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -3
- package/src/account/auth.js +297 -0
- package/src/account/credits.js +373 -0
- package/src/account/desktop-session.js +127 -0
- package/src/account/models.js +216 -0
- package/src/account/session.js +115 -0
- package/src/core/endpoints.js +78 -0
- package/src/core/http.js +124 -0
- package/src/core/paths.js +71 -0
- package/src/core/sign.js +166 -0
- package/src/index.js +429 -0
- package/src/llm/adapter.js +698 -0
- package/src/llm/endings.js +116 -0
- package/src/llm/sse.js +80 -0
- package/src/llm/wire.js +272 -0
- package/src/panel/api.js +661 -0
|
@@ -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
|
+
}
|