dsh-model 0.23.3 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # dsh-model
4
4
 
5
- **📅 Daily check-in credits claimed for you · 🔁 one address that relays every model · 🧩 free and subscription models, your pick**
5
+ **📅 Daily check-in credits claimed for you (Trae, Qoder, WorkBuddy, Doubao) · 🔁 one address that relays every model · 🧩 free and subscription models, your pick**
6
6
 
7
7
  Install once and all three are taken care of. dsh gets a management page; without dsh, the command line does everything.
8
8
 
@@ -27,10 +27,12 @@ English · [中文](README.md)
27
27
  |---|---|---|
28
28
  | **Trae** | Detected automatically if Trae is installed and signed in; nothing to do | Check-in credits (200 a day in testing) |
29
29
  | **Qoder** | Paste a personal access token once (China or international) | That day's check-in credits |
30
- | **WorkBuddy (China)** | Sign in once | Check-in credits |
30
+ | **WorkBuddy (China)** | Sign in once (several accounts are fine) | Check-in credits |
31
+ | **Doubao** (experimental) | Copy the sign-in cookie from the browser once (personal accounts only) | Daily trial credits |
31
32
 
32
33
  - **Automatic by default**: done at a random 5–55 minutes after midnight Beijing time; failures retry an hour later; if the computer was off, it catches up within a minute of starting.
33
34
  - **You can see it**: one line at the top of the settings page, "Checked in today: …", with a one-click "Check in all" for anything missed, plus a one-time notice in the corner of dsh.
35
+ - **Several accounts**: each product can have more than one. In the check-in card's Details: "+ Add a WorkBuddy account" (approve through a link) and "+ Add another Qoder account"; several Trae installs are checked in once per account automatically.
34
36
  - **Check-in only, credits untouched**: they stay on your account for use in each product's own app. Command line: `dsh-model checkin`.
35
37
 
36
38
  ## 🔁 2. Relay: one address, one key, every model
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # dsh-model
4
4
 
5
- **📅 每天自动签到领积分 · 🔁 一个地址中转所有模型 · 🧩 免费和订阅模型随你接**
5
+ **📅 每天自动签到领积分(Trae、Qoder、WorkBuddy、豆包)· 🔁 一个地址中转所有模型 · 🧩 免费和订阅模型随你接**
6
6
 
7
7
  装一次,三件事都替你做好。dsh 里有图形管理页,没有 dsh 也能用命令行。
8
8
 
@@ -27,10 +27,12 @@
27
27
  |---|---|---|
28
28
  | **Trae** | 本机装了 Trae 并登录过,自动识别,什么都不用做 | 签到送积分(实测每天 200) |
29
29
  | **Qoder** | 粘贴一次个人访问令牌(国内版、国际版都行) | 领当天的签到积分 |
30
- | **WorkBuddy 国内版** | 登录一次 | 签到送积分 |
30
+ | **WorkBuddy 国内版** | 登录一次(可以加几个账号) | 签到送积分 |
31
+ | **豆包**(实验) | 从浏览器复制一次登录 Cookie(只支持个人账号) | 「天天领额度」送体验额度 |
31
32
 
32
33
  - **自动签到默认打开**:北京时间零点后随机延后 5–55 分钟签好;失败的一小时后重试;电脑关机错过了,开机一分钟内补签。
33
34
  - **结果看得见**:设置页顶部一行「今日已完成签到:…」,没签上的可以「一键签到」;签完在 dsh 右下角提示一次。
35
+ - **多账号**:一家可以签几个账号。在签到卡片的「详情」里「+ 添加 WorkBuddy 账号」(打开链接授权)、「+ 再加一个 Qoder 账号」;本机装了几个 Trae 会自动按账号各签一次。
34
36
  - **只签到,不碰积分**:积分留在你的账号里,照常在各家官方软件里用。命令行:`dsh-model checkin`。
35
37
 
36
38
  ## 🔁 2. 中转:一个地址、一把 key,用上所有模型
@@ -7,7 +7,7 @@
7
7
  // GET {base}/v2/plugin/login/account?state=…(Bearer) → 账号(uid / 企业 / 昵称)
8
8
  // 令牌按移植凭据库的"自有副本"格式写进 $DSH_MODEL_HOME/workbuddy/<ownFilename>(0600),bridge 直接用。
9
9
  // 网络请求走 curl:它认 -x 代理(Node 的 fetch 不走代理);国内版域名强制直连。
10
- import { join } from 'node:path';
10
+ import { dirname, join } from 'node:path';
11
11
  import { DshModelError } from '../errors.js';
12
12
  import { L } from '../i18n.js';
13
13
  import { run } from '../util/exec.js';
@@ -110,9 +110,9 @@ export async function workbuddyLogin(variant, home, opts) {
110
110
  ...(nickname ? { nickname } : {}),
111
111
  source: 'dsh',
112
112
  };
113
- const dir = bridgeDir(home);
114
- await ensureDir(dir, { owner: opts.owner });
115
- await atomicWrite(join(dir, variant.ownFilename), JSON.stringify({ version: 1, credential }, null, 2) + '\n', { mode: 0o600, owner: opts.owner });
113
+ const file = opts.file ?? join(bridgeDir(home), variant.ownFilename);
114
+ await ensureDir(dirname(file), { owner: opts.owner });
115
+ await atomicWrite(file, JSON.stringify({ version: 1, credential }, null, 2) + '\n', { mode: 0o600, owner: opts.owner });
116
116
  return credential;
117
117
  }
118
118
  /** 退出登录:删掉 dsh-model 保存的那份(App 的文件不动) */
