magpie-plugin-loomy-auth 0.1.7 → 0.1.9

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.
Files changed (4) hide show
  1. package/README.md +154 -144
  2. package/docs/internals.md +62 -24
  3. package/index.mjs +118 -58
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,144 +1,154 @@
1
- # Loomy for magpie
2
-
3
- **10 个模型 · 三种登录 · 三笔积分一分不落**
4
-
5
- 讯飞 Loomy 的编程订阅接进 magpie:对话、看图、工具调用、思考档位;
6
- 积分按 magpie 的调度规则如实上报,该领的新手任务和邀请奖励自动领。
7
-
8
- ```
9
- 今日积分 ████████░░ 4014 / 5000 ← 决定现在能不能用这个号
10
- 永久积分 5500 (附加) ← 不会过期
11
- 新手任务 可领 9500 (附加) ← 一次性,领完就没了
12
- ```
13
-
14
- ---
15
-
16
- ## 为什么选这个
17
-
18
- **积分算得准。** 空号显示 0,不会因为「看起来额度很足」被 magpie 优先派活;
19
- 有余额的号按真实消耗排队。这一栏是 `used` 决定的路由输入,报错一个数字,
20
- 整个路由顺序就跟着错。
21
-
22
- **多领两笔钱。**
23
- - **新手任务 10,000 分** —— 卡片上直接显示「可领 9500 积分(7 项待完成)」,签到后自动到账
24
- - **邀请码 5,000 分** —— 添加新账号时自动带本机老账号的邀请码,**双方各得 5000**
25
-
26
- **不用重新登录。** 凭据格式与旧插件相通,装上就能用,登录态原样带过来。
27
-
28
- **免费模型标得对。** `spark-x2.6` / `spark-x` 是 0 倍率(限时免费),
29
- 插件如实上报 `free`,路由组用「免费优先」时它们会被真正排在前面。
30
-
31
- ---
32
-
33
- ## 模型
34
-
35
- 10 个对话模型,全部实测(非文档抄录):
36
-
37
- | 模型 | 上下文 | 最大输出 | 看图 | 倍率 |
38
- |---|---|---|---|---|
39
- | `DeepSeek-V4.1-Flash` | 1M | 384K | ✓ | x2.0 |
40
- | `MiniMax-M3` | 1M | 512K | ✓ | x4.0 |
41
- | `GLM-5.3-Flash` | 1M | 128K | ✓ | x0.8 |
42
- | `qwen3.8-flash` | 1M | 128K | ✓ | x0.8 |
43
- | `mimo-v2.5` | 1M | 128K | ✓ | x3.3 |
44
- | `qwen-3.8-max` | 1M | 64K | ✗ | x12.0 |
45
- | `Kimi-k2.6` | 256K | 64K | ✓ | x6.5 |
46
- | `spark-x2.6` | 1M | 128K | ✗ | **免费** |
47
- | `spark-x` | 1M | 64K | ✗ | **免费** |
48
- | `auto` | — | — | ✗ | 自动选 |
49
-
50
- - **思考档位** `none / low / medium / high / xhigh`,默认 `low`,在 magpie 里直接选
51
- - **看图** 支持视觉的模型可直接发图;deepseek 系要求特殊格式,插件自动转换
52
- - **工具调用** 全部支持
53
-
54
- ---
55
-
56
- ## 供应商
57
-
58
- ### 登录
59
-
60
- | 方式 | 适用 |
61
- |---|---|
62
- | **桌面端导入**(推荐) | 本机装着 Loomy 客户端并已登录 —— 点一下,不收短信 |
63
- | **手机号 + 短信** | 没装客户端 |
64
- | **粘贴 session** | 想手动导 |
65
-
66
- 会话固定 **14 天**,Loomy 没有续期接口,到期后需重新登录一次。
67
- 插件不会假装能自动续 —— 否则 magpie 会一直以为可用,你反而等不到「请重新登录」的提示。
68
-
69
- ### 三笔积分
70
-
71
- | | 怎么来 | 影响路由吗 |
72
- |---|---|---|
73
- | **今日积分** | 每天 5000,北京时间 0 点重置,用掉不回补 | **是** —— 只有它决定现在能不能用这个号 |
74
- | **永久积分** | 注册 + 新手任务,不会过期 | 否(附加) |
75
- | **新手任务** | 8 项任务合计 10,000 分,一次性 | 否(附加) |
76
-
77
- 为什么这么分:今日池每天清零,用光就是没了;永久积分不过期,不该因为它多就优先烧;
78
- 新手任务领一次就结束,更不能当常规额度。
79
-
80
- ### 签到
81
-
82
- magpie 按北京时间每天调一次,签到同时会刷新当天额度,并**自动领取新手任务里已完成的那几项**。
83
- 结果显示具体数字,不是含糊的成功/失败:
84
-
85
- ```
86
- 今日额度已刷新(3479/5000 积分)· 新手任务 +500 分
87
- ```
88
-
89
- ### 加第二个账号
90
-
91
- Loomy 的邀请机制:**邀请好友双方各得 5000 积分**。
92
-
93
- 添加新账号时,插件会自动从本机已有账号里挑一个邀请码带上去 ——
94
- 用没用过的、还有余量的、用得最少的那个。你只要正常「添加账号」,那 5000 分自己就到手。
95
-
96
- ---
97
-
98
- ## 安装
99
-
100
- 在 magpie 的 **插件 → 发现** 里搜 `Loomy`,点**安装**。
101
-
102
- ⚠ 先卸掉别的 Loomy 插件 —— 两个插件抢同一个 `loomy` 供应商会互相打架。
103
-
104
- 装完 `magpie plugin list` 应看到:
105
-
106
- ```
107
- magpie-plugin-loomy-auth on
108
- loomy (Loomy, 10 models) signed in as 189****8051
109
- ```
110
-
111
- ---
112
-
113
- ## 常见问题
114
-
115
- **积分显示 0,但官方 App 里还有?**
116
- ① 今日额度用光,等北京时间 0 点重置;② 那是**新手任务**,卡片上单独一行「可领 XXXX 积分」,签到后到账。
117
-
118
- **显示「登录已失效」?**
119
- 会话 14 天到期,重新登录一次即可。
120
-
121
- **会花我的积分吗?**
122
- 不会。插件只读余额、签到、领任务奖励。消耗积分的是你通过 magpie 发的对话请求。
123
-
124
- **能看到我的什么?**
125
- Loomy 登录态(存在 magpie 自己的凭据文件里)、积分/模型/任务读数。
126
- 直连 `loomyad.xunfei.cn` 与 `account.xfinfr.com`,不经任何第三方中转。
127
- 邀请码功能读同文件里其它 Loomy 账号的码,仅只读,不外发。
128
-
129
- **装完没反应?**
130
- `magpie plugin list` 确认只有一个 `loomy`。改完配置重启一次 magpie 最稳(登录态不丢)。
131
-
132
- ---
133
-
134
- ## 卸载
135
-
136
- `magpie plugin rm magpie-plugin-loomy-auth`
137
-
138
- 登录态留在 magpie 的凭据里,重装即恢复。
139
-
140
- ---
141
-
142
- ## 许可
143
-
144
- MIT。协议事实、实现判据、测试与本地安装见 [`docs/internals.md`](docs/internals.md)。
1
+ # Loomy for magpie
2
+
3
+ **10 个模型 · 三种登录 · 三笔积分一分不落**
4
+
5
+ 讯飞 Loomy 的编程订阅接进 magpie:对话、看图、工具调用、思考档位;
6
+ 积分按 magpie 的调度规则如实上报,该领的新手任务和邀请奖励自动领。
7
+
8
+ ```
9
+ 今日积分 ████████░░ 4014 / 5000 ← 决定现在能不能用这个号
10
+ 永久积分 5500 (附加) ← 不会过期
11
+ 新手任务 可领 9500 (附加) ← 一次性,领完就没了
12
+ ```
13
+
14
+ ---
15
+
16
+ ## 为什么选这个
17
+
18
+ **积分算得准。** 空号显示 0,不会因为「看起来额度很足」被 magpie 优先派活;
19
+ 有余额的号按真实消耗排队。这一栏是 `used` 决定的路由输入,报错一个数字,
20
+ 整个路由顺序就跟着错。
21
+
22
+ **每天签到,顺手把能领的都领了。**
23
+ - **新手任务 10,000 分** —— 卡片上显示「可领 9500 积分」,签到后自动到账
24
+ - **邀请码 5,000 分** —— 本机有别的账号时自动兑换,**双方各得 5000**(实测)
25
+
26
+ 三件事(刷新额度 / 领任务 / 兑换邀请码)全部幂等,都放在 magpie 每天一次
27
+ 的签到里做完,**你不需要点任何按钮**。
28
+
29
+ **不用重新登录。** 凭据格式与旧插件相通,装上就能用,登录态原样带过来。
30
+
31
+ **免费模型标得对。** `spark-x2.6` / `spark-x` 是 0 倍率(限时免费),
32
+ 插件如实上报 `free`,路由组用「免费优先」时它们会被真正排在前面。
33
+
34
+ ---
35
+
36
+ ## 模型
37
+
38
+ 10 个对话模型,全部实测(非文档抄录):
39
+
40
+ | 模型 | 上下文 | 最大输出 | 看图 | 倍率 |
41
+ |---|---|---|---|---|
42
+ | `DeepSeek-V4.1-Flash` | 1M | 384K | ✓ | x2.0 |
43
+ | `MiniMax-M3` | 1M | 512K | ✓ | x4.0 |
44
+ | `GLM-5.3-Flash` | 1M | 128K | ✓ | x0.8 |
45
+ | `qwen3.8-flash` | 1M | 128K | ✓ | x0.8 |
46
+ | `mimo-v2.5` | 1M | 128K | ✓ | x3.3 |
47
+ | `qwen-3.8-max` | 1M | 64K | ✗ | x12.0 |
48
+ | `Kimi-k2.6` | 256K | 64K | ✓ | x6.5 |
49
+ | `spark-x2.6` | 1M | 128K | ✗ | **免费** |
50
+ | `spark-x` | 1M | 64K | ✗ | **免费** |
51
+ | `auto` | — | — | ✗ | 自动选 |
52
+
53
+ - **思考档位** `none / low / medium / high / xhigh`,默认 `low`,在 magpie 里直接选
54
+ - **看图** 支持视觉的模型可直接发图;deepseek 系要求特殊格式,插件自动转换
55
+ - **工具调用** 全部支持
56
+
57
+ ---
58
+
59
+ ## 供应商
60
+
61
+ ### 登录
62
+
63
+ | 方式 | 适用 |
64
+ |---|---|
65
+ | **桌面端导入**(推荐) | 本机装着 Loomy 客户端并已登录 —— 点一下,不收短信 |
66
+ | **手机号 + 短信** | 没装客户端 |
67
+ | **粘贴 session** | 想手动导 |
68
+
69
+ 会话固定 **14 天**,Loomy 没有续期接口,到期后需重新登录一次。
70
+ 插件不会假装能自动续 —— 否则 magpie 会一直以为可用,你反而等不到「请重新登录」的提示。
71
+
72
+ ### 三笔积分
73
+
74
+ | | 怎么来 | 影响路由吗 |
75
+ |---|---|---|
76
+ | **今日积分** | 每天 5000,北京时间 0 点重置,用掉不回补 | **是** —— 只有它决定现在能不能用这个号 |
77
+ | **永久积分** | 注册 + 新手任务,不会过期 | 否(附加) |
78
+ | **新手任务** | 8 项任务合计 10,000 分,一次性 | 否(附加) |
79
+
80
+ 为什么这么分:今日池每天清零,用光就是没了;永久积分不过期,不该因为它多就优先烧;
81
+ 新手任务领一次就结束,更不能当常规额度。
82
+
83
+ ### 签到
84
+
85
+ magpie 按北京时间每天调一次,签到同时会刷新当天额度,并**自动领取新手任务里已完成的那几项**。
86
+ 结果显示具体数字,不是含糊的成功/失败:
87
+
88
+ ```
89
+ 今日额度已刷新(3479/5000 积分)· 新手任务 +500 分
90
+ ```
91
+
92
+ ### 加第二个账号
93
+
94
+ **不用填邀请码,签到自动完成。**
95
+
96
+ Loomy 的邀请奖励:**邀请好友双方各得 5000 积分**(实测,附流水在 `docs/internals.md`)。
97
+ 每个号能邀请 1000 人,每个号**只能兑换一次**别人的码。
98
+
99
+ 你只要照常把新账号登进 magpie,下一次签到时会自动:查本机其它账号的邀请码 →
100
+ 挑一个 → 兑换。新号 +5000,老号 +5000。
101
+
102
+ 每条邀请码能邀请 1000 人;**每个号只能被邀请一次**,所以「A 邀 B、B 再邀 A」
103
+ 平台是允许的(实测过,服务端不拦)。插件不干预这个 —— 它只守平台自己的那条:
104
+ 本号已经被邀请过就不再兑换。
105
+
106
+ ---
107
+
108
+ ## 安装
109
+
110
+ 在 magpie 的 **插件 → 发现** 里搜 `Loomy`,点**安装**。
111
+
112
+ ⚠ 先卸掉别的 Loomy 插件 —— 两个插件抢同一个 `loomy` 供应商会互相打架。
113
+
114
+ 装完 `magpie plugin list` 应看到:
115
+
116
+ ```
117
+ magpie-plugin-loomy-auth on
118
+ loomy (Loomy, 10 models) signed in as 189****8051
119
+ ```
120
+
121
+ ---
122
+
123
+ ## 常见问题
124
+
125
+ **积分显示 0,但官方 App 里还有?**
126
+ ① 今日额度用光,等北京时间 0 点重置;② 那是**新手任务**,卡片上单独一行「可领 XXXX 积分」,签到后到账。
127
+
128
+ **显示「登录已失效」?**
129
+ 会话 14 天到期,重新登录一次即可。
130
+
131
+ **会花我的积分吗?**
132
+ 不会。插件只读余额、签到、领任务奖励。消耗积分的是你通过 magpie 发的对话请求。
133
+
134
+ **能看到我的什么?**
135
+ Loomy 登录态(存在 magpie 自己的凭据文件里)、积分/模型/任务读数。
136
+ 直连 `loomyad.xunfei.cn` 与 `account.xfinfr.com`,不经任何第三方中转。
137
+ 邀请码功能读同文件里其它 Loomy 账号的码,仅只读,不外发。
138
+
139
+ **装完没反应?**
140
+ `magpie plugin list` 确认只有一个 `loomy`。改完配置重启一次 magpie 最稳(登录态不丢)。
141
+
142
+ ---
143
+
144
+ ## 卸载
145
+
146
+ `magpie plugin rm magpie-plugin-loomy-auth`
147
+
148
+ 登录态留在 magpie 的凭据里,重装即恢复。
149
+
150
+ ---
151
+
152
+ ## 许可
153
+
154
+ MIT。协议事实、实现判据、测试与本地安装见 [`docs/internals.md`](docs/internals.md)。
package/docs/internals.md CHANGED
@@ -139,39 +139,77 @@ Loomy 还有第三笔额度 —— **新手任务奖励**,总计 10,000 分,
139
139
  > 实况:`181****0632` 余额 0,但还有 10,000 分新手任务可领。卡片会如实显示,
