dsh-connect-workbuddy 4.9.3 → 4.10.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.
@@ -917,6 +917,19 @@ function clearAtRestKeyCache() {
917
917
  *
918
918
  * @module dsh-connect-workbuddy/upstream
919
919
  */
920
+ /**
921
+ * The write half of each campaign: the endpoint that actually grants the
922
+ * credits.
923
+ *
924
+ * Kept beside the status paths because they must move together: the pair is
925
+ * what the 「套餐与积分」 page itself uses (`xp`/`vp` in its bundle), and a
926
+ * campaign whose status is read from one path can only be claimed through its
927
+ * matching claim path. Note the same absence of a `/v2` prefix.
928
+ */
929
+ const BENEFIT_CLAIM_PATHS = {
930
+ compensation: "/billing/meter/claim-compensation",
931
+ gift: "/billing/meter/claim-gift"
932
+ };
920
933
  const CN_CHAT_BASE = "https://copilot.tencent.com";
921
934
  const CN_BILLING_BASE = "https://www.codebuddy.cn";
922
935
  const GLOBAL_BASE = "https://www.workbuddy.ai";
@@ -1095,6 +1108,23 @@ function classifyUpstreamError(status, body) {
1095
1108
  return "client";
1096
1109
  }
1097
1110
  /**
1111
+ * Read one of the upstream's localized fields, in either shape it ships: a plain
1112
+ * string, or a `{ zh, en }` object.
1113
+ *
1114
+ * One reader for every such field on purpose. `displayMsg` and `displayTips`
1115
+ * arrive in the same two shapes from the same envelope, so parsing them with two
1116
+ * separate hand-rolled branches is how one of them ends up only handling the
1117
+ * object form (which is exactly what happened to `displayTips`: it was never
1118
+ * read at all, issue #78).
1119
+ */
1120
+ function localizedText(value) {
1121
+ if (typeof value === "string") return value;
1122
+ if (typeof value !== "object" || value === null) return void 0;
1123
+ const localized = value;
1124
+ const pick = localized.zh ?? localized.en;
1125
+ return typeof pick === "string" ? pick : void 0;
1126
+ }
1127
+ /**
1098
1128
  * Parse the known-good fields out of an upstream failure body. Non-JSON
1099
1129
  * bodies (HTML error pages, empty strings) yield undefined rather than a guess.
1100
1130
  */
@@ -1107,19 +1137,13 @@ function parseUpstreamErrorDetail(body) {
1107
1137
  }
1108
1138
  if (typeof parsed !== "object" || parsed === null) return void 0;
1109
1139
  const raw = parsed;
1110
- let displayMsg;
1111
- const display = raw.displayMsg;
1112
- if (typeof display === "string") displayMsg = display;
1113
- else if (typeof display === "object" && display !== null) {
1114
- const localized = display;
1115
- const pick = localized.zh ?? localized.en;
1116
- if (typeof pick === "string") displayMsg = pick;
1117
- }
1118
- if (displayMsg === void 0 && typeof raw.msg === "string") displayMsg = raw.msg;
1140
+ const displayMsg = localizedText(raw.displayMsg) ?? (typeof raw.msg === "string" ? raw.msg : void 0);
1141
+ const tips = localizedText(raw.displayTips);
1119
1142
  const detail = {};
1120
1143
  if (typeof raw.code === "number") detail.upstreamCode = raw.code;
1121
1144
  if (typeof raw.requestId === "string") detail.requestId = raw.requestId;
1122
1145
  if (displayMsg !== void 0) detail.displayMsg = displayMsg;
1146
+ if (tips !== void 0) detail.tips = tips;
1123
1147
  return detail;
1124
1148
  }
