@elvatis_com/openclaw-cli-bridge-elvatis 0.2.23 → 0.2.26
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/.ai/handoff/DASHBOARD.md +32 -19
- package/.ai/handoff/LOG.md +111 -38
- package/.ai/handoff/MANIFEST.json +49 -126
- package/.ai/handoff/NEXT_ACTIONS.md +21 -22
- package/.ai/handoff/STATUS.md +76 -48
- package/.ai/handoff/TRUST.md +40 -51
- package/README.md +19 -1
- package/SKILL.md +1 -1
- package/index.ts +274 -10
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -2
- package/src/claude-auth.ts +40 -16
- package/src/grok-client.ts +428 -0
- package/src/grok-session.ts +195 -0
- package/src/proxy-server.ts +74 -4
- package/test/grok-proxy.test.ts +301 -0
- package/test/grok-session.test.ts +133 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grok-client.ts
|
|
3
|
+
*
|
|
4
|
+
* HTTP client that sends chat completion requests to grok.com's internal REST API
|
|
5
|
+
* using an authenticated browser session (cookies).
|
|
6
|
+
*
|
|
7
|
+
* Endpoint: POST https://grok.com/rest/app-chat/conversations/new
|
|
8
|
+
* Response: Server-Sent Events (SSE) stream
|
|
9
|
+
*
|
|
10
|
+
* This mimics what the grok.com web UI does internally.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { BrowserContext } from "playwright";
|
|
14
|
+
|
|
15
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
// Types
|
|
17
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface ChatMessage {
|
|
20
|
+
role: "system" | "user" | "assistant";
|
|
21
|
+
content: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GrokCompleteOptions {
|
|
25
|
+
messages: ChatMessage[];
|
|
26
|
+
model?: string; // "grok-3" | "grok-3-fast" | "grok-3-mini" | "grok-3-mini-fast"
|
|
27
|
+
stream?: boolean;
|
|
28
|
+
maxTokens?: number;
|
|
29
|
+
temperature?: number;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GrokCompleteResult {
|
|
34
|
+
content: string;
|
|
35
|
+
model: string;
|
|
36
|
+
finishReason: string;
|
|
37
|
+
/** estimated — grok.com doesn't expose exact token counts */
|
|
38
|
+
promptTokens?: number;
|
|
39
|
+
completionTokens?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** SSE token event from grok.com */
|
|
43
|
+
interface GrokTokenEvent {
|
|
44
|
+
result?: {
|
|
45
|
+
response?: {
|
|
46
|
+
token?: string;
|
|
47
|
+
finalMetadata?: {
|
|
48
|
+
inputTokenCount?: number;
|
|
49
|
+
outputTokenCount?: number;
|
|
50
|
+
};
|
|
51
|
+
modelResponse?: {
|
|
52
|
+
responseId?: string;
|
|
53
|
+
message?: string;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
isSoftStop?: boolean;
|
|
57
|
+
};
|
|
58
|
+
error?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// Model ID mapping: OpenAI-style → grok.com internal IDs
|
|
63
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const MODEL_MAP: Record<string, string> = {
|
|
66
|
+
"grok-3": "grok-3",
|
|
67
|
+
"grok-3-fast": "grok-3-fast",
|
|
68
|
+
"grok-3-mini": "grok-3-mini",
|
|
69
|
+
"grok-3-mini-fast": "grok-3-mini-fast",
|
|
70
|
+
// aliases
|
|
71
|
+
"grok": "grok-3",
|
|
72
|
+
"grok-fast": "grok-3-fast",
|
|
73
|
+
"grok-mini": "grok-3-mini",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function resolveModel(model?: string): string {
|
|
77
|
+
if (!model) return "grok-3";
|
|
78
|
+
return MODEL_MAP[model] ?? model;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
// Request builder
|
|
83
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/** Build the request body for grok.com's internal API */
|
|
86
|
+
function buildRequestBody(opts: GrokCompleteOptions): Record<string, unknown> {
|
|
87
|
+
const model = resolveModel(opts.model);
|
|
88
|
+
|
|
89
|
+
// Combine messages into a single user prompt (grok.com web doesn't expose multi-turn directly)
|
|
90
|
+
// System prompt → prepended to first user message
|
|
91
|
+
const systemMsgs = opts.messages.filter((m) => m.role === "system");
|
|
92
|
+
const convMsgs = opts.messages.filter((m) => m.role !== "system");
|
|
93
|
+
|
|
94
|
+
let userPrompt = "";
|
|
95
|
+
if (systemMsgs.length > 0) {
|
|
96
|
+
userPrompt = systemMsgs.map((m) => m.content).join("\n") + "\n\n";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Build conversation history for multi-turn
|
|
100
|
+
const history: Array<{ role: string; content: string }> = [];
|
|
101
|
+
for (let i = 0; i < convMsgs.length - 1; i++) {
|
|
102
|
+
history.push({ role: convMsgs[i].role, content: convMsgs[i].content });
|
|
103
|
+
}
|
|
104
|
+
const lastMsg = convMsgs[convMsgs.length - 1];
|
|
105
|
+
userPrompt += lastMsg?.content ?? "";
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
temporary: false,
|
|
109
|
+
modelName: model,
|
|
110
|
+
message: userPrompt,
|
|
111
|
+
fileAttachments: [],
|
|
112
|
+
imageAttachments: [],
|
|
113
|
+
disableSearch: false,
|
|
114
|
+
enableImageGeneration: false,
|
|
115
|
+
returnImageBytes: false,
|
|
116
|
+
returnRawGrokInXaiRequest: false,
|
|
117
|
+
enableSideBySide: false,
|
|
118
|
+
isReasoning: model.includes("mini"), // mini models support reasoning
|
|
119
|
+
conversationHistory: history,
|
|
120
|
+
toolOverrides: {},
|
|
121
|
+
enableCustomization: false,
|
|
122
|
+
deepsearchPreset: "",
|
|
123
|
+
isPreset: false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
// SSE parser
|
|
129
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function parseSSELine(line: string): GrokTokenEvent | null {
|
|
132
|
+
if (!line.startsWith("data: ")) return null;
|
|
133
|
+
const data = line.slice(6).trim();
|
|
134
|
+
if (data === "[DONE]") return null;
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(data) as GrokTokenEvent;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
// Main client function
|
|
144
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
const GROK_API_URL = "https://grok.com/rest/app-chat/conversations/new";
|
|
147
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Complete a chat via grok.com's internal API using a browser session context.
|
|
151
|
+
* Uses page.evaluate to make the fetch from inside the authenticated browser context.
|
|
152
|
+
*/
|
|
153
|
+
export async function grokComplete(
|
|
154
|
+
context: BrowserContext,
|
|
155
|
+
opts: GrokCompleteOptions,
|
|
156
|
+
log: (msg: string) => void
|
|
157
|
+
): Promise<GrokCompleteResult> {
|
|
158
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
159
|
+
const model = resolveModel(opts.model);
|
|
160
|
+
const body = buildRequestBody(opts);
|
|
161
|
+
|
|
162
|
+
log(`grok-client: POST ${GROK_API_URL} model=${model}`);
|
|
163
|
+
|
|
164
|
+
// Open a background page in the authenticated context
|
|
165
|
+
const page = await context.newPage();
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Navigate to grok.com first to ensure cookies are sent correctly
|
|
169
|
+
await page.goto("https://grok.com", {
|
|
170
|
+
waitUntil: "domcontentloaded",
|
|
171
|
+
timeout: 15_000,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Make the API call from within the page (inherits cookies automatically)
|
|
175
|
+
const result = await page.evaluate(
|
|
176
|
+
async ({ url, requestBody, timeout }: { url: string; requestBody: unknown; timeout: number }) => {
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const resp = await fetch(url, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: {
|
|
184
|
+
"Content-Type": "application/json",
|
|
185
|
+
Accept: "text/event-stream",
|
|
186
|
+
},
|
|
187
|
+
body: JSON.stringify(requestBody),
|
|
188
|
+
credentials: "include",
|
|
189
|
+
signal: controller.signal,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (!resp.ok) {
|
|
193
|
+
const errText = await resp.text().catch(() => "");
|
|
194
|
+
return {
|
|
195
|
+
error: `HTTP ${resp.status}: ${errText.substring(0, 300)}`,
|
|
196
|
+
content: "",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const reader = resp.body!.getReader();
|
|
201
|
+
const decoder = new TextDecoder();
|
|
202
|
+
let fullText = "";
|
|
203
|
+
let buffer = "";
|
|
204
|
+
let inputTokens = 0;
|
|
205
|
+
let outputTokens = 0;
|
|
206
|
+
let finishReason = "stop";
|
|
207
|
+
|
|
208
|
+
while (true) {
|
|
209
|
+
const { done, value } = await reader.read();
|
|
210
|
+
if (done) break;
|
|
211
|
+
|
|
212
|
+
buffer += decoder.decode(value, { stream: true });
|
|
213
|
+
const lines = buffer.split("\n");
|
|
214
|
+
buffer = lines.pop() ?? "";
|
|
215
|
+
|
|
216
|
+
for (const line of lines) {
|
|
217
|
+
if (!line.startsWith("data: ")) continue;
|
|
218
|
+
const data = line.slice(6).trim();
|
|
219
|
+
if (data === "[DONE]") {
|
|
220
|
+
finishReason = "stop";
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const evt = JSON.parse(data);
|
|
225
|
+
const response = evt?.result?.response;
|
|
226
|
+
if (response?.token) {
|
|
227
|
+
fullText += response.token;
|
|
228
|
+
}
|
|
229
|
+
if (response?.finalMetadata) {
|
|
230
|
+
inputTokens = response.finalMetadata.inputTokenCount ?? 0;
|
|
231
|
+
outputTokens = response.finalMetadata.outputTokenCount ?? 0;
|
|
232
|
+
}
|
|
233
|
+
if (evt?.result?.isSoftStop) {
|
|
234
|
+
finishReason = "stop";
|
|
235
|
+
}
|
|
236
|
+
if (evt?.error) {
|
|
237
|
+
return { error: String(evt.error), content: fullText };
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// ignore parse errors on individual SSE lines
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
content: fullText,
|
|
247
|
+
inputTokens,
|
|
248
|
+
outputTokens,
|
|
249
|
+
finishReason,
|
|
250
|
+
};
|
|
251
|
+
} finally {
|
|
252
|
+
clearTimeout(timer);
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
{ url: GROK_API_URL, requestBody: body, timeout: timeoutMs }
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
if ("error" in result && result.error) {
|
|
259
|
+
throw new Error(`grok.com API error: ${result.error}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
log(
|
|
263
|
+
`grok-client: done — ${result.outputTokens ?? "?"} output tokens`
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
content: result.content ?? "",
|
|
268
|
+
model,
|
|
269
|
+
finishReason: result.finishReason ?? "stop",
|
|
270
|
+
promptTokens: result.inputTokens,
|
|
271
|
+
completionTokens: result.outputTokens,
|
|
272
|
+
};
|
|
273
|
+
} finally {
|
|
274
|
+
await page.close();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
279
|
+
// Streaming variant — yields tokens via callback
|
|
280
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
export async function grokCompleteStream(
|
|
283
|
+
context: BrowserContext,
|
|
284
|
+
opts: GrokCompleteOptions,
|
|
285
|
+
onToken: (token: string) => void,
|
|
286
|
+
log: (msg: string) => void
|
|
287
|
+
): Promise<GrokCompleteResult> {
|
|
288
|
+
// grok.com streams via SSE; we accumulate on the JS side and call onToken per chunk.
|
|
289
|
+
// Because page.evaluate can't stream back to Node, we use a polling approach:
|
|
290
|
+
// write tokens to window.__grokTokenBuf, poll from Node side.
|
|
291
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
292
|
+
const model = resolveModel(opts.model);
|
|
293
|
+
const body = buildRequestBody(opts);
|
|
294
|
+
|
|
295
|
+
log(`grok-client: streaming POST ${GROK_API_URL} model=${model}`);
|
|
296
|
+
|
|
297
|
+
const page = await context.newPage();
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
await page.goto("https://grok.com", {
|
|
301
|
+
waitUntil: "domcontentloaded",
|
|
302
|
+
timeout: 15_000,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Initialize token buffer on the page
|
|
306
|
+
await page.evaluate(() => {
|
|
307
|
+
(window as unknown as Record<string, unknown>).__grokTokenBuf = [];
|
|
308
|
+
(window as unknown as Record<string, unknown>).__grokDone = false;
|
|
309
|
+
(window as unknown as Record<string, unknown>).__grokError = null;
|
|
310
|
+
(window as unknown as Record<string, unknown>).__grokMeta = null;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Start the fetch in the page (non-blocking — we poll from Node)
|
|
314
|
+
await page.evaluate(
|
|
315
|
+
async ({ url, requestBody, timeout }: { url: string; requestBody: unknown; timeout: number }) => {
|
|
316
|
+
const w = window as unknown as Record<string, unknown>;
|
|
317
|
+
const controller = new AbortController();
|
|
318
|
+
setTimeout(() => controller.abort(), timeout);
|
|
319
|
+
|
|
320
|
+
(async () => {
|
|
321
|
+
try {
|
|
322
|
+
const resp = await fetch(url, {
|
|
323
|
+
method: "POST",
|
|
324
|
+
headers: {
|
|
325
|
+
"Content-Type": "application/json",
|
|
326
|
+
Accept: "text/event-stream",
|
|
327
|
+
},
|
|
328
|
+
body: JSON.stringify(requestBody),
|
|
329
|
+
credentials: "include",
|
|
330
|
+
signal: controller.signal,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
if (!resp.ok) {
|
|
334
|
+
const errText = await resp.text().catch(() => "");
|
|
335
|
+
w.__grokError = `HTTP ${resp.status}: ${errText.substring(0, 300)}`;
|
|
336
|
+
w.__grokDone = true;
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const reader = resp.body!.getReader();
|
|
341
|
+
const decoder = new TextDecoder();
|
|
342
|
+
let buffer = "";
|
|
343
|
+
|
|
344
|
+
while (true) {
|
|
345
|
+
const { done, value } = await reader.read();
|
|
346
|
+
if (done) break;
|
|
347
|
+
buffer += decoder.decode(value, { stream: true });
|
|
348
|
+
const lines = buffer.split("\n");
|
|
349
|
+
buffer = lines.pop() ?? "";
|
|
350
|
+
|
|
351
|
+
for (const line of lines) {
|
|
352
|
+
if (!line.startsWith("data: ")) continue;
|
|
353
|
+
const data = line.slice(6).trim();
|
|
354
|
+
if (data === "[DONE]") continue;
|
|
355
|
+
try {
|
|
356
|
+
const evt = JSON.parse(data);
|
|
357
|
+
const response = evt?.result?.response;
|
|
358
|
+
if (response?.token) {
|
|
359
|
+
(w.__grokTokenBuf as string[]).push(response.token);
|
|
360
|
+
}
|
|
361
|
+
if (response?.finalMetadata) {
|
|
362
|
+
w.__grokMeta = response.finalMetadata;
|
|
363
|
+
}
|
|
364
|
+
if (evt?.error) {
|
|
365
|
+
w.__grokError = String(evt.error);
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
// ignore
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch (e: unknown) {
|
|
373
|
+
w.__grokError = String(e);
|
|
374
|
+
} finally {
|
|
375
|
+
w.__grokDone = true;
|
|
376
|
+
}
|
|
377
|
+
})();
|
|
378
|
+
},
|
|
379
|
+
{ url: GROK_API_URL, requestBody: body, timeout: timeoutMs }
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Poll the token buffer from Node side
|
|
383
|
+
let fullContent = "";
|
|
384
|
+
const pollInterval = 100; // ms
|
|
385
|
+
const deadline = Date.now() + timeoutMs;
|
|
386
|
+
|
|
387
|
+
while (Date.now() < deadline) {
|
|
388
|
+
const state = await page.evaluate(() => {
|
|
389
|
+
const w = window as unknown as Record<string, unknown>;
|
|
390
|
+
const tokens = (w.__grokTokenBuf as string[]).splice(0);
|
|
391
|
+
return {
|
|
392
|
+
tokens,
|
|
393
|
+
done: w.__grokDone as boolean,
|
|
394
|
+
error: w.__grokError as string | null,
|
|
395
|
+
meta: w.__grokMeta as { inputTokenCount?: number; outputTokenCount?: number } | null,
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
for (const token of state.tokens) {
|
|
400
|
+
onToken(token);
|
|
401
|
+
fullContent += token;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (state.error) {
|
|
405
|
+
throw new Error(`grok.com stream error: ${state.error}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (state.done) {
|
|
409
|
+
log(
|
|
410
|
+
`grok-client: stream done — ${state.meta?.outputTokenCount ?? "?"} tokens`
|
|
411
|
+
);
|
|
412
|
+
return {
|
|
413
|
+
content: fullContent,
|
|
414
|
+
model,
|
|
415
|
+
finishReason: "stop",
|
|
416
|
+
promptTokens: state.meta?.inputTokenCount,
|
|
417
|
+
completionTokens: state.meta?.outputTokenCount,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
throw new Error(`grok.com stream timeout after ${timeoutMs}ms`);
|
|
425
|
+
} finally {
|
|
426
|
+
await page.close();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grok-session.ts
|
|
3
|
+
*
|
|
4
|
+
* Manages a persistent grok.com browser session using Playwright.
|
|
5
|
+
*
|
|
6
|
+
* Auth flow:
|
|
7
|
+
* 1. First run: open Chromium, navigate to grok.com → user logs in manually via X.com OAuth
|
|
8
|
+
* 2. On success: save cookies + localStorage to SESSION_PATH
|
|
9
|
+
* 3. Subsequent runs: restore session from file, verify still valid
|
|
10
|
+
* 4. If session expired: repeat step 1
|
|
11
|
+
*
|
|
12
|
+
* The saved session file is stored at ~/.openclaw/grok-session.json
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import type { Browser, BrowserContext, Cookie } from "playwright";
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_SESSION_PATH = join(
|
|
21
|
+
homedir(),
|
|
22
|
+
".openclaw",
|
|
23
|
+
"grok-session.json"
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export const GROK_HOME = "https://grok.com";
|
|
27
|
+
export const GROK_API_BASE = "https://grok.com/api";
|
|
28
|
+
|
|
29
|
+
/** Stored session data */
|
|
30
|
+
export interface GrokSession {
|
|
31
|
+
cookies: Cookie[];
|
|
32
|
+
savedAt: number; // epoch ms
|
|
33
|
+
userAgent?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Result of a session check */
|
|
37
|
+
export interface SessionCheckResult {
|
|
38
|
+
valid: boolean;
|
|
39
|
+
reason?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// Persistence helpers
|
|
44
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function loadSession(sessionPath: string): GrokSession | null {
|
|
47
|
+
if (!existsSync(sessionPath)) return null;
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(sessionPath, "utf-8");
|
|
50
|
+
const parsed = JSON.parse(raw) as GrokSession;
|
|
51
|
+
if (!parsed.cookies || !Array.isArray(parsed.cookies)) return null;
|
|
52
|
+
return parsed;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function saveSession(
|
|
59
|
+
sessionPath: string,
|
|
60
|
+
session: GrokSession
|
|
61
|
+
): void {
|
|
62
|
+
mkdirSync(dirname(sessionPath), { recursive: true });
|
|
63
|
+
writeFileSync(sessionPath, JSON.stringify(session, null, 2), "utf-8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function deleteSession(sessionPath: string): void {
|
|
67
|
+
if (existsSync(sessionPath)) {
|
|
68
|
+
unlinkSync(sessionPath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
// Session validation
|
|
74
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
77
|
+
|
|
78
|
+
export function isSessionExpiredByAge(session: GrokSession): boolean {
|
|
79
|
+
return Date.now() - session.savedAt > SESSION_MAX_AGE_MS;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Verify the session is still valid by making a lightweight API call.
|
|
84
|
+
* Returns {valid: true} if the session works, {valid: false, reason} otherwise.
|
|
85
|
+
*/
|
|
86
|
+
export async function verifySession(
|
|
87
|
+
context: BrowserContext,
|
|
88
|
+
log: (msg: string) => void
|
|
89
|
+
): Promise<SessionCheckResult> {
|
|
90
|
+
const page = await context.newPage();
|
|
91
|
+
try {
|
|
92
|
+
log("verifying grok session...");
|
|
93
|
+
await page.goto(GROK_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
94
|
+
|
|
95
|
+
// If we see Sign In link → not logged in
|
|
96
|
+
const signIn = page.locator('a[href*="sign-in"], a[href*="/login"]');
|
|
97
|
+
const signInVisible = await signIn.isVisible().catch(() => false);
|
|
98
|
+
if (signInVisible) {
|
|
99
|
+
return { valid: false, reason: "sign-in link visible — session expired" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// If we see the chat input → logged in
|
|
103
|
+
const chatInput = page.locator('textarea, [placeholder*="mind"], [aria-label*="message"]');
|
|
104
|
+
const chatVisible = await chatInput.isVisible().catch(() => false);
|
|
105
|
+
if (chatVisible) {
|
|
106
|
+
log("session valid ✅");
|
|
107
|
+
return { valid: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Ambiguous — try API endpoint
|
|
111
|
+
const resp = await page.evaluate(async () => {
|
|
112
|
+
try {
|
|
113
|
+
const r = await fetch("https://grok.com/rest/app-chat/conversations", {
|
|
114
|
+
method: "GET",
|
|
115
|
+
credentials: "include",
|
|
116
|
+
});
|
|
117
|
+
return { status: r.status };
|
|
118
|
+
} catch (e: unknown) {
|
|
119
|
+
return { status: 0, error: String(e) };
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (resp.status === 200 || resp.status === 204) {
|
|
124
|
+
log("session valid via API check ✅");
|
|
125
|
+
return { valid: true };
|
|
126
|
+
}
|
|
127
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
128
|
+
return { valid: false, reason: `API returned ${resp.status}` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
log(`session check ambiguous (status ${resp.status}) — assuming valid`);
|
|
132
|
+
return { valid: true };
|
|
133
|
+
} finally {
|
|
134
|
+
await page.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
// Interactive login (opens visible browser window)
|
|
140
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
export async function runInteractiveLogin(
|
|
143
|
+
browser: Browser,
|
|
144
|
+
sessionPath: string,
|
|
145
|
+
log: (msg: string) => void,
|
|
146
|
+
timeoutMs = 5 * 60 * 1000
|
|
147
|
+
): Promise<GrokSession> {
|
|
148
|
+
log("opening browser for grok.com login — please sign in with your X account...");
|
|
149
|
+
|
|
150
|
+
const context = await browser.newContext({
|
|
151
|
+
viewport: { width: 1280, height: 800 },
|
|
152
|
+
});
|
|
153
|
+
const page = await context.newPage();
|
|
154
|
+
|
|
155
|
+
await page.goto(GROK_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
156
|
+
|
|
157
|
+
// Wait for sign-in to complete: look for chat textarea to appear
|
|
158
|
+
log(`waiting for login (timeout: ${timeoutMs / 1000}s)...`);
|
|
159
|
+
await page.waitForSelector(
|
|
160
|
+
'textarea, [placeholder*="mind"], [aria-label*="message"]',
|
|
161
|
+
{ timeout: timeoutMs }
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
log("login detected — saving session...");
|
|
165
|
+
const cookies = await context.cookies();
|
|
166
|
+
const userAgent = await page.evaluate(() => navigator.userAgent);
|
|
167
|
+
|
|
168
|
+
const session: GrokSession = {
|
|
169
|
+
cookies,
|
|
170
|
+
savedAt: Date.now(),
|
|
171
|
+
userAgent,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
saveSession(sessionPath, session);
|
|
175
|
+
log(`session saved to ${sessionPath}`);
|
|
176
|
+
|
|
177
|
+
await context.close();
|
|
178
|
+
return session;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
// Context factory: create a BrowserContext with restored cookies
|
|
183
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
export async function createContextFromSession(
|
|
186
|
+
browser: Browser,
|
|
187
|
+
session: GrokSession
|
|
188
|
+
): Promise<BrowserContext> {
|
|
189
|
+
const context = await browser.newContext({
|
|
190
|
+
userAgent: session.userAgent,
|
|
191
|
+
viewport: { width: 1280, height: 800 },
|
|
192
|
+
});
|
|
193
|
+
await context.addCookies(session.cookies);
|
|
194
|
+
return context;
|
|
195
|
+
}
|