mslxdff 0.1.238 → 0.1.240

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,280 @@
1
+ // multi-ip-pool · 一槽一条 openvpn 子进程 + 一条独立策略路由表。
2
+ //
3
+ // 为什么这么装(照参照实现 aimili-vpngate 的必要动作,但这里每槽各一套):
4
+ // openvpn 起来后内核多一个 tunX 设备,VPNGate 推给它的内网地址就是**本槽的源地址**;
5
+ // 把「源地址 = <tunX 地址> 的包」整表引到「默认路由 = tunX」的策略表里,
6
+ // 该槽的出站(含 undici 的 connect.localAddress 绑定)就只会从这条隧道出去。
7
+ // ⇒ 一块网卡上 N 条隧道、N 个真实出口,互不干扰(参照实现一次只挂 1 条)。
8
+ //
9
+ // 平台纪律:只有 Linux 能干(tun + `ip rule`);其它平台由调用方**诚实跳过一行**,不做半吊子。
10
+ import { spawn } from "node:child_process";
11
+ import { devNameFor, tableFor } from "./config.js";
12
+ import { tailReason } from "../warp-pool/revive.js";
13
+ import { fmtExit, fmtRpf, fmtRuleFail, fmtSpawn, fmtTunAddrFail } from "./report.js";
14
+
15
+ /** openvpn 就绪标志(配置文件里可能出现别的 "Sequence",必须整串匹配)。 */
16
+ export const READY_PATTERN = /Initialization Sequence Completed/;
17
+
18
+ /** 策略路由表号 → 规则优先级:落在 main(32766) 之前,且多槽之间固定可复现。 */
19
+ export const RULE_PRIORITY_BASE = 20_000;
20
+
21
+ export function rulePriorityFor(i) {
22
+ return RULE_PRIORITY_BASE + i;
23
+ }
24
+
25
+ /** 只有 Linux 支持:tun 设备 + `ip rule`/`ip route`。 */
26
+ export function isSupportedPlatform(platform = process.platform) {
27
+ return platform === "linux";
28
+ }
29
+
30
+ export function isRoot(getuid = process.getuid?.bind(process)) {
31
+ try {
32
+ return typeof getuid === "function" ? getuid() === 0 : true; // 取不到 uid 的平台上由平台门先拦
33
+ } catch {
34
+ return true;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * openvpn 命令行。**dev 与路由一律走命令行覆盖**(命令行优先于 config 文件):
40
+ * `--route-nopull` + `--pull-filter ignore redirect-gateway` 保证它绝不碰主路由表——
41
+ * 主路由表被改 = 整台机器的流量(含 daemon 自己打上游)都被拽进隧道,那是灾难。
42
+ */
43
+ export function buildOpenvpnArgs({ conf, dev, authFile, logFile = "", verb = 3 }) {
44
+ return [
45
+ "--config", conf,
46
+ "--dev", dev,
47
+ "--auth-user-pass", authFile,
48
+ "--route-nopull",
49
+ "--pull-filter", "ignore", "redirect-gateway",
50
+ "--verb", String(verb),
51
+ ...(logFile ? ["--log", logFile] : []),
52
+ ];
53
+ }
54
+
55
+ /** 从 `ip -4 addr show dev <dev>` 输出里取 tun 地址(VPNGate 推的是 10.x 内网地址)。 */
56
+ export function parseTunAddr(text) {
57
+ const m = /inet\s+(\d+\.\d+\.\d+\.\d+)/.exec(String(text || ""));
58
+ return m ? m[1] : "";
59
+ }
60
+
61
+ /** 该槽要装的策略路由(纯函数):源地址定表 → 表里默认走隧道。 */
62
+ export function tunnelUpCommands({ tunIp, table, dev, priority }) {
63
+ return [
64
+ ["ip", "rule", "add", "from", `${tunIp}/32`, "lookup", String(table), "priority", String(priority)],
65
+ ["ip", "route", "replace", "default", "dev", dev, "table", String(table)],
66
+ ];
67
+ }
68
+
69
+ /** 拆槽时按相反顺序撤(先撤表、再撤规则)。 */
70
+ export function tunnelDownCommands({ tunIp, table }) {
71
+ return [
72
+ ["ip", "route", "flush", "table", String(table)],
73
+ ["ip", "rule", "del", "from", `${tunIp}/32`, "lookup", String(table)],
74
+ ];
75
+ }
76
+
77
+ /** `ip rule` 的优先级和表号都可能是别人先占的——失败要能一眼看出「占了什么」。 */
78
+ export function rpFilterPaths() {
79
+ return [
80
+ "/proc/sys/net/ipv4/conf/all/rp_filter",
81
+ "/proc/sys/net/ipv4/conf/default/rp_filter",
82
+ ];
83
+ }
84
+
85
+ /** 逐行跑命令;超时 SIGKILL(绝不让一个卡住的 ip 调用把启动挂死)。 */
86
+ export function runCmd(cmd, args = [], { timeoutMs = 10_000, spawnFn = spawn } = {}) {
87
+ return new Promise((resolve) => {
88
+ let out = "";
89
+ let err = "";
90
+ let child;
91
+ try {
92
+ child = spawnFn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
93
+ } catch (e) {
94
+ resolve({ code: -1, stdout: "", stderr: String(e?.message || e) });
95
+ return;
96
+ }
97
+ const timer = setTimeout(() => {
98
+ try {
99
+ child.kill("SIGKILL");
100
+ } catch {
101
+ /* 已经退了 */
102
+ }
103
+ }, timeoutMs);
104
+ child.stdout?.on("data", (b) => { out += String(b); });
105
+ child.stderr?.on("data", (b) => { err += String(b); });
106
+ child.once("error", (e) => { clearTimeout(timer); resolve({ code: -1, stdout: out, stderr: `${err}${e.message}` }); });
107
+ child.once("exit", (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stdout: out, stderr: err }); });
108
+ });
109
+ }
110
+
111
+ /** 本机到底有没有 openvpn(没有就别装了,诚实跳过)。 */
112
+ export async function checkOpenvpn(bin, { runCmdFn = runCmd } = {}) {
113
+ const r = await runCmdFn(bin, ["--version"], { timeoutMs: 8_000 });
114
+ const text = `${r.stdout}${r.stderr}`;
115
+ if (r.code === 0 && /openvpn/i.test(text)) return { ok: true, version: (text.split(/\r?\n/)[0] || "").trim() };
116
+ return { ok: false, why: r.stderr.trim() || `退出码 ${r.code}` };
117
+ }
118
+
119
+ /**
120
+ * 起一条隧道并等它就绪。返回句柄;`ready` reject 表示这槽本轮不可用(调用方换下一个节点)。
121
+ * tail 保留末 20 行:真机排障时"openvpn 为什么退"全在这几行里。
122
+ */
123
+ export function startTunnel({ bin, args, timeoutMs = 25_000, spawnFn = spawn, log = () => {}, onExit = null, dev = "" } = {}) {
124
+ let child;
125
+ try {
126
+ child = spawnFn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
127
+ } catch (e) {
128
+ return { pid: 0, ready: Promise.reject(new Error(`拉不起 ${bin}:${String(e?.message || e)}`)), tail: () => "", stop: () => {} };
129
+ }
130
+ const tail = [];
131
+ const t0 = Date.now();
132
+ let settled = false;
133
+ let stopping = false;
134
+ const push = (buf) => {
135
+ for (const line of String(buf).split(/\r?\n/)) {
136
+ const s = line.trim();
137
+ if (!s) continue;
138
+ tail.push(s);
139
+ if (tail.length > 20) tail.shift();
140
+ }
141
+ };
142
+ const lastTail = () => tailReason(tail.join("\n"));
143
+ const ready = new Promise((resolve, reject) => {
144
+ const timer = setTimeout(() => {
145
+ if (settled) return;
146
+ settled = true;
147
+ try { child.kill("SIGTERM"); } catch { /* 已退 */ }
148
+ reject(new Error(`等 ${Math.round(timeoutMs / 1000)}s 没等到「Initialization Sequence Completed」(隧道没起来):${lastTail()}`));
149
+ }, timeoutMs);
150
+ const onChunk = (buf) => {
151
+ push(buf);
152
+ if (settled || !READY_PATTERN.test(tail.join("\n"))) return;
153
+ settled = true;
154
+ clearTimeout(timer);
155
+ resolve({ pid: child.pid });
156
+ };
157
+ child.stdout?.on("data", onChunk);
158
+ child.stderr?.on("data", onChunk);
159
+ child.once("exit", (code, signal) => {
160
+ const aliveMs = Date.now() - t0;
161
+ if (!settled) {
162
+ settled = true;
163
+ clearTimeout(timer);
164
+ reject(new Error(`openvpn 提前退出(code=${code} signal=${signal}):${lastTail()}`));
165
+ }
166
+ // 主动关掉的隧道不算"意外退出":收摊日志只该出现在真出事时(否则每次换槽都刷一行假警报)
167
+ if (!stopping) log(fmtExit({ dev, code, signal, pid: child.pid, aliveMs, tail: lastTail() }));
168
+ if (onExit) { try { onExit({ code, signal, tail: lastTail() }); } catch { /* 回调失败不影响退出 */ } }
169
+ });
170
+ child.once("error", (err) => {
171
+ if (settled) return;
172
+ settled = true;
173
+ clearTimeout(timer);
174
+ reject(new Error(`拉不起 ${bin}:${String(err?.message || err)}`));
175
+ });
176
+ });
177
+ return { child, pid: child.pid, ready, tail: lastTail, stop: () => { stopping = true; try { child.kill("SIGTERM"); } catch { /* 已退 */ } } };
178
+ }
179
+
180
+ /** 等 tun 地址出现(VPNGate 的 ifconfig 是随隧道建立推送的,通常一两百毫秒内就位)。 */
181
+ export async function waitTunAddr({ dev, runCmdFn = runCmd, wait = (ms) => new Promise((r) => setTimeout(r, ms)), tries = 10, gapMs = 300 } = {}) {
182
+ let last = "";
183
+ for (let i = 0; i < tries; i++) {
184
+ const r = await runCmdFn("ip", ["-4", "addr", "show", "dev", dev], { timeoutMs: 5_000 });
185
+ last = `${r.stdout}${r.stderr}`.trim();
186
+ const ip = parseTunAddr(r.stdout);
187
+ if (ip) return { ip, out: last };
188
+ if (i < tries - 1) await wait(gapMs);
189
+ }
190
+ return { ip: "", out: last };
191
+ }
192
+
193
+ /**
194
+ * 放宽 rp_filter(严格反向路径过滤会丢「从 tun 出去、从 eth 回来」的不对称包 ⇒ 隧道看起来活着但请求全挂住)。
195
+ * 默认开;MSLXDFF_MULTI_IP_SYSCTL=0 关。改的是内核参数,日志必须写清楚改了什么、怎么还原。
196
+ */
197
+ export async function relaxRpf({ enabled = true, readFile, writeFile, log = () => {} } = {}) {
198
+ if (!enabled) {
199
+ log(fmtRpf({ skipped: true }));
200
+ return { ok: true, changed: [] };
201
+ }
202
+ const paths = rpFilterPaths();
203
+ const changed = [];
204
+ try {
205
+ const fs = await import("node:fs");
206
+ const read = readFile || fs.readFileSync;
207
+ const write = writeFile || fs.writeFileSync;
208
+ for (const p of paths) {
209
+ const before = String(read(p, "utf8")).trim();
210
+ if (before === "2") continue;
211
+ write(p, "2");
212
+ changed.push({ path: p, from: before, to: "2" });
213
+ }
214
+ log(fmtRpf({ ok: true, changed }));
215
+ return { ok: true, changed };
216
+ } catch (e) {
217
+ log(fmtRpf({ ok: false, error: String(e?.message || e), changed }));
218
+ return { ok: false, changed, error: String(e?.message || e) };
219
+ }
220
+ }
221
+
222
+ /**
223
+ * 起槽全流程:spawn openvpn → 等就绪 → 读 tun 地址 → 放宽 rp_filter → 装策略路由。
224
+ * @returns {Promise<{dev, table, priority, tunIp, pid, stop, tail}>}
225
+ */
226
+ export async function bringUpTunnel({ cfg, slotIndex, node, deps = {} } = {}) {
227
+ const dev = devNameFor(cfg.devPrefix, slotIndex);
228
+ const table = tableFor(cfg.baseTable, slotIndex);
229
+ const priority = rulePriorityFor(slotIndex);
230
+ const runCmdFn = deps.runCmdFn || runCmd;
231
+ const wait = deps.wait || ((ms) => new Promise((r) => setTimeout(r, ms)));
232
+ const log = deps.log || (() => {});
233
+ const t = startTunnel({
234
+ bin: cfg.openvpnBin,
235
+ args: buildOpenvpnArgs({ conf: node.conf, dev, authFile: deps.authFile, logFile: deps.openvpnLog || "" }),
236
+ timeoutMs: cfg.connectTimeoutMs,
237
+ spawnFn: deps.spawnFn,
238
+ log,
239
+ dev,
240
+ onExit: deps.onExit,
241
+ });
242
+ try {
243
+ await t.ready;
244
+ } catch (e) {
245
+ t.stop();
246
+ throw e;
247
+ }
248
+ log(fmtSpawn({ dev, pid: t.pid, host: node.host, port: node.port, country: node.countryShort || "", table }));
249
+ const addr = await waitTunAddr({ dev, runCmdFn, wait, tries: deps.addrTries ?? 10 });
250
+ if (!addr.ip) {
251
+ t.stop();
252
+ throw new Error(fmtTunAddrFail({ dev, out: addr.out }));
253
+ }
254
+ await relaxRpf({ enabled: cfg.sysctl !== false, log, readFile: deps.readFile, writeFile: deps.writeFile });
255
+ for (const cmd of tunnelUpCommands({ tunIp: addr.ip, table, dev, priority })) {
256
+ const r = await runCmdFn(cmd[0], cmd.slice(1), { timeoutMs: 8_000 });
257
+ if (r.code !== 0) {
258
+ t.stop();
259
+ throw new Error(fmtRuleFail({ dev, cmd: cmd.join(" "), error: `${r.stderr || r.stdout}`.trim() }));
260
+ }
261
+ }
262
+ return { dev, table, priority, tunIp: addr.ip, pid: t.pid, stop: t.stop, tail: t.tail };
263
+ }
264
+
265
+ /** 拆槽:先撤策略路由(否则表里留着已消失设备的默认路由),再关子进程。 */
266
+ export async function tearDownTunnel({ slot, runCmdFn = runCmd, log = () => {} } = {}) {
267
+ if (!slot) return;
268
+ for (const cmd of tunnelDownCommands(slot)) {
269
+ try {
270
+ await runCmdFn(cmd[0], cmd.slice(1), { timeoutMs: 5_000 });
271
+ } catch (e) {
272
+ log(`[multi-ip-pool] 撤策略路由失败(${cmd.join(" ")}):${String(e?.message || e)}`);
273
+ }
274
+ }
275
+ try {
276
+ slot.stop?.();
277
+ } catch {
278
+ /* 已经退了 */
279
+ }
280
+ }
@@ -0,0 +1,198 @@
1
+ // codearts 每日签到领积分(`/v1/ops/*`)—— 与 `claimBenefit` 的「模型 Token 福利」是**两本账**。
2
+ // 协议三方交叉验证:HITZY2002/codearts2api `internal/upstream/checkin.go`(Go)、
3
+ // L0NE-6/CodeArts-Daily `codearts_daily.py`(青龙)、anye-99·gurio-wine `src/codearts-credits.ts`(TS)。
4
+ // 现网取证 2026-10-24:`GET /v1/ops/delivery?channel=IDE` → 4 个活动,`#1 USER_LOGIN` 每日 1000 积分。
5
+ // 字段坑:`campaignId` 是**数字**、积分字段叫 **`benefitAmount`**、不可领取时 `status` 是 **`null`**。
6
+ import { SNAP_BASE } from "./const.js";
7
+ import { compatFetch, timeoutSignal } from "../../compat.js";
8
+ import { signRequest } from "./sign.js";
9
+
10
+ /** 活动列表/领取/确认的渠道标识(声明请求来自 IDE 形态;官方文档与两个参考实现同值)。 */
11
+ export const OPS_CHANNEL = "IDE";
12
+ /** ops 路由需要的 Agent-Type;值错会 `TM.00001001`(空→未找到合法可路由 api)。 */
13
+ export const OPS_AGENT_TYPE = "PromptCenter";
14
+ export const EP_OPS_DELIVERY = "/v1/ops/delivery";
15
+ export const EP_OPS_CLAIM = "/v1/ops/claim";
16
+ export const EP_OPS_CONFIRM = "/v1/ops/confirm";
17
+ export const EP_PACKAGE_INFO = "/snap-manager/v1/statistics/plugin";
18
+ export const snapBase = () => SNAP_BASE;
19
+
20
+ /** 只领这两类:每日签到 + 新用户注册礼(INVITE_USER / STUDENT_CERTIFIED 不是每日任务,不碰)。 */
21
+ const DAILY_TYPES = new Set(["USER_LOGIN", "NEW_USER_REGISTER"]);
22
+ /** 已到账态:这几个状态下绝不 claim(幂等只靠活动列表预检,上游没有业务码兜底)。 */
23
+ const DONE_STATUSES = new Set(["CONFIRMED", "CONSUMED"]);
24
+
25
+ /**
26
+ * 解析活动列表。业务码非 0 或缺 `data.items` → 空表(不臆造契约)。
27
+ * @returns {Array<{id:number,type:string,title:string,credit:number,unit:string,claimable:boolean,status:string}>}
28
+ */
29
+ export function parseCreditCampaigns(payload) {
30
+ const items = payload?.data?.items;
31
+ if (!Array.isArray(items)) return [];
32
+ const out = [];
33
+ for (const it of items) {
34
+ const id = Number(it?.campaignId);
35
+ if (!Number.isFinite(id)) continue; // campaignId 缺失的条目没法回传,丢掉
36
+ out.push({
37
+ id,
38
+ type: String(it?.type || ""),
39
+ title: String(it?.title || ""),
40
+ // 对齐参考实现:`res.Credits = pendingTotalAmount || benefitAmount`(待结算优先,缺则取活动额)
41
+ credit: Number(it?.pendingTotalAmount) || Number(it?.benefitAmount) || 0,
42
+ unit: String(it?.benefitUnit || ""),
43
+ claimable: it?.claimable === true,
44
+ status: it?.status == null ? "" : String(it.status),
45
+ });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * 由活动列表推出本次要做的写操作(纯函数,便于单测)。
52
+ * 判据:只有 `claimable===true` 才 claim;`status==="CLAIMED"` 只补 confirm;已到账态零动作。
53
+ * @returns {Array<{kind:"claim"|"confirm", id:number, credit:number, title:string}>}
54
+ */
55
+ export function planCreditActions(campaigns) {
56
+ const out = [];
57
+ for (const c of Array.isArray(campaigns) ? campaigns : []) {
58
+ if (!DAILY_TYPES.has(c?.type)) continue;
59
+ if (c.unit && c.unit !== "CREDIT") continue; // 非积分账本不碰
60
+ if (!Number.isFinite(c.id) || c.id <= 0) continue;
61
+ if (c.status === "CLAIMED") { out.push({ kind: "confirm", id: c.id, credit: c.credit, title: c.title }); continue; }
62
+ if (DONE_STATUSES.has(c.status)) continue;
63
+ // 只有 ELIGIBLE + claimable 才 claim(参考实现:status 空 = 未获得活动资格;未知状态不臆造)
64
+ if (c.status === "ELIGIBLE" && c.claimable) out.push({ kind: "claim", id: c.id, credit: c.credit, title: c.title });
65
+ }
66
+ return out;
67
+ }
68
+
69
+ // ─────────────────────────── HTTP 层(签名 + 写操作) ───────────────────────────
70
+
71
+ function credOf(account) {
72
+ return { accessKeyId: account?.accessKeyId, secretAccessKey: account?.secretAccessKey, securityToken: account?.securityToken };
73
+ }
74
+
75
+ /**
76
+ * 签名 ops 请求。只把 `content-type`(+ `sign.js` 自己加的 host/x-sdk-*)送进 canonical;
77
+ * `agent-type` 与 `x-language` 必须**签名后追加**(参考实现口径:进 canonical 会 401)。
78
+ */
79
+ async function opsRequest({ account, path, method = "GET", body, fetchImpl, baseUrl, timeoutMs = 20_000 } = {}) {
80
+ const url = `${baseUrl || snapBase()}${path}`;
81
+ const payload = body === undefined ? "" : JSON.stringify(body);
82
+ const { headers: signedHeaders } = signRequest({
83
+ method, url, headers: { "content-type": "application/json" }, body: payload, cred: credOf(account),
84
+ });
85
+ signedHeaders["agent-type"] = OPS_AGENT_TYPE;
86
+ signedHeaders["x-language"] = "zh-cn";
87
+ const doFetch = fetchImpl || compatFetch;
88
+ let res;
89
+ try {
90
+ res = await doFetch(url, { method, headers: signedHeaders, ...(body === undefined ? {} : { body: payload }), signal: timeoutSignal(timeoutMs) });
91
+ } catch (e) {
92
+ return { status: 0, json: null, text: "", error: String(e?.message || e).slice(0, 200) };
93
+ }
94
+ const text = await res.text().catch(() => "");
95
+ let json = null;
96
+ if (text) { try { json = JSON.parse(text); } catch { /* 非 JSON:沿用 text 给调用方看 */ } }
97
+ return { status: res.status, json, text };
98
+ }
99
+
100
+ /** 上游业务码非 0 即失败(`{code:4000,message:"invalid channel, group not found"}` 这种)。 */
101
+ const bizFailed = (r) => Boolean(r?.json) && r.json.code != null && Number(r.json.code) !== 0;
102
+ const errOf = (r) => r.error
103
+ || (bizFailed(r) ? String(r.json.message || `code=${r.json.code}`).slice(0, 180)
104
+ : (r.status === 200 ? "" : String(r.text || "").slice(0, 180)));
105
+
106
+ /** 读活动列表(只读)。 */
107
+ export async function fetchCreditCampaigns(opts = {}) {
108
+ const r = await opsRequest({ ...opts, path: `${EP_OPS_DELIVERY}?channel=${OPS_CHANNEL}` });
109
+ if (r.status !== 200) return { ok: false, status: r.status, campaigns: [], error: errOf(r), code: r.json?.code };
110
+ if (bizFailed(r)) return { ok: false, status: 200, campaigns: [], error: errOf(r), code: r.json.code };
111
+ return { ok: true, status: 200, campaigns: parseCreditCampaigns(r.json), error: "" };
112
+ }
113
+
114
+ /** 领取:`{campaignId, channel, idempotentKey}`(幂等键带毫秒时间戳,与参考实现同形状)。 */
115
+ export async function claimCreditCampaign({ campaignId, now = Date.now, ...opts } = {}) {
116
+ const body = { campaignId, channel: OPS_CHANNEL, idempotentKey: `claim_${campaignId}_${now()}` };
117
+ const r = await opsRequest({ ...opts, path: EP_OPS_CLAIM, method: "POST", body });
118
+ return { ok: r.status === 200 && !bizFailed(r), status: r.status, json: r.json, error: errOf(r) };
119
+ }
120
+
121
+ /** 确认到账:`{campaignId}`。claim 之后无条件补这一步,否则状态停在 CLAIMED 不算到账。 */
122
+ export async function confirmCreditCampaign({ campaignId, ...opts } = {}) {
123
+ const r = await opsRequest({ ...opts, path: EP_OPS_CONFIRM, method: "POST", body: { campaignId } });
124
+ return { ok: r.status === 200 && !bizFailed(r), status: r.status, json: r.json, error: errOf(r) };
125
+ }
126
+
127
+ /**
128
+ * 单账号签到:列表 → (claim → confirm) → **重读列表复查**。
129
+ * 到账只认复查后的状态(raccoon/traework 同款纪律:只信回执就会谎报到账)。
130
+ */
131
+ export async function runCheckin({ account, fetchImpl, baseUrl, timeoutMs, dry = false, now = Date.now } = {}) {
132
+ const common = { account, fetchImpl, baseUrl, timeoutMs };
133
+ const r = { account: account?.userId || null, ok: false, status: "error", dry: false, claimed: 0, credits: 0, actions: [], writes: [], campaigns: [], message: "" };
134
+ const first = await fetchCreditCampaigns(common);
135
+ r.campaigns = first.campaigns;
136
+ if (!first.ok) { r.message = `查询活动失败 HTTP ${first.status || "-"}${first.error ? `:${first.error}` : ""}`; return r; }
137
+
138
+ const actions = planCreditActions(first.campaigns);
139
+ r.actions = actions;
140
+ if (!actions.length) {
141
+ const done = first.campaigns.some((c) => DAILY_TYPES.has(c.type) && DONE_STATUSES.has(c.status));
142
+ r.ok = true;
143
+ r.status = done ? "already" : "nothing";
144
+ r.message = done ? "今日已领取(零写操作)" : "无可用签到活动";
145
+ return r;
146
+ }
147
+ if (dry) {
148
+ r.ok = true; r.dry = true; r.status = "claimable";
149
+ r.message = `干跑:${actions.length} 项待领,未发写请求`;
150
+ return r;
151
+ }
152
+
153
+ for (const a of actions) {
154
+ if (a.kind === "claim") {
155
+ const c1 = await claimCreditCampaign({ ...common, campaignId: a.id, now });
156
+ r.writes.push({ kind: "claim", id: a.id, ok: c1.ok, status: c1.status, error: c1.error });
157
+ if (!c1.ok) continue; // claim 失败则不 confirm(对齐参考实现:claim 错即返回)
158
+ }
159
+ const c2 = await confirmCreditCampaign({ ...common, campaignId: a.id });
160
+ r.writes.push({ kind: "confirm", id: a.id, ok: c2.ok, status: c2.status, error: c2.error });
161
+ }
162
+
163
+ const after = await fetchCreditCampaigns(common);
164
+ if (after.ok) r.campaigns = after.campaigns;
165
+ const landed = (after.campaigns || []).filter((c) => actions.some((a) => a.id === c.id) && DONE_STATUSES.has(c.status));
166
+ r.claimed = landed.length;
167
+ r.credits = landed.reduce((s, c) => s + (c.credit || 0), 0);
168
+ if (landed.length) {
169
+ r.ok = true; r.status = "claimed";
170
+ r.message = `签到到账 +${r.credits} 积分(${landed.length} 项:${landed.map((c) => `#${c.id}`).join(" ")})`;
171
+ } else {
172
+ const bad = r.writes.find((w) => !w.ok);
173
+ r.status = "not-landed";
174
+ r.message = `写请求已发但复查仍未到账(不许当今天签过):${bad ? `${bad.kind} HTTP ${bad.status || "-"} ${bad.error || ""}` : "重读活动列表后状态未变"}`.slice(0, 220);
175
+ }
176
+ return r;
177
+ }
178
+
179
+ /** 积分账本:取 `usageTotalPackageCredit` 的总额/已用/剩余(分类明细单列,**不累加**)。 */
180
+ export async function fetchCreditBalance(opts = {}) {
181
+ const r = await opsRequest({ ...opts, path: EP_PACKAGE_INFO });
182
+ if (r.status !== 200) return { ok: false, status: r.status, error: errOf(r) };
183
+ const metrics = Array.isArray(r.json?.metrics) ? r.json.metrics : [];
184
+ const num = (v) => Number(v) || 0;
185
+ const pick = (name) => metrics.find((m) => m?.name === name);
186
+ const creditOf = (m) => (m ? { total: num(m.package_credit_amount), used: num(m.package_credit_used), remain: num(m.package_credit_remain) } : null);
187
+ const totalM = pick("usageTotalPackageCredit");
188
+ const tokenM = pick("usageTokenChatMessages");
189
+ return {
190
+ ok: true, status: 200,
191
+ total: num(totalM?.package_credit_amount), used: num(totalM?.package_credit_used), remain: num(totalM?.package_credit_remain),
192
+ basic: creditOf(pick("usageBasicPackageCredit")),
193
+ bonus: creditOf(pick("usageBonusPackageCredit")),
194
+ onDemand: creditOf(pick("useOnDemandPackageCredit")) || creditOf(pick("usageOnDemandPackageCredit")),
195
+ tokenUsage: num(tokenM?.usage_token_num), tokenPackages: num(tokenM?.package_token_amount),
196
+ isCreditPackage: Boolean(totalM),
197
+ };
198
+ }
@@ -68,6 +68,11 @@ export async function startDaemonMain(VERSION) {
68
68
  try { logs.appendEvent({ ts: Date.now(), type: "raccoon-checkin-setup-failed", error: String(e?.message || e).slice(0, 200) }); } catch {}
69
69
  });
70
70
 
71
+ const { setupCodeartsCheckin } = await import("./codearts-checkin.js");
72
+ void setupCodeartsCheckin({ bus, logs }).catch((e) => {
73
+ try { logs.appendEvent({ ts: Date.now(), type: "codearts-checkin-setup-failed", error: String(e?.message || e).slice(0, 200) }); } catch {}
74
+ });
75
+
71
76
  const { setupWorkbuddyGrowth } = await import("./workbuddy-growth.js");
72
77
  void setupWorkbuddyGrowth({ bus, logs }).catch((e) => {
73
78
  try { logs.appendEvent({ ts: Date.now(), type: "workbuddy-growth-setup-failed", error: String(e?.message || e).slice(0, 200) }); } catch {}
@@ -0,0 +1,115 @@
1
+ /**
2
+ * codearts 每日签到领积分(daemon 内置),仿 src/runtime/qoder-checkin.js。
3
+ * 开关:MSLXDFF_CODEARTS_CHECKIN=0 关闭(默认开);时间:MSLXDFF_CODEARTS_CHECKIN_HOUR(默认 9 点)。
4
+ * 账号源:providerConfigs.codearts.keys 的凭据 blob —— 复用 createCodeartsProvider 的 authPool,
5
+ * 刷新轮转换号**原位写回**(自己写 saveFn 会与 chat 路径的写回逻辑分叉)。
6
+ * 该上游对并发极敏感 → 逐号**串行**;ops 是独立端点,不占 chat 的并发闸(3 会话上限不受影响)。
7
+ * 记账:state.codeartsCheckin {date, at, ok, total, credits, accounts:[…]}(qoder/workbuddy 同款)。
8
+ */
9
+ import { todayKey, nextRunDelayMs, shouldCatchUp } from "./workbuddy-checkin.js";
10
+
11
+ export function isCheckinEnabled(env = process.env) {
12
+ const v = String(env.MSLXDFF_CODEARTS_CHECKIN ?? "1").trim().toLowerCase();
13
+ return !["0", "false", "off", "no"].includes(v);
14
+ }
15
+
16
+ export function getCheckinHour(env = process.env) {
17
+ const h = Number(env.MSLXDFF_CODEARTS_CHECKIN_HOUR);
18
+ return Number.isInteger(h) && h >= 0 && h <= 23 ? h : 9;
19
+ }
20
+
21
+ export async function setupCodeartsCheckin({ bus, logs, deps = {} } = {}) {
22
+ const env = deps.env || process.env;
23
+ function emit(type, data = {}) {
24
+ const entry = { ts: Date.now(), type, ...data };
25
+ try { bus?.emit(entry); } catch { /* 事件不阻断签到 */ }
26
+ try { logs?.appendEvent?.(entry); } catch { /* 同上 */ }
27
+ try { console.log(`[codearts-checkin] ${type} ${JSON.stringify(data)}`.slice(0, 500)); } catch { /* 同上 */ }
28
+ }
29
+ if (!isCheckinEnabled(env)) {
30
+ console.log("[codearts-checkin] disabled (set MSLXDFF_CODEARTS_CHECKIN=1 to enable)");
31
+ emit("codearts-checkin-disabled");
32
+ return { enabled: false };
33
+ }
34
+ const hour = getCheckinHour(env);
35
+ console.log(`[codearts-checkin] enabled: daily ${String(hour).padStart(2, "0")}:00 local, multi-account serial`);
36
+ emit("codearts-checkin-enabled", { hour });
37
+
38
+ const stateMod = await import("../state.js");
39
+
40
+ /** 取号:deps.accounts 优先(测试注入,零网络);否则走 provider 的 authPool(缺省带写回)。 */
41
+ async function loadAccounts() {
42
+ if (Array.isArray(deps.accounts)) return { accounts: deps.accounts, close: null };
43
+ const { createCodeartsProvider } = await import("../providers/codearts/index.js");
44
+ const provider = deps.provider || createCodeartsProvider({ fetchImpl: deps.fetchImpl });
45
+ const pool = provider._pool;
46
+ const accounts = [];
47
+ let candidates = [];
48
+ try { candidates = pool.aliveAccounts() || []; } catch { candidates = []; }
49
+ for (const cand of candidates) {
50
+ try { accounts.push(await pool.getCredential(cand.blob)); } catch { /* 死号跳过,别拖垮整轮 */ }
51
+ }
52
+ return { accounts, close: () => { try { provider.close?.(); } catch { /* ignore */ } } };
53
+ }
54
+
55
+ let running = false;
56
+ async function runOnce(reason) {
57
+ if (running) return { skipped: "already-running" };
58
+ running = true;
59
+ let cleanup = null;
60
+ try {
61
+ const loaded = await loadAccounts();
62
+ cleanup = loaded.close;
63
+ const accounts = loaded.accounts;
64
+ if (!accounts.length) {
65
+ emit("codearts-checkin-no-accounts", { reason });
66
+ return { ok: false, reason: "no-accounts" };
67
+ }
68
+ const { runCheckin } = await import("../providers/codearts/checkin.js");
69
+ const rows = [];
70
+ for (const account of accounts) { // 逐号串行:该上游对并发极敏感
71
+ const uid = String(account?.userId || "").slice(0, 8);
72
+ try {
73
+ const r = await runCheckin({ account, fetchImpl: deps.fetchImpl });
74
+ rows.push({ uid, ok: r.ok, status: r.status, claimed: r.claimed, credits: r.credits, msg: String(r.message || "").slice(0, 140) });
75
+ } catch (e) {
76
+ rows.push({ uid, ok: false, status: "error", claimed: 0, credits: 0, msg: String(e?.message || e).slice(0, 140) });
77
+ }
78
+ }
79
+ const okCount = rows.filter((r) => r.ok).length;
80
+ const credits = rows.reduce((s, r) => s + (r.credits || 0), 0);
81
+ const date = todayKey();
82
+ try {
83
+ stateMod.writeStateImmediate(stateMod.defaultStateFile(), {
84
+ codeartsCheckin: { date, at: Date.now(), ok: okCount, total: rows.length, credits, accounts: rows },
85
+ });
86
+ } catch { /* 落盘失败不影响本次结果 */ }
87
+ for (const r of rows) emit("codearts-checkin-account", { ...r, reason });
88
+ emit("codearts-checkin-done", { date, ok: okCount, total: rows.length, credits, reason });
89
+ return { ok: okCount > 0, okCount, total: rows.length, rows };
90
+ } catch (e) {
91
+ emit("codearts-checkin-failed", { error: String(e?.message || e).slice(0, 200), reason });
92
+ return { ok: false, error: String(e?.message || e).slice(0, 200) };
93
+ } finally {
94
+ try { cleanup?.(); } catch { /* ignore */ }
95
+ running = false;
96
+ }
97
+ }
98
+
99
+ // 启动补跑:已过今日计划点、且(从没签过 或 上次不是今天)
100
+ try {
101
+ const lastDate = stateMod.readState(stateMod.defaultStateFile())?.codeartsCheckin?.date || "";
102
+ if (shouldCatchUp({ lastDate, hour })) {
103
+ setTimeout(() => { runOnce("catch-up").catch(() => {}); }, 90_000).unref?.();
104
+ console.log(`[codearts-checkin] catch-up scheduled (last=${lastDate || "never"})`);
105
+ }
106
+ } catch { /* 读 state 失败就不补跑,等定时 */ }
107
+ function arm() {
108
+ const delay = nextRunDelayMs(new Date(), hour);
109
+ setTimeout(() => {
110
+ runOnce("daily").catch(() => {}).finally(arm);
111
+ }, delay).unref?.();
112
+ }
113
+ arm();
114
+ return { enabled: true, hour, runOnce };
115
+ }
@@ -14,6 +14,7 @@ import { probeFrom } from "./probe.js";
14
14
  import { randomAddress } from "./prefix.js";
15
15
  import { fmtClose, fmtConfig, fmtDetect, fmtDisabled, fmtPool, fmtPoolEmpty, fmtProbe, fmtReadyFail, fmtSkip } from "./report.js";
16
16
  import { createWarpDispatcher } from "../warp-pool/index.js";
17
+ import { createMultiIpDispatcher } from "../multi-ip-pool/index.js";
17
18
 
18
19
  /** 每槽 Agent 的连接池参数。注入 dispatcher 后 transport 不建自带池(transport/index.js L28),所以必须自带。 */
19
20
  export const AGENT_OPTIONS = { connections: 4, pipelining: 1, keepAliveTimeout: 30_000, keepAliveMaxTimeout: 60_000 };
@@ -139,13 +140,17 @@ export function createV6Pool({ env = process.env, baseUrl = "https://opencode.ai
139
140
  }
140
141
 
141
142
  /**
142
- * 出口池二选一(唯一接线点用):v6 池可用就用它,否则退回 WARP 池。
143
- * 两者都只产出一个 dispatcher 对象,注入点与契约完全一致(见 MODULES.md §1)。
143
+ * 出口池三选一(唯一接线点用):v6 池 → 多出口池 → WARP 池。
144
+ * 三者都只产出一个 dispatcher 对象,注入点与契约完全一致(见 MODULES.md §1)。
144
145
  * 放在这里而不是 providers-setup:那里是 10KB 硬门下的文件,多一行都是风险。
145
146
  */
146
147
  export function createPreferredDispatcher({ baseUrl = "https://opencode.ai", env = process.env, log, deps = {} } = {}) {
147
148
  const pool = createV6Pool({ env, baseUrl, log, deps });
148
149
  if (pool) return pool.dispatcher;
150
+ // 次选:多出口池(VPNGate 免费 OpenVPN 节点当出口,只组长/单机建)——它给的是**另一批真实出口 IP**,
151
+ // 而 v6 只是本机自带的那段地址,所以 v6 优先、它次之。任一不可用都安静跳过(各自打自己的日志)。
152
+ const multi = createMultiIpDispatcher({ env, baseUrl, log, deps: deps.multiIp });
153
+ if (multi) return multi;
149
154
  // 单向依赖:warp-pool 不 import v6pool(见 MODULES.md §2「已知耦合」)。
150
155
  return createWarpDispatcher({ baseUrl, env, log });
151
156
  }