@deadragdoll/tellymcp 0.0.12 → 0.0.13

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.
Files changed (48) hide show
  1. package/README-ru.md +19 -0
  2. package/README.md +19 -0
  3. package/TOOLS.md +206 -3
  4. package/dist/cli.js +109 -1
  5. package/dist/services/features/telegram-mcp/browser.service.js +38 -1
  6. package/dist/services/features/telegram-mcp/mcp-server.service.js +12 -0
  7. package/dist/services/features/telegram-mcp/src/app/bootstrap/runtime.js +14 -0
  8. package/dist/services/features/telegram-mcp/src/app/config/env.js +13 -0
  9. package/dist/services/features/telegram-mcp/src/entities/request/model/schema.js +147 -2
  10. package/dist/services/features/telegram-mcp/src/features/browser/model/browserClickTool.js +1 -1
  11. package/dist/services/features/telegram-mcp/src/features/browser/model/browserDomTool.js +1 -1
  12. package/dist/services/features/telegram-mcp/src/features/browser/model/browserFillTool.js +1 -1
  13. package/dist/services/features/telegram-mcp/src/features/browser/model/browserInjectScriptTool.js +28 -0
  14. package/dist/services/features/telegram-mcp/src/features/browser/model/browserListAttachedInstancesTool.js +33 -0
  15. package/dist/services/features/telegram-mcp/src/features/browser/model/browserListTabsTool.js +33 -0
  16. package/dist/services/features/telegram-mcp/src/features/browser/model/browserPressTool.js +1 -1
  17. package/dist/services/features/telegram-mcp/src/features/browser/model/browserRecordingStartTool.js +28 -0
  18. package/dist/services/features/telegram-mcp/src/features/browser/model/browserRecordingStatusTool.js +28 -0
  19. package/dist/services/features/telegram-mcp/src/features/browser/model/browserRecordingStopTool.js +28 -0
  20. package/dist/services/features/telegram-mcp/src/features/browser/model/browserScreenshotTool.js +1 -1
  21. package/dist/services/features/telegram-mcp/src/features/browser/model/browserService.js +485 -1
  22. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/browserRecordingBundle.js +502 -0
  23. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/firefoxAttachRegistry.js +79 -0
  24. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/firefoxAttachServer.js +559 -0
  25. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/types.js +2 -0
  26. package/dist/services/features/telegram-mcp/src/shared/integrations/redis/stateStore.js +28 -0
  27. package/docs/STANDALONE-ru.md +42 -6
  28. package/docs/STANDALONE.md +42 -6
  29. package/package.json +6 -3
  30. package/packages/chrome-attach-extension/dist/background.js +1326 -0
  31. package/packages/chrome-attach-extension/dist/icon.svg +6 -0
  32. package/packages/chrome-attach-extension/dist/manifest.json +36 -0
  33. package/packages/chrome-attach-extension/dist/options.html +312 -0
  34. package/packages/chrome-attach-extension/dist/options.js +593 -0
  35. package/packages/chrome-attach-extension/dist/popup.html +93 -0
  36. package/packages/chrome-attach-extension/dist/popup.js +79 -0
  37. package/packages/chrome-attach-extension/dist/recorder-content.js +83 -0
  38. package/packages/chrome-attach-extension/dist/recorder-page.js +266 -0
  39. package/packages/firefox-attach-extension/README.md +13 -0
  40. package/packages/firefox-attach-extension/dist/background.js +1242 -0
  41. package/packages/firefox-attach-extension/dist/icon.svg +6 -0
  42. package/packages/firefox-attach-extension/dist/manifest.json +56 -0
  43. package/packages/firefox-attach-extension/dist/options.html +312 -0
  44. package/packages/firefox-attach-extension/dist/options.js +527 -0
  45. package/packages/firefox-attach-extension/dist/popup.html +93 -0
  46. package/packages/firefox-attach-extension/dist/popup.js +64 -0
  47. package/packages/firefox-attach-extension/dist/recorder-content.js +77 -0
  48. package/packages/firefox-attach-extension/dist/recorder-page.js +302 -0
