Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

本文件按 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 的风格记录。

## [Unreleased]

### Added

- **Loomy 的宠物每日打工奖励(0~1000 分/天)随签到领取**。`GET /pet-work` 读当日
快照、`POST /pet-work/rewards` 申请一档;档位由服务端按该账号当日在官方客户端的
有效对话轮次计算,本包只把已达档位的分收下来,未达档位时如实上报(不冒充领到)。
一次签到会把今天未领的档位逐档申请,某档没达标就停手。请求体的 `configVersion`
取自快照的 `config.version` —— 两处拼写不同是服务端的形状(2026-10-23 实测);
封顶或当天没有未领档位时一个写请求都不发。
- 额度卡片新增一行 `aside` 的只读进度(`宠物打工 今日 300/1000 · 已领 1/3 档`),
按账号缓存 10 分钟,封顶或活动关闭时不显示。
- 领奖只挂在 `auth.checkin`(`Check in now` / 每日自动签到 / `magpie accounts
checkin`),**不**在 `usage` 里读到就领:未达档位是常态,每次额度刷新都发一个
注定被拒的请求只是白烧上游(新手任务能那样做,是因为服务端不校验前置行为)。

## [0.9.9] - 2026-10-22

### Fixed
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ magpie plugin options <插件名> '{"accessKeyId": "…", "accessKeySecret": "
| 思考档位 | 远端 `supported_reasoning_levels` 原样(`none` / `high` / `max`);只声明远端下发过的档位 —— 上游对非法档位不报错、按最大档计费执行 | 厂商自己的档位(`none` / `low` / `medium` / `high` / `xhigh`)直接映射到宿主 variants | 只有「开 / 关」两态,宿主词汇用 `high` / `none`,在请求体里写成 `extra_body.thinking.type = enabled` / `disabled`(厂商唯一有效的通道;`reasoning_effort` 实测无效,因此插件不会把它当档位用) | 远端 `reasoning_effort_config.options` 的字符串原样作为档位(`light` / `high` / `extra_high` …);它们是上游 wire 值,不做白名单转换 |
| 用量卡片 | `points/balance` 的权威合计 + 会员/购买/赠送/星火分池(各自带到期展示);无重置周期,全部 `aside`(只展示,不参与选号) | 三行:今日积分(参与选号)+ 永久积分(`aside`)+ 新手任务进度(`aside`,8/8 领完时不显示) | `GET /balance` 的 available / reward / daily / topup / monthly 分池展示;上限服务未公布,全部 `aside`(只展示,不参与选号) | `POST /pay/ide_user_ent_usage` 的**积分包**列表,剩余 = Σ(授予 − 已用),另加一行签到状态;服务从不公布账号级上限,全部 `aside` |
| 锁定永久积分 | `options.lockPermanent: true` 时,今日额度用完即拒绝请求(429,宿主把账号 bench 到窗口重置),不再悄悄花永久积分 | — | — |
| 每日签到 | 有:`client-popups/pending` 空表即已领(零写请求),否则对每个可领弹窗 `complete`(每日签到 + 新手礼包一起领);登录成功后顺手领一次 | 有:`POST /points/first-login`(幂等,返回当日额度),并在同一次签到里顺手补领没领完的**新手任务**(8 项 / 10000 分,纯 API 直领、不花模型 token;领完的账号零额外请求) | 没有:每日 300 积分由服务端自动发放、没有端点,因此插件不做签到 | 有:先查 `checkin_credits/status`(已领就不发写请求),再 `checkin_credits/claim`;claim 响应不含积分数,成功后再查一次 status 取实际所得。签到按**设备**每日一次,`9074` 如实报失败、不换设备重试 |
| 每日签到 | 有:`client-popups/pending` 空表即已领(零写请求),否则对每个可领弹窗 `complete`(每日签到 + 新手礼包一起领);登录成功后顺手领一次 | 有:`POST /points/first-login`(幂等,返回当日额度),并在同一次签到里顺手补领没领完的**新手任务**(8 项 / 10000 分,纯 API 直领、不花模型 token;领完的账号零额外请求)与**宠物打工**奖励(每天 0~1000 分,档位按官方客户端的有效对话轮次算,未达档位如实上报) | 没有:每日 300 积分由服务端自动发放、没有端点,因此插件不做签到 | 有:先查 `checkin_credits/status`(已领就不发写请求),再 `checkin_credits/claim`;claim 响应不含积分数,成功后再查一次 status 取实际所得。签到按**设备**每日一次,`9074` 如实报失败、不换设备重试 |
- **新手任务(Loomy)**:宿主没有一次性奖励钩子,也不给插件任何按钮,所以本包把领取
挂在两处:`auth.checkin`(`Check in now` / `magpie accounts checkin` / 每日自动签到
都会顺手领),以及 `auth.usage`——**卡片读到还有未完成就自动领掉**,用户不用点任何
Expand Down Expand Up @@ -421,7 +421,8 @@ bun run verify # 改动完成前必须全绿

