Discord
This commit is contained in:
@@ -5,3 +5,4 @@ MAIL_TRANSPORT="smtp://localhost:1025"
|
|||||||
PUBLIC_URL=http://localhost:9000
|
PUBLIC_URL=http://localhost:9000
|
||||||
DATA_PATH=/path/to/data
|
DATA_PATH=/path/to/data
|
||||||
HOME_DIR=/home/user
|
HOME_DIR=/home/user
|
||||||
|
BROWSER_RELAY_PORT=18792
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"cron": "^4.3.3",
|
"cron": "^4.3.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"definitions": "workspace:*",
|
"definitions": "workspace:*",
|
||||||
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"emailer": "workspace:*",
|
"emailer": "workspace:*",
|
||||||
@@ -217,6 +218,7 @@
|
|||||||
"@react-email/code-block": "^0.0.11",
|
"@react-email/code-block": "^0.0.11",
|
||||||
"@react-email/components": "^0.0.31",
|
"@react-email/components": "^0.0.31",
|
||||||
"@react-email/render": "^1.0.3",
|
"@react-email/render": "^1.0.3",
|
||||||
|
"officerdb": "workspace:*",
|
||||||
"react-email": "^3.0.4",
|
"react-email": "^3.0.4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -315,6 +317,18 @@
|
|||||||
|
|
||||||
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
|
||||||
|
|
||||||
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
|
"@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=="],
|
"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=="],
|
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||||
|
|
||||||
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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-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=="],
|
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"refractor/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
|
"refractor/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"cron": "^4.3.3",
|
"cron": "^4.3.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"definitions": "workspace:*",
|
"definitions": "workspace:*",
|
||||||
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"emailer": "workspace:*",
|
"emailer": "workspace:*",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
version: 3
|
version: 3
|
||||||
name: email_db
|
name: email_db
|
||||||
label: Email Database
|
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
|
language: typescript
|
||||||
inputs:
|
inputs:
|
||||||
action:
|
action:
|
||||||
@@ -36,6 +36,10 @@ inputs:
|
|||||||
type: string
|
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."
|
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
|
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:
|
limit:
|
||||||
type: number
|
type: number
|
||||||
description: "Maximum number of results to return (default 20, max 100)."
|
description: "Maximum number of results to return (default 20, max 100)."
|
||||||
@@ -65,9 +69,9 @@ emails (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
integration TEXT, -- source: 'gmail', 'outlook', etc.
|
integration TEXT, -- source: 'gmail', 'outlook', etc.
|
||||||
email_account TEXT, -- which account: 'user@gmail.com'
|
email_account TEXT, -- which account: 'user@gmail.com'
|
||||||
from_name TEXT,
|
from_name TEXT, -- sender display name
|
||||||
from_address TEXT,
|
from_address TEXT, -- sender email (lowercase)
|
||||||
from_domain TEXT,
|
from_domain TEXT, -- domain extracted from sender
|
||||||
to_address TEXT,
|
to_address TEXT,
|
||||||
cc TEXT,
|
cc TEXT,
|
||||||
subject TEXT,
|
subject TEXT,
|
||||||
@@ -77,7 +81,8 @@ emails (
|
|||||||
text_body TEXT,
|
text_body TEXT,
|
||||||
attachment_count INTEGER,
|
attachment_count INTEGER,
|
||||||
read INTEGER,
|
read INTEGER,
|
||||||
deleted INTEGER
|
deleted INTEGER,
|
||||||
|
labels TEXT -- comma-separated label list (e.g. 'INBOX,UNREAD,CATEGORY_UPDATES')
|
||||||
)
|
)
|
||||||
|
|
||||||
attachments (
|
attachments (
|
||||||
@@ -91,10 +96,13 @@ attachments (
|
|||||||
|
|
||||||
## Examples
|
## 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"`
|
- Count emails from a domain: `action: "count", domain: "newsletter.com"`
|
||||||
- Delete all emails from a domain: `action: "delete", domain: "spam.com"`
|
- Delete all emails from a domain: `action: "delete", domain: "spam.com"`
|
||||||
- Top 10 domains: `action: "domains", limit: 10`
|
- Top 10 domains: `action: "domains", limit: 10`
|
||||||
|
- Stats for spam folder: `action: "stats", folder: "spam"`
|
||||||
- List attachment types: `action: "attachment-types"`
|
- List attachment types: `action: "attachment-types"`
|
||||||
- Find image attachments: `action: "attachments", content_type: "image"`
|
- Find image attachments: `action: "attachments", content_type: "image"`
|
||||||
- Find PDFs from a sender: `action: "attachments", content_type: "application/pdf", sender: "boss@company.com"`
|
- Find PDFs from a sender: `action: "attachments", content_type: "application/pdf", sender: "boss@company.com"`
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Params = {
|
|||||||
before?: string;
|
before?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
content_type?: string;
|
content_type?: string;
|
||||||
|
folder?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,9 +73,18 @@ function formatRows(rows: Record<string, unknown>[], limit: number): string {
|
|||||||
|
|
||||||
// ── Helpers ──
|
// ── 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 conditions: string[] = [];
|
||||||
|
|
||||||
|
const folder = params.folder ?? defaultFolder;
|
||||||
|
const folderCond = buildFolderCondition(folder);
|
||||||
|
if (folderCond) conditions.push(folderCond);
|
||||||
|
|
||||||
if (params.domain) {
|
if (params.domain) {
|
||||||
conditions.push(`from_domain = ${sqlStr(params.domain.toLowerCase())}`);
|
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 {
|
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}`);
|
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);
|
return formatRows(rows, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStats(): string {
|
function getStats(params: Params): string {
|
||||||
const totalRows = queryJson('SELECT COUNT(*) as count FROM emails WHERE deleted = 0');
|
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;
|
const total = (totalRows[0]?.count as number) ?? 0;
|
||||||
if (total === 0) return 'Email database is empty.';
|
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 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 WHERE deleted = 0 GROUP BY from_domain ORDER BY count DESC LIMIT 10');
|
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 WHERE deleted = 0 GROUP BY from_address 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 WHERE deleted = 0 AND attachment_count > 0');
|
const attRows = queryJson(`SELECT COUNT(*) as count FROM emails ${folderWhere} AND attachment_count > 0`);
|
||||||
const withAttachments = (attRows[0]?.count as number) ?? 0;
|
const withAttachments = (attRows[0]?.count as number) ?? 0;
|
||||||
|
|
||||||
const dr = dateRange[0] ?? {};
|
const dr = dateRange[0] ?? {};
|
||||||
@@ -146,7 +157,7 @@ function getStats(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function countEmails(params: Params): 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 rows = queryJson(`SELECT COUNT(*) as count FROM emails ${where}`);
|
||||||
const count = (rows[0]?.count as number) ?? 0;
|
const count = (rows[0]?.count as number) ?? 0;
|
||||||
|
|
||||||
@@ -161,16 +172,18 @@ function countEmails(params: Params): string {
|
|||||||
return `${count} email${count !== 1 ? 's' : ''}${desc}.`;
|
return `${count} email${count !== 1 ? 's' : ''}${desc}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listDomains(limit: number): string {
|
function listDomains(params: Params, 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}`);
|
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.';
|
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' : ''}`);
|
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');
|
return [`**Sender Domains** (${rows.length}):`, '', ...lines].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function listSenders(limit: number): string {
|
function listSenders(params: Params, 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}`);
|
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.';
|
if (rows.length === 0) return 'No emails in database.';
|
||||||
|
|
||||||
const lines = rows.map((s, i) => {
|
const lines = rows.map((s, i) => {
|
||||||
@@ -217,7 +230,7 @@ function searchAttachments(params: Params, limit: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deleteEmails(params: Params): string {
|
function deleteEmails(params: Params): string {
|
||||||
const where = buildWhereClause(params);
|
const where = buildWhereClause(params, 'inbox');
|
||||||
if (!where) {
|
if (!where) {
|
||||||
throw new Error('Delete requires at least one filter (domain, sender, before, after, or search).');
|
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<Tool
|
|||||||
return ok(searchEmails(params, limit));
|
return ok(searchEmails(params, limit));
|
||||||
|
|
||||||
case 'stats':
|
case 'stats':
|
||||||
return ok(getStats());
|
return ok(getStats(params));
|
||||||
|
|
||||||
case 'count':
|
case 'count':
|
||||||
return ok(countEmails(params));
|
return ok(countEmails(params));
|
||||||
|
|
||||||
case 'domains':
|
case 'domains':
|
||||||
return ok(listDomains(limit));
|
return ok(listDomains(params, limit));
|
||||||
|
|
||||||
case 'senders':
|
case 'senders':
|
||||||
return ok(listSenders(limit));
|
return ok(listSenders(params, limit));
|
||||||
|
|
||||||
case 'attachment-types':
|
case 'attachment-types':
|
||||||
return ok(listAttachmentTypes(limit));
|
return ok(listAttachmentTypes(limit));
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function App() {
|
|||||||
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
||||||
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
||||||
<Route path="/email" element={<Dashboard.EmailScreen />} />
|
<Route path="/email" element={<Dashboard.EmailScreen />} />
|
||||||
|
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
|
||||||
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
||||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -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<string | null>('BROWSER_SELECTED_TAB', null);
|
||||||
|
const workspace = useWorkspacesState<LayoutNode>('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 (
|
||||||
|
<div className="h-full w-full pt-2">
|
||||||
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
components={components}
|
||||||
|
mobilePanelId={mobilePanelId}
|
||||||
|
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<string | null>('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<RelayToken>('/browser/relay-token'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: targets, isLoading } = useQuery({
|
||||||
|
queryKey: ['browser-targets'],
|
||||||
|
queryFn: () => client.get<Target[]>('/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 (
|
||||||
|
<div className="flex h-full flex-col overflow-y-auto">
|
||||||
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<Globe className="h-4 w-4 opacity-60" />
|
||||||
|
<span className="text-sm font-medium">Browser</span>
|
||||||
|
<div
|
||||||
|
className={`ml-auto h-2 w-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}
|
||||||
|
title={isConnected ? 'Extension connected' : 'Extension not connected'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isConnected && (
|
||||||
|
<div className="flex flex-col gap-3 p-4 text-sm">
|
||||||
|
<p className="opacity-60">Connect the Officer Browser Relay extension to start.</p>
|
||||||
|
|
||||||
|
{tokenData && (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium opacity-60">Server address</span>
|
||||||
|
<code className="text-xs">{window.location.hostname}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium opacity-60">Relay port</span>
|
||||||
|
<code className="text-xs">{tokenData.port}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium opacity-60">Relay token</span>
|
||||||
|
<Button variant="ghost" size="sm" className="ml-auto h-6 px-2" onClick={handleCopyToken}>
|
||||||
|
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||||
|
<span className="ml-1 text-xs">{copied ? 'Copied' : 'Copy'}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ol className="list-inside list-decimal space-y-1 text-xs opacity-50">
|
||||||
|
<li>Load the extension from <code>src/extensions/browser-relay/</code></li>
|
||||||
|
<li>Open extension options</li>
|
||||||
|
<li>Enter the server address, port, and token</li>
|
||||||
|
<li>Click the extension icon on a tab</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isConnected && isLoading && (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin opacity-50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isConnected && !isLoading && targetList.length === 0 && (
|
||||||
|
<div className="flex h-32 flex-col items-center justify-center gap-2 text-sm opacity-50">
|
||||||
|
<Globe className="h-6 w-6" />
|
||||||
|
<span>No tabs attached</span>
|
||||||
|
<span className="text-xs">Click the extension icon on a tab</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isConnected && targetList.length > 0 && (
|
||||||
|
<div className="flex flex-1 flex-col divide-y divide-white/10">
|
||||||
|
{targetList.map((target) => (
|
||||||
|
<button
|
||||||
|
key={target.id}
|
||||||
|
onClick={() => setSelectedId(target.id)}
|
||||||
|
className={`group flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer ${
|
||||||
|
selectedId === target.id ? 'bg-accent' : 'hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{target.title || 'Untitled'}</span>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
handleActivate(target.id);
|
||||||
|
}}
|
||||||
|
className="rounded p-0.5 hover:bg-black/10"
|
||||||
|
title="Focus tab"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
handleClose(target.id);
|
||||||
|
}}
|
||||||
|
className="rounded p-0.5 hover:bg-black/10"
|
||||||
|
title="Close tab"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-xs opacity-50">{target.url}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<string | null>('BROWSER_SELECTED_TAB', null);
|
||||||
|
const [navUrl, setNavUrl] = useState('');
|
||||||
|
const [evalExpr, setEvalExpr] = useState('');
|
||||||
|
const [evalResult, setEvalResult] = useState<string | null>(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 (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
|
||||||
|
<Globe className="h-8 w-8" />
|
||||||
|
<span>Select a tab to preview</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
{/* URL bar */}
|
||||||
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => void refetch()} disabled={isFetching}>
|
||||||
|
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
|
<form
|
||||||
|
className="flex flex-1 items-center gap-2"
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
void handleNavigate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={navUrl}
|
||||||
|
onChange={(ev) => 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"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" type="submit" disabled={isNavigating || !navUrl.trim()}>
|
||||||
|
<Send className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Screenshot */}
|
||||||
|
<div className="flex-1 overflow-auto p-2">
|
||||||
|
{isLoading && !screenshot && (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin opacity-50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{screenshot && (
|
||||||
|
<img
|
||||||
|
src={`data:image/${screenshot.format};base64,${screenshot.data}`}
|
||||||
|
alt="Tab screenshot"
|
||||||
|
className="w-full rounded-md border"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JS Console */}
|
||||||
|
<div className="border-t">
|
||||||
|
<form
|
||||||
|
className="flex items-center gap-2 px-3 py-2"
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
void handleEvaluate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Terminal className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={evalExpr}
|
||||||
|
onChange={(ev) => setEvalExpr(ev.target.value)}
|
||||||
|
placeholder="Evaluate JavaScript..."
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none font-mono"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 px-2 shrink-0" type="submit" disabled={isEvaluating || !evalExpr.trim()}>
|
||||||
|
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{evalResult !== null && (
|
||||||
|
<pre className="max-h-32 overflow-auto border-t px-3 py-2 text-xs font-mono opacity-70">{evalResult}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { BrowserScreen } from './BrowserScreen';
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
@@ -18,6 +18,13 @@ type GoogleStatus = {
|
|||||||
|
|
||||||
const LIMIT = 50;
|
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 formatDate = (iso: string) => {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -32,6 +39,7 @@ export const EmailList = () => {
|
|||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||||
|
const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const { jobs, createJob } = useJobs({ type: 'gmail-sync' });
|
const { jobs, createJob } = useJobs({ type: 'gmail-sync' });
|
||||||
const isSyncing = jobs.some((j) => j.status === 'queued' || j.status === 'running');
|
const isSyncing = jobs.some((j) => j.status === 'queued' || j.status === 'running');
|
||||||
@@ -42,11 +50,16 @@ export const EmailList = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['email-messages', page],
|
queryKey: ['email-messages', page, folder],
|
||||||
queryFn: () =>
|
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 () => {
|
const handleSync = async () => {
|
||||||
try {
|
try {
|
||||||
await createJob({ lane: 'google-api', type: 'gmail-sync', notify: false });
|
await createJob({ lane: 'google-api', type: 'gmail-sync', notify: false });
|
||||||
@@ -106,8 +119,25 @@ export const EmailList = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-y-auto">
|
<div className="flex h-full flex-col overflow-y-auto">
|
||||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
<Mail className="h-4 w-4 opacity-60" />
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-sm font-medium">Inbox</span>
|
{FOLDERS.map((f) => {
|
||||||
|
const Icon = f.icon;
|
||||||
|
const isActive = folder === f.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
onClick={() => handleFolderChange(f.key)}
|
||||||
|
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors cursor-pointer ${
|
||||||
|
isActive ? 'bg-accent font-medium' : 'opacity-60 hover:opacity-100 hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
title={f.label}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">{f.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<span className="text-xs opacity-50">{total}</span>
|
<span className="text-xs opacity-50">{total}</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const Dock = ({ items, className }: DockProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail } from 'lucide-react';
|
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail, Globe } from 'lucide-react';
|
||||||
|
|
||||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
||||||
@@ -123,6 +123,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
|||||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||||
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
||||||
|
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||||
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
|
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Copy } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
type DiscordConnection = {
|
||||||
|
linked: boolean;
|
||||||
|
discordId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscordStatus = {
|
||||||
|
configured: boolean;
|
||||||
|
running: boolean;
|
||||||
|
serverInvite: string | null;
|
||||||
|
botHandle: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DiscordAccount = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [connection, setConnection] = useState<DiscordConnection>({ linked: false });
|
||||||
|
const [botStatus, setBotStatus] = useState<DiscordStatus>({ configured: false, running: false });
|
||||||
|
const [pairingCode, setPairingCode] = useState<string | null>(null);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
client.get<DiscordConnection>('/channels/discord/connection').then(setConnection),
|
||||||
|
client.get<DiscordStatus>('/channels/discord/status').then(setBotStatus),
|
||||||
|
])
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGenerateCode = async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
const res = await client.post<{ code: string; expiresIn: number }>('/channels/discord/pair', {});
|
||||||
|
setPairingCode(res.code);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to generate pairing code');
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyCode = () => {
|
||||||
|
if (pairingCode) {
|
||||||
|
navigator.clipboard.writeText(pairingCode);
|
||||||
|
toast.success('Code copied to clipboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
try {
|
||||||
|
await client.delete('/channels/discord/connection');
|
||||||
|
setConnection({ linked: false });
|
||||||
|
setPairingCode(null);
|
||||||
|
toast.success('Discord account unlinked');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to unlink Discord account');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
|
if (!botStatus.configured) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||||
|
Discord integration has not been configured yet. Ask your administrator to set up the Discord bot in the
|
||||||
|
Enterprise settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionInfo = (botStatus.serverInvite || botStatus.botHandle) && (
|
||||||
|
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-2">
|
||||||
|
{botStatus.serverInvite && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Server:</span>
|
||||||
|
<a
|
||||||
|
href={botStatus.serverInvite}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-duck-teal underline truncate"
|
||||||
|
>
|
||||||
|
{botStatus.serverInvite}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{botStatus.botHandle && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Bot:</span>
|
||||||
|
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-2 py-1 rounded">{botStatus.botHandle}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connection.linked) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Linked</p>
|
||||||
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">
|
||||||
|
Discord ID: {connection.discordId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{connectionInfo}
|
||||||
|
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
You can send direct messages to the bot on Discord and they will be handled by your PI agent.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
className="w-full h-11 cursor-pointer"
|
||||||
|
>
|
||||||
|
Unlink Discord
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||||
|
Link your Discord account to chat with your PI agent via direct messages.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{connectionInfo}
|
||||||
|
|
||||||
|
{pairingCode ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||||
|
<code className="text-2xl font-mono font-bold tracking-[0.3em] text-duck-dark dark:text-foreground">
|
||||||
|
{pairingCode}
|
||||||
|
</code>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={handleCopyCode} className="cursor-pointer shrink-0">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||||
|
Send this code as a direct message to the bot on Discord. Expires in 10 minutes.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleGenerateCode}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="w-full h-11 cursor-pointer"
|
||||||
|
>
|
||||||
|
Generate New Code
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGenerateCode}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isGenerating ? 'Generating...' : 'Link Discord'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+237
@@ -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 (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
|
||||||
|
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
|
||||||
|
Step-by-step setup guide
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Create a Discord application</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
Go to the{' '}
|
||||||
|
<a href="https://discord.com/developers/applications" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||||
|
Discord Developer Portal
|
||||||
|
</a>{' '}
|
||||||
|
and click <strong>New Application</strong>.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||||
|
<li>Fill in <strong>Name</strong> (e.g. "Officer"), optionally a <strong>Description</strong> and <strong>Tags</strong></li>
|
||||||
|
<li>Leave everything else empty — <strong>Interactions Endpoint URL</strong>, <strong>Linked Roles Verification URL</strong>, and <strong>Terms of Service / Privacy Policy URLs</strong> are not needed</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-1">Click <strong>Create</strong>.</p>
|
||||||
|
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Go to the Bot page</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
In the left sidebar, click <strong>Bot</strong>. 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.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Enable privileged intents</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
Still on the <strong>Bot</strong> page, scroll down to <strong>Privileged Gateway Intents</strong> and enable:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||||
|
<li><strong>Presence Intent</strong> — shows the bot as online in your server</li>
|
||||||
|
<li><strong>Server Members Intent</strong> — makes the bot visible in the member list so users can find and DM it</li>
|
||||||
|
<li><strong>Message Content Intent</strong> — required to read DM message text</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-1">Click <strong>Save Changes</strong>.</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Copy the bot token</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
On the <strong>Bot</strong> page, click <strong>Reset Token</strong> (or <strong>View Token</strong> if this is a new bot).
|
||||||
|
Copy the token — you will only see it once.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||||
|
Keep this token secret. Anyone with this token can control your bot. If it leaks, reset it immediately from this page.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Invite the bot to your server (optional)</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
Open this URL in your browser, replacing{' '}
|
||||||
|
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">YOUR_APP_ID</code>{' '}
|
||||||
|
with the Application ID from the <strong>General Information</strong> page:
|
||||||
|
</p>
|
||||||
|
<code className="mt-1.5 block text-xs bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2 rounded break-all">
|
||||||
|
https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot&permissions=0
|
||||||
|
</code>
|
||||||
|
<p className="mt-1.5">
|
||||||
|
Select your server and click <strong>Authorize</strong>. No bot permissions are needed — leave them at zero.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||||
|
Adding the bot to a shared server is optional but has a few advantages:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50 list-disc list-outside pl-5 grid gap-0.5">
|
||||||
|
<li>Users can find the bot in the member list and right-click → <strong>Message</strong> to start a DM</li>
|
||||||
|
<li>The bot shows as online in the server, so users can see at a glance whether it's running</li>
|
||||||
|
<li>Easier onboarding — you can pin the pairing instructions in a channel for your team</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||||
|
Without a shared server, users can still DM the bot by searching its exact username — but a shared server makes discovery much simpler.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Paste the token below and save</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
Paste the bot token into the field below and click <strong>Save</strong>.
|
||||||
|
The bot will come online automatically. Users can then link their Discord accounts from their personal integration settings.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<DiscordStatus | null>(null);
|
||||||
|
|
||||||
|
const fetchStatus = () => {
|
||||||
|
client
|
||||||
|
.get<DiscordStatus>('/channels/discord/status')
|
||||||
|
.then(setStatus)
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
client.get<DiscordConfig>('/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<DiscordStatus>('/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 (
|
||||||
|
<div className="grid gap-5">
|
||||||
|
{status && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||||
|
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.running ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`} />
|
||||||
|
<span className="text-sm text-duck-dark dark:text-foreground">
|
||||||
|
{status.running ? `Online as ${status.botUsername}` : status.configured ? 'Bot configured but not running' : 'Not configured'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SetupGuide />
|
||||||
|
|
||||||
|
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70 dark:text-foreground/70">Bot Token</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="password"
|
||||||
|
value={botToken}
|
||||||
|
onChange={(ev) => setBotToken(ev.target.value)}
|
||||||
|
placeholder="MTIz..."
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70 dark:text-foreground/70">Server Invite Link</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
value={serverInvite}
|
||||||
|
onChange={(ev) => setServerInvite(ev.target.value)}
|
||||||
|
placeholder="https://discord.gg/..."
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
Shown to users so they can join the server and find the bot.
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70 dark:text-foreground/70">Bot Handle</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
value={botHandle}
|
||||||
|
onChange={(ev) => setBotHandle(ev.target.value)}
|
||||||
|
placeholder="my-bot#1234"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
The bot's username#discriminator — shown to users who want to DM the bot directly.
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !botToken.trim()}
|
||||||
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
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 type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
import { WorkspaceLayout } from 'officerdev';
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
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 { SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
|
||||||
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
|
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
|
||||||
import { GoogleAccount } from './GoogleAccount';
|
import { GoogleAccount } from './GoogleAccount';
|
||||||
|
import { DiscordBotConfig } from './DiscordBotConfig';
|
||||||
|
import { DiscordAccount } from './DiscordAccount';
|
||||||
|
|
||||||
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
|
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
|
||||||
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
|
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
|
||||||
|
|
||||||
const enterpriseSections: SettingsSection[] = [
|
const enterpriseSections: SettingsSection[] = [
|
||||||
{ key: 'google-oauth', icon: KeyRound, title: 'Google OAuth', description: 'Client ID and secret for Google APIs', content: <GoogleOAuthConfig /> },
|
{ key: 'google-oauth', icon: KeyRound, title: 'Google OAuth', description: 'Client ID and secret for Google APIs', content: <GoogleOAuthConfig /> },
|
||||||
|
{ key: 'discord-bot', icon: MessageCircle, title: 'Discord Bot', description: 'Bot token for Discord integration', content: <DiscordBotConfig /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const personalSections: SettingsSection[] = [
|
const personalSections: SettingsSection[] = [
|
||||||
{ key: 'google-account', icon: UserCircle, title: 'Google Account', description: 'Connect your Google account', content: <GoogleAccount /> },
|
{ key: 'google-account', icon: UserCircle, title: 'Google Account', description: 'Connect your Google account', content: <GoogleAccount /> },
|
||||||
|
{ key: 'discord-account', icon: MessageCircle, title: 'Discord', description: 'Link your Discord account', content: <DiscordAccount /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const IntegrationsSidebar = () => {
|
const IntegrationsSidebar = () => {
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ export * from './Workspaces';
|
|||||||
export * from './Projects';
|
export * from './Projects';
|
||||||
export * from './Terminal';
|
export * from './Terminal';
|
||||||
export * from './Email';
|
export * from './Email';
|
||||||
|
export * from './Browser';
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export {
|
|||||||
getUserIntegration,
|
getUserIntegration,
|
||||||
upsertUserIntegration,
|
upsertUserIntegration,
|
||||||
deleteUserIntegration,
|
deleteUserIntegration,
|
||||||
|
findUserByIntegrationConfig,
|
||||||
} from './queries/integrations';
|
} from './queries/integrations';
|
||||||
|
|
||||||
export { db } from './db';
|
export { db } from './db';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { serverIntegrations, userIntegrations } from '../schema';
|
import { serverIntegrations, userIntegrations } from '../schema';
|
||||||
|
import { users } from '../schema/auth';
|
||||||
import type { ServerIntegrationSelect, UserIntegrationSelect } from '../types';
|
import type { ServerIntegrationSelect, UserIntegrationSelect } from '../types';
|
||||||
|
|
||||||
// ── Server Integrations ──
|
// ── Server Integrations ──
|
||||||
@@ -71,3 +72,41 @@ export async function deleteUserIntegration(userId: number, provider: string): P
|
|||||||
.returning({ id: userIntegrations.id });
|
.returning({ id: userIntegrations.id });
|
||||||
return result.length > 0;
|
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<UserIntegrationWithUser | undefined> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<T>(path: string, fallback: T): Promise<T> {
|
|
||||||
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<UserSelect> {
|
|
||||||
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<Omit<UserSelect, 'id'>>): Promise<UserSelect | undefined> {
|
|
||||||
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<boolean> {
|
|
||||||
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<PasskeySelect> {
|
|
||||||
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<Omit<PasskeySelect, 'id'>>,
|
|
||||||
): Promise<PasskeySelect | undefined> {
|
|
||||||
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<string | null> {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<void>|null} */
|
||||||
|
let relayConnectPromise = null
|
||||||
|
let relayGatewayToken = ''
|
||||||
|
/** @type {string|null} */
|
||||||
|
let relayConnectRequestId = null
|
||||||
|
|
||||||
|
let nextSession = 1
|
||||||
|
|
||||||
|
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
|
||||||
|
const tabs = new Map()
|
||||||
|
/** @type {Map<string, number>} */
|
||||||
|
const tabBySession = new Map()
|
||||||
|
/** @type {Map<string, number>} */
|
||||||
|
const childSessionToTab = new Map()
|
||||||
|
|
||||||
|
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
|
||||||
|
const pending = new Map()
|
||||||
|
|
||||||
|
/** @type {Set<number>} */
|
||||||
|
const tabOperationLocks = new Set()
|
||||||
|
|
||||||
|
/** @type {Set<number>} */
|
||||||
|
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
|
||||||
|
})
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 614 B |
Binary file not shown.
|
After Width: | Height: | Size: 265 B |
Binary file not shown.
|
After Width: | Height: | Size: 318 B |
Binary file not shown.
|
After Width: | Height: | Size: 370 B |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Officer Browser Relay",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Connect your Chrome browser to Officer",
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"32": "icons/icon32.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
},
|
||||||
|
"permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"],
|
||||||
|
"host_permissions": ["<all_urls>"],
|
||||||
|
"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 }
|
||||||
|
}
|
||||||
@@ -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.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Officer Browser Relay</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--accent: #06b6d4;
|
||||||
|
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
|
||||||
|
--border: color-mix(in oklab, canvasText 18%, transparent);
|
||||||
|
--muted: color-mix(in oklab, canvasText 70%, transparent);
|
||||||
|
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
|
||||||
|
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
|
||||||
|
"SF Pro Display", "Segoe UI", sans-serif;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
|
||||||
|
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
|
||||||
|
canvas;
|
||||||
|
color: canvasText;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 36px auto;
|
||||||
|
padding: 0 24px 48px 24px;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in oklab, var(--accent) 18%, transparent);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
margin: 2px 0 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.card p {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: color-mix(in oklab, canvas 92%, canvasText 8%);
|
||||||
|
color: canvasText;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
input.host-input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
input.port-input {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
input.token-input {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in oklab, var(--accent) 80%, white 20%),
|
||||||
|
var(--accent)
|
||||||
|
);
|
||||||
|
color: white;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
.status[data-kind='ok'] {
|
||||||
|
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
|
||||||
|
}
|
||||||
|
.status[data-kind='error'] {
|
||||||
|
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<div class="logo" aria-hidden="true">🌐</div>
|
||||||
|
<div>
|
||||||
|
<h1>Officer Browser Relay</h1>
|
||||||
|
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Getting started</h2>
|
||||||
|
<p>
|
||||||
|
If you see a red <code>!</code> 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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You can find your relay token in the Officer dashboard under <strong>Browser</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Relay connection</h2>
|
||||||
|
|
||||||
|
<label for="host">Server address</label>
|
||||||
|
<div class="row">
|
||||||
|
<input id="host" class="host-input" placeholder="e.g. 100.64.0.1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="port" style="margin-top: 10px">Port</label>
|
||||||
|
<div class="row">
|
||||||
|
<input id="port" class="port-input" inputmode="numeric" pattern="[0-9]*" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="token" style="margin-top: 10px">Relay token</label>
|
||||||
|
<div class="row">
|
||||||
|
<input id="token" class="token-input" type="password" autocomplete="off" />
|
||||||
|
<button id="save" type="button">Save</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint">
|
||||||
|
Default port: <code>18792</code>. Extension connects to: <code id="relay-url">http://<host>:<port>/</code>.
|
||||||
|
</div>
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="options.js"></script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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()
|
||||||
@@ -8,6 +8,7 @@ import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/
|
|||||||
import { piWebsocket } from './servers/api/pi/websocket';
|
import { piWebsocket } from './servers/api/pi/websocket';
|
||||||
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
|
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
|
||||||
import officerWeb from './apps/officer-web/index.html';
|
import officerWeb from './apps/officer-web/index.html';
|
||||||
|
import { startBrowserRelay } from './servers/api/browser/relay';
|
||||||
|
|
||||||
const { PORT = '5000' } = process.env;
|
const { PORT = '5000' } = process.env;
|
||||||
|
|
||||||
@@ -199,6 +200,14 @@ const server = serve({
|
|||||||
|
|
||||||
console.log(`🚀 Server running at ${server.url}`);
|
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();
|
void initTerminalSidecars();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
type CdpCommandOptions = {
|
||||||
|
relayPort: number;
|
||||||
|
userToken: string;
|
||||||
|
targetId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function sendCdpCommand(opts: CdpCommandOptions, method: string, params?: unknown): Promise<unknown> {
|
||||||
|
const { relayPort, userToken, targetId } = opts;
|
||||||
|
const url = `ws://127.0.0.1:${relayPort}/cdp?token=${encodeURIComponent(userToken)}`;
|
||||||
|
|
||||||
|
return await new Promise<unknown>((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<string, unknown> = { 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<string> {
|
||||||
|
const params: Record<string, unknown> = { 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<unknown> {
|
||||||
|
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<void> {
|
||||||
|
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: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, number>();
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
@@ -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<unknown>;
|
||||||
|
connectedTargets: Map<string, ConnectedTarget>;
|
||||||
|
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>;
|
||||||
|
};
|
||||||
@@ -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<WSData> | null;
|
||||||
|
cdpClients: Set<ServerWebSocket<WSData>>;
|
||||||
|
connectedTargets: Map<string, ConnectedTarget>;
|
||||||
|
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>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const userRelays = new Map<number, UserRelay>();
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
if (extensionConnected(relay)) return true;
|
||||||
|
return await new Promise<boolean>((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<unknown> {
|
||||||
|
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<unknown>((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<WSData>, res: CdpResponse) {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(JSON.stringify(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTargetEventsForClient(relay: UserRelay, ws: ServerWebSocket<WSData>, 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<unknown> {
|
||||||
|
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<WSData>) {
|
||||||
|
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<WSData>) {
|
||||||
|
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<WSData>, 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<typeof Bun.serve> | 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<WSData>({
|
||||||
|
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<string, string> = {};
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<WSData>).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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -11,12 +11,15 @@ emailRouter.get('/messages', async (ctx) => {
|
|||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const page = Number(ctx.req.query('page') ?? '1');
|
const page = Number(ctx.req.query('page') ?? '1');
|
||||||
const limit = Number(ctx.req.query('limit') ?? '50');
|
const limit = Number(ctx.req.query('limit') ?? '50');
|
||||||
|
const folder = ctx.req.query('folder') ?? 'inbox';
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const folderWhere = folder === 'all' ? 'deleted = 0' : `deleted = 0 AND labels LIKE '%${folder}%'`;
|
||||||
|
|
||||||
const db = openEmailDb(email);
|
const db = openEmailDb(email);
|
||||||
try {
|
try {
|
||||||
const rows = db.query('SELECT * FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ? OFFSET ?').all(limit, offset) as Record<string, unknown>[];
|
const rows = db.query(`SELECT * FROM emails WHERE ${folderWhere} ORDER BY date DESC LIMIT ? OFFSET ?`).all(limit, offset) as Record<string, unknown>[];
|
||||||
const countRow = db.query('SELECT COUNT(*) as total FROM emails WHERE deleted = 0').get() as { total: number };
|
const countRow = db.query(`SELECT COUNT(*) as total FROM emails WHERE ${folderWhere}`).get() as { total: number };
|
||||||
const messages = rows.map(rowToSummary);
|
const messages = rows.map(rowToSummary);
|
||||||
return ctx.json({ messages, total: countRow.total });
|
return ctx.json({ messages, total: countRow.total });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -122,15 +125,43 @@ emailRouter.get('/sync-status', async (ctx) => {
|
|||||||
|
|
||||||
emailRouter.get('/stats', async (ctx) => {
|
emailRouter.get('/stats', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
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);
|
const db = openEmailDb(email);
|
||||||
try {
|
try {
|
||||||
const total = (db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number }).count;
|
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 deleted = 0 GROUP BY from_domain ORDER BY count DESC LIMIT 20').all() as Array<{ from_domain: string; count: number }>;
|
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 deleted = 0 GROUP BY from_address ORDER BY count DESC LIMIT 20').all() as Array<{ from_address: string; from_name: 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 });
|
return ctx.json({ total, byDomain, bySender });
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
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<string, number>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { syncSeedResources } from './sync-resources';
|
|||||||
import { migrateSettingsToResources } from './migrate-resources';
|
import { migrateSettingsToResources } from './migrate-resources';
|
||||||
import { generateResourceSkill } from './api/pi/pi-bridge';
|
import { generateResourceSkill } from './api/pi/pi-bridge';
|
||||||
import { initQueue } from './queue';
|
import { initQueue } from './queue';
|
||||||
|
import { startDiscordBotIfConfigured } from './channels/discord/bot';
|
||||||
|
|
||||||
mkdirSync(DATA_PATH, { recursive: true });
|
mkdirSync(DATA_PATH, { recursive: true });
|
||||||
|
|
||||||
@@ -61,4 +62,8 @@ async function installPi(): Promise<boolean> {
|
|||||||
await initQueue().catch(err => {
|
await initQueue().catch(err => {
|
||||||
console.error('[bootstrap] Failed to initialize queue:', err);
|
console.error('[bootstrap] Failed to initialize queue:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await startDiscordBotIfConfigured().catch(err => {
|
||||||
|
console.error('[channels] Failed to start Discord bot:', err);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const integration = await getServerIntegration('discord');
|
||||||
|
if (!integration?.enabled) return;
|
||||||
|
|
||||||
|
const config = integration.config as Record<string, unknown>;
|
||||||
|
const botToken = config.botToken as string | undefined;
|
||||||
|
if (!botToken) return;
|
||||||
|
|
||||||
|
await startDiscordBot(botToken);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<unknown> };
|
||||||
|
|
||||||
|
type AccessPolicy = { allowedModels: string[] };
|
||||||
|
const ACCESS_POLICY_KEY = 'pi-access-policy';
|
||||||
|
|
||||||
|
async function getVisibleModels(): Promise<ModelInfo[]> {
|
||||||
|
const allModels = await listPiModels();
|
||||||
|
const policy = await readConfigValue<AccessPolicy>(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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
const { content, channel, discordId } = ctx;
|
||||||
|
const lower = content.toLowerCase();
|
||||||
|
|
||||||
|
const helpSections: Record<string, string> = {
|
||||||
|
models:
|
||||||
|
'**Models:**\n' +
|
||||||
|
'`!model` — show current model\n' +
|
||||||
|
'`!model <id>` — 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 <topic>` — 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<string, string[]>();
|
||||||
|
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 <id>` 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<void> {
|
||||||
|
// 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<void> }).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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, PairingEntry>();
|
||||||
|
|
||||||
|
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<PairingResult | null> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
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 });
|
||||||
|
});
|
||||||
@@ -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<string, Promise<void>>();
|
||||||
|
|
||||||
|
// Per-session callback — swapped each time a new prompt is sent
|
||||||
|
type EventCallback = (event: PiEvent) => void;
|
||||||
|
const sessionCallbacks = new Map<string, EventCallback>();
|
||||||
|
|
||||||
|
// Channel model overrides — survive session eviction/recreation
|
||||||
|
const channelModelOverrides = new Map<string, string>();
|
||||||
|
|
||||||
|
async function getUserDefaultModel(userId: number): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const settings = await getUserSettings(userId);
|
||||||
|
const chat = settings?.chat as Record<string, unknown> | 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<SendAndAwaitResult> {
|
||||||
|
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<void>((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<SendAndAwaitResult> {
|
||||||
|
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<SendAndAwaitResult>((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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type ChannelProvider = 'discord' | 'telegram' | 'whatsapp';
|
||||||
|
|
||||||
|
export type ChannelBot = {
|
||||||
|
provider: ChannelProvider;
|
||||||
|
start: (token: string) => Promise<void>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
isRunning: () => boolean;
|
||||||
|
};
|
||||||
@@ -24,6 +24,8 @@ import { dockRouter } from './api/dock/dock';
|
|||||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||||
import { queueRouter } from './api/queue/queue';
|
import { queueRouter } from './api/queue/queue';
|
||||||
import { emailRouter } from './api/email/email';
|
import { emailRouter } from './api/email/email';
|
||||||
|
import { channelsRouter } from './channels/routes';
|
||||||
|
import { browserRouter } from './api/browser/router';
|
||||||
import { CustomError } from './custom-errors';
|
import { CustomError } from './custom-errors';
|
||||||
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
|
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
|
||||||
|
|
||||||
@@ -79,6 +81,8 @@ protectedRouter.route('/dock', dockRouter);
|
|||||||
protectedRouter.route('/integrations', integrationsRouter);
|
protectedRouter.route('/integrations', integrationsRouter);
|
||||||
protectedRouter.route('/queue', queueRouter);
|
protectedRouter.route('/queue', queueRouter);
|
||||||
protectedRouter.route('/email', emailRouter);
|
protectedRouter.route('/email', emailRouter);
|
||||||
|
protectedRouter.route('/channels', channelsRouter);
|
||||||
|
protectedRouter.route('/browser', browserRouter);
|
||||||
protectedRouter.route('/', piRestRouter);
|
protectedRouter.route('/', piRestRouter);
|
||||||
|
|
||||||
honoServer.route('/api', protectedRouter);
|
honoServer.route('/api', protectedRouter);
|
||||||
|
|||||||
@@ -383,8 +383,7 @@ const gmailSyncHandler: JobHandler = {
|
|||||||
|
|
||||||
if (year) {
|
if (year) {
|
||||||
// Year-scoped sync: count total emails first, then sync month by month
|
// 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 = `after:${year}/1/1 before:${year + 1}/1/1`;
|
||||||
const yearQuery = `${labelScope} after:${year}/1/1 before:${year + 1}/1/1`;
|
|
||||||
await ctx.updateProgress({ current: 0, total: 0, label: 'Counting emails...' });
|
await ctx.updateProgress({ current: 0, total: 0, label: 'Counting emails...' });
|
||||||
const totalEmails = await countMessages(token, yearQuery);
|
const totalEmails = await countMessages(token, yearQuery);
|
||||||
console.log(`[gmail-sync] Pre-flight: ${totalEmails} emails for ${year}`);
|
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++) {
|
for (let i = 0; i < months.length; i++) {
|
||||||
const month = months[i]!;
|
const month = months[i]!;
|
||||||
await ctx.updateProgress({ current: totalSaved + totalSkipped, total: totalEmails, label: month.label });
|
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 result = await syncInbox(token, db, ctx.job.userId, query, (p) => {
|
||||||
const current = totalSaved + p.saved + p.skipped + p.errors;
|
const current = totalSaved + p.saved + p.skipped + p.errors;
|
||||||
const label = `${month.label} — Saved ${(totalSaved + p.saved).toLocaleString()} of ${totalEmails.toLocaleString()}`;
|
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' });
|
await ctx.updateProgress({ current: totalEmails, total: totalEmails, label: 'Done' });
|
||||||
} else {
|
} else {
|
||||||
// Scope to inbox + sent to avoid syncing trash/spam/drafts
|
|
||||||
const lastSyncDate = getSyncMeta(db, 'last_sync_date');
|
const lastSyncDate = getSyncMeta(db, 'last_sync_date');
|
||||||
const labelScope = 'in:inbox OR in:sent OR in:trash OR in:spam';
|
let syncQuery: string | undefined;
|
||||||
let syncQuery: string = labelScope;
|
|
||||||
if (lastSyncDate) {
|
if (lastSyncDate) {
|
||||||
const d = new Date(lastSyncDate);
|
const d = new Date(lastSyncDate);
|
||||||
const dateScope = `after:${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
|
syncQuery = `after:${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
|
||||||
syncQuery = `(${labelScope}) ${dateScope}`;
|
|
||||||
console.log(`[gmail-sync] Scoping full sync with query: ${syncQuery}`);
|
console.log(`[gmail-sync] Scoping full sync with query: ${syncQuery}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user