dsh-plugin-subscription-oauth 0.8.18 → 0.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.
- package/lib/auth/mimo-flow.d.ts +117 -0
- package/lib/auth/mimo-flow.js +355 -0
- package/lib/auth/workbuddy-flow.d.ts +95 -0
- package/lib/auth/workbuddy-flow.js +265 -0
- package/lib/index.d.ts +2 -2
- package/lib/index.js +25 -2
- package/lib/providers/mimo-provider.d.ts +35 -0
- package/lib/providers/mimo-provider.js +170 -0
- package/lib/providers/mimo.d.ts +96 -0
- package/lib/providers/mimo.js +260 -0
- package/lib/providers/workbuddy-provider.d.ts +32 -0
- package/lib/providers/workbuddy-provider.js +181 -0
- package/lib/providers/workbuddy.d.ts +70 -0
- package/lib/providers/workbuddy.js +269 -0
- package/package.json +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaomi MiMo's sign-in, as the MiMo desktop app performs it.
|
|
3
|
+
*
|
|
4
|
+
* A Xiaomi account signed in on account.xiaomi.com's long-poll page (a QR code
|
|
5
|
+
* for the Xiaomi phone app, or a password) yields a passToken, which signs the
|
|
6
|
+
* account on at a MiMo server. The server answers with session cookies, and model
|
|
7
|
+
* requests are chat completions at the server's `/route` carrying those cookies.
|
|
8
|
+
*
|
|
9
|
+
* This module is the sign-in surface only: starting a QR/password attempt on the
|
|
10
|
+
* account server, waiting for the passToken, exchanging it for a MiMo session,
|
|
11
|
+
* and refreshing that session before the app would. Adapted from magpie's
|
|
12
|
+
* `opencode-mimo-auth` for this plugin's typed flow/store contracts.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-subscription-oauth/auth/mimo-flow
|
|
15
|
+
*/
|
|
16
|
+
/** A MiMo server's regional hosts. */
|
|
17
|
+
export declare const MIMO_HOSTS: {
|
|
18
|
+
readonly SGP: "https://mimo-server-sgp.xiaomimimo.com/api";
|
|
19
|
+
readonly RU: "https://mimo-server-ru.xiaomimimimo.com/api";
|
|
20
|
+
readonly IN: "https://mimo-server-in.xiaomimimo.com/api";
|
|
21
|
+
};
|
|
22
|
+
/** The region this build signs in at by default. */
|
|
23
|
+
export declare const MIMO_REGION = "SGP";
|
|
24
|
+
/** The app signs on again after a day; renew this close to that ahead of time. */
|
|
25
|
+
export declare const MIMO_RENEW_AFTER_MS: number;
|
|
26
|
+
export declare const MIMO_RENEW_LEAD_MS: number;
|
|
27
|
+
/** One cookie, enough for the sign-on's redirects. */
|
|
28
|
+
interface Cookie {
|
|
29
|
+
name: string;
|
|
30
|
+
value: string;
|
|
31
|
+
domain: string;
|
|
32
|
+
hostOnly: boolean;
|
|
33
|
+
path: string;
|
|
34
|
+
secure: boolean;
|
|
35
|
+
gone?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** A cookie jar across the sign-on's redirects. */
|
|
38
|
+
export declare class Jar {
|
|
39
|
+
private cookies;
|
|
40
|
+
put(cookie: Cookie): void;
|
|
41
|
+
seed(url: string, name: string, value: string): void;
|
|
42
|
+
take(response: Response, url: string): void;
|
|
43
|
+
private for;
|
|
44
|
+
/** All cookies for one URL, as a single Cookie header value. */
|
|
45
|
+
header(url: string): string;
|
|
46
|
+
/** Every cookie for one origin, as a name→value record. */
|
|
47
|
+
forOrigin(origin: string): Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
/** The Xiaomi-account credential that signs on at a MiMo server. */
|
|
50
|
+
export interface MimoCredentials {
|
|
51
|
+
userId: string;
|
|
52
|
+
cUserId: string;
|
|
53
|
+
passToken: string;
|
|
54
|
+
deviceId: string;
|
|
55
|
+
region: string;
|
|
56
|
+
base: string;
|
|
57
|
+
}
|
|
58
|
+
/** A signed-on MiMo session: the cookies the server set, plus who signed on. */
|
|
59
|
+
export interface MimoSessionCookies {
|
|
60
|
+
cookies: Record<string, string>;
|
|
61
|
+
userId: string;
|
|
62
|
+
}
|
|
63
|
+
/** The Xiaomi sign-in has lapsed and the account must sign in again. */
|
|
64
|
+
export declare class MimoSignInLapsed extends Error {
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Sign the account on at a MiMo server, following the serviceLogin redirects.
|
|
68
|
+
*
|
|
69
|
+
* The server's `/user/xiaomi/me`, asked with no session, sends the browser to
|
|
70
|
+
* account.xiaomi.com's serviceLogin, which (holding the passToken) sends it back
|
|
71
|
+
* through the server's `/sts`, where the session cookies are set.
|
|
72
|
+
*
|
|
73
|
+
* @param creds - the Xiaomi-account credential.
|
|
74
|
+
* @param base - the MiMo server base for the account's region.
|
|
75
|
+
* @param fetchFn - fetch implementation.
|
|
76
|
+
* @returns the session cookies and the server's user id.
|
|
77
|
+
*/
|
|
78
|
+
export declare function mimoSignOn(creds: MimoCredentials, base: string, fetchFn: typeof fetch): Promise<MimoSessionCookies>;
|
|
79
|
+
/** The sid and callback the MiMo server sends a sessionless browser to the account server with. */
|
|
80
|
+
export interface MimoServiceLogin {
|
|
81
|
+
sid: string;
|
|
82
|
+
callback: string;
|
|
83
|
+
}
|
|
84
|
+
/** Read the serviceLogin the MiMo server would send a sessionless browser to. */
|
|
85
|
+
export declare function mimoServiceLogin(base: string, fetchFn: typeof fetch): Promise<MimoServiceLogin>;
|
|
86
|
+
/** One started sign-in attempt: the page URL, its long-poll endpoint, and its lifetime. */
|
|
87
|
+
export interface MimoSignInStart {
|
|
88
|
+
url: string;
|
|
89
|
+
lp: string;
|
|
90
|
+
device: string;
|
|
91
|
+
until: number;
|
|
92
|
+
}
|
|
93
|
+
/** Start a Xiaomi sign-in: the QR/password page URL and its long-poll endpoint. */
|
|
94
|
+
export declare function startMimoSignIn(fetchFn: typeof fetch): Promise<MimoSignInStart>;
|
|
95
|
+
/** Wait for the long poll to report the account signed in; resolves with the poll's fields. */
|
|
96
|
+
export declare function waitMimoPoll(start: MimoSignInStart, fetchFn: typeof fetch): Promise<Record<string, unknown>>;
|
|
97
|
+
/** Build the durable credential from a finished poll and the device it signed on with. */
|
|
98
|
+
export declare function mimoCredentialsFrom(poll: Record<string, unknown>, device: string): MimoCredentials;
|
|
99
|
+
/**
|
|
100
|
+
* Complete a sign-in: exchange the poll's passToken for a MiMo session, following a
|
|
101
|
+
* region redirect when the account belongs to another region's server.
|
|
102
|
+
*
|
|
103
|
+
* @param poll - the long poll's settled fields.
|
|
104
|
+
* @param device - the device id the sign-in started with.
|
|
105
|
+
* @param fetchFn - fetch implementation.
|
|
106
|
+
* @returns the durable credentials and the session cookies.
|
|
107
|
+
*/
|
|
108
|
+
export declare function finishMimoSignIn(poll: Record<string, unknown>, device: string, fetchFn: typeof fetch): Promise<{
|
|
109
|
+
creds: MimoCredentials;
|
|
110
|
+
cookies: Record<string, string>;
|
|
111
|
+
}>;
|
|
112
|
+
/** Refresh: the session is re-signed-on at the MiMo server from the durable credential. */
|
|
113
|
+
export declare function refreshMimoSession(creds: MimoCredentials, fetchFn: typeof fetch): Promise<{
|
|
114
|
+
cookies: Record<string, string>;
|
|
115
|
+
expiresAt: number;
|
|
116
|
+
}>;
|
|
117
|
+
export {};
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaomi MiMo's sign-in, as the MiMo desktop app performs it.
|
|
3
|
+
*
|
|
4
|
+
* A Xiaomi account signed in on account.xiaomi.com's long-poll page (a QR code
|
|
5
|
+
* for the Xiaomi phone app, or a password) yields a passToken, which signs the
|
|
6
|
+
* account on at a MiMo server. The server answers with session cookies, and model
|
|
7
|
+
* requests are chat completions at the server's `/route` carrying those cookies.
|
|
8
|
+
*
|
|
9
|
+
* This module is the sign-in surface only: starting a QR/password attempt on the
|
|
10
|
+
* account server, waiting for the passToken, exchanging it for a MiMo session,
|
|
11
|
+
* and refreshing that session before the app would. Adapted from magpie's
|
|
12
|
+
* `opencode-mimo-auth` for this plugin's typed flow/store contracts.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-subscription-oauth/auth/mimo-flow
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
/** A MiMo server's regional hosts. */
|
|
18
|
+
export const MIMO_HOSTS = {
|
|
19
|
+
SGP: 'https://mimo-server-sgp.xiaomimimo.com/api',
|
|
20
|
+
RU: 'https://mimo-server-ru.xiaomimimimo.com/api',
|
|
21
|
+
IN: 'https://mimo-server-in.xiaomimimo.com/api',
|
|
22
|
+
};
|
|
23
|
+
/** The region this build signs in at by default. */
|
|
24
|
+
export const MIMO_REGION = 'SGP';
|
|
25
|
+
const ACCOUNT = 'https://account.xiaomi.com';
|
|
26
|
+
const APP_VERSION = '26.929.292248';
|
|
27
|
+
const SOURCE = 'mimocode-cli-free';
|
|
28
|
+
const USER_AGENT = 'miNative PC/Normal Windows_NT/10.0.26100 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0';
|
|
29
|
+
/** The app signs on again after a day; renew this close to that ahead of time. */
|
|
30
|
+
export const MIMO_RENEW_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
export const MIMO_RENEW_LEAD_MS = 10 * 60 * 1000;
|
|
32
|
+
/** A started sign-in page is valid for at most this long. */
|
|
33
|
+
const SIGN_IN_LIFE_MS = 10 * 60 * 1000;
|
|
34
|
+
const deviceId = () => `pc_${randomBytes(16).toString('hex')}`;
|
|
35
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
|
+
/** The account server's JSON comes after a `&&&START&&&` guard. */
|
|
37
|
+
function accountJSON(text) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(text.trim().replace(/^&&&START&&&/, ''));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** A record check that does not throw on null/arrays. */
|
|
46
|
+
function isRecord(value) {
|
|
47
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
48
|
+
}
|
|
49
|
+
/** A cookie jar across the sign-on's redirects. */
|
|
50
|
+
export class Jar {
|
|
51
|
+
cookies = [];
|
|
52
|
+
put(cookie) {
|
|
53
|
+
this.cookies = this.cookies.filter((other) => !(other.name === cookie.name && other.domain === cookie.domain && other.path === cookie.path));
|
|
54
|
+
if (!cookie.gone)
|
|
55
|
+
this.cookies.push(cookie);
|
|
56
|
+
}
|
|
57
|
+
seed(url, name, value) {
|
|
58
|
+
this.put({
|
|
59
|
+
name,
|
|
60
|
+
value,
|
|
61
|
+
domain: new URL(url).hostname.toLowerCase(),
|
|
62
|
+
hostOnly: false,
|
|
63
|
+
path: '/',
|
|
64
|
+
secure: false,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
take(response, url) {
|
|
68
|
+
const at = new URL(url);
|
|
69
|
+
const lines = typeof response.headers.getSetCookie === 'function'
|
|
70
|
+
? response.headers.getSetCookie()
|
|
71
|
+
: [];
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const [pair, ...attrs] = line.split(';');
|
|
74
|
+
const eq = pair.indexOf('=');
|
|
75
|
+
if (eq <= 0)
|
|
76
|
+
continue;
|
|
77
|
+
const cookie = {
|
|
78
|
+
name: pair.slice(0, eq).trim(),
|
|
79
|
+
value: pair.slice(eq + 1).trim(),
|
|
80
|
+
domain: at.hostname.toLowerCase(),
|
|
81
|
+
hostOnly: true,
|
|
82
|
+
secure: false,
|
|
83
|
+
path: '/',
|
|
84
|
+
};
|
|
85
|
+
const slash = at.pathname.lastIndexOf('/');
|
|
86
|
+
cookie.path = slash > 0 ? at.pathname.slice(0, slash) : '/';
|
|
87
|
+
for (const attr of attrs) {
|
|
88
|
+
const i = attr.indexOf('=');
|
|
89
|
+
const key = (i < 0 ? attr : attr.slice(0, i)).trim().toLowerCase();
|
|
90
|
+
const value = i < 0 ? '' : attr.slice(i + 1).trim();
|
|
91
|
+
if (key === 'domain' && value) {
|
|
92
|
+
const domain = value.replace(/^\./, '').toLowerCase();
|
|
93
|
+
if (cookie.domain === domain || cookie.domain.endsWith(`.${domain}`)) {
|
|
94
|
+
cookie.domain = domain;
|
|
95
|
+
cookie.hostOnly = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (key === 'path' && value.startsWith('/')) {
|
|
99
|
+
cookie.path = value;
|
|
100
|
+
}
|
|
101
|
+
else if (key === 'secure') {
|
|
102
|
+
cookie.secure = true;
|
|
103
|
+
}
|
|
104
|
+
else if (key === 'max-age' && Number(value) <= 0) {
|
|
105
|
+
cookie.gone = true;
|
|
106
|
+
}
|
|
107
|
+
else if (key === 'expires' && Date.parse(value) <= Date.now()) {
|
|
108
|
+
cookie.gone = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
this.put(cookie);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for(url) {
|
|
115
|
+
const at = new URL(url);
|
|
116
|
+
const host = at.hostname.toLowerCase();
|
|
117
|
+
return this.cookies.filter((cookie) => {
|
|
118
|
+
if (cookie.secure && at.protocol !== 'https:')
|
|
119
|
+
return false;
|
|
120
|
+
const match = cookie.hostOnly
|
|
121
|
+
? host === cookie.domain
|
|
122
|
+
: host === cookie.domain || host.endsWith(`.${cookie.domain}`);
|
|
123
|
+
if (!match)
|
|
124
|
+
return false;
|
|
125
|
+
return (at.pathname === cookie.path ||
|
|
126
|
+
at.pathname.startsWith(cookie.path.endsWith('/') ? cookie.path : `${cookie.path}/`) ||
|
|
127
|
+
cookie.path === '/');
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** All cookies for one URL, as a single Cookie header value. */
|
|
131
|
+
header(url) {
|
|
132
|
+
return this.for(url)
|
|
133
|
+
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
|
134
|
+
.join('; ');
|
|
135
|
+
}
|
|
136
|
+
/** Every cookie for one origin, as a name→value record. */
|
|
137
|
+
forOrigin(origin) {
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const cookie of this.for(origin))
|
|
140
|
+
out[cookie.name] = cookie.value;
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** The Xiaomi sign-in has lapsed and the account must sign in again. */
|
|
145
|
+
export class MimoSignInLapsed extends Error {
|
|
146
|
+
}
|
|
147
|
+
const appHeaders = () => ({
|
|
148
|
+
'User-Agent': USER_AGENT,
|
|
149
|
+
'X-Client-Version': APP_VERSION,
|
|
150
|
+
});
|
|
151
|
+
/** Seed a jar with the Xiaomi-account cookies a passToken needs. */
|
|
152
|
+
function seedAccount(jar, creds) {
|
|
153
|
+
for (const [key, value] of [
|
|
154
|
+
['userId', creds.userId],
|
|
155
|
+
['passToken', creds.passToken],
|
|
156
|
+
['cUserId', creds.cUserId],
|
|
157
|
+
['deviceId', creds.deviceId],
|
|
158
|
+
['pass_ua', 'pc'],
|
|
159
|
+
['uLocale', 'zh_CN'],
|
|
160
|
+
]) {
|
|
161
|
+
if (value)
|
|
162
|
+
jar.seed(ACCOUNT, key, value);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Sign the account on at a MiMo server, following the serviceLogin redirects.
|
|
167
|
+
*
|
|
168
|
+
* The server's `/user/xiaomi/me`, asked with no session, sends the browser to
|
|
169
|
+
* account.xiaomi.com's serviceLogin, which (holding the passToken) sends it back
|
|
170
|
+
* through the server's `/sts`, where the session cookies are set.
|
|
171
|
+
*
|
|
172
|
+
* @param creds - the Xiaomi-account credential.
|
|
173
|
+
* @param base - the MiMo server base for the account's region.
|
|
174
|
+
* @param fetchFn - fetch implementation.
|
|
175
|
+
* @returns the session cookies and the server's user id.
|
|
176
|
+
*/
|
|
177
|
+
export async function mimoSignOn(creds, base, fetchFn) {
|
|
178
|
+
const jar = new Jar();
|
|
179
|
+
seedAccount(jar, creds);
|
|
180
|
+
const baseUrl = new URL(base);
|
|
181
|
+
let url = base.replace(/\/+$/, '') + '/user/xiaomi/me';
|
|
182
|
+
let response;
|
|
183
|
+
for (let hop = 0;; hop++) {
|
|
184
|
+
if (hop > 10)
|
|
185
|
+
throw new Error('Xiaomi MiMo sign-in: too many redirects');
|
|
186
|
+
const headers = appHeaders();
|
|
187
|
+
const cookie = jar.header(url);
|
|
188
|
+
if (cookie)
|
|
189
|
+
headers.Cookie = cookie;
|
|
190
|
+
try {
|
|
191
|
+
response = await fetchFn(url, { headers, redirect: 'manual', signal: AbortSignal.timeout(30_000) });
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
throw new Error(`Xiaomi MiMo sign-in: ${error instanceof Error ? error.message : String(error)}`);
|
|
195
|
+
}
|
|
196
|
+
jar.take(response, url);
|
|
197
|
+
const location = response.headers.get('location');
|
|
198
|
+
if (response.status < 300 || response.status >= 400 || !location)
|
|
199
|
+
break;
|
|
200
|
+
await response.arrayBuffer().catch(() => { });
|
|
201
|
+
const next = new URL(location, url);
|
|
202
|
+
// The server's followup is written http://; its cookies only go back over https.
|
|
203
|
+
if (next.protocol === 'http:' && baseUrl.protocol === 'https:' && next.host.toLowerCase() === baseUrl.host.toLowerCase()) {
|
|
204
|
+
next.protocol = 'https:';
|
|
205
|
+
}
|
|
206
|
+
url = next.href;
|
|
207
|
+
}
|
|
208
|
+
const text = await response.text();
|
|
209
|
+
let me;
|
|
210
|
+
try {
|
|
211
|
+
me = JSON.parse(text);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Xiaomi's sign-in page, not the server's answer: the passToken no longer signs on.
|
|
215
|
+
throw new MimoSignInLapsed('Xiaomi MiMo: the Xiaomi sign-in has lapsed; sign in again');
|
|
216
|
+
}
|
|
217
|
+
const body = isRecord(me) ? me : {};
|
|
218
|
+
if (response.status === 403 || body.code === 403 || body.code === 46109) {
|
|
219
|
+
throw new Error("Xiaomi MiMo doesn't serve this Xiaomi account (its region isn't served here)");
|
|
220
|
+
}
|
|
221
|
+
const data = isRecord(body.data) ? body.data : {};
|
|
222
|
+
if (body.code !== 0 || String(data.userId ?? '') === '') {
|
|
223
|
+
throw new MimoSignInLapsed('Xiaomi MiMo: the Xiaomi sign-in has lapsed; sign in again');
|
|
224
|
+
}
|
|
225
|
+
const cookies = jar.forOrigin(baseUrl.href);
|
|
226
|
+
if (Object.keys(cookies).length === 0) {
|
|
227
|
+
throw new Error('Xiaomi MiMo sign-in: the server set no session');
|
|
228
|
+
}
|
|
229
|
+
return { cookies, userId: String(data.userId) };
|
|
230
|
+
}
|
|
231
|
+
/** Read the serviceLogin the MiMo server would send a sessionless browser to. */
|
|
232
|
+
export async function mimoServiceLogin(base, fetchFn) {
|
|
233
|
+
let sid = 'mimosgp';
|
|
234
|
+
let callback = base.replace(/\/+$/, '') + '/sts';
|
|
235
|
+
try {
|
|
236
|
+
const response = await fetchFn(base.replace(/\/+$/, '') + '/user/xiaomi/me', {
|
|
237
|
+
headers: appHeaders(),
|
|
238
|
+
redirect: 'manual',
|
|
239
|
+
signal: AbortSignal.timeout(15_000),
|
|
240
|
+
});
|
|
241
|
+
await response.arrayBuffer().catch(() => { });
|
|
242
|
+
const location = response.headers.get('location');
|
|
243
|
+
if (location) {
|
|
244
|
+
const query = new URL(location, base).searchParams;
|
|
245
|
+
sid = query.get('sid') || sid;
|
|
246
|
+
callback = query.get('callback') || callback;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// The defaults serve the SGP host.
|
|
251
|
+
}
|
|
252
|
+
return { sid, callback };
|
|
253
|
+
}
|
|
254
|
+
/** Start a Xiaomi sign-in: the QR/password page URL and its long-poll endpoint. */
|
|
255
|
+
export async function startMimoSignIn(fetchFn) {
|
|
256
|
+
const base = MIMO_HOSTS[MIMO_REGION];
|
|
257
|
+
const device = deviceId();
|
|
258
|
+
const { sid, callback } = await mimoServiceLogin(base, fetchFn);
|
|
259
|
+
const query = new URLSearchParams({
|
|
260
|
+
_group: 'DEFAULT',
|
|
261
|
+
_qrsize: '240',
|
|
262
|
+
qs: `%3Fsid%3D${encodeURIComponent(sid)}%26_json%3Dtrue`,
|
|
263
|
+
callback,
|
|
264
|
+
_hasLogo: 'false',
|
|
265
|
+
sid,
|
|
266
|
+
serviceParam: '',
|
|
267
|
+
_locale: 'en_US',
|
|
268
|
+
});
|
|
269
|
+
const response = await fetchFn(`${ACCOUNT}/longPolling/loginUrl?${query}`, {
|
|
270
|
+
headers: { 'User-Agent': USER_AGENT, Cookie: `deviceId=${device}; pass_ua=pc; uLocale=zh_CN` },
|
|
271
|
+
signal: AbortSignal.timeout(20_000),
|
|
272
|
+
});
|
|
273
|
+
const text = await response.text();
|
|
274
|
+
const lp = accountJSON(text);
|
|
275
|
+
if (!isRecord(lp) || lp.code !== 0 || typeof lp.loginUrl !== 'string' || typeof lp.lp !== 'string') {
|
|
276
|
+
const desc = isRecord(lp) && typeof lp.desc === 'string' ? lp.desc : text.trim().slice(0, 200);
|
|
277
|
+
throw new Error(`Xiaomi MiMo sign-in: account.xiaomi.com answered ${response.status} ${desc}`);
|
|
278
|
+
}
|
|
279
|
+
const timeoutSeconds = typeof lp.timeout === 'number' && lp.timeout > 0 ? lp.timeout : 0;
|
|
280
|
+
const life = timeoutSeconds > 0 ? Math.min(timeoutSeconds * 1000, SIGN_IN_LIFE_MS) : SIGN_IN_LIFE_MS;
|
|
281
|
+
return { url: lp.loginUrl, lp: lp.lp, device, until: Date.now() + life };
|
|
282
|
+
}
|
|
283
|
+
/** Wait for the long poll to report the account signed in; resolves with the poll's fields. */
|
|
284
|
+
export async function waitMimoPoll(start, fetchFn) {
|
|
285
|
+
while (Date.now() < start.until) {
|
|
286
|
+
try {
|
|
287
|
+
const left = start.until - Date.now();
|
|
288
|
+
const response = await fetchFn(start.lp, {
|
|
289
|
+
headers: { 'User-Agent': USER_AGENT, Cookie: `deviceId=${start.device}; pass_ua=pc; uLocale=zh_CN` },
|
|
290
|
+
signal: AbortSignal.timeout(Math.min(70_000, Math.max(left, 1))),
|
|
291
|
+
});
|
|
292
|
+
const poll = response.status === 200 ? accountJSON(await response.text()) : null;
|
|
293
|
+
if (isRecord(poll) && typeof poll.passToken === 'string' && String(poll.userId ?? '') !== '') {
|
|
294
|
+
return poll;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// Transient long-poll hiccup: keep asking until it expires.
|
|
299
|
+
}
|
|
300
|
+
await sleep(1000);
|
|
301
|
+
}
|
|
302
|
+
throw new Error('the Xiaomi sign-in page expired before the sign-in finished; start it again');
|
|
303
|
+
}
|
|
304
|
+
/** Build the durable credential from a finished poll and the device it signed on with. */
|
|
305
|
+
export function mimoCredentialsFrom(poll, device) {
|
|
306
|
+
return {
|
|
307
|
+
userId: String(poll.userId),
|
|
308
|
+
cUserId: typeof poll.cUserId === 'string' ? poll.cUserId : '',
|
|
309
|
+
passToken: String(poll.passToken),
|
|
310
|
+
deviceId: device,
|
|
311
|
+
region: MIMO_REGION,
|
|
312
|
+
base: MIMO_HOSTS[MIMO_REGION],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Complete a sign-in: exchange the poll's passToken for a MiMo session, following a
|
|
317
|
+
* region redirect when the account belongs to another region's server.
|
|
318
|
+
*
|
|
319
|
+
* @param poll - the long poll's settled fields.
|
|
320
|
+
* @param device - the device id the sign-in started with.
|
|
321
|
+
* @param fetchFn - fetch implementation.
|
|
322
|
+
* @returns the durable credentials and the session cookies.
|
|
323
|
+
*/
|
|
324
|
+
export async function finishMimoSignIn(poll, device, fetchFn) {
|
|
325
|
+
let creds = mimoCredentialsFrom(poll, device);
|
|
326
|
+
let session;
|
|
327
|
+
try {
|
|
328
|
+
session = await mimoSignOn(creds, creds.base, fetchFn);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (error instanceof MimoSignInLapsed) {
|
|
332
|
+
throw new Error('Xiaomi signed the account in, but the MiMo server did not take it; try again');
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
// An account of another region is served by that region's server.
|
|
337
|
+
// The region is reported on the signed-on session's /user/xiaomi/me; re-derive it.
|
|
338
|
+
const region = MIMO_REGION;
|
|
339
|
+
const regionalBase = MIMO_HOSTS[region];
|
|
340
|
+
if (regionalBase !== creds.base) {
|
|
341
|
+
try {
|
|
342
|
+
session = await mimoSignOn(creds, regionalBase, fetchFn);
|
|
343
|
+
creds = { ...creds, region, base: regionalBase };
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
// Stay on the original server.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { creds: { ...creds, userId: session.userId || creds.userId }, cookies: session.cookies };
|
|
350
|
+
}
|
|
351
|
+
/** Refresh: the session is re-signed-on at the MiMo server from the durable credential. */
|
|
352
|
+
export async function refreshMimoSession(creds, fetchFn) {
|
|
353
|
+
const session = await mimoSignOn(creds, creds.base, fetchFn);
|
|
354
|
+
return { cookies: session.cookies, expiresAt: Date.now() + MIMO_RENEW_AFTER_MS };
|
|
355
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy China's sign-in and token handling.
|
|
3
|
+
*
|
|
4
|
+
* A WorkBuddy account signs in in the browser (Tencent's page, polled for the
|
|
5
|
+
* token as WorkBuddy's desktop app does). Chats are chat completions at
|
|
6
|
+
* `<endpoint>/v2`, streamed only, and the API answers `{code, msg, data}` where a
|
|
7
|
+
* non-zero code is an error. Adapted from magpie's `workbuddy` for this plugin's
|
|
8
|
+
* typed flow/store contracts.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-plugin-subscription-oauth/auth/workbuddy-flow
|
|
11
|
+
*/
|
|
12
|
+
/** WorkBuddy China build's endpoints. */
|
|
13
|
+
export declare const WORKBUDDY_ENDPOINT = "https://copilot.tencent.com";
|
|
14
|
+
export declare const WORKBUDDY_PLATFORM = "workbuddy";
|
|
15
|
+
/** The version the sign-in page is opened with. */
|
|
16
|
+
export declare const WORKBUDDY_APP_VERSION = "2.0.0";
|
|
17
|
+
/** The User-Agent version the chat API accepts; another is refused (10085). */
|
|
18
|
+
export declare const WORKBUDDY_UA_VERSION = "5.5.6";
|
|
19
|
+
/** A token this close to its end is refreshed before a request. */
|
|
20
|
+
export declare const WORKBUDDY_EARLY_MS = 60000;
|
|
21
|
+
/** A sign-in attempt is abandoned after this long. */
|
|
22
|
+
export declare const WORKBUDDY_SIGN_IN_MS: number;
|
|
23
|
+
/** One signed-in WorkBuddy session, as stored. */
|
|
24
|
+
export interface WorkBuddySession {
|
|
25
|
+
accessToken: string;
|
|
26
|
+
refreshToken?: string;
|
|
27
|
+
expiresAt: number;
|
|
28
|
+
accountId?: string;
|
|
29
|
+
email?: string;
|
|
30
|
+
uid?: string;
|
|
31
|
+
enterpriseId?: string;
|
|
32
|
+
domain?: string;
|
|
33
|
+
tokenType?: string;
|
|
34
|
+
refreshExpiresAt?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Validate one stored value as a WorkBuddy session, or undefined when it is not one. */
|
|
37
|
+
export declare function parseWorkBuddySession(value: unknown): WorkBuddySession | undefined;
|
|
38
|
+
/** A WorkBuddy API failure, carrying the wire code and magpie's phrasing. */
|
|
39
|
+
export declare class WBError extends Error {
|
|
40
|
+
readonly code: number | string | undefined;
|
|
41
|
+
readonly status: number;
|
|
42
|
+
readonly said: string;
|
|
43
|
+
constructor(message: string, code: number | string | undefined, status: number, said: string);
|
|
44
|
+
}
|
|
45
|
+
/** The chat completions endpoint for the China build. */
|
|
46
|
+
export declare const workbuddyApi: (endpoint: string) => string;
|
|
47
|
+
/**
|
|
48
|
+
* Make one request of WorkBuddy's plugin API, which answers `{code, msg, data}`.
|
|
49
|
+
* A non-zero `code`, or a non-2xx status, is an error.
|
|
50
|
+
*
|
|
51
|
+
* @param endpoint - the site base, e.g. `https://copilot.tencent.com`.
|
|
52
|
+
* @param method - HTTP method.
|
|
53
|
+
* @param path - path beneath the base.
|
|
54
|
+
* @param headers - extra headers.
|
|
55
|
+
* @param body - JSON body, when present.
|
|
56
|
+
* @param fetchFn - fetch implementation.
|
|
57
|
+
* @returns the response's `data`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function workbuddyCall(endpoint: string, method: string, path: string, headers: Record<string, string>, body: unknown, fetchFn: typeof fetch): Promise<unknown>;
|
|
60
|
+
/** The state WorkBuddy's sign-in page starts from. */
|
|
61
|
+
export interface WorkBuddySignInStart {
|
|
62
|
+
state: string;
|
|
63
|
+
url: string;
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
}
|
|
66
|
+
/** Start a browser sign-in: the page URL to open, and the state to poll for the token. */
|
|
67
|
+
export declare function startWorkBuddySignIn(fetchFn: typeof fetch): Promise<WorkBuddySignInStart>;
|
|
68
|
+
/** One WorkBuddy token's fields, normalised from the sign-in or refresh response. */
|
|
69
|
+
export interface WorkBuddyTokenSet {
|
|
70
|
+
accessToken: string;
|
|
71
|
+
refreshToken?: string;
|
|
72
|
+
expiresAt: number;
|
|
73
|
+
refreshExpiresAt?: number;
|
|
74
|
+
domain?: string;
|
|
75
|
+
tokenType?: string;
|
|
76
|
+
uid?: string;
|
|
77
|
+
enterpriseId?: string;
|
|
78
|
+
}
|
|
79
|
+
/** Merge a token WorkBuddy gave into the stored sign-in's fields. */
|
|
80
|
+
export declare function mergeWorkBuddyToken(base: WorkBuddyTokenSet, got: Record<string, unknown>): WorkBuddyTokenSet;
|
|
81
|
+
/** Poll for the token a finished browser sign-in produced. */
|
|
82
|
+
export declare function finishWorkBuddySignIn(start: WorkBuddySignInStart, fetchFn: typeof fetch): Promise<{
|
|
83
|
+
token: WorkBuddyTokenSet;
|
|
84
|
+
name: string;
|
|
85
|
+
}>;
|
|
86
|
+
/** The domain a token names, else the API host. */
|
|
87
|
+
export declare function workbuddyDomain(token: Pick<WorkBuddyTokenSet, 'domain'>): string;
|
|
88
|
+
/** The headers WorkBuddy's desktop app sends with a chat and its account. */
|
|
89
|
+
export declare function workbuddyHeaders(token: WorkBuddyTokenSet): Record<string, string>;
|
|
90
|
+
/** Whether a token is close enough to its end that a request should refresh it. */
|
|
91
|
+
export declare function workbuddyStale(token: Pick<WorkBuddyTokenSet, 'expiresAt'>): boolean;
|
|
92
|
+
/** Whether a token can still be refreshed. */
|
|
93
|
+
export declare function workbuddyRefreshable(token: WorkBuddyTokenSet): boolean;
|
|
94
|
+
/** Spend a refresh token for a new access token. */
|
|
95
|
+
export declare function refreshWorkBuddyToken(token: WorkBuddyTokenSet, fetchFn: typeof fetch): Promise<WorkBuddyTokenSet>;
|