- Raccoon 的「桌面端登录奖励」是三千里程碑式的一次性奖励,magpie 没有对应钩子(它既不是每日签到、也不该在后台偷偷代领),所以本包不实现;请到官方客户端领取。
- Loomy 的「新手任务」10000 分随每日签到一起领(宿主的 `auth.checkin` 是唯一写钩子;任务领取是纯 API 直领,服务端不校验前置行为)。已领完的账号不产生额外请求。
- 额度读取默认只读,例外是 Loomy 的新手任务:`auth.usage` 读到还有未完成时会把它们领掉(宿主的卡片不给插件按钮,这是唯一入口;`options.claimTasks: false` 可关掉)。会写远端状态的调用:Loomy 的每日额度领取与新手任务补领(`auth.checkin` + `auth.usage`)、TRAE 的每日签到领取,以及 Raccoon / TRAE 的 token 续期。
- Loomy 的「宠物打工」每天 0~1000 分,档位由服务端按**官方客户端的有效对话轮次**计算:本包只把已达档位的分收下来,未达档位时如实说「未达档位」,不冒充领到。额度卡片上有一行只读进度(`aside`),封顶或活动关闭时不显示。
- 额度读取默认只读,例外是 Loomy 的新手任务:`auth.usage` 读到还有未完成时会把它们领掉(宿主的卡片不给插件按钮,这是唯一入口;`options.claimTasks: false` 可关掉)。会写远端状态的调用:Loomy 的每日额度领取、新手任务补领与宠物打工领奖(`auth.checkin`;新手任务另有 `auth.usage`)、TRAE 的每日签到领取,以及 Raccoon / TRAE 的 token 续期。
- 短信登录依赖厂商自己的验证码链路(Raccoon 的阿里云滑块脚本来自其 CDN),离线或脚本被拦截时只有扫码可用。
- TRAE 走的是逆向的私有接口,不是厂商公开 API;它的签到按**设备**每日一次(换账号不重置),失败时如实告知而不轮换设备重试。
- 三家厂商都还没在 magpie 内跑过真实登录与真实聊天;当前证据是 `plugin add` 级别的加载与形状检查。逐条未验证边界登记在 `docs/vendors.md` §5(Trae 另有 §5 第 11–15 条)。
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,10 @@ Loomy 的当日上限只有签到应答会说,因此签到与 usage 都把它
(`usage.ts` 的 `quotaForNow`);属于已经重置那一天的上限会被丢掉,不带到
新的一天。

Loomy 的宠物打工奖励(`/pet-work`)**只挂在 `auth.checkin`**:档位由服务端按官方
客户端的对话轮次算,本包不产生轮次归属,读一次就写一次只会白烧上游请求;卡片上那
一行是只读的 `aside` 窗口(`petwork.ts`)。

**续期**:只有 Raccoon 有(`auth.refresh` + `refreshLead`)。Loomy 没有续期端点,
到点由宿主标记失效、用户重新登录——不实现假的续期。

