magicrew-auth 0.3.8 → 0.3.14
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 +312 -0
- package/docs/incident-2026-10-28-/347/254/254/344/272/214/344/270/252/350/264/246/345/217/267/344/270/262/345/217/267.md +68 -0
- package/docs/internals.md +45 -1
- package/index.mjs +3 -22
- package/package.json +1 -5
- package/src/api.mjs +82 -0
- package/src/chat-task.mjs +335 -0
- package/src/context.mjs +78 -60
- package/src/hooks/methods.mjs +16 -0
- package/src/hooks/models.mjs +45 -2
- package/src/inspect.mjs +191 -0
- package/src/store.mjs +103 -0
- package/src/tasks.mjs +194 -5
- package/src/self-register.mjs +0 -234
package/src/inspect.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// 体检 —— 「首任务做了没」「邀请奖励到没到账」
|
|
2
|
+
//
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
4
|
+
// 为什么需要它
|
|
5
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
6
|
+
//
|
|
7
|
+
// 邀请奖励不是「注册即得」。官方前端 locale 的原文是:
|
|
8
|
+
//
|
|
9
|
+
// 被邀请人完成首个任务或对话后,即可获得 {{points}} 积分奖励。
|
|
10
|
+
//
|
|
11
|
+
// 于是每次拉新都卡在同一个问题上:**小号到底算不算「完成」了?钱到没到?**
|
|
12
|
+
// 手工去网页翻「免费积分计划」里那张表很麻烦,尤其号一多。
|
|
13
|
+
//
|
|
14
|
+
// 所以给 magpie 的登录方式列表里挂一条「体检」:点一下,把所有账号的
|
|
15
|
+
// 首任务状态、邀请人数、奖励明细一次列出来。不输入、不改任何状态。
|
|
16
|
+
//
|
|
17
|
+
// ⚠ 判据不是猜的:`first_ai_work` 与邀请奖励用的是同一套「产品内完成过」
|
|
18
|
+
// 判定。实测同一个号通过 magpie 调模型、真扣了积分,`first_ai_work`
|
|
19
|
+
// 仍然是 PENDING,邀请奖励也一分不发 —— 见 CHANGELOG 0.3.9。
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from "node:fs"
|
|
22
|
+
import { homedir } from "node:os"
|
|
23
|
+
import { join } from "node:path"
|
|
24
|
+
import {
|
|
25
|
+
SITES,
|
|
26
|
+
fetchWho,
|
|
27
|
+
fetchPoints,
|
|
28
|
+
fetchOnboardingTasks,
|
|
29
|
+
fetchOwnInviteCode,
|
|
30
|
+
fetchInviteRewards,
|
|
31
|
+
} from "./api.mjs"
|
|
32
|
+
|
|
33
|
+
/** magpie 的凭据库;zcode 插件也是直接读它来枚举多账号的 */
|
|
34
|
+
function authStorePath(home = homedir()) {
|
|
35
|
+
return join(home, ".config", "magpie", "plugin-auth.json")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 枚举本机已登录的全部 magicrew 账号。
|
|
40
|
+
*
|
|
41
|
+
* 键名形如 `magicrew`、`magicrew-intl`、`magicrew#4rm3fp`、`magicrew-intl#z983c8`,
|
|
42
|
+
* 所以按 `magicrew-intl` 前缀判站点,剩下的都算国内站。
|
|
43
|
+
*
|
|
44
|
+
* @returns {Array<{id,siteId,site,jwt,org,accountId}>}
|
|
45
|
+
*/
|
|
46
|
+
export function listAccounts({ home = homedir() } = {}) {
|
|
47
|
+
let raw
|
|
48
|
+
try {
|
|
49
|
+
raw = JSON.parse(readFileSync(authStorePath(home), "utf8"))
|
|
50
|
+
} catch {
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
53
|
+
const out = []
|
|
54
|
+
for (const [id, e] of Object.entries(raw)) {
|
|
55
|
+
// 宿主里的键名是 `magicrew` / `magicrew#4rm3fp` / `magicrew-intl` / `magicrew-intl#z983c8`
|
|
56
|
+
// ⚠ 别写成 /^magicrew(#|$)/ —— 那样 `magicrew-intl*` 全部匹配不上,
|
|
57
|
+
// 国际站会显示「还没有登录任何账号」(踩过)
|
|
58
|
+
if (!/^magicrew(-intl)?(#|$)/.test(id)) continue
|
|
59
|
+
if (typeof e?.jwt !== "string" || !e.jwt) continue
|
|
60
|
+
const siteId = id.startsWith("magicrew-intl") ? "intl" : "cn"
|
|
61
|
+
out.push({
|
|
62
|
+
id,
|
|
63
|
+
siteId,
|
|
64
|
+
site: SITES[siteId],
|
|
65
|
+
jwt: e.jwt,
|
|
66
|
+
org: String(e.org ?? ""),
|
|
67
|
+
accountId: String(e.accountId ?? id),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 查一个账号的四件事。任一接口挂了不影响其它项 —— 体检要尽量给出全貌,
|
|
75
|
+
* 不能因为「积分读不到」就整份报告作废。
|
|
76
|
+
*/
|
|
77
|
+
export async function inspectOne(a) {
|
|
78
|
+
const host = a.site.host
|
|
79
|
+
const r = { ...a, errors: [], points: null, who: null, firstTask: null, invite: null, rewards: null }
|
|
80
|
+
|
|
81
|
+
const guard = async (name, p) => {
|
|
82
|
+
try {
|
|
83
|
+
return await p
|
|
84
|
+
} catch (e) {
|
|
85
|
+
r.errors.push(`${name}:${e?.message ?? e}`)
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 五个请求并发:串行要等五轮,太慢;guard 已保证单个失败不拖垮整份报告
|
|
91
|
+
const [points, who, tasks, invite, rewards] = await Promise.all([
|
|
92
|
+
guard("积分", fetchPoints(a.jwt, a.org, host)),
|
|
93
|
+
guard("账号名", fetchWho(a.jwt, a.org, host)),
|
|
94
|
+
guard("首任务", fetchOnboardingTasks(a.jwt, a.org, host)),
|
|
95
|
+
guard("邀请码", fetchOwnInviteCode(a.jwt, a.org, host)),
|
|
96
|
+
guard("邀请奖励", fetchInviteRewards(a.jwt, a.org, host)),
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
r.points = points
|
|
100
|
+
r.who = who
|
|
101
|
+
r.invite = invite
|
|
102
|
+
r.rewards = rewards
|
|
103
|
+
|
|
104
|
+
if (tasks) {
|
|
105
|
+
const t = tasks.find((x) => x.code === "first_ai_work")
|
|
106
|
+
r.firstTask = t
|
|
107
|
+
? {
|
|
108
|
+
done: t.status === "COMPLETED" || t.rewardStatus === "GRANTED",
|
|
109
|
+
points: t.points,
|
|
110
|
+
at: t.completedAt,
|
|
111
|
+
}
|
|
112
|
+
: { done: null, points: 0, at: null } // null = 本站没有这项任务(国际站恒如此)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return r
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 一个账号一段,纯文本 —— magpie 那边是当错误信息弹出来的,不做富文本 */
|
|
119
|
+
export function formatOne(r) {
|
|
120
|
+
const tag = r.siteId === "intl" ? "国际站" : "国内站"
|
|
121
|
+
// 昵称/手机号好认;取不到才退回组织码(那是一串随机字母数字,认不出是谁)
|
|
122
|
+
const who = r.who?.name || r.org || r.accountId
|
|
123
|
+
const L = []
|
|
124
|
+
L.push(`【${tag}】${who} (${r.org})`)
|
|
125
|
+
|
|
126
|
+
if (r.points) L.push(` 积分 ${r.points.total}`)
|
|
127
|
+
else L.push(" 积分 读取失败")
|
|
128
|
+
|
|
129
|
+
if (r.firstTask === null) {
|
|
130
|
+
L.push(" 首任务 读取失败")
|
|
131
|
+
} else if (r.firstTask.done === null) {
|
|
132
|
+
L.push(` 首任务 —(本站没有新手福利,无需完成)`)
|
|
133
|
+
} else if (r.firstTask.done) {
|
|
134
|
+
// completed_at 是 UTC 带时区的 ISO;本地化再显示,否则会差 8 小时
|
|
135
|
+
let at = ""
|
|
136
|
+
if (r.firstTask.at) {
|
|
137
|
+
try {
|
|
138
|
+
at = " " + new Date(r.firstTask.at).toLocaleString("zh-CN", { hour12: false })
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
L.push(` 首任务 ✅ 已完成${at}`)
|
|
142
|
+
} else {
|
|
143
|
+
L.push(` 首任务 ⏳ 未完成${r.firstTask.points ? `(完成可得 ${r.firstTask.points} 分)` : ""}`)
|
|
144
|
+
L.push(" ↳ 需在网页/客户端里真跑完一次对话或任务,通过 magpie 调模型不算")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (r.invite) {
|
|
148
|
+
const n = r.invite.count
|
|
149
|
+
const earned = r.rewards ? r.rewards.earned : 0
|
|
150
|
+
L.push(` 邀请码 ${r.invite.code} · 已邀 ${n} 人 · 已到账 ${earned} 分`)
|
|
151
|
+
} else {
|
|
152
|
+
L.push(" 邀请码 读取失败")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (r.rewards) {
|
|
156
|
+
if (!r.rewards.list.length) {
|
|
157
|
+
L.push(` 奖励记录 0 条${r.invite?.count ? " ← 有人用了你的码,但还没「完成首任务」,所以没发" : ""}`)
|
|
158
|
+
} else {
|
|
159
|
+
for (const x of r.rewards.list) {
|
|
160
|
+
const who = x.invitedUserId ? ` 邀请 ${x.invitedUserId.slice(0, 14)}…` : ""
|
|
161
|
+
L.push(` · ${x.description} +${x.points} · ${x.statusText}${who}`)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (r.errors.length) L.push(` ⚠ ${r.errors.join(";")}`)
|
|
167
|
+
return L.join("\n")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 全量体检,返回可直接展示的整份报告。
|
|
172
|
+
*
|
|
173
|
+
* @param {{siteId?: "cn"|"intl"}} opts 传了就只查该站点(两个 provider 各挂一条,
|
|
174
|
+
* 点哪个就只看哪个站,跟「一个包两个供应商、互不聚合」保持一致)
|
|
175
|
+
*/
|
|
176
|
+
export async function buildReport({ siteId } = {}) {
|
|
177
|
+
const all = listAccounts()
|
|
178
|
+
const accounts = siteId ? all.filter((a) => a.siteId === siteId) : all
|
|
179
|
+
if (!accounts.length) {
|
|
180
|
+
const where = siteId === "intl" ? "国际站 MagiCrew " : siteId === "cn" ? "国内站 超级麦吉 " : ""
|
|
181
|
+
return where
|
|
182
|
+
? `${where}还没有登录任何账号,先添加一个再来体检。`
|
|
183
|
+
: "还没有登录任何超级麦吉 / MagiCrew 账号,先添加一个再来体检。"
|
|
184
|
+
}
|
|
185
|
+
const rows = []
|
|
186
|
+
// 串行:一批号同时打四五个请求容易撞限流,慢一点但结果可信
|
|
187
|
+
for (const a of accounts) rows.push(await inspectOne(a))
|
|
188
|
+
return [`体检(${accounts.length} 个账号)`, ...rows.map(formatOne)].join("\n\n")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export { authStorePath }
|
package/src/store.mjs
CHANGED
|
@@ -217,6 +217,109 @@ function noteOrg(siteId, org) {
|
|
|
217
217
|
} catch {}
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
// ═══ 「首次 AI 工作 / 首个对话」的一次性标记 ═════════════════════════
|
|
221
|
+
//
|
|
222
|
+
// 这件事对每个账号**只该做一次**。目的只有一个:满足服务端
|
|
223
|
+
// 「被邀请人完成首个任务或对话后,邀请人才能拿到积分」那条判定。
|
|
224
|
+
// 邀请奖励只发一次,所以补一次就够,再发纯属白烧积分。
|
|
225
|
+
//
|
|
226
|
+
// 早前的闸门写错了 —— 按「今天发过没有」记日期,于是:
|
|
227
|
+
//
|
|
228
|
+
// ① 服务端结算慢(几十秒到几分钟)、或者任务状态压根不翻 COMPLETED 时,
|
|
229
|
+
// 第二天签到又看到 PENDING,再发一条。一个号天天烧钱。
|
|
230
|
+
// ② 只按「有没有项目」判:项目建了但消息没发出去,会被当成「已用过」
|
|
231
|
+
// 而永远不补;反过来项目没建成,每次进来都重试。
|
|
232
|
+
//
|
|
233
|
+
// 现在的规则:
|
|
234
|
+
// · 发送成功 → 写 `status: "sent"`,**此后再也不发**(换天、重登、重启都一样)
|
|
235
|
+
// · 真去发了但失败 → 记 attempts,最多 MAX_AUTOCHAT_ATTEMPTS 次,到顶写
|
|
236
|
+
// `gave-up` 也不再发(别让一个永远失败的号每次签到都撞一遍)
|
|
237
|
+
// · 「还没有工作台/项目」不算失败 —— 那是账号没就绪,不计数,下次再来
|
|
238
|
+
// · 老文件里的 `"YYYY-MM-DD"` 字符串按「已发过」读,升级后不会重发
|
|
239
|
+
//
|
|
240
|
+
// 文件:~/.cache/magpie/magicrew-autochat-<siteId>.json
|
|
241
|
+
// { [org]: { at: "ISO", status: "sent" | "failed" | "gave-up", attempts: n, reason?: "…" } }
|
|
242
|
+
|
|
243
|
+
const MAX_AUTOCHAT_ATTEMPTS = 3
|
|
244
|
+
|
|
245
|
+
function autochatPath(siteId) {
|
|
246
|
+
return join(cacheDir(), `magicrew-autochat-${siteId}.json`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 读整张表。老格式(值为 `"YYYY-MM-DD"` 字符串)归一成 `{ at, status: "sent" }`,
|
|
251
|
+
* 这样升级后的第一次读就把历史账号认成「已经补过」。
|
|
252
|
+
*/
|
|
253
|
+
export function readAutoChat(siteId) {
|
|
254
|
+
try {
|
|
255
|
+
const v = JSON.parse(readFileSync(autochatPath(siteId), "utf8"))
|
|
256
|
+
if (!v || typeof v !== "object") return {}
|
|
257
|
+
const out = {}
|
|
258
|
+
for (const [org, e] of Object.entries(v)) {
|
|
259
|
+
if (typeof e === "string") out[org] = { at: e, status: "sent", attempts: 1, legacy: true }
|
|
260
|
+
else if (e && typeof e === "object") out[org] = e
|
|
261
|
+
}
|
|
262
|
+
return out
|
|
263
|
+
} catch {
|
|
264
|
+
return {}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 这个账号的那一笔,没有就 null */
|
|
269
|
+
export function readAutoChatEntry(siteId, org) {
|
|
270
|
+
return readAutoChat(siteId)[org] ?? null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 已经补过(或已放弃)→ 一次都不再发。换天也不会。 */
|
|
274
|
+
export function isAutoChatSettled(siteId, org) {
|
|
275
|
+
const e = readAutoChatEntry(siteId, org)
|
|
276
|
+
return !!e && (e.status === "sent" || e.status === "gave-up")
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function writeAutoChat(siteId, org, entry) {
|
|
280
|
+
try {
|
|
281
|
+
const m = readAutoChat(siteId)
|
|
282
|
+
m[org] = entry
|
|
283
|
+
// 只留最近 200 个账号,文件别无限涨
|
|
284
|
+
const keys = Object.keys(m)
|
|
285
|
+
if (keys.length > 200) for (const k of keys.slice(0, keys.length - 200)) delete m[k]
|
|
286
|
+
const f = autochatPath(siteId)
|
|
287
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
288
|
+
writeFileSync(f, JSON.stringify(m))
|
|
289
|
+
} catch {}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 消息确实发出去了 —— 从此这个账号不再补。只在**发送成功**后调用。 */
|
|
293
|
+
export function noteAutoChat(siteId, org, extra = {}) {
|
|
294
|
+
const prev = readAutoChatEntry(siteId, org)
|
|
295
|
+
writeAutoChat(siteId, org, {
|
|
296
|
+
at: new Date().toISOString(),
|
|
297
|
+
status: "sent",
|
|
298
|
+
attempts: (Number(prev?.attempts) || 0) + 1,
|
|
299
|
+
...extra,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 真去发了但没成 —— 记一次尝试,留给下一次。
|
|
305
|
+
*
|
|
306
|
+
* @returns {{attempts:number, givenUp:boolean}}
|
|
307
|
+
*/
|
|
308
|
+
export function noteAutoChatFailure(siteId, org, reason) {
|
|
309
|
+
const prev = readAutoChatEntry(siteId, org)
|
|
310
|
+
const attempts = (Number(prev?.attempts) || 0) + 1
|
|
311
|
+
const givenUp = attempts >= MAX_AUTOCHAT_ATTEMPTS
|
|
312
|
+
writeAutoChat(siteId, org, {
|
|
313
|
+
at: new Date().toISOString(),
|
|
314
|
+
status: givenUp ? "gave-up" : "failed",
|
|
315
|
+
attempts,
|
|
316
|
+
reason: String(reason ?? "").slice(0, 200),
|
|
317
|
+
})
|
|
318
|
+
return { attempts, givenUp }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export { MAX_AUTOCHAT_ATTEMPTS }
|
|
322
|
+
|
|
220
323
|
export {
|
|
221
324
|
AUTHOR_INVITE,
|
|
222
325
|
cacheDir,
|
package/src/tasks.mjs
CHANGED
|
@@ -20,7 +20,13 @@
|
|
|
20
20
|
//
|
|
21
21
|
// 每天跑一次,两步都做,任一步失败不影响另一步。
|
|
22
22
|
|
|
23
|
-
import { fetchPoints, claimLoginGift, fetchOnboardingTasks, completeOnboardingTask } from "./api.mjs"
|
|
23
|
+
import { fetchPoints, claimLoginGift, fetchOnboardingTasks, completeOnboardingTask, fetchFirstWorkspaceId, fetchProjectTopics } from "./api.mjs"
|
|
24
|
+
import { isAutoChatSettled, noteAutoChat, noteAutoChatFailure } from "./store.mjs"
|
|
25
|
+
|
|
26
|
+
/** 站点 id —— 去重标记按站点分文件存 */
|
|
27
|
+
function siteIdOf(host) {
|
|
28
|
+
return host.includes("magicrew") ? "magicrew-intl" : "magicrew"
|
|
29
|
+
}
|
|
24
30
|
|
|
25
31
|
/** 已知不支持手动完成的任务,别再白试 */
|
|
26
32
|
const NOT_MANUALLY_COMPLETABLE = new Set(["first_ai_work"])
|
|
@@ -74,8 +80,24 @@ export async function runCheckin(auth) {
|
|
|
74
80
|
failed = true
|
|
75
81
|
parts.push(`新手任务读取失败:${tasksErr?.message ?? tasksErr}`)
|
|
76
82
|
} else if (!tasks.length) {
|
|
77
|
-
//
|
|
78
|
-
|
|
83
|
+
// 国际站:onboarding 接口恒返回空数组,没有「新手福利」这套任务。
|
|
84
|
+
// 但**邀请奖励的判据是一样的** —— 官方 locale 原文「被邀请人完成首个任务
|
|
85
|
+
// 或对话后,即可获得 {{points}} 积分奖励」,而国际站没有任务清单,
|
|
86
|
+
// 所以那条判据在这边就等于「发过一条消息」。
|
|
87
|
+
//
|
|
88
|
+
// 实测(2026-10-28):新号发一条消息后,邀请人立刻 +200。
|
|
89
|
+
//
|
|
90
|
+
// 怎么判断「要不要发」:看这个账号有没有工作台项目 ——
|
|
91
|
+
// 一个项目都没有 = 从没发过消息 → 建项目 + 发一条
|
|
92
|
+
// 已经有项目 = 用过 → 跳过(别每天签到都发,白烧积分)
|
|
93
|
+
const r = await autoFirstChat(h)
|
|
94
|
+
if (r.ok) {
|
|
95
|
+
claimedAnything = true
|
|
96
|
+
parts.push(`首对话:已自动完成(${r.topicName},模型 ${r.model})`)
|
|
97
|
+
} else if (!r.settled) {
|
|
98
|
+
// settled = 这个账号早就补过了(一次性任务)—— 不必每天在签到消息里复述
|
|
99
|
+
parts.push(`首对话:${r.reason}`)
|
|
100
|
+
}
|
|
79
101
|
} else {
|
|
80
102
|
const pending = tasks.filter((t) => t.status !== "COMPLETED" && t.rewardStatus !== "GRANTED")
|
|
81
103
|
|
|
@@ -87,8 +109,20 @@ export async function runCheckin(auth) {
|
|
|
87
109
|
const taskFailed = []
|
|
88
110
|
|
|
89
111
|
for (const t of pending) {
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
// 「完成第一次 AI 工作」是唯一 PUT 不掉的:服务端认死必须是产品内
|
|
113
|
+
// 真的跑完过一次对话(PUT 回 9303)。所以改成**替它发一条消息** ——
|
|
114
|
+
// 走 chat-task 的纯协议通道,不开浏览器。
|
|
115
|
+
// 这也是邀请奖励的触发条件:被邀请人完成首个任务后,邀请人才拿到分。
|
|
116
|
+
if (t.code === "first_ai_work") {
|
|
117
|
+
const r = await autoFirstWork(h)
|
|
118
|
+
if (r.ok) {
|
|
119
|
+
claimed.push({ code: t.code, title: t.title, points: t.points })
|
|
120
|
+
if (r.pending) parts.push("首任务:消息已发出,等结算")
|
|
121
|
+
} else if (r.settled) {
|
|
122
|
+
// 这个账号早就补过了(一次性任务)—— 安静跳过,不必每天复述
|
|
123
|
+
} else {
|
|
124
|
+
skipped.push(`${t.title}(${t.points} 分):${r.reason}`)
|
|
125
|
+
}
|
|
92
126
|
continue
|
|
93
127
|
}
|
|
94
128
|
try {
|
|
@@ -134,4 +168,159 @@ export async function runCheckin(auth) {
|
|
|
134
168
|
}
|
|
135
169
|
}
|
|
136
170
|
|
|
171
|
+
/**
|
|
172
|
+
* 替账号完成「第一次 AI 工作」:建个话题、发一条消息、等结算。
|
|
173
|
+
*
|
|
174
|
+
* 为什么不能 PUT:`PUT /onboarding/tasks/first_ai_work/completion` 回 9303
|
|
175
|
+
* 「该新人引导任务不支持手动完成」—— 服务端只认产品内真的有一次对话。
|
|
176
|
+
*
|
|
177
|
+
* 为什么不用浏览器:消息走 Socket.IO,协议已经摸清(见 chat-task.mjs),
|
|
178
|
+
* 纯 Node 发得出去,比拉一个 Chrome 再点输入框可靠得多。
|
|
179
|
+
*
|
|
180
|
+
* @returns {{ok:boolean, pending?:boolean, reason?:string}}
|
|
181
|
+
*/
|
|
182
|
+
async function autoFirstWork(h) {
|
|
183
|
+
const siteId = siteIdOf(h.host)
|
|
184
|
+
|
|
185
|
+
// 闸门:这个账号**曾经**补过就不再发 —— 不是「今天发过没有」。
|
|
186
|
+
// 邀请奖励只发一次,补第二遍纯属白烧积分(见 store.mjs 那节注释)。
|
|
187
|
+
if (isAutoChatSettled(siteId, h.org)) {
|
|
188
|
+
return { ok: false, settled: true, reason: "首任务此前已补过(一次性任务)" }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const wsId = await fetchFirstWorkspaceId(h.jwt, h.org, h.host).catch(() => "")
|
|
193
|
+
if (!wsId) {
|
|
194
|
+
// 账号还没进过工作台 —— 不算失败、也不计数:只读接口,下次签到再来
|
|
195
|
+
return { ok: false, reason: "这个账号还没有工作台 —— 先在网页里进一次工作台" }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 重模块按需加载:WebSocket 那套挂了不该拖累整个签到
|
|
199
|
+
const { runChatTask } = await import("./chat-task.mjs")
|
|
200
|
+
const r = await runChatTask({
|
|
201
|
+
jwt: h.jwt,
|
|
202
|
+
org: h.org,
|
|
203
|
+
host: h.host,
|
|
204
|
+
workspaceId: wsId,
|
|
205
|
+
})
|
|
206
|
+
if (!r.ok) {
|
|
207
|
+
// 真去发了但没成 —— 计数,到上限就放弃,别让一个永远失败的号每次签到撞一遍
|
|
208
|
+
const f = noteAutoChatFailure(siteId, h.org, r.reason || "发送失败")
|
|
209
|
+
const why = r.reason || "发送失败"
|
|
210
|
+
return { ok: false, reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 消息确实发出去了 —— 立刻写**一次性**标记。之后不管结算成不成功、换不换天、
|
|
214
|
+
// 重不重登,都不会再发第二条:这件事判的是「有没有过」,不是「今天有没有」。
|
|
215
|
+
noteAutoChat(siteId, h.org, { topicId: r.topicId || undefined, model: r.model || undefined })
|
|
216
|
+
|
|
217
|
+
// 顺手等一会儿结算,让签到消息报得准。拿不到也无所谓 —— 标记已经写了,
|
|
218
|
+
// 下次签到看到 PENDING 只会安静跳过,不会再发消息。
|
|
219
|
+
for (let i = 0; i < 12; i++) {
|
|
220
|
+
await new Promise((res) => setTimeout(res, 5000))
|
|
221
|
+
const list = await fetchOnboardingTasks(h.jwt, h.org, h.host).catch(() => null)
|
|
222
|
+
if (!list) continue
|
|
223
|
+
const fw = list.find((x) => x.code === "first_ai_work")
|
|
224
|
+
if (fw && (fw.status === "COMPLETED" || fw.rewardStatus === "GRANTED")) {
|
|
225
|
+
return { ok: true }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { ok: true, pending: true }
|
|
229
|
+
} catch (e) {
|
|
230
|
+
const why = String(e?.message ?? e)
|
|
231
|
+
const f = noteAutoChatFailure(siteId, h.org, why)
|
|
232
|
+
return { ok: false, reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 国际站的「首个对话」:没有任务清单可依,只能看它用没用过。
|
|
238
|
+
*
|
|
239
|
+
* 判据是**有没有话题**,不是有没有工作台项目 —— 项目在打开工作台时就会
|
|
240
|
+
* 自动建,「建了项目、一句话没说」的新号很常见,而那种号正是需要补一条的。
|
|
241
|
+
* 话题才是「真的发过消息」的证据。
|
|
242
|
+
*
|
|
243
|
+
* @returns {{ok?:boolean, settled?:boolean, reason?:string, topicName?:string, model?:string}}
|
|
244
|
+
*/
|
|
245
|
+
async function autoFirstChat(h) {
|
|
246
|
+
const siteId = siteIdOf(h.host)
|
|
247
|
+
// 同一道闸门:一次性。换天、重登、重启都不会再发。
|
|
248
|
+
if (isAutoChatSettled(siteId, h.org)) {
|
|
249
|
+
return { settled: true, reason: "首对话此前已补过(一次性任务)" }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const wsId = await fetchFirstWorkspaceId(h.jwt, h.org, h.host).catch(() => "")
|
|
254
|
+
if (!wsId) return { reason: "这个账号还没有工作区,先在网页里进一次" }
|
|
255
|
+
|
|
256
|
+
const { findProjectId, runChatTask } = await import("./chat-task.mjs")
|
|
257
|
+
const pid = await findProjectId(h.jwt, h.org, h.host, wsId).catch(() => "")
|
|
258
|
+
if (pid) {
|
|
259
|
+
let topics
|
|
260
|
+
try {
|
|
261
|
+
topics = await fetchProjectTopics(h.jwt, h.org, pid, h.host)
|
|
262
|
+
} catch (e) {
|
|
263
|
+
// 查不到判据就**这次不发**:宁可漏一轮(下次登录 / 签到还会来),
|
|
264
|
+
// 也不要把「接口抖了一下」当成「没说过话」白花一次积分。
|
|
265
|
+
// 不落标记、不计数 —— 这不是失败,是没拿到判据。
|
|
266
|
+
return { reason: `话题清单查不到(${String(e?.message ?? e)}),这次不发` }
|
|
267
|
+
}
|
|
268
|
+
if (topics.length > 0) {
|
|
269
|
+
// 有话题 = 真的发过消息 → 落一次性标记,以后连查都不查
|
|
270
|
+
noteAutoChat(siteId, h.org, { reason: `已有 ${topics.length} 个话题,视为用过` })
|
|
271
|
+
return { settled: true, reason: `此前已用过(已有 ${topics.length} 个话题)` }
|
|
272
|
+
}
|
|
273
|
+
// 有项目、没话题:项目多半是打开工作台时自动建的,一句话没说 —— 往下补。
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const r = await runChatTask({ jwt: h.jwt, org: h.org, host: h.host, workspaceId: wsId })
|
|
277
|
+
if (!r.ok) {
|
|
278
|
+
const f = noteAutoChatFailure(siteId, h.org, r.reason || "发送失败")
|
|
279
|
+
const why = r.reason || "发送失败"
|
|
280
|
+
return { reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 发出去了才写一次性标记;失败的一直不写,留给下一次
|
|
284
|
+
noteAutoChat(siteId, h.org, { topicId: r.topicId || undefined, model: r.model || undefined })
|
|
285
|
+
return { ok: true, topicName: r.topicName, model: r.model }
|
|
286
|
+
} catch (e) {
|
|
287
|
+
const why = String(e?.message ?? e)
|
|
288
|
+
const f = noteAutoChatFailure(siteId, h.org, why)
|
|
289
|
+
return { reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 账号刚加进来时的一次性补完 —— 登录成功后调用,和签到共用同一道闸门。
|
|
295
|
+
*
|
|
296
|
+
* 为什么单独开一个入口:主人的要求是「一个账号加入的时候做一次」,
|
|
297
|
+
* 而不是「每天签到都做」。登录那一刻就是「加入」,做完就永久标记,
|
|
298
|
+
* 之后无论重登多少次、签到多少天,都不会再发第二条消息。
|
|
299
|
+
*
|
|
300
|
+
* 调用方**不要 await** —— 建项目 + 发消息 + 兜底等结算最坏要一分钟,
|
|
301
|
+
* 登录必须立刻返回。失败也不用管:下一个签到日 checkin 会接着做(同一道闸门)。
|
|
302
|
+
*
|
|
303
|
+
* @param {{jwt:string, org:string, host:string}} h
|
|
304
|
+
*/
|
|
305
|
+
export async function ensureFirstTaskOnce(h) {
|
|
306
|
+
const siteId = siteIdOf(h.host)
|
|
307
|
+
if (isAutoChatSettled(siteId, h.org)) return { settled: true }
|
|
308
|
+
|
|
309
|
+
// 国内站:有任务清单。清单里没有 first_ai_work、或者已经完成 → 什么都不用做
|
|
310
|
+
const tasks = await fetchOnboardingTasks(h.jwt, h.org, h.host).catch(() => null)
|
|
311
|
+
if (Array.isArray(tasks) && tasks.length) {
|
|
312
|
+
const fw = tasks.find((t) => t.code === "first_ai_work")
|
|
313
|
+
if (!fw) return { skipped: true, reason: "本站没有这项任务" }
|
|
314
|
+
if (fw.status === "COMPLETED" || fw.rewardStatus === "GRANTED") {
|
|
315
|
+
// 服务端已经认过了 —— 落标记,以后不查了
|
|
316
|
+
noteAutoChat(siteId, h.org, { reason: "服务端已标记完成" })
|
|
317
|
+
return { skipped: true, reason: "已完成" }
|
|
318
|
+
}
|
|
319
|
+
return autoFirstWork(h)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 国际站:没有任务清单 → 看有没有话题(不是有没有项目)
|
|
323
|
+
return autoFirstChat(h)
|
|
324
|
+
}
|
|
325
|
+
|
|
137
326
|
export { NOT_MANUALLY_COMPLETABLE }
|