140
140
  > 不会因为「余额 0」就把这个人判死刑。
141
141
 
142
- ### 邀请码:新账号自动用老账号的邀请资格
142
+ ### 邀请码:签到时自动兑换(不在注册时)
143
143
 
144
- Loomy 的「分享有礼」——**邀请好友双方各得 5000 积分**(客户端常量 `nb = 5e3`;
145
- ⚠ 别和新手任务里 `share_soul` 的 2000 混,那是两笔不同的奖励)。
144
+ **实测结论(2026-10-26,真号跑的)**:邀请双方**各得 5000**,不是 2000。
146
145
 
147
- 每个账号有**专属邀请码**(实测:`189****8051` → `E4DPT6`、`181****00632` → `FDW3CU`,
148
- 上限 1000 次、已用 0),并且**每个账号只能兑换一次**别人的码。
146
+ ```
147
+ 操作:181****0632 兑换 189****8051 的码 E4DPT6(走 bindInviteCode → POST /points/activation)
148
+
149
+ 189****8051 永久积分 14860 → 19860 (+5000) 流水:+5000 邀请好友奖励
150
+ 181****0632 永久积分 10000 → 15000 (+5000) 流水:+5000 填写邀请码奖励
151
+ E4DPT6 usedCount 0 → 1 181 activation false → true
152
+ ```
149
153
 
