From bc4c20929c84714ed904ca467e73611627b86c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 27 Feb 2026 08:27:43 +0000 Subject: [PATCH] Discord --- .env.example | 1 + bun.lock | 44 +- package.json | 1 + seed/tools/email-db/TOOL.md | 20 +- seed/tools/email-db/index.ts | 47 +- src/apps/officer-web/App.tsx | 1 + .../Dashboard/Browser/BrowserScreen.tsx | 38 + .../Screens/Dashboard/Browser/TabList.tsx | 177 ++++ .../Screens/Dashboard/Browser/TabPreview.tsx | 137 +++ .../Dashboard/Browser/defaultLayout.ts | 11 + .../Screens/Dashboard/Browser/index.tsx | 1 + .../Screens/Dashboard/Email/EmailList.tsx | 40 +- .../Screens/Dashboard/Layout/Dock.tsx | 3 +- .../IntegrationsSettings/DiscordAccount.tsx | 174 ++++ .../IntegrationsSettings/DiscordBotConfig.tsx | 237 +++++ .../Settings/IntegrationsSettings/index.tsx | 6 +- .../officer-web/Screens/Dashboard/index.tsx | 1 + src/databases/officer_db/src/index.ts | 1 + .../officer_db/src/queries/integrations.ts | 41 +- src/databases/officer_db/src/store.ts | 230 ----- src/extensions/browser-relay/SETUP.md | 45 + .../browser-relay/background-utils.js | 51 + src/extensions/browser-relay/background.js | 920 ++++++++++++++++++ .../browser-relay/icons/icon128.png | Bin 0 -> 614 bytes src/extensions/browser-relay/icons/icon16.png | Bin 0 -> 265 bytes src/extensions/browser-relay/icons/icon32.png | Bin 0 -> 318 bytes src/extensions/browser-relay/icons/icon48.png | Bin 0 -> 370 bytes src/extensions/browser-relay/manifest.json | 25 + .../browser-relay/options-validation.js | 66 ++ src/extensions/browser-relay/options.html | 209 ++++ src/extensions/browser-relay/options.js | 78 ++ src/server.tsx | 9 + src/servers/api/browser/cdp.ts | 99 ++ src/servers/api/browser/relay-auth.ts | 31 + src/servers/api/browser/relay-types.ts | 89 ++ src/servers/api/browser/relay.ts | 670 +++++++++++++ src/servers/api/browser/router.ts | 135 +++ src/servers/api/email/email.ts | 41 +- src/servers/bootstrap.ts | 5 + src/servers/channels/discord/bot.ts | 65 ++ src/servers/channels/discord/chunker.ts | 51 + src/servers/channels/discord/handler.ts | 276 ++++++ src/servers/channels/pairing.ts | 78 ++ src/servers/channels/routes.ts | 116 +++ src/servers/channels/send-and-await.ts | 325 +++++++ src/servers/channels/types.ts | 8 + src/servers/hono.ts | 4 + src/servers/queue/handlers/gmail-sync.ts | 12 +- 48 files changed, 4344 insertions(+), 275 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Browser/BrowserScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Browser/TabList.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Browser/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/Browser/index.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DiscordAccount.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DiscordBotConfig.tsx delete mode 100644 src/databases/officer_db/src/store.ts create mode 100644 src/extensions/browser-relay/SETUP.md create mode 100644 src/extensions/browser-relay/background-utils.js create mode 100644 src/extensions/browser-relay/background.js create mode 100644 src/extensions/browser-relay/icons/icon128.png create mode 100644 src/extensions/browser-relay/icons/icon16.png create mode 100644 src/extensions/browser-relay/icons/icon32.png create mode 100644 src/extensions/browser-relay/icons/icon48.png create mode 100644 src/extensions/browser-relay/manifest.json create mode 100644 src/extensions/browser-relay/options-validation.js create mode 100644 src/extensions/browser-relay/options.html create mode 100644 src/extensions/browser-relay/options.js create mode 100644 src/servers/api/browser/cdp.ts create mode 100644 src/servers/api/browser/relay-auth.ts create mode 100644 src/servers/api/browser/relay-types.ts create mode 100644 src/servers/api/browser/relay.ts create mode 100644 src/servers/api/browser/router.ts create mode 100644 src/servers/channels/discord/bot.ts create mode 100644 src/servers/channels/discord/chunker.ts create mode 100644 src/servers/channels/discord/handler.ts create mode 100644 src/servers/channels/pairing.ts create mode 100644 src/servers/channels/routes.ts create mode 100644 src/servers/channels/send-and-await.ts create mode 100644 src/servers/channels/types.ts diff --git a/.env.example b/.env.example index c3aa4894..301ea540 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,4 @@ MAIL_TRANSPORT="smtp://localhost:1025" PUBLIC_URL=http://localhost:9000 DATA_PATH=/path/to/data HOME_DIR=/home/user +BROWSER_RELAY_PORT=18792 diff --git a/bun.lock b/bun.lock index a34a4fef..e1e3bf57 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "cron": "^4.3.3", "date-fns": "^4.1.0", "definitions": "workspace:*", + "discord.js": "^14.25.1", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", "emailer": "workspace:*", @@ -217,6 +218,7 @@ "@react-email/code-block": "^0.0.11", "@react-email/components": "^0.0.31", "@react-email/render": "^1.0.3", + "officerdb": "workspace:*", "react-email": "^3.0.4", }, }, @@ -315,6 +317,18 @@ "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + "@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="], + + "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], + + "@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="], + + "@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="], + + "@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="], + + "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -751,6 +765,12 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], + + "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], + + "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], + "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], @@ -921,6 +941,8 @@ "@visual-json/react": ["@visual-json/react@0.1.1", "", { "dependencies": { "@visual-json/core": "0.1.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-KCpFYmBhH5F+xctfvxxVX7mVZK/iwvDwh/ITNkGPzEKXTIJQ16fau+hrG7m7QoTAOxvsdhHtHoCUiEjNHXboUQ=="], + "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], @@ -1133,6 +1155,10 @@ "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + "discord-api-types": ["discord-api-types@0.38.40", "", {}, "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ=="], + + "discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="], + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], @@ -1205,7 +1231,7 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - "fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -1417,6 +1443,10 @@ "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + + "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], + "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -1431,6 +1461,8 @@ "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], + "magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="], + "mailparser": ["mailparser@3.9.3", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.2", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.13", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ=="], "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], @@ -1959,6 +1991,8 @@ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], @@ -1975,6 +2009,8 @@ "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -2071,6 +2107,10 @@ "@babel/traverse/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + + "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -2191,6 +2231,8 @@ "react-email/glob": ["glob@10.3.4", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.0.3", "minimatch": "^9.0.1", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", "path-scurry": "^1.10.1" }, "bin": { "glob": "dist/cjs/src/bin.js" } }, "sha512-6LFElP3A+i/Q8XQKEvZjkEWEOTgAIALR9AO2rwT8bgPhDd1anmqDJDZ6lLddI4ehxxxR1S5RIqKe1uapMQfYaQ=="], + "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "refractor/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], diff --git a/package.json b/package.json index 52ca9b3e..0ef09c10 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "cron": "^4.3.3", "date-fns": "^4.1.0", "definitions": "workspace:*", + "discord.js": "^14.25.1", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", "emailer": "workspace:*", diff --git a/seed/tools/email-db/TOOL.md b/seed/tools/email-db/TOOL.md index 87511c41..28981678 100644 --- a/seed/tools/email-db/TOOL.md +++ b/seed/tools/email-db/TOOL.md @@ -2,7 +2,7 @@ version: 3 name: email_db label: Email Database -description: Query, search, aggregate, and manage the user's email database. Use this tool to answer questions about emails, find messages by sender/domain/date/content, get statistics, and delete emails. The database is a local SQLite copy of the user's synced Gmail inbox. +description: Query, search, aggregate, and manage the user's email database. Use this tool to answer questions about emails, find messages by sender/domain/date/content, get statistics, and delete emails. The database is a local SQLite copy of the user's synced Gmail. All actions default to inbox scope — use folder parameter to query other folders like sent, spam, trash, or 'all' for everything. language: typescript inputs: action: @@ -36,6 +36,10 @@ inputs: type: string description: "Attachment content type filter. Full MIME type (e.g. 'image/jpeg') or just the type prefix (e.g. 'image' matches all image types). Used with 'attachments' action." optional: true + folder: + type: string + description: "Gmail folder/label to scope results. Defaults to 'inbox'. Use 'all' for all folders. Common values: inbox, sent, spam, trash." + optional: true limit: type: number description: "Maximum number of results to return (default 20, max 100)." @@ -65,9 +69,9 @@ emails ( id TEXT PRIMARY KEY, integration TEXT, -- source: 'gmail', 'outlook', etc. email_account TEXT, -- which account: 'user@gmail.com' - from_name TEXT, - from_address TEXT, - from_domain TEXT, + from_name TEXT, -- sender display name + from_address TEXT, -- sender email (lowercase) + from_domain TEXT, -- domain extracted from sender to_address TEXT, cc TEXT, subject TEXT, @@ -77,7 +81,8 @@ emails ( text_body TEXT, attachment_count INTEGER, read INTEGER, - deleted INTEGER + deleted INTEGER, + labels TEXT -- comma-separated label list (e.g. 'INBOX,UNREAD,CATEGORY_UPDATES') ) attachments ( @@ -91,10 +96,13 @@ attachments ( ## Examples -- Search for invoices: `action: "search", search: "invoice"` +- Search for invoices (inbox only): `action: "search", search: "invoice"` +- Search sent emails for invoices: `action: "search", search: "invoice", folder: "sent"` +- Search all folders: `action: "search", search: "invoice", folder: "all"` - Count emails from a domain: `action: "count", domain: "newsletter.com"` - Delete all emails from a domain: `action: "delete", domain: "spam.com"` - Top 10 domains: `action: "domains", limit: 10` +- Stats for spam folder: `action: "stats", folder: "spam"` - List attachment types: `action: "attachment-types"` - Find image attachments: `action: "attachments", content_type: "image"` - Find PDFs from a sender: `action: "attachments", content_type: "application/pdf", sender: "boss@company.com"` diff --git a/seed/tools/email-db/index.ts b/seed/tools/email-db/index.ts index 95f6bd09..b2fcbadc 100644 --- a/seed/tools/email-db/index.ts +++ b/seed/tools/email-db/index.ts @@ -15,6 +15,7 @@ type Params = { before?: string; after?: string; content_type?: string; + folder?: string; limit?: number; }; @@ -72,9 +73,18 @@ function formatRows(rows: Record[], limit: number): string { // ── Helpers ── -function buildWhereClause(params: Params): string { +function buildFolderCondition(folder: string | undefined): string | null { + if (!folder || folder === 'all') return null; + return `labels LIKE ${sqlStr(`%${folder}%`)}`; +} + +function buildWhereClause(params: Params, defaultFolder?: string): string { const conditions: string[] = []; + const folder = params.folder ?? defaultFolder; + const folderCond = buildFolderCondition(folder); + if (folderCond) conditions.push(folderCond); + if (params.domain) { conditions.push(`from_domain = ${sqlStr(params.domain.toLowerCase())}`); } @@ -111,20 +121,21 @@ function runQuery(sql: string, limit: number): string { } function searchEmails(params: Params, limit: number): string { - const where = withActive(buildWhereClause(params)); + const where = withActive(buildWhereClause(params, 'inbox')); const rows = queryJson(`SELECT id, from_name, from_address, subject, date, snippet, attachment_count FROM emails ${where} ORDER BY date DESC LIMIT ${limit}`); return formatRows(rows, limit); } -function getStats(): string { - const totalRows = queryJson('SELECT COUNT(*) as count FROM emails WHERE deleted = 0'); +function getStats(params: Params): string { + const folderWhere = withActive(buildWhereClause({ action: 'stats', folder: params.folder }, 'inbox')); + const totalRows = queryJson(`SELECT COUNT(*) as count FROM emails ${folderWhere}`); const total = (totalRows[0]?.count as number) ?? 0; if (total === 0) return 'Email database is empty.'; - const dateRange = queryJson('SELECT MIN(date) as oldest, MAX(date) as newest FROM emails WHERE deleted = 0'); - const topDomains = queryJson('SELECT from_domain, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_domain ORDER BY count DESC LIMIT 10'); - const topSenders = queryJson('SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_address ORDER BY count DESC LIMIT 10'); - const attRows = queryJson('SELECT COUNT(*) as count FROM emails WHERE deleted = 0 AND attachment_count > 0'); + const dateRange = queryJson(`SELECT MIN(date) as oldest, MAX(date) as newest FROM emails ${folderWhere}`); + const topDomains = queryJson(`SELECT from_domain, COUNT(*) as count FROM emails ${folderWhere} GROUP BY from_domain ORDER BY count DESC LIMIT 10`); + const topSenders = queryJson(`SELECT from_address, from_name, COUNT(*) as count FROM emails ${folderWhere} GROUP BY from_address ORDER BY count DESC LIMIT 10`); + const attRows = queryJson(`SELECT COUNT(*) as count FROM emails ${folderWhere} AND attachment_count > 0`); const withAttachments = (attRows[0]?.count as number) ?? 0; const dr = dateRange[0] ?? {}; @@ -146,7 +157,7 @@ function getStats(): string { } function countEmails(params: Params): string { - const where = withActive(buildWhereClause(params)); + const where = withActive(buildWhereClause(params, 'inbox')); const rows = queryJson(`SELECT COUNT(*) as count FROM emails ${where}`); const count = (rows[0]?.count as number) ?? 0; @@ -161,16 +172,18 @@ function countEmails(params: Params): string { return `${count} email${count !== 1 ? 's' : ''}${desc}.`; } -function listDomains(limit: number): string { - const rows = queryJson(`SELECT from_domain, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_domain ORDER BY count DESC LIMIT ${limit}`); +function listDomains(params: Params, limit: number): string { + const where = withActive(buildWhereClause({ action: 'domains', folder: params.folder }, 'inbox')); + const rows = queryJson(`SELECT from_domain, COUNT(*) as count FROM emails ${where} GROUP BY from_domain ORDER BY count DESC LIMIT ${limit}`); if (rows.length === 0) return 'No emails in database.'; const lines = rows.map((d, i) => `${i + 1}. ${d.from_domain} — ${d.count} email${(d.count as number) !== 1 ? 's' : ''}`); return [`**Sender Domains** (${rows.length}):`, '', ...lines].join('\n'); } -function listSenders(limit: number): string { - const rows = queryJson(`SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_address ORDER BY count DESC LIMIT ${limit}`); +function listSenders(params: Params, limit: number): string { + const where = withActive(buildWhereClause({ action: 'senders', folder: params.folder }, 'inbox')); + const rows = queryJson(`SELECT from_address, from_name, COUNT(*) as count FROM emails ${where} GROUP BY from_address ORDER BY count DESC LIMIT ${limit}`); if (rows.length === 0) return 'No emails in database.'; const lines = rows.map((s, i) => { @@ -217,7 +230,7 @@ function searchAttachments(params: Params, limit: number): string { } function deleteEmails(params: Params): string { - const where = buildWhereClause(params); + const where = buildWhereClause(params, 'inbox'); if (!where) { throw new Error('Delete requires at least one filter (domain, sender, before, after, or search).'); } @@ -254,16 +267,16 @@ export async function execute(_toolCallId: string, params: Params): Promise} /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/BrowserScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Browser/BrowserScreen.tsx new file mode 100644 index 00000000..9535c6ae --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Browser/BrowserScreen.tsx @@ -0,0 +1,38 @@ +import { useMemo, useCallback } from 'react'; +import type { LayoutNode, PanelComponents } from 'officerdev'; +import { WorkspaceView } from 'officerdev'; +import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useIsMobile } from 'hooks/useIsMobile'; +import { useGlobal } from 'hooks/useGlobal'; +import { defaultLayout } from './defaultLayout'; +import { TabList } from './TabList'; +import { TabPreview } from './TabPreview'; + +export const BrowserScreen = () => { + const isMobile = useIsMobile(); + const [selectedId, setSelectedId] = useGlobal('BROWSER_SELECTED_TAB', null); + const workspace = useWorkspacesState('screens/browser', defaultLayout); + + const components: PanelComponents = useMemo( + () => ({ + 'browser-tabs': TabList, + 'browser-preview': TabPreview, + }), + [], + ); + + const mobilePanelId = isMobile && selectedId ? 'browser-preview' : undefined; + const onMobileBack = useCallback(() => setSelectedId(null), [setSelectedId]); + + return ( +
+ onMobileBack() : undefined} + /> +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/TabList.tsx b/src/apps/officer-web/Screens/Dashboard/Browser/TabList.tsx new file mode 100644 index 00000000..3df4b217 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Browser/TabList.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Globe, Copy, Check, Loader2, X, Eye } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { useClient } from 'hooks/useClient'; +import { useGlobal } from 'hooks/useGlobal'; + +type RelayToken = { + token: string; + port: number; +}; + +type Target = { + id: string; + sessionId: string; + type: string; + title: string; + url: string; +}; + +export const TabList = () => { + const client = useClient(); + const [selectedId, setSelectedId] = useGlobal('BROWSER_SELECTED_TAB', null); + const [copied, setCopied] = useState(false); + + const { data: status } = useQuery({ + queryKey: ['browser-status'], + queryFn: () => client.get<{ extensionConnected: boolean; targetCount: number }>('/browser/status'), + refetchInterval: 5000, + }); + + const { data: tokenData } = useQuery({ + queryKey: ['browser-relay-token'], + queryFn: () => client.get('/browser/relay-token'), + }); + + const { data: targets, isLoading } = useQuery({ + queryKey: ['browser-targets'], + queryFn: () => client.get('/browser/targets'), + refetchInterval: 3000, + enabled: !!status?.extensionConnected, + }); + + const targetList: Target[] = targets ?? []; + + const handleCopyToken = async () => { + if (!tokenData?.token) return; + try { + await navigator.clipboard.writeText(tokenData.token); + setCopied(true); + toast.success('Token copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error('Failed to copy'); + } + }; + + const handleClose = async (targetId: string) => { + try { + await client.post(`/browser/targets/${targetId}/close`); + } catch { + toast.error('Failed to close tab'); + } + }; + + const handleActivate = async (targetId: string) => { + try { + await client.post(`/browser/targets/${targetId}/activate`); + } catch { + toast.error('Failed to activate tab'); + } + }; + + const isConnected = status?.extensionConnected ?? false; + + return ( +
+
+ + Browser +
+
+ + {!isConnected && ( +
+

Connect the Officer Browser Relay extension to start.

+ + {tokenData && ( +
+
+ Server address + {window.location.hostname} +
+
+ Relay port + {tokenData.port} +
+
+ Relay token + +
+
+ )} + +
    +
  1. Load the extension from src/extensions/browser-relay/
  2. +
  3. Open extension options
  4. +
  5. Enter the server address, port, and token
  6. +
  7. Click the extension icon on a tab
  8. +
+
+ )} + + {isConnected && isLoading && ( +
+ +
+ )} + + {isConnected && !isLoading && targetList.length === 0 && ( +
+ + No tabs attached + Click the extension icon on a tab +
+ )} + + {isConnected && targetList.length > 0 && ( +
+ {targetList.map((target) => ( + + +
+
+ {target.url} + + ))} +
+ )} + + ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx b/src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx new file mode 100644 index 00000000..495c9535 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx @@ -0,0 +1,137 @@ +import { useState, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Globe, RefreshCw, Send, Terminal, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { useClient } from 'hooks/useClient'; +import { useGlobal } from 'hooks/useGlobal'; + +export const TabPreview = () => { + const client = useClient(); + const [selectedId] = useGlobal('BROWSER_SELECTED_TAB', null); + const [navUrl, setNavUrl] = useState(''); + const [evalExpr, setEvalExpr] = useState(''); + const [evalResult, setEvalResult] = useState(null); + const [isNavigating, setIsNavigating] = useState(false); + const [isEvaluating, setIsEvaluating] = useState(false); + + const { + data: screenshot, + isLoading, + refetch, + isFetching, + } = useQuery({ + queryKey: ['browser-screenshot', selectedId], + queryFn: () => client.get<{ data: string; format: string }>(`/browser/targets/${selectedId}/screenshot`), + enabled: !!selectedId, + refetchInterval: 10000, + }); + + const handleNavigate = useCallback(async () => { + if (!selectedId || !navUrl.trim()) return; + setIsNavigating(true); + try { + await client.post(`/browser/targets/${selectedId}/navigate`, { url: navUrl.trim() }); + setTimeout(() => void refetch(), 1000); + } catch { + toast.error('Navigation failed'); + } finally { + setIsNavigating(false); + } + }, [client, selectedId, navUrl, refetch]); + + const handleEvaluate = useCallback(async () => { + if (!selectedId || !evalExpr.trim()) return; + setIsEvaluating(true); + try { + const res = await client.post<{ result: unknown }>(`/browser/targets/${selectedId}/evaluate`, { + expression: evalExpr.trim(), + }); + setEvalResult(JSON.stringify(res.result, null, 2)); + } catch (err) { + setEvalResult(`Error: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setIsEvaluating(false); + } + }, [client, selectedId, evalExpr]); + + if (!selectedId) { + return ( +
+ + Select a tab to preview +
+ ); + } + + return ( +
+ {/* URL bar */} +
+ +
{ + ev.preventDefault(); + void handleNavigate(); + }} + > + setNavUrl(ev.target.value)} + placeholder="Navigate to URL..." + className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500" + /> + +
+
+ + {/* Screenshot */} +
+ {isLoading && !screenshot && ( +
+ +
+ )} + {screenshot && ( + Tab screenshot + )} +
+ + {/* JS Console */} +
+
{ + ev.preventDefault(); + void handleEvaluate(); + }} + > + + setEvalExpr(ev.target.value)} + placeholder="Evaluate JavaScript..." + className="flex-1 bg-transparent text-sm outline-none font-mono" + /> + + + {evalResult !== null && ( +
{evalResult}
+ )} +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Browser/defaultLayout.ts new file mode 100644 index 00000000..5cfd4b95 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Browser/defaultLayout.ts @@ -0,0 +1,11 @@ +import type { LayoutNode } from 'officerdev'; + +export const defaultLayout: LayoutNode = { + type: 'group', + id: 'browser-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'browser-tabs', appType: null }, size: 25 }, + { node: { type: 'panel', id: 'browser-preview', appType: null }, size: 75 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/index.tsx b/src/apps/officer-web/Screens/Dashboard/Browser/index.tsx new file mode 100644 index 00000000..d31dc96a --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Browser/index.tsx @@ -0,0 +1 @@ +export { BrowserScreen } from './BrowserScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx index f1166a3d..40d8aa8e 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Link } from 'react-router'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { ChevronLeft, ChevronRight, Loader2, Mail, Paperclip, RefreshCw } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Send, ShieldAlert, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { useClient } from 'hooks/useClient'; @@ -18,6 +18,13 @@ type GoogleStatus = { const LIMIT = 50; +const FOLDERS = [ + { key: 'inbox', label: 'Inbox', icon: Inbox }, + { key: 'sent', label: 'Sent', icon: Send }, + { key: 'spam', label: 'Spam', icon: ShieldAlert }, + { key: 'trash', label: 'Trash', icon: Trash2 }, +] as const; + const formatDate = (iso: string) => { const date = new Date(iso); const now = new Date(); @@ -32,6 +39,7 @@ export const EmailList = () => { const client = useClient(); const queryClient = useQueryClient(); const [selectedId, setSelectedId] = useGlobal('EMAIL_SELECTED', null); + const [folder, setFolder] = useGlobal('EMAIL_FOLDER', 'inbox'); const [page, setPage] = useState(1); const { jobs, createJob } = useJobs({ type: 'gmail-sync' }); const isSyncing = jobs.some((j) => j.status === 'queued' || j.status === 'running'); @@ -42,11 +50,16 @@ export const EmailList = () => { }); const { data, isLoading } = useQuery({ - queryKey: ['email-messages', page], + queryKey: ['email-messages', page, folder], queryFn: () => - client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}`), + client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`), }); + const handleFolderChange = (newFolder: string) => { + setFolder(newFolder); + setPage(1); + }; + const handleSync = async () => { try { await createJob({ lane: 'google-api', type: 'gmail-sync', notify: false }); @@ -106,8 +119,25 @@ export const EmailList = () => { return (
- - Inbox +
+ {FOLDERS.map((f) => { + const Icon = f.icon; + const isActive = folder === f.key; + return ( + + ); + })} +
{total} +
+ ); + } + + return ( +
+

+ Link your Discord account to chat with your PI agent via direct messages. +

+ + {connectionInfo} + + {pairingCode ? ( +
+
+ + {pairingCode} + + +
+

+ Send this code as a direct message to the bot on Discord. Expires in 10 minutes. +

+ +
+ ) : ( + + )} +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DiscordBotConfig.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DiscordBotConfig.tsx new file mode 100644 index 00000000..e540e9a2 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DiscordBotConfig.tsx @@ -0,0 +1,237 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { ChevronDown } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; +import { useClient } from 'hooks/useClient'; + +type DiscordConfig = { + configured: boolean; + enabled: boolean; + botToken: string | null; + serverInvite: string | null; + botHandle: string | null; +}; + +type DiscordStatus = { + configured: boolean; + enabled: boolean; + running: boolean; + botUsername: string | null; +}; + +const SetupGuide = () => { + const [open, setOpen] = useState(false); + + return ( + + + + Step-by-step setup guide + + +
    +
  1. + Create a Discord application +

    + Go to the{' '} + + Discord Developer Portal + {' '} + and click New Application. +

    +
      +
    • Fill in Name (e.g. "Officer"), optionally a Description and Tags
    • +
    • Leave everything else empty — Interactions Endpoint URL, Linked Roles Verification URL, and Terms of Service / Privacy Policy URLs are not needed
    • +
    +

    Click Create.

    +

    + You will see an Application ID and Public Key on this page — you can ignore both. Our bot connects via the gateway (WebSocket), not webhooks, so these are not used. +

    +
  2. + +
  3. + Go to the Bot page +

    + In the left sidebar, click Bot. A bot user is created automatically with your application. + You can optionally set a custom username and avatar here — this is what users will see when they DM the bot. +

    +
  4. + +
  5. + Enable privileged intents +

    + Still on the Bot page, scroll down to Privileged Gateway Intents and enable: +

    +
      +
    • Presence Intent — shows the bot as online in your server
    • +
    • Server Members Intent — makes the bot visible in the member list so users can find and DM it
    • +
    • Message Content Intent — required to read DM message text
    • +
    +

    Click Save Changes.

    +
  6. + +
  7. + Copy the bot token +

    + On the Bot page, click Reset Token (or View Token if this is a new bot). + Copy the token — you will only see it once. +

    +

    + Keep this token secret. Anyone with this token can control your bot. If it leaks, reset it immediately from this page. +

    +
  8. + +
  9. + Invite the bot to your server (optional) +

    + Open this URL in your browser, replacing{' '} + YOUR_APP_ID{' '} + with the Application ID from the General Information page: +

    + + https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot&permissions=0 + +

    + Select your server and click Authorize. No bot permissions are needed — leave them at zero. +

    +

    + Adding the bot to a shared server is optional but has a few advantages: +

    +
      +
    • Users can find the bot in the member list and right-click → Message to start a DM
    • +
    • The bot shows as online in the server, so users can see at a glance whether it's running
    • +
    • Easier onboarding — you can pin the pairing instructions in a channel for your team
    • +
    +

    + Without a shared server, users can still DM the bot by searching its exact username — but a shared server makes discovery much simpler. +

    +
  10. + +
  11. + Paste the token below and save +

    + Paste the bot token into the field below and click Save. + The bot will come online automatically. Users can then link their Discord accounts from their personal integration settings. +

    +
  12. +
+
+
+ ); +}; + +export const DiscordBotConfig = () => { + const client = useClient(); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [botToken, setBotToken] = useState(''); + const [serverInvite, setServerInvite] = useState(''); + const [botHandle, setBotHandle] = useState(''); + const [status, setStatus] = useState(null); + + const fetchStatus = () => { + client + .get('/channels/discord/status') + .then(setStatus) + .catch(() => {}); + }; + + useEffect(() => { + Promise.all([ + client.get('/channels/discord/config').then((data) => { + if (data.botToken) setBotToken(data.botToken); + if (data.serverInvite) setServerInvite(data.serverInvite); + if (data.botHandle) setBotHandle(data.botHandle); + }), + client.get('/channels/discord/status').then(setStatus), + ]) + .catch(() => {}) + .finally(() => setIsLoading(false)); + }, []); + + const handleSave = async () => { + if (isSaving) return; + setIsSaving(true); + try { + await client.put('/channels/discord/config', { + botToken: botToken.trim(), + serverInvite: serverInvite.trim() || undefined, + botHandle: botHandle.trim() || undefined, + }); + toast.success('Discord bot configuration saved'); + fetchStatus(); + } catch { + toast.error('Failed to save Discord bot configuration'); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) return null; + + return ( +
+ {status && ( +
+
+ + {status.running ? `Online as ${status.botUsername}` : status.configured ? 'Bot configured but not running' : 'Not configured'} + +
+ )} + + + +
+ + + + + + + +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx index 53f7e256..149fee53 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { Puzzle, KeyRound, UserCircle } from 'lucide-react'; +import { Puzzle, KeyRound, UserCircle, MessageCircle } from 'lucide-react'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { WorkspaceLayout } from 'officerdev'; import { useAuth } from 'hooks/useAuth'; @@ -9,16 +9,20 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel'; import { GoogleOAuthConfig } from './GoogleOAuthConfig'; import { GoogleAccount } from './GoogleAccount'; +import { DiscordBotConfig } from './DiscordBotConfig'; +import { DiscordAccount } from './DiscordAccount'; const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED'; const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB'; const enterpriseSections: SettingsSection[] = [ { key: 'google-oauth', icon: KeyRound, title: 'Google OAuth', description: 'Client ID and secret for Google APIs', content: }, + { key: 'discord-bot', icon: MessageCircle, title: 'Discord Bot', description: 'Bot token for Discord integration', content: }, ]; const personalSections: SettingsSection[] = [ { key: 'google-account', icon: UserCircle, title: 'Google Account', description: 'Connect your Google account', content: }, + { key: 'discord-account', icon: MessageCircle, title: 'Discord', description: 'Link your Discord account', content: }, ]; const IntegrationsSidebar = () => { diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 2502824a..8661ad14 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -17,3 +17,4 @@ export * from './Workspaces'; export * from './Projects'; export * from './Terminal'; export * from './Email'; +export * from './Browser'; diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 08295415..017fd82c 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -31,6 +31,7 @@ export { getUserIntegration, upsertUserIntegration, deleteUserIntegration, + findUserByIntegrationConfig, } from './queries/integrations'; export { db } from './db'; diff --git a/src/databases/officer_db/src/queries/integrations.ts b/src/databases/officer_db/src/queries/integrations.ts index 4cd9158e..6dca03f0 100644 --- a/src/databases/officer_db/src/queries/integrations.ts +++ b/src/databases/officer_db/src/queries/integrations.ts @@ -1,6 +1,7 @@ -import { eq, and } from 'drizzle-orm'; +import { eq, and, sql } from 'drizzle-orm'; import { db } from '../db'; import { serverIntegrations, userIntegrations } from '../schema'; +import { users } from '../schema/auth'; import type { ServerIntegrationSelect, UserIntegrationSelect } from '../types'; // ── Server Integrations ── @@ -71,3 +72,41 @@ export async function deleteUserIntegration(userId: number, provider: string): P .returning({ id: userIntegrations.id }); return result.length > 0; } + +// ── Cross-table lookup ── + +type UserIntegrationWithUser = UserIntegrationSelect & { + user: { id: number; email: string; username: string | null; role: string }; +}; + +export async function findUserByIntegrationConfig( + provider: string, + configKey: string, + configValue: string, +): Promise { + const [row] = await db + .select({ + id: userIntegrations.id, + userId: userIntegrations.userId, + provider: userIntegrations.provider, + serverIntegrationId: userIntegrations.serverIntegrationId, + config: userIntegrations.config, + createdAt: userIntegrations.createdAt, + updatedAt: userIntegrations.updatedAt, + user: { + id: users.id, + email: users.email, + username: users.username, + role: users.role, + }, + }) + .from(userIntegrations) + .innerJoin(users, eq(userIntegrations.userId, users.id)) + .where( + and( + eq(userIntegrations.provider, provider), + sql`${userIntegrations.config}->>${configKey} = ${configValue}`, + ), + ); + return row as UserIntegrationWithUser | undefined; +} diff --git a/src/databases/officer_db/src/store.ts b/src/databases/officer_db/src/store.ts deleted file mode 100644 index fe02bcb9..00000000 --- a/src/databases/officer_db/src/store.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { join } from 'node:path'; -import { mkdir } from 'node:fs/promises'; -import type { UserSelect, UserInsert, PasskeySelect, PasskeyInsert } from './types'; - -type PasskeyChallenge = { - email: string; - origin: string; - challenge: string; - createdAt: number; -}; - -type TokenBlacklistEntry = { - jti: string; - expiresAt: number; -}; - -type Meta = { - nextUserId: number; - nextPasskeyId: number; -}; - -const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); -const AUTH_DIR = join(DATA_PATH, 'auth'); - -const files = { - users: join(AUTH_DIR, 'users.json'), - passkeys: join(AUTH_DIR, 'passkeys.json'), - challenges: join(AUTH_DIR, 'passkey-challenges.json'), - blacklist: join(AUTH_DIR, 'token-blacklist.json'), - meta: join(AUTH_DIR, 'meta.json'), -}; - -let users: UserSelect[] = []; -let passkeys: PasskeySelect[] = []; -let challenges: PasskeyChallenge[] = []; -let blacklist: TokenBlacklistEntry[] = []; -let meta: Meta = { nextUserId: 1, nextPasskeyId: 1 }; - -async function readJson(path: string, fallback: T): Promise { - try { - const file = Bun.file(path); - if (!(await file.exists())) return fallback; - return (await file.json()) as T; - } catch { - return fallback; - } -} - -const writeJson = (path: string, data: unknown) => Bun.write(path, JSON.stringify(data, null, 2)); - -async function flushUsers() { - await writeJson(files.users, users); -} - -async function flushPasskeys() { - await writeJson(files.passkeys, passkeys); -} - -async function flushChallenges() { - await writeJson(files.challenges, challenges); -} - -async function flushBlacklist() { - await writeJson(files.blacklist, blacklist); -} - -async function flushMeta() { - await writeJson(files.meta, meta); -} - -// ── Lifecycle ── - -export async function initAuthStore() { - await mkdir(AUTH_DIR, { recursive: true }); - users = await readJson(files.users, []); - passkeys = await readJson(files.passkeys, []); - challenges = await readJson(files.challenges, []); - blacklist = await readJson(files.blacklist, []); - meta = await readJson(files.meta, { nextUserId: 1, nextPasskeyId: 1 }); - - // Reconcile meta with existing data - const maxUserId = users.reduce((max, u) => Math.max(max, u.id), 0); - const maxPasskeyId = passkeys.reduce((max, p) => Math.max(max, p.id), 0); - if (meta.nextUserId <= maxUserId) meta.nextUserId = maxUserId + 1; - if (meta.nextPasskeyId <= maxPasskeyId) meta.nextPasskeyId = maxPasskeyId + 1; -} - -// ── Users ── - -export function getUsers(): UserSelect[] { - return users; -} - -export function getUserById(id: number): UserSelect | undefined { - return users.find((u) => u.id === id); -} - -export function getUserByEmail(email: string): UserSelect | undefined { - return users.find((u) => u.email === email); -} - -export function getUserCount(): number { - return users.length; -} - -export async function createUser(data: UserInsert): Promise { - const id = meta.nextUserId++; - const user: UserSelect = { - id, - email: data.email, - password: data.password ?? null, - role: data.role ?? 'Member', - status: data.status ?? 'Unverified', - name: data.name ?? null, - username: data.username ?? null, - avatar: data.avatar ?? null, - passwordChangedAt: data.passwordChangedAt ?? null, - }; - users.push(user); - await Promise.all([flushUsers(), flushMeta()]); - return user; -} - -export async function updateUser(id: number, data: Partial>): Promise { - const idx = users.findIndex((u) => u.id === id); - if (idx === -1) return undefined; - users[idx] = { ...users[idx]!, ...data }; - await flushUsers(); - return users[idx]; -} - -export async function deleteUser(id: number): Promise { - const idx = users.findIndex((u) => u.id === id); - if (idx === -1) return false; - users.splice(idx, 1); - await flushUsers(); - return true; -} - -// ── Passkeys ── - -export function getPasskeysByEmail(email: string): PasskeySelect[] { - return passkeys.filter((p) => p.email === email); -} - -export function getPasskeysByEmailAndOrigin(email: string, origin: string): PasskeySelect[] { - return passkeys.filter((p) => p.email === email && p.origin === origin); -} - -export function getPasskeyByCredentialId(email: string, credentialId: string): PasskeySelect | undefined { - return passkeys.find((p) => p.email === email && p.credentialId === credentialId); -} - -export async function createPasskey(data: PasskeyInsert): Promise { - const id = meta.nextPasskeyId++; - const passkey: PasskeySelect = { - id, - email: data.email, - origin: data.origin ?? null, - credentialId: data.credentialId ?? null, - publicKey: data.publicKey ?? null, - counter: data.counter ?? 0, - }; - passkeys.push(passkey); - await Promise.all([flushPasskeys(), flushMeta()]); - return passkey; -} - -export async function updatePasskey( - id: number, - data: Partial>, -): Promise { - const idx = passkeys.findIndex((p) => p.id === id); - if (idx === -1) return undefined; - passkeys[idx] = { ...passkeys[idx]!, ...data }; - await flushPasskeys(); - return passkeys[idx]; -} - -// ── Passkey Challenges ── - -export async function storeChallenge(email: string, origin: string, challenge: string) { - const idx = challenges.findIndex((c) => c.email === email && c.origin === origin); - const entry: PasskeyChallenge = { email, origin, challenge, createdAt: Date.now() }; - if (idx !== -1) { - challenges[idx] = entry; - } else { - challenges.push(entry); - } - await flushChallenges(); -} - -export async function consumeChallenge(email: string, origin: string, ttlMs: number): Promise { - const now = Date.now(); - - // Remove expired challenges - challenges = challenges.filter((c) => now - c.createdAt < ttlMs); - - const idx = challenges.findIndex((c) => c.email === email && c.origin === origin); - if (idx === -1) { - await flushChallenges(); - return null; - } - - const entry = challenges[idx]!; - challenges.splice(idx, 1); - await flushChallenges(); - - if (now - entry.createdAt >= ttlMs) return null; - return entry.challenge; -} - -// ── Token Blacklist ── - -export async function blacklistToken(jti: string, expiresAt: number) { - if (blacklist.some((b) => b.jti === jti)) return; - blacklist.push({ jti, expiresAt }); - await flushBlacklist(); -} - -export function isTokenBlacklisted(jti: string): boolean { - return blacklist.some((b) => b.jti === jti); -} - -export async function cleanupExpiredTokens() { - const now = Math.floor(Date.now() / 1000); - const before = blacklist.length; - blacklist = blacklist.filter((b) => b.expiresAt >= now); - if (blacklist.length !== before) await flushBlacklist(); -} diff --git a/src/extensions/browser-relay/SETUP.md b/src/extensions/browser-relay/SETUP.md new file mode 100644 index 00000000..c69268e0 --- /dev/null +++ b/src/extensions/browser-relay/SETUP.md @@ -0,0 +1,45 @@ +# Officer Browser Relay — Setup Guide + +## Prerequisites +- Officer server running (`bun dev`) +- Google Chrome or Chromium-based browser on your machine +- Both machines on the same Tailscale network (or localhost for local dev) + +## Steps + +### 1. Verify relay is running +Check the server logs for `[browser-relay] listening on port 18792`. + +### 2. Get your relay credentials +Open Officer dashboard → **Browser** screen (or `/browser`). +The setup card shows your **server address**, **port**, and **relay token**. + +### 3. Load the extension in Chrome +1. Open `chrome://extensions` +2. Enable **Developer mode** (top-right toggle) +3. Click **Load unpacked** +4. Select `src/extensions/browser-relay/` + +### 4. Configure the extension +1. The options page opens automatically after install (or right-click extension → Options) +2. Enter the **Server address** (Officer's IP — e.g. your Tailscale IP, or `127.0.0.1` for local) +3. Enter the **Port** (default `18792`) +4. Paste your **Relay token** from step 2 +5. Click **Save** — status should show green "Relay reachable and authenticated" + +### 5. Attach a tab +1. Navigate to any webpage in Chrome +2. Click the Officer Browser Relay extension icon in the toolbar +3. Badge shows **ON** (cyan) = attached + +### 6. Use from the dashboard +1. Open `/browser` in Officer +2. Your attached tab appears in the left panel +3. Click it to see a live screenshot in the right panel +4. Use the URL bar to navigate, or the JS console to evaluate expressions + +## Troubleshooting +- **Red `!` badge**: Relay not reachable — check server address/port/token +- **`…` badge**: Reconnecting — relay was temporarily unavailable +- **Tab not appearing**: Make sure the extension badge shows ON, and refresh the dashboard +- **Screenshot blank**: The tab may be on a `chrome://` page (not supported by CDP) diff --git a/src/extensions/browser-relay/background-utils.js b/src/extensions/browser-relay/background-utils.js new file mode 100644 index 00000000..629f67f8 --- /dev/null +++ b/src/extensions/browser-relay/background-utils.js @@ -0,0 +1,51 @@ +// Based on OpenClaw Browser Extension Relay (MIT License) +// https://github.com/nicepkg/openclaw + +export function reconnectDelayMs( + attempt, + opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random }, +) { + const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000; + const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000; + const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000; + const random = typeof opts.random === "function" ? opts.random : Math.random; + const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0); + const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs); + return backoff + Math.max(0, jitterMs) * random(); +} + +export async function deriveRelayToken(gatewayToken, port) { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + enc.encode(gatewayToken), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + enc.encode(`officer-browser-relay-v1:${port}`), + ); + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export async function buildRelayWsUrl(host, port, gatewayToken) { + const token = String(gatewayToken || "").trim(); + if (!token) { + throw new Error( + "Missing relay token in extension settings (chrome.storage.local.relayToken)", + ); + } + const relayToken = await deriveRelayToken(token, port); + return `ws://${host}:${port}/extension?token=${encodeURIComponent(relayToken)}`; +} + +export function isRetryableReconnectError(err) { + const message = err instanceof Error ? err.message : String(err || ""); + if (message.includes("Missing relay token")) { + return false; + } + return true; +} diff --git a/src/extensions/browser-relay/background.js b/src/extensions/browser-relay/background.js new file mode 100644 index 00000000..1418aeea --- /dev/null +++ b/src/extensions/browser-relay/background.js @@ -0,0 +1,920 @@ +// Based on OpenClaw Browser Extension Relay (MIT License) +// https://github.com/nicepkg/openclaw + +import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' + +const DEFAULT_PORT = 18792 +const DEFAULT_HOST = '127.0.0.1' + +const BADGE = { + on: { text: 'ON', color: '#06b6d4' }, + off: { text: '', color: '#000000' }, + connecting: { text: '…', color: '#F59E0B' }, + error: { text: '!', color: '#B91C1C' }, +} + +/** @type {WebSocket|null} */ +let relayWs = null +/** @type {Promise|null} */ +let relayConnectPromise = null +let relayGatewayToken = '' +/** @type {string|null} */ +let relayConnectRequestId = null + +let nextSession = 1 + +/** @type {Map} */ +const tabs = new Map() +/** @type {Map} */ +const tabBySession = new Map() +/** @type {Map} */ +const childSessionToTab = new Map() + +/** @type {Mapvoid, reject:(e:Error)=>void}>} */ +const pending = new Map() + +/** @type {Set} */ +const tabOperationLocks = new Set() + +/** @type {Set} */ +const reattachPending = new Set() + +let reconnectAttempt = 0 +let reconnectTimer = null + +async function getRelayPort() { + const stored = await chrome.storage.local.get(['relayPort']) + const raw = stored.relayPort + const n = Number.parseInt(String(raw || ''), 10) + if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT + return n +} + +async function getRelayHost() { + const stored = await chrome.storage.local.get(['relayHost']) + const host = String(stored.relayHost || '').trim() + return host || DEFAULT_HOST +} + +async function getRelayToken() { + const stored = await chrome.storage.local.get(['relayToken']) + const token = String(stored.relayToken || '').trim() + return token || '' +} + +function setBadge(tabId, kind) { + const cfg = BADGE[kind] + void chrome.action.setBadgeText({ tabId, text: cfg.text }) + void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color }) + void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {}) +} + +async function persistState() { + try { + const tabEntries = [] + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected' && tab.sessionId && tab.targetId) { + tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder }) + } + } + await chrome.storage.session.set({ + persistedTabs: tabEntries, + nextSession, + }) + } catch { + // chrome.storage.session may not be available in all contexts. + } +} + +async function rehydrateState() { + try { + const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession']) + if (stored.nextSession) { + nextSession = Math.max(nextSession, stored.nextSession) + } + const entries = stored.persistedTabs || [] + for (const entry of entries) { + tabs.set(entry.tabId, { + state: 'connected', + sessionId: entry.sessionId, + targetId: entry.targetId, + attachOrder: entry.attachOrder, + }) + tabBySession.set(entry.sessionId, entry.tabId) + setBadge(entry.tabId, 'on') + } + for (const entry of entries) { + try { + await chrome.tabs.get(entry.tabId) + await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }) + } catch { + tabs.delete(entry.tabId) + tabBySession.delete(entry.sessionId) + setBadge(entry.tabId, 'off') + } + } + } catch { + // Ignore rehydration errors. + } +} + +async function ensureRelayConnection() { + if (relayWs && relayWs.readyState === WebSocket.OPEN) return + if (relayConnectPromise) return await relayConnectPromise + + relayConnectPromise = (async () => { + const port = await getRelayPort() + const host = await getRelayHost() + const token = await getRelayToken() + const httpBase = `http://${host}:${port}` + const wsUrl = await buildRelayWsUrl(host, port, token) + + try { + await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) }) + } catch (err) { + throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) + } + + const ws = new WebSocket(wsUrl) + relayWs = ws + relayGatewayToken = token + ws.onmessage = (event) => { + if (ws !== relayWs) return + void whenReady(() => onRelayMessage(String(event.data || ''))) + } + + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000) + ws.onopen = () => { + clearTimeout(t) + resolve() + } + ws.onerror = () => { + clearTimeout(t) + reject(new Error('WebSocket connect failed')) + } + ws.onclose = (ev) => { + clearTimeout(t) + reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`)) + } + }) + + ws.onclose = () => { + if (ws !== relayWs) return + onRelayClosed('closed') + } + ws.onerror = () => { + if (ws !== relayWs) return + onRelayClosed('error') + } + })() + + try { + await relayConnectPromise + reconnectAttempt = 0 + } finally { + relayConnectPromise = null + } +} + +function onRelayClosed(reason) { + relayWs = null + relayGatewayToken = '' + relayConnectRequestId = null + + for (const [id, p] of pending.entries()) { + pending.delete(id) + p.reject(new Error(`Relay disconnected (${reason})`)) + } + + reattachPending.clear() + + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected') { + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'Officer Browser Relay: relay reconnecting…', + }) + } + } + + scheduleReconnect() +} + +function scheduleReconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + const delay = reconnectDelayMs(reconnectAttempt) + reconnectAttempt++ + + console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`) + + reconnectTimer = setTimeout(async () => { + reconnectTimer = null + try { + await ensureRelayConnection() + reconnectAttempt = 0 + console.log('Reconnected successfully') + await reannounceAttachedTabs() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`) + if (!isRetryableReconnectError(err)) { + return + } + scheduleReconnect() + } + }, delay) +} + +function cancelReconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + reconnectAttempt = 0 +} + +async function reannounceAttachedTabs() { + for (const [tabId, tab] of tabs.entries()) { + if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue + + try { + await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }) + } catch { + tabs.delete(tabId) + if (tab.sessionId) tabBySession.delete(tab.sessionId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'Officer Browser Relay (click to attach/detach)', + }) + continue + } + + try { + const info = /** @type {any} */ ( + await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo') + ) + const targetInfo = info?.targetInfo + + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.attachedToTarget', + params: { + sessionId: tab.sessionId, + targetInfo: { ...targetInfo, attached: true }, + waitingForDebugger: false, + }, + }, + }) + + setBadge(tabId, 'on') + void chrome.action.setTitle({ + tabId, + title: 'Officer Browser Relay: attached (click to detach)', + }) + } catch { + setBadge(tabId, 'on') + } + } + + await persistState() +} + +function sendToRelay(payload) { + const ws = relayWs + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error('Relay not connected') + } + ws.send(JSON.stringify(payload)) +} + +function ensureGatewayHandshakeStarted(payload) { + if (relayConnectRequestId) return + const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : '' + relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` + sendToRelay({ + type: 'req', + id: relayConnectRequestId, + method: 'connect', + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: 'officer-browser-relay-extension', + version: '1.0.0', + platform: 'chrome-extension', + mode: 'webchat', + }, + role: 'operator', + scopes: ['operator.read', 'operator.write'], + caps: [], + commands: [], + nonce: nonce || undefined, + auth: relayGatewayToken ? { token: relayGatewayToken } : undefined, + }, + }) +} + +async function maybeOpenHelpOnce() { + try { + const stored = await chrome.storage.local.get(['helpOnErrorShown']) + if (stored.helpOnErrorShown === true) return + await chrome.storage.local.set({ helpOnErrorShown: true }) + await chrome.runtime.openOptionsPage() + } catch { + // ignore + } +} + +function requestFromRelay(command) { + const id = command.id + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error('Relay request timeout (30s)')) + }, 30000) + pending.set(id, { + resolve: (v) => { clearTimeout(timer); resolve(v) }, + reject: (e) => { clearTimeout(timer); reject(e) }, + }) + try { + sendToRelay(command) + } catch (err) { + clearTimeout(timer) + pending.delete(id) + reject(err instanceof Error ? err : new Error(String(err))) + } + }) +} + +async function onRelayMessage(text) { + /** @type {any} */ + let msg + try { + msg = JSON.parse(text) + } catch { + return + } + + if (msg && msg.type === 'event' && msg.event === 'connect.challenge') { + try { + ensureGatewayHandshakeStarted(msg.payload) + } catch (err) { + console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err)) + relayConnectRequestId = null + const ws = relayWs + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(1008, 'gateway connect failed') + } + } + return + } + + if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) { + relayConnectRequestId = null + if (!msg.ok) { + const detail = msg?.error?.message || msg?.error || 'gateway connect failed' + console.warn('gateway connect handshake rejected', String(detail)) + const ws = relayWs + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(1008, 'gateway connect failed') + } + } + return + } + + if (msg && msg.method === 'ping') { + try { + sendToRelay({ method: 'pong' }) + } catch { + // ignore + } + return + } + + if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) { + const p = pending.get(msg.id) + if (!p) return + pending.delete(msg.id) + if (msg.error) p.reject(new Error(String(msg.error))) + else p.resolve(msg.result) + return + } + + if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') { + try { + const result = await handleForwardCdpCommand(msg) + sendToRelay({ id: msg.id, result }) + } catch (err) { + sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) }) + } + } +} + +function getTabBySessionId(sessionId) { + const direct = tabBySession.get(sessionId) + if (direct) return { tabId: direct, kind: 'main' } + const child = childSessionToTab.get(sessionId) + if (child) return { tabId: child, kind: 'child' } + return null +} + +function getTabByTargetId(targetId) { + for (const [tabId, tab] of tabs.entries()) { + if (tab.targetId === targetId) return tabId + } + return null +} + +async function attachTab(tabId, opts = {}) { + const debuggee = { tabId } + await chrome.debugger.attach(debuggee, '1.3') + await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {}) + + const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')) + const targetInfo = info?.targetInfo + const targetId = String(targetInfo?.targetId || '').trim() + if (!targetId) { + throw new Error('Target.getTargetInfo returned no targetId') + } + + const sid = nextSession++ + const sessionId = `cb-tab-${sid}` + const attachOrder = sid + + tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder }) + tabBySession.set(sessionId, tabId) + void chrome.action.setTitle({ + tabId, + title: 'Officer Browser Relay: attached (click to detach)', + }) + + if (!opts.skipAttachedEvent) { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.attachedToTarget', + params: { + sessionId, + targetInfo: { ...targetInfo, attached: true }, + waitingForDebugger: false, + }, + }, + }) + } + + setBadge(tabId, 'on') + await persistState() + + return { sessionId, targetId } +} + +async function detachTab(tabId, reason) { + const tab = tabs.get(tabId) + + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: childSessionId, reason: 'parent_detached' }, + }, + }) + } catch { + // Relay may be down. + } + childSessionToTab.delete(childSessionId) + } + } + + if (tab?.sessionId && tab?.targetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: tab.sessionId, targetId: tab.targetId, reason }, + }, + }) + } catch { + // Relay may be down. + } + } + + if (tab?.sessionId) tabBySession.delete(tab.sessionId) + tabs.delete(tabId) + + try { + await chrome.debugger.detach({ tabId }) + } catch { + // May already be detached. + } + + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'Officer Browser Relay (click to attach/detach)', + }) + + await persistState() +} + +async function connectOrToggleForActiveTab() { + const [active] = await chrome.tabs.query({ active: true, currentWindow: true }) + const tabId = active?.id + if (!tabId) return + + if (tabOperationLocks.has(tabId)) return + tabOperationLocks.add(tabId) + + try { + if (reattachPending.has(tabId)) { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'Officer Browser Relay (click to attach/detach)', + }) + return + } + + const existing = tabs.get(tabId) + if (existing?.state === 'connected') { + await detachTab(tabId, 'toggle') + return + } + + cancelReconnect() + + tabs.set(tabId, { state: 'connecting' }) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'Officer Browser Relay: connecting to relay…', + }) + + try { + await ensureRelayConnection() + await attachTab(tabId) + } catch (err) { + tabs.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'Officer Browser Relay: relay not running (open options for setup)', + }) + void maybeOpenHelpOnce() + const message = err instanceof Error ? err.message : String(err) + console.warn('attach failed', message) + } + } finally { + tabOperationLocks.delete(tabId) + } +} + +async function handleForwardCdpCommand(msg) { + const method = String(msg?.params?.method || '').trim() + const params = msg?.params?.params || undefined + const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined + + const bySession = sessionId ? getTabBySessionId(sessionId) : null + const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined + const tabId = + bySession?.tabId || + (targetId ? getTabByTargetId(targetId) : null) || + (() => { + for (const [id, tab] of tabs.entries()) { + if (tab.state === 'connected') return id + } + return null + })() + + if (!tabId) throw new Error(`No attached tab for method ${method}`) + + /** @type {chrome.debugger.DebuggerSession} */ + const debuggee = { tabId } + + if (method === 'Runtime.enable') { + try { + await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') + await new Promise((r) => setTimeout(r, 50)) + } catch { + // ignore + } + return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params) + } + + if (method === 'Target.createTarget') { + const url = typeof params?.url === 'string' ? params.url : 'about:blank' + const tab = await chrome.tabs.create({ url, active: false }) + if (!tab.id) throw new Error('Failed to create tab') + await new Promise((r) => setTimeout(r, 100)) + const attached = await attachTab(tab.id) + return { targetId: attached.targetId } + } + + if (method === 'Target.closeTarget') { + const target = typeof params?.targetId === 'string' ? params.targetId : '' + const toClose = target ? getTabByTargetId(target) : tabId + if (!toClose) return { success: false } + try { + await chrome.tabs.remove(toClose) + } catch { + return { success: false } + } + return { success: true } + } + + if (method === 'Target.activateTarget') { + const target = typeof params?.targetId === 'string' ? params.targetId : '' + const toActivate = target ? getTabByTargetId(target) : tabId + if (!toActivate) return {} + const tab = await chrome.tabs.get(toActivate).catch(() => null) + if (!tab) return {} + if (tab.windowId) { + await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) + } + await chrome.tabs.update(toActivate, { active: true }).catch(() => {}) + return {} + } + + const tabState = tabs.get(tabId) + const mainSessionId = tabState?.sessionId + const debuggerSession = + sessionId && mainSessionId && sessionId !== mainSessionId + ? { ...debuggee, sessionId } + : debuggee + + return await chrome.debugger.sendCommand(debuggerSession, method, params) +} + +function onDebuggerEvent(source, method, params) { + const tabId = source.tabId + if (!tabId) return + const tab = tabs.get(tabId) + if (!tab?.sessionId) return + + if (method === 'Target.attachedToTarget' && params?.sessionId) { + childSessionToTab.set(String(params.sessionId), tabId) + } + + if (method === 'Target.detachedFromTarget' && params?.sessionId) { + childSessionToTab.delete(String(params.sessionId)) + } + + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + sessionId: source.sessionId || tab.sessionId, + method, + params, + }, + }) + } catch { + // Relay may be down. + } +} + +async function onDebuggerDetach(source, reason) { + const tabId = source.tabId + if (!tabId) return + if (!tabs.has(tabId)) return + + if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { + void detachTab(tabId, reason) + return + } + + let tabInfo + try { + tabInfo = await chrome.tabs.get(tabId) + } catch { + void detachTab(tabId, reason) + return + } + + if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { + void detachTab(tabId, reason) + return + } + + if (reattachPending.has(tabId)) return + + const oldTab = tabs.get(tabId) + const oldSessionId = oldTab?.sessionId + const oldTargetId = oldTab?.targetId + + if (oldSessionId) tabBySession.delete(oldSessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + + if (oldSessionId && oldTargetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, + }, + }) + } catch { + // Relay may be down. + } + } + + reattachPending.add(tabId) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'Officer Browser Relay: re-attaching after navigation…', + }) + + const delays = [300, 700, 1500] + for (let attempt = 0; attempt < delays.length; attempt++) { + await new Promise((r) => setTimeout(r, delays[attempt])) + + if (!reattachPending.has(tabId)) return + + try { + await chrome.tabs.get(tabId) + } catch { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + return + } + + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + reattachPending.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'Officer Browser Relay: relay disconnected during re-attach', + }) + return + } + + try { + await attachTab(tabId) + reattachPending.delete(tabId) + return + } catch { + // continue retries + } + } + + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'Officer Browser Relay: re-attach failed (click to retry)', + }) +} + +chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { + reattachPending.delete(tabId) + if (!tabs.has(tabId)) return + const tab = tabs.get(tabId) + if (tab?.sessionId) tabBySession.delete(tab.sessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + if (tab?.sessionId && tab?.targetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' }, + }, + }) + } catch { + // Relay may be down. + } + } + void persistState() +})) + +chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => { + const tab = tabs.get(removedTabId) + if (!tab) return + tabs.delete(removedTabId) + tabs.set(addedTabId, tab) + if (tab.sessionId) { + tabBySession.set(tab.sessionId, addedTabId) + } + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === removedTabId) { + childSessionToTab.set(childSessionId, addedTabId) + } + } + setBadge(addedTabId, 'on') + void persistState() +})) + +chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args))) +chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args))) + +chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab())) + +chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => { + if (frameId !== 0) return + const tab = tabs.get(tabId) + if (tab?.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } +})) + +chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => { + const tab = tabs.get(tabId) + if (tab?.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } +})) + +chrome.runtime.onInstalled.addListener(() => { + void chrome.runtime.openOptionsPage() +}) + +chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 }) + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== 'relay-keepalive') return + await initPromise + + if (tabs.size === 0) return + + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } + } + + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + if (!relayConnectPromise && !reconnectTimer) { + console.log('Keepalive: WebSocket unhealthy, triggering reconnect') + await ensureRelayConnection().catch(() => { + if (!reconnectTimer) { + scheduleReconnect() + } + }) + } + } +}) + +const initPromise = rehydrateState() + +initPromise.then(() => { + if (tabs.size > 0) { + ensureRelayConnection().then(() => { + reconnectAttempt = 0 + return reannounceAttachedTabs() + }).catch(() => { + scheduleReconnect() + }) + } +}) + +async function whenReady(fn) { + await initPromise + return fn() +} + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type !== 'relayCheck') return false + const { url, token } = msg + const headers = token ? { 'x-officer-relay-token': token } : {} + fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) + .then(async (res) => { + const contentType = String(res.headers.get('content-type') || '') + let json = null + if (contentType.includes('application/json')) { + try { + json = await res.json() + } catch { + json = null + } + } + sendResponse({ status: res.status, ok: res.ok, contentType, json }) + }) + .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) + return true +}) diff --git a/src/extensions/browser-relay/icons/icon128.png b/src/extensions/browser-relay/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..533cc812de79e24f30da7c024d5d23ee8be2622c GIT binary patch literal 614 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSY)RhkE)4%caKYZ?lNlJ8q&;06 zLn`LHy=&Vg;>gf&QPQyRpz(~Zl{2MR96Z)EqnauCe8L;fGp+_^0nFycqQU1C);z3W z+8xoht?FB~{@SJ6%bzV?ko7x#J2TLD5XhVL_lrAKskwd@uaQzAp05@4mk^eW`36!VYtg6u*06 z`s_{VzpKB`cRxWBa@N(K2@RzY_~_BCw=c{T?)f|-Sr4=w$9 zd)D8AUW0G-_3i(U@2gF7DN_~p`F!z?0$a-LR@bAF8#ey`ZT-i7mNd(EEoEkA=EMI# zRdgJa=KokJ&uH>QG455&i4#W-95|3*TPa&8HIc!G-^PH0Re@&#yUNjx4U8O1nR%Q& z4OwQNjR<}e7@oXlTjYB??-;L?goFeKe)eO%>=GBR1{~!UVP@cR%POhi659au34^Dr KpUXO@geCxOlV+Cy literal 0 HcmV?d00001 diff --git a/src/extensions/browser-relay/icons/icon32.png b/src/extensions/browser-relay/icons/icon32.png new file mode 100644 index 0000000000000000000000000000000000000000..f4c1be8a6a0faf269d0d4998c57cc7ebd09db913 GIT binary patch literal 318 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzwj^(N7l!{JxM1({$v~0Mo-U3d z5v|Dy60FLy20}pzN00uVFT~wf`@82lpPCoXa<&66t!A80wEOY?y+e?PL7vTljYgr+ zAsLOy2@;-OuD^EVVJ?n3QS$KzXm~m&Ib5@bmCqtmd1|i)O zfn_G<9EydH6&;%`_~V*4@4uZraV;Bb#)-o-uT8#w@aBn=nJ11PnDCv!OXyd;T>suA zB_+;_EDEZMDF<%sEHL5Zl{(OL#_`-sjinb97*6Clvq5ZKaZPN|tY){{PR=|%-%hYe z@l^5$nCQ8@lHk)lFrmLu@jYYLB!;bRlA4}f5Dgkyt$n+sf{zMb+kO7f&Bco^JM#uy z5p!Z&vaNf"], + "background": { "service_worker": "background.js", "type": "module" }, + "action": { + "default_title": "Officer Browser Relay (click to attach/detach)", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "options_ui": { "page": "options.html", "open_in_tab": true } +} diff --git a/src/extensions/browser-relay/options-validation.js b/src/extensions/browser-relay/options-validation.js new file mode 100644 index 00000000..af4184cb --- /dev/null +++ b/src/extensions/browser-relay/options-validation.js @@ -0,0 +1,66 @@ +// Based on OpenClaw Browser Extension Relay (MIT License) +// https://github.com/nicepkg/openclaw + +function hasCdpVersionShape(data) { + return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data +} + +export function classifyRelayCheckResponse(res, host, port) { + if (!res) { + return { action: 'throw', error: 'No response from service worker' } + } + + if (res.status === 401) { + return { action: 'status', kind: 'error', message: 'Relay token rejected. Check token and save again.' } + } + + if (res.error) { + return { action: 'throw', error: res.error } + } + + if (!res.ok) { + return { action: 'throw', error: `HTTP ${res.status}` } + } + + const contentType = String(res.contentType || '') + if (!contentType.includes('application/json')) { + return { + action: 'status', + kind: 'error', + message: `Wrong endpoint: expected Officer browser relay at this address.`, + } + } + + if (!hasCdpVersionShape(res.json)) { + const browser = res.json?.Browser ?? '' + if (browser && !browser.startsWith('Officer/')) { + return { + action: 'status', + kind: 'error', + message: `Wrong relay: found "${browser}" instead of Officer/browser-relay.`, + } + } + return { + action: 'status', + kind: 'error', + message: `Wrong endpoint: expected relay /json/version response.`, + } + } + + return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://${host}:${port}/` } +} + +export function classifyRelayCheckException(err, host, port) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + return { + kind: 'error', + message: `Wrong endpoint: this is not a relay endpoint.`, + } + } + + return { + kind: 'error', + message: `Relay not reachable at http://${host}:${port}/. Verify the server address and relay token.`, + } +} diff --git a/src/extensions/browser-relay/options.html b/src/extensions/browser-relay/options.html new file mode 100644 index 00000000..d830d0e9 --- /dev/null +++ b/src/extensions/browser-relay/options.html @@ -0,0 +1,209 @@ + + + + + + Officer Browser Relay + + + +
+
+ +
+

Officer Browser Relay

+

Click the toolbar button on a tab to attach / detach.

+
+
+ +
+
+

Getting started

+

+ If you see a red ! badge on the extension icon, the relay server is not reachable. + Make sure Officer is running and enter the correct server address, port, and relay token below. +

+

+ You can find your relay token in the Officer dashboard under Browser. +

+
+ +
+

Relay connection

+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ Default port: 18792. Extension connects to: http://<host>:<port>/. +
+
+
+
+ + +
+ + diff --git a/src/extensions/browser-relay/options.js b/src/extensions/browser-relay/options.js new file mode 100644 index 00000000..0a7c838d --- /dev/null +++ b/src/extensions/browser-relay/options.js @@ -0,0 +1,78 @@ +import { deriveRelayToken } from './background-utils.js' +import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' + +const DEFAULT_PORT = 18792 +const DEFAULT_HOST = '127.0.0.1' + +function clampPort(value) { + const n = Number.parseInt(String(value || ''), 10) + if (!Number.isFinite(n)) return DEFAULT_PORT + if (n <= 0 || n > 65535) return DEFAULT_PORT + return n +} + +function updateRelayUrl(host, port) { + const el = document.getElementById('relay-url') + if (!el) return + el.textContent = `http://${host}:${port}/` +} + +function setStatus(kind, message) { + const status = document.getElementById('status') + if (!status) return + status.dataset.kind = kind || '' + status.textContent = message || '' +} + +async function checkRelayReachable(host, port, token) { + const url = `http://${host}:${port}/json/version` + const trimmedToken = String(token || '').trim() + if (!trimmedToken) { + setStatus('error', 'Relay token required. Save your token to connect.') + return + } + try { + const relayToken = await deriveRelayToken(trimmedToken, port) + const res = await chrome.runtime.sendMessage({ + type: 'relayCheck', + url, + token: relayToken, + }) + const result = classifyRelayCheckResponse(res, host, port) + if (result.action === 'throw') throw new Error(result.error) + setStatus(result.kind, result.message) + } catch (err) { + const result = classifyRelayCheckException(err, host, port) + setStatus(result.kind, result.message) + } +} + +async function load() { + const stored = await chrome.storage.local.get(['relayPort', 'relayHost', 'relayToken']) + const port = clampPort(stored.relayPort) + const host = String(stored.relayHost || '').trim() || DEFAULT_HOST + const token = String(stored.relayToken || '').trim() + document.getElementById('host').value = host + document.getElementById('port').value = String(port) + document.getElementById('token').value = token + updateRelayUrl(host, port) + await checkRelayReachable(host, port, token) +} + +async function save() { + const hostInput = document.getElementById('host') + const portInput = document.getElementById('port') + const tokenInput = document.getElementById('token') + const host = String(hostInput.value || '').trim() || DEFAULT_HOST + const port = clampPort(portInput.value) + const token = String(tokenInput.value || '').trim() + await chrome.storage.local.set({ relayHost: host, relayPort: port, relayToken: token }) + hostInput.value = host + portInput.value = String(port) + tokenInput.value = token + updateRelayUrl(host, port) + await checkRelayReachable(host, port, token) +} + +document.getElementById('save').addEventListener('click', () => void save()) +void load() diff --git a/src/server.tsx b/src/server.tsx index 9fb9f8e8..8c32df2e 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -8,6 +8,7 @@ import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/ import { piWebsocket } from './servers/api/pi/websocket'; import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router'; import officerWeb from './apps/officer-web/index.html'; +import { startBrowserRelay } from './servers/api/browser/relay'; const { PORT = '5000' } = process.env; @@ -199,6 +200,14 @@ const server = serve({ console.log(`🚀 Server running at ${server.url}`); +const BROWSER_RELAY_PORT = Number(process.env.BROWSER_RELAY_PORT ?? '18792'); +try { + startBrowserRelay(BROWSER_RELAY_PORT); + console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`); +} catch (err) { + console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err); +} + void initTerminalSidecars(); diff --git a/src/servers/api/browser/cdp.ts b/src/servers/api/browser/cdp.ts new file mode 100644 index 00000000..5030ab95 --- /dev/null +++ b/src/servers/api/browser/cdp.ts @@ -0,0 +1,99 @@ +type CdpCommandOptions = { + relayPort: number; + userToken: string; + targetId?: string; +}; + +async function sendCdpCommand(opts: CdpCommandOptions, method: string, params?: unknown): Promise { + const { relayPort, userToken, targetId } = opts; + const url = `ws://127.0.0.1:${relayPort}/cdp?token=${encodeURIComponent(userToken)}`; + + return await new Promise((resolve, reject) => { + const ws = new WebSocket(url); + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + ws.close(); + reject(new Error(`CDP command timeout: ${method}`)); + }, 30_000); + + ws.addEventListener('open', () => { + const cmd: Record = { id: 1, method }; + if (params) cmd.params = params; + if (targetId) cmd.sessionId = targetId; + ws.send(JSON.stringify(cmd)); + }); + + ws.addEventListener('message', (event) => { + if (settled) return; + try { + const msg = JSON.parse(String(event.data)) as { id?: number; result?: unknown; error?: { message: string } }; + if (msg.id === 1) { + settled = true; + clearTimeout(timeout); + ws.close(); + if (msg.error) reject(new Error(msg.error.message)); + else resolve(msg.result); + } + } catch { + // ignore parse errors, wait for correct message + } + }); + + ws.addEventListener('error', () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(new Error('CDP WebSocket connection failed')); + }); + + ws.addEventListener('close', () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(new Error('CDP WebSocket closed before response')); + }); + }); +} + +export async function captureScreenshot( + opts: CdpCommandOptions, + format: 'png' | 'jpeg' = 'png', + quality?: number, +): Promise { + const params: Record = { format }; + if (quality !== undefined) params.quality = quality; + const result = (await sendCdpCommand(opts, 'Page.captureScreenshot', params)) as { data: string }; + return result.data; +} + +export async function evaluateJS(opts: CdpCommandOptions, expression: string): Promise { + const result = (await sendCdpCommand(opts, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + })) as { result?: { value?: unknown; description?: string }; exceptionDetails?: { text?: string } }; + + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.text ?? 'Evaluation failed'); + } + return result.result?.value; +} + +export async function navigateTo(opts: CdpCommandOptions, url: string): Promise { + await sendCdpCommand(opts, 'Page.navigate', { url }); +} + +export async function getPageInfo(opts: CdpCommandOptions): Promise<{ title: string; url: string }> { + const result = (await sendCdpCommand(opts, 'Runtime.evaluate', { + expression: 'JSON.stringify({ title: document.title, url: location.href })', + returnByValue: true, + })) as { result?: { value?: string } }; + + try { + return JSON.parse(result.result?.value ?? '{}'); + } catch { + return { title: '', url: '' }; + } +} diff --git a/src/servers/api/browser/relay-auth.ts b/src/servers/api/browser/relay-auth.ts new file mode 100644 index 00000000..923ad637 --- /dev/null +++ b/src/servers/api/browser/relay-auth.ts @@ -0,0 +1,31 @@ +import { createHmac } from 'node:crypto'; + +const RELAY_TOKEN_CONTEXT = 'officer-browser-relay-v1'; + +const { JWT_SECRET } = process.env; + +if (!JWT_SECRET) { + throw new Error('JWT_SECRET is required for browser relay auth'); +} + +export function deriveRelayToken(userId: number, port: number): string { + return createHmac('sha256', JWT_SECRET!) + .update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}`) + .digest('hex'); +} + +const tokenToUser = new Map(); + +export function registerUserToken(userId: number, port: number): string { + const token = deriveRelayToken(userId, port); + tokenToUser.set(token, userId); + return token; +} + +export function resolveUserFromToken(token: string): number | null { + return tokenToUser.get(token) ?? null; +} + +export function getRelayAuthHeader(): string { + return 'x-officer-relay-token'; +} diff --git a/src/servers/api/browser/relay-types.ts b/src/servers/api/browser/relay-types.ts new file mode 100644 index 00000000..21525a21 --- /dev/null +++ b/src/servers/api/browser/relay-types.ts @@ -0,0 +1,89 @@ +export type TargetInfo = { + targetId: string; + type?: string; + title?: string; + url?: string; + attached?: boolean; +}; + +export type ConnectedTarget = { + sessionId: string; + targetId: string; + targetInfo: TargetInfo; +}; + +export type RelayStatus = { + extensionConnected: boolean; + targetCount: number; +}; + +export type CdpCommand = { + id: number; + method: string; + params?: unknown; + sessionId?: string; +}; + +export type CdpResponse = { + id: number; + result?: unknown; + error?: { message: string }; + sessionId?: string; +}; + +export type CdpEvent = { + method: string; + params?: unknown; + sessionId?: string; +}; + +export type ExtensionForwardCommandMessage = { + id: number; + method: 'forwardCDPCommand'; + params: { method: string; params?: unknown; sessionId?: string }; +}; + +export type ExtensionResponseMessage = { + id: number; + result?: unknown; + error?: string; +}; + +export type ExtensionForwardEventMessage = { + method: 'forwardCDPEvent'; + params: { method: string; params?: unknown; sessionId?: string }; +}; + +export type ExtensionPingMessage = { method: 'ping' }; +export type ExtensionPongMessage = { method: 'pong' }; + +export type ExtensionMessage = ExtensionResponseMessage | ExtensionForwardEventMessage | ExtensionPongMessage; + +export type AttachedToTargetEvent = { + sessionId: string; + targetInfo: TargetInfo; + waitingForDebugger?: boolean; +}; + +export type DetachedFromTargetEvent = { + sessionId: string; + targetId?: string; +}; + +export type UserRelay = { + extensionWs: unknown | null; + cdpClients: Set; + connectedTargets: Map; + pingInterval: Timer | null; + gracePeriodTimer: Timer | null; + pendingExtension: Map< + number, + { + resolve: (v: unknown) => void; + reject: (e: Error) => void; + timer: Timer; + } + >; + nextExtensionId: number; + extensionReconnectWaiters: Set<(connected: boolean) => void>; +}; diff --git a/src/servers/api/browser/relay.ts b/src/servers/api/browser/relay.ts new file mode 100644 index 00000000..c35b2cf1 --- /dev/null +++ b/src/servers/api/browser/relay.ts @@ -0,0 +1,670 @@ +// Based on OpenClaw Browser Extension Relay (MIT License) +// https://github.com/nicepkg/openclaw +// +// Rewritten for Bun.serve() with multi-user support. + +import type { ServerWebSocket } from 'bun'; +import type { + AttachedToTargetEvent, + CdpCommand, + CdpEvent, + CdpResponse, + ConnectedTarget, + DetachedFromTargetEvent, + ExtensionForwardCommandMessage, + ExtensionForwardEventMessage, + ExtensionMessage, + ExtensionPongMessage, +} from './relay-types'; +import { getRelayAuthHeader, resolveUserFromToken } from './relay-auth'; + +const RELAY_AUTH_HEADER = getRelayAuthHeader(); +const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 5_000; +const DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS = 3_000; + +type WSData = { + kind: 'extension' | 'cdp'; + userId: number; + token: string; +}; + +type UserRelay = { + extensionWs: ServerWebSocket | null; + cdpClients: Set>; + connectedTargets: Map; + pingInterval: Timer | null; + gracePeriodTimer: Timer | null; + pendingExtension: Map void; reject: (e: Error) => void; timer: Timer }>; + nextExtensionId: number; + extensionReconnectWaiters: Set<(connected: boolean) => void>; +}; + +const userRelays = new Map(); + +function getUserRelay(userId: number): UserRelay { + let relay = userRelays.get(userId); + if (!relay) { + relay = { + extensionWs: null, + cdpClients: new Set(), + connectedTargets: new Map(), + pingInterval: null, + gracePeriodTimer: null, + pendingExtension: new Map(), + nextExtensionId: 1, + extensionReconnectWaiters: new Set(), + }; + userRelays.set(userId, relay); + } + return relay; +} + +function extensionConnected(relay: UserRelay): boolean { + return relay.extensionWs !== null && relay.extensionWs.readyState === WebSocket.OPEN; +} + +function flushExtensionReconnectWaiters(relay: UserRelay, connected: boolean) { + if (relay.extensionReconnectWaiters.size === 0) return; + const waiters = Array.from(relay.extensionReconnectWaiters); + relay.extensionReconnectWaiters.clear(); + for (const waiter of waiters) waiter(connected); +} + +function clearGracePeriodTimer(relay: UserRelay) { + if (!relay.gracePeriodTimer) return; + clearTimeout(relay.gracePeriodTimer); + relay.gracePeriodTimer = null; +} + +function closeCdpClientsAfterExtensionDisconnect(relay: UserRelay) { + relay.connectedTargets.clear(); + for (const client of relay.cdpClients) { + try { + client.close(1011, 'extension disconnected'); + } catch { + // ignore + } + } + relay.cdpClients.clear(); + flushExtensionReconnectWaiters(relay, false); +} + +function scheduleExtensionDisconnectCleanup(relay: UserRelay) { + clearGracePeriodTimer(relay); + relay.gracePeriodTimer = setTimeout(() => { + relay.gracePeriodTimer = null; + if (extensionConnected(relay)) return; + closeCdpClientsAfterExtensionDisconnect(relay); + }, DEFAULT_EXTENSION_RECONNECT_GRACE_MS); +} + +async function waitForExtensionReconnect(relay: UserRelay, timeoutMs: number): Promise { + if (extensionConnected(relay)) return true; + return await new Promise((resolve) => { + let settled = false; + const waiter = (connected: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + relay.extensionReconnectWaiters.delete(waiter); + resolve(connected); + }; + const timer = setTimeout(() => waiter(false), timeoutMs); + relay.extensionReconnectWaiters.add(waiter); + }); +} + +function sendToExtension(relay: UserRelay, payload: ExtensionForwardCommandMessage): Promise { + const ws = relay.extensionWs; + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error('Chrome extension not connected'); + } + ws.send(JSON.stringify(payload)); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + relay.pendingExtension.delete(payload.id); + reject(new Error(`extension request timeout: ${payload.params.method}`)); + }, 30_000); + relay.pendingExtension.set(payload.id, { resolve, reject, timer }); + }); +} + +function broadcastToCdpClients(relay: UserRelay, evt: CdpEvent) { + const msg = JSON.stringify(evt); + for (const ws of relay.cdpClients) { + if (ws.readyState !== WebSocket.OPEN) continue; + ws.send(msg); + } +} + +function sendResponseToCdp(ws: ServerWebSocket, res: CdpResponse) { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify(res)); +} + +function ensureTargetEventsForClient(relay: UserRelay, ws: ServerWebSocket, mode: 'autoAttach' | 'discover') { + for (const target of relay.connectedTargets.values()) { + if (mode === 'autoAttach') { + ws.send( + JSON.stringify({ + method: 'Target.attachedToTarget', + params: { + sessionId: target.sessionId, + targetInfo: { ...target.targetInfo, attached: true }, + waitingForDebugger: false, + }, + } satisfies CdpEvent), + ); + } else { + ws.send( + JSON.stringify({ + method: 'Target.targetCreated', + params: { targetInfo: { ...target.targetInfo, attached: true } }, + } satisfies CdpEvent), + ); + } + } +} + +async function routeCdpCommand(relay: UserRelay, cmd: CdpCommand): Promise { + switch (cmd.method) { + case 'Browser.getVersion': + return { + protocolVersion: '1.3', + product: 'Chrome/Officer-Browser-Relay', + revision: '0', + userAgent: 'Officer-Browser-Relay', + jsVersion: 'V8', + }; + case 'Browser.setDownloadBehavior': + return {}; + case 'Target.setAutoAttach': + case 'Target.setDiscoverTargets': + return {}; + case 'Target.getTargets': + return { + targetInfos: Array.from(relay.connectedTargets.values()).map((t) => ({ + ...t.targetInfo, + attached: true, + })), + }; + case 'Target.getTargetInfo': { + const params = (cmd.params ?? {}) as { targetId?: string }; + const targetId = typeof params.targetId === 'string' ? params.targetId : undefined; + if (targetId) { + for (const t of relay.connectedTargets.values()) { + if (t.targetId === targetId) return { targetInfo: t.targetInfo }; + } + } + if (cmd.sessionId && relay.connectedTargets.has(cmd.sessionId)) { + const t = relay.connectedTargets.get(cmd.sessionId); + if (t) return { targetInfo: t.targetInfo }; + } + const first = Array.from(relay.connectedTargets.values())[0]; + return { targetInfo: first?.targetInfo }; + } + case 'Target.attachToTarget': { + const params = (cmd.params ?? {}) as { targetId?: string }; + const targetId = typeof params.targetId === 'string' ? params.targetId : undefined; + if (!targetId) throw new Error('targetId required'); + for (const t of relay.connectedTargets.values()) { + if (t.targetId === targetId) return { sessionId: t.sessionId }; + } + throw new Error('target not found'); + } + default: { + const id = relay.nextExtensionId++; + return await sendToExtension(relay, { + id, + method: 'forwardCDPCommand', + params: { + method: cmd.method, + sessionId: cmd.sessionId, + params: cmd.params, + }, + }); + } + } +} + +function handleExtensionMessage(relay: UserRelay, data: string) { + let parsed: ExtensionMessage | null = null; + try { + parsed = JSON.parse(data) as ExtensionMessage; + } catch { + return; + } + + if (parsed && typeof parsed === 'object' && 'id' in parsed && typeof parsed.id === 'number') { + const pending = relay.pendingExtension.get(parsed.id); + if (!pending) return; + relay.pendingExtension.delete(parsed.id); + clearTimeout(pending.timer); + if ('error' in parsed && typeof parsed.error === 'string' && parsed.error.trim()) { + pending.reject(new Error(parsed.error)); + } else { + pending.resolve(parsed.result); + } + return; + } + + if (parsed && typeof parsed === 'object' && 'method' in parsed) { + if ((parsed as ExtensionPongMessage).method === 'pong') return; + if ((parsed as ExtensionForwardEventMessage).method !== 'forwardCDPEvent') return; + + const evt = parsed as ExtensionForwardEventMessage; + const method = evt.params?.method; + const params = evt.params?.params; + const sessionId = evt.params?.sessionId; + if (!method || typeof method !== 'string') return; + + if (method === 'Target.attachedToTarget') { + const attached = (params ?? {}) as AttachedToTargetEvent; + const targetType = attached?.targetInfo?.type ?? 'page'; + if (targetType !== 'page') return; + if (attached?.sessionId && attached?.targetInfo?.targetId) { + const prev = relay.connectedTargets.get(attached.sessionId); + const nextTargetId = attached.targetInfo.targetId; + const prevTargetId = prev?.targetId; + const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId); + relay.connectedTargets.set(attached.sessionId, { + sessionId: attached.sessionId, + targetId: nextTargetId, + targetInfo: attached.targetInfo, + }); + if (changedTarget && prevTargetId) { + broadcastToCdpClients(relay, { + method: 'Target.detachedFromTarget', + params: { sessionId: attached.sessionId, targetId: prevTargetId }, + sessionId: attached.sessionId, + }); + } + if (!prev || changedTarget) { + broadcastToCdpClients(relay, { method, params, sessionId }); + } + return; + } + } + + if (method === 'Target.detachedFromTarget') { + const detached = (params ?? {}) as DetachedFromTargetEvent; + if (detached?.sessionId) { + relay.connectedTargets.delete(detached.sessionId); + } + broadcastToCdpClients(relay, { method, params, sessionId }); + return; + } + + if (method === 'Target.targetInfoChanged') { + const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } }; + const targetInfo = changed?.targetInfo; + const targetId = targetInfo?.targetId; + if (targetId && (targetInfo?.type ?? 'page') === 'page') { + for (const [sid, target] of relay.connectedTargets) { + if (target.targetId !== targetId) continue; + relay.connectedTargets.set(sid, { + ...target, + targetInfo: { ...target.targetInfo, ...(targetInfo as object) }, + }); + } + } + } + + broadcastToCdpClients(relay, { method, params, sessionId }); + } +} + +function handleExtensionClose(relay: UserRelay, ws: ServerWebSocket) { + if (relay.pingInterval) { + clearInterval(relay.pingInterval); + relay.pingInterval = null; + } + if (relay.extensionWs !== ws) return; + relay.extensionWs = null; + for (const [, pending] of relay.pendingExtension) { + clearTimeout(pending.timer); + pending.reject(new Error('extension disconnected')); + } + relay.pendingExtension.clear(); + scheduleExtensionDisconnectCleanup(relay); +} + +function handleExtensionOpen(relay: UserRelay, ws: ServerWebSocket) { + relay.extensionWs = ws; + clearGracePeriodTimer(relay); + flushExtensionReconnectWaiters(relay, true); + + relay.pingInterval = setInterval(() => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ method: 'ping' })); + }, 5000); +} + +async function handleCdpMessage(relay: UserRelay, ws: ServerWebSocket, data: string) { + let cmd: CdpCommand | null = null; + try { + cmd = JSON.parse(data) as CdpCommand; + } catch { + return; + } + if (!cmd || typeof cmd !== 'object') return; + if (typeof cmd.id !== 'number' || typeof cmd.method !== 'string') return; + + if (!extensionConnected(relay)) { + const reconnected = await waitForExtensionReconnect(relay, DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS); + if (!reconnected || !extensionConnected(relay)) { + sendResponseToCdp(ws, { + id: cmd.id, + sessionId: cmd.sessionId, + error: { message: 'Extension not connected' }, + }); + return; + } + } + + try { + const result = await routeCdpCommand(relay, cmd); + + if (cmd.method === 'Target.setAutoAttach' && !cmd.sessionId) { + ensureTargetEventsForClient(relay, ws, 'autoAttach'); + } + if (cmd.method === 'Target.setDiscoverTargets') { + const discover = (cmd.params ?? {}) as { discover?: boolean }; + if (discover.discover === true) { + ensureTargetEventsForClient(relay, ws, 'discover'); + } + } + if (cmd.method === 'Target.attachToTarget') { + const params = (cmd.params ?? {}) as { targetId?: string }; + const targetId = typeof params.targetId === 'string' ? params.targetId : undefined; + if (targetId) { + const target = Array.from(relay.connectedTargets.values()).find((t) => t.targetId === targetId); + if (target) { + ws.send( + JSON.stringify({ + method: 'Target.attachedToTarget', + params: { + sessionId: target.sessionId, + targetInfo: { ...target.targetInfo, attached: true }, + waitingForDebugger: false, + }, + } satisfies CdpEvent), + ); + } + } + } + + sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result }); + } catch (err) { + sendResponseToCdp(ws, { + id: cmd.id, + sessionId: cmd.sessionId, + error: { message: err instanceof Error ? err.message : String(err) }, + }); + } +} + +function getTokenFromUrl(url: string): string | null { + try { + const parsed = new URL(url, 'http://localhost'); + return parsed.searchParams.get('token')?.trim() ?? null; + } catch { + return null; + } +} + +function getTokenFromHeaders(headers: Headers): string | null { + return headers.get(RELAY_AUTH_HEADER)?.trim() ?? null; +} + +function getTokenFromRequest(req: Request): string | null { + return getTokenFromHeaders(req.headers) ?? getTokenFromUrl(req.url); +} + +function authenticateRequest(req: Request): number | null { + const token = getTokenFromRequest(req); + if (!token) return null; + return resolveUserFromToken(token); +} + +let relayServer: ReturnType | null = null; +let relayPort = 0; + +export function getRelayPort(): number { + return relayPort; +} + +export function getUserRelayStatus(userId: number): { extensionConnected: boolean; targetCount: number } { + const relay = userRelays.get(userId); + if (!relay) return { extensionConnected: false, targetCount: 0 }; + return { + extensionConnected: extensionConnected(relay), + targetCount: relay.connectedTargets.size, + }; +} + +export function getUserTargets(userId: number): ConnectedTarget[] { + const relay = userRelays.get(userId); + if (!relay) return []; + return Array.from(relay.connectedTargets.values()); +} + +export function startBrowserRelay(port: number) { + relayPort = port; + + const server = Bun.serve({ + port, + hostname: '0.0.0.0', + idleTimeout: 60, + + routes: { + '/': (req: Request) => { + if (req.method === 'HEAD') return new Response(null, { status: 200 }); + return new Response('OK', { status: 200, headers: { 'Content-Type': 'text/plain' } }); + }, + }, + + fetch(req, server) { + const url = new URL(req.url); + const path = url.pathname; + + // CORS for chrome-extension origins + const origin = req.headers.get('origin'); + const corsHeaders: Record = {}; + if (origin) { + corsHeaders['Access-Control-Allow-Origin'] = origin; + corsHeaders['Vary'] = 'Origin'; + } + + if (req.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { + ...corsHeaders, + 'Access-Control-Allow-Methods': 'GET, PUT, POST, OPTIONS', + 'Access-Control-Allow-Headers': `Content-Type, ${RELAY_AUTH_HEADER}`, + 'Access-Control-Max-Age': '86400', + }, + }); + } + + if (path === '/extension/status') { + const connectedCount = Array.from(userRelays.values()).filter((r) => extensionConnected(r)).length; + return Response.json({ connected: connectedCount > 0, users: connectedCount }, { headers: corsHeaders }); + } + + // Auth-protected endpoints + const token = getTokenFromRequest(req); + const userId = token ? resolveUserFromToken(token) : null; + + if (path === '/json/version' || path === '/json/version/') { + if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders }); + const relay = userRelays.get(userId); + const payload: Record = { + Browser: 'Officer/browser-relay', + 'Protocol-Version': '1.3', + }; + if (relay && extensionConnected(relay)) { + const hostHeader = req.headers.get('host')?.trim() || `0.0.0.0:${port}`; + payload.webSocketDebuggerUrl = `ws://${hostHeader}/cdp?token=${token}`; + } + return Response.json(payload, { headers: corsHeaders }); + } + + const listPaths = new Set(['/json', '/json/', '/json/list', '/json/list/']); + if (listPaths.has(path)) { + if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders }); + const relay = userRelays.get(userId); + const hostHeader = req.headers.get('host')?.trim() || `0.0.0.0:${port}`; + const cdpWsUrl = `ws://${hostHeader}/cdp?token=${token}`; + const list = relay + ? Array.from(relay.connectedTargets.values()).map((t) => ({ + id: t.targetId, + type: t.targetInfo.type ?? 'page', + title: t.targetInfo.title ?? '', + description: t.targetInfo.title ?? '', + url: t.targetInfo.url ?? '', + webSocketDebuggerUrl: cdpWsUrl, + })) + : []; + return Response.json(list, { headers: corsHeaders }); + } + + const activateMatch = path.match(/^\/json\/activate\/(.+)$/); + if (activateMatch) { + if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders }); + const targetId = decodeURIComponent(activateMatch[1]!).trim(); + if (!targetId) return new Response('targetId required', { status: 400, headers: corsHeaders }); + const relay = userRelays.get(userId); + if (relay && extensionConnected(relay)) { + void sendToExtension(relay, { + id: relay.nextExtensionId++, + method: 'forwardCDPCommand', + params: { method: 'Target.activateTarget', params: { targetId } }, + }).catch(() => {}); + } + return new Response('OK', { status: 200, headers: corsHeaders }); + } + + const closeMatch = path.match(/^\/json\/close\/(.+)$/); + if (closeMatch) { + if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders }); + const targetId = decodeURIComponent(closeMatch[1]!).trim(); + if (!targetId) return new Response('targetId required', { status: 400, headers: corsHeaders }); + const relay = userRelays.get(userId); + if (relay && extensionConnected(relay)) { + void sendToExtension(relay, { + id: relay.nextExtensionId++, + method: 'forwardCDPCommand', + params: { method: 'Target.closeTarget', params: { targetId } }, + }).catch(() => {}); + } + return new Response('OK', { status: 200, headers: corsHeaders }); + } + + // WebSocket upgrades + if (path === '/extension') { + if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders }); + const relay = getUserRelay(userId); + // Close stale extension WS + if (relay.extensionWs && relay.extensionWs.readyState !== WebSocket.OPEN) { + try { + (relay.extensionWs as ServerWebSocket).close(); + } catch { + // ignore + } + relay.extensionWs = null; + } + if (extensionConnected(relay)) { + return new Response('Extension already connected', { status: 409, headers: corsHeaders }); + } + const ok = server.upgrade(req, { data: { kind: 'extension', userId, token: token! } }); + if (!ok) return new Response('Upgrade failed', { status: 500, headers: corsHeaders }); + return undefined as unknown as Response; + } + + if (path === '/cdp') { + if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders }); + const relay = userRelays.get(userId); + if (!relay || !extensionConnected(relay)) { + return new Response('Extension not connected', { status: 503, headers: corsHeaders }); + } + const ok = server.upgrade(req, { data: { kind: 'cdp', userId, token: token! } }); + if (!ok) return new Response('Upgrade failed', { status: 500, headers: corsHeaders }); + return undefined as unknown as Response; + } + + return new Response('Not found', { status: 404, headers: corsHeaders }); + }, + + websocket: { + open(ws) { + const { kind, userId } = ws.data; + const relay = getUserRelay(userId); + + if (kind === 'extension') { + handleExtensionOpen(relay, ws); + } else { + relay.cdpClients.add(ws); + } + }, + + message(ws, raw) { + const { kind, userId } = ws.data; + const relay = userRelays.get(userId); + if (!relay) return; + const data = typeof raw === 'string' ? raw : new TextDecoder().decode(raw as unknown as ArrayBuffer); + + if (kind === 'extension') { + handleExtensionMessage(relay, data); + } else { + void handleCdpMessage(relay, ws, data); + } + }, + + close(ws) { + const { kind, userId } = ws.data; + const relay = userRelays.get(userId); + if (!relay) return; + + if (kind === 'extension') { + handleExtensionClose(relay, ws); + } else { + relay.cdpClients.delete(ws); + } + }, + }, + }); + + relayServer = server; + return server; +} + +export function stopBrowserRelay() { + if (!relayServer) return; + for (const [, relay] of userRelays) { + clearGracePeriodTimer(relay); + if (relay.pingInterval) clearInterval(relay.pingInterval); + flushExtensionReconnectWaiters(relay, false); + for (const [, pending] of relay.pendingExtension) { + clearTimeout(pending.timer); + pending.reject(new Error('server stopping')); + } + relay.pendingExtension.clear(); + try { + relay.extensionWs?.close(1001, 'server stopping'); + } catch { + // ignore + } + for (const ws of relay.cdpClients) { + try { + ws.close(1001, 'server stopping'); + } catch { + // ignore + } + } + } + userRelays.clear(); + relayServer.stop(); + relayServer = null; +} diff --git a/src/servers/api/browser/router.ts b/src/servers/api/browser/router.ts new file mode 100644 index 00000000..6521463c --- /dev/null +++ b/src/servers/api/browser/router.ts @@ -0,0 +1,135 @@ +import { createRouter } from '@@/create-router'; +import * as errors from '@@/custom-errors'; +import { registerUserToken } from './relay-auth'; +import { getRelayPort, getUserRelayStatus, getUserTargets } from './relay'; +import { captureScreenshot, evaluateJS, navigateTo } from './cdp'; + +export const browserRouter = createRouter(); + +function getOpts(userId: number) { + const port = getRelayPort(); + const token = registerUserToken(userId, port); + return { relayPort: port, userToken: token }; +} + +browserRouter.get('/status', async (ctx) => { + const user = ctx.get('user'); + const status = getUserRelayStatus(user.id); + return ctx.json(status); +}); + +browserRouter.get('/relay-token', async (ctx) => { + const user = ctx.get('user'); + const port = getRelayPort(); + const token = registerUserToken(user.id, port); + return ctx.json({ token, port }); +}); + +browserRouter.get('/targets', async (ctx) => { + const user = ctx.get('user'); + const targets = getUserTargets(user.id); + return ctx.json( + targets.map((t) => ({ + id: t.targetId, + sessionId: t.sessionId, + type: t.targetInfo.type ?? 'page', + title: t.targetInfo.title ?? '', + url: t.targetInfo.url ?? '', + })), + ); +}); + +browserRouter.get('/targets/:id/screenshot', async (ctx) => { + const user = ctx.get('user'); + const targetId = ctx.req.param('id'); + const format = (ctx.req.query('format') as 'png' | 'jpeg') || 'png'; + + const opts = getOpts(user.id); + // Find session ID for the target + const targets = getUserTargets(user.id); + const target = targets.find((t) => t.targetId === targetId); + if (!target) throw errors.NOT_FOUND('Target not found'); + + try { + const data = await captureScreenshot({ ...opts, targetId: target.sessionId }, format); + return ctx.json({ data, format }); + } catch (err) { + throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Screenshot failed'); + } +}); + +browserRouter.post('/targets/:id/evaluate', async (ctx) => { + const user = ctx.get('user'); + const targetId = ctx.req.param('id'); + const body = ctx.get('body') as { expression?: string }; + + if (!body.expression) throw errors.BAD_REQUEST('expression is required'); + + const opts = getOpts(user.id); + const targets = getUserTargets(user.id); + const target = targets.find((t) => t.targetId === targetId); + if (!target) throw errors.NOT_FOUND('Target not found'); + + try { + const result = await evaluateJS({ ...opts, targetId: target.sessionId }, body.expression); + return ctx.json({ result }); + } catch (err) { + throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Evaluation failed'); + } +}); + +browserRouter.post('/targets/:id/navigate', async (ctx) => { + const user = ctx.get('user'); + const targetId = ctx.req.param('id'); + const body = ctx.get('body') as { url?: string }; + + if (!body.url) throw errors.BAD_REQUEST('url is required'); + + const opts = getOpts(user.id); + const targets = getUserTargets(user.id); + const target = targets.find((t) => t.targetId === targetId); + if (!target) throw errors.NOT_FOUND('Target not found'); + + try { + await navigateTo({ ...opts, targetId: target.sessionId }, body.url); + return ctx.json({ ok: true }); + } catch (err) { + throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Navigation failed'); + } +}); + +browserRouter.post('/targets/:id/activate', async (ctx) => { + const user = ctx.get('user'); + const targetId = ctx.req.param('id'); + + const port = getRelayPort(); + const token = registerUserToken(user.id, port); + + try { + const res = await fetch(`http://127.0.0.1:${port}/json/activate/${encodeURIComponent(targetId)}`, { + headers: { 'x-officer-relay-token': token }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return ctx.json({ ok: true }); + } catch (err) { + throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Activate failed'); + } +}); + +browserRouter.post('/targets/:id/close', async (ctx) => { + const user = ctx.get('user'); + const targetId = ctx.req.param('id'); + + const port = getRelayPort(); + const token = registerUserToken(user.id, port); + + try { + const res = await fetch(`http://127.0.0.1:${port}/json/close/${encodeURIComponent(targetId)}`, { + headers: { 'x-officer-relay-token': token }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return ctx.json({ ok: true }); + } catch (err) { + throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Close failed'); + } +}); diff --git a/src/servers/api/email/email.ts b/src/servers/api/email/email.ts index bca4c7da..64fbb248 100644 --- a/src/servers/api/email/email.ts +++ b/src/servers/api/email/email.ts @@ -11,12 +11,15 @@ emailRouter.get('/messages', async (ctx) => { const email = ctx.get('user').email; const page = Number(ctx.req.query('page') ?? '1'); const limit = Number(ctx.req.query('limit') ?? '50'); + const folder = ctx.req.query('folder') ?? 'inbox'; const offset = (page - 1) * limit; + const folderWhere = folder === 'all' ? 'deleted = 0' : `deleted = 0 AND labels LIKE '%${folder}%'`; + const db = openEmailDb(email); try { - const rows = db.query('SELECT * FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ? OFFSET ?').all(limit, offset) as Record[]; - const countRow = db.query('SELECT COUNT(*) as total FROM emails WHERE deleted = 0').get() as { total: number }; + const rows = db.query(`SELECT * FROM emails WHERE ${folderWhere} ORDER BY date DESC LIMIT ? OFFSET ?`).all(limit, offset) as Record[]; + const countRow = db.query(`SELECT COUNT(*) as total FROM emails WHERE ${folderWhere}`).get() as { total: number }; const messages = rows.map(rowToSummary); return ctx.json({ messages, total: countRow.total }); } finally { @@ -122,15 +125,43 @@ emailRouter.get('/sync-status', async (ctx) => { emailRouter.get('/stats', async (ctx) => { const email = ctx.get('user').email; + const folder = ctx.req.query('folder') ?? 'inbox'; + + const folderWhere = folder === 'all' ? 'deleted = 0' : `deleted = 0 AND labels LIKE '%${folder}%'`; const db = openEmailDb(email); try { - const total = (db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number }).count; - const byDomain = db.query('SELECT from_domain, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_domain ORDER BY count DESC LIMIT 20').all() as Array<{ from_domain: string; count: number }>; - const bySender = db.query('SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_address ORDER BY count DESC LIMIT 20').all() as Array<{ from_address: string; from_name: string; count: number }>; + const total = (db.query(`SELECT COUNT(*) as count FROM emails WHERE ${folderWhere}`).get() as { count: number }).count; + const byDomain = db.query(`SELECT from_domain, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_domain ORDER BY count DESC LIMIT 20`).all() as Array<{ from_domain: string; count: number }>; + const bySender = db.query(`SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_address ORDER BY count DESC LIMIT 20`).all() as Array<{ from_address: string; from_name: string; count: number }>; return ctx.json({ total, byDomain, bySender }); } finally { db.close(); } }); + +emailRouter.get('/labels', async (ctx) => { + const email = ctx.get('user').email; + + const db = openEmailDb(email); + try { + const rows = db.query('SELECT labels FROM emails WHERE deleted = 0 AND labels IS NOT NULL').all() as Array<{ labels: string }>; + + const counts = new Map(); + for (const row of rows) { + for (const label of row.labels.split(',')) { + const trimmed = label.trim().toLowerCase(); + if (trimmed) counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1); + } + } + + const labels = [...counts.entries()] + .map(([label, count]) => ({ label, count })) + .sort((a, b) => b.count - a.count); + + return ctx.json({ labels }); + } finally { + db.close(); + } +}); diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index 94a06c43..8c9c0253 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -7,6 +7,7 @@ import { syncSeedResources } from './sync-resources'; import { migrateSettingsToResources } from './migrate-resources'; import { generateResourceSkill } from './api/pi/pi-bridge'; import { initQueue } from './queue'; +import { startDiscordBotIfConfigured } from './channels/discord/bot'; mkdirSync(DATA_PATH, { recursive: true }); @@ -61,4 +62,8 @@ async function installPi(): Promise { await initQueue().catch(err => { console.error('[bootstrap] Failed to initialize queue:', err); }); + + await startDiscordBotIfConfigured().catch(err => { + console.error('[channels] Failed to start Discord bot:', err); + }); })(); diff --git a/src/servers/channels/discord/bot.ts b/src/servers/channels/discord/bot.ts new file mode 100644 index 00000000..80bd5bef --- /dev/null +++ b/src/servers/channels/discord/bot.ts @@ -0,0 +1,65 @@ +import { Client, GatewayIntentBits, Partials, Events } from 'discord.js'; +import { getServerIntegration } from 'officerdb'; +import { handleDiscordMessage } from './handler'; + +let client: Client | null = null; + +export async function startDiscordBot(token: string): Promise { + if (client) { + await stopDiscordBot(); + } + + client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildPresences, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + partials: [Partials.Channel], + }); + + client.on(Events.MessageCreate, (message) => { + handleDiscordMessage(message).catch((err) => { + console.error('[discord] Unhandled error in message handler:', err); + }); + }); + + client.once(Events.ClientReady, (c) => { + console.log(`[discord] Bot logged in as ${c.user.tag}`); + }); + + await client.login(token); +} + +export async function stopDiscordBot(): Promise { + if (client) { + client.destroy(); + client = null; + console.log('[discord] Bot stopped'); + } +} + +export function isDiscordBotRunning(): boolean { + return client !== null && client.isReady(); +} + +export function getDiscordBotUsername(): string | null { + return client?.user?.tag ?? null; +} + +type DiscordConfig = { + botToken: string; +}; + +export async function startDiscordBotIfConfigured(): Promise { + const integration = await getServerIntegration('discord'); + if (!integration?.enabled) return; + + const config = integration.config as Record; + const botToken = config.botToken as string | undefined; + if (!botToken) return; + + await startDiscordBot(botToken); +} diff --git a/src/servers/channels/discord/chunker.ts b/src/servers/channels/discord/chunker.ts new file mode 100644 index 00000000..14897c48 --- /dev/null +++ b/src/servers/channels/discord/chunker.ts @@ -0,0 +1,51 @@ +const MAX_LENGTH = 2000; + +export function chunkMessage(text: string): string[] { + if (text.length <= MAX_LENGTH) return [text]; + + const chunks: string[] = []; + const paragraphs = text.split('\n\n'); + + let current = ''; + + for (const paragraph of paragraphs) { + if (paragraph.length > MAX_LENGTH) { + // Flush current chunk + if (current) { + chunks.push(current.trim()); + current = ''; + } + // Split long paragraph on newlines + const lines = paragraph.split('\n'); + for (const line of lines) { + if (line.length > MAX_LENGTH) { + // Flush current + if (current) { + chunks.push(current.trim()); + current = ''; + } + // Hard-split long line + for (let i = 0; i < line.length; i += MAX_LENGTH) { + chunks.push(line.slice(i, i + MAX_LENGTH)); + } + } else if (current.length + 1 + line.length > MAX_LENGTH) { + chunks.push(current.trim()); + current = line; + } else { + current += (current ? '\n' : '') + line; + } + } + } else if (current.length + 2 + paragraph.length > MAX_LENGTH) { + chunks.push(current.trim()); + current = paragraph; + } else { + current += (current ? '\n\n' : '') + paragraph; + } + } + + if (current.trim()) { + chunks.push(current.trim()); + } + + return chunks; +} diff --git a/src/servers/channels/discord/handler.ts b/src/servers/channels/discord/handler.ts new file mode 100644 index 00000000..726fedca --- /dev/null +++ b/src/servers/channels/discord/handler.ts @@ -0,0 +1,276 @@ +import type { Message as DiscordMessage } from 'discord.js'; +import { findUserByIntegrationConfig, readConfigValue } from 'officerdb'; +import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-await'; +import { consumePairingCode } from '../pairing'; +import { chunkMessage } from './chunker'; +import { listPiModels } from '@@/api/pi/list-models'; +import { enqueue } from '@@/queue/engine'; +import { readJob } from '@@/queue/storage'; +import { openEmailDb } from '@@/api/email/email-db'; +import type { ModelInfo } from '@@/api/pi/types'; + +const PAIRING_CODE_PATTERN = /^[A-Z0-9]{6}$/; +const TYPING_INTERVAL_MS = 8_000; + +type SendableChannel = { send: (content: string) => Promise }; + +type AccessPolicy = { allowedModels: string[] }; +const ACCESS_POLICY_KEY = 'pi-access-policy'; + +async function getVisibleModels(): Promise { + const allModels = await listPiModels(); + const policy = await readConfigValue(ACCESS_POLICY_KEY, { allowedModels: [] }); + const allowed = policy.allowedModels; + + if (allowed.length === 0) return allModels; + + const allowedSet = new Set(allowed); + const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0])); + + return allModels.filter((m) => { + const key = `${m.provider}:${m.id}`; + const isExplicitlyAllowed = allowedSet.has(key); + const isFromNewProvider = !allowedProviderSet.has(m.provider); + return isExplicitlyAllowed || isFromNewProvider; + }); +} + +type CommandContext = { + content: string; + channel: SendableChannel; + userId: number; + email: string; + discordId: string; +}; + +async function handleEmailSync(ctx: CommandContext): Promise { + const { channel, email } = ctx; + + // Count emails before sync + let countBefore = 0; + try { + const db = openEmailDb(email); + const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number }; + countBefore = row.count; + db.close(); + } catch { + // DB might not exist yet + } + + await channel.send('Syncing emails...'); + + const job = await enqueue({ lane: 'google-api', type: 'gmail-sync', userId: email }); + + // Poll until done + const poll = async (): Promise<'completed' | 'failed' | 'cancelled'> => { + for (let i = 0; i < 120; i++) { + await new Promise((r) => setTimeout(r, 3000)); + const current = await readJob(job.id); + if (!current) return 'failed'; + if (current.status === 'completed' || current.status === 'failed' || current.status === 'cancelled') { + return current.status; + } + } + return 'failed'; + }; + + const status = await poll(); + + if (status !== 'completed') { + await channel.send('Email sync failed. Check the queue dashboard for details.'); + return; + } + + // Count emails after sync and get newest ones + try { + const db = openEmailDb(email); + const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number }; + const countAfter = row.count; + const newCount = countAfter - countBefore; + + if (newCount <= 0) { + db.close(); + await channel.send('Sync complete — no new emails.'); + return; + } + + const newest = db.query( + 'SELECT from_name, from_address, subject FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ?', + ).all(Math.min(newCount, 20)) as Array<{ from_name: string | null; from_address: string; subject: string }>; + db.close(); + + const lines = newest.map((e) => { + const sender = e.from_name || e.from_address; + return `- **${sender}**: ${e.subject}`; + }); + + let text = `Sync complete — **${newCount}** new email${newCount !== 1 ? 's' : ''}`; + if (newCount > 20) text += ` (showing latest 20)`; + text += `:\n\n${lines.join('\n')}`; + + const chunks = chunkMessage(text); + for (const chunk of chunks) { + await channel.send(chunk); + } + } catch (err) { + await channel.send(`Sync complete but failed to read results: ${err instanceof Error ? err.message : String(err)}`); + } +} + +async function handleCommand(ctx: CommandContext): Promise { + const { content, channel, discordId } = ctx; + const lower = content.toLowerCase(); + + const helpSections: Record = { + models: + '**Models:**\n' + + '`!model` — show current model\n' + + '`!model ` — switch model\n' + + '`!models` — list available models', + email: + '**Email:**\n' + + '`!email sync` — sync Gmail and show new emails', + }; + + if (lower === '!help' || lower.startsWith('!help ')) { + const topic = content.slice('!help'.length).trim().toLowerCase(); + if (topic && topic in helpSections) { + await channel.send(helpSections[topic]!); + return true; + } + if (topic) { + await channel.send(`Unknown topic: \`${topic}\`\nAvailable: ${Object.keys(helpSections).map((k) => `\`${k}\``).join(', ')}`); + return true; + } + const full = Object.values(helpSections).join('\n\n'); + await channel.send(full + '\n\n`!help ` — show commands for a topic'); + return true; + } + + if (lower === '!models') { + const models = await getVisibleModels(); + if (models.length === 0) { + await channel.send('No models available.'); + return true; + } + const current = getSessionModel('discord', discordId); + const grouped = new Map(); + for (const m of models) { + const list = grouped.get(m.provider) ?? []; + list.push(m.id === current ? `**${m.id}** (current)` : m.id); + grouped.set(m.provider, list); + } + let text = '**Available models:**\n'; + for (const [provider, ids] of grouped) { + text += `\n__${provider}__\n${ids.map((id) => ` ${id}`).join('\n')}\n`; + } + text += '\nUse `!model ` to switch.'; + await channel.send(text); + return true; + } + + if (lower === '!model') { + const current = getSessionModel('discord', discordId); + await channel.send(current ? `Current model: **${current}**` : 'No active session yet — the default model will be used on your next message.'); + return true; + } + + if (lower.startsWith('!model ')) { + const requested = content.slice('!model '.length).trim(); + if (!requested) { + const current = getSessionModel('discord', discordId); + await channel.send(current ? `Current model: **${current}**` : 'No active session yet.'); + return true; + } + const models = await getVisibleModels(); + const match = models.find((m) => m.id === requested || m.name === requested); + if (!match) { + await channel.send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`); + return true; + } + setSessionModel('discord', discordId, match.id); + await channel.send(`Model switched to **${match.id}**. The new model will be used on your next message.`); + return true; + } + + if (lower === '!email sync') { + await handleEmailSync(ctx); + return true; + } + + // Not a recognized command — pass through to PI + return false; +} + +export async function handleDiscordMessage(message: DiscordMessage): Promise { + // Ignore bots and non-DM messages + if (message.author.bot) return; + if (!message.channel.isDMBased() || !('send' in message.channel)) return; + + const channel = message.channel; + const discordId = message.author.id; + const content = message.content.trim(); + if (!content) return; + + // Look up linked Officer user + const linked = await findUserByIntegrationConfig('discord', 'discordId', discordId); + + if (!linked) { + // Check if this is a pairing code + if (PAIRING_CODE_PATTERN.test(content.toUpperCase())) { + const result = await consumePairingCode(content.toUpperCase(), discordId); + if (result) { + await channel.send('Account linked! You can now chat with me.'); + return; + } + await channel.send('Invalid or expired pairing code. Please generate a new one from Officer Settings.'); + return; + } + + await channel.send( + 'I don\'t recognize your Discord account. To link it:\n' + + '1. Go to Officer Settings → Integrations → Discord\n' + + '2. Click "Link Discord" to get a pairing code\n' + + '3. Send the 6-character code to me here', + ); + return; + } + + // Handle commands + if (content.startsWith('!')) { + const handled = await handleCommand({ content, channel, userId: linked.user.id, email: linked.user.email, discordId }); + if (handled) return; + } + + // Start typing indicator with keep-alive + const sendTyping = () => { + if ('sendTyping' in channel) { + (channel as { sendTyping: () => Promise }).sendTyping().catch(() => {}); + } + }; + const typingInterval = setInterval(sendTyping, TYPING_INTERVAL_MS); + sendTyping(); + + try { + const result = await sendAndAwait({ + userId: linked.user.id, + email: linked.user.email, + username: linked.user.username ?? linked.user.email.split('@')[0]!, + prompt: content, + context: 'discord', + contextId: discordId, + }); + + clearInterval(typingInterval); + + const signature = `\`${result.model}\`\n`; + const chunks = chunkMessage(result.text); + for (let i = 0; i < chunks.length; i++) { + await channel.send(i === 0 ? signature + chunks[i]! : chunks[i]!); + } + } catch (err) { + clearInterval(typingInterval); + console.error('[discord] Error handling message:', err); + await channel.send('Sorry, something went wrong processing your message.').catch(() => {}); + } +} diff --git a/src/servers/channels/pairing.ts b/src/servers/channels/pairing.ts new file mode 100644 index 00000000..a81367bf --- /dev/null +++ b/src/servers/channels/pairing.ts @@ -0,0 +1,78 @@ +import type { ChannelProvider } from './types'; +import { upsertUserIntegration } from 'officerdb'; + +type PairingEntry = { + userId: number; + email: string; + provider: ChannelProvider; + expiresAt: number; +}; + +const pairingCodes = new Map(); + +const CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const CODE_LENGTH = 6; +const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I ambiguity + +function generateCode(): string { + let code = ''; + for (let i = 0; i < CODE_LENGTH; i++) { + code += CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]!; + } + return code; +} + +function cleanupExpiredCodes(): void { + const now = Date.now(); + for (const [code, entry] of pairingCodes) { + if (entry.expiresAt <= now) { + pairingCodes.delete(code); + } + } +} + +export function generatePairingCode(userId: number, email: string, provider: ChannelProvider): string { + cleanupExpiredCodes(); + + // Revoke any existing code for this user+provider + for (const [code, entry] of pairingCodes) { + if (entry.userId === userId && entry.provider === provider) { + pairingCodes.delete(code); + } + } + + let code: string; + do { + code = generateCode(); + } while (pairingCodes.has(code)); + + pairingCodes.set(code, { + userId, + email, + provider, + expiresAt: Date.now() + CODE_TTL_MS, + }); + + return code; +} + +type PairingResult = { + userId: number; + email: string; + provider: ChannelProvider; +}; + +export async function consumePairingCode(code: string, channelUserId: string): Promise { + const entry = pairingCodes.get(code.toUpperCase()); + if (!entry || entry.expiresAt <= Date.now()) return null; + + pairingCodes.delete(code.toUpperCase()); + + await upsertUserIntegration({ + userId: entry.userId, + provider: entry.provider, + config: { discordId: channelUserId }, + }); + + return { userId: entry.userId, email: entry.email, provider: entry.provider }; +} diff --git a/src/servers/channels/routes.ts b/src/servers/channels/routes.ts new file mode 100644 index 00000000..08d8c867 --- /dev/null +++ b/src/servers/channels/routes.ts @@ -0,0 +1,116 @@ +import { createRouter } from '@@/create-router'; +import { + getServerIntegration, + upsertServerIntegration, + getUserIntegration, + deleteUserIntegration, +} from 'officerdb'; +import { startDiscordBot, stopDiscordBot, isDiscordBotRunning, getDiscordBotUsername } from './discord/bot'; +import { generatePairingCode } from './pairing'; + +export const channelsRouter = createRouter(); + +// ── Admin: Discord config ── + +channelsRouter.get('/discord/config', async (ctx) => { + const user = ctx.get('user'); + if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403); + + const integration = await getServerIntegration('discord'); + if (!integration) return ctx.json({ configured: false }); + + const config = integration.config as Record; + const botToken = config.botToken as string | undefined; + + return ctx.json({ + configured: !!botToken, + enabled: integration.enabled, + botToken: botToken ? `${botToken.slice(0, 8)}...${botToken.slice(-4)}` : null, + serverInvite: (config.serverInvite as string) ?? null, + botHandle: (config.botHandle as string) ?? null, + }); +}); + +channelsRouter.put('/discord/config', async (ctx) => { + const user = ctx.get('user'); + if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403); + + const body = ctx.get('body') as Record; + const botToken = body.botToken as string | undefined; + const enabled = body.enabled as boolean | undefined; + const serverInvite = body.serverInvite as string | undefined; + const botHandle = body.botHandle as string | undefined; + + if (!botToken && enabled === undefined && serverInvite === undefined && botHandle === undefined) { + return ctx.json({ error: 'At least one field required' }, 400); + } + + const existing = await getServerIntegration('discord'); + const existingConfig = (existing?.config ?? {}) as Record; + const newConfig = { ...existingConfig }; + if (botToken) newConfig.botToken = botToken; + if (serverInvite !== undefined) newConfig.serverInvite = serverInvite; + if (botHandle !== undefined) newConfig.botHandle = botHandle; + + await upsertServerIntegration('discord', newConfig, enabled ?? existing?.enabled ?? true); + + // Restart bot if running or if we have a token and it's enabled + const shouldRun = enabled ?? existing?.enabled ?? true; + const token = (botToken ?? existingConfig.botToken) as string | undefined; + + if (token && shouldRun) { + try { + await startDiscordBot(token); + } catch (err) { + console.error('[channels] Failed to start Discord bot:', err); + return ctx.json({ success: true, botStarted: false, error: String(err) }); + } + return ctx.json({ success: true, botStarted: true }); + } + + if (!shouldRun) { + await stopDiscordBot(); + } + + return ctx.json({ success: true, botStarted: false }); +}); + +channelsRouter.get('/discord/status', async (ctx) => { + const integration = await getServerIntegration('discord'); + const config = (integration?.config ?? {}) as Record; + + return ctx.json({ + configured: !!config.botToken, + enabled: integration?.enabled ?? false, + running: isDiscordBotRunning(), + botUsername: getDiscordBotUsername(), + serverInvite: (config.serverInvite as string) ?? null, + botHandle: (config.botHandle as string) ?? null, + }); +}); + +// ── User: Discord pairing ── + +channelsRouter.post('/discord/pair', async (ctx) => { + const user = ctx.get('user'); + const code = generatePairingCode(user.id, user.email, 'discord'); + return ctx.json({ code, expiresIn: 600 }); +}); + +channelsRouter.get('/discord/connection', async (ctx) => { + const user = ctx.get('user'); + const integration = await getUserIntegration(user.id, 'discord'); + if (!integration) return ctx.json({ linked: false }); + + const config = integration.config as Record; + return ctx.json({ + linked: true, + discordId: config.discordId, + }); +}); + +channelsRouter.delete('/discord/connection', async (ctx) => { + const user = ctx.get('user'); + const deleted = await deleteUserIntegration(user.id, 'discord'); + return ctx.json({ success: deleted }); +}); diff --git a/src/servers/channels/send-and-await.ts b/src/servers/channels/send-and-await.ts new file mode 100644 index 00000000..b0f12c0c --- /dev/null +++ b/src/servers/channels/send-and-await.ts @@ -0,0 +1,325 @@ +import { randomUUID } from 'crypto'; +import { join } from 'path'; +import type { PiEvent, MessageCost, Message } from '@@/api/pi/types'; +import { sessionManager } from '@@/api/pi/session-manager'; +import * as storage from '@@/api/pi/storage'; +import * as piBridge from '@@/api/pi/pi-bridge'; +import { getHomeDir } from '@@/data-path'; +import { getUserSettings } from 'officerdb'; +import { logger } from '@@/api/pi/logger'; + +const DEFAULT_MODEL = 'opencode/big-pickle'; +const IDLE_TIMEOUT_MS = 60 * 60 * 1000; +const SEND_TIMEOUT_MS = 5 * 60 * 1000; + +type SendAndAwaitParams = { + userId: number; + email: string; + username: string; + prompt: string; + context: string; + contextId: string; + model?: string; +}; + +type SendAndAwaitResult = { + text: string; + sessionId: string; + model: string; + cost: MessageCost; +}; + +// Per-session mutex to serialize concurrent prompts +const sessionLocks = new Map>(); + +// Per-session callback — swapped each time a new prompt is sent +type EventCallback = (event: PiEvent) => void; +const sessionCallbacks = new Map(); + +// Channel model overrides — survive session eviction/recreation +const channelModelOverrides = new Map(); + +async function getUserDefaultModel(userId: number): Promise { + try { + const settings = await getUserSettings(userId); + const chat = settings?.chat as Record | undefined; + return (chat?.defaultModel as string) || null; + } catch { + return null; + } +} + +export function getSessionModel(context: string, contextId: string): string | null { + const sessionId = `channel-${context}-${contextId}`; + const session = sessionManager.getSession(sessionId); + return session?.model ?? channelModelOverrides.get(sessionId) ?? null; +} + +export function setSessionModel(context: string, contextId: string, model: string): void { + const sessionId = `channel-${context}-${contextId}`; + // Store override independently of session — survives idle eviction + channelModelOverrides.set(sessionId, model); + + const session = sessionManager.getSession(sessionId); + if (session) { + session.model = model; + session.meta.model = model; + // Kill existing PI process so it respawns with the new model + if (session.piProcess) { + piBridge.killPi(session.piProcess); + session.piProcess = null; + } + logger.info('Channel model switched', { sessionId, model, killedProcess: true }); + } else { + logger.info('Channel model override stored (no active session)', { sessionId, model }); + } +} + +export async function sendAndAwait(params: SendAndAwaitParams): Promise { + const { context, contextId } = params; + const sessionId = `channel-${context}-${contextId}`; + + // Serialize per session — if two messages arrive at once, second waits for first + const existing = sessionLocks.get(sessionId) ?? Promise.resolve(); + let releaseLock: () => void; + const lockPromise = new Promise((resolve) => { + releaseLock = resolve; + }); + sessionLocks.set(sessionId, existing.then(() => lockPromise)); + + await existing; + + try { + return await doSend(sessionId, params); + } finally { + releaseLock!(); + if (sessionLocks.get(sessionId) === existing.then(() => lockPromise)) { + sessionLocks.delete(sessionId); + } + } +} + +// Persistent event dispatcher — registered once at spawn time, delegates to current callback +function createDispatcher(sessionId: string): (event: PiEvent) => void { + return (event: PiEvent) => { + const cb = sessionCallbacks.get(sessionId); + if (cb) cb(event); + }; +} + +async function doSend(sessionId: string, params: SendAndAwaitParams): Promise { + const { userId, email, username, prompt, context, contextId } = params; + const homeDir = getHomeDir(email); + const cwd = homeDir; + + // Resolve model: explicit param > channel override > existing session model > user default > system default + const existingSession = sessionManager.getSession(sessionId); + let model = params.model ?? channelModelOverrides.get(sessionId) ?? existingSession?.model; + if (!model) { + const userDefault = await getUserDefaultModel(userId); + model = userDefault ?? DEFAULT_MODEL; + } + + const session = sessionManager.getOrCreate(sessionId, email, cwd, model, null, context, contextId); + session.model = model; + session.meta.model = model; + session.userId = userId; + logger.info('Channel doSend', { sessionId, model, hasProcess: !!session.piProcess }); + + return new Promise((resolve, reject) => { + let resultText = ''; + const cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 }; + let settled = false; + + const timeout = setTimeout(() => { + if (!settled) { + settled = true; + session.isGenerating = false; + sessionCallbacks.delete(sessionId); + reject(new Error('sendAndAwait timed out after 5 minutes')); + } + }, SEND_TIMEOUT_MS); + + const settle = () => { + sessionCallbacks.delete(sessionId); + clearTimeout(timeout); + settled = true; + }; + + // Register per-prompt callback — the persistent dispatcher will call this + sessionCallbacks.set(sessionId, (event: PiEvent) => { + if (settled) return; + + switch (event.type) { + case 'delta': { + session.streamBuffer += event.text; + break; + } + + case 'text': { + const text = event.text || session.streamBuffer; + if (text) { + resultText += (resultText ? '\n\n' : '') + text; + const assistantMsg: Message = { + id: randomUUID(), + timestamp: Date.now(), + role: 'assistant', + text, + model, + }; + session.messages.push(assistantMsg); + session.meta.messageCount += 1; + session.streamBuffer = ''; + } + break; + } + + case 'tool:start': { + if (session.streamBuffer) { + resultText += (resultText ? '\n\n' : '') + session.streamBuffer; + const assistantMsg: Message = { + id: randomUUID(), + timestamp: Date.now(), + role: 'assistant', + text: session.streamBuffer, + model, + }; + session.messages.push(assistantMsg); + session.meta.messageCount += 1; + session.streamBuffer = ''; + } + const toolMsg: Message = { + id: randomUUID(), + timestamp: Date.now(), + role: 'tool', + toolCallId: event.toolCallId, + toolName: event.toolName, + toolInput: event.toolInput, + }; + session.messages.push(toolMsg); + session.meta.messageCount += 1; + break; + } + + case 'tool:result': { + for (let i = session.messages.length - 1; i >= 0; i--) { + const m = session.messages[i]!; + if (m.role === 'tool' && m.toolCallId === event.toolCallId) { + m.output = event.output; + m.isError = event.isError; + break; + } + } + break; + } + + case 'result': { + if (session.streamBuffer) { + resultText += (resultText ? '\n\n' : '') + session.streamBuffer; + const assistantMsg: Message = { + id: randomUUID(), + timestamp: Date.now(), + role: 'assistant', + text: session.streamBuffer, + model, + cost: event.cost, + }; + session.messages.push(assistantMsg); + session.meta.messageCount += 1; + session.streamBuffer = ''; + } + + session.isGenerating = false; + session.meta.cost.inputTokens += event.cost.inputTokens; + session.meta.cost.outputTokens += event.cost.outputTokens; + session.meta.cost.totalUSD += event.cost.totalUSD; + session.meta.updatedAt = Date.now(); + + cost.inputTokens = event.cost.inputTokens; + cost.outputTokens = event.cost.outputTokens; + cost.totalUSD = event.cost.totalUSD; + + storage.saveSession(homeDir, sessionId, session.meta, session.messages).catch((err) => { + logger.error('Failed to save channel session', { sessionId, error: String(err) }); + }); + + sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS); + + settle(); + resolve({ text: resultText || '(no response)', sessionId, model: model!, cost }); + break; + } + + case 'error': { + session.isGenerating = false; + settle(); + reject(new Error(event.message)); + break; + } + + case 'stopped': { + session.isGenerating = false; + settle(); + resolve({ text: resultText || '(stopped)', sessionId, model: model!, cost }); + break; + } + } + }); + + // Spawn Pi process if not running + (async () => { + try { + if (!session.piProcess) { + let spawnOptions: { sessionFile?: string } | undefined; + if (session.messages.length > 0) { + await storage.saveSession(homeDir, sessionId, session.meta, session.messages); + const hostPath = await storage.getSessionFilePath(homeDir, sessionId); + if (hostPath) { + // Remap host path to container path + const containerHome = `/home/${username}`; + const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions'); + const relativePart = hostPath.slice(sessionsPrefix.length); + spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` }; + } + } + + const dispatcher = createDispatcher(sessionId); + const sandbox = { userId, username, email, homeDir }; + session.piProcess = await piBridge.spawnPi(cwd, model!, userId, email, dispatcher, sandbox, spawnOptions); + session.sandboxed = true; + + const proc = session.piProcess; + proc.exited.then(() => { + if (session.piProcess === proc) { + session.piProcess = null; + logger.info('Channel Pi process exited', { sessionId }); + } + }); + + logger.info('Spawned Pi for channel session', { sessionId, model, context }); + } + + // Add user message + const userMsg: Message = { + id: randomUUID(), + timestamp: Date.now(), + role: 'user', + text: prompt, + }; + session.messages.push(userMsg); + session.meta.messageCount += 1; + session.meta.updatedAt = Date.now(); + + if (!session.meta.title) { + session.meta.title = prompt.slice(0, 100); + } + + session.isGenerating = true; + piBridge.sendPrompt(session.piProcess, prompt, randomUUID()); + } catch (err) { + settle(); + reject(err); + } + })(); + }); +} diff --git a/src/servers/channels/types.ts b/src/servers/channels/types.ts new file mode 100644 index 00000000..c09ca3d1 --- /dev/null +++ b/src/servers/channels/types.ts @@ -0,0 +1,8 @@ +export type ChannelProvider = 'discord' | 'telegram' | 'whatsapp'; + +export type ChannelBot = { + provider: ChannelProvider; + start: (token: string) => Promise; + stop: () => Promise; + isRunning: () => boolean; +}; diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 62cf90a0..5d008e6b 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -24,6 +24,8 @@ import { dockRouter } from './api/dock/dock'; import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations'; import { queueRouter } from './api/queue/queue'; import { emailRouter } from './api/email/email'; +import { channelsRouter } from './channels/routes'; +import { browserRouter } from './api/browser/router'; import { CustomError } from './custom-errors'; import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares'; @@ -79,6 +81,8 @@ protectedRouter.route('/dock', dockRouter); protectedRouter.route('/integrations', integrationsRouter); protectedRouter.route('/queue', queueRouter); protectedRouter.route('/email', emailRouter); +protectedRouter.route('/channels', channelsRouter); +protectedRouter.route('/browser', browserRouter); protectedRouter.route('/', piRestRouter); honoServer.route('/api', protectedRouter); diff --git a/src/servers/queue/handlers/gmail-sync.ts b/src/servers/queue/handlers/gmail-sync.ts index b7397f8c..cda4dd8d 100644 --- a/src/servers/queue/handlers/gmail-sync.ts +++ b/src/servers/queue/handlers/gmail-sync.ts @@ -383,8 +383,7 @@ const gmailSyncHandler: JobHandler = { if (year) { // Year-scoped sync: count total emails first, then sync month by month - const labelScope = '(in:inbox OR in:sent OR in:trash OR in:spam)'; - const yearQuery = `${labelScope} after:${year}/1/1 before:${year + 1}/1/1`; + const yearQuery = `after:${year}/1/1 before:${year + 1}/1/1`; await ctx.updateProgress({ current: 0, total: 0, label: 'Counting emails...' }); const totalEmails = await countMessages(token, yearQuery); console.log(`[gmail-sync] Pre-flight: ${totalEmails} emails for ${year}`); @@ -393,7 +392,7 @@ const gmailSyncHandler: JobHandler = { for (let i = 0; i < months.length; i++) { const month = months[i]!; await ctx.updateProgress({ current: totalSaved + totalSkipped, total: totalEmails, label: month.label }); - const query = `${labelScope} after:${month.after} before:${month.before}`; + const query = `after:${month.after} before:${month.before}`; const result = await syncInbox(token, db, ctx.job.userId, query, (p) => { const current = totalSaved + p.saved + p.skipped + p.errors; const label = `${month.label} — Saved ${(totalSaved + p.saved).toLocaleString()} of ${totalEmails.toLocaleString()}`; @@ -411,14 +410,11 @@ const gmailSyncHandler: JobHandler = { } await ctx.updateProgress({ current: totalEmails, total: totalEmails, label: 'Done' }); } else { - // Scope to inbox + sent to avoid syncing trash/spam/drafts const lastSyncDate = getSyncMeta(db, 'last_sync_date'); - const labelScope = 'in:inbox OR in:sent OR in:trash OR in:spam'; - let syncQuery: string = labelScope; + let syncQuery: string | undefined; if (lastSyncDate) { const d = new Date(lastSyncDate); - const dateScope = `after:${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; - syncQuery = `(${labelScope}) ${dateScope}`; + syncQuery = `after:${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; console.log(`[gmail-sync] Scoping full sync with query: ${syncQuery}`); }