@@ -0,0 +1,1242 @@
1
+ const DEFAULT_SETTINGS = {
2
+ host: "127.0.0.1",
3
+ port: 9999,
4
+ };
5
+
6
+ const CONNECTION_STATUS_KEY = "attach_connection_status";
7
+ const INSTANCE_ID_KEY = "attach_instance_id";
8
+ const ATTACHED_TAB_KEY = "attach_selected_tab";
9
+ const CONNECTION_ENABLED_KEY = "attach_connection_enabled";
10
+ const RECORDING_STATUS_KEY = "attach_recording_status";
11
+ const POPUP_COMMAND_KEY = "attach_popup_command";
12
+ const POPUP_COMMAND_RESULT_KEY = "attach_popup_command_result";
13
+ const RECONNECT_DELAY_MS = 3000;
14
+ const HEARTBEAT_INTERVAL_MS = 15000;
15
+ const MAX_CAPTURE_BYTES = 512 * 1024;
16
+
17
+ let socket = null;
18
+ let reconnectTimer = null;
19
+ let heartbeatTimer = null;
20
+ let instanceId = null;
21
+ let manualDisconnect = false;
22
+ const pendingManualRecordingRequests = new Map();
23
+
24
+ const activeRecordingsById = new Map();
25
+ const activeRecordingIdByTabId = new Map();
26
+
27
+ async function getSettings() {
28
+ return browser.storage.local.get({
29
+ ...DEFAULT_SETTINGS,
30
+ [CONNECTION_ENABLED_KEY]: true,
31
+ });
32
+ }
33
+
34
+ async function setConnectionStatus(status) {
35
+ await browser.storage.local.set({
36
+ [CONNECTION_STATUS_KEY]: status,
37
+ });
38
+ }
39
+
40
+ async function setLocalInstanceId(value) {
41
+ await browser.storage.local.set({
42
+ [INSTANCE_ID_KEY]: value,
43
+ });
44
+ }
45
+
46
+ async function setAttachedTab(tab) {
47
+ await browser.storage.local.set({
48
+ [ATTACHED_TAB_KEY]: tab,
49
+ });
50
+ }
51
+
52
+ async function setRecordingStatus(status) {
53
+ await browser.storage.local.set({
54
+ [RECORDING_STATUS_KEY]: status,
55
+ });
56
+ }
57
+
58
+ function buildWebSocketUrl(settings) {
59
+ return `ws://${settings.host}:${settings.port}/browser-attach/ws`;
60
+ }
61
+
62
+ async function computeInstanceId() {
63
+ if (instanceId) {
64
+ return instanceId;
65
+ }
66
+ const runtimeId = browser.runtime.id || "firefox";
67
+ instanceId = `firefox-${runtimeId}`;
68
+ await setLocalInstanceId(instanceId);
69
+ return instanceId;
70
+ }
71
+
72
+ async function listTabs() {
73
+ const tabs = await browser.tabs.query({});
74
+ return tabs.map((tab) => ({
75
+ tab_id: tab.id,
76
+ window_id: tab.windowId,
77
+ active: tab.active === true,
78
+ title: tab.title || "",
79
+ url: tab.url || "",
80
+ status: tab.status || "",
81
+ }));
82
+ }
83
+
84
+ async function getActiveTab() {
85
+ const tabs = await browser.tabs.query({ active: true, currentWindow: true });
86
+ const tab = tabs[0];
87
+ if (!tab || typeof tab.id !== "number") {
88
+ return null;
89
+ }
90
+ return {
91
+ tab_id: tab.id,
92
+ window_id: tab.windowId,
93
+ active: tab.active === true,
94
+ title: tab.title || "",
95
+ url: tab.url || "",
96
+ status: tab.status || "",
97
+ };
98
+ }
99
+
100
+ async function ensureTabIsActive(tabId) {
101
+ let tab;
102
+ try {
103
+ tab = await browser.tabs.get(tabId);
104
+ } catch {
105
+ throw new Error("Tab not found.");
106
+ }
107
+ await browser.tabs.update(tabId, { active: true });
108
+ await browser.windows.update(tab.windowId, { focused: true });
109
+ return await browser.tabs.get(tabId);
110
+ }
111
+
112
+ function buildTabActionCode(action, payload) {
113
+ const serializedAction = JSON.stringify(action);
114
+ const serializedPayload = JSON.stringify(payload || {});
115
+
116
+ return `(() => {
117
+ const action = ${serializedAction};
118
+ const payload = ${serializedPayload};
119
+ const normalize = (value) => typeof value === "string" ? value.trim() : "";
120
+ const byText = (text, exact) => {
121
+ const needle = normalize(text);
122
+ if (!needle) return null;
123
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
124
+ while (walker.nextNode()) {
125
+ const element = walker.currentNode;
126
+ const textContent = normalize(element.textContent || "");
127
+ if (!textContent) continue;
128
+ if ((exact && textContent === needle) || (!exact && textContent.includes(needle))) {
129
+ return element;
130
+ }
131
+ }
132
+ return null;
133
+ };
134
+ const resolveTarget = () => {
135
+ const aiTag = normalize(payload.ai_tag);
136
+ if (aiTag) {
137
+ return document.querySelector('[data-drive-tag="' + aiTag.replace(/"/g, '\\"') + '"], [ai-tag="' + aiTag.replace(/"/g, '\\"') + '"]');
138
+ }
139
+ const selector = normalize(payload.selector);
140
+ if (selector) {
141
+ return document.querySelector(selector);
142
+ }
143
+ const text = normalize(payload.text);
144
+ if (text) {
145
+ return byText(text, payload.exact === true);
146
+ }
147
+ return document.body;
148
+ };
149
+ const target = resolveTarget();
150
+ if (action !== "screenshot" && action !== "inject_script" && !target) {
151
+ return { ok: false, error: "Target element was not found." };
152
+ }
153
+ if (target && typeof target.scrollIntoView === "function") {
154
+ target.scrollIntoView({ block: "center", inline: "center" });
155
+ }
156
+ const toVisible = (element) => {
157
+ const computed = window.getComputedStyle(element);
158
+ return computed.display !== "none" && computed.visibility !== "hidden" && computed.opacity !== "0";
159
+ };
160
+ if (action === "dom") {
161
+ const attributes = target
162
+ ? Object.fromEntries(Array.from(target.attributes || []).map((attr) => [attr.name, attr.value]))
163
+ : {};
164
+ return {
165
+ ok: true,
166
+ result: {
167
+ found: Boolean(target),
168
+ outer_html: payload.include_html === false ? undefined : target.outerHTML,
169
+ text_content: payload.include_text === false ? undefined : (target.textContent || "").trim(),
170
+ visible: target ? toVisible(target) : false,
171
+ attributes,
172
+ url: location.href,
173
+ title: document.title,
174
+ },
175
+ };
176
+ }
177
+ if (action === "click") {
178
+ target.click();
179
+ return {
180
+ ok: true,
181
+ result: {
182
+ url: location.href,
183
+ title: document.title,
184
+ },
185
+ };
186
+ }
187
+ if (action === "fill") {
188
+ target.focus();
189
+ target.value = payload.value || "";
190
+ target.dispatchEvent(new Event("input", { bubbles: true }));
191
+ target.dispatchEvent(new Event("change", { bubbles: true }));
192
+ return {
193
+ ok: true,
194
+ result: {
195
+ url: location.href,
196
+ title: document.title,
197
+ },
198
+ };
199
+ }
200
+ if (action === "inject_script") {
201
+ const namespace = normalize(payload.namespace) || "TELLY";
202
+ const source = String(payload.source || "");
203
+ if (!source) {
204
+ return { ok: false, error: "Script source is required." };
205
+ }
206
+ const wrapped = "const __tellyNamespace=" + JSON.stringify(namespace)
207
+ + ";window[__tellyNamespace]=window[__tellyNamespace]||{};var TELLY=window[__tellyNamespace];const __tellyBeforeKeys=new Set(Object.getOwnPropertyNames(window));\\n"
208
+ + source
209
+ + "\\nfor(const __tellyKey of Object.getOwnPropertyNames(window)){if(__tellyBeforeKeys.has(__tellyKey)){continue;}if(__tellyKey===__tellyNamespace){continue;}try{window[__tellyNamespace][__tellyKey]=window[__tellyKey];}catch{}}";
210
+ const script = document.createElement("script");
211
+ script.textContent = wrapped;
212
+ (document.documentElement || document.head || document.body).appendChild(script);
213
+ script.remove();
214
+ return {
215
+ ok: true,
216
+ result: {
217
+ namespace,
218
+ bytes: source.length,
219
+ url: location.href,
220
+ title: document.title,
221
+ },
222
+ };
223
+ }
224
+ if (action === "press") {
225
+ const key = String(payload.key || "");
226
+ if (target && typeof target.focus === "function") {
227
+ target.focus();
228
+ }
229
+ const eventInit = { key, bubbles: true, cancelable: true };
230
+ const keyboardTarget = document.activeElement || target || document.body;
231
+ keyboardTarget.dispatchEvent(new KeyboardEvent("keydown", eventInit));
232
+ keyboardTarget.dispatchEvent(new KeyboardEvent("keypress", eventInit));
233
+ keyboardTarget.dispatchEvent(new KeyboardEvent("keyup", eventInit));
234
+ return {
235
+ ok: true,
236
+ result: {
237
+ url: location.href,
238
+ title: document.title,
239
+ },
240
+ };
241
+ }
242
+ return { ok: false, error: "Unsupported tab action." };
243
+ })();`;
244
+ }
245
+
246
+ async function runTabAction(tabId, action, payload) {
247
+ const activeTab = await ensureTabIsActive(tabId);
248
+
249
+ if (action === "screenshot") {
250
+ const dataUrl = await browser.tabs.captureTab(activeTab.windowId, {
251
+ format: "png",
252
+ });
253
+ return {
254
+ ok: true,
255
+ result: {
256
+ png_base64: String(dataUrl).replace(/^data:image\/png;base64,/, ""),
257
+ url: activeTab.url || "",
258
+ title: activeTab.title || "",
259
+ },
260
+ };
261
+ }
262
+
263
+ const [result] = await browser.tabs.executeScript(tabId, {
264
+ code: buildTabActionCode(action, payload),
265
+ });
266
+
267
+ if (!result || result.ok !== true) {
268
+ return {
269
+ ok: false,
270
+ error: result?.error || "Tab action did not return a successful result.",
271
+ };
272
+ }
273
+
274
+ return result;
275
+ }
276
+
277
+ function sendJson(payload) {
278
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
279
+ return;
280
+ }
281
+ socket.send(JSON.stringify(payload));
282
+ }
283
+
284
+ function clearTimers() {
285
+ if (reconnectTimer) {
286
+ clearTimeout(reconnectTimer);
287
+ reconnectTimer = null;
288
+ }
289
+ if (heartbeatTimer) {
290
+ clearInterval(heartbeatTimer);
291
+ heartbeatTimer = null;
292
+ }
293
+ }
294
+
295
+ function scheduleReconnect() {
296
+ if (manualDisconnect) {
297
+ return;
298
+ }
299
+ clearTimers();
300
+ reconnectTimer = setTimeout(() => {
301
+ void connect();
302
+ }, RECONNECT_DELAY_MS);
303
+ }
304
+
305
+ async function waitForSocketReady(timeoutMs = 2500) {
306
+ const deadline = Date.now() + timeoutMs;
307
+ while (Date.now() < deadline) {
308
+ if (socket && socket.readyState === WebSocket.OPEN) {
309
+ return true;
310
+ }
311
+ await new Promise((resolve) => setTimeout(resolve, 100));
312
+ }
313
+ return Boolean(socket && socket.readyState === WebSocket.OPEN);
314
+ }
315
+
316
+ function startHeartbeat() {
317
+ if (heartbeatTimer) {
318
+ clearInterval(heartbeatTimer);
319
+ }
320
+ heartbeatTimer = setInterval(() => {
321
+ sendJson({
322
+ type: "heartbeat",
323
+ sent_at: new Date().toISOString(),
324
+ });
325
+ }, HEARTBEAT_INTERVAL_MS);
326
+ }
327
+
328
+ async function sendHello() {
329
+ sendJson({
330
+ type: "hello",
331
+ extension_version: browser.runtime.getManifest().version,
332
+ browser: "firefox",
333
+ instance_id: await computeInstanceId(),
334
+ profile_name: "default",
335
+ });
336
+ }
337
+
338
+ function bytesToBase64(bytes) {
339
+ let binary = "";
340
+ const chunkSize = 0x8000;
341
+ for (let index = 0; index < bytes.length; index += chunkSize) {
342
+ const slice = bytes.subarray(index, index + chunkSize);
343
+ binary += String.fromCharCode(...slice);
344
+ }
345
+ return btoa(binary);
346
+ }
347
+
348
+ function headersToArray(headers) {
349
+ return (headers || []).map((header) => ({
350
+ name: String(header.name || ""),
351
+ value: String(header.value || ""),
352
+ }));
353
+ }
354
+
355
+ function getHeaderValue(headers, headerName) {
356
+ const normalizedName = String(headerName || "").trim().toLowerCase();
357
+ const match = (headers || []).find(
358
+ (header) => String(header.name || "").trim().toLowerCase() === normalizedName,
359
+ );
360
+ return match?.value ? String(match.value) : "";
361
+ }
362
+
363
+ function isTextLikeContentType(contentType) {
364
+ const normalized = String(contentType || "").trim().toLowerCase();
365
+ if (!normalized) {
366
+ return false;
367
+ }
368
+ return (
369
+ normalized.startsWith("text/") ||
370
+ normalized.includes("json") ||
371
+ normalized.includes("xml") ||
372
+ normalized.includes("javascript") ||
373
+ normalized.includes("ecmascript") ||
374
+ normalized.includes("x-www-form-urlencoded") ||
375
+ normalized.includes("svg")
376
+ );
377
+ }
378
+
379
+ function getRecordingByTabId(tabId) {
380
+ const recordingId = activeRecordingIdByTabId.get(tabId);
381
+ return recordingId ? activeRecordingsById.get(recordingId) || null : null;
382
+ }
383
+
384
+ function sendRecordingEvent(recordingId, tabId, event) {
385
+ if (!recordingId || !Number.isInteger(tabId)) {
386
+ return;
387
+ }
388
+ sendJson({
389
+ type: "recording_event",
390
+ recording_id: recordingId,
391
+ tab_id: tabId,
392
+ event: {
393
+ ...event,
394
+ at: event.at || new Date().toISOString(),
395
+ },
396
+ });
397
+ }
398
+
399
+ async function injectRecorderContent(tabId) {
400
+ await browser.tabs.executeScript(tabId, {
401
+ file: "recorder-content.js",
402
+ allFrames: true,
403
+ });
404
+ }
405
+
406
+ async function captureTabSnapshot(tabId, reason) {
407
+ const [result] = await browser.tabs.executeScript(tabId, {
408
+ code: `(() => ({
409
+ kind: "page_snapshot",
410
+ source: "background",
411
+ reason: ${JSON.stringify(reason)},
412
+ at: new Date().toISOString(),
413
+ url: location.href,
414
+ title: document.title,
415
+ ready_state: document.readyState,
416
+ html: document.documentElement ? document.documentElement.outerHTML : ""
417
+ }))();`,
418
+ });
419
+ return result || null;
420
+ }
421
+
422
+ async function emitCookiesSnapshot(recordingId, tabId, tabUrl, tabTitle) {
423
+ if (!tabUrl || !/^https?:\/\//iu.test(tabUrl)) {
424
+ return;
425
+ }
426
+ try {
427
+ const cookies = await browser.cookies.getAll({ url: tabUrl });
428
+ sendRecordingEvent(recordingId, tabId, {
429
+ kind: "cookies_snapshot",
430
+ source: "browser",
431
+ url: tabUrl,
432
+ tab_title: tabTitle || "",
433
+ cookies: cookies.map((cookie) => ({
434
+ name: cookie.name,
435
+ value: cookie.value,
436
+ domain: cookie.domain,
437
+ path: cookie.path,
438
+ secure: cookie.secure,
439
+ http_only: cookie.httpOnly,
440
+ same_site: cookie.sameSite,
441
+ store_id: cookie.storeId,
442
+ })),
443
+ });
444
+ } catch {
445
+ // ignore
446
+ }
447
+ }
448
+
449
+ async function startRecording(message) {
450
+ const attached = (await browser.storage.local.get({ [ATTACHED_TAB_KEY]: null }))[ATTACHED_TAB_KEY];
451
+ const tabId = Number(message.tab_id);
452
+ if (!attached || attached.tab_id !== tabId) {
453
+ return {
454
+ ok: false,
455
+ active: false,
456
+ error: "Selected attached tab does not match the requested recording tab.",
457
+ };
458
+ }
459
+
460
+ const browserTab = await browser.tabs.get(tabId);
461
+ activeRecordingsById.set(message.recording_id, {
462
+ recordingId: message.recording_id,
463
+ tabId,
464
+ tabTitle: browserTab.title || "",
465
+ tabUrl: browserTab.url || "",
466
+ startedAt: new Date().toISOString(),
467
+ });
468
+ activeRecordingIdByTabId.set(tabId, message.recording_id);
469
+
470
+ await injectRecorderContent(tabId);
471
+ const snapshot = await captureTabSnapshot(tabId, "recording-start");
472
+ if (snapshot) {
473
+ sendRecordingEvent(message.recording_id, tabId, snapshot);
474
+ }
475
+ await emitCookiesSnapshot(
476
+ message.recording_id,
477
+ tabId,
478
+ browserTab.url || "",
479
+ browserTab.title || "",
480
+ );
481
+
482
+ return {
483
+ ok: true,
484
+ active: true,
485
+ };
486
+ }
487
+
488
+ async function stopRecording(message) {
489
+ activeRecordingsById.delete(message.recording_id);
490
+ activeRecordingIdByTabId.delete(message.tab_id);
491
+ await setRecordingStatus({
492
+ active: false,
493
+ });
494
+ return {
495
+ ok: true,
496
+ active: false,
497
+ };
498
+ }
499
+
500
+ async function sendManualRecordingRequest(type, payload = {}) {
501
+ const ready = await waitForSocketReady();
502
+ if (!ready) {
503
+ return {
504
+ ok: false,
505
+ active: false,
506
+ error: "Extension is not connected to TellyMCP.",
507
+ };
508
+ }
509
+
510
+ const requestId = `manual-recording-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
511
+ const result = await new Promise((resolve, reject) => {
512
+ const timer = setTimeout(() => {
513
+ pendingManualRecordingRequests.delete(requestId);
514
+ reject(new Error("Recording request timed out."));
515
+ }, 15000);
516
+ pendingManualRecordingRequests.set(requestId, { resolve, reject, timer });
517
+ sendJson({
518
+ type,
519
+ request_id: requestId,
520
+ ...payload,
521
+ });
522
+ }).catch((error) => ({
523
+ ok: false,
524
+ active: false,
525
+ error: error instanceof Error ? error.message : String(error),
526
+ }));
527
+
528
+ if (result && typeof result === "object" && result.recording) {
529
+ await setRecordingStatus(result);
530
+ } else if (result && typeof result === "object" && result.active === false) {
531
+ await setRecordingStatus({ active: false });
532
+ }
533
+
534
+ return result;
535
+ }
536
+
537
+ async function handlePopupCommand(command) {
538
+ if (!command || typeof command !== "object") {
539
+ return {
540
+ ok: false,
541
+ error: "Invalid popup command.",
542
+ };
543
+ }
544
+
545
+ if (command.type === "attach_tab_selected") {
546
+ const tabId = Number(command.tab_id);
547
+ if (!Number.isInteger(tabId) || tabId < 0) {
548
+ return {
549
+ ok: false,
550
+ error: "Invalid tab_id.",
551
+ };
552
+ }
553
+
554
+ let browserTab;
555
+ try {
556
+ browserTab = await browser.tabs.get(tabId);
557
+ } catch {
558
+ return {
559
+ ok: false,
560
+ error: "Tab not found.",
561
+ };
562
+ }
563
+
564
+ const ready = await waitForSocketReady();
565
+ if (!ready) {
566
+ return {
567
+ ok: false,
568
+ error: "Extension is not connected to TellyMCP.",
569
+ };
570
+ }
571
+
572
+ const record = {
573
+ tab_id: tabId,
574
+ window_id: browserTab.windowId,
575
+ active: browserTab.active === true,
576
+ title: browserTab.title || "",
577
+ url: browserTab.url || "",
578
+ status: browserTab.status || "",
579
+ };
580
+
581
+ sendJson({
582
+ type: "attach_tab_selected",
583
+ tab: record,
584
+ });
585
+ await setAttachedTab(record);
586
+
587
+ return {
588
+ ok: true,
589
+ tab: record,
590
+ };
591
+ }
592
+
593
+ if (command.type === "attach_recording_start") {
594
+ const stored = await browser.storage.local.get({ [ATTACHED_TAB_KEY]: null });
595
+ const tab = stored[ATTACHED_TAB_KEY];
596
+ if (!tab) {
597
+ return {
598
+ ok: false,
599
+ active: false,
600
+ error: "Select a tab first.",
601
+ };
602
+ }
603
+ return await sendManualRecordingRequest("recording_manual_start", { tab });
604
+ }
605
+
606
+ if (command.type === "attach_recording_stop") {
607
+ return await sendManualRecordingRequest("recording_manual_stop");
608
+ }
609
+
610
+ if (command.type === "attach_recording_status") {
611
+ return await sendManualRecordingRequest("recording_manual_status");
612
+ }
613
+
614
+ if (command.type === "attach_inject_script") {
615
+ const stored = await browser.storage.local.get({ [ATTACHED_TAB_KEY]: null });
616
+ const tab = stored[ATTACHED_TAB_KEY];
617
+ if (!tab) {
618
+ return {
619
+ ok: false,
620
+ error: "Select a tab first.",
621
+ };
622
+ }
623
+ const source = String(command.source || "");
624
+ if (!source.trim()) {
625
+ return {
626
+ ok: false,
627
+ error: "Script source is empty.",
628
+ };
629
+ }
630
+ return await runTabAction(tab.tab_id, "inject_script", {
631
+ namespace: typeof command.namespace === "string" ? command.namespace : "TELLY",
632
+ source,
633
+ });
634
+ }
635
+
636
+ return {
637
+ ok: false,
638
+ error: `Unsupported popup command: ${String(command.type || "")}`,
639
+ };
640
+ }
641
+
642
+ async function handleMessage(rawData) {
643
+ const message =
644
+ typeof rawData === "string"
645
+ ? JSON.parse(rawData)
646
+ : JSON.parse(String(rawData));
647
+
648
+ switch (message.type) {
649
+ case "hello_ack":
650
+ await setConnectionStatus({
651
+ state: "connected",
652
+ text: `Connected: ${message.session_label || message.session_id || message.instance_id}`,
653
+ instance_id: message.instance_id,
654
+ ...(message.session_id ? { session_id: message.session_id } : {}),
655
+ ...(message.session_label ? { session_label: message.session_label } : {}),
656
+ });
657
+ startHeartbeat();
658
+ return;
659
+ case "list_tabs": {
660
+ sendJson({
661
+ type: "list_tabs_result",
662
+ request_id: message.request_id,
663
+ tabs: await listTabs(),
664
+ });
665
+ return;
666
+ }
667
+ case "get_active_tab": {
668
+ sendJson({
669
+ type: "get_active_tab_result",
670
+ request_id: message.request_id,
671
+ tab: await getActiveTab(),
672
+ });
673
+ return;
674
+ }
675
+ case "tab_action": {
676
+ const result = await runTabAction(
677
+ message.tab_id,
678
+ message.action,
679
+ message.payload || {},
680
+ );
681
+ sendJson({
682
+ type: "tab_action_result",
683
+ request_id: message.request_id,
684
+ ok: result.ok === true,
685
+ ...(result.result ? { result: result.result } : {}),
686
+ ...(result.error ? { error: result.error } : {}),
687
+ });
688
+ return;
689
+ }
690
+ case "recording_start": {
691
+ const result = await startRecording(message);
692
+ sendJson({
693
+ type: "recording_control_result",
694
+ request_id: message.request_id,
695
+ ok: result.ok === true,
696
+ active: result.active === true,
697
+ ...(result.error ? { error: result.error } : {}),
698
+ });
699
+ return;
700
+ }
701
+ case "recording_stop": {
702
+ const result = await stopRecording(message);
703
+ sendJson({
704
+ type: "recording_control_result",
705
+ request_id: message.request_id,
706
+ ok: result.ok === true,
707
+ active: result.active === true,
708
+ ...(result.error ? { error: result.error } : {}),
709
+ });
710
+ return;
711
+ }
712
+ case "recording_manual_result": {
713
+ const pending = pendingManualRecordingRequests.get(message.request_id);
714
+ if (!pending) {
715
+ return;
716
+ }
717
+ clearTimeout(pending.timer);
718
+ pendingManualRecordingRequests.delete(message.request_id);
719
+ pending.resolve(message);
720
+ return;
721
+ }
722
+ case "recording_state":
723
+ await setRecordingStatus({
724
+ active: message.active === true,
725
+ ...(message.recording ? { recording: message.recording } : {}),
726
+ });
727
+ return;
728
+ default:
729
+ return;
730
+ }
731
+ }
732
+
733
+ async function connect() {
734
+ const settings = await getSettings();
735
+ manualDisconnect = settings[CONNECTION_ENABLED_KEY] === false;
736
+ if (manualDisconnect) {
737
+ clearTimers();
738
+ await setConnectionStatus({
739
+ state: "disconnected",
740
+ text: "Disconnected: manual",
741
+ });
742
+ return;
743
+ }
744
+ clearTimers();
745
+ await setConnectionStatus({
746
+ state: "connecting",
747
+ text: `Connecting: ${settings.host}:${settings.port}`,
748
+ });
749
+ const wsUrl = buildWebSocketUrl(settings);
750
+ socket = new WebSocket(wsUrl);
751
+
752
+ socket.addEventListener("open", () => {
753
+ void sendHello();
754
+ });
755
+
756
+ socket.addEventListener("message", (event) => {
757
+ void handleMessage(event.data);
758
+ });
759
+
760
+ socket.addEventListener("close", () => {
761
+ socket = null;
762
+ if (manualDisconnect) {
763
+ void setConnectionStatus({
764
+ state: "disconnected",
765
+ text: "Disconnected: manual",
766
+ });
767
+ return;
768
+ }
769
+ void setConnectionStatus({
770
+ state: "disconnected",
771
+ text: `Disconnected: reconnecting in ${Math.floor(RECONNECT_DELAY_MS / 1000)}s`,
772
+ });
773
+ scheduleReconnect();
774
+ });
775
+
776
+ socket.addEventListener("error", () => {
777
+ void setConnectionStatus({
778
+ state: "disconnected",
779
+ text: "Disconnected: WebSocket error",
780
+ });
781
+ if (socket) {
782
+ socket.close();
783
+ }
784
+ });
785
+ }
786
+
787
+ browser.storage.onChanged.addListener((changes, areaName) => {
788
+ if (areaName !== "local") {
789
+ return;
790
+ }
791
+
792
+ if (changes[CONNECTION_ENABLED_KEY] !== undefined) {
793
+ const nextEnabled = changes[CONNECTION_ENABLED_KEY].newValue !== false;
794
+ manualDisconnect = !nextEnabled;
795
+ if (nextEnabled) {
796
+ if (socket) {
797
+ socket.close();
798
+ } else {
799
+ void connect();
800
+ }
801
+ } else {
802
+ clearTimers();
803
+ if (socket) {
804
+ socket.close();
805
+ } else {
806
+ void setConnectionStatus({
807
+ state: "disconnected",
808
+ text: "Disconnected: manual",
809
+ });
810
+ }
811
+ }
812
+ return;
813
+ }
814
+
815
+ if (
816
+ changes.host === undefined &&
817
+ changes.port === undefined
818
+ ) {
819
+ if (changes[POPUP_COMMAND_KEY] !== undefined) {
820
+ const command = changes[POPUP_COMMAND_KEY].newValue;
821
+ if (!command) {
822
+ return;
823
+ }
824
+ void handlePopupCommand(command)
825
+ .then((result) =>
826
+ browser.storage.local.set({
827
+ [POPUP_COMMAND_RESULT_KEY]: {
828
+ command_id: command.command_id,
829
+ result,
830
+ at: new Date().toISOString(),
831
+ },
832
+ }),
833
+ )
834
+ .catch((error) =>
835
+ browser.storage.local.set({
836
+ [POPUP_COMMAND_RESULT_KEY]: {
837
+ command_id: command.command_id,
838
+ result: {
839
+ ok: false,
840
+ error: error instanceof Error ? error.message : String(error),
841
+ },
842
+ at: new Date().toISOString(),
843
+ },
844
+ }),
845
+ );
846
+ return;
847
+ }
848
+ return;
849
+ }
850
+
851
+ if (socket) {
852
+ socket.close();
853
+ } else if (!manualDisconnect) {
854
+ void connect();
855
+ }
856
+ });
857
+
858
+ browser.tabs.onActivated.addListener(async () => {
859
+ const tab = await getActiveTab();
860
+ if (!tab) {
861
+ return;
862
+ }
863
+ sendJson({
864
+ type: "active_tab_changed",
865
+ tab,
866
+ });
867
+ });
868
+
869
+ browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
870
+ if (
871
+ typeof tabId !== "number" ||
872
+ (changeInfo.title === undefined &&
873
+ changeInfo.url === undefined &&
874
+ changeInfo.status === undefined)
875
+ ) {
876
+ return;
877
+ }
878
+
879
+ sendJson({
880
+ type: "tab_updated",
881
+ tab: {
882
+ tab_id: tabId,
883
+ window_id: tab.windowId,
884
+ active: tab.active === true,
885
+ title: tab.title || "",
886
+ url: tab.url || "",
887
+ status: changeInfo.status || tab.status || "",
888
+ },
889
+ });
890
+
891
+ const recording = getRecordingByTabId(tabId);
892
+ if (!recording) {
893
+ return;
894
+ }
895
+
896
+ sendRecordingEvent(recording.recordingId, tabId, {
897
+ kind: "navigation",
898
+ source: "browser",
899
+ status: changeInfo.status || tab.status || "",
900
+ url: tab.url || "",
901
+ title: tab.title || "",
902
+ });
903
+
904
+ if (changeInfo.status === "complete") {
905
+ try {
906
+ await injectRecorderContent(tabId);
907
+ const snapshot = await captureTabSnapshot(tabId, "tab-updated-complete");
908
+ if (snapshot) {
909
+ sendRecordingEvent(recording.recordingId, tabId, snapshot);
910
+ }
911
+ } catch {
912
+ // ignore
913
+ }
914
+ await emitCookiesSnapshot(
915
+ recording.recordingId,
916
+ tabId,
917
+ tab.url || "",
918
+ tab.title || "",
919
+ );
920
+ }
921
+ });
922
+
923
+ async function handleRuntimeMessage(message, sender) {
924
+ if (!message || typeof message !== "object") {
925
+ return undefined;
926
+ }
927
+
928
+ if (message.type === "attach_tab_selected") {
929
+ return (async () => {
930
+ const tabId = Number(message.tab_id);
931
+ if (!Number.isInteger(tabId) || tabId < 0) {
932
+ return {
933
+ ok: false,
934
+ error: "Invalid tab_id.",
935
+ };
936
+ }
937
+
938
+ let browserTab;
939
+ try {
940
+ browserTab = await browser.tabs.get(tabId);
941
+ } catch {
942
+ return {
943
+ ok: false,
944
+ error: "Tab not found.",
945
+ };
946
+ }
947
+
948
+ const ready = await waitForSocketReady();
949
+ if (!ready) {
950
+ return {
951
+ ok: false,
952
+ error: "Extension is not connected to TellyMCP.",
953
+ };
954
+ }
955
+
956
+ const record = {
957
+ tab_id: tabId,
958
+ window_id: browserTab.windowId,
959
+ active: browserTab.active === true,
960
+ title: browserTab.title || "",
961
+ url: browserTab.url || "",
962
+ status: browserTab.status || "",
963
+ };
964
+
965
+ sendJson({
966
+ type: "attach_tab_selected",
967
+ tab: record,
968
+ });
969
+ await setAttachedTab(record);
970
+
971
+ return {
972
+ ok: true,
973
+ tab: record,
974
+ };
975
+ })();
976
+ }
977
+
978
+ if (message.type === "attach_connection_set_enabled") {
979
+ return (async () => {
980
+ const enabled = message.enabled === true;
981
+ await browser.storage.local.set({
982
+ [CONNECTION_ENABLED_KEY]: enabled,
983
+ });
984
+ return { ok: true, enabled };
985
+ })();
986
+ }
987
+
988
+ if (message.type === "attach_recording_start") {
989
+ return (async () => {
990
+ const stored = await browser.storage.local.get({ [ATTACHED_TAB_KEY]: null });
991
+ const tab = stored[ATTACHED_TAB_KEY];
992
+ if (!tab) {
993
+ return {
994
+ ok: false,
995
+ active: false,
996
+ error: "Select a tab first.",
997
+ };
998
+ }
999
+ return await sendManualRecordingRequest("recording_manual_start", { tab });
1000
+ })();
1001
+ }
1002
+
1003
+ if (message.type === "attach_recording_stop") {
1004
+ return (async () => {
1005
+ return await sendManualRecordingRequest("recording_manual_stop");
1006
+ })();
1007
+ }
1008
+
1009
+ if (message.type === "attach_recording_status") {
1010
+ return (async () => {
1011
+ return await sendManualRecordingRequest("recording_manual_status");
1012
+ })();
1013
+ }
1014
+
1015
+ if (message.type === "attach_inject_script") {
1016
+ return (async () => {
1017
+ return await handlePopupCommand({
1018
+ type: "attach_inject_script",
1019
+ source: message.source,
1020
+ namespace: message.namespace,
1021
+ });
1022
+ })();
1023
+ }
1024
+
1025
+ if (message.type === "telly_recording_page_event") {
1026
+ const tabId = sender?.tab?.id;
1027
+ if (!Number.isInteger(tabId)) {
1028
+ return undefined;
1029
+ }
1030
+ const recording = getRecordingByTabId(tabId);
1031
+ if (!recording) {
1032
+ return undefined;
1033
+ }
1034
+ sendRecordingEvent(recording.recordingId, tabId, message.event || {});
1035
+ return { ok: true };
1036
+ }
1037
+
1038
+ return undefined;
1039
+ }
1040
+
1041
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
1042
+ void handleRuntimeMessage(message, sender)
1043
+ .then((result) => {
1044
+ sendResponse(result);
1045
+ })
1046
+ .catch((error) => {
1047
+ sendResponse({
1048
+ ok: false,
1049
+ error: error instanceof Error ? error.message : String(error),
1050
+ });
1051
+ });
1052
+ return true;
1053
+ });
1054
+
1055
+ browser.webRequest.onBeforeRequest.addListener(
1056
+ (details) => {
1057
+ const recording = getRecordingByTabId(details.tabId);
1058
+ if (!recording) {
1059
+ return;
1060
+ }
1061
+
1062
+ let bodyText = "";
1063
+ let bodyBase64 = "";
1064
+ if (details.requestBody?.formData) {
1065
+ bodyText = Object.entries(details.requestBody.formData)
1066
+ .map(([name, values]) =>
1067
+ values.map((value) => `${name}=${String(value)}`).join("&"),
1068
+ )
1069
+ .join("&");
1070
+ } else if (Array.isArray(details.requestBody?.raw)) {
1071
+ const chunks = details.requestBody.raw
1072
+ .map((item) => item.bytes)
1073
+ .filter(Boolean)
1074
+ .map((buffer) => new Uint8Array(buffer));
1075
+ if (chunks.length > 0) {
1076
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
1077
+ const merged = new Uint8Array(total);
1078
+ let offset = 0;
1079
+ for (const chunk of chunks) {
1080
+ merged.set(chunk, offset);
1081
+ offset += chunk.length;
1082
+ }
1083
+ bodyBase64 = bytesToBase64(merged.subarray(0, MAX_CAPTURE_BYTES));
1084
+ }
1085
+ }
1086
+
1087
+ sendRecordingEvent(recording.recordingId, details.tabId, {
1088
+ kind: "network_request",
1089
+ source: "browser",
1090
+ request_id: details.requestId,
1091
+ url: details.url,
1092
+ method: details.method,
1093
+ resource_type: details.type,
1094
+ ...(bodyText ? { body_text: bodyText.slice(0, MAX_CAPTURE_BYTES) } : {}),
1095
+ ...(bodyBase64 ? { body_base64: bodyBase64 } : {}),
1096
+ });
1097
+ },
1098
+ { urls: ["<all_urls>"] },
1099
+ ["requestBody"],
1100
+ );
1101
+
1102
+ browser.webRequest.onBeforeSendHeaders.addListener(
1103
+ (details) => {
1104
+ const recording = getRecordingByTabId(details.tabId);
1105
+ if (!recording) {
1106
+ return;
1107
+ }
1108
+ sendRecordingEvent(recording.recordingId, details.tabId, {
1109
+ kind: "network_request_headers",
1110
+ source: "browser",
1111
+ request_id: details.requestId,
1112
+ url: details.url,
1113
+ method: details.method,
1114
+ resource_type: details.type,
1115
+ headers: headersToArray(details.requestHeaders),
1116
+ });
1117
+ },
1118
+ { urls: ["<all_urls>"] },
1119
+ ["requestHeaders"],
1120
+ );
1121
+
1122
+ browser.webRequest.onHeadersReceived.addListener(
1123
+ (details) => {
1124
+ const recording = getRecordingByTabId(details.tabId);
1125
+ if (!recording) {
1126
+ return;
1127
+ }
1128
+
1129
+ sendRecordingEvent(recording.recordingId, details.tabId, {
1130
+ kind: "network_response_headers",
1131
+ source: "browser",
1132
+ request_id: details.requestId,
1133
+ url: details.url,
1134
+ method: details.method,
1135
+ resource_type: details.type,
1136
+ status_code: details.statusCode,
1137
+ headers: headersToArray(details.responseHeaders),
1138
+ });
1139
+
1140
+ if (typeof browser.webRequest.filterResponseData !== "function") {
1141
+ return;
1142
+ }
1143
+
1144
+ try {
1145
+ const filter = browser.webRequest.filterResponseData(details.requestId);
1146
+ const responseHeaders = headersToArray(details.responseHeaders);
1147
+ const contentType = getHeaderValue(responseHeaders, "content-type");
1148
+ const isTextLike = isTextLikeContentType(contentType);
1149
+ const chunks = [];
1150
+ let capturedBytes = 0;
1151
+ filter.ondata = (event) => {
1152
+ const chunk = new Uint8Array(event.data);
1153
+ if (capturedBytes < MAX_CAPTURE_BYTES) {
1154
+ const remaining = MAX_CAPTURE_BYTES - capturedBytes;
1155
+ chunks.push(chunk.subarray(0, remaining));
1156
+ capturedBytes += Math.min(chunk.length, remaining);
1157
+ }
1158
+ filter.write(event.data);
1159
+ };
1160
+ filter.onstop = () => {
1161
+ try {
1162
+ filter.disconnect();
1163
+ } catch {
1164
+ // ignore
1165
+ }
1166
+ if (chunks.length === 0) {
1167
+ return;
1168
+ }
1169
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
1170
+ const merged = new Uint8Array(total);
1171
+ let offset = 0;
1172
+ for (const chunk of chunks) {
1173
+ merged.set(chunk, offset);
1174
+ offset += chunk.length;
1175
+ }
1176
+ let bodyText = "";
1177
+ if (isTextLike) {
1178
+ try {
1179
+ bodyText = new TextDecoder("utf-8").decode(merged);
1180
+ } catch {
1181
+ bodyText = "";
1182
+ }
1183
+ }
1184
+ sendRecordingEvent(recording.recordingId, details.tabId, {
1185
+ kind: "network_response_body",
1186
+ source: "browser",
1187
+ request_id: details.requestId,
1188
+ url: details.url,
1189
+ ...(contentType ? { body_mime_type: contentType } : {}),
1190
+ body_text: bodyText,
1191
+ body_base64: bodyText ? undefined : bytesToBase64(merged),
1192
+ body_truncated: capturedBytes >= MAX_CAPTURE_BYTES,
1193
+ });
1194
+ };
1195
+ } catch {
1196
+ // ignore
1197
+ }
1198
+ },
1199
+ { urls: ["<all_urls>"] },
1200
+ ["responseHeaders", "blocking"],
1201
+ );
1202
+
1203
+ browser.webRequest.onCompleted.addListener(
1204
+ (details) => {
1205
+ const recording = getRecordingByTabId(details.tabId);
1206
+ if (!recording) {
1207
+ return;
1208
+ }
1209
+ sendRecordingEvent(recording.recordingId, details.tabId, {
1210
+ kind: "network_response_complete",
1211
+ source: "browser",
1212
+ request_id: details.requestId,
1213
+ url: details.url,
1214
+ method: details.method,
1215
+ resource_type: details.type,
1216
+ status_code: details.statusCode,
1217
+ });
1218
+ },
1219
+ { urls: ["<all_urls>"] },
1220
+ );
1221
+
1222
+ browser.webRequest.onErrorOccurred.addListener(
1223
+ (details) => {
1224
+ const recording = getRecordingByTabId(details.tabId);
1225
+ if (!recording) {
1226
+ return;
1227
+ }
1228
+ sendRecordingEvent(recording.recordingId, details.tabId, {
1229
+ kind: "network_error",
1230
+ source: "browser",
1231
+ request_id: details.requestId,
1232
+ url: details.url,
1233
+ method: details.method,
1234
+ resource_type: details.type,
1235
+ error: details.error,
1236
+ });
1237
+ },
1238
+ { urls: ["<all_urls>"] },
1239
+ );
1240
+
1241
+ void computeInstanceId();
1242
+ void connect();