150
154
  | 端点 | 用途 |
151
155
  |---|---|
152
- | `GET /api/v1/invitation-codes` | 查自己的码(只读) |
153
- | `GET /api/v1/points/activation` | 本账号是否已用过别人的码(只读) |
154
- | `POST /api/v1/points/activation` | 绑定别人的码(一次性写) |
155
- | `POST /api/v1/points/first-login` | **也接受 `{inviteCode, deviceId}`** —— 首次初始化时捎上,不用事后再绑 |
156
-
157
- **这个插件怎么做的**:新账号登录时,自动从**本机其它已登录的 Loomy 账号**里挑一个
158
- 邀请码带上去。
159
-
160
- - 挑码规则:排除自己的、用完的(`usedCount >= maxUses`)、非 `active` 的;
161
- 优先**用得最少**的(把额度摊开);`maxUses <= 0` 视为不限次。
162
- - 先查 `activation`:**已用过就跳过**,不白发一次写请求(每账号只能用一次)。
163
- - 走 `first-login {inviteCode}`(首次初始化顺带绑定);那条没成再退回
164
- `points/activation`。**绑定失败不影响登录** —— 这是加分项。
165
- - 为什么能拿到别的账号的邀请码:`getAuth()` 只在当前账号作用域里可见
166
- (magpie 的 `inScope`),所以直接读 `~/.config/magpie/plugin-auth.json`,
167
- 把 `loomy` / `loomy#xxxxxx` 全捞出来逐个查(只读、不打印 session)。
156
+ | `GET /api/v1/invitation-codes` | 查自己的码(只读)。回 `{inviteCode, usedCount, maxUses, status}` |
157
+ | `GET /api/v1/points/activation` | 本号是否已兑换过;回 `{activated, appliedInvitationCode}` |
158
+ | `POST /api/v1/points/activation` | 绑定别人的码(**每账号一次**) |
159
+ | `POST /api/v1/points/first-login` | **也接受 `{inviteCode, deviceId}`** |
160
+
161
+ #### 为什么放在签到里,而不是登录时
162
+
163
+ 邀请码的有效期是「账号存在期间」,不是「注册那一刻」。放签到里覆盖两种情形:
164
+ 新号第一次签到、老号后来才想用。注册流程里不用塞输入框。
165
+
166
+ `auth.checkin` 现在一次做三件事(全部幂等,重复调用无害):
168
167
 