1125
1149
  /**
@@ -1910,6 +1934,90 @@ var WorkBuddyUpstreamClient = class {
1910
1934
  ...typeof data["claim_button_text"] === "string" && data["claim_button_text"] !== "" ? { claimButtonText: data["claim_button_text"] } : {}
1911
1935
  };
1912
1936
  }
1937
+ /**
1938
+ * Read every promotional benefit this account can still see, WITHOUT claiming
1939
+ * anything.
1940
+ *
1941
+ * The campaign list is closed and small on purpose: the gateway exposes one
1942
+ * status endpoint per campaign, so "discover benefits" is a fixed fan-out, not
1943
+ * a search. A new campaign type means a new `/billing/meter/*-status` endpoint
1944
+ * added here — the plugin never guesses at undocumented ones.
1945
+ *
1946
+ * Every endpoint is read in parallel because they are independent, and a
1947
+ * failure is per-endpoint: one campaign answering 5xx must not blank out the
1948
+ * other. The caller gets only what was actually read.
1949
+ *
1950
+ * Note the paths carry NO `/v2` prefix — these are not the `/v2/billing/meter`
1951
+ * family the credit and check-in calls use. Probing `/v2/...` answers 404, which
1952
+ * is how this distinction was found.
1953
+ */
1954
+ async fetchBenefits(credential) {
1955
+ const read = async (kind, path) => {
1956
+ try {
1957
+ return await this.readBenefitStatus(kind, credential, path);
1958
+ } catch {
1959
+ return;
1960
+ }
1961
+ };
1962
+ const [compensation, gift] = await Promise.all([read("compensation", "/billing/meter/compensation-status"), read("gift", "/billing/meter/check-gift-claimed")]);
1963
+ return [compensation, gift].filter((benefit) => benefit !== void 0);
1964
+ }
1965
+ /** One benefit status endpoint. Throws on anything that is not a clean `code: 0`. */
1966
+ async readBenefitStatus(kind, credential, path) {
1967
+ const response = await fetch(`${billingBase(credential)}${path}`, {
1968
+ method: "POST",
1969
+ headers: billingHeaders(credential),
1970
+ body: "{}",
1971
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
1972
+ });
1973
+ const envelope = await readEnvelope(response);
1974
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1975
+ const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
1976
+ const numberField = (key) => typeof data[key] === "number" ? data[key] : 0;
1977
+ /** The gateway states times as `YYYY-MM-DD HH:MM:SS` in local time. */
1978
+ const timeField = (key) => {
1979
+ const raw = data[key];
1980
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
1981
+ const parsed = Date.parse(raw.replace(" ", "T"));
1982
+ return Number.isNaN(parsed) ? void 0 : parsed;
1983
+ };
1984
+ const endsAtMs = timeField("end_time");
1985
+ const startsAtMs = timeField("start_time");
1986
+ const claimedAtMs = timeField("claimed_at");
1987
+ return {
1988
+ kind,
1989
+ active: data["active"] === true,
1990
+ claimed: data["claimed"] === true,
1991
+ creditNum: numberField("credit_num"),
1992
+ ...endsAtMs === void 0 ? {} : { endsAtMs },
1993
+ ...startsAtMs === void 0 ? {} : { startsAtMs },
1994
+ ...claimedAtMs === void 0 ? {} : { claimedAtMs }
1995
+ };
1996
+ }
1997
+ /**
1998
+ * Claim one benefit campaign on ONE account (issue #79).
1999
+ *
2000
+ * This is a real credit grant on a real account, so it takes no account
2001
+ * argument other than the credential it is handed: the caller decides which
2002
+ * single account, and there is deliberately no batch form of this method. The
2003
+ * browser route is the only caller, and it guards the mutation with the same
2004
+ * POST + loopback checks the check-in route carries.
2005
+ *
2006
+ * The response is not parsed: the documented envelope of these two endpoints
2007
+ * is an empty success, and the caller re-reads the status afterwards. That
2008
+ * re-read, not this answer, is what the user is shown — which is why a claim
2009
+ * that silently did nothing still cannot leave the card saying 待领取.
2010
+ */
2011
+ async claimBenefit(credential, kind) {
2012
+ const response = await fetch(`${billingBase(credential)}${BENEFIT_CLAIM_PATHS[kind]}`, {
2013
+ method: "POST",
2014
+ headers: billingHeaders(credential),
2015
+ body: "{}",
2016
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
2017
+ });
2018
+ const envelope = await readEnvelope(response);
2019
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
2020
+ }
1913
2021
  /** Claim today's check-in reward. The browser route guards this mutation. */
