dsh-connect-workbuddy 4.9.4 → 4.10.1

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.
@@ -863,21 +863,93 @@ function fetchAtRestKeyPayload(executable) {
863
863
  let cachedKey;
864
864
  let inflightKey;
865
865
  /**
866
+ * When the last FAILED lookup finished, epoch ms; 0 means "no recent failure".
867
+ *
868
+ * Issue #77: only a SUCCESS was remembered, so a machine where the key cannot
869
+ * be obtained re-ran the whole discovery on every single probe. `scanAll()`
870
+ * probes one auth file at a time, and a usage request scans several times, so
871
+ * one page open re-ran the discovery dozens of times — each run being three
872
+ * SYNCHRONOUS `reg query` child processes on the host's main thread when the
873
+ * desktop app is not installed (measured 15.3 s of a 19.1 s request, 135
874
+ * `reg.exe` spawns). A failure is therefore remembered for
875
+ * {@link AT_REST_KEY_FAILURE_TTL_MS}: the answer cannot change within one page
876
+ * open, while a user who installs or launches the app still sees it pick up
877
+ * within that window instead of only after a restart.
878
+ */
879
+ let failedKeyAtMs = 0;
880
+ /** The discovery result behind the current key (or failure); see {@link discoverAppExecutable}. */
881
+ let cachedAppExecutable;
882
+ /**
883
+ * How long a failed key lookup is remembered. Long enough to collapse the
884
+ * dozens of probes one request makes, short enough that installing (or
885
+ * launching) the desktop app takes effect without a restart.
886
+ */
887
+ const AT_REST_KEY_FAILURE_TTL_MS = 6e4;
888
+ /**
889
+ * How long a NEGATIVE executable discovery is remembered.
890
+ *
891
+ * The negative answer is the expensive one: every candidate path misses, then
892
+ * the Windows fallback shells out to `reg query` once per uninstall hive. The
893
+ * positive answer is cached for the process lifetime instead — its path is
894
+ * retried with `existsSync` on every call anyway, so an app that was moved or
895
+ * removed still falls through to a fresh discovery.
896
+ */
897
+ const APP_EXECUTABLE_MISS_TTL_MS = 6e4;
898
+ /**
899
+ * Locate the desktop executable through {@link findWorkbuddyAppExecutable},
900
+ * remembering a MISS for {@link APP_EXECUTABLE_MISS_TTL_MS}.
901
+ *
902
+ * Only this module's own lookup is cached, and only when it goes through the
903
+ * real seams: `findWorkbuddyAppExecutable` stays a pure function of its
904
+ * arguments for `doctor` and for the injected-platform tests, which must keep
905
+ * seeing the machine as it is right now.
906
+ */
907
+ function discoverAppExecutable() {
908
+ const now = Date.now();
909
+ if (cachedAppExecutable !== void 0) {
910
+ if (now - cachedAppExecutable.foundAtMs < APP_EXECUTABLE_MISS_TTL_MS) return cachedAppExecutable.executable;
911
+ cachedAppExecutable = void 0;
912
+ }
913
+ const executable = findWorkbuddyAppExecutable();
914
+ if (executable === void 0) {
915
+ cachedAppExecutable = {
916
+ executable: "",
917
+ foundAtMs: now
918
+ };
919
+ return;
920
+ }
921
+ cachedAppExecutable = {
922
+ executable,
923
+ foundAtMs: Number.POSITIVE_INFINITY
924
+ };
925
+ return executable;
926
+ }
927
+ /**
866
928
  * The desktop app's at-rest field key, or undefined when it cannot be obtained
867
929
  * (app not installed, an older build without the native module, or a future
868
- * build that rotates the payload). Cached after the first success so the app is
869
- * spawned at most once per process; a failure is retried on the next call,
870
- * because the user may install or start the app between reads.
930
+ * build that rotates the payload).
931
+ *
932
+ * Both outcomes are cached: a success for the process lifetime (the app is
933
+ * spawned at most once), a failure for {@link AT_REST_KEY_FAILURE_TTL_MS}. The
934
+ * failure TTL is what keeps a machine WITHOUT the app from paying the
935
+ * synchronous registry probe on every credential file — see {@link failedKeyAtMs}.
871
936
  */
872
937
  function readAtRestKey() {
873
938
  if (cachedKey !== void 0) return Promise.resolve(cachedKey);
874
- inflightKey ??= (async () => {
875
- const executable = findWorkbuddyAppExecutable();
939
+ if (inflightKey !== void 0) return inflightKey;
940
+ if (failedKeyAtMs !== 0 && Date.now() - failedKeyAtMs < AT_REST_KEY_FAILURE_TTL_MS) return Promise.resolve(void 0);
941
+ inflightKey = (async () => {
942
+ const executable = discoverAppExecutable();
876
943
  if (executable === void 0) return void 0;
877
- const key = deriveAtRestKey(await fetchAtRestKeyPayload(executable));
944
+ return deriveAtRestKey(await fetchAtRestKeyPayload(executable));
945
+ })().then((key) => {
878
946
  cachedKey = key;
947
+ failedKeyAtMs = key === void 0 ? Date.now() : 0;
879
948
  return key;
880
- })().finally(() => {
949
+ }, (error) => {
950
+ failedKeyAtMs = Date.now();
951
+ throw error;
952
+ }).finally(() => {
881
953
  inflightKey = void 0;
882
954
  });
883
955
  return inflightKey;
@@ -886,6 +958,8 @@ function readAtRestKey() {
886
958
  function clearAtRestKeyCache() {
887
959
  cachedKey = void 0;
888
960
  inflightKey = void 0;
961
+ failedKeyAtMs = 0;
962
+ cachedAppExecutable = void 0;
889
963
  }
890
964
  //#endregion
891
965
  //#region src/upstream.ts
@@ -917,6 +991,19 @@ function clearAtRestKeyCache() {
917
991
  *
918
992
  * @module dsh-connect-workbuddy/upstream
919
993
  */
994
+ /**
995
+ * The write half of each campaign: the endpoint that actually grants the
996
+ * credits.
997
+ *
998
+ * Kept beside the status paths because they must move together: the pair is
999
+ * what the 「套餐与积分」 page itself uses (`xp`/`vp` in its bundle), and a
1000
+ * campaign whose status is read from one path can only be claimed through its
1001
+ * matching claim path. Note the same absence of a `/v2` prefix.
1002
+ */
1003
+ const BENEFIT_CLAIM_PATHS = {
1004
+ compensation: "/billing/meter/claim-compensation",
1005
+ gift: "/billing/meter/claim-gift"
1006
+ };
920
1007
  const CN_CHAT_BASE = "https://copilot.tencent.com";
921
1008
  const CN_BILLING_BASE = "https://www.codebuddy.cn";
922
1009
  const GLOBAL_BASE = "https://www.workbuddy.ai";
@@ -1921,6 +2008,90 @@ var WorkBuddyUpstreamClient = class {
1921
2008
  ...typeof data["claim_button_text"] === "string" && data["claim_button_text"] !== "" ? { claimButtonText: data["claim_button_text"] } : {}
1922
2009
  };
1923
2010
  }
2011
+ /**
2012
+ * Read every promotional benefit this account can still see, WITHOUT claiming
2013
+ * anything.
2014
+ *
2015
+ * The campaign list is closed and small on purpose: the gateway exposes one
2016
+ * status endpoint per campaign, so "discover benefits" is a fixed fan-out, not
2017
+ * a search. A new campaign type means a new `/billing/meter/*-status` endpoint
2018
+ * added here — the plugin never guesses at undocumented ones.
2019
+ *
2020
+ * Every endpoint is read in parallel because they are independent, and a
2021
+ * failure is per-endpoint: one campaign answering 5xx must not blank out the
2022
+ * other. The caller gets only what was actually read.
2023
+ *
2024
+ * Note the paths carry NO `/v2` prefix — these are not the `/v2/billing/meter`
2025
+ * family the credit and check-in calls use. Probing `/v2/...` answers 404, which
2026
+ * is how this distinction was found.
2027
+ */
2028
+ async fetchBenefits(credential) {
2029
+ const read = async (kind, path) => {
2030
+ try {
2031
+ return await this.readBenefitStatus(kind, credential, path);
2032
+ } catch {
2033
+ return;
2034
+ }
2035
+ };
2036
+ const [compensation, gift] = await Promise.all([read("compensation", "/billing/meter/compensation-status"), read("gift", "/billing/meter/check-gift-claimed")]);
2037
+ return [compensation, gift].filter((benefit) => benefit !== void 0);
2038
+ }
2039
+ /** One benefit status endpoint. Throws on anything that is not a clean `code: 0`. */
2040
+ async readBenefitStatus(kind, credential, path) {
2041
+ const response = await fetch(`${billingBase(credential)}${path}`, {
2042
+ method: "POST",
2043
+ headers: billingHeaders(credential),
2044
+ body: "{}",
2045
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
2046
+ });
2047
+ const envelope = await readEnvelope(response);
2048
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
2049
+ const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
2050
+ const numberField = (key) => typeof data[key] === "number" ? data[key] : 0;
2051
+ /** The gateway states times as `YYYY-MM-DD HH:MM:SS` in local time. */
2052
+ const timeField = (key) => {
2053
+ const raw = data[key];
2054
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
2055
+ const parsed = Date.parse(raw.replace(" ", "T"));
2056
+ return Number.isNaN(parsed) ? void 0 : parsed;
2057
+ };
2058
+ const endsAtMs = timeField("end_time");
2059
+ const startsAtMs = timeField("start_time");
2060
+ const claimedAtMs = timeField("claimed_at");
2061
+ return {
2062
+ kind,
2063
+ active: data["active"] === true,
2064
+ claimed: data["claimed"] === true,
2065
+ creditNum: numberField("credit_num"),
2066
+ ...endsAtMs === void 0 ? {} : { endsAtMs },
2067
+ ...startsAtMs === void 0 ? {} : { startsAtMs },
2068
+ ...claimedAtMs === void 0 ? {} : { claimedAtMs }
2069
+ };
2070
+ }
2071
+ /**
2072
+ * Claim one benefit campaign on ONE account (issue #79).
2073
+ *
2074
+ * This is a real credit grant on a real account, so it takes no account
2075
+ * argument other than the credential it is handed: the caller decides which
2076
+ * single account, and there is deliberately no batch form of this method. The
2077
+ * browser route is the only caller, and it guards the mutation with the same
2078
+ * POST + loopback checks the check-in route carries.
2079
+ *
2080
+ * The response is not parsed: the documented envelope of these two endpoints
2081
+ * is an empty success, and the caller re-reads the status afterwards. That
2082
+ * re-read, not this answer, is what the user is shown — which is why a claim
2083
+ * that silently did nothing still cannot leave the card saying 待领取.
2084
+ */
2085
+ async claimBenefit(credential, kind) {
2086
+ const response = await fetch(`${billingBase(credential)}${BENEFIT_CLAIM_PATHS[kind]}`, {
2087
+ method: "POST",
2088
+ headers: billingHeaders(credential),
2089
+ body: "{}",
2090
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
2091
+ });
2092
+ const envelope = await readEnvelope(response);
2093
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
2094
+ }
1924
2095
  /** Claim today's check-in reward. The browser route guards this mutation. */
1925
2096
  async claimDailyCheckin(credential) {
1926
2097
  const response = await fetch(`${billingBase(credential)}/v2/billing/meter/daily-checkin`, {
@@ -2766,8 +2937,17 @@ var WorkBuddyCredentialStore = class {
2766
2937
  * Undefined means "no scan in flight and nothing cached".
2767
2938
  */
2768
2939
  scanInflight;
2769
- /** When the cached scan was STARTED, epoch ms; only meaningful with it set. */
2770
- scanAtMs = 0;
2940
+ /**
2941
+ * When the cached scan FINISHED, epoch ms; 0 means "not finished yet".
2942
+ *
2943
+ * Deliberately the finish time, not the start time (issue #77). The window
2944
+ * used to be measured from the START, so a scan that took longer than the
2945
+ * window was already outside it the moment it landed: the very next `readAll`
2946
+ * started a fresh scan, and the sharing this cache exists for never happened.
2947
+ * On the machine in that report one scan took 1.4–2.1 s against a 1000 ms
2948
+ * window, so every one of a page open's ~6 reads re-scanned the filesystem.
2949
+ */
2950
+ scanFinishedAtMs = 0;
2771
2951
  /**
2772
2952
  * A RUNTIME account override from the pool's rotation, or undefined.
2773
2953
  *
@@ -2924,28 +3104,38 @@ var WorkBuddyCredentialStore = class {
2924
3104
  /**
2925
3105
  * The account list, sharing one scan across concurrent and repeated reads.
2926
3106
  *
2927
- * See {@link scanInflight}: the whole performance fix lives in these four
2928
- * lines. Every caller keeps its OWN semantics (`current`, `accounts`,
2929
- * `status`, `selectionLost`), because this returns the same array they used
2930
- * to build themselves — only the filesystem work is shared.
3107
+ * See {@link scanInflight}: the whole performance fix lives in these lines.
3108
+ * Every caller keeps its OWN semantics (`current`, `accounts`, `status`,
3109
+ * `selectionLost`), because this returns the same array they used to build
3110
+ * themselves — only the filesystem work is shared.
3111
+ *
3112
+ * The window runs from the FINISH time ({@link scanFinishedAtMs}), so a scan
3113
+ * slower than the window still serves the reads that follow it. A scan in
3114
+ * flight is always joined, however long it has been running.
2931
3115
  *
2932
3116
  * A failed scan is NOT cached: the rejection clears the slot so the next read
2933
3117
  * retries, rather than pinning every later caller to one transient failure.
2934
3118
  */
2935
3119
  async readAll() {
2936
- const now = Date.now();
2937
- if (this.scanInflight !== void 0 && now - this.scanAtMs < SCAN_SHARE_WINDOW_MS) return this.scanInflight;
2938
- this.scanAtMs = now;
2939
- this.scanInflight = this.scanAll().catch((error) => {
2940
- this.scanInflight = void 0;
3120
+ if (this.scanInflight !== void 0) {
3121
+ if (this.scanFinishedAtMs === 0 || Date.now() - this.scanFinishedAtMs < SCAN_SHARE_WINDOW_MS) return this.scanInflight;
3122
+ }
3123
+ this.scanFinishedAtMs = 0;
3124
+ const scan = this.scanAll().then((credentials) => {
3125
+ this.scanFinishedAtMs = Date.now();
3126
+ return credentials;
3127
+ }, (error) => {
3128
+ if (this.scanInflight === scan) this.scanInflight = void 0;
3129
+ this.scanFinishedAtMs = 0;
2941
3130
  throw error;
2942
3131
  });
2943
- return this.scanInflight;
3132
+ this.scanInflight = scan;
3133
+ return scan;
2944
3134
  }
2945
3135
  /** Drop the shared scan so the next read re-reads the filesystem. */
2946
3136
  invalidateScan() {
2947
3137
  this.scanInflight = void 0;
2948
- this.scanAtMs = 0;
3138
+ this.scanFinishedAtMs = 0;
2949
3139
  }
2950
3140
  /** The one real scan: read every candidate file, then merge the vault. */
2951
3141
  async scanAll() {
@@ -3977,7 +4167,7 @@ function generationOf(version) {
3977
4167
  //#endregion
3978
4168
  //#region src/version.ts
3979
4169
  /** The npm package version this build was produced from. */
3980
- const WORKBUDDY_CONNECT_VERSION = "4.9.4";
4170
+ const WORKBUDDY_CONNECT_VERSION = "4.10.1";
3981
4171
  //#endregion
3982
4172
  //#region src/host-heartbeat.ts
3983
4173
  /**
package/lib/index.d.ts CHANGED
@@ -267,8 +267,17 @@ declare class WorkBuddyCredentialStore {
267
267
  * Undefined means "no scan in flight and nothing cached".
268
268
  */
269
269
  private scanInflight;
270
- /** When the cached scan was STARTED, epoch ms; only meaningful with it set. */
271
- private scanAtMs;
270
+ /**
271
+ * When the cached scan FINISHED, epoch ms; 0 means "not finished yet".
272
+ *
273
+ * Deliberately the finish time, not the start time (issue #77). The window
274
+ * used to be measured from the START, so a scan that took longer than the
275
+ * window was already outside it the moment it landed: the very next `readAll`
276
+ * started a fresh scan, and the sharing this cache exists for never happened.
277
+ * On the machine in that report one scan took 1.4–2.1 s against a 1000 ms
278
+ * window, so every one of a page open's ~6 reads re-scanned the filesystem.
279
+ */
280
+ private scanFinishedAtMs;
272
281
  /**
273
282
  * A RUNTIME account override from the pool's rotation, or undefined.
274
283
  *
@@ -356,10 +365,14 @@ declare class WorkBuddyCredentialStore {
356
365
  /**
357
366
  * The account list, sharing one scan across concurrent and repeated reads.
358
367
  *
359
- * See {@link scanInflight}: the whole performance fix lives in these four
360
- * lines. Every caller keeps its OWN semantics (`current`, `accounts`,
361
- * `status`, `selectionLost`), because this returns the same array they used
362
- * to build themselves — only the filesystem work is shared.
368
+ * See {@link scanInflight}: the whole performance fix lives in these lines.
369
+ * Every caller keeps its OWN semantics (`current`, `accounts`, `status`,
370
+ * `selectionLost`), because this returns the same array they used to build
371
+ * themselves — only the filesystem work is shared.
372
+ *
373
+ * The window runs from the FINISH time ({@link scanFinishedAtMs}), so a scan
374
+ * slower than the window still serves the reads that follow it. A scan in
375
+ * flight is always joined, however long it has been running.
363
376
  *
364
377
  * A failed scan is NOT cached: the rejection clears the slot so the next read
365
378
  * retries, rather than pinning every later caller to one transient failure.
@@ -706,6 +719,47 @@ interface WorkBuddyCheckinClaim {
706
719
  streakDays: number;
707
720
  isStreakDay: boolean;
708
721
  }
722
+ /**
723
+ * One promotional benefit the account may or may not still be entitled to.
724
+ *
725
+ * Read from the billing gateway's OWN status endpoints — the same two the
726
+ * 「套餐与积分」 page calls before it decides whether to render a claim card
727
+ * (`/billing/meter/compensation-status`, `/billing/meter/check-gift-claimed`).
728
+ * Reading them is what makes the card honest: the plugin shows a benefit only
729
+ * while the gateway itself would still show one.
730
+ *
731
+ * The two flags answer DIFFERENT questions and neither can be derived from the
732
+ * other — this is the single most important fact about this shape, measured on
733
+ * a real account pool:
734
+ *
735
+ * - `active` is about the WINDOW, not about the reader. An account that has
736
+ * already claimed still reports `active: true` (observed: `claimed: true`
737
+ * together with `active: true` on a claimed gift), so `active` alone cannot
738
+ * mean "claimable".
739
+ * - `claimed` is about THIS account, and it survives the window: once the
740
+ * campaign lapses the endpoint keeps answering `claimed: false` with
741
+ * `active: false` (observed on every international account, whose campaign
742
+ * ended months ago).
743
+ *
744
+ * Hence a benefit is claimable exactly when {@link active} AND NOT
745
+ * {@link claimed}. See `benefitOf` in `src/benefits.ts`.
746
+ */
747
+ interface WorkBuddyBenefit {
748
+ /** Which campaign this is; the plugin renders one row per kind. */
749
+ kind: 'compensation' | 'gift';
750
+ /** The campaign is inside its window (empty, see: already claimed ≠ inactive). */
751
+ active: boolean;
752
+ /** This account has already taken it — permanently, even after the window. */
753
+ claimed: boolean;
754
+ /** Credits on offer, as the gateway states them. */
755
+ creditNum: number;
756
+ /** Window end, epoch ms. Absent when the gateway sent none it could parse. */
757
+ endsAtMs?: number;
758
+ /** Window start, epoch ms. Compensation omits it. */
759
+ startsAtMs?: number;
760
+ /** When this account claimed it, epoch ms. Only meaningful with `claimed`. */
761
+ claimedAtMs?: number;
762
+ }
709
763
  /** Token refresh answer; fields the upstream omits stay absent. */
710
764
  interface WorkBuddyRefreshOutcome {
711
765
  accessToken: string;
@@ -934,6 +988,41 @@ declare class WorkBuddyUpstreamClient {
934
988
  fetchModels(credential: WorkBuddyCredential, signal?: AbortSignal): Promise<readonly WorkBuddyUpstreamModel[]>;
935
989
  /** Query today's check-in status without changing account state. */
936
990
  fetchCheckinStatus(credential: WorkBuddyCredential): Promise<WorkBuddyCheckinStatus>;
991
+ /**
992
+ * Read every promotional benefit this account can still see, WITHOUT claiming
993
+ * anything.
994
+ *
995
+ * The campaign list is closed and small on purpose: the gateway exposes one
996
+ * status endpoint per campaign, so "discover benefits" is a fixed fan-out, not
997
+ * a search. A new campaign type means a new `/billing/meter/*-status` endpoint
998
+ * added here — the plugin never guesses at undocumented ones.
999
+ *
1000
+ * Every endpoint is read in parallel because they are independent, and a
1001
+ * failure is per-endpoint: one campaign answering 5xx must not blank out the
1002
+ * other. The caller gets only what was actually read.
1003
+ *
1004
+ * Note the paths carry NO `/v2` prefix — these are not the `/v2/billing/meter`
1005
+ * family the credit and check-in calls use. Probing `/v2/...` answers 404, which
1006
+ * is how this distinction was found.
1007
+ */
1008
+ fetchBenefits(credential: WorkBuddyCredential): Promise<readonly WorkBuddyBenefit[]>;
1009
+ /** One benefit status endpoint. Throws on anything that is not a clean `code: 0`. */
1010
+ private readBenefitStatus;
1011
+ /**
1012
+ * Claim one benefit campaign on ONE account (issue #79).
1013
+ *
1014
+ * This is a real credit grant on a real account, so it takes no account
1015
+ * argument other than the credential it is handed: the caller decides which
1016
+ * single account, and there is deliberately no batch form of this method. The
1017
+ * browser route is the only caller, and it guards the mutation with the same
1018
+ * POST + loopback checks the check-in route carries.
1019
+ *
1020
+ * The response is not parsed: the documented envelope of these two endpoints
1021
+ * is an empty success, and the caller re-reads the status afterwards. That
1022
+ * re-read, not this answer, is what the user is shown — which is why a claim
1023
+ * that silently did nothing still cannot leave the card saying 待领取.
1024
+ */
1025
+ claimBenefit(credential: WorkBuddyCredential, kind: WorkBuddyBenefit['kind']): Promise<void>;
937
1026
  /** Claim today's check-in reward. The browser route guards this mutation. */
938
1027
  claimDailyCheckin(credential: WorkBuddyCredential): Promise<WorkBuddyCheckinClaim>;
939
1028
  /**
@@ -1638,9 +1727,12 @@ declare function fetchAtRestKeyPayload(executable: string): Promise<string>;
1638
1727
  /**
1639
1728
  * The desktop app's at-rest field key, or undefined when it cannot be obtained
1640
1729
  * (app not installed, an older build without the native module, or a future
1641
- * build that rotates the payload). Cached after the first success so the app is
1642
- * spawned at most once per process; a failure is retried on the next call,
1643
- * because the user may install or start the app between reads.
1730
+ * build that rotates the payload).
1731
+ *
1732
+ * Both outcomes are cached: a success for the process lifetime (the app is
1733
+ * spawned at most once), a failure for {@link AT_REST_KEY_FAILURE_TTL_MS}. The
1734
+ * failure TTL is what keeps a machine WITHOUT the app from paying the
1735
+ * synchronous registry probe on every credential file — see {@link failedKeyAtMs}.
1644
1736
  */
1645
1737
  declare function readAtRestKey(): Promise<Buffer | undefined>;
1646
1738
  /** Drop the cached key; tests and diagnostics only. */
@@ -2011,6 +2103,29 @@ declare function probeModel(input: {
2011
2103
  signal?: AbortSignal;
2012
2104
  }): Promise<WorkBuddyProbeResult>;
2013
2105
  //#endregion
2106
+ //#region src/benefits.d.ts
2107
+ /** What the account card shows for one campaign. */
2108
+ type WorkBuddyBenefitState =
2109
+ /** Offered, inside its window, not taken yet. The only state with a claim button. */
2110
+ 'claimable' |
2111
+ /** This account already took it. Rendered as nothing at all. */
2112
+ 'claimed' |
2113
+ /** The window is over and it was never taken — the offer is gone. */
2114
+ 'expired';
2115
+ /** One campaign, resolved for display. */
2116
+ interface WorkBuddyBenefitView {
2117
+ kind: WorkBuddyBenefit['kind'];
2118
+ state: WorkBuddyBenefitState;
2119
+ /** Credits on offer, as the gateway stated them. */
2120
+ creditNum: number;
2121
+ /** Window end, epoch ms. */
2122
+ endsAtMs?: number;
2123
+ /** Whole days until the window closes, floored at 0. Absent without `endsAtMs`. */
2124
+ daysLeft?: number;
2125
+ /** True while the window closes within {@link URGENT_DAYS} days. */
2126
+ urgent: boolean;
2127
+ }
2128
+ //#endregion
2014
2129
  //#region src/account-pool.d.ts
2015
2130
  /**
2016
2131
  * Which of the pool's writers produced a measurement.
@@ -2651,6 +2766,24 @@ type WorkBuddyWebPoolExclusion = 'unusable' | 'rate-limited' |
2651
2766
  * does not say (issue #56).
2652
2767
  */
2653
2768
  'model-busy' | 'out-of-credit' | 'credential-rejected' | 'not-found' | 'unavailable' | 'failed';
2769
+ /** One claimable benefit, as the card renders it (issue #79). */
2770
+ interface WorkBuddyWebBenefit {
2771
+ /** Which campaign: compensation (crash补偿) or gift (新人首月赠送). */
2772
+ kind: 'compensation' | 'gift';
2773
+ /** Display name for the campaign, resolved Host-side so both clients agree. */
2774
+ title: string;
2775
+ /** Credits on offer. */
2776
+ creditNum: number;
2777
+ /**
2778
+ * Whole days until the window closes, floored at 0.
2779
+ *
2780
+ * ABSENT when the gateway stated no end time — the card must then omit the
2781
+ * countdown rather than print a number nobody knows.
2782
+ */
2783
+ daysLeft?: number;
2784
+ /** True when the window closes within 3 days, so the card can say 即将到期. */
2785
+ urgent: boolean;
2786
+ }
2654
2787
  /**
2655
2788
  * One account's row in the pool, as the card renders it.
2656
2789
  *
@@ -2727,6 +2860,16 @@ interface WorkBuddyWebPoolAccount {
2727
2860
  * result the user just watched succeed.
2728
2861
  */
2729
2862
  checkedInToday?: boolean;
2863
+ /**
2864
+ * Promotional benefits this account can STILL claim (issue #79).
2865
+ *
2866
+ * Never carries a claimed or lapsed campaign: the issue's rule is that those
2867
+ * do not appear at all, so their absence here IS the "hide it" instruction.
2868
+ * ABSENT means not read (or nothing claimable), and the card renders no line
2869
+ * either way — a benefit line is only ever added to say "there is something
2870
+ * here for you", never to announce its own absence.
2871
+ */
2872
+ benefits?: readonly WorkBuddyWebBenefit[];
2730
2873
  /**
2731
2874
  * The model a TEST of this row would use (issue #61).
2732
2875
  *
@@ -2884,7 +3027,7 @@ declare const WORKBUDDY_SETTINGS_NS: "workbuddy";
2884
3027
  interface WorkBuddyStatusRouteOptions {
2885
3028
  /** The region-scoped credential store backing each region's requests. */
2886
3029
  store(region: WorkBuddyRegion): WorkBuddyCredentialStore;
2887
- client: Pick<WorkBuddyUpstreamClient, 'fetchCredits' | 'fetchCheckinStatus' | 'claimDailyCheckin'>;
3030
+ client: Pick<WorkBuddyUpstreamClient, 'fetchCredits' | 'fetchCheckinStatus' | 'claimDailyCheckin' | 'fetchBenefits' | 'claimBenefit'>;
2888
3031
  /**
2889
3032
  * The requested region's last-refreshed model directory (unfiltered) for
2890
3033
  * card display. Region-scoped because the CN and international apps expose
@@ -3170,6 +3313,37 @@ interface WorkBuddyPoolDeps {
3170
3313
  * per-account read in the full document.
3171
3314
  */
3172
3315
  cachedCheckedInToday?(region: WorkBuddyRegion): Readonly<Record<string, boolean>>;
3316
+ /**
3317
+ * Claimable promotional benefits per account id (issue #79).
3318
+ *
3319
+ * Only campaigns the account can STILL claim are present; a claimed or lapsed
3320
+ * campaign is absent on purpose, so the card renders nothing for it. Absent
3321
+ * (or a missing entry) also means "not read yet", which the card treats the
3322
+ * same way — no line, rather than a guessed state.
3323
+ */
3324
+ benefits?(region: WorkBuddyRegion): Promise<Readonly<Record<string, readonly WorkBuddyBenefitView[]>>>;
3325
+ /**
3326
+ * Claimable benefits, from the readings already taken this process.
3327
+ *
3328
+ * The first-paint counterpart of {@link benefits}: no request, so painting
3329
+ * never waits on two upstream reads per account.
3330
+ */
3331
+ cachedBenefits?(region: WorkBuddyRegion): Readonly<Record<string, readonly WorkBuddyBenefitView[]>>;
3332
+ /**
3333
+ * Forget this account's cached benefit reading so the next status read is live.
3334
+ *
3335
+ * The claim route writes to upstream directly, so nothing else in the plugin
3336
+ * knows the campaign just changed. Without this the card kept showing 领取 for
3337
+ * up to `BENEFIT_CACHE_MS` (30 min) after a SUCCESSFUL claim: the credits went
3338
+ * up, the pill stayed, and the only remaining explanation was "the claim
3339
+ * silently failed" — which invited a second click the route then refused as
3340
+ * already-claimed. `checkin` has the same rule and invalidates its own cache;
3341
+ * this is the benefit counterpart.
3342
+ *
3343
+ * Absent is legal (a Host from before this hook) and means "the click refreshes
3344
+ * the card, but the host-side cache may still answer stale for a while".
3345
+ */
3346
+ invalidateBenefit?(region: WorkBuddyRegion, accountId: string): void;
3173
3347
  /**
3174
3348
  * Every local sign-in the pool does NOT cover, so the card can offer them to
3175
3349
  * check in. Absent means the card lists only pool members.