169
168
  ```
170
- 新账号登录 → 收集本机其它 Loomy 账号的邀请码 → 挑用得最少的
171
- → 查 activation(已用过就跳过)→ first-login {inviteCode}
172
- → 双方各 +5000
169
+ ① POST /points/first-login 刷新每日额度(拿 dailyQuota)
170
+ ② GET|POST /onboarding/tasks* 领新手任务
171
+ ③ GET /points/activation 确认没兑换过
172
+ GET /invitation-codes (本机其它账号) 取候选
173
+ POST /points/activation 兑换选中的码
173
174
  ```
174
175
 
176
+ ②③ 失败都不影响签到结果 —— 它们是顺带做的事,不是前置条件。
177
+
178
+ #### 判据只有一条:本号是否已经被邀请过
179
+
180
+ 平台规则:**每个账号仅能兑换一次**(客户端文案「你已经兑换过积分啦」)。
181
+ 所以判断依据就是 `GET /points/activation` 的 `activated`,跟「谁邀请了谁」无关。
182
+
183
+ **为什么不需要知道对方是谁**:`GET /api/v1/invitation-codes` 只回
184
+ `{inviteCode, usedCount, maxUses, status}` —— 有数量、没有名单。
185
+ 客户端里也没有「被邀请的好友」列表接口;探测 `/invitation-codes/invitees`、
186
+ `/invitation-records`、`/points/invitation-records` 全部 404。
187
+ 既然拿不到,就不该假装能判断。
188
+
189
+ #### 平台**允许**互刷(实测,不是推测)
190
+
191
+ ```
192
+ 189 兑换 181 的 FDW3CU → +5000 填写邀请码奖励
193
+ 181 兑换 189 的 E4DPT6 → +5000 填写邀请码奖励
194
+ 两边各再收 +5000 邀请好友奖励
195
+ ⇒ 各得 10000,服务端全程 200,没有任何拦截
196
+ ```
197
+
198
+ **这是平台的规则漏洞,不是插件的责任。** 本插件只做两件合规的事:
199
+
200
+ 1. 本号 `activated: true` 时不再兑换(平台规则);
201
+ 2. 把本号自己的码从候选里排除(不能自己邀请自己)。
202
+
203
+ ⚠ 曾经加过一条「本号码被用过就不再兑换别人的码」的规则,**已移除** —— 它拦不住互刷
204
+ (先兑换的一方 `usedCount` 还是 0),却会误伤 A→B→C 这种正常长链里的 B。
205
+
206
+ #### 候选从哪来
207
+
208
+ `getAuth()` 只在当前账号作用域可见(magpie 的 `inScope`),拿不到别的账号。
209
+ 所以直接读 `~/.config/magpie/plugin-auth.json`,把 `loomy` / `loomy#xxxxxx`
210
+ 全捞出来逐个查(只读,session 不打印、不外发)。挑码规则:
211
+ 排除自己的、用完的(`usedCount >= maxUses`)、非 `active` 的;优先 `usedCount` 最小的。
212
+
175
213
  ### 一次 `usage()` 的请求数