1914
2022
  async claimDailyCheckin(credential) {
1915
2023
  const response = await fetch(`${billingBase(credential)}/v2/billing/meter/daily-checkin`, {
@@ -3966,7 +4074,7 @@ function generationOf(version) {
3966
4074
  //#endregion
3967
4075
  //#region src/version.ts
3968
4076
  /** The npm package version this build was produced from. */
3969
- const WORKBUDDY_CONNECT_VERSION = "4.9.3";
4077
+ const WORKBUDDY_CONNECT_VERSION = "4.10.0";
3970
4078
  //#endregion
3971
4079
  //#region src/host-heartbeat.ts
3972
4080
  /**
package/lib/index.d.ts CHANGED
@@ -706,6 +706,47 @@ interface WorkBuddyCheckinClaim {
706
706
  streakDays: number;
707
707
  isStreakDay: boolean;
708
708
  }
709
+ /**
710
+ * One promotional benefit the account may or may not still be entitled to.
711
+ *
712
+ * Read from the billing gateway's OWN status endpoints — the same two the
713
+ * 「套餐与积分」 page calls before it decides whether to render a claim card
714
+ * (`/billing/meter/compensation-status`, `/billing/meter/check-gift-claimed`).
715
+ * Reading them is what makes the card honest: the plugin shows a benefit only
716
+ * while the gateway itself would still show one.
717
+ *
718
+ * The two flags answer DIFFERENT questions and neither can be derived from the
719
+ * other — this is the single most important fact about this shape, measured on
720
+ * a real account pool:
721
+ *
722
+ * - `active` is about the WINDOW, not about the reader. An account that has
723
+ * already claimed still reports `active: true` (observed: `claimed: true`
724
+ * together with `active: true` on a claimed gift), so `active` alone cannot
725
+ * mean "claimable".
726
+ * - `claimed` is about THIS account, and it survives the window: once the
727
+ * campaign lapses the endpoint keeps answering `claimed: false` with
728
+ * `active: false` (observed on every international account, whose campaign
729
+ * ended months ago).
730
+ *
731
+ * Hence a benefit is claimable exactly when {@link active} AND NOT
732
+ * {@link claimed}. See `benefitOf` in `src/benefits.ts`.
733
+ */
734
+ interface WorkBuddyBenefit {
735
+ /** Which campaign this is; the plugin renders one row per kind. */
736
+ kind: 'compensation' | 'gift';
737
+ /** The campaign is inside its window (empty, see: already claimed ≠ inactive). */
738
+ active: boolean;
739
+ /** This account has already taken it — permanently, even after the window. */
740
+ claimed: boolean;
741
+ /** Credits on offer, as the gateway states them. */
742
+ creditNum: number;
743
+ /** Window end, epoch ms. Absent when the gateway sent none it could parse. */
744
+ endsAtMs?: number;
745
+ /** Window start, epoch ms. Compensation omits it. */
746
+ startsAtMs?: number;
747
+ /** When this account claimed it, epoch ms. Only meaningful with `claimed`. */
748
+ claimedAtMs?: number;
749
+ }
709
750
  /** Token refresh answer; fields the upstream omits stay absent. */
710
751
  interface WorkBuddyRefreshOutcome {
711
752
  accessToken: string;
@@ -754,6 +795,23 @@ interface UpstreamErrorDetail {
754
795
  requestId?: string;
755
796
  /** Best human-facing message: `displayMsg.zh`, falling back to `.en`, then `msg`. */
756
797
  displayMsg?: string;
798
+ /**
799
+ * What the upstream says to DO about it: `displayTips.zh`, falling back to
800
+ * `.en` (issue #78).
801
+ *
802
+ * Kept separate from {@link displayMsg} because the two answer different
803
+ * questions and both are needed: `displayMsg` names what was rejected
804
+ * ("模型无法处理此请求"), while `displayTips` names the REMEDY ("请新建任务、换个
805
+ * 模型或稍后重试"). The generic failure path used to discard it and render the
806
+ * raw body instead, whose first 400 characters are the `code`/`requestId`/
807
+ * `extError` skeleton — so the one actionable sentence was always cut off, and
808
+ * a plain parameter rejection read like an empty account pool.
809
+ *
810
+ * Recorded as data rather than folded into `displayMsg` so a caller that wants
811
+ * to tell the user what to change can do so without the "what went wrong"
812
+ * clause losing its own meaning.
813
+ */
814
+ tips?: string;
757
815
  }
758
816
  /**
759
817
  * Region for a login domain; an empty domain means CN (matching upstream tooling).
@@ -917,6 +975,41 @@ declare class WorkBuddyUpstreamClient {
917
975
  fetchModels(credential: WorkBuddyCredential, signal?: AbortSignal): Promise<readonly WorkBuddyUpstreamModel[]>;
918
976
  /** Query today's check-in status without changing account state. */
919
977
  fetchCheckinStatus(credential: WorkBuddyCredential): Promise<WorkBuddyCheckinStatus>;
978
+ /**
979
+ * Read every promotional benefit this account can still see, WITHOUT claiming
980
+ * anything.
981
+ *
982
+ * The campaign list is closed and small on purpose: the gateway exposes one
983
+ * status endpoint per campaign, so "discover benefits" is a fixed fan-out, not
984
+ * a search. A new campaign type means a new `/billing/meter/*-status` endpoint
985
+ * added here — the plugin never guesses at undocumented ones.
986
+ *
987
+ * Every endpoint is read in parallel because they are independent, and a
988
+ * failure is per-endpoint: one campaign answering 5xx must not blank out the
989
+ * other. The caller gets only what was actually read.
990
+ *
991
+ * Note the paths carry NO `/v2` prefix — these are not the `/v2/billing/meter`
992
+ * family the credit and check-in calls use. Probing `/v2/...` answers 404, which
993
+ * is how this distinction was found.
994
+ */
995
+ fetchBenefits(credential: WorkBuddyCredential): Promise<readonly WorkBuddyBenefit[]>;
996
+ /** One benefit status endpoint. Throws on anything that is not a clean `code: 0`. */
997
+ private readBenefitStatus;
998
+ /**
999
+ * Claim one benefit campaign on ONE account (issue #79).
1000
+ *
1001
+ * This is a real credit grant on a real account, so it takes no account
1002
+ * argument other than the credential it is handed: the caller decides which
1003
+ * single account, and there is deliberately no batch form of this method. The
1004
+ * browser route is the only caller, and it guards the mutation with the same
1005
+ * POST + loopback checks the check-in route carries.
1006
+ *
1007
+ * The response is not parsed: the documented envelope of these two endpoints
1008
+ * is an empty success, and the caller re-reads the status afterwards. That
1009
+ * re-read, not this answer, is what the user is shown — which is why a claim
1010
+ * that silently did nothing still cannot leave the card saying 待领取.
1011
+ */
1012
+ claimBenefit(credential: WorkBuddyCredential, kind: WorkBuddyBenefit['kind']): Promise<void>;
920
1013
  /** Claim today's check-in reward. The browser route guards this mutation. */
921
1014
  claimDailyCheckin(credential: WorkBuddyCredential): Promise<WorkBuddyCheckinClaim>;
922
1015
  /**
@@ -1994,6 +2087,29 @@ declare function probeModel(input: {
1994
2087
  signal?: AbortSignal;
1995
2088
  }): Promise<WorkBuddyProbeResult>;
1996
2089
  //#endregion
2090
+ //#region src/benefits.d.ts
2091
+ /** What the account card shows for one campaign. */
2092
+ type WorkBuddyBenefitState =
2093
+ /** Offered, inside its window, not taken yet. The only state with a claim button. */
2094
+ 'claimable' |
2095
+ /** This account already took it. Rendered as nothing at all. */
2096
+ 'claimed' |
2097
+ /** The window is over and it was never taken — the offer is gone. */
2098
+ 'expired';
2099
+ /** One campaign, resolved for display. */
2100
+ interface WorkBuddyBenefitView {
2101
+ kind: WorkBuddyBenefit['kind'];
2102
+ state: WorkBuddyBenefitState;
2103
+ /** Credits on offer, as the gateway stated them. */
2104
+ creditNum: number;
2105
+ /** Window end, epoch ms. */
2106
+ endsAtMs?: number;
2107
+ /** Whole days until the window closes, floored at 0. Absent without `endsAtMs`. */
2108
+ daysLeft?: number;
2109
+ /** True while the window closes within {@link URGENT_DAYS} days. */
2110
+ urgent: boolean;
2111
+ }
2112
+ //#endregion
1997
2113
  //#region src/account-pool.d.ts
1998
2114
  /**
1999
2115
  * Which of the pool's writers produced a measurement.
@@ -2634,6 +2750,24 @@ type WorkBuddyWebPoolExclusion = 'unusable' | 'rate-limited' |
2634
2750
  * does not say (issue #56).
2635
2751
  */
2636
2752
  'model-busy' | 'out-of-credit' | 'credential-rejected' | 'not-found' | 'unavailable' | 'failed';
2753
+ /** One claimable benefit, as the card renders it (issue #79). */
2754
+ interface WorkBuddyWebBenefit {
2755
+ /** Which campaign: compensation (crash补偿) or gift (新人首月赠送). */
2756
+ kind: 'compensation' | 'gift';
2757
+ /** Display name for the campaign, resolved Host-side so both clients agree. */
2758
+ title: string;
2759
+ /** Credits on offer. */
2760
+ creditNum: number;
2761
+ /**
2762
+ * Whole days until the window closes, floored at 0.
2763
+ *
2764
+ * ABSENT when the gateway stated no end time — the card must then omit the
2765
+ * countdown rather than print a number nobody knows.
2766
+ */
2767
+ daysLeft?: number;
2768
+ /** True when the window closes within 3 days, so the card can say 即将到期. */
2769
+ urgent: boolean;
2770
+ }
2637
2771
  /**
2638
2772
  * One account's row in the pool, as the card renders it.
2639
2773
  *
@@ -2710,6 +2844,16 @@ interface WorkBuddyWebPoolAccount {
2710
2844
  * result the user just watched succeed.
2711
2845
  */
2712
2846
  checkedInToday?: boolean;
2847
+ /**
2848
+ * Promotional benefits this account can STILL claim (issue #79).
2849
+ *
2850
+ * Never carries a claimed or lapsed campaign: the issue's rule is that those
2851
+ * do not appear at all, so their absence here IS the "hide it" instruction.
2852
+ * ABSENT means not read (or nothing claimable), and the card renders no line
2853
+ * either way — a benefit line is only ever added to say "there is something
2854
+ * here for you", never to announce its own absence.
2855
+ */
2856
+ benefits?: readonly WorkBuddyWebBenefit[];
2713
2857
  /**
2714
2858
  * The model a TEST of this row would use (issue #61).
2715
2859
  *
@@ -2867,7 +3011,7 @@ declare const WORKBUDDY_SETTINGS_NS: "workbuddy";
2867
3011
  interface WorkBuddyStatusRouteOptions {
2868
3012
  /** The region-scoped credential store backing each region's requests. */
2869
3013
  store(region: WorkBuddyRegion): WorkBuddyCredentialStore;
2870
- client: Pick<WorkBuddyUpstreamClient, 'fetchCredits' | 'fetchCheckinStatus' | 'claimDailyCheckin'>;
3014
+ client: Pick<WorkBuddyUpstreamClient, 'fetchCredits' | 'fetchCheckinStatus' | 'claimDailyCheckin' | 'fetchBenefits' | 'claimBenefit'>;
2871
3015
  /**
2872
3016
  * The requested region's last-refreshed model directory (unfiltered) for
2873
3017
  * card display. Region-scoped because the CN and international apps expose
@@ -3153,6 +3297,37 @@ interface WorkBuddyPoolDeps {
3153
3297
  * per-account read in the full document.
3154
3298
  */
3155
3299
  cachedCheckedInToday?(region: WorkBuddyRegion): Readonly<Record<string, boolean>>;
3300
+ /**
3301
+ * Claimable promotional benefits per account id (issue #79).
3302
+ *
3303
+ * Only campaigns the account can STILL claim are present; a claimed or lapsed
3304
+ * campaign is absent on purpose, so the card renders nothing for it. Absent
3305
+ * (or a missing entry) also means "not read yet", which the card treats the
3306
+ * same way — no line, rather than a guessed state.
3307
+ */
3308
+ benefits?(region: WorkBuddyRegion): Promise<Readonly<Record<string, readonly WorkBuddyBenefitView[]>>>;
3309
+ /**
3310
+ * Claimable benefits, from the readings already taken this process.
3311
+ *
3312
+ * The first-paint counterpart of {@link benefits}: no request, so painting
3313
+ * never waits on two upstream reads per account.
3314
+ */
3315
+ cachedBenefits?(region: WorkBuddyRegion): Readonly<Record<string, readonly WorkBuddyBenefitView[]>>;
3316
+ /**
3317
+ * Forget this account's cached benefit reading so the next status read is live.
3318
+ *
3319
+ * The claim route writes to upstream directly, so nothing else in the plugin
3320
+ * knows the campaign just changed. Without this the card kept showing 领取 for
3321
+ * up to `BENEFIT_CACHE_MS` (30 min) after a SUCCESSFUL claim: the credits went
3322
+ * up, the pill stayed, and the only remaining explanation was "the claim
3323
+ * silently failed" — which invited a second click the route then refused as
3324
+ * already-claimed. `checkin` has the same rule and invalidates its own cache;
3325
+ * this is the benefit counterpart.
3326
+ *
3327
+ * Absent is legal (a Host from before this hook) and means "the click refreshes
3328
+ * the card, but the host-side cache may still answer stale for a while".
3329
+ */
3330
+ invalidateBenefit?(region: WorkBuddyRegion, accountId: string): void;
3156
3331
  /**
3157
3332
  * Every local sign-in the pool does NOT cover, so the card can offer them to
3158
3333
  * check in. Absent means the card lists only pool members.