@@ -0,0 +1,147 @@
1
+ // checkin/accounts.ts — 多账号签到(设计 §14.19)
2
+ //
3
+ // 每个签到项一个 id:第一个账号沿用 workbuddy / qoder / trae,多出来的是 <家>:<账号>(如 workbuddy:12345)。
4
+ // - WorkBuddy:主账号就是来源里登录的那个(bridge 的凭据);多出来的账号只用来签到,
5
+ // 各存一份 $DSH_MODEL_HOME/workbuddy/checkin/<uid>.json(与 dsh-model 自己登录的格式相同,0600),不接模型。
6
+ // - Qoder:secrets.json 里一组个人访问令牌,各带一个名字(Qoder 的接口不返回账号名)。
7
+ // - Trae:本机装的每个 Trae 都读,按账号(userId)去重,每个账号签一次。
8
+ // - 豆包:secrets.json 里一组 Cookie(用户从浏览器复制),各带一个名字。
9
+ import { readdir, rm } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { bridgeDir } from '../bridge/runtime.js';
12
+ import { WorkBuddyCredentialStore } from '../bridge/workbuddy/auth.js';
13
+ import { atRestKeyProviderFor } from '../bridge/workbuddy/desktop-credential-protection.js';
14
+ import { WorkBuddyUpstreamClient } from '../bridge/workbuddy/upstream.js';
15
+ import { CN_VARIANT } from '../bridge/workbuddy/variants.js';
16
+ import { loadSecrets, saveSecrets } from '../secrets.js';
17
+ import { readJson } from '../util/fs.js';
18
+ import { randomBytes } from 'node:crypto';
19
+ /** 一个签到项属于哪一家:workbuddy:123 → workbuddy */
20
+ export const checkinKind = (id) => id.split(':')[0];
21
+ // —— WorkBuddy 签到账号 ——
22
+ export const workbuddyAccountsDir = (home) => join(bridgeDir(home), 'checkin');
23
+ /** 多出来的 WorkBuddy 签到账号(不含来源里那个主账号) */
24
+ export async function listWorkbuddyAccounts(home) {
25
+ const dir = workbuddyAccountsDir(home);
26
+ let names = [];
27
+ try {
28
+ names = (await readdir(dir)).filter((n) => /^[A-Za-z0-9_-]+\.json$/.test(n));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ const out = [];
34
+ for (const n of names.sort()) {
35
+ const doc = await readJson(join(dir, n));
36
+ const uid = doc?.credential?.uid || n.replace(/\.json$/, '');
37
+ out.push({ uid, ...(doc?.credential?.nickname ? { nickname: doc.credential.nickname } : {}), file: join(dir, n) });
38
+ }
39
+ return out;
40
+ }
41
+ /** 一个签到账号的凭据库:只用它自己的那份文件,不读桌面 App(否则会读到主账号) */
42
+ export function workbuddyAccountStore(home, file) {
43
+ const client = new WorkBuddyUpstreamClient();
44
+ const store = new WorkBuddyCredentialStore({
45
+ variant: CN_VARIANT,
46
+ keyProvider: atRestKeyProviderFor(CN_VARIANT),
47
+ refresh: (credential) => client.refreshToken(credential),
48
+ ownPath: file,
49
+ desktopPath: join(workbuddyAccountsDir(home), '.no-desktop-credential'),
50
+ });
51
+ return { store, client };
52
+ }
53
+ export async function removeWorkbuddyAccount(home, uid) {
54
+ const acct = (await listWorkbuddyAccounts(home)).find((a) => a.uid === uid);
55
+ if (!acct)
56
+ return false;
57
+ await rm(acct.file, { force: true });
58
+ return true;
59
+ }
60
+ /** 名字里显示账号:昵称,没有就 uid 后 4 位 */
61
+ export const accountTag = (nickname, uid) => nickname || `…${uid.slice(-4)}`;
62
+ /** 读全部 Qoder 令牌;旧版只存了一个(secrets.checkin.qoder),读出来当第一个 */
63
+ export async function loadQoders(ctx) {
64
+ const c = (await loadSecrets(ctx)).checkin;
65
+ if (c?.qoders?.length)
66
+ return c.qoders;
67
+ return c?.qoder?.pat ? [{ id: '1', pat: c.qoder.pat, region: c.qoder.region, ...(c.qoder.savedAt ? { savedAt: c.qoder.savedAt } : {}) }] : [];
68
+ }
69
+ export async function saveQoders(ctx, list) {
70
+ const secrets = await loadSecrets(ctx);
71
+ const checkin = { ...secrets.checkin };
72
+ delete checkin.qoder;
73
+ if (list.length)
74
+ checkin.qoders = list;
75
+ else
76
+ delete checkin.qoders;
77
+ secrets.checkin = Object.keys(checkin).length ? checkin : undefined;
78
+ await saveSecrets(ctx, secrets);
79
+ }
80
+ /** 加一个令牌:同一个令牌再加就是改名字 / 区域;返回它的 id */
81
+ export async function addQoder(ctx, entry) {
82
+ const list = await loadQoders(ctx);
83
+ const same = list.find((q) => q.pat === entry.pat);
84
+ const name = entry.name?.trim().slice(0, 20);
85
+ if (same) {
86
+ same.region = entry.region;
87
+ if (name)
88
+ same.name = name;
89
+ await saveQoders(ctx, list);
90
+ return same.id;
91
+ }
92
+ const used = new Set(list.map((q) => q.id));
93
+ let n = list.length + 1;
94
+ while (used.has(String(n)))
95
+ n++;
96
+ const id = String(n);
97
+ list.push({ id, pat: entry.pat, region: entry.region, ...(name ? { name } : {}), savedAt: new Date().toISOString() });
98
+ await saveQoders(ctx, list);
99
+ return id;
100
+ }
101
+ export async function removeQoder(ctx, id) {
102
+ const list = await loadQoders(ctx);
103
+ // 不给 id:删第一个(旧版页面的"删除 Qoder 令牌")
104
+ const target = id ?? list[0]?.id;
105
+ await saveQoders(ctx, list.filter((q) => q.id !== target));
106
+ }
107
+ export async function loadDoubaos(ctx) {
108
+ return (await loadSecrets(ctx)).checkin?.doubaos ?? [];
109
+ }
110
+ async function saveDoubaos(ctx, list) {
111
+ const secrets = await loadSecrets(ctx);
112
+ const checkin = { ...secrets.checkin };
113
+ if (list.length)
114
+ checkin.doubaos = list;
115
+ else
116
+ delete checkin.doubaos;
117
+ secrets.checkin = Object.keys(checkin).length ? checkin : undefined;
118
+ await saveSecrets(ctx, secrets);
119
+ }
120
+ /** 加一个豆包账号;同一个 Cookie 再加 = 改名字。返回 id */
121
+ export async function addDoubao(ctx, entry) {
122
+ const list = await loadDoubaos(ctx);
123
+ const name = entry.name?.trim().slice(0, 20);
124
+ const same = list.find((d) => d.cookie === entry.cookie);
125
+ if (same) {
126
+ if (name)
127
+ same.name = name;
128
+ await saveDoubaos(ctx, list);
129
+ return same.id;
130
+ }
131
+ const used = new Set(list.map((d) => d.id));
132
+ let n = list.length + 1;
133
+ while (used.has(String(n)))
134
+ n++;
135
+ const id = String(n);
136
+ list.push({ id, cookie: entry.cookie, seed: entry.seed ?? randomBytes(16).toString('hex'), ...(name ? { name } : {}), savedAt: new Date().toISOString() });
137
+ await saveDoubaos(ctx, list);
138
+ return id;
139
+ }
140
+ export async function removeDoubao(ctx, id) {
141
+ const list = await loadDoubaos(ctx);
142
+ const next = list.filter((d) => d.id !== id);
143
+ if (next.length === list.length)
144
+ return false;
145
+ await saveDoubaos(ctx, next);
146
+ return true;
147
+ }
@@ -0,0 +1,112 @@
1
+ // checkin/doubao.ts — 豆包每日签到(「天天领额度」,每天 +200 体验额度)
2
+ //
3
+ // 协议照 techysy/CreditDaddy 的 src/doubaoClient.js(MIT),对方 2026-10-19/20 抓包实测:
4
+ // POST https://www.doubao.com/alice/ug/campaign/v3/detail/ 任务面板(含今天签没签)
5
+ // POST https://www.doubao.com/alice/ug/campaign/landing/checkin 领取;服务端幂等,重复领返回 idempotent:true
6
+ // 鉴权只要 doubao.com 的登录 Cookie,没有 a_bogus / msToken 这类签名(dsh-model 也不伪造签名)。
7
+ // 请求带豆包电脑版网页的平台参数(版本号、web 平台等),不带的话接口回"请求非法"。
8
+ // 只支持个人账号:飞书企业账号的任务面板里没有签到任务。
9
+ // Cookie 由用户从浏览器复制一次(存 secrets.json,0600),dsh-model 不读豆包客户端的文件。
10
+ import { createHash } from 'node:crypto';
11
+ const DETAIL_URL = 'https://www.doubao.com/alice/ug/campaign/v3/detail/';
12
+ const CHECKIN_URL = 'https://www.doubao.com/alice/ug/campaign/landing/checkin';
13
+ /** 任务面板所属活动组;签到是其中 task_kind = daily_signin 的任务 */
14
+ const CAMPAIGN_GROUP = 'pc_incentive_task_mvp_v3';
15
+ /** 登录过期 / 未登录的业务码 */
16
+ const LOGIN_INVALID = 710012001;
17
+ const TIMEOUT_MS = 20_000;
18
+ const CLIENT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) DoubaoDesktop/2.32.6 Chrome/147.0.7727.149 Safari/537.36';
19
+ const COMMON_PARAMS = {
20
+ version_code: '20800',
21
+ language: 'zh',
22
+ device_platform: 'web',
23
+ doubao_device_platform: 'web',
24
+ aid: '497858',
25
+ real_aid: '497858',
26
+ pkg_type: 'release_version',
27
+ pc_version: '2.32.6',
28
+ doubao_pc_version: '5.72.66',
29
+ samantha_web: '1',
30
+ web_platform: 'desktop',
31
+ 'use-olympus-account': '1',
32
+ web_tab_id: '0',
33
+ };
34
+ export class DoubaoAuthError extends Error {
35
+ }
36
+ /** 每个账号一组稳定的网页标识(由这个账号自己的随机种子派生,账号之间不同) */
37
+ export function deviceIdsFor(seed) {
38
+ const h = createHash('sha256').update(`doubao-dev:${seed}`).digest();
39
+ return {
40
+ web_id: '76' + [...h.subarray(0, 8)].map((b) => b % 10).join(''),
41
+ tea_uuid: [...h.subarray(8, 16)].map((b) => b % 10).join('').padEnd(16, '7'),
42
+ };
43
+ }
44
+ function apiUrl(base, seed) {
45
+ return `${base}?${new URLSearchParams({ ...COMMON_PARAMS, ...deviceIdsFor(seed), device_id: '', region: '', sys_region: '' })}`;
46
+ }
47
+ /** 用户粘贴的可能是整行 "Cookie: a=b; c=d",也可能带换行:规整成 "a=b; c=d" */
48
+ export function normalizeCookie(raw) {
49
+ return raw.replace(/^\s*cookie\s*:\s*/i, '').replace(/[\r\n]+/g, ' ').replace(/\s*;\s*/g, '; ').replace(/;\s*$/, '').trim();
50
+ }
51
+ async function post(url, cookie, body) {
52
+ const res = await fetch(url, {
53
+ method: 'POST',
54
+ headers: {
55
+ Cookie: cookie,
56
+ 'Content-Type': 'application/json',
57
+ Accept: 'application/json, text/plain, */*',
58
+ Referer: 'https://www.doubao.com/pc/agent-explore',
59
+ Origin: 'https://www.doubao.com',
60
+ 'User-Agent': CLIENT_UA,
61
+ },
62
+ body: JSON.stringify(body),
63
+ signal: AbortSignal.timeout(TIMEOUT_MS),
64
+ });
65
+ const text = await res.text();
66
+ try {
67
+ const json = JSON.parse(text);
68
+ if (json && typeof json === 'object')
69
+ return json;
70
+ }
71
+ catch {
72
+ // 非 JSON
73
+ }
74
+ throw new Error(`豆包接口返回无法解析(HTTP ${res.status})`);
75
+ }
76
+ /** 签到状态(detail 接口)。Cookie 不对抛 DoubaoAuthError */
77
+ export async function doubaoStatus(cookie, seed) {
78
+ const body = await post(apiUrl(DETAIL_URL, seed), cookie, { campaign_group_code: CAMPAIGN_GROUP });
79
+ const code = Number(body.code);
80
+ if (code === LOGIN_INVALID)
81
+ throw new DoubaoAuthError('豆包登录已过期(Cookie 失效)');
82
+ if (code !== 0)
83
+ throw new Error(`豆包任务面板返回异常:${String(body.msg ?? body.message ?? '')}(code ${code})`);
84
+ const data = (body.data ?? {});
85
+ const task = Array.isArray(data.tasks) ? data.tasks.find((t) => t?.task_kind === 'daily_signin') : undefined;
86
+ const c = task?.daily_checkin ?? {};
87
+ return {
88
+ checkedIn: task ? c.checked_in_today === true : null,
89
+ continuousDays: Number(c.continuous_days) || 0,
90
+ rewardAmount: Number(c.reward?.reward_amount) || 0,
91
+ totalAmount: Number(data.progress?.total_amount) || 0,
92
+ };
93
+ }
94
+ /** 签到:先看今天签没签,没签才领 */
95
+ export async function doubaoCheckin(cookie, seed) {
96
+ const st = await doubaoStatus(cookie, seed);
97
+ if (st.checkedIn === null)
98
+ throw new Error('这个豆包账号没有签到任务:只有个人账号能领,飞书企业账号不行(也可能活动暂时下线)');
99
+ if (st.checkedIn)
100
+ return { state: 'already' };
101
+ const body = await post(apiUrl(CHECKIN_URL, seed), cookie, { scene: CAMPAIGN_GROUP });
102
+ const code = Number(body.code);
103
+ if (code === LOGIN_INVALID)
104
+ throw new DoubaoAuthError('豆包登录已过期(Cookie 失效)');
105
+ if (code !== 0)
106
+ throw new Error(`豆包签到失败:${String(body.msg ?? body.message ?? '')}(code ${code})`);
107
+ const d = (body.data ?? {});
108
+ if (d.idempotent === true)
109
+ return { state: 'already' };
110
+ const credit = Number(d.reward_amount) || st.rewardAmount || undefined;
111
+ return { state: 'signed', ...(credit ? { credit } : {}) };
112
+ }
@@ -1,4 +1,4 @@
1
- // checkin/index.ts — 每日签到(设计 §14.15):WorkBuddy 国内版、Qoder、Trae;以后加 WPS 灵犀
1
+ // checkin/index.ts — 每日签到(设计 §14.15):WorkBuddy 国内版、Qoder、Trae、豆包;每家可以有几个账号(§14.19,checkin/accounts.ts)
2
2
  //