176
214
 
177
215
  | 情形 | 请求数 |
package/index.mjs CHANGED
@@ -164,6 +164,54 @@ export const LoomyAuthPlugin = async ({ client, directory } = {}) => {
164
164
  return out
165
165
  }
166
166
 
167
+ /**
168
+ * 给**本账号**挑一个可用的邀请码(供签到钩子调用)。
169
+ *
170
+ * 返回 `undefined` 表示不该兑换 —— 三种情况:
171
+ * 1. 本号已经兑换过别人的码(`activated: true`)—— 每号只能一次,别白发请求
172
+ * 2. 本机没有**别人**的码(只有自己)—— 不能自己邀请自己
173
+ * 3. 挑不出还有余量的码
174
+ *
175
+ * ⚠ **被邀请者仍可以继续邀请别人**:实测 181 号兑换了 189 的码之后,
176
+ * 它自己的 `FDW3CU` 仍是 `active` / `usedCount 0`。所以这里**不**因为
177
+ * 「本号 activated」就把本号从候选里剔除 —— 它自己那份照常可用。
178
+ * 反过来,**已邀请过我的那个码**要排除:不然 A 邀请 B、B 又邀请 A,
179
+ * 平台侧可能视作互刷。
180
+ */
181
+ const pickInviteFor = async (session) => {
182
+ // ① **判据只有一条:本号是否已经被邀请过。**
183
+ //
184
+ // 平台规则就这一条(客户端文案:「每个账号仅能兑换一次」「你已经兑换过积分啦」)。
185
+ // 所以不需要知道「谁邀请了谁」—— 那信息接口根本不提供
186
+ // (`invitation-codes` 只回 `usedCount` 这个数量,不说是谁)。
187
+ // `appliedInvitationCode` 只用于日志,不参与判断。
188
+ let applied
189
+ try {
190
+ const st = await fetchActivation(session)
191
+ if (st.activated) return undefined
192
+ applied = st.appliedInvitationCode
193
+ } catch {
194
+ // 读不到状态就别乱兑换 —— 宁可这一天不换
195
+ return undefined
196
+ }
197
+
198
+ // ② 本号自己的码要从候选里排除(不能自己邀请自己)
199
+ let selfCode
200
+ try {
201
+ const mine = await fetchInviteCodes(session)
202
+ selfCode = mine[0]?.code
203
+ } catch {
204
+ selfCode = undefined
205
+ }
206
+
207
+ // ③ 从本机**别的账号**的码里挑一个还有余量的
208
+ const candidates = await collectInviteCandidates(session)
209
+ const picked = _pure.pickInviteCode(candidates, selfCode)
210
+ if (!picked) return undefined
211
+ if (applied && picked.code === applied) return undefined // 理论上不可达,防御性保留
212
+ return picked.code
213
+ }
214
+
167
215
  /**
168
216
  * 新账号登录后的收尾:把「已有账号的邀请码」带上去 + 记住当日上限。
169
217
  *
@@ -174,40 +222,17 @@ export const LoomyAuthPlugin = async ({ client, directory } = {}) => {
174
222
  * 邀请码从**其它已登录账号**里挑(`getAuth` 拿不到全局,所以由调用方传 candidates)。
175
223
  * 一个都找不到就静默跳过 —— 这是加分项,不该让登录失败。
176
224
  */