Expand Down
72 changes: 72 additions & 0 deletions docs/vendors.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ metadata。适配器在插件加载时注册回退模型、V2 integration 登录
| 今日积分 | `dailyQuota − dailyConsumed`(`/points/records` 只给两个余额,`dailyQuota` 只有 `POST /points/first-login` 给) | 唯一参与路由的窗口:`used = consumed/quota`,重置时间取服务端 `dailyCycleDate`,缺失时用北京时间次日 00:05 |
| 永久积分 | 同上 | `aside: true`:只展示,不参与路由 |
| 新手任务 | `GET /onboarding/tasks` 的 8 个标记 | `aside: true`:只展示,不参与路由;8/8 领完时不显示这一行 |
| 宠物打工 | `GET /pet-work` 的 `status`(今日累计、已发档位、是否封顶) | `aside: true`:只展示,不参与路由;封顶或活动关闭时不显示这一行 |

**`dailyQuota` 只有 `POST /points/first-login` 会说**(2026-10-15 实测:
`/points/records` 只回 `balance` / `dailyBalance` / `availableBalance`,**没有**
Expand Down Expand Up @@ -236,6 +237,77 @@ checkin`、Settings › Daily check-in › Plugins 的自动开关,三者都
- 领取是幂等的(已领完的账号 0 个请求);被拒时停下、记下已领的部分,等读数过期再试。
- `options.claimTasks: false` 关掉它,让 `usage` 回到严格只读。默认开启。

### 宠物每日打工(0~1000 分,2026-10-23 接入)

`GET /pet-work` 读当日快照,`POST /pet-work/rewards` 申请一档。这是 Loomy 唯一
「按天算、要手动领」的积分:每天上限 1000 分,档位由服务端按**该账号当日在官方
客户端的有效对话轮次**计算(`sessionWeight × sessionIndex + depthWeight ×
depthIndex`,权重与基线都由快照的 `config` 下发,2026-10-23 读到 0.4 / 0.6、
index 上限 2 / 2、采样 28 天、最少 7 个活跃日)。

**实测形状**(2026-10-23,真账号只读探测;成功码仍是字符串 `"000000"`):

```json
{ "enabled": true, "businessDate": "2026-10-23",
"config": { "version": "pet-work-v1", "dailyMaxPoints": 1000,
"tiers": [{ "tierId": "tier_1", "score": 60 },
{ "tierId": "tier_2", "score": 100 },
{ "tierId": "tier_3", "score": 150 }] },
"status": { "earnedPointsToday": 1000,
"rewardedTierIds": ["tier_1", "tier_2", "tier_3"],
"dailyRewardCapped": true,
"lastGrant": { "grantBatchId": "pwg_…", "grantedTierIds": ["tier_3"],
"grantedPoints": 500, "rewardedAt": 1792698335 } } }
```

⚠️ **配置版本两处拼写不同**:快照里是 `config.version`,领奖请求体里是
`configVersion`。把快照里的键名原样塞进请求体会被服务端判过期。

**领奖请求**只有三个字段:`{ businessDate, configVersion, reachedTierId }`。
`businessDate` 必须用快照里那个服务端业务日 —— 本机时钟猜的日期会被拒。

**回执**(同一账号当天已封顶时的实测,即「已领」形态):

```json
{ "businessDate": "2026-10-23", "grantBatchId": "", "granted": false,
"duplicate": true, "grantedPoints": 0, "grantedTierIds": [],
"status": { "earnedPointsToday": 1000, "rewardedTierIds": ["tier_1", "tier_2", "tier_3"],
"dailyRewardCapped": true } }
```

**档位怎么推进。** 本包拿不到该账号的轮次统计,只能按「已发档位」推进:一次都没领
就申请 `tier_1`(够不够由服务端判),已发 `tier_1` 就申请 `tier_2`,全发完不再申请。
拿不到档位表时不猜更高档:`tier_1` 已发即视为完成。**一次签到会把今天未领的档位逐档
申请**(用户当天在官方客户端聊够了却一直没按签到,一档都不会漏),某档没达标就停手 ——
档位按分数单调,这一档没到,更高的更不可能到;回执带回的 `grantedTierIds` 会并进
「已领」,所以服务端若一次发多档也不会重复申请。

**为什么只挂在 `auth.checkin`。** 达标由服务端按官方客户端的轮次算,本包是网关、
不产生轮次归属 —— 每次领奖请求对「够不够档」毫无影响,只在用户当天已经攒够时把分
收下来。收益是用户的,动作就落在用户按得下去的地方(`Check in now` / 每日自动签到 /
`magpie accounts checkin`)。**不在 `usage` 里读到就领**:未达档位是常态,每次额度
刷新都发一个注定被拒的 POST 只是白烧上游请求 —— 新手任务能那样做,是因为那 8 项
服务端不校验前置行为、必然领得到。

**卡片上那一行**(`petwork.ts`,`aside: true`,按账号缓存 10 分钟):

```
宠物打工 今日 300/1000 · 已领 1/3 档
宠物打工 今日 0/1000 · 未领取
```

`0 分` 只写「未领取」,**不写「未达档位」**:快照里没有「已达档位」这个字段(只有
`earnedPointsToday` / `rewardedTierIds` / `dailyRewardCapped`),够不够档只有领奖回执
才会说。实测反例(2026-10-24):卡片显示 `0/1000`,紧接着一次签到领到 `+1000`,三档
其实早就够了 —— 把「没领」写成「没到」就是编结论。

封顶(`dailyRewardCapped`)或活动关闭时不显示 —— 与新手任务 8/8 时不显示同一取舍。
不带 `limit` / `amount`,否则卡片会把它画成「池子被花掉多少」的进度条,并让它参与选号。

**未验证边界**:`granted: true` 的发放分支在 2026-10-24 已实测到(三档一次领完,回执
合计 `+1000`);仍未见过的是「部分档位已领、下次再领剩余档」的回执形态,这条由离线
测试覆盖。

### 上下文窗口与图片能力的硬覆盖(2026-10-22 自 JetHub 移植)

| 覆盖 | 模型 | 远端声明 | 真实值 | 证据 |
Expand Down
147 changes: 146 additions & 1 deletion src/vendors/loomy/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import { errorMessage, SignInExpiredError, VendorError } from "../../core/errors.ts"
import { type Fetcher, httpBytes, httpJson } from "../../core/http.ts"
import { asArray, asObject, num, str } from "../../core/json.ts"
import { asArray, asObject, bool, num, str, stringList } from "../../core/json.ts"
import { randomId } from "../../core/util.ts"
import {
LOOMY_ACCOUNT_BASE,
Expand All @@ -25,6 +25,7 @@ import {
LOOMY_OK_CODE,
LOOMY_ONBOARDING_TOTAL,
LOOMY_PATHS,
LOOMY_PETWORK_FIRST_TIER,
LOOMY_REQUEST_TIMEOUT_MS,
LOOMY_SESSION_TTL_SECONDS,
LOOMY_SIGNED_OUT_CODE,
Expand Down Expand Up @@ -606,6 +607,150 @@ export async function claimKnownTasks(
}
}

// ---- 宠物每日打工(pet-work)-------------------------------------------------

/** 快照里的一个档位;`score` 是拿到该档所需的档位分,不是积分额。 */
export interface LoomyPetWorkTier {
id: string
score: number
}

/**
* `GET /pet-work` 的读数(2026-10-23 实测形状):
*
* ```
* { enabled, businessDate,
* config: { version, dailyMaxPoints, tiers: [{ tierId, score }] },
* status: { earnedPointsToday, rewardedTierIds, dailyRewardCapped } }
* ```
*
* ⚠️ 配置版本在快照里叫 `config.version`,领奖请求体里叫 `configVersion`:
* 两处拼写不同是服务端的形状,别顺手统一。
*/
export interface LoomyPetWork {
enabled: boolean
businessDate?: string
configVersion?: string
dailyMax?: number
tiers: LoomyPetWorkTier[]
earnedToday?: number
rewardedTiers: string[]
capped: boolean
/** 还没领到的最低档;undefined 表示今日没有可申请的档位。 */
nextTier?: string
}

/** 档位表:缺 `tierId` 或 `score` 的条目直接丢掉,不猜。 */
function readTiers(value: unknown): LoomyPetWorkTier[] {
const list = asArray(value)
if (list === undefined) return []
const tiers: LoomyPetWorkTier[] = []
for (const item of list) {
const record = asObject(item)
const id = str(record?.tierId)
const score = num(record?.score)
if (id === undefined || score === undefined) continue
tiers.push({ id, score })
}
return tiers
}

/**
* 该申请哪一档:已发档位的下一个。
*
* 本包拿不到该账号的轮次统计,所以只能按「已发档位」推进:一次都没领就申请最低档
* (够不够由服务端判),全领完则不申请。拿不到档位表时不猜更高档 —— `tier_1` 已发
* 就视为完成。
*/
function nextTierOf(tiers: readonly LoomyPetWorkTier[], rewarded: readonly string[]): string | undefined {
const done = new Set(rewarded)
if (tiers.length === 0) return done.has(LOOMY_PETWORK_FIRST_TIER) ? undefined : LOOMY_PETWORK_FIRST_TIER
for (const tier of tiers) {
if (!done.has(tier.id)) return tier.id
}
return undefined
}

function readPetWork(data: Record<string, unknown> | undefined): LoomyPetWork {
const config = asObject(data?.config) ?? {}
const status = asObject(data?.status) ?? {}
const tiers = readTiers(config.tiers)
const rewardedTiers = stringList(status.rewardedTierIds)
const businessDate = str(data?.businessDate)
// 历史形状把配置版本平铺在 data 上,两条都读;都没有才让调用方用兜底常量。
const configVersion = str(config.version) ?? str(data?.configVersion)
const dailyMax = num(config.dailyMaxPoints)
const earnedToday = num(status.earnedPointsToday)
const nextTier = nextTierOf(tiers, rewardedTiers)
return {
enabled: bool(data?.enabled) === true,
tiers,
rewardedTiers,
capped: bool(status.dailyRewardCapped) === true,
...(businessDate === undefined ? {} : { businessDate }),
...(configVersion === undefined ? {} : { configVersion }),
...(dailyMax === undefined ? {} : { dailyMax }),
...(earnedToday === undefined ? {} : { earnedToday }),
...(nextTier === undefined ? {} : { nextTier }),
}
}

/** 宠物打工的当日状态,只读。活动关闭时 `enabled` 为 false,其余字段可能缺。 */
export async function fetchPetWork(fetcher: Fetcher, access: string): Promise<LoomyRead<LoomyPetWork>> {
const result = await productRead<Record<string, unknown>>(fetcher, access, LOOMY_PATHS.petWork)
if (!result.ok) return result
return { ok: true, data: readPetWork(result.data) }
}

/** 领奖回执(2026-10-23 实测「已领」形态:`duplicate: true` + `granted: false`)。 */
export interface LoomyPetWorkGrant {
businessDate?: string
granted: boolean
duplicate: boolean
points?: number
grantedTiers: string[]
earnedToday?: number
capped: boolean
}

function readPetWorkGrant(data: Record<string, unknown> | undefined): LoomyPetWorkGrant {
const status = asObject(data?.status) ?? {}
const businessDate = str(data?.businessDate)
const points = num(data?.grantedPoints)
const earnedToday = num(status.earnedPointsToday)
return {
granted: bool(data?.granted) === true,
duplicate: bool(data?.duplicate) === true,
grantedTiers: stringList(data?.grantedTierIds),
capped: bool(status.dailyRewardCapped) === true,
...(businessDate === undefined ? {} : { businessDate }),
...(points === undefined ? {} : { points }),
...(earnedToday === undefined ? {} : { earnedToday }),
}
}

/**
* 申请一档奖励。请求体只有服务端认的三个字段(`businessDate` / `configVersion` /
* `reachedTierId`);`configVersion` 必须取自快照的 `config.version`,编一个版本号
* 会被服务端判过期。
*/
export async function claimPetWorkReward(
fetcher: Fetcher,
access: string,
input: { businessDate: string; configVersion: string; tierId: string },
): Promise<LoomyRead<LoomyPetWorkGrant>> {
const result = await productRead<Record<string, unknown>>(fetcher, access, LOOMY_PATHS.petWorkRewards, {
method: "POST",
body: JSON.stringify({
businessDate: input.businessDate,
configVersion: input.configVersion,
reachedTierId: input.tierId,
}),
})
if (!result.ok) return result
return { ok: true, data: readPetWorkGrant(result.data) }
}

// ---- WeChat's own pages ------------------------------------------------------

export function wechatAuthUrl(state: string): string {
Expand Down
Loading