3
3
  // 「今天」按北京时间算:这几家都在北京时间零点换天(VPS 的系统时间是 UTC)。
4
4
  // 自动签到默认打开:守护进程每 15 分钟看一眼,过了当天随机的几分钟后签一次;失败的隔一小时再试;错过了下次开机补。
@@ -20,11 +20,16 @@ export function todayResults(file, now = new Date()) {
20
20
  return file.date === beijingNow(now).date ? file.results : [];
21
21
  }
22
22
  /** 失败原因 → 该怎么办 */
23
- export function checkinAdvice(id, message) {
24
- if (/无效|过期|401|403|unauthor|expired/i.test(message)) {
23
+ export function checkinAdvice(itemId, message) {
24
+ const id = itemId.split(':')[0];
25
+ if (/无效|过期|401|403|unauthor|expired|not able to authenticate/i.test(message)) {
26
+ if (id === 'doubao')
27
+ return L('豆包登录过期:在浏览器里重新登录 doubao.com,到签到卡片里删掉它,再复制 Cookie 添加一次', 'Doubao sign-in expired: sign in at doubao.com again, remove it in the check-in card and add the new cookie');
25
28
  if (id === 'trae')
26
29
  return L('Trae 登录过期:打开 Trae 重新登录一次', 'Trae sign-in expired: open Trae and sign in again');
27
- return id === 'qoder' ? L('Qoder 个人访问令牌失效:在签到卡片里重新填一个', 'The Qoder access token no longer works: enter a new one in the check-in card') : L('WorkBuddy 登录过期:在来源里重新登录 WorkBuddy', 'WorkBuddy sign-in expired: sign in to WorkBuddy again under Sources');
30
+ if (id === 'qoder')
31
+ return L('Qoder 个人访问令牌失效:在签到卡片里删掉它再添加一个新的', 'The Qoder access token no longer works: remove it in the check-in card and add a new one');
32
+ return itemId.includes(':') ? L('这个 WorkBuddy 签到账号的登录过期:在签到卡片里删掉它再添加一次', 'This WorkBuddy check-in account has expired: remove it in the check-in card and add it again') : L('WorkBuddy 登录过期:在来源里重新登录 WorkBuddy', 'WorkBuddy sign-in expired: sign in to WorkBuddy again under Sources');
28
33
  }
29
34
  if (/timeout|timed out|fetch failed|network|ECONN/i.test(message))
30
35
  return L('网络不通:稍后会自动再试', 'Network problem: it will retry automatically');
@@ -42,6 +42,17 @@ export function decryptStorageValue(encoded) {
42
42
  throw new Error('Trae 登录信息校验不通过');
43
43
  return plain.toString('utf8');
44
44
  }
45
+ /** JWT 里的账号 id(data.id);读不出来返回 undefined */
46
+ export function tokenAccountId(token) {
47
+ try {
48
+ const p = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString());
49
+ const id = p.data?.id ?? p.sub;
50
+ return id === undefined || id === null || id === '' ? undefined : String(id);
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
45
56
  /** 各平台的应用数据目录 */
46
57
  function appDataRoots(home, platform = process.platform, env = process.env) {
47
58
  if (platform === 'darwin')
@@ -50,45 +61,84 @@ function appDataRoots(home, platform = process.platform, env = process.env) {
50
61
  return [...new Set([env.APPDATA, join(home, 'AppData', 'Roaming')].filter((v) => Boolean(v)))];
51
62
  return [env.XDG_CONFIG_HOME || join(home, '.config')];
52
63
  }
53
- /** 找本机的 Trae 登录:先桌面 App,再 CLI。找不到返回 null(不报错:没装 Trae 就不显示) */
64
+ /**
65
+ * 找本机的 Trae 登录。可能有好几份(Trae CN、TRAE SOLO CN、CLI…),常有一份早就过期没人用了
66
+ * (2026-10-29 实测:先读到的 TRAE SOLO CN 已过期 7 天,正在用的 Trae CN 是新的,签到因此失败)。
67
+ * 所以全读一遍再挑:没过期的优先,其中桌面 App(带设备 id)优先,再按到期最晚。找不到返回 null(没装 Trae 就不显示)。
68
+ */
54
69
  export async function findTraeCredential(home = homedir()) {
70
+ return (await findTraeCredentials(home))[0] ?? null;
71
+ }
72
+ /** 本机的每个 Trae 账号各一份登录(同一个账号装了几个 Trae 只算一个),第一个是最该用的 */
73
+ export async function findTraeCredentials(home = homedir()) {
74
+ const found = await readTraeCredentials(home);
75
+ const groups = new Map();
76
+ for (const c of found) {
77
+ const key = c.accountId ?? `?${c.source}`;
78
+ groups.set(key, [...(groups.get(key) ?? []), c]);
79
+ }
80
+ const best = [...groups.values()].map((g) => pickTraeCredential(g)).filter(Boolean);
81
+ // 账号之间:没过期的在前
82
+ const first = pickTraeCredential(best);
83
+ return first ? [first, ...best.filter((c) => c !== first)] : [];
84
+ }
85
+ async function readTraeCredentials(home) {
55
86
  // 测试环境不看本机真装了什么(与不发现桌面 App 同一个开关)
56
87
  if (process.env.DSH_MODEL_NO_APP_DISCOVERY && home === homedir())
57
- return null;
88
+ return [];
89
+ const found = [];
58
90
  for (const root of appDataRoots(home)) {
59
91
  for (const app of APPS) {
60
92
  const file = join(root, app, 'User', 'globalStorage', 'storage.json');
61
- let storage;
62
93
  try {
63
- storage = JSON.parse(await readFile(file, 'utf8'));
94
+ const storage = JSON.parse(await readFile(file, 'utf8'));
95
+ const raw = storage[AUTH_KEY];
96
+ if (typeof raw !== 'string' || !raw.trim())
97
+ continue;
98
+ const text = raw.trim().startsWith('{') ? raw : decryptStorageValue(raw);
99
+ const auth = JSON.parse(text);
100
+ const token = auth.token || auth.accessToken;
101
+ if (!token)
102
+ continue;
103
+ const dc = Object.keys(storage).find((k) => k.startsWith(DEVICE_PREFIX));
104
+ const accountId = auth.userId !== undefined && auth.userId !== null && auth.userId !== '' ? String(auth.userId) : tokenAccountId(token);
105
+ found.push({ token, deviceId: dc ? dc.slice(DEVICE_PREFIX.length) : String(storage['telemetry.devDeviceId'] ?? ''), source: app, ...(accountId ? { accountId } : {}) });
64
106
  }
65
107
  catch {
66
- continue;
108
+ // 没装、读不了或解不开:看下一个
67
109
  }
68
- const raw = storage[AUTH_KEY];
69
- if (typeof raw !== 'string' || !raw.trim())
70
- continue;
71
- const text = raw.trim().startsWith('{') ? raw : decryptStorageValue(raw);
72
- const auth = JSON.parse(text);
73
- const token = auth.token || auth.accessToken;
74
- if (!token)
75
- continue;
76
- const dc = Object.keys(storage).find((k) => k.startsWith(DEVICE_PREFIX));
77
- return { token, deviceId: dc ? dc.slice(DEVICE_PREFIX.length) : String(storage['telemetry.devDeviceId'] ?? ''), source: app };
78
110
  }
79
111
  }
80
112
  for (const dir of ['.trae-cn', '.trae']) {
81
113
  try {
82
114
  const text = (await readFile(join(home, dir, 'trae-jwt-token'), 'utf8')).trim();
83
115
  const token = text.startsWith('{') ? String(JSON.parse(text).token ?? '') : text;
116
+ const accountId = tokenAccountId(token);
84
117
  if (token.split('.').length === 3)
85
- return { token, deviceId: '', source: `~/${dir}` };
118
+ found.push({ token, deviceId: '', source: `~/${dir}`, ...(accountId ? { accountId } : {}) });
86
119
  }
87
120
  catch {
88
121
  // 没有
89
122
  }
90
123
  }
91
- return null;
124
+ return found;
125
+ }
126
+ /** 几份登录里挑一份:没过期的优先 → 带设备 id 的(桌面 App)优先 → 到期最晚的。导出给测试 */
127
+ export function pickTraeCredential(list, now = Date.now()) {
128
+ const exp = (t) => {
129
+ const d = tokenDaysLeft(t, now);
130
+ return d === null ? Number.POSITIVE_INFINITY : d;
131
+ };
132
+ const ranked = [...list].sort((a, b) => {
133
+ const live = Number(exp(b.token) >= 0) - Number(exp(a.token) >= 0);
134
+ if (live)
135
+ return live;
136
+ const desktop = Number(Boolean(b.deviceId)) - Number(Boolean(a.deviceId));
137
+ if (desktop)
138
+ return desktop;
139
+ return exp(b.token) - exp(a.token);
140
+ });
141
+ return ranked[0] ?? null;
92
142
  }
93
143
  /** 令牌还剩几天(读 JWT 的 exp);读不出来返回 null */
94
144
  export function tokenDaysLeft(token, now = Date.now()) {
@@ -5,7 +5,7 @@
5
5
  // 职责:来源状态与开关、登录会话(引擎 OAuth / WorkBuddy 自有登录 / OpenCode key)、key 管理、用量统计。
6
6
  // CLI 与 dsh 插件都只通过 /control/* 调这里,逻辑只有一份。
7
7
  import { spawn } from 'node:child_process';
8
- import { readFile } from 'node:fs/promises';
8
+ import { readFile, rename, rm } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
10
  import { latestVersion, selfJobLog } from '../commands/self.js';
11
11
  import { LOCAL_SERVERS, loadLocalCatalog, refreshLocalCatalog } from '../integrations/local.js';
@@ -18,7 +18,10 @@ import { spawnDetached } from '../util/detach.js';
18
18
  import { classify, recentFailures } from './failures.js';
19
19
  import { autoDue, beijingNow, checkinAdvice, ensureJitter, loadCheckin, runCheckin, setAuto, todayResults } from '../checkin/index.js';
20
20
  import { QODER_REGIONS, exchangePat, qoderCheckin } from '../checkin/qoder.js';
21
- import { findTraeCredential, traeCheckin } from '../checkin/trae.js';
21
+ import { findTraeCredentials, traeCheckin } from '../checkin/trae.js';
22
+ import { DoubaoAuthError, doubaoCheckin, doubaoStatus, normalizeCookie } from '../checkin/doubao.js';
23
+ import { accountTag, addDoubao, addQoder, listWorkbuddyAccounts, loadDoubaos, loadQoders, removeDoubao, removeQoder, removeWorkbuddyAccount, workbuddyAccountStore, workbuddyAccountsDir } from '../checkin/accounts.js';
24
+ import { CN_VARIANT } from '../bridge/workbuddy/variants.js';
22
25
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
23
26
  import { randomBytes } from 'node:crypto';
24
27
  import { currentBinary } from '../engine/install.js';
@@ -34,7 +37,7 @@ import { blockedNotice as workbuddyBlockedNotice, riskNotice as workbuddyRiskNot
34
37
  import { DSH_KEY_NAME, addKey, findActive, loadKeys, revokeKey, rotateKey, saveKeys } from '../keys.js';
35
38
  import { applyKeyChange, dshCandidates, dshKey, loadAll, saveAll, syncAll } from '../ops.js';
36
39
  import { defaultPick, pickedFor } from '../dsh/pick.js';
37
- import { loadSecrets, saveSecrets } from '../secrets.js';
40
+ import { loadSecrets } from '../secrets.js';
38
41
  import { SOURCES, credsFor, findSource, paymentRequired } from '../sources.js';
39
42
  import { withLock } from '../state.js';
40
43
  import { riskNotice as upstreamRiskNotice } from '../upstreams.js';
@@ -717,35 +720,78 @@ export class Daemon {
717
720
  }
718
721
  // —— 每日签到 ——
719
722
  /** 现在能签到的:WorkBuddy 国内版(已登录)、Qoder(填了个人访问令牌) */
723
+ /**
724
+ * 能签到的每个账号(设计 §14.19):WorkBuddy 主账号 + 多加的签到账号、每个 Qoder 令牌、本机每个 Trae 账号。
725
+ * 第一个账号的 id 沿用 workbuddy / qoder / trae(旧结果照常认),多出来的是 <家>:<账号>。同一个账号只签一次。
726
+ */
720
727
  async checkers() {
721
728
  const out = [];
729
+ const home = this.ctx.paths.home;
730
+ const extra = await listWorkbuddyAccounts(home);
731
+ const seen = new Set();
722
732
  for (const rt of this.runtimes) {
723
733
  if (rt.variant.id !== 'workbuddy')
724
734
  continue; // 国际版没有签到
725
735
  const st = await rt.wbStore.status().catch(() => null);
726
736
  if (st?.state !== 'signed-in')
727
737
  continue;
728
- out.push({ id: 'workbuddy', label: rt.label, run: async () => rt.wbClient.dailyCheckin(await rt.wbStore.resolve()) });
738
+ const uid = (await rt.wbStore.resolve().catch(() => null))?.uid;
739
+ if (uid)
740
+ seen.add(uid);
741
+ const tag = extra.length ? `(${accountTag(st.nickname, uid ?? '')})` : '';
742
+ out.push({ id: 'workbuddy', label: `${rt.label}${tag}`, run: async () => rt.wbClient.dailyCheckin(await rt.wbStore.resolve()) });
743
+ }
744
+ for (const a of extra) {
745
+ if (seen.has(a.uid))
746
+ continue;
747
+ seen.add(a.uid);
748
+ const { store, client } = workbuddyAccountStore(home, a.file);
749
+ out.push({ id: `workbuddy:${a.uid}`, label: `WorkBuddy(${accountTag(a.nickname, a.uid)})`, run: async () => client.dailyCheckin(await store.resolve()) });
729
750
  }
730
- const q = (await loadSecrets(this.ctx)).checkin?.qoder;
731
- if (q?.pat)
732
- out.push({ id: 'qoder', label: QODER_REGIONS[q.region].label, run: () => qoderCheckin(q.pat, q.region) });
733
- // Trae:本机装了并登录过 Trae 才有(读它自己的登录信息,不需要另外设置;VPS 上一般没有)
734
- const trae = await findTraeCredential().catch(() => null);
735
- if (trae)
736
- out.push({ id: 'trae', label: 'Trae', run: async () => traeCheckin((await findTraeCredential()) ?? trae) });
751
+ const qoders = await loadQoders(this.ctx);
752
+ qoders.forEach((q, i) => {
753
+ // 一个令牌:「Qoder(国内版)」;几个:带上名字(没起名就用编号),如「Qoder(小号 · 国内版)」
754
+ const region = q.region === 'cn' ? L('国内版', 'China') : '';
755
+ const tag = q.name || (qoders.length > 1 ? `#${q.id}` : '');
756
+ const label = tag ? `Qoder(${[tag, region].filter(Boolean).join(' · ')})` : QODER_REGIONS[q.region].label;
757
+ out.push({ id: i === 0 ? 'qoder' : `qoder:${q.id}`, label, run: () => qoderCheckin(q.pat, q.region) });
758
+ });
759
+ // 豆包:用户粘贴的 Cookie,每个账号一个
760
+ const doubaos = await loadDoubaos(this.ctx);
761
+ doubaos.forEach((d, i) => {
762
+ const tag = d.name || (doubaos.length > 1 ? `#${d.id}` : '');
763
+ out.push({ id: i === 0 ? 'doubao' : `doubao:${d.id}`, label: tag ? L(`豆包(${tag})`, `Doubao (${tag})`) : L('豆包', 'Doubao'), run: () => doubaoCheckin(d.cookie, d.seed) });
764
+ });
765
+ // Trae:本机装了并登录过 Trae 才有(读它自己的登录信息,不需要另外设置;VPS 上一般没有)。每个账号签一次
766
+ const traes = await findTraeCredentials().catch(() => []);
767
+ traes.forEach((t, i) => {
768
+ const id = i === 0 ? 'trae' : `trae:${t.accountId ?? t.source}`;
769
+ out.push({
770
+ id,
771
+ label: traes.length > 1 ? `Trae(${t.accountId ? `…${t.accountId.slice(-4)}` : t.source})` : 'Trae',
772
+ run: async () => traeCheckin((await findTraeCredentials()).find((x) => (x.accountId ?? x.source) === (t.accountId ?? t.source)) ?? t),
773
+ });
774
+ });
737
775
  return out;
738
776
  }
739
777
  async checkinStatus() {
740
778
  const file = await loadCheckin(this.ctx.paths.home);
741
779
  const checkers = await this.checkers();
742
- const q = (await loadSecrets(this.ctx)).checkin?.qoder;
780
+ const qoders = await loadQoders(this.ctx);
781
+ const extra = await listWorkbuddyAccounts(this.ctx.paths.home);
782
+ const ids = new Set(checkers.map((c) => c.id));
743
783
  return {
744
784
  auto: file.auto !== false,
745
785
  date: beijingNow().date,
746
- results: todayResults(file).map((r) => (r.state === 'failed' ? { ...r, advice: checkinAdvice(r.id, r.message ?? '') } : r)),
786
+ // 删掉的账号今天的结果不再显示
787
+ results: todayResults(file).filter((r) => ids.has(r.id)).map((r) => (r.state === 'failed' ? { ...r, advice: checkinAdvice(r.id, r.message ?? '') } : r)),
747
788
  available: checkers.map((c) => ({ id: c.id, label: c.label })),
748
- qoder: { configured: Boolean(q?.pat), ...(q ? { region: q.region } : {}) },
789
+ qoder: { configured: qoders.length > 0, ...(qoders[0] ? { region: qoders[0].region } : {}) },
790
+ accounts: {
791
+ workbuddy: extra.map((a) => ({ uid: a.uid, label: `WorkBuddy(${accountTag(a.nickname, a.uid)})` })),
792
+ qoder: qoders.map((q, i) => ({ id: q.id, label: checkers.find((c) => c.id === (i === 0 ? 'qoder' : `qoder:${q.id}`))?.label ?? q.id, region: q.region })),
793
+ doubao: (await loadDoubaos(this.ctx)).map((d, i) => ({ id: d.id, label: checkers.find((c) => c.id === (i === 0 ? 'doubao' : `doubao:${d.id}`))?.label ?? d.id })),
794
+ },
749
795
  };
750
796
  }
751
797
  checkinInflight;
@@ -767,20 +813,80 @@ export class Daemon {
767
813
  if (results)
768
814
  log_(`daemon: auto check-in ${results.results.map((r) => `${r.id}=${r.state}`).join(' ')}`);
769
815
  }
770
- async setQoderPat(pat, region) {
816
+ /** 加一个 Qoder 令牌(同一个令牌再加 = 改名字 / 区域) */
817
+ async addQoderPat(pat, region, name) {
771
818
  if (!pat || /\s/.test(pat) || pat.length < 8)
772
819
  throw new DshModelError('invalid_key', L('这看起来不是 Qoder 的个人访问令牌', 'That does not look like a Qoder access token'));
773
820
  await exchangePat(pat, region); // 换得到短期 token 才算可用
774
- const secrets = await loadSecrets(this.ctx);
775
- secrets.checkin = { ...secrets.checkin, qoder: { pat, region, savedAt: new Date().toISOString() } };
776
- await saveSecrets(this.ctx, secrets);
821
+ await addQoder(this.ctx, { pat, region, ...(name ? { name } : {}) });
777
822
  }
778
- async removeQoderPat() {
779
- const secrets = await loadSecrets(this.ctx);
780
- if (secrets.checkin?.qoder) {
781
- delete secrets.checkin.qoder;
782
- await saveSecrets(this.ctx, secrets);
823
+ async removeQoderPat(id) {
824
+ await removeQoder(this.ctx, id);
825
+ }
826
+ /** 加一个豆包账号:先用这份 Cookie 查一次签到任务,查得到才保存 */
827
+ async addDoubaoCookie(raw, name) {
828
+ const cookie = normalizeCookie(raw);
829
+ if (cookie.length < 20 || !cookie.includes('='))
830
+ throw new DshModelError('invalid_key', L('这看起来不是豆包的 Cookie:要从浏览器开发者工具里复制整串 Cookie(形如 a=1; b=2; …)', 'That does not look like a Doubao cookie: copy the whole Cookie string from the browser developer tools (like a=1; b=2; …)'));
831
+ const seed = randomBytes(16).toString('hex');
832
+ let st;
833
+ try {
834
+ st = await doubaoStatus(cookie, seed);
783
835
  }
836
+ catch (error) {
837
+ if (error instanceof DoubaoAuthError)
838
+ throw new DshModelError('key_rejected', L('豆包说这份 Cookie 没有登录或已过期:在浏览器里登录 doubao.com 后重新复制', 'Doubao says this cookie is signed out or expired: sign in at doubao.com in the browser and copy it again'));
839
+ throw new DshModelError('doubao_failed', String(error.message ?? error));
840
+ }
841
+ if (st.checkedIn === null)
842
+ throw new DshModelError('doubao_no_task', L('这个豆包账号没有签到任务:只有个人账号能领每日额度,飞书企业账号不行。请换成个人账号登录后再复制 Cookie', 'This Doubao account has no check-in task: only personal accounts get the daily credits, not Feishu enterprise accounts. Sign in with a personal account and copy the cookie again'));
843
+ await addDoubao(this.ctx, { cookie, seed, ...(name ? { name } : {}) });
844
+ }
845
+ /** 加一个 WorkBuddy 签到账号:打开链接授权(同来源登录一样),存成单独一份,只用来签到 */
846
+ async startWorkbuddyCheckinLogin() {
847
+ const all = await loadAll(this.ctx);
848
+ const home = this.ctx.paths.home;
849
+ const s = {
850
+ id: `wbc-${randomBytes(8).toString('hex')}`,
851
+ source: 'workbuddy-checkin',
852
+ kind: 'link',
853
+ status: 'pending',
854
+ needsPaste: false,
855
+ startedAt: new Date().toISOString(),
856
+ expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
857
+ abort: new AbortController(),
858
+ };
859
+ this.sessions.set(s.id, s);
860
+ const tmp = join(workbuddyAccountsDir(home), `.pending-${s.id}.json`);
861
+ let urlReady;
862
+ const ready = new Promise((r) => (urlReady = r));
863
+ void workbuddyLogin(CN_VARIANT, home, { proxy: all.config.proxy, owner: this.ctx.owner, signal: s.abort.signal, file: tmp, onUrl: (url) => { s.url = url; urlReady(); } })
864
+ .then(async (cred) => {
865
+ const uid = cred.uid || randomBytes(4).toString('hex');
866
+ const primary = this.runtimes.find((r) => r.variant.id === 'workbuddy');
867
+ const primaryUid = primary ? (await primary.wbStore.resolve().catch(() => null))?.uid : undefined;
868
+ if (primaryUid && primaryUid === uid) {
869
+ await rm(tmp, { force: true });
870
+ s.status = 'error';
871
+ s.error = L('这个账号就是来源里登录的 WorkBuddy,已经在签到了。请在浏览器里换另一个账号授权', 'This is the WorkBuddy account already signed in under Sources, so it is checked in already. Authorise a different account');
872
+ return;
873
+ }
874
+ await rename(tmp, join(workbuddyAccountsDir(home), `${uid.replace(/[^A-Za-z0-9_-]/g, '_')}.json`));
875
+ s.status = 'ok';
876
+ void this.checkinNow(false).catch(() => { });
877
+ })
878
+ .catch(async (error) => {
879
+ await rm(tmp, { force: true }).catch(() => { });
880
+ if (s.status === 'pending') {
881
+ s.status = 'error';
882
+ s.error = String(error.message ?? error);
883
+ }
884
+ urlReady();
885
+ });
886
+ await Promise.race([ready, new Promise((r) => setTimeout(r, 30_000))]);
887
+ if (s.status === 'error')
888
+ throw new DshModelError('login_failed', s.error ?? 'login failed');
889
+ return publicSession(s);
784
890
  }
785
891
  // —— 更新 / 卸载(管理页的按钮)——
786
892
  selfJob;
@@ -1104,11 +1210,27 @@ export class Daemon {
1104
1210
  return ok(await this.checkinStatus());
1105
1211
  }
1106
1212
  if (method === 'POST' && path === '/checkin/qoder') {
1107
- await this.setQoderPat(String(b.pat ?? '').trim(), b.region === 'intl' ? 'intl' : 'cn');
1213
+ await this.addQoderPat(String(b.pat ?? '').trim(), b.region === 'intl' ? 'intl' : 'cn', typeof b.name === 'string' ? b.name : undefined);
1108
1214
  return ok(await this.checkinNow(true));
1109
1215
  }
1110
- if (method === 'DELETE' && path === '/checkin/qoder') {
1111
- await this.removeQoderPat();
1216
+ if (method === 'DELETE' && (path === '/checkin/qoder' || (seg[0] === 'checkin' && seg[1] === 'qoder' && seg[2]))) {
1217
+ await this.removeQoderPat(seg[2]);
1218
+ return ok(await this.checkinStatus());
1219
+ }
1220
+ if (method === 'POST' && path === '/checkin/workbuddy')
1221
+ return ok({ login: await this.startWorkbuddyCheckinLogin() });
1222
+ if (method === 'POST' && path === '/checkin/doubao') {
1223
+ await this.addDoubaoCookie(String(b.cookie ?? ''), typeof b.name === 'string' ? b.name : undefined);
1224
+ return ok(await this.checkinNow(true));
1225
+ }
1226
+ if (method === 'DELETE' && seg[0] === 'checkin' && seg[1] === 'doubao' && seg[2]) {
1227
+ if (!(await removeDoubao(this.ctx, seg[2])))
1228
+ throw new DshModelError('no_account', L('没有这个签到账号', 'No such check-in account'));
1229
+ return ok(await this.checkinStatus());
1230
+ }
1231
+ if (method === 'DELETE' && seg[0] === 'checkin' && seg[1] === 'workbuddy' && seg[2]) {
1232
+ if (!(await removeWorkbuddyAccount(this.ctx.paths.home, seg[2])))
1233
+ throw new DshModelError('no_account', L('没有这个签到账号', 'No such check-in account'));
1112
1234
  return ok(await this.checkinStatus());
1113
1235
  }
1114
1236
  if (method === 'GET' && path === '/failures') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model",
3
- "version": "0.23.3",
3
+ "version": "0.25.0",
4
4
  "description": "Daily check-in credits claimed for you (Trae, Qoder, WorkBuddy China), one OpenAI-compatible address that relays every model, and free or subscription model sources (SiliconFlow, Gemini, Codex, Kimi, Ollama…). For DeepSeek Harness (dsh) and any OpenAI-compatible tool. 每天自动签到领积分 · 一个地址中转所有模型 · 免费和订阅模型随你接。",
5
5
  "keywords": [
6
6
  "siliconflow",
package/plugin/client.js CHANGED
@@ -985,37 +985,76 @@ window.__ModuleLoader__.load({
985
985
  // —— 每日签到 ——
986
986
  const CHECKIN_TEXT = (r) => (r.state === 'signed' ? L(`成功${r.credit ? ` +${r.credit} 积分` : ''}`, `done${r.credit ? ` +${r.credit} credits` : ''}`) : r.state === 'already' ? L('今天已签过', 'already today') : L(`失败:${r.message || ''}`, `failed: ${r.message || ''}`))
987
987
 
988
- function QoderDialog({ onDone, onClose }) {
988
+ function QoderDialog({ onDone, onClose, another }) {
989
989
  const [pat, setPat] = useState('')
990
+ const [name, setName] = useState('')
990
991
  const [region, setRegion] = useState('cn')
991
992
  const [busy, setBusy] = useState(false)
992
993
  const [msg, setMsg] = useState('')
993
994
  const save = async () => {
994
995
  setBusy(true)
995
996
  setMsg(L('正在验证这个令牌并签到…', 'Checking the token and checking in…'))
996
- try { await api('POST', '/checkin/qoder', { pat: pat.trim(), region }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
997
+ try { await api('POST', '/checkin/qoder', { pat: pat.trim(), region, ...(name.trim() ? { name: name.trim() } : {}) }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
997
998
  }
998
- return h(Modal, { title: L('添加 Qoder 签到', 'Add Qoder check-in'), onClose },
999
+ return h(Modal, { title: another ? L('再加一个 Qoder 账号', 'Add another Qoder account') : L('添加 Qoder 签到', 'Add Qoder check-in'), onClose },
999
1000
  h('p', { style: S.note },
1000
1001
  L('登录 Qoder 网站,在「设置 → API 令牌」(有的版本叫「账号 → 集成」)里点「创建令牌」,复制生成的个人访问令牌(以 pt- 开头,只显示一次),粘贴到这里。只用来每天领签到积分,保存在本机。', 'Sign in on the Qoder website, open Settings → API Tokens (Account → Integrations in some versions), click Create token, then paste the personal access token here (it starts with pt- and is shown only once). It is only used to claim the daily credits, and stays on this machine.'),
1001
1002
  h('a', { href: region === 'cn' ? 'https://qoder.com.cn/account/integrations' : 'https://qoder.com/account/integrations', target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent, marginLeft: 6 } }, region === 'cn' ? L('去 Qoder 国内版创建 ↗', 'Create on Qoder China ↗') : L('去 Qoder 国际版创建 ↗', 'Create on Qoder ↗'))),
1002
1003
  h('div', { style: { ...S.line, marginBottom: 8 } },
1003
1004
  ['cn', 'intl'].map((r) => h('label', { key: r, style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' } },
1004
1005
  h('input', { type: 'radio', name: 'qoder-region', checked: region === r, onChange: () => setRegion(r) }), r === 'cn' ? L('国内版', 'China') : L('国际版', 'International')))),
1006
+ another ? h('div', { style: { ...S.line, marginBottom: 8 } },
1007
+ h('input', { style: { ...S.input, ...S.grow }, maxLength: 20, placeholder: L('给这个账号起个名字,如:小号', 'A name for this account, e.g. Second'), value: name, onChange: (e) => setName(e.target.value), 'aria-label': L('账号名字', 'Account name') })) : null,
1005
1008
  h('div', { style: S.line },
1006
1009
  h('input', { type: 'password', style: { ...S.input, ...S.grow }, placeholder: L('个人访问令牌', 'Personal access token'), value: pat, onChange: (e) => setPat(e.target.value) }),
1007
1010
  h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !pat.trim(), onClick: () => void save() }, L('保存', 'Save'))),
1008
1011
  msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
1009
1012
  }
1010
1013
 
1014
+ /** 添加豆包签到:从浏览器复制一次登录 Cookie(豆包没有令牌,也不读豆包客户端的文件) */
1015
+ function DoubaoDialog({ onDone, onClose, another }) {
1016
+ const [cookie, setCookie] = useState('')
1017
+ const [name, setName] = useState('')
1018
+ const [busy, setBusy] = useState(false)
1019
+ const [msg, setMsg] = useState('')
1020
+ const save = async () => {
1021
+ setBusy(true)
1022
+ setMsg(L('正在用这份 Cookie 查签到任务并签到…', 'Checking this cookie and checking in…'))
1023
+ try { await api('POST', '/checkin/doubao', { cookie: cookie.trim(), ...(name.trim() ? { name: name.trim() } : {}) }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
1024
+ }
1025
+ const step = (n, text) => h('div', { style: { ...S.meta, lineHeight: 1.7 } }, `${n}. `, text)
1026
+ return h(Modal, { title: another ? L('再加一个豆包账号', 'Add another Doubao account') : L('添加豆包签到(实验)', 'Add Doubao check-in (experimental)'), onClose },
1027
+ h('p', { style: S.note }, L('豆包「天天领额度」每天送体验额度,只有个人账号能领(飞书企业账号不行)。豆包没有令牌,要从浏览器复制一次登录 Cookie:', 'Doubao gives daily credits to personal accounts (not Feishu enterprise accounts). Doubao has no token, so copy its sign-in cookie from the browser once:')),
1028
+ step(1, h('span', null, L('用电脑浏览器打开 ', 'Open '), h('a', { href: 'https://www.doubao.com/', target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent } }, 'doubao.com ↗'), L(' 并登录', ' in a desktop browser and sign in'))),
1029
+ step(2, L('按 F12 打开开发者工具,切到「网络 / Network」,刷新一下页面', 'Press F12, open the Network tab, and reload the page')),
1030
+ step(3, L('点列表里任意一条 doubao.com 的请求,在「请求标头 / Request Headers」里找到 Cookie 那一行,右键复制它的值', 'Click any doubao.com request, find Cookie under Request Headers, and copy its value')),
1031
+ h('div', { style: { fontSize: 12, color: C.warn, margin: '8px 0' } }, L('Cookie 等于你的登录:只粘在这里(保存在本机),不要发给别人。', 'The cookie is your sign-in: paste it only here (it stays on this machine) and never share it.')),
1032
+ another ? h('div', { style: { ...S.line, marginBottom: 8 } },
1033
+ h('input', { style: { ...S.input, ...S.grow }, maxLength: 20, placeholder: L('给这个账号起个名字,如:小号', 'A name for this account, e.g. Second'), value: name, onChange: (e) => setName(e.target.value), 'aria-label': L('账号名字', 'Account name') })) : null,
1034
+ h('div', { style: S.line },
1035
+ h('input', { type: 'password', style: { ...S.input, ...S.grow }, placeholder: L('粘贴 Cookie', 'Paste the cookie'), value: cookie, onChange: (e) => setCookie(e.target.value), 'aria-label': 'Cookie' }),
1036
+ h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !cookie.trim(), onClick: () => void save() }, L('保存', 'Save'))),
1037
+ msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
1038
+ }
1039
+
1011
1040
  /** 签到卡片:平时一行"今日已完成签到:…";没签完的列出来,一键签到;自动签到开关 */
1012
1041
  function CheckinCard() {
1013
1042
  const [st, setSt] = useState(null)
1014
1043
  const [busy, setBusy] = useState(false)
1015
1044
  const [open, setOpen] = useState(false)
1016
1045
  const [qoder, setQoder] = useState(false)
1046
+ const [wbLogin, setWbLogin] = useState(null)
1047
+ const [doubao, setDoubao] = useState(false)
1017
1048
  const [err, setErr] = useState('')
1018
1049
  const refresh = useCallback(() => api('GET', '/checkin').then(setSt, () => {}), [])
1050
+ const addWorkbuddy = async () => {
1051
+ setErr('')
1052
+ try { setWbLogin((await api('POST', '/checkin/workbuddy')).login) } catch (e) { setErr(e.message) }
1053
+ }
1054
+ const removeAccount = async (kind, id, label) => {
1055
+ if (!window.confirm(L(`不再给「${label}」签到?`, `Stop checking in "${label}"?`))) return
1056
+ try { setSt(await api('DELETE', `/checkin/${kind}/${encodeURIComponent(id)}`)) } catch (e) { setErr(e.message) }
1057
+ }
1019
1058
  useEffect(() => { void refresh(); const t = setInterval(refresh, 5 * 60 * 1000); return () => clearInterval(t) }, [refresh])
1020
1059
  if (!st) return null
1021
1060
  const byId = new Map(st.results.map((r) => [r.id, r]))
@@ -1033,8 +1072,10 @@ window.__ModuleLoader__.load({
1033
1072
  // 没有能签到的:只留一个很小的入口
1034
1073
  return h('div', { style: { ...S.section, marginBottom: 16 } },
1035
1074
  h('div', { style: { ...S.meta } }, L('每日签到:登录 WorkBuddy 国内版,或', 'Daily check-in: sign in to WorkBuddy (China), or '),
1036
- h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setQoder(true) }, L('添加 Qoder', 'Add Qoder'))),
1037
- qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null)
1075
+ h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setQoder(true) }, L('添加 Qoder', 'Add Qoder')),
1076
+ h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setDoubao(true) }, L('添加豆包', 'Add Doubao'))),
1077
+ qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null,
1078
+ doubao ? h(DoubaoDialog, { onDone: () => { setDoubao(false); void refresh() }, onClose: () => setDoubao(false) }) : null)
1038
1079
  }
1039
1080
  return h('div', { style: S.section },
1040
1081
  h('div', { style: { ...S.card, padding: '10px 14px' } },
@@ -1044,19 +1085,31 @@ window.__ModuleLoader__.load({
1044
1085
  done.length && pending.length ? h('span', { style: { color: C.sub } }, L(';', '; ')) : null,
1045
1086
  pending.length ? h('span', { style: { color: C.text } }, L(`还没签:${pending.map((a) => a.label).join('、')}`, `Not yet: ${pending.map((a) => a.label).join(', ')}`)) : null),
1046
1087
  pending.length ? h('button', { type: 'button', style: S.btnPrimary, disabled: busy, onClick: () => void run() }, busy ? L('签到中…', 'Checking in…') : L('一键签到', 'Check in all')) : null,
1047
- st.results.length ? h('button', { type: 'button', style: S.btn, onClick: () => setOpen(!open) }, open ? L('收起', 'Hide') : L('详情', 'Details')) : null,
1088
+ // 详情里还有管理账号的按钮,所以没签过也要能打开
1089
+ h('button', { type: 'button', style: S.btn, onClick: () => setOpen(!open) }, open ? L('收起', 'Hide') : L('详情', 'Details')),
1048
1090
  h('label', { style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', whiteSpace: 'nowrap' } },
1049
1091
  h('input', { type: 'checkbox', checked: st.auto, onChange: (e) => void toggleAuto(e.target.checked) }), L('自动签到', 'Auto'))),
1050
1092
  open ? h('div', { style: { marginTop: 8 } },
1051
1093
  st.results.map((r) => h('div', { key: r.id, style: { fontSize: 12, lineHeight: 1.8, color: r.state === 'failed' ? C.err : C.text } },
1052
1094
  `${r.label}:${CHECKIN_TEXT(r)}`, r.advice ? h('span', { style: { color: C.sub, marginLeft: 6 } }, r.advice) : null)),
1053
- h('div', { style: { ...S.line, marginTop: 6 } },
1054
- st.qoder.configured
1055
- ? h('button', { type: 'button', style: S.btn, onClick: () => { if (window.confirm(L('删除 Qoder 的令牌?之后不再给 Qoder 签到。', 'Remove the Qoder token? Qoder will no longer be checked in.'))) void api('DELETE', '/checkin/qoder').then(setSt, (e) => setErr(e.message)) } }, L('删除 Qoder 令牌', 'Remove Qoder token'))
1056
- : h('button', { type: 'button', style: S.btn, onClick: () => setQoder(true) }, L('添加 Qoder', 'Add Qoder')))) : null,
1095
+ // 多账号:多加的 WorkBuddy 签到账号、每个 Qoder 令牌,都能删;再加一个
1096
+ h('div', { style: { marginTop: 8, borderTop: `1px solid ${C.border}`, paddingTop: 8 } },
1097
+ h('div', { style: { ...S.meta, marginBottom: 4 } }, L('签到账号(同一家可以加几个账号)', 'Check-in accounts (several per product are fine)')),
1098
+ [...((st.accounts && st.accounts.workbuddy) || []).map((a) => ({ kind: 'workbuddy', id: a.uid, label: a.label })), ...((st.accounts && st.accounts.qoder) || []).map((q) => ({ kind: 'qoder', id: q.id, label: q.label })), ...((st.accounts && st.accounts.doubao) || []).map((d) => ({ kind: 'doubao', id: d.id, label: d.label }))].map((a) =>
1099
+ h('div', { key: `${a.kind}:${a.id}`, style: { ...S.line, minHeight: 24, fontSize: 12 } },
1100
+ h('span', { style: S.grow }, a.label),
1101
+ h('button', { type: 'button', style: { ...S.btn, padding: '2px 8px' }, onClick: () => void removeAccount(a.kind, a.id, a.label) }, L('删除', 'Remove')))),
1102
+ h('div', { style: { ...S.line, marginTop: 4, flexWrap: 'wrap' } },
1103
+ h('button', { type: 'button', style: S.btn, onClick: () => void addWorkbuddy() }, L('+ 添加 WorkBuddy 账号', '+ Add a WorkBuddy account')),
1104
+ h('button', { type: 'button', style: S.btn, onClick: () => setQoder(true) }, st.qoder.configured ? L('+ 再加一个 Qoder 账号', '+ Add another Qoder account') : L('+ 添加 Qoder', '+ Add Qoder')),
1105
+ h('button', { type: 'button', style: S.btn, onClick: () => setDoubao(true) }, (st.accounts && st.accounts.doubao && st.accounts.doubao.length) ? L('+ 再加一个豆包账号', '+ Add another Doubao account') : L('+ 添加豆包', '+ Add Doubao')))),
1106
+ // Trae 读本机 Trae 的登录:这台没装(比如 VPS)就不会出现,在装了 Trae 的电脑上签就行(每个账号一天一次)
1107
+ !st.available.some((a) => a.id === 'trae') ? h('div', { style: { ...S.meta, marginTop: 6 } }, L('Trae:这台电脑上没装 Trae,所以不签。在装了 Trae 的电脑上会自动签(一个账号一天签一次就够)。', 'Trae: not installed on this machine, so it is skipped. A computer with Trae installed checks it in automatically (once a day per account is enough).')) : null) : null,
1057
1108
  !open && !st.qoder.configured ? h('div', { style: { ...S.meta, marginTop: 4 } }, h('button', { type: 'button', style: { ...S.btn, padding: '2px 8px' }, onClick: () => setQoder(true) }, L('+ 添加 Qoder', '+ Add Qoder'))) : null,
1058
1109
  err ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, err) : null),
1059
- qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null)
1110
+ qoder ? h(QoderDialog, { another: st.qoder.configured, onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null,
1111
+ doubao ? h(DoubaoDialog, { another: Boolean(st.accounts && st.accounts.doubao && st.accounts.doubao.length), onDone: () => { setDoubao(false); setOpen(true); void refresh() }, onClose: () => setDoubao(false) }) : null,
1112
+ wbLogin ? h(LoginDialog, { source: { id: 'workbuddy-checkin', label: L('WorkBuddy 签到账号', 'WorkBuddy check-in account'), login: 'link' }, session: wbLogin, onDone: () => { setWbLogin(null); setOpen(true); setTimeout(() => void refresh(), 3000) }, onClose: () => { setWbLogin(null); void refresh() } }) : null)
1060
1113
  }
1061
1114
 
1062
1115
  /** 签完在 dsh 右下角提示一次(成功或失败),一天一次 */
package/plugin/index.js CHANGED
@@ -63,8 +63,10 @@ const ALLOWED = [
63
63
  ['GET', /^\/self$/],
64
64
  ['GET', /^\/failures$/],
65
65
  ['GET', /^\/checkin$/],
66
- ['POST', /^\/checkin(\/auto|\/qoder)?$/],
67
- ['DELETE', /^\/checkin\/qoder$/],
66
+ ['POST', /^\/checkin(\/auto|\/qoder|\/workbuddy|\/doubao)?$/],
67
+ ['DELETE', /^\/checkin\/qoder(\/[A-Za-z0-9_-]{1,40})?$/],
68
+ ['DELETE', /^\/checkin\/workbuddy\/[A-Za-z0-9_-]{1,80}$/],
69
+ ['DELETE', /^\/checkin\/doubao\/[A-Za-z0-9_-]{1,40}$/],
68
70
  ['POST', /^\/models\/test$/],
69
71
  ['GET', /^\/combos$/],
70
72
  ['POST', /^\/combos$/],