177
- const settleNewAccount = async (session, auth, { inviteCandidates, selfCode } = {}) => {
178
- const patch = {}
179
- let claim
180
-
181
- // 挑一个可用的邀请码(排除自己的、用完的、非 active 的)
182
- const picked = _pure.pickInviteCode(inviteCandidates ?? [], selfCode)
183
- if (picked) {
184
- try {
185
- const state = await fetchActivation(session)
186
- if (state.activated) {
187
- log(`该账号已用过邀请码,跳过(不重复兑换)`)
188
- } else {
189
- // 两条路都能绑:带上 inviteCode 调 first-login(首次初始化),
190
- // 或显式 POST points/activation。先用前者 —— 它顺便把当日额度也初始化了。
191
- claim = await claimDailyQuota(session, { inviteCode: picked.code })
192
- if (claim.ok) {
193
- log(`已绑定邀请码 ${picked.code}(来自已有账号),双方各得 ${_pure.INVITE_REWARD_POINTS} 积分`)
194
- } else {
195
- // first-login 那条没成,退回显式绑定
196
- const bound = await bindInviteCode(session, picked.code)
197
- if (bound.ok) log(`已绑定邀请码 ${picked.code}(activation)`)
198
- else log(`邀请码绑定失败(不影响登录):${bound.message}`, "warn")
199
- claim = claim ?? (await claimDailyQuota(session))
200
- }
201
- }
202
- } catch (e) {
203
- log(`邀请码流程跳过:${e?.message ?? e}`, "warn")
204
- }
205
- }
206
- claim = claim ?? (await claimDailyQuota(session))
207
-
208
- const qp = quotaPatch(claim)
209
- if (Object.keys(qp).length) Object.assign(patch, qp)
210
- return patch
225
+ /**
226
+ * 登录后的收尾:**只初始化当天额度**,顺手记进凭据。
227
+ *
228
+ * ⚠ 邀请码**不在这里兑换**。它是「账号存在期间随时可做一次」的事,
229
+ * 不是「注册那一刻必须做」的事 —— 放进每天一次的签到钩子,既覆盖新号
230
+ * 第一次签到,也覆盖「老号后来才想用」,注册流程里不用塞输入框。
231
+ * 见下面 `auth.checkin` 的第 ③ 步。
232
+ */
233
+ const settleNewAccount = async (session) => {
234
+ const claim = await claimDailyQuota(session)
235
+ return quotaPatch(claim)
211
236
  }
212
237
 
213
238
  const remember = async (provider, auth, patch) => {
@@ -283,8 +308,7 @@ export const LoomyAuthPlugin = async ({ client, directory } = {}) => {
283
308
  (desktop.phone ? _pure.maskPhone(String(desktop.phone)) : `Loomy ${session.slice(-6)}`)
284
309
  // 登录后立刻初始化当天额度(官方客户端同款行为)并把上限记进凭据;
285
310
  // 顺便带上已有账号的邀请码(每个账号只能兑换一次,失败不影响登录)
286
- const inviteCandidates = await collectInviteCandidates(session)
287
- const claim = await settleNewAccount(session, null, { inviteCandidates })
311
+ const patch = await settleNewAccount(session)
288
312
  return {
289
313
  type: "success",
290
314
  access: session,
@@ -293,7 +317,7 @@ export const LoomyAuthPlugin = async ({ client, directory } = {}) => {
293
317
  accountId: label,
294
318
  ...(desktop.userid ? { uid: String(desktop.userid) } : {}),
295
319
  ...(desktop.phone ? { phone: String(desktop.phone) } : {}),
296
- ...quotaPatch(claim),
320
+ ...patch,
297
321
  }
298
322
  },
299
323
  }
@@ -322,8 +346,7 @@ export const LoomyAuthPlugin = async ({ client, directory } = {}) => {
322
346
  instructions: `验证码已发送至 ${_pure.maskPhone(phone)},输入 6 位数字完成登录。`,
323
347
  callback: async (code) => {
324
348
  const { session, userid } = await loginBySmsCode(phone, code, msgid)
325
- const inviteCandidates = await collectInviteCandidates(session)
326
- const claim = await settleNewAccount(session, null, { inviteCandidates })
349
+ const patch = await settleNewAccount(session)
327
350
  return {
328
351
  type: "success",
329
352
  access: session,
@@ -332,7 +355,7 @@ export const LoomyAuthPlugin = async ({ client, directory } = {}) => {
332
355
  accountId: _pure.maskPhone(phone),
333
356
  uid: userid,
334
357
  phone,
335
- ...quotaPatch(claim),
358
+ ...patch,
336
359
  }
337
360
  },
338
361
  }
@@ -365,15 +388,14 @@ export const LoomyAuthPlugin = async ({ client, directory } = {}) => {
365
388
  return { type: "failed", error: "session 无效或已过期(拉取模型目录失败)" }
366
389
  }
367
390
  const note = String(inputs?.note ?? "").trim()
368
- const inviteCandidates = await collectInviteCandidates(session)
369
- const claim = await settleNewAccount(session, null, { inviteCandidates })
391
+ const patch = await settleNewAccount(session)
370
392
  return {
371
393
  type: "success",
372
394
  access: session,
373
395
  refresh: "",
374
396
  expires: Date.now() + SESSION_TTL_MS,
375
397
  accountId: note || `Loomy ${session.slice(-6)}`,
376
- ...quotaPatch(claim),
398
+ ...patch,
377
399
  }
378
400
  },
379
401
  },
@@ -452,14 +474,27 @@ export const LoomyAuthPlugin = async ({ client, directory } = {}) => {
452
474
  * (返回 `"success"`,magpie 归 `failed` 却保留 message,
453
475
  * 界面于是显示「签到失败 · 每日赠送积分已刷新」)。
454
476
  */
477
+ /**
478
+ * 签到钩子 = **每天把能领的都领一遍**。
479
+ *
480
+ * magpie 按北京时间每账号每天调一次,所以这里是「顺手做」的最佳位置:
481
+ *
482
+ * ① 刷新每日额度(`first-login`,幂等)
483
+ * ② 领新手任务(`onboarding/tasks/complete`,幂等)
484
+ * ③ 兑换邀请码(`points/activation`,**每账号一次**)
485
+ *
486
+ * 三件事全部幂等,重复调用无害 —— 这是把它们放在每天签到里的前提。
487
+ * ⚠ ②③ 失败都**不影响签到结果**:它们是一天里顺带做的事,
488
+ * 不是签到的前置条件。
489
+ */
455
490
  async checkin(getAuth, provider) {
456
491
  const auth = await getAuth()
457
492
  const credential = _pure.credentialOf(auth)
458
493
  if (!credential) return { outcome: "failed", message: "未登录" }
459
494
 
495
+ // ① 每日额度
460
496
  const claim = await claimDailyQuota(credential.session)
461
497
  const result = _pure.checkinOutcome(claim)
462
-
463
498
  if (claim.ok) {
464
499
  // 签到是拿到 `dailyQuota` 的**权威时刻**:记进凭据并清缓存,
465
500
  // 让卡片立刻反映新的上限(而不是等 60 秒 TTL)
@@ -467,24 +502,47 @@ export const LoomyAuthPlugin = async ({ client, directory } = {}) => {
467
502
  clearUsageCache()
468
503
  }
469
504
 
470
- // 新手任务跟着签到一起领:它们本就是「登录后的一次性引导奖励」,
471
- // 放在每天一次的签到里做,既不会漏也不会重复(领取是幂等的)。
472
- // ⚠ 失败不影响签到结果 —— 这是**加分项**,不是签到的前置条件。
473
- if (claim.ok) {
474
- try {
475
- const state = await fetchOnboarding(credential.session)
476
- const { claimed, failed } = await claimAllTasks(credential.session, state.tasks)
477
- onboardingCache.set(credential.session, { at: Date.now(), value: null })
478
- if (claimed.length > 0) {
479
- const gained = claimed.reduce((n, x) => n + x.points, 0)
480
- log(`新手任务领到 ${claimed.length} 项(+${gained} 分)${failed.length ? `,${failed.length} 项失败` : ""}`)
481
- result.message += ` · 新手任务 +${gained} 分`
505
+ const extras = []
506
+
507
+ // ② 新手任务
508
+ try {
509
+ const state = await fetchOnboarding(credential.session)
510
+ const { claimed, failed } = await claimAllTasks(credential.session, state.tasks)
511
+ onboardingCache.set(credential.session, { at: Date.now(), value: null })
512
+ if (claimed.length > 0) {
513
+ const gained = claimed.reduce((n, x) => n + x.points, 0)
514
+ log(`新手任务领到 ${claimed.length} 项(+${gained} 分)${failed.length ? `,${failed.length} 项失败` : ""}`)
515
+ extras.push(`新手任务 +${gained} 分`)
516
+ clearUsageCache()
517
+ }
518
+ } catch (e) {
519
+ log(`新手任务领取跳过:${e?.message ?? e}`, "warn")
520
+ }
521
+
522
+ // ③ 邀请码 —— **签到时才兑换,不在登录时**
523
+ //
524
+ // 为什么改到这里:邀请码的有效期是「帐号存在期间」,不是「注册那一刻」。
525
+ // 放在签到里,既覆盖了「新号第一次签到」,也覆盖了「老号后来才决定用」,
526
+ // 不用在注册流程里塞一个输入框。
527
+ try {
528
+ const picked = await pickInviteFor(credential.session)
529
+ if (picked) {
530
+ const r = await bindInviteCode(credential.session, picked)
531
+ if (r.ok && !r.alreadyActivated) {
532
+ log(`已兑换邀请码 ${picked}(邀请人 +${_pure.INVITE_REWARD_POINTS},本号 +${_pure.INVITE_REWARD_POINTS})`)
533
+ extras.push(`邀请码 +${_pure.INVITE_REWARD_POINTS} 分`)
482
534
  clearUsageCache()
535
+ } else if (r.ok && r.alreadyActivated) {
536
+ log(`本号已兑换过邀请码(${picked}),跳过`)
537
+ } else {
538
+ log(`邀请码兑换失败(不影响签到):${r.message}`, "warn")
483
539
  }
484
- } catch (e) {
485
- log(`新手任务领取跳过:${e?.message ?? e}`, "warn")
486
540
  }
541
+ } catch (e) {
542
+ log(`邀请码兑换跳过:${e?.message ?? e}`, "warn")
487
543
  }
544
+
545
+ if (extras.length) result.message += ` · ${extras.join(" · ")}`
488
546
  return result
489
547
  },
490
548
  },
@@ -560,4 +618,6 @@ export const _internal = {
560
618
  bindInviteCode,
561
619
  fetchActivation,
562
620
  readAllLoomyAccounts,
621
+ pickInviteFor: (session) => pickInviteFor(session),
622
+ collectInviteCandidates: (s) => collectInviteCandidates(s),
563
623
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-plugin-loomy-auth",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Loomy (iFlytek) for magpie: desktop-session import / SMS / session sign-in, live model catalog with reasoning efforts, chat with tools and images, and the two credit pools reported as the host routes them — the daily pool counts, the permanent one is shown aside.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",