3
.prettierrc
Normal file
3
.prettierrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -203,7 +203,7 @@ Groups can have additional directories mounted via `containerConfig` in `data/re
|
||||
"containerConfig": {
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/Users/gavriel/projects/webapp",
|
||||
"hostPath": "~/projects/webapp",
|
||||
"containerPath": "webapp",
|
||||
"readonly": false
|
||||
}
|
||||
|
||||
@@ -31,22 +31,6 @@ When you learn something important:
|
||||
- Add recurring context directly to this CLAUDE.md
|
||||
- Always index new memory files at the top of CLAUDE.md
|
||||
|
||||
## Qwibit Ops Access
|
||||
|
||||
You have access to Qwibit operations data at `/workspace/extra/qwibit-ops/` with these key areas:
|
||||
|
||||
- **sales/** - Pipeline, deals, playbooks, pitch materials (see `sales/CLAUDE.md`)
|
||||
- **clients/** - Active accounts, service delivery, client management (see `clients/CLAUDE.md`)
|
||||
- **company/** - Strategy, thesis, operational philosophy (see `company/CLAUDE.md`)
|
||||
|
||||
Read the CLAUDE.md files in each folder for role-specific context and workflows.
|
||||
|
||||
**Key context:**
|
||||
- Qwibit is a B2B GEO (Generative Engine Optimization) agency
|
||||
- Pricing: $2,000-$4,000/month, month-to-month contracts
|
||||
- Team: Gavriel (founder, sales & client work), Lazer (founder, dealflow), Ali (PM)
|
||||
- Obsidian-based workflow with Kanban boards (PIPELINE.md, PORTFOLIO.md)
|
||||
|
||||
## WhatsApp Formatting
|
||||
|
||||
Do NOT use markdown headings (##) in WhatsApp messages. Only use:
|
||||
@@ -171,7 +155,7 @@ Groups can have extra directories mounted. Add `containerConfig` to their entry:
|
||||
"containerConfig": {
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/Users/gavriel/projects/webapp",
|
||||
"hostPath": "~/projects/webapp",
|
||||
"containerPath": "webapp",
|
||||
"readonly": false
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# NanoClaw Testing
|
||||
|
||||
This group is used for testing NanoClaw features and functionality.
|
||||
|
||||
## What You Can Do
|
||||
|
||||
- Answer questions and have conversations
|
||||
- Search the web and fetch content from URLs
|
||||
- Read and write files in your workspace
|
||||
- Run bash commands in your sandbox
|
||||
- Schedule tasks to run later or on a recurring basis
|
||||
- Send messages back to the chat
|
||||
|
||||
## Qwibit Ops Access
|
||||
|
||||
You have access to Qwibit operations data at `/workspace/extra/qwibit-ops/` with these key areas:
|
||||
|
||||
- **sales/** - Pipeline, deals, playbooks, pitch materials (see `sales/CLAUDE.md`)
|
||||
- **clients/** - Active accounts, service delivery, client management (see `clients/CLAUDE.md`)
|
||||
- **company/** - Strategy, thesis, operational philosophy (see `company/CLAUDE.md`)
|
||||
|
||||
Read the CLAUDE.md files in each folder for role-specific context and workflows.
|
||||
|
||||
**Key context:**
|
||||
- Qwibit is a B2B GEO (Generative Engine Optimization) agency
|
||||
- Pricing: $2,000-$4,000/month, month-to-month contracts
|
||||
- Team: Gavriel (founder, sales & client work), Lazer (founder, dealflow), Ali (PM)
|
||||
- Obsidian-based workflow with Kanban boards (PIPELINE.md, PORTFOLIO.md)
|
||||
|
||||
## WhatsApp Formatting
|
||||
|
||||
Do NOT use markdown headings (##) in WhatsApp messages. Only use:
|
||||
- *Bold* (asterisks)
|
||||
- _Italic_ (underscores)
|
||||
- • Bullets (bullet points)
|
||||
- ```Code blocks``` (triple backticks)
|
||||
|
||||
Keep messages clean and readable for WhatsApp.
|
||||
637
package-lock.json
generated
637
package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
@@ -551,6 +552,120 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
@@ -602,6 +717,103 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
@@ -671,6 +883,75 @@
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
@@ -731,6 +1012,26 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@keyv/bigmap": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
|
||||
@@ -863,9 +1164,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||
"version": "22.19.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz",
|
||||
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -1282,9 +1583,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hookified": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz",
|
||||
"integrity": "sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==",
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
|
||||
"integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
@@ -1452,9 +1753,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/music-metadata": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.11.1.tgz",
|
||||
"integrity": "sha512-8FT+lSLznASDhn5KNJtQE6ZH95VqhxtKWNPrvdfhlqgbdZZEEAXehx+xpUvas4VuEZAu49BhQgLa3NlmPeRaww==",
|
||||
"version": "11.11.2",
|
||||
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.11.2.tgz",
|
||||
"integrity": "sha512-tJx+lsDg1bGUOxojKKj12BIvccBBUcVa6oWrvOchCF0WAQ9E5t/hK35ILp1z3wWrUSYtgg57LfRbvVMkxGIyzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1476,7 +1777,7 @@
|
||||
"strtok3": "^10.3.4",
|
||||
"token-types": "^6.1.2",
|
||||
"uint8array-extras": "^1.5.0",
|
||||
"win-guid": "^0.2.0"
|
||||
"win-guid": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -1642,6 +1943,22 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
@@ -1877,306 +2194,6 @@
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts",
|
||||
"auth": "tsx src/whatsapp-auth.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "^7.0.0-rc.9",
|
||||
@@ -24,6 +26,7 @@
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
|
||||
@@ -9,23 +9,39 @@ const PROJECT_ROOT = process.cwd();
|
||||
const HOME_DIR = process.env.HOME || '/Users/user';
|
||||
|
||||
// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
|
||||
export const MOUNT_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'mount-allowlist.json');
|
||||
export const MOUNT_ALLOWLIST_PATH = path.join(
|
||||
HOME_DIR,
|
||||
'.config',
|
||||
'nanoclaw',
|
||||
'mount-allowlist.json',
|
||||
);
|
||||
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
||||
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
||||
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
||||
export const MAIN_GROUP_FOLDER = 'main';
|
||||
|
||||
export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
|
||||
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '300000', 10);
|
||||
export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default
|
||||
export const CONTAINER_IMAGE =
|
||||
process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
|
||||
export const CONTAINER_TIMEOUT = parseInt(
|
||||
process.env.CONTAINER_TIMEOUT || '300000',
|
||||
10,
|
||||
);
|
||||
export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
|
||||
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
||||
10,
|
||||
); // 10MB default
|
||||
export const IPC_POLL_INTERVAL = 1000;
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export const TRIGGER_PATTERN = new RegExp(`^@${escapeRegex(ASSISTANT_NAME)}\\b`, 'i');
|
||||
export const TRIGGER_PATTERN = new RegExp(
|
||||
`^@${escapeRegex(ASSISTANT_NAME)}\\b`,
|
||||
'i',
|
||||
);
|
||||
|
||||
// Timezone for scheduled tasks (cron expressions, etc.)
|
||||
// Uses system timezone by default
|
||||
export const TIMEZONE = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
export const TIMEZONE =
|
||||
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
@@ -2,25 +2,25 @@
|
||||
* Container Runner for NanoClaw
|
||||
* Spawns agent execution in Apple Container and handles IPC
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import pino from 'pino';
|
||||
|
||||
import {
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_TIMEOUT,
|
||||
CONTAINER_MAX_OUTPUT_SIZE,
|
||||
CONTAINER_TIMEOUT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
DATA_DIR
|
||||
} from './config.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
import { validateAdditionalMounts } from './mount-security.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: { target: 'pino-pretty', options: { colorize: true } }
|
||||
transport: { target: 'pino-pretty', options: { colorize: true } },
|
||||
});
|
||||
|
||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||
@@ -30,7 +30,9 @@ const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
function getHomeDir(): string {
|
||||
const home = process.env.HOME || os.homedir();
|
||||
if (!home) {
|
||||
throw new Error('Unable to determine home directory: HOME environment variable is not set and os.homedir() returned empty');
|
||||
throw new Error(
|
||||
'Unable to determine home directory: HOME environment variable is not set and os.homedir() returned empty',
|
||||
);
|
||||
}
|
||||
return home;
|
||||
}
|
||||
@@ -57,7 +59,10 @@ interface VolumeMount {
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount[] {
|
||||
function buildVolumeMounts(
|
||||
group: RegisteredGroup,
|
||||
isMain: boolean,
|
||||
): VolumeMount[] {
|
||||
const mounts: VolumeMount[] = [];
|
||||
const homeDir = getHomeDir();
|
||||
const projectRoot = process.cwd();
|
||||
@@ -67,21 +72,21 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount
|
||||
mounts.push({
|
||||
hostPath: projectRoot,
|
||||
containerPath: '/workspace/project',
|
||||
readonly: false
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Main also gets its group folder as the working directory
|
||||
mounts.push({
|
||||
hostPath: path.join(GROUPS_DIR, group.folder),
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false
|
||||
readonly: false,
|
||||
});
|
||||
} else {
|
||||
// Other groups only get their own folder
|
||||
mounts.push({
|
||||
hostPath: path.join(GROUPS_DIR, group.folder),
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Global memory directory (read-only for non-main)
|
||||
@@ -91,19 +96,24 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount
|
||||
mounts.push({
|
||||
hostPath: globalDir,
|
||||
containerPath: '/workspace/global',
|
||||
readonly: true
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Per-group Claude sessions directory (isolated from other groups)
|
||||
// Each group gets their own .claude/ to prevent cross-group session access
|
||||
const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder, '.claude');
|
||||
const groupSessionsDir = path.join(
|
||||
DATA_DIR,
|
||||
'sessions',
|
||||
group.folder,
|
||||
'.claude',
|
||||
);
|
||||
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
||||
mounts.push({
|
||||
hostPath: groupSessionsDir,
|
||||
containerPath: '/home/node/.claude',
|
||||
readonly: false
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Per-group IPC namespace: each group gets its own IPC directory
|
||||
@@ -114,7 +124,7 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount
|
||||
mounts.push({
|
||||
hostPath: groupIpcDir,
|
||||
containerPath: '/workspace/ipc',
|
||||
readonly: false
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Environment file directory (workaround for Apple Container -i env var bug)
|
||||
@@ -125,20 +135,21 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount
|
||||
if (fs.existsSync(envFile)) {
|
||||
const envContent = fs.readFileSync(envFile, 'utf-8');
|
||||
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
||||
const filteredLines = envContent
|
||||
.split('\n')
|
||||
.filter(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return false;
|
||||
return allowedVars.some(v => trimmed.startsWith(`${v}=`));
|
||||
});
|
||||
const filteredLines = envContent.split('\n').filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return false;
|
||||
return allowedVars.some((v) => trimmed.startsWith(`${v}=`));
|
||||
});
|
||||
|
||||
if (filteredLines.length > 0) {
|
||||
fs.writeFileSync(path.join(envDir, 'env'), filteredLines.join('\n') + '\n');
|
||||
fs.writeFileSync(
|
||||
path.join(envDir, 'env'),
|
||||
filteredLines.join('\n') + '\n',
|
||||
);
|
||||
mounts.push({
|
||||
hostPath: envDir,
|
||||
containerPath: '/workspace/env-dir',
|
||||
readonly: true
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -148,7 +159,7 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount
|
||||
const validatedMounts = validateAdditionalMounts(
|
||||
group.containerConfig.additionalMounts,
|
||||
group.name,
|
||||
isMain
|
||||
isMain,
|
||||
);
|
||||
mounts.push(...validatedMounts);
|
||||
}
|
||||
@@ -162,7 +173,10 @@ function buildContainerArgs(mounts: VolumeMount[]): string[] {
|
||||
// Apple Container: --mount for readonly, -v for read-write
|
||||
for (const mount of mounts) {
|
||||
if (mount.readonly) {
|
||||
args.push('--mount', `type=bind,source=${mount.hostPath},target=${mount.containerPath},readonly`);
|
||||
args.push(
|
||||
'--mount',
|
||||
`type=bind,source=${mount.hostPath},target=${mount.containerPath},readonly`,
|
||||
);
|
||||
} else {
|
||||
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
|
||||
}
|
||||
@@ -175,7 +189,7 @@ function buildContainerArgs(mounts: VolumeMount[]): string[] {
|
||||
|
||||
export async function runContainerAgent(
|
||||
group: RegisteredGroup,
|
||||
input: ContainerInput
|
||||
input: ContainerInput,
|
||||
): Promise<ContainerOutput> {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -185,24 +199,33 @@ export async function runContainerAgent(
|
||||
const mounts = buildVolumeMounts(group, input.isMain);
|
||||
const containerArgs = buildContainerArgs(mounts);
|
||||
|
||||
logger.debug({
|
||||
group: group.name,
|
||||
mounts: mounts.map(m => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`),
|
||||
containerArgs: containerArgs.join(' ')
|
||||
}, 'Container mount configuration');
|
||||
logger.debug(
|
||||
{
|
||||
group: group.name,
|
||||
mounts: mounts.map(
|
||||
(m) =>
|
||||
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
|
||||
),
|
||||
containerArgs: containerArgs.join(' '),
|
||||
},
|
||||
'Container mount configuration',
|
||||
);
|
||||
|
||||
logger.info({
|
||||
group: group.name,
|
||||
mountCount: mounts.length,
|
||||
isMain: input.isMain
|
||||
}, 'Spawning container agent');
|
||||
logger.info(
|
||||
{
|
||||
group: group.name,
|
||||
mountCount: mounts.length,
|
||||
isMain: input.isMain,
|
||||
},
|
||||
'Spawning container agent',
|
||||
);
|
||||
|
||||
const logsDir = path.join(GROUPS_DIR, group.folder, 'logs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const container = spawn('container', containerArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
@@ -220,7 +243,10 @@ export async function runContainerAgent(
|
||||
if (chunk.length > remaining) {
|
||||
stdout += chunk.slice(0, remaining);
|
||||
stdoutTruncated = true;
|
||||
logger.warn({ group: group.name, size: stdout.length }, 'Container stdout truncated due to size limit');
|
||||
logger.warn(
|
||||
{ group: group.name, size: stdout.length },
|
||||
'Container stdout truncated due to size limit',
|
||||
);
|
||||
} else {
|
||||
stdout += chunk;
|
||||
}
|
||||
@@ -237,7 +263,10 @@ export async function runContainerAgent(
|
||||
if (chunk.length > remaining) {
|
||||
stderr += chunk.slice(0, remaining);
|
||||
stderrTruncated = true;
|
||||
logger.warn({ group: group.name, size: stderr.length }, 'Container stderr truncated due to size limit');
|
||||
logger.warn(
|
||||
{ group: group.name, size: stderr.length },
|
||||
'Container stderr truncated due to size limit',
|
||||
);
|
||||
} else {
|
||||
stderr += chunk;
|
||||
}
|
||||
@@ -249,7 +278,7 @@ export async function runContainerAgent(
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container timed out after ${CONTAINER_TIMEOUT}ms`
|
||||
error: `Container timed out after ${CONTAINER_TIMEOUT}ms`,
|
||||
});
|
||||
}, group.containerConfig?.timeout || CONTAINER_TIMEOUT);
|
||||
|
||||
@@ -259,7 +288,8 @@ export async function runContainerAgent(
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const logFile = path.join(logsDir, `container-${timestamp}.log`);
|
||||
const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
||||
const isVerbose =
|
||||
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
||||
|
||||
const logLines = [
|
||||
`=== Container Run Log ===`,
|
||||
@@ -270,7 +300,7 @@ export async function runContainerAgent(
|
||||
`Exit Code: ${code}`,
|
||||
`Stdout Truncated: ${stdoutTruncated}`,
|
||||
`Stderr Truncated: ${stderrTruncated}`,
|
||||
``
|
||||
``,
|
||||
];
|
||||
|
||||
if (isVerbose) {
|
||||
@@ -282,13 +312,18 @@ export async function runContainerAgent(
|
||||
containerArgs.join(' '),
|
||||
``,
|
||||
`=== Mounts ===`,
|
||||
mounts.map(m => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'),
|
||||
mounts
|
||||
.map(
|
||||
(m) =>
|
||||
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
|
||||
)
|
||||
.join('\n'),
|
||||
``,
|
||||
`=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
|
||||
stderr,
|
||||
``,
|
||||
`=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
|
||||
stdout
|
||||
stdout,
|
||||
);
|
||||
} else {
|
||||
logLines.push(
|
||||
@@ -297,15 +332,17 @@ export async function runContainerAgent(
|
||||
`Session ID: ${input.sessionId || 'new'}`,
|
||||
``,
|
||||
`=== Mounts ===`,
|
||||
mounts.map(m => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'),
|
||||
``
|
||||
mounts
|
||||
.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
|
||||
.join('\n'),
|
||||
``,
|
||||
);
|
||||
|
||||
if (code !== 0) {
|
||||
logLines.push(
|
||||
`=== Stderr (last 500 chars) ===`,
|
||||
stderr.slice(-500),
|
||||
``
|
||||
``,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -314,18 +351,21 @@ export async function runContainerAgent(
|
||||
logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error({
|
||||
group: group.name,
|
||||
code,
|
||||
duration,
|
||||
stderr: stderr.slice(-500),
|
||||
logFile
|
||||
}, 'Container exited with error');
|
||||
logger.error(
|
||||
{
|
||||
group: group.name,
|
||||
code,
|
||||
duration,
|
||||
stderr: stderr.slice(-500),
|
||||
logFile,
|
||||
},
|
||||
'Container exited with error',
|
||||
);
|
||||
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container exited with code ${code}: ${stderr.slice(-200)}`
|
||||
error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -337,7 +377,9 @@ export async function runContainerAgent(
|
||||
|
||||
let jsonLine: string;
|
||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||
jsonLine = stdout.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim();
|
||||
jsonLine = stdout
|
||||
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
|
||||
.trim();
|
||||
} else {
|
||||
// Fallback: last non-empty line (backwards compatibility)
|
||||
const lines = stdout.trim().split('\n');
|
||||
@@ -346,25 +388,31 @@ export async function runContainerAgent(
|
||||
|
||||
const output: ContainerOutput = JSON.parse(jsonLine);
|
||||
|
||||
logger.info({
|
||||
group: group.name,
|
||||
duration,
|
||||
status: output.status,
|
||||
hasResult: !!output.result
|
||||
}, 'Container completed');
|
||||
logger.info(
|
||||
{
|
||||
group: group.name,
|
||||
duration,
|
||||
status: output.status,
|
||||
hasResult: !!output.result,
|
||||
},
|
||||
'Container completed',
|
||||
);
|
||||
|
||||
resolve(output);
|
||||
} catch (err) {
|
||||
logger.error({
|
||||
group: group.name,
|
||||
stdout: stdout.slice(-500),
|
||||
error: err
|
||||
}, 'Failed to parse container output');
|
||||
logger.error(
|
||||
{
|
||||
group: group.name,
|
||||
stdout: stdout.slice(-500),
|
||||
error: err,
|
||||
},
|
||||
'Failed to parse container output',
|
||||
);
|
||||
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`
|
||||
error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -375,7 +423,7 @@ export async function runContainerAgent(
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container spawn error: ${err.message}`
|
||||
error: `Container spawn error: ${err.message}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -392,7 +440,7 @@ export function writeTasksSnapshot(
|
||||
schedule_value: string;
|
||||
status: string;
|
||||
next_run: string | null;
|
||||
}>
|
||||
}>,
|
||||
): void {
|
||||
// Write filtered tasks to the group's IPC directory
|
||||
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
|
||||
@@ -401,7 +449,7 @@ export function writeTasksSnapshot(
|
||||
// Main sees all tasks, others only see their own
|
||||
const filteredTasks = isMain
|
||||
? tasks
|
||||
: tasks.filter(t => t.groupFolder === groupFolder);
|
||||
: tasks.filter((t) => t.groupFolder === groupFolder);
|
||||
|
||||
const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
|
||||
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
|
||||
@@ -423,7 +471,7 @@ export function writeGroupsSnapshot(
|
||||
groupFolder: string,
|
||||
isMain: boolean,
|
||||
groups: AvailableGroup[],
|
||||
registeredJids: Set<string>
|
||||
registeredJids: Set<string>,
|
||||
): void {
|
||||
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
|
||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||
@@ -432,8 +480,15 @@ export function writeGroupsSnapshot(
|
||||
const visibleGroups = isMain ? groups : [];
|
||||
|
||||
const groupsFile = path.join(groupIpcDir, 'available_groups.json');
|
||||
fs.writeFileSync(groupsFile, JSON.stringify({
|
||||
groups: visibleGroups,
|
||||
lastSync: new Date().toISOString()
|
||||
}, null, 2));
|
||||
fs.writeFileSync(
|
||||
groupsFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
groups: visibleGroups,
|
||||
lastSync: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
202
src/db.ts
202
src/db.ts
@@ -1,9 +1,11 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { proto } from '@whiskeysockets/baileys';
|
||||
import { NewMessage, ScheduledTask, TaskRunLog } from './types.js';
|
||||
|
||||
import { STORE_DIR } from './config.js';
|
||||
import { NewMessage, ScheduledTask, TaskRunLog } from './types.js';
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
@@ -63,34 +65,48 @@ export function initDatabase(): void {
|
||||
// Add sender_name column if it doesn't exist (migration for existing DBs)
|
||||
try {
|
||||
db.exec(`ALTER TABLE messages ADD COLUMN sender_name TEXT`);
|
||||
} catch { /* column already exists */ }
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add context_mode column if it doesn't exist (migration for existing DBs)
|
||||
try {
|
||||
db.exec(`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`);
|
||||
} catch { /* column already exists */ }
|
||||
db.exec(
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`,
|
||||
);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store chat metadata only (no message content).
|
||||
* Used for all chats to enable group discovery without storing sensitive content.
|
||||
*/
|
||||
export function storeChatMetadata(chatJid: string, timestamp: string, name?: string): void {
|
||||
export function storeChatMetadata(
|
||||
chatJid: string,
|
||||
timestamp: string,
|
||||
name?: string,
|
||||
): void {
|
||||
if (name) {
|
||||
// Update with name, preserving existing timestamp if newer
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
|
||||
ON CONFLICT(jid) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
last_message_time = MAX(last_message_time, excluded.last_message_time)
|
||||
`).run(chatJid, name, timestamp);
|
||||
`,
|
||||
).run(chatJid, name, timestamp);
|
||||
} else {
|
||||
// Update timestamp only, preserve existing name if any
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
|
||||
ON CONFLICT(jid) DO UPDATE SET
|
||||
last_message_time = MAX(last_message_time, excluded.last_message_time)
|
||||
`).run(chatJid, chatJid, timestamp);
|
||||
`,
|
||||
).run(chatJid, chatJid, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,10 +116,12 @@ export function storeChatMetadata(chatJid: string, timestamp: string, name?: str
|
||||
* Used during group metadata sync.
|
||||
*/
|
||||
export function updateChatName(chatJid: string, name: string): void {
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
|
||||
ON CONFLICT(jid) DO UPDATE SET name = excluded.name
|
||||
`).run(chatJid, name, new Date().toISOString());
|
||||
`,
|
||||
).run(chatJid, name, new Date().toISOString());
|
||||
}
|
||||
|
||||
export interface ChatInfo {
|
||||
@@ -116,11 +134,15 @@ export interface ChatInfo {
|
||||
* Get all known chats, ordered by most recent activity.
|
||||
*/
|
||||
export function getAllChats(): ChatInfo[] {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT jid, name, last_message_time
|
||||
FROM chats
|
||||
ORDER BY last_message_time DESC
|
||||
`).all() as ChatInfo[];
|
||||
`,
|
||||
)
|
||||
.all() as ChatInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,7 +150,9 @@ export function getAllChats(): ChatInfo[] {
|
||||
*/
|
||||
export function getLastGroupSync(): string | null {
|
||||
// Store sync time in a special chat entry
|
||||
const row = db.prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`).get() as { last_message_time: string } | undefined;
|
||||
const row = db
|
||||
.prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`)
|
||||
.get() as { last_message_time: string } | undefined;
|
||||
return row?.last_message_time || null;
|
||||
}
|
||||
|
||||
@@ -137,14 +161,21 @@ export function getLastGroupSync(): string | null {
|
||||
*/
|
||||
export function setLastGroupSync(): void {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`).run(now);
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`,
|
||||
).run(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a message with full content.
|
||||
* Only call this for registered groups where message history is needed.
|
||||
*/
|
||||
export function storeMessage(msg: proto.IWebMessageInfo, chatJid: string, isFromMe: boolean, pushName?: string): void {
|
||||
export function storeMessage(
|
||||
msg: proto.IWebMessageInfo,
|
||||
chatJid: string,
|
||||
isFromMe: boolean,
|
||||
pushName?: string,
|
||||
): void {
|
||||
if (!msg.key) return;
|
||||
|
||||
const content =
|
||||
@@ -159,11 +190,24 @@ export function storeMessage(msg: proto.IWebMessageInfo, chatJid: string, isFrom
|
||||
const senderName = pushName || sender.split('@')[0];
|
||||
const msgId = msg.key.id || '';
|
||||
|
||||
db.prepare(`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(msgId, chatJid, sender, senderName, content, timestamp, isFromMe ? 1 : 0);
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
msgId,
|
||||
chatJid,
|
||||
sender,
|
||||
senderName,
|
||||
content,
|
||||
timestamp,
|
||||
isFromMe ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getNewMessages(jids: string[], lastTimestamp: string, botPrefix: string): { messages: NewMessage[]; newTimestamp: string } {
|
||||
export function getNewMessages(
|
||||
jids: string[],
|
||||
lastTimestamp: string,
|
||||
botPrefix: string,
|
||||
): { messages: NewMessage[]; newTimestamp: string } {
|
||||
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
||||
|
||||
const placeholders = jids.map(() => '?').join(',');
|
||||
@@ -175,7 +219,9 @@ export function getNewMessages(jids: string[], lastTimestamp: string, botPrefix:
|
||||
ORDER BY timestamp
|
||||
`;
|
||||
|
||||
const rows = db.prepare(sql).all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[];
|
||||
const rows = db
|
||||
.prepare(sql)
|
||||
.all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[];
|
||||
|
||||
let newTimestamp = lastTimestamp;
|
||||
for (const row of rows) {
|
||||
@@ -185,7 +231,11 @@ export function getNewMessages(jids: string[], lastTimestamp: string, botPrefix:
|
||||
return { messages: rows, newTimestamp };
|
||||
}
|
||||
|
||||
export function getMessagesSince(chatJid: string, sinceTimestamp: string, botPrefix: string): NewMessage[] {
|
||||
export function getMessagesSince(
|
||||
chatJid: string,
|
||||
sinceTimestamp: string,
|
||||
botPrefix: string,
|
||||
): NewMessage[] {
|
||||
// Filter out bot's own messages by checking content prefix
|
||||
const sql = `
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp
|
||||
@@ -193,14 +243,20 @@ export function getMessagesSince(chatJid: string, sinceTimestamp: string, botPre
|
||||
WHERE chat_jid = ? AND timestamp > ? AND content NOT LIKE ?
|
||||
ORDER BY timestamp
|
||||
`;
|
||||
return db.prepare(sql).all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[];
|
||||
return db
|
||||
.prepare(sql)
|
||||
.all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[];
|
||||
}
|
||||
|
||||
export function createTask(task: Omit<ScheduledTask, 'last_run' | 'last_result'>): void {
|
||||
db.prepare(`
|
||||
export function createTask(
|
||||
task: Omit<ScheduledTask, 'last_run' | 'last_result'>,
|
||||
): void {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
`,
|
||||
).run(
|
||||
task.id,
|
||||
task.group_folder,
|
||||
task.chat_jid,
|
||||
@@ -210,36 +266,69 @@ export function createTask(task: Omit<ScheduledTask, 'last_run' | 'last_result'>
|
||||
task.context_mode || 'isolated',
|
||||
task.next_run,
|
||||
task.status,
|
||||
task.created_at
|
||||
task.created_at,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTaskById(id: string): ScheduledTask | undefined {
|
||||
return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as ScheduledTask | undefined;
|
||||
return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as
|
||||
| ScheduledTask
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function getTasksForGroup(groupFolder: string): ScheduledTask[] {
|
||||
return db.prepare('SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC').all(groupFolder) as ScheduledTask[];
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC',
|
||||
)
|
||||
.all(groupFolder) as ScheduledTask[];
|
||||
}
|
||||
|
||||
export function getAllTasks(): ScheduledTask[] {
|
||||
return db.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC').all() as ScheduledTask[];
|
||||
return db
|
||||
.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC')
|
||||
.all() as ScheduledTask[];
|
||||
}
|
||||
|
||||
export function updateTask(id: string, updates: Partial<Pick<ScheduledTask, 'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status'>>): void {
|
||||
export function updateTask(
|
||||
id: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
ScheduledTask,
|
||||
'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status'
|
||||
>
|
||||
>,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (updates.prompt !== undefined) { fields.push('prompt = ?'); values.push(updates.prompt); }
|
||||
if (updates.schedule_type !== undefined) { fields.push('schedule_type = ?'); values.push(updates.schedule_type); }
|
||||
if (updates.schedule_value !== undefined) { fields.push('schedule_value = ?'); values.push(updates.schedule_value); }
|
||||
if (updates.next_run !== undefined) { fields.push('next_run = ?'); values.push(updates.next_run); }
|
||||
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
|
||||
if (updates.prompt !== undefined) {
|
||||
fields.push('prompt = ?');
|
||||
values.push(updates.prompt);
|
||||
}
|
||||
if (updates.schedule_type !== undefined) {
|
||||
fields.push('schedule_type = ?');
|
||||
values.push(updates.schedule_type);
|
||||
}
|
||||
if (updates.schedule_value !== undefined) {
|
||||
fields.push('schedule_value = ?');
|
||||
values.push(updates.schedule_value);
|
||||
}
|
||||
if (updates.next_run !== undefined) {
|
||||
fields.push('next_run = ?');
|
||||
values.push(updates.next_run);
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
fields.push('status = ?');
|
||||
values.push(updates.status);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
db.prepare(
|
||||
`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`,
|
||||
).run(...values);
|
||||
}
|
||||
|
||||
export function deleteTask(id: string): void {
|
||||
@@ -250,35 +339,58 @@ export function deleteTask(id: string): void {
|
||||
|
||||
export function getDueTasks(): ScheduledTask[] {
|
||||
const now = new Date().toISOString();
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM scheduled_tasks
|
||||
WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ?
|
||||
ORDER BY next_run
|
||||
`).all(now) as ScheduledTask[];
|
||||
`,
|
||||
)
|
||||
.all(now) as ScheduledTask[];
|
||||
}
|
||||
|
||||
export function updateTaskAfterRun(id: string, nextRun: string | null, lastResult: string): void {
|
||||
export function updateTaskAfterRun(
|
||||
id: string,
|
||||
nextRun: string | null,
|
||||
lastResult: string,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE scheduled_tasks
|
||||
SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END
|
||||
WHERE id = ?
|
||||
`).run(nextRun, now, lastResult, nextRun, id);
|
||||
`,
|
||||
).run(nextRun, now, lastResult, nextRun, id);
|
||||
}
|
||||
|
||||
export function logTaskRun(log: TaskRunLog): void {
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(log.task_id, log.run_at, log.duration_ms, log.status, log.result, log.error);
|
||||
`,
|
||||
).run(
|
||||
log.task_id,
|
||||
log.run_at,
|
||||
log.duration_ms,
|
||||
log.status,
|
||||
log.result,
|
||||
log.error,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTaskRunLogs(taskId: string, limit = 10): TaskRunLog[] {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT task_id, run_at, duration_ms, status, result, error
|
||||
FROM task_run_logs
|
||||
WHERE task_id = ?
|
||||
ORDER BY run_at DESC
|
||||
LIMIT ?
|
||||
`).all(taskId, limit) as TaskRunLog[];
|
||||
`,
|
||||
)
|
||||
.all(taskId, limit) as TaskRunLog[];
|
||||
}
|
||||
|
||||
404
src/index.ts
404
src/index.ts
@@ -1,35 +1,53 @@
|
||||
import makeWASocket, {
|
||||
useMultiFileAuthState,
|
||||
DisconnectReason,
|
||||
makeCacheableSignalKeyStore,
|
||||
WASocket
|
||||
} from '@whiskeysockets/baileys';
|
||||
import pino from 'pino';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import pino from 'pino';
|
||||
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
WASocket,
|
||||
makeCacheableSignalKeyStore,
|
||||
useMultiFileAuthState,
|
||||
} from '@whiskeysockets/baileys';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
DATA_DIR,
|
||||
IPC_POLL_INTERVAL,
|
||||
MAIN_GROUP_FOLDER,
|
||||
POLL_INTERVAL,
|
||||
STORE_DIR,
|
||||
DATA_DIR,
|
||||
TIMEZONE,
|
||||
TRIGGER_PATTERN,
|
||||
MAIN_GROUP_FOLDER,
|
||||
IPC_POLL_INTERVAL,
|
||||
TIMEZONE
|
||||
} from './config.js';
|
||||
import { RegisteredGroup, Session, NewMessage } from './types.js';
|
||||
import { initDatabase, storeMessage, storeChatMetadata, getNewMessages, getMessagesSince, getAllTasks, getTaskById, updateChatName, getAllChats, getLastGroupSync, setLastGroupSync } from './db.js';
|
||||
import {
|
||||
AvailableGroup,
|
||||
runContainerAgent,
|
||||
writeGroupsSnapshot,
|
||||
writeTasksSnapshot,
|
||||
} from './container-runner.js';
|
||||
import {
|
||||
getAllChats,
|
||||
getAllTasks,
|
||||
getLastGroupSync,
|
||||
getMessagesSince,
|
||||
getNewMessages,
|
||||
getTaskById,
|
||||
initDatabase,
|
||||
setLastGroupSync,
|
||||
storeChatMetadata,
|
||||
storeMessage,
|
||||
updateChatName,
|
||||
} from './db.js';
|
||||
import { startSchedulerLoop } from './task-scheduler.js';
|
||||
import { runContainerAgent, writeTasksSnapshot, writeGroupsSnapshot, AvailableGroup } from './container-runner.js';
|
||||
import { NewMessage, RegisteredGroup, Session } from './types.js';
|
||||
import { loadJson, saveJson } from './utils.js';
|
||||
|
||||
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: { target: 'pino-pretty', options: { colorize: true } }
|
||||
transport: { target: 'pino-pretty', options: { colorize: true } },
|
||||
});
|
||||
|
||||
let sock: WASocket;
|
||||
@@ -48,16 +66,28 @@ async function setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||
|
||||
function loadState(): void {
|
||||
const statePath = path.join(DATA_DIR, 'router_state.json');
|
||||
const state = loadJson<{ last_timestamp?: string; last_agent_timestamp?: Record<string, string> }>(statePath, {});
|
||||
const state = loadJson<{
|
||||
last_timestamp?: string;
|
||||
last_agent_timestamp?: Record<string, string>;
|
||||
}>(statePath, {});
|
||||
lastTimestamp = state.last_timestamp || '';
|
||||
lastAgentTimestamp = state.last_agent_timestamp || {};
|
||||
sessions = loadJson(path.join(DATA_DIR, 'sessions.json'), {});
|
||||
registeredGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {});
|
||||
logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded');
|
||||
registeredGroups = loadJson(
|
||||
path.join(DATA_DIR, 'registered_groups.json'),
|
||||
{},
|
||||
);
|
||||
logger.info(
|
||||
{ groupCount: Object.keys(registeredGroups).length },
|
||||
'State loaded',
|
||||
);
|
||||
}
|
||||
|
||||
function saveState(): void {
|
||||
saveJson(path.join(DATA_DIR, 'router_state.json'), { last_timestamp: lastTimestamp, last_agent_timestamp: lastAgentTimestamp });
|
||||
saveJson(path.join(DATA_DIR, 'router_state.json'), {
|
||||
last_timestamp: lastTimestamp,
|
||||
last_agent_timestamp: lastAgentTimestamp,
|
||||
});
|
||||
saveJson(path.join(DATA_DIR, 'sessions.json'), sessions);
|
||||
}
|
||||
|
||||
@@ -69,7 +99,10 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||
const groupDir = path.join(DATA_DIR, '..', 'groups', group.folder);
|
||||
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
||||
|
||||
logger.info({ jid, name: group.name, folder: group.folder }, 'Group registered');
|
||||
logger.info(
|
||||
{ jid, name: group.name, folder: group.folder },
|
||||
'Group registered',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,12 +152,12 @@ function getAvailableGroups(): AvailableGroup[] {
|
||||
const registeredJids = new Set(Object.keys(registeredGroups));
|
||||
|
||||
return chats
|
||||
.filter(c => c.jid !== '__group_sync__' && c.jid.endsWith('@g.us'))
|
||||
.map(c => ({
|
||||
.filter((c) => c.jid !== '__group_sync__' && c.jid.endsWith('@g.us'))
|
||||
.map((c) => ({
|
||||
jid: c.jid,
|
||||
name: c.name,
|
||||
lastActivity: c.last_message_time,
|
||||
isRegistered: registeredJids.has(c.jid)
|
||||
isRegistered: registeredJids.has(c.jid),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -140,22 +173,30 @@ async function processMessage(msg: NewMessage): Promise<void> {
|
||||
|
||||
// Get all messages since last agent interaction so the session has full context
|
||||
const sinceTimestamp = lastAgentTimestamp[msg.chat_jid] || '';
|
||||
const missedMessages = getMessagesSince(msg.chat_jid, sinceTimestamp, ASSISTANT_NAME);
|
||||
const missedMessages = getMessagesSince(
|
||||
msg.chat_jid,
|
||||
sinceTimestamp,
|
||||
ASSISTANT_NAME,
|
||||
);
|
||||
|
||||
const lines = missedMessages.map(m => {
|
||||
const lines = missedMessages.map((m) => {
|
||||
// Escape XML special characters in content
|
||||
const escapeXml = (s: string) => s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
const escapeXml = (s: string) =>
|
||||
s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
return `<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`;
|
||||
});
|
||||
const prompt = `<messages>\n${lines.join('\n')}\n</messages>`;
|
||||
|
||||
if (!prompt) return;
|
||||
|
||||
logger.info({ group: group.name, messageCount: missedMessages.length }, 'Processing message');
|
||||
logger.info(
|
||||
{ group: group.name, messageCount: missedMessages.length },
|
||||
'Processing message',
|
||||
);
|
||||
|
||||
await setTyping(msg.chat_jid, true);
|
||||
const response = await runAgent(group, prompt, msg.chat_jid);
|
||||
@@ -167,25 +208,38 @@ async function processMessage(msg: NewMessage): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string): Promise<string | null> {
|
||||
async function runAgent(
|
||||
group: RegisteredGroup,
|
||||
prompt: string,
|
||||
chatJid: string,
|
||||
): Promise<string | null> {
|
||||
const isMain = group.folder === MAIN_GROUP_FOLDER;
|
||||
const sessionId = sessions[group.folder];
|
||||
|
||||
// Update tasks snapshot for container to read (filtered by group)
|
||||
const tasks = getAllTasks();
|
||||
writeTasksSnapshot(group.folder, isMain, tasks.map(t => ({
|
||||
id: t.id,
|
||||
groupFolder: t.group_folder,
|
||||
prompt: t.prompt,
|
||||
schedule_type: t.schedule_type,
|
||||
schedule_value: t.schedule_value,
|
||||
status: t.status,
|
||||
next_run: t.next_run
|
||||
})));
|
||||
writeTasksSnapshot(
|
||||
group.folder,
|
||||
isMain,
|
||||
tasks.map((t) => ({
|
||||
id: t.id,
|
||||
groupFolder: t.group_folder,
|
||||
prompt: t.prompt,
|
||||
schedule_type: t.schedule_type,
|
||||
schedule_value: t.schedule_value,
|
||||
status: t.status,
|
||||
next_run: t.next_run,
|
||||
})),
|
||||
);
|
||||
|
||||
// Update available groups snapshot (main group only can see all groups)
|
||||
const availableGroups = getAvailableGroups();
|
||||
writeGroupsSnapshot(group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups)));
|
||||
writeGroupsSnapshot(
|
||||
group.folder,
|
||||
isMain,
|
||||
availableGroups,
|
||||
new Set(Object.keys(registeredGroups)),
|
||||
);
|
||||
|
||||
try {
|
||||
const output = await runContainerAgent(group, {
|
||||
@@ -193,7 +247,7 @@ async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string)
|
||||
sessionId,
|
||||
groupFolder: group.folder,
|
||||
chatJid,
|
||||
isMain
|
||||
isMain,
|
||||
});
|
||||
|
||||
if (output.newSessionId) {
|
||||
@@ -202,7 +256,10 @@ async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string)
|
||||
}
|
||||
|
||||
if (output.status === 'error') {
|
||||
logger.error({ group: group.name, error: output.error }, 'Container agent error');
|
||||
logger.error(
|
||||
{ group: group.name, error: output.error },
|
||||
'Container agent error',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -230,7 +287,7 @@ function startIpcWatcher(): void {
|
||||
// Scan all group IPC directories (identity determined by directory)
|
||||
let groupFolders: string[];
|
||||
try {
|
||||
groupFolders = fs.readdirSync(ipcBaseDir).filter(f => {
|
||||
groupFolders = fs.readdirSync(ipcBaseDir).filter((f) => {
|
||||
const stat = fs.statSync(path.join(ipcBaseDir, f));
|
||||
return stat.isDirectory() && f !== 'errors';
|
||||
});
|
||||
@@ -248,7 +305,9 @@ function startIpcWatcher(): void {
|
||||
// Process messages from this group's IPC directory
|
||||
try {
|
||||
if (fs.existsSync(messagesDir)) {
|
||||
const messageFiles = fs.readdirSync(messagesDir).filter(f => f.endsWith('.json'));
|
||||
const messageFiles = fs
|
||||
.readdirSync(messagesDir)
|
||||
.filter((f) => f.endsWith('.json'));
|
||||
for (const file of messageFiles) {
|
||||
const filePath = path.join(messagesDir, file);
|
||||
try {
|
||||
@@ -256,30 +315,53 @@ function startIpcWatcher(): void {
|
||||
if (data.type === 'message' && data.chatJid && data.text) {
|
||||
// Authorization: verify this group can send to this chatJid
|
||||
const targetGroup = registeredGroups[data.chatJid];
|
||||
if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) {
|
||||
await sendMessage(data.chatJid, `${ASSISTANT_NAME}: ${data.text}`);
|
||||
logger.info({ chatJid: data.chatJid, sourceGroup }, 'IPC message sent');
|
||||
if (
|
||||
isMain ||
|
||||
(targetGroup && targetGroup.folder === sourceGroup)
|
||||
) {
|
||||
await sendMessage(
|
||||
data.chatJid,
|
||||
`${ASSISTANT_NAME}: ${data.text}`,
|
||||
);
|
||||
logger.info(
|
||||
{ chatJid: data.chatJid, sourceGroup },
|
||||
'IPC message sent',
|
||||
);
|
||||
} else {
|
||||
logger.warn({ chatJid: data.chatJid, sourceGroup }, 'Unauthorized IPC message attempt blocked');
|
||||
logger.warn(
|
||||
{ chatJid: data.chatJid, sourceGroup },
|
||||
'Unauthorized IPC message attempt blocked',
|
||||
);
|
||||
}
|
||||
}
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (err) {
|
||||
logger.error({ file, sourceGroup, err }, 'Error processing IPC message');
|
||||
logger.error(
|
||||
{ file, sourceGroup, err },
|
||||
'Error processing IPC message',
|
||||
);
|
||||
const errorDir = path.join(ipcBaseDir, 'errors');
|
||||
fs.mkdirSync(errorDir, { recursive: true });
|
||||
fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
|
||||
fs.renameSync(
|
||||
filePath,
|
||||
path.join(errorDir, `${sourceGroup}-${file}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, sourceGroup }, 'Error reading IPC messages directory');
|
||||
logger.error(
|
||||
{ err, sourceGroup },
|
||||
'Error reading IPC messages directory',
|
||||
);
|
||||
}
|
||||
|
||||
// Process tasks from this group's IPC directory
|
||||
try {
|
||||
if (fs.existsSync(tasksDir)) {
|
||||
const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json'));
|
||||
const taskFiles = fs
|
||||
.readdirSync(tasksDir)
|
||||
.filter((f) => f.endsWith('.json'));
|
||||
for (const file of taskFiles) {
|
||||
const filePath = path.join(tasksDir, file);
|
||||
try {
|
||||
@@ -288,10 +370,16 @@ function startIpcWatcher(): void {
|
||||
await processTaskIpc(data, sourceGroup, isMain);
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (err) {
|
||||
logger.error({ file, sourceGroup, err }, 'Error processing IPC task');
|
||||
logger.error(
|
||||
{ file, sourceGroup, err },
|
||||
'Error processing IPC task',
|
||||
);
|
||||
const errorDir = path.join(ipcBaseDir, 'errors');
|
||||
fs.mkdirSync(errorDir, { recursive: true });
|
||||
fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
|
||||
fs.renameSync(
|
||||
filePath,
|
||||
path.join(errorDir, `${sourceGroup}-${file}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,30 +412,46 @@ async function processTaskIpc(
|
||||
trigger?: string;
|
||||
containerConfig?: RegisteredGroup['containerConfig'];
|
||||
},
|
||||
sourceGroup: string, // Verified identity from IPC directory
|
||||
isMain: boolean // Verified from directory path
|
||||
sourceGroup: string, // Verified identity from IPC directory
|
||||
isMain: boolean, // Verified from directory path
|
||||
): Promise<void> {
|
||||
// Import db functions dynamically to avoid circular deps
|
||||
const { createTask, updateTask, deleteTask, getTaskById: getTask } = await import('./db.js');
|
||||
const {
|
||||
createTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
getTaskById: getTask,
|
||||
} = await import('./db.js');
|
||||
const { CronExpressionParser } = await import('cron-parser');
|
||||
|
||||
switch (data.type) {
|
||||
case 'schedule_task':
|
||||
if (data.prompt && data.schedule_type && data.schedule_value && data.groupFolder) {
|
||||
if (
|
||||
data.prompt &&
|
||||
data.schedule_type &&
|
||||
data.schedule_value &&
|
||||
data.groupFolder
|
||||
) {
|
||||
// Authorization: non-main groups can only schedule for themselves
|
||||
const targetGroup = data.groupFolder;
|
||||
if (!isMain && targetGroup !== sourceGroup) {
|
||||
logger.warn({ sourceGroup, targetGroup }, 'Unauthorized schedule_task attempt blocked');
|
||||
logger.warn(
|
||||
{ sourceGroup, targetGroup },
|
||||
'Unauthorized schedule_task attempt blocked',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Resolve the correct JID for the target group (don't trust IPC payload)
|
||||
const targetJid = Object.entries(registeredGroups).find(
|
||||
([, group]) => group.folder === targetGroup
|
||||
([, group]) => group.folder === targetGroup,
|
||||
)?.[0];
|
||||
|
||||
if (!targetJid) {
|
||||
logger.warn({ targetGroup }, 'Cannot schedule task: target group not registered');
|
||||
logger.warn(
|
||||
{ targetGroup },
|
||||
'Cannot schedule task: target group not registered',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -356,32 +460,44 @@ async function processTaskIpc(
|
||||
let nextRun: string | null = null;
|
||||
if (scheduleType === 'cron') {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(data.schedule_value, { tz: TIMEZONE });
|
||||
const interval = CronExpressionParser.parse(data.schedule_value, {
|
||||
tz: TIMEZONE,
|
||||
});
|
||||
nextRun = interval.next().toISOString();
|
||||
} catch {
|
||||
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid cron expression');
|
||||
logger.warn(
|
||||
{ scheduleValue: data.schedule_value },
|
||||
'Invalid cron expression',
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else if (scheduleType === 'interval') {
|
||||
const ms = parseInt(data.schedule_value, 10);
|
||||
if (isNaN(ms) || ms <= 0) {
|
||||
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid interval');
|
||||
logger.warn(
|
||||
{ scheduleValue: data.schedule_value },
|
||||
'Invalid interval',
|
||||
);
|
||||
break;
|
||||
}
|
||||
nextRun = new Date(Date.now() + ms).toISOString();
|
||||
} else if (scheduleType === 'once') {
|
||||
const scheduled = new Date(data.schedule_value);
|
||||
if (isNaN(scheduled.getTime())) {
|
||||
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid timestamp');
|
||||
logger.warn(
|
||||
{ scheduleValue: data.schedule_value },
|
||||
'Invalid timestamp',
|
||||
);
|
||||
break;
|
||||
}
|
||||
nextRun = scheduled.toISOString();
|
||||
}
|
||||
|
||||
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const contextMode = (data.context_mode === 'group' || data.context_mode === 'isolated')
|
||||
? data.context_mode
|
||||
: 'isolated';
|
||||
const contextMode =
|
||||
data.context_mode === 'group' || data.context_mode === 'isolated'
|
||||
? data.context_mode
|
||||
: 'isolated';
|
||||
createTask({
|
||||
id: taskId,
|
||||
group_folder: targetGroup,
|
||||
@@ -392,9 +508,12 @@ async function processTaskIpc(
|
||||
context_mode: contextMode,
|
||||
next_run: nextRun,
|
||||
status: 'active',
|
||||
created_at: new Date().toISOString()
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
logger.info({ taskId, sourceGroup, targetGroup, contextMode }, 'Task created via IPC');
|
||||
logger.info(
|
||||
{ taskId, sourceGroup, targetGroup, contextMode },
|
||||
'Task created via IPC',
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -403,9 +522,15 @@ async function processTaskIpc(
|
||||
const task = getTask(data.taskId);
|
||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
||||
updateTask(data.taskId, { status: 'paused' });
|
||||
logger.info({ taskId: data.taskId, sourceGroup }, 'Task paused via IPC');
|
||||
logger.info(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Task paused via IPC',
|
||||
);
|
||||
} else {
|
||||
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt');
|
||||
logger.warn(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Unauthorized task pause attempt',
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -415,9 +540,15 @@ async function processTaskIpc(
|
||||
const task = getTask(data.taskId);
|
||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
||||
updateTask(data.taskId, { status: 'active' });
|
||||
logger.info({ taskId: data.taskId, sourceGroup }, 'Task resumed via IPC');
|
||||
logger.info(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Task resumed via IPC',
|
||||
);
|
||||
} else {
|
||||
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt');
|
||||
logger.warn(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Unauthorized task resume attempt',
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -427,9 +558,15 @@ async function processTaskIpc(
|
||||
const task = getTask(data.taskId);
|
||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
||||
deleteTask(data.taskId);
|
||||
logger.info({ taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC');
|
||||
logger.info(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Task cancelled via IPC',
|
||||
);
|
||||
} else {
|
||||
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt');
|
||||
logger.warn(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Unauthorized task cancel attempt',
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -437,21 +574,36 @@ async function processTaskIpc(
|
||||
case 'refresh_groups':
|
||||
// Only main group can request a refresh
|
||||
if (isMain) {
|
||||
logger.info({ sourceGroup }, 'Group metadata refresh requested via IPC');
|
||||
logger.info(
|
||||
{ sourceGroup },
|
||||
'Group metadata refresh requested via IPC',
|
||||
);
|
||||
await syncGroupMetadata(true);
|
||||
// Write updated snapshot immediately
|
||||
const availableGroups = getAvailableGroups();
|
||||
const { writeGroupsSnapshot: writeGroups } = await import('./container-runner.js');
|
||||
writeGroups(sourceGroup, true, availableGroups, new Set(Object.keys(registeredGroups)));
|
||||
const { writeGroupsSnapshot: writeGroups } =
|
||||
await import('./container-runner.js');
|
||||
writeGroups(
|
||||
sourceGroup,
|
||||
true,
|
||||
availableGroups,
|
||||
new Set(Object.keys(registeredGroups)),
|
||||
);
|
||||
} else {
|
||||
logger.warn({ sourceGroup }, 'Unauthorized refresh_groups attempt blocked');
|
||||
logger.warn(
|
||||
{ sourceGroup },
|
||||
'Unauthorized refresh_groups attempt blocked',
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'register_group':
|
||||
// Only main group can register new groups
|
||||
if (!isMain) {
|
||||
logger.warn({ sourceGroup }, 'Unauthorized register_group attempt blocked');
|
||||
logger.warn(
|
||||
{ sourceGroup },
|
||||
'Unauthorized register_group attempt blocked',
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (data.jid && data.name && data.folder && data.trigger) {
|
||||
@@ -460,10 +612,13 @@ async function processTaskIpc(
|
||||
folder: data.folder,
|
||||
trigger: data.trigger,
|
||||
added_at: new Date().toISOString(),
|
||||
containerConfig: data.containerConfig
|
||||
containerConfig: data.containerConfig,
|
||||
});
|
||||
} else {
|
||||
logger.warn({ data }, 'Invalid register_group request - missing required fields');
|
||||
logger.warn(
|
||||
{ data },
|
||||
'Invalid register_group request - missing required fields',
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -479,19 +634,25 @@ async function connectWhatsApp(): Promise<void> {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
|
||||
sock = makeWASocket({
|
||||
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||
},
|
||||
printQRInTerminal: false,
|
||||
logger,
|
||||
browser: ['NanoClaw', 'Chrome', '1.0.0']
|
||||
browser: ['NanoClaw', 'Chrome', '1.0.0'],
|
||||
});
|
||||
|
||||
sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
const msg = 'WhatsApp authentication required. Run /setup in Claude Code.';
|
||||
const msg =
|
||||
'WhatsApp authentication required. Run /setup in Claude Code.';
|
||||
logger.error(msg);
|
||||
exec(`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`);
|
||||
exec(
|
||||
`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
|
||||
);
|
||||
setTimeout(() => process.exit(1), 1000);
|
||||
}
|
||||
|
||||
@@ -510,15 +671,19 @@ async function connectWhatsApp(): Promise<void> {
|
||||
} else if (connection === 'open') {
|
||||
logger.info('Connected to WhatsApp');
|
||||
// Sync group metadata on startup (respects 24h cache)
|
||||
syncGroupMetadata().catch(err => logger.error({ err }, 'Initial group sync failed'));
|
||||
syncGroupMetadata().catch((err) =>
|
||||
logger.error({ err }, 'Initial group sync failed'),
|
||||
);
|
||||
// Set up daily sync timer
|
||||
setInterval(() => {
|
||||
syncGroupMetadata().catch(err => logger.error({ err }, 'Periodic group sync failed'));
|
||||
syncGroupMetadata().catch((err) =>
|
||||
logger.error({ err }, 'Periodic group sync failed'),
|
||||
);
|
||||
}, GROUP_SYNC_INTERVAL_MS);
|
||||
startSchedulerLoop({
|
||||
sendMessage,
|
||||
registeredGroups: () => registeredGroups,
|
||||
getSessions: () => sessions
|
||||
getSessions: () => sessions,
|
||||
});
|
||||
startIpcWatcher();
|
||||
startMessageLoop();
|
||||
@@ -533,14 +698,21 @@ async function connectWhatsApp(): Promise<void> {
|
||||
const chatJid = msg.key.remoteJid;
|
||||
if (!chatJid || chatJid === 'status@broadcast') continue;
|
||||
|
||||
const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString();
|
||||
const timestamp = new Date(
|
||||
Number(msg.messageTimestamp) * 1000,
|
||||
).toISOString();
|
||||
|
||||
// Always store chat metadata for group discovery
|
||||
storeChatMetadata(chatJid, timestamp);
|
||||
|
||||
// Only store full message content for registered groups
|
||||
if (registeredGroups[chatJid]) {
|
||||
storeMessage(msg, chatJid, msg.key.fromMe || false, msg.pushName || undefined);
|
||||
storeMessage(
|
||||
msg,
|
||||
chatJid,
|
||||
msg.key.fromMe || false,
|
||||
msg.pushName || undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -554,7 +726,8 @@ async function startMessageLoop(): Promise<void> {
|
||||
const jids = Object.keys(registeredGroups);
|
||||
const { messages } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
|
||||
|
||||
if (messages.length > 0) logger.info({ count: messages.length }, 'New messages');
|
||||
if (messages.length > 0)
|
||||
logger.info({ count: messages.length }, 'New messages');
|
||||
for (const msg of messages) {
|
||||
try {
|
||||
await processMessage(msg);
|
||||
@@ -562,7 +735,10 @@ async function startMessageLoop(): Promise<void> {
|
||||
lastTimestamp = msg.timestamp;
|
||||
saveState();
|
||||
} catch (err) {
|
||||
logger.error({ err, msg: msg.id }, 'Error processing message, will retry');
|
||||
logger.error(
|
||||
{ err, msg: msg.id },
|
||||
'Error processing message, will retry',
|
||||
);
|
||||
// Stop processing this batch - failed message will be retried next loop
|
||||
break;
|
||||
}
|
||||
@@ -570,7 +746,7 @@ async function startMessageLoop(): Promise<void> {
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error in message loop');
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,14 +761,30 @@ function ensureContainerSystemRunning(): void {
|
||||
logger.info('Apple Container system started');
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to start Apple Container system');
|
||||
console.error('\n╔════════════════════════════════════════════════════════════════╗');
|
||||
console.error('║ FATAL: Apple Container system failed to start ║');
|
||||
console.error('║ ║');
|
||||
console.error('║ Agents cannot run without Apple Container. To fix: ║');
|
||||
console.error('║ 1. Install from: https://github.com/apple/container/releases ║');
|
||||
console.error('║ 2. Run: container system start ║');
|
||||
console.error('║ 3. Restart NanoClaw ║');
|
||||
console.error('╚════════════════════════════════════════════════════════════════╝\n');
|
||||
console.error(
|
||||
'\n╔════════════════════════════════════════════════════════════════╗',
|
||||
);
|
||||
console.error(
|
||||
'║ FATAL: Apple Container system failed to start ║',
|
||||
);
|
||||
console.error(
|
||||
'║ ║',
|
||||
);
|
||||
console.error(
|
||||
'║ Agents cannot run without Apple Container. To fix: ║',
|
||||
);
|
||||
console.error(
|
||||
'║ 1. Install from: https://github.com/apple/container/releases ║',
|
||||
);
|
||||
console.error(
|
||||
'║ 2. Run: container system start ║',
|
||||
);
|
||||
console.error(
|
||||
'║ 3. Restart NanoClaw ║',
|
||||
);
|
||||
console.error(
|
||||
'╚════════════════════════════════════════════════════════════════╝\n',
|
||||
);
|
||||
throw new Error('Apple Container system is required but failed to start');
|
||||
}
|
||||
}
|
||||
@@ -606,7 +798,7 @@ async function main(): Promise<void> {
|
||||
await connectWhatsApp();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
main().catch((err) => {
|
||||
logger.error({ err }, 'Failed to start NanoClaw');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
*
|
||||
* Allowlist location: ~/.config/nanoclaw/mount-allowlist.json
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import pino from 'pino';
|
||||
|
||||
import { MOUNT_ALLOWLIST_PATH } from './config.js';
|
||||
import { AdditionalMount, MountAllowlist, AllowedRoot } from './types.js';
|
||||
import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js';
|
||||
|
||||
const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: { target: 'pino-pretty', options: { colorize: true } }
|
||||
transport: { target: 'pino-pretty', options: { colorize: true } },
|
||||
});
|
||||
|
||||
// Cache the allowlist in memory - only reloads on process restart
|
||||
@@ -63,9 +63,11 @@ export function loadMountAllowlist(): MountAllowlist | null {
|
||||
try {
|
||||
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
|
||||
allowlistLoadError = `Mount allowlist not found at ${MOUNT_ALLOWLIST_PATH}`;
|
||||
logger.warn({ path: MOUNT_ALLOWLIST_PATH },
|
||||
logger.warn(
|
||||
{ path: MOUNT_ALLOWLIST_PATH },
|
||||
'Mount allowlist not found - additional mounts will be BLOCKED. ' +
|
||||
'Create the file to enable additional mounts.');
|
||||
'Create the file to enable additional mounts.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -87,24 +89,30 @@ export function loadMountAllowlist(): MountAllowlist | null {
|
||||
|
||||
// Merge with default blocked patterns
|
||||
const mergedBlockedPatterns = [
|
||||
...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])
|
||||
...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns]),
|
||||
];
|
||||
allowlist.blockedPatterns = mergedBlockedPatterns;
|
||||
|
||||
cachedAllowlist = allowlist;
|
||||
logger.info({
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
allowedRoots: allowlist.allowedRoots.length,
|
||||
blockedPatterns: allowlist.blockedPatterns.length
|
||||
}, 'Mount allowlist loaded successfully');
|
||||
logger.info(
|
||||
{
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
allowedRoots: allowlist.allowedRoots.length,
|
||||
blockedPatterns: allowlist.blockedPatterns.length,
|
||||
},
|
||||
'Mount allowlist loaded successfully',
|
||||
);
|
||||
|
||||
return cachedAllowlist;
|
||||
} catch (err) {
|
||||
allowlistLoadError = err instanceof Error ? err.message : String(err);
|
||||
logger.error({
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
error: allowlistLoadError
|
||||
}, 'Failed to load mount allowlist - additional mounts will be BLOCKED');
|
||||
logger.error(
|
||||
{
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
error: allowlistLoadError,
|
||||
},
|
||||
'Failed to load mount allowlist - additional mounts will be BLOCKED',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -138,7 +146,10 @@ function getRealPath(p: string): string | null {
|
||||
/**
|
||||
* Check if a path matches any blocked pattern
|
||||
*/
|
||||
function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): string | null {
|
||||
function matchesBlockedPattern(
|
||||
realPath: string,
|
||||
blockedPatterns: string[],
|
||||
): string | null {
|
||||
const pathParts = realPath.split(path.sep);
|
||||
|
||||
for (const pattern of blockedPatterns) {
|
||||
@@ -161,7 +172,10 @@ function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): str
|
||||
/**
|
||||
* Check if a real path is under an allowed root
|
||||
*/
|
||||
function findAllowedRoot(realPath: string, allowedRoots: AllowedRoot[]): AllowedRoot | null {
|
||||
function findAllowedRoot(
|
||||
realPath: string,
|
||||
allowedRoots: AllowedRoot[],
|
||||
): AllowedRoot | null {
|
||||
for (const root of allowedRoots) {
|
||||
const expandedRoot = expandPath(root.path);
|
||||
const realRoot = getRealPath(expandedRoot);
|
||||
@@ -216,7 +230,7 @@ export interface MountValidationResult {
|
||||
*/
|
||||
export function validateMount(
|
||||
mount: AdditionalMount,
|
||||
isMain: boolean
|
||||
isMain: boolean,
|
||||
): MountValidationResult {
|
||||
const allowlist = loadMountAllowlist();
|
||||
|
||||
@@ -224,7 +238,7 @@ export function validateMount(
|
||||
if (allowlist === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`
|
||||
reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,7 +246,7 @@ export function validateMount(
|
||||
if (!isValidContainerPath(mount.containerPath)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Invalid container path: "${mount.containerPath}" - must be relative, non-empty, and not contain ".."`
|
||||
reason: `Invalid container path: "${mount.containerPath}" - must be relative, non-empty, and not contain ".."`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,16 +257,19 @@ export function validateMount(
|
||||
if (realPath === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`
|
||||
reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check against blocked patterns
|
||||
const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns);
|
||||
const blockedMatch = matchesBlockedPattern(
|
||||
realPath,
|
||||
allowlist.blockedPatterns,
|
||||
);
|
||||
if (blockedMatch !== null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`
|
||||
reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,9 +278,9 @@ export function validateMount(
|
||||
if (allowedRoot === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${
|
||||
allowlist.allowedRoots.map(r => expandPath(r.path)).join(', ')
|
||||
}`
|
||||
reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots
|
||||
.map((r) => expandPath(r.path))
|
||||
.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,16 +292,22 @@ export function validateMount(
|
||||
if (!isMain && allowlist.nonMainReadOnly) {
|
||||
// Non-main groups forced to read-only
|
||||
effectiveReadonly = true;
|
||||
logger.info({
|
||||
mount: mount.hostPath
|
||||
}, 'Mount forced to read-only for non-main group');
|
||||
logger.info(
|
||||
{
|
||||
mount: mount.hostPath,
|
||||
},
|
||||
'Mount forced to read-only for non-main group',
|
||||
);
|
||||
} else if (!allowedRoot.allowReadWrite) {
|
||||
// Root doesn't allow read-write
|
||||
effectiveReadonly = true;
|
||||
logger.info({
|
||||
mount: mount.hostPath,
|
||||
root: allowedRoot.path
|
||||
}, 'Mount forced to read-only - root does not allow read-write');
|
||||
logger.info(
|
||||
{
|
||||
mount: mount.hostPath,
|
||||
root: allowedRoot.path,
|
||||
},
|
||||
'Mount forced to read-only - root does not allow read-write',
|
||||
);
|
||||
} else {
|
||||
// Read-write allowed
|
||||
effectiveReadonly = false;
|
||||
@@ -295,7 +318,7 @@ export function validateMount(
|
||||
allowed: true,
|
||||
reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`,
|
||||
realHostPath: realPath,
|
||||
effectiveReadonly
|
||||
effectiveReadonly,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,7 +330,7 @@ export function validateMount(
|
||||
export function validateAdditionalMounts(
|
||||
mounts: AdditionalMount[],
|
||||
groupName: string,
|
||||
isMain: boolean
|
||||
isMain: boolean,
|
||||
): Array<{
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
@@ -326,23 +349,29 @@ export function validateAdditionalMounts(
|
||||
validatedMounts.push({
|
||||
hostPath: result.realHostPath!,
|
||||
containerPath: `/workspace/extra/${mount.containerPath}`,
|
||||
readonly: result.effectiveReadonly!
|
||||
readonly: result.effectiveReadonly!,
|
||||
});
|
||||
|
||||
logger.debug({
|
||||
group: groupName,
|
||||
hostPath: result.realHostPath,
|
||||
containerPath: mount.containerPath,
|
||||
readonly: result.effectiveReadonly,
|
||||
reason: result.reason
|
||||
}, 'Mount validated successfully');
|
||||
logger.debug(
|
||||
{
|
||||
group: groupName,
|
||||
hostPath: result.realHostPath,
|
||||
containerPath: mount.containerPath,
|
||||
readonly: result.effectiveReadonly,
|
||||
reason: result.reason,
|
||||
},
|
||||
'Mount validated successfully',
|
||||
);
|
||||
} else {
|
||||
logger.warn({
|
||||
group: groupName,
|
||||
requestedPath: mount.hostPath,
|
||||
containerPath: mount.containerPath,
|
||||
reason: result.reason
|
||||
}, 'Additional mount REJECTED');
|
||||
logger.warn(
|
||||
{
|
||||
group: groupName,
|
||||
requestedPath: mount.hostPath,
|
||||
containerPath: mount.containerPath,
|
||||
reason: result.reason,
|
||||
},
|
||||
'Additional mount REJECTED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,26 +387,26 @@ export function generateAllowlistTemplate(): string {
|
||||
{
|
||||
path: '~/projects',
|
||||
allowReadWrite: true,
|
||||
description: 'Development projects'
|
||||
description: 'Development projects',
|
||||
},
|
||||
{
|
||||
path: '~/repos',
|
||||
allowReadWrite: true,
|
||||
description: 'Git repositories'
|
||||
description: 'Git repositories',
|
||||
},
|
||||
{
|
||||
path: '~/Documents/work',
|
||||
allowReadWrite: false,
|
||||
description: 'Work documents (read-only)'
|
||||
}
|
||||
description: 'Work documents (read-only)',
|
||||
},
|
||||
],
|
||||
blockedPatterns: [
|
||||
// Additional patterns beyond defaults
|
||||
'password',
|
||||
'secret',
|
||||
'token'
|
||||
'token',
|
||||
],
|
||||
nonMainReadOnly: true
|
||||
nonMainReadOnly: true,
|
||||
};
|
||||
|
||||
return JSON.stringify(template, null, 2);
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import pino from 'pino';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { getDueTasks, updateTaskAfterRun, logTaskRun, getTaskById, getAllTasks } from './db.js';
|
||||
import { ScheduledTask, RegisteredGroup } from './types.js';
|
||||
import { GROUPS_DIR, SCHEDULER_POLL_INTERVAL, DATA_DIR, MAIN_GROUP_FOLDER, TIMEZONE } from './config.js';
|
||||
|
||||
import {
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
MAIN_GROUP_FOLDER,
|
||||
SCHEDULER_POLL_INTERVAL,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { runContainerAgent, writeTasksSnapshot } from './container-runner.js';
|
||||
import {
|
||||
getAllTasks,
|
||||
getDueTasks,
|
||||
getTaskById,
|
||||
logTaskRun,
|
||||
updateTaskAfterRun,
|
||||
} from './db.js';
|
||||
import { RegisteredGroup, ScheduledTask } from './types.js';
|
||||
|
||||
const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: { target: 'pino-pretty', options: { colorize: true } }
|
||||
transport: { target: 'pino-pretty', options: { colorize: true } },
|
||||
});
|
||||
|
||||
export interface SchedulerDependencies {
|
||||
@@ -18,25 +31,36 @@ export interface SchedulerDependencies {
|
||||
getSessions: () => Record<string, string>;
|
||||
}
|
||||
|
||||
async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promise<void> {
|
||||
async function runTask(
|
||||
task: ScheduledTask,
|
||||
deps: SchedulerDependencies,
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const groupDir = path.join(GROUPS_DIR, task.group_folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
|
||||
logger.info({ taskId: task.id, group: task.group_folder }, 'Running scheduled task');
|
||||
logger.info(
|
||||
{ taskId: task.id, group: task.group_folder },
|
||||
'Running scheduled task',
|
||||
);
|
||||
|
||||
const groups = deps.registeredGroups();
|
||||
const group = Object.values(groups).find(g => g.folder === task.group_folder);
|
||||
const group = Object.values(groups).find(
|
||||
(g) => g.folder === task.group_folder,
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
logger.error({ taskId: task.id, groupFolder: task.group_folder }, 'Group not found for task');
|
||||
logger.error(
|
||||
{ taskId: task.id, groupFolder: task.group_folder },
|
||||
'Group not found for task',
|
||||
);
|
||||
logTaskRun({
|
||||
task_id: task.id,
|
||||
run_at: new Date().toISOString(),
|
||||
duration_ms: Date.now() - startTime,
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Group not found: ${task.group_folder}`
|
||||
error: `Group not found: ${task.group_folder}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -44,22 +68,27 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis
|
||||
// Update tasks snapshot for container to read (filtered by group)
|
||||
const isMain = task.group_folder === MAIN_GROUP_FOLDER;
|
||||
const tasks = getAllTasks();
|
||||
writeTasksSnapshot(task.group_folder, isMain, tasks.map(t => ({
|
||||
id: t.id,
|
||||
groupFolder: t.group_folder,
|
||||
prompt: t.prompt,
|
||||
schedule_type: t.schedule_type,
|
||||
schedule_value: t.schedule_value,
|
||||
status: t.status,
|
||||
next_run: t.next_run
|
||||
})));
|
||||
writeTasksSnapshot(
|
||||
task.group_folder,
|
||||
isMain,
|
||||
tasks.map((t) => ({
|
||||
id: t.id,
|
||||
groupFolder: t.group_folder,
|
||||
prompt: t.prompt,
|
||||
schedule_type: t.schedule_type,
|
||||
schedule_value: t.schedule_value,
|
||||
status: t.status,
|
||||
next_run: t.next_run,
|
||||
})),
|
||||
);
|
||||
|
||||
let result: string | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
// For group context mode, use the group's current session
|
||||
const sessions = deps.getSessions();
|
||||
const sessionId = task.context_mode === 'group' ? sessions[task.group_folder] : undefined;
|
||||
const sessionId =
|
||||
task.context_mode === 'group' ? sessions[task.group_folder] : undefined;
|
||||
|
||||
try {
|
||||
const output = await runContainerAgent(group, {
|
||||
@@ -68,7 +97,7 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis
|
||||
groupFolder: task.group_folder,
|
||||
chatJid: task.chat_jid,
|
||||
isMain,
|
||||
isScheduledTask: true
|
||||
isScheduledTask: true,
|
||||
});
|
||||
|
||||
if (output.status === 'error') {
|
||||
@@ -77,7 +106,10 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis
|
||||
result = output.result;
|
||||
}
|
||||
|
||||
logger.info({ taskId: task.id, durationMs: Date.now() - startTime }, 'Task completed');
|
||||
logger.info(
|
||||
{ taskId: task.id, durationMs: Date.now() - startTime },
|
||||
'Task completed',
|
||||
);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ taskId: task.id, error }, 'Task failed');
|
||||
@@ -91,12 +123,14 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis
|
||||
duration_ms: durationMs,
|
||||
status: error ? 'error' : 'success',
|
||||
result,
|
||||
error
|
||||
error,
|
||||
});
|
||||
|
||||
let nextRun: string | null = null;
|
||||
if (task.schedule_type === 'cron') {
|
||||
const interval = CronExpressionParser.parse(task.schedule_value, { tz: TIMEZONE });
|
||||
const interval = CronExpressionParser.parse(task.schedule_value, {
|
||||
tz: TIMEZONE,
|
||||
});
|
||||
nextRun = interval.next().toISOString();
|
||||
} else if (task.schedule_type === 'interval') {
|
||||
const ms = parseInt(task.schedule_value, 10);
|
||||
@@ -104,7 +138,11 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis
|
||||
}
|
||||
// 'once' tasks have no next run
|
||||
|
||||
const resultSummary = error ? `Error: ${error}` : (result ? result.slice(0, 200) : 'Completed');
|
||||
const resultSummary = error
|
||||
? `Error: ${error}`
|
||||
: result
|
||||
? result.slice(0, 200)
|
||||
: 'Completed';
|
||||
updateTaskAfterRun(task.id, nextRun, resultSummary);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface AdditionalMount {
|
||||
hostPath: string; // Absolute path on host (supports ~ for home)
|
||||
hostPath: string; // Absolute path on host (supports ~ for home)
|
||||
containerPath: string; // Path inside container (under /workspace/extra/)
|
||||
readonly?: boolean; // Default: true for safety
|
||||
readonly?: boolean; // Default: true for safety
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +29,7 @@ export interface AllowedRoot {
|
||||
|
||||
export interface ContainerConfig {
|
||||
additionalMounts?: AdditionalMount[];
|
||||
timeout?: number; // Default: 300000 (5 minutes)
|
||||
timeout?: number; // Default: 300000 (5 minutes)
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
*
|
||||
* Usage: npx tsx src/whatsapp-auth.ts
|
||||
*/
|
||||
|
||||
import makeWASocket, {
|
||||
useMultiFileAuthState,
|
||||
DisconnectReason,
|
||||
makeCacheableSignalKeyStore,
|
||||
} from '@whiskeysockets/baileys';
|
||||
import pino from 'pino';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import pino from 'pino';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
makeCacheableSignalKeyStore,
|
||||
useMultiFileAuthState,
|
||||
} from '@whiskeysockets/baileys';
|
||||
|
||||
const AUTH_DIR = './store/auth';
|
||||
|
||||
@@ -30,7 +30,9 @@ async function authenticate(): Promise<void> {
|
||||
|
||||
if (state.creds.registered) {
|
||||
console.log('✓ Already authenticated with WhatsApp');
|
||||
console.log(' To re-authenticate, delete the store/auth folder and run again.');
|
||||
console.log(
|
||||
' To re-authenticate, delete the store/auth folder and run again.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user