Add prettier

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-03 17:14:17 +02:00
parent 1a32bff6ec
commit 21c66df2b1
14 changed files with 1105 additions and 692 deletions

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"singleQuote": true
}

View File

@@ -203,7 +203,7 @@ Groups can have additional directories mounted via `containerConfig` in `data/re
"containerConfig": { "containerConfig": {
"additionalMounts": [ "additionalMounts": [
{ {
"hostPath": "/Users/gavriel/projects/webapp", "hostPath": "~/projects/webapp",
"containerPath": "webapp", "containerPath": "webapp",
"readonly": false "readonly": false
} }

View File

@@ -31,22 +31,6 @@ When you learn something important:
- Add recurring context directly to this CLAUDE.md - Add recurring context directly to this CLAUDE.md
- Always index new memory files at the top of 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 ## WhatsApp Formatting
Do NOT use markdown headings (##) in WhatsApp messages. Only use: 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": { "containerConfig": {
"additionalMounts": [ "additionalMounts": [
{ {
"hostPath": "/Users/gavriel/projects/webapp", "hostPath": "~/projects/webapp",
"containerPath": "webapp", "containerPath": "webapp",
"readonly": false "readonly": false
} }

View File

@@ -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
View File

@@ -20,6 +20,7 @@
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"prettier": "^3.8.1",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0" "typescript": "^5.7.0"
}, },
@@ -551,6 +552,120 @@
"node": ">=18" "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": { "node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", "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" "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": { "node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", "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" "@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": { "node_modules/@img/sharp-wasm32": {
"version": "0.34.5", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
@@ -731,6 +1012,26 @@
"url": "https://opencollective.com/libvips" "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": { "node_modules/@keyv/bigmap": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
@@ -863,9 +1164,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.7", "version": "22.19.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -1282,9 +1583,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/hookified": { "node_modules/hookified": {
"version": "1.15.0", "version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
"integrity": "sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==", "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ieee754": { "node_modules/ieee754": {
@@ -1452,9 +1753,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/music-metadata": { "node_modules/music-metadata": {
"version": "11.11.1", "version": "11.11.2",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.11.1.tgz", "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.11.2.tgz",
"integrity": "sha512-8FT+lSLznASDhn5KNJtQE6ZH95VqhxtKWNPrvdfhlqgbdZZEEAXehx+xpUvas4VuEZAu49BhQgLa3NlmPeRaww==", "integrity": "sha512-tJx+lsDg1bGUOxojKKj12BIvccBBUcVa6oWrvOchCF0WAQ9E5t/hK35ILp1z3wWrUSYtgg57LfRbvVMkxGIyzA==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1476,7 +1777,7 @@
"strtok3": "^10.3.4", "strtok3": "^10.3.4",
"token-types": "^6.1.2", "token-types": "^6.1.2",
"uint8array-extras": "^1.5.0", "uint8array-extras": "^1.5.0",
"win-guid": "^0.2.0" "win-guid": "^0.2.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -1642,6 +1943,22 @@
"node": ">=10" "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": { "node_modules/process-warning": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@@ -1877,306 +2194,6 @@
"@img/sharp-win32-x64": "0.34.5" "@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": { "node_modules/simple-concat": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",

View File

@@ -9,7 +9,9 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"auth": "tsx src/whatsapp-auth.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": { "dependencies": {
"@whiskeysockets/baileys": "^7.0.0-rc.9", "@whiskeysockets/baileys": "^7.0.0-rc.9",
@@ -24,6 +26,7 @@
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"prettier": "^3.8.1",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0" "typescript": "^5.7.0"
}, },

View File

@@ -9,23 +9,39 @@ const PROJECT_ROOT = process.cwd();
const HOME_DIR = process.env.HOME || '/Users/user'; const HOME_DIR = process.env.HOME || '/Users/user';
// Mount security: allowlist stored OUTSIDE project root, never mounted into containers // 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 STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
export const MAIN_GROUP_FOLDER = 'main'; export const MAIN_GROUP_FOLDER = 'main';
export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; export const CONTAINER_IMAGE =
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '300000', 10); process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default 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; export const IPC_POLL_INTERVAL = 1000;
function escapeRegex(str: string): string { function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 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.) // Timezone for scheduled tasks (cron expressions, etc.)
// Uses system timezone by default // 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;

View File

@@ -2,25 +2,25 @@
* Container Runner for NanoClaw * Container Runner for NanoClaw
* Spawns agent execution in Apple Container and handles IPC * Spawns agent execution in Apple Container and handles IPC
*/ */
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import pino from 'pino'; import pino from 'pino';
import { import {
CONTAINER_IMAGE, CONTAINER_IMAGE,
CONTAINER_TIMEOUT,
CONTAINER_MAX_OUTPUT_SIZE, CONTAINER_MAX_OUTPUT_SIZE,
CONTAINER_TIMEOUT,
DATA_DIR,
GROUPS_DIR, GROUPS_DIR,
DATA_DIR
} from './config.js'; } from './config.js';
import { RegisteredGroup } from './types.js';
import { validateAdditionalMounts } from './mount-security.js'; import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js';
const logger = pino({ const logger = pino({
level: process.env.LOG_LEVEL || 'info', 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) // Sentinel markers for robust output parsing (must match agent-runner)
@@ -30,7 +30,9 @@ const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
function getHomeDir(): string { function getHomeDir(): string {
const home = process.env.HOME || os.homedir(); const home = process.env.HOME || os.homedir();
if (!home) { 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; return home;
} }
@@ -57,7 +59,10 @@ interface VolumeMount {
readonly?: boolean; readonly?: boolean;
} }
function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount[] { function buildVolumeMounts(
group: RegisteredGroup,
isMain: boolean,
): VolumeMount[] {
const mounts: VolumeMount[] = []; const mounts: VolumeMount[] = [];
const homeDir = getHomeDir(); const homeDir = getHomeDir();
const projectRoot = process.cwd(); const projectRoot = process.cwd();
@@ -67,21 +72,21 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount
mounts.push({ mounts.push({
hostPath: projectRoot, hostPath: projectRoot,
containerPath: '/workspace/project', containerPath: '/workspace/project',
readonly: false readonly: false,
}); });
// Main also gets its group folder as the working directory // Main also gets its group folder as the working directory
mounts.push({ mounts.push({
hostPath: path.join(GROUPS_DIR, group.folder), hostPath: path.join(GROUPS_DIR, group.folder),
containerPath: '/workspace/group', containerPath: '/workspace/group',
readonly: false readonly: false,
}); });
} else { } else {
// Other groups only get their own folder // Other groups only get their own folder
mounts.push({ mounts.push({
hostPath: path.join(GROUPS_DIR, group.folder), hostPath: path.join(GROUPS_DIR, group.folder),
containerPath: '/workspace/group', containerPath: '/workspace/group',
readonly: false readonly: false,
}); });
// Global memory directory (read-only for non-main) // Global memory directory (read-only for non-main)
@@ -91,19 +96,24 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount
mounts.push({ mounts.push({
hostPath: globalDir, hostPath: globalDir,
containerPath: '/workspace/global', containerPath: '/workspace/global',
readonly: true readonly: true,
}); });
} }
} }
// Per-group Claude sessions directory (isolated from other groups) // Per-group Claude sessions directory (isolated from other groups)
// Each group gets their own .claude/ to prevent cross-group session access // 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 }); fs.mkdirSync(groupSessionsDir, { recursive: true });
mounts.push({ mounts.push({
hostPath: groupSessionsDir, hostPath: groupSessionsDir,
containerPath: '/home/node/.claude', containerPath: '/home/node/.claude',
readonly: false readonly: false,
}); });
// Per-group IPC namespace: each group gets its own IPC directory // Per-group IPC namespace: each group gets its own IPC directory
@@ -114,7 +124,7 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount
mounts.push({ mounts.push({
hostPath: groupIpcDir, hostPath: groupIpcDir,
containerPath: '/workspace/ipc', containerPath: '/workspace/ipc',
readonly: false readonly: false,
}); });
// Environment file directory (workaround for Apple Container -i env var bug) // 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)) { if (fs.existsSync(envFile)) {
const envContent = fs.readFileSync(envFile, 'utf-8'); const envContent = fs.readFileSync(envFile, 'utf-8');
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']; const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
const filteredLines = envContent const filteredLines = envContent.split('\n').filter((line) => {
.split('\n') const trimmed = line.trim();
.filter(line => { if (!trimmed || trimmed.startsWith('#')) return false;
const trimmed = line.trim(); return allowedVars.some((v) => trimmed.startsWith(`${v}=`));
if (!trimmed || trimmed.startsWith('#')) return false; });
return allowedVars.some(v => trimmed.startsWith(`${v}=`));
});
if (filteredLines.length > 0) { 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({ mounts.push({
hostPath: envDir, hostPath: envDir,
containerPath: '/workspace/env-dir', containerPath: '/workspace/env-dir',
readonly: true readonly: true,
}); });
} }
} }
@@ -148,7 +159,7 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount
const validatedMounts = validateAdditionalMounts( const validatedMounts = validateAdditionalMounts(
group.containerConfig.additionalMounts, group.containerConfig.additionalMounts,
group.name, group.name,
isMain isMain,
); );
mounts.push(...validatedMounts); mounts.push(...validatedMounts);
} }
@@ -162,7 +173,10 @@ function buildContainerArgs(mounts: VolumeMount[]): string[] {
// Apple Container: --mount for readonly, -v for read-write // Apple Container: --mount for readonly, -v for read-write
for (const mount of mounts) { for (const mount of mounts) {
if (mount.readonly) { 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 { } else {
args.push('-v', `${mount.hostPath}:${mount.containerPath}`); args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
} }
@@ -175,7 +189,7 @@ function buildContainerArgs(mounts: VolumeMount[]): string[] {
export async function runContainerAgent( export async function runContainerAgent(
group: RegisteredGroup, group: RegisteredGroup,
input: ContainerInput input: ContainerInput,
): Promise<ContainerOutput> { ): Promise<ContainerOutput> {
const startTime = Date.now(); const startTime = Date.now();
@@ -185,24 +199,33 @@ export async function runContainerAgent(
const mounts = buildVolumeMounts(group, input.isMain); const mounts = buildVolumeMounts(group, input.isMain);
const containerArgs = buildContainerArgs(mounts); const containerArgs = buildContainerArgs(mounts);
logger.debug({ logger.debug(
group: group.name, {
mounts: mounts.map(m => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`), group: group.name,
containerArgs: containerArgs.join(' ') mounts: mounts.map(
}, 'Container mount configuration'); (m) =>
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
),
containerArgs: containerArgs.join(' '),
},
'Container mount configuration',
);
logger.info({ logger.info(
group: group.name, {
mountCount: mounts.length, group: group.name,
isMain: input.isMain mountCount: mounts.length,
}, 'Spawning container agent'); isMain: input.isMain,
},
'Spawning container agent',
);
const logsDir = path.join(GROUPS_DIR, group.folder, 'logs'); const logsDir = path.join(GROUPS_DIR, group.folder, 'logs');
fs.mkdirSync(logsDir, { recursive: true }); fs.mkdirSync(logsDir, { recursive: true });
return new Promise((resolve) => { return new Promise((resolve) => {
const container = spawn('container', containerArgs, { const container = spawn('container', containerArgs, {
stdio: ['pipe', 'pipe', 'pipe'] stdio: ['pipe', 'pipe', 'pipe'],
}); });
let stdout = ''; let stdout = '';
@@ -220,7 +243,10 @@ export async function runContainerAgent(
if (chunk.length > remaining) { if (chunk.length > remaining) {
stdout += chunk.slice(0, remaining); stdout += chunk.slice(0, remaining);
stdoutTruncated = true; 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 { } else {
stdout += chunk; stdout += chunk;
} }
@@ -237,7 +263,10 @@ export async function runContainerAgent(
if (chunk.length > remaining) { if (chunk.length > remaining) {
stderr += chunk.slice(0, remaining); stderr += chunk.slice(0, remaining);
stderrTruncated = true; 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 { } else {
stderr += chunk; stderr += chunk;
} }
@@ -249,7 +278,7 @@ export async function runContainerAgent(
resolve({ resolve({
status: 'error', status: 'error',
result: null, result: null,
error: `Container timed out after ${CONTAINER_TIMEOUT}ms` error: `Container timed out after ${CONTAINER_TIMEOUT}ms`,
}); });
}, group.containerConfig?.timeout || CONTAINER_TIMEOUT); }, group.containerConfig?.timeout || CONTAINER_TIMEOUT);
@@ -259,7 +288,8 @@ export async function runContainerAgent(
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logFile = path.join(logsDir, `container-${timestamp}.log`); 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 = [ const logLines = [
`=== Container Run Log ===`, `=== Container Run Log ===`,
@@ -270,7 +300,7 @@ export async function runContainerAgent(
`Exit Code: ${code}`, `Exit Code: ${code}`,
`Stdout Truncated: ${stdoutTruncated}`, `Stdout Truncated: ${stdoutTruncated}`,
`Stderr Truncated: ${stderrTruncated}`, `Stderr Truncated: ${stderrTruncated}`,
`` ``,
]; ];
if (isVerbose) { if (isVerbose) {
@@ -282,13 +312,18 @@ export async function runContainerAgent(
containerArgs.join(' '), containerArgs.join(' '),
``, ``,
`=== Mounts ===`, `=== 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${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
stderr, stderr,
``, ``,
`=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
stdout stdout,
); );
} else { } else {
logLines.push( logLines.push(
@@ -297,15 +332,17 @@ export async function runContainerAgent(
`Session ID: ${input.sessionId || 'new'}`, `Session ID: ${input.sessionId || 'new'}`,
``, ``,
`=== Mounts ===`, `=== Mounts ===`,
mounts.map(m => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), mounts
`` .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
.join('\n'),
``,
); );
if (code !== 0) { if (code !== 0) {
logLines.push( logLines.push(
`=== Stderr (last 500 chars) ===`, `=== Stderr (last 500 chars) ===`,
stderr.slice(-500), stderr.slice(-500),
`` ``,
); );
} }
} }
@@ -314,18 +351,21 @@ export async function runContainerAgent(
logger.debug({ logFile, verbose: isVerbose }, 'Container log written'); logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
if (code !== 0) { if (code !== 0) {
logger.error({ logger.error(
group: group.name, {
code, group: group.name,
duration, code,
stderr: stderr.slice(-500), duration,
logFile stderr: stderr.slice(-500),
}, 'Container exited with error'); logFile,
},
'Container exited with error',
);
resolve({ resolve({
status: 'error', status: 'error',
result: null, result: null,
error: `Container exited with code ${code}: ${stderr.slice(-200)}` error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
}); });
return; return;
} }
@@ -337,7 +377,9 @@ export async function runContainerAgent(
let jsonLine: string; let jsonLine: string;
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { 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 { } else {
// Fallback: last non-empty line (backwards compatibility) // Fallback: last non-empty line (backwards compatibility)
const lines = stdout.trim().split('\n'); const lines = stdout.trim().split('\n');
@@ -346,25 +388,31 @@ export async function runContainerAgent(
const output: ContainerOutput = JSON.parse(jsonLine); const output: ContainerOutput = JSON.parse(jsonLine);
logger.info({ logger.info(
group: group.name, {
duration, group: group.name,
status: output.status, duration,
hasResult: !!output.result status: output.status,
}, 'Container completed'); hasResult: !!output.result,
},
'Container completed',
);
resolve(output); resolve(output);
} catch (err) { } catch (err) {
logger.error({ logger.error(
group: group.name, {
stdout: stdout.slice(-500), group: group.name,
error: err stdout: stdout.slice(-500),
}, 'Failed to parse container output'); error: err,
},
'Failed to parse container output',
);
resolve({ resolve({
status: 'error', status: 'error',
result: null, 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({ resolve({
status: 'error', status: 'error',
result: null, result: null,
error: `Container spawn error: ${err.message}` error: `Container spawn error: ${err.message}`,
}); });
}); });
}); });
@@ -392,7 +440,7 @@ export function writeTasksSnapshot(
schedule_value: string; schedule_value: string;
status: string; status: string;
next_run: string | null; next_run: string | null;
}> }>,
): void { ): void {
// Write filtered tasks to the group's IPC directory // Write filtered tasks to the group's IPC directory
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder); const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
@@ -401,7 +449,7 @@ export function writeTasksSnapshot(
// Main sees all tasks, others only see their own // Main sees all tasks, others only see their own
const filteredTasks = isMain const filteredTasks = isMain
? tasks ? tasks
: tasks.filter(t => t.groupFolder === groupFolder); : tasks.filter((t) => t.groupFolder === groupFolder);
const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
@@ -423,7 +471,7 @@ export function writeGroupsSnapshot(
groupFolder: string, groupFolder: string,
isMain: boolean, isMain: boolean,
groups: AvailableGroup[], groups: AvailableGroup[],
registeredJids: Set<string> registeredJids: Set<string>,
): void { ): void {
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder); const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
fs.mkdirSync(groupIpcDir, { recursive: true }); fs.mkdirSync(groupIpcDir, { recursive: true });
@@ -432,8 +480,15 @@ export function writeGroupsSnapshot(
const visibleGroups = isMain ? groups : []; const visibleGroups = isMain ? groups : [];
const groupsFile = path.join(groupIpcDir, 'available_groups.json'); const groupsFile = path.join(groupIpcDir, 'available_groups.json');
fs.writeFileSync(groupsFile, JSON.stringify({ fs.writeFileSync(
groups: visibleGroups, groupsFile,
lastSync: new Date().toISOString() JSON.stringify(
}, null, 2)); {
groups: visibleGroups,
lastSync: new Date().toISOString(),
},
null,
2,
),
);
} }

202
src/db.ts
View File

@@ -1,9 +1,11 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { proto } from '@whiskeysockets/baileys'; import { proto } from '@whiskeysockets/baileys';
import { NewMessage, ScheduledTask, TaskRunLog } from './types.js';
import { STORE_DIR } from './config.js'; import { STORE_DIR } from './config.js';
import { NewMessage, ScheduledTask, TaskRunLog } from './types.js';
let db: Database.Database; 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) // Add sender_name column if it doesn't exist (migration for existing DBs)
try { try {
db.exec(`ALTER TABLE messages ADD COLUMN sender_name TEXT`); 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) // Add context_mode column if it doesn't exist (migration for existing DBs)
try { try {
db.exec(`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`); db.exec(
} catch { /* column already exists */ } `ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`,
);
} catch {
/* column already exists */
}
} }
/** /**
* Store chat metadata only (no message content). * Store chat metadata only (no message content).
* Used for all chats to enable group discovery without storing sensitive 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) { if (name) {
// Update with name, preserving existing timestamp if newer // Update with name, preserving existing timestamp if newer
db.prepare(` db.prepare(
`
INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
ON CONFLICT(jid) DO UPDATE SET ON CONFLICT(jid) DO UPDATE SET
name = excluded.name, name = excluded.name,
last_message_time = MAX(last_message_time, excluded.last_message_time) last_message_time = MAX(last_message_time, excluded.last_message_time)
`).run(chatJid, name, timestamp); `,
).run(chatJid, name, timestamp);
} else { } else {
// Update timestamp only, preserve existing name if any // Update timestamp only, preserve existing name if any
db.prepare(` db.prepare(
`
INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
ON CONFLICT(jid) DO UPDATE SET ON CONFLICT(jid) DO UPDATE SET
last_message_time = MAX(last_message_time, excluded.last_message_time) 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. * Used during group metadata sync.
*/ */
export function updateChatName(chatJid: string, name: string): void { export function updateChatName(chatJid: string, name: string): void {
db.prepare(` db.prepare(
`
INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
ON CONFLICT(jid) DO UPDATE SET name = excluded.name ON CONFLICT(jid) DO UPDATE SET name = excluded.name
`).run(chatJid, name, new Date().toISOString()); `,
).run(chatJid, name, new Date().toISOString());
} }
export interface ChatInfo { export interface ChatInfo {
@@ -116,11 +134,15 @@ export interface ChatInfo {
* Get all known chats, ordered by most recent activity. * Get all known chats, ordered by most recent activity.
*/ */
export function getAllChats(): ChatInfo[] { export function getAllChats(): ChatInfo[] {
return db.prepare(` return db
.prepare(
`
SELECT jid, name, last_message_time SELECT jid, name, last_message_time
FROM chats FROM chats
ORDER BY last_message_time DESC 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 { export function getLastGroupSync(): string | null {
// Store sync time in a special chat entry // 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; return row?.last_message_time || null;
} }
@@ -137,14 +161,21 @@ export function getLastGroupSync(): string | null {
*/ */
export function setLastGroupSync(): void { export function setLastGroupSync(): void {
const now = new Date().toISOString(); 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. * Store a message with full content.
* Only call this for registered groups where message history is needed. * 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; if (!msg.key) return;
const content = const content =
@@ -159,11 +190,24 @@ export function storeMessage(msg: proto.IWebMessageInfo, chatJid: string, isFrom
const senderName = pushName || sender.split('@')[0]; const senderName = pushName || sender.split('@')[0];
const msgId = msg.key.id || ''; const msgId = msg.key.id || '';
db.prepare(`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?, ?)`) db.prepare(
.run(msgId, chatJid, sender, senderName, content, timestamp, isFromMe ? 1 : 0); `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 }; if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
const placeholders = jids.map(() => '?').join(','); const placeholders = jids.map(() => '?').join(',');
@@ -175,7 +219,9 @@ export function getNewMessages(jids: string[], lastTimestamp: string, botPrefix:
ORDER BY timestamp 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; let newTimestamp = lastTimestamp;
for (const row of rows) { for (const row of rows) {
@@ -185,7 +231,11 @@ export function getNewMessages(jids: string[], lastTimestamp: string, botPrefix:
return { messages: rows, newTimestamp }; 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 // Filter out bot's own messages by checking content prefix
const sql = ` const sql = `
SELECT id, chat_jid, sender, sender_name, content, timestamp 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 ? WHERE chat_jid = ? AND timestamp > ? AND content NOT LIKE ?
ORDER BY timestamp 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 { export function createTask(
db.prepare(` 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) INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `,
).run(
task.id, task.id,
task.group_folder, task.group_folder,
task.chat_jid, task.chat_jid,
@@ -210,36 +266,69 @@ export function createTask(task: Omit<ScheduledTask, 'last_run' | 'last_result'>
task.context_mode || 'isolated', task.context_mode || 'isolated',
task.next_run, task.next_run,
task.status, task.status,
task.created_at task.created_at,
); );
} }
export function getTaskById(id: string): ScheduledTask | undefined { 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[] { 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[] { 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 fields: string[] = [];
const values: unknown[] = []; const values: unknown[] = [];
if (updates.prompt !== undefined) { fields.push('prompt = ?'); values.push(updates.prompt); } if (updates.prompt !== undefined) {
if (updates.schedule_type !== undefined) { fields.push('schedule_type = ?'); values.push(updates.schedule_type); } fields.push('prompt = ?');
if (updates.schedule_value !== undefined) { fields.push('schedule_value = ?'); values.push(updates.schedule_value); } values.push(updates.prompt);
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.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; if (fields.length === 0) return;
values.push(id); 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 { export function deleteTask(id: string): void {
@@ -250,35 +339,58 @@ export function deleteTask(id: string): void {
export function getDueTasks(): ScheduledTask[] { export function getDueTasks(): ScheduledTask[] {
const now = new Date().toISOString(); const now = new Date().toISOString();
return db.prepare(` return db
.prepare(
`
SELECT * FROM scheduled_tasks SELECT * FROM scheduled_tasks
WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ? WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ?
ORDER BY 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(); const now = new Date().toISOString();
db.prepare(` db.prepare(
`
UPDATE scheduled_tasks UPDATE scheduled_tasks
SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END
WHERE id = ? WHERE id = ?
`).run(nextRun, now, lastResult, nextRun, id); `,
).run(nextRun, now, lastResult, nextRun, id);
} }
export function logTaskRun(log: TaskRunLog): void { export function logTaskRun(log: TaskRunLog): void {
db.prepare(` db.prepare(
`
INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error) INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error)
VALUES (?, ?, ?, ?, ?, ?) 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[] { export function getTaskRunLogs(taskId: string, limit = 10): TaskRunLog[] {
return db.prepare(` return db
.prepare(
`
SELECT task_id, run_at, duration_ms, status, result, error SELECT task_id, run_at, duration_ms, status, result, error
FROM task_run_logs FROM task_run_logs
WHERE task_id = ? WHERE task_id = ?
ORDER BY run_at DESC ORDER BY run_at DESC
LIMIT ? LIMIT ?
`).all(taskId, limit) as TaskRunLog[]; `,
)
.all(taskId, limit) as TaskRunLog[];
} }

View File

@@ -1,35 +1,53 @@
import makeWASocket, {
useMultiFileAuthState,
DisconnectReason,
makeCacheableSignalKeyStore,
WASocket
} from '@whiskeysockets/baileys';
import pino from 'pino';
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import pino from 'pino';
import makeWASocket, {
DisconnectReason,
WASocket,
makeCacheableSignalKeyStore,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
import { import {
ASSISTANT_NAME, ASSISTANT_NAME,
DATA_DIR,
IPC_POLL_INTERVAL,
MAIN_GROUP_FOLDER,
POLL_INTERVAL, POLL_INTERVAL,
STORE_DIR, STORE_DIR,
DATA_DIR, TIMEZONE,
TRIGGER_PATTERN, TRIGGER_PATTERN,
MAIN_GROUP_FOLDER,
IPC_POLL_INTERVAL,
TIMEZONE
} from './config.js'; } from './config.js';
import { RegisteredGroup, Session, NewMessage } from './types.js'; import {
import { initDatabase, storeMessage, storeChatMetadata, getNewMessages, getMessagesSince, getAllTasks, getTaskById, updateChatName, getAllChats, getLastGroupSync, setLastGroupSync } from './db.js'; 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 { 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'; import { loadJson, saveJson } from './utils.js';
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const logger = pino({ const logger = pino({
level: process.env.LOG_LEVEL || 'info', level: process.env.LOG_LEVEL || 'info',
transport: { target: 'pino-pretty', options: { colorize: true } } transport: { target: 'pino-pretty', options: { colorize: true } },
}); });
let sock: WASocket; let sock: WASocket;
@@ -48,16 +66,28 @@ async function setTyping(jid: string, isTyping: boolean): Promise<void> {
function loadState(): void { function loadState(): void {
const statePath = path.join(DATA_DIR, 'router_state.json'); 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 || ''; lastTimestamp = state.last_timestamp || '';
lastAgentTimestamp = state.last_agent_timestamp || {}; lastAgentTimestamp = state.last_agent_timestamp || {};
sessions = loadJson(path.join(DATA_DIR, 'sessions.json'), {}); sessions = loadJson(path.join(DATA_DIR, 'sessions.json'), {});
registeredGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {}); registeredGroups = loadJson(
logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded'); path.join(DATA_DIR, 'registered_groups.json'),
{},
);
logger.info(
{ groupCount: Object.keys(registeredGroups).length },
'State loaded',
);
} }
function saveState(): void { 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); 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); const groupDir = path.join(DATA_DIR, '..', 'groups', group.folder);
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); 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)); const registeredJids = new Set(Object.keys(registeredGroups));
return chats return chats
.filter(c => c.jid !== '__group_sync__' && c.jid.endsWith('@g.us')) .filter((c) => c.jid !== '__group_sync__' && c.jid.endsWith('@g.us'))
.map(c => ({ .map((c) => ({
jid: c.jid, jid: c.jid,
name: c.name, name: c.name,
lastActivity: c.last_message_time, 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 // Get all messages since last agent interaction so the session has full context
const sinceTimestamp = lastAgentTimestamp[msg.chat_jid] || ''; 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 // Escape XML special characters in content
const escapeXml = (s: string) => s const escapeXml = (s: string) =>
.replace(/&/g, '&amp;') s
.replace(/</g, '&lt;') .replace(/&/g, '&amp;')
.replace(/>/g, '&gt;') .replace(/</g, '&lt;')
.replace(/"/g, '&quot;'); .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
return `<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`; return `<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`;
}); });
const prompt = `<messages>\n${lines.join('\n')}\n</messages>`; const prompt = `<messages>\n${lines.join('\n')}\n</messages>`;
if (!prompt) return; 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); await setTyping(msg.chat_jid, true);
const response = await runAgent(group, prompt, msg.chat_jid); 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 isMain = group.folder === MAIN_GROUP_FOLDER;
const sessionId = sessions[group.folder]; const sessionId = sessions[group.folder];
// Update tasks snapshot for container to read (filtered by group) // Update tasks snapshot for container to read (filtered by group)
const tasks = getAllTasks(); const tasks = getAllTasks();
writeTasksSnapshot(group.folder, isMain, tasks.map(t => ({ writeTasksSnapshot(
id: t.id, group.folder,
groupFolder: t.group_folder, isMain,
prompt: t.prompt, tasks.map((t) => ({
schedule_type: t.schedule_type, id: t.id,
schedule_value: t.schedule_value, groupFolder: t.group_folder,
status: t.status, prompt: t.prompt,
next_run: t.next_run 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) // Update available groups snapshot (main group only can see all groups)
const availableGroups = getAvailableGroups(); const availableGroups = getAvailableGroups();
writeGroupsSnapshot(group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups))); writeGroupsSnapshot(
group.folder,
isMain,
availableGroups,
new Set(Object.keys(registeredGroups)),
);
try { try {
const output = await runContainerAgent(group, { const output = await runContainerAgent(group, {
@@ -193,7 +247,7 @@ async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string)
sessionId, sessionId,
groupFolder: group.folder, groupFolder: group.folder,
chatJid, chatJid,
isMain isMain,
}); });
if (output.newSessionId) { if (output.newSessionId) {
@@ -202,7 +256,10 @@ async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string)
} }
if (output.status === 'error') { 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; return null;
} }
@@ -230,7 +287,7 @@ function startIpcWatcher(): void {
// Scan all group IPC directories (identity determined by directory) // Scan all group IPC directories (identity determined by directory)
let groupFolders: string[]; let groupFolders: string[];
try { try {
groupFolders = fs.readdirSync(ipcBaseDir).filter(f => { groupFolders = fs.readdirSync(ipcBaseDir).filter((f) => {
const stat = fs.statSync(path.join(ipcBaseDir, f)); const stat = fs.statSync(path.join(ipcBaseDir, f));
return stat.isDirectory() && f !== 'errors'; return stat.isDirectory() && f !== 'errors';
}); });
@@ -248,7 +305,9 @@ function startIpcWatcher(): void {
// Process messages from this group's IPC directory // Process messages from this group's IPC directory
try { try {
if (fs.existsSync(messagesDir)) { 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) { for (const file of messageFiles) {
const filePath = path.join(messagesDir, file); const filePath = path.join(messagesDir, file);
try { try {
@@ -256,30 +315,53 @@ function startIpcWatcher(): void {
if (data.type === 'message' && data.chatJid && data.text) { if (data.type === 'message' && data.chatJid && data.text) {
// Authorization: verify this group can send to this chatJid // Authorization: verify this group can send to this chatJid
const targetGroup = registeredGroups[data.chatJid]; const targetGroup = registeredGroups[data.chatJid];
if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { if (
await sendMessage(data.chatJid, `${ASSISTANT_NAME}: ${data.text}`); isMain ||
logger.info({ chatJid: data.chatJid, sourceGroup }, 'IPC message sent'); (targetGroup && targetGroup.folder === sourceGroup)
) {
await sendMessage(
data.chatJid,
`${ASSISTANT_NAME}: ${data.text}`,
);
logger.info(
{ chatJid: data.chatJid, sourceGroup },
'IPC message sent',
);
} else { } 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); fs.unlinkSync(filePath);
} catch (err) { } 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'); const errorDir = path.join(ipcBaseDir, 'errors');
fs.mkdirSync(errorDir, { recursive: true }); fs.mkdirSync(errorDir, { recursive: true });
fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`)); fs.renameSync(
filePath,
path.join(errorDir, `${sourceGroup}-${file}`),
);
} }
} }
} }
} catch (err) { } 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 // Process tasks from this group's IPC directory
try { try {
if (fs.existsSync(tasksDir)) { 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) { for (const file of taskFiles) {
const filePath = path.join(tasksDir, file); const filePath = path.join(tasksDir, file);
try { try {
@@ -288,10 +370,16 @@ function startIpcWatcher(): void {
await processTaskIpc(data, sourceGroup, isMain); await processTaskIpc(data, sourceGroup, isMain);
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
} catch (err) { } 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'); const errorDir = path.join(ipcBaseDir, 'errors');
fs.mkdirSync(errorDir, { recursive: true }); 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; trigger?: string;
containerConfig?: RegisteredGroup['containerConfig']; containerConfig?: RegisteredGroup['containerConfig'];
}, },
sourceGroup: string, // Verified identity from IPC directory sourceGroup: string, // Verified identity from IPC directory
isMain: boolean // Verified from directory path isMain: boolean, // Verified from directory path
): Promise<void> { ): Promise<void> {
// Import db functions dynamically to avoid circular deps // 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'); const { CronExpressionParser } = await import('cron-parser');
switch (data.type) { switch (data.type) {
case 'schedule_task': 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 // Authorization: non-main groups can only schedule for themselves
const targetGroup = data.groupFolder; const targetGroup = data.groupFolder;
if (!isMain && targetGroup !== sourceGroup) { if (!isMain && targetGroup !== sourceGroup) {
logger.warn({ sourceGroup, targetGroup }, 'Unauthorized schedule_task attempt blocked'); logger.warn(
{ sourceGroup, targetGroup },
'Unauthorized schedule_task attempt blocked',
);
break; break;
} }
// Resolve the correct JID for the target group (don't trust IPC payload) // Resolve the correct JID for the target group (don't trust IPC payload)
const targetJid = Object.entries(registeredGroups).find( const targetJid = Object.entries(registeredGroups).find(
([, group]) => group.folder === targetGroup ([, group]) => group.folder === targetGroup,
)?.[0]; )?.[0];
if (!targetJid) { if (!targetJid) {
logger.warn({ targetGroup }, 'Cannot schedule task: target group not registered'); logger.warn(
{ targetGroup },
'Cannot schedule task: target group not registered',
);
break; break;
} }
@@ -356,32 +460,44 @@ async function processTaskIpc(
let nextRun: string | null = null; let nextRun: string | null = null;
if (scheduleType === 'cron') { if (scheduleType === 'cron') {
try { try {
const interval = CronExpressionParser.parse(data.schedule_value, { tz: TIMEZONE }); const interval = CronExpressionParser.parse(data.schedule_value, {
tz: TIMEZONE,
});
nextRun = interval.next().toISOString(); nextRun = interval.next().toISOString();
} catch { } catch {
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid cron expression'); logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid cron expression',
);
break; break;
} }
} else if (scheduleType === 'interval') { } else if (scheduleType === 'interval') {
const ms = parseInt(data.schedule_value, 10); const ms = parseInt(data.schedule_value, 10);
if (isNaN(ms) || ms <= 0) { if (isNaN(ms) || ms <= 0) {
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid interval'); logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid interval',
);
break; break;
} }
nextRun = new Date(Date.now() + ms).toISOString(); nextRun = new Date(Date.now() + ms).toISOString();
} else if (scheduleType === 'once') { } else if (scheduleType === 'once') {
const scheduled = new Date(data.schedule_value); const scheduled = new Date(data.schedule_value);
if (isNaN(scheduled.getTime())) { if (isNaN(scheduled.getTime())) {
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid timestamp'); logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid timestamp',
);
break; break;
} }
nextRun = scheduled.toISOString(); nextRun = scheduled.toISOString();
} }
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const contextMode = (data.context_mode === 'group' || data.context_mode === 'isolated') const contextMode =
? data.context_mode data.context_mode === 'group' || data.context_mode === 'isolated'
: 'isolated'; ? data.context_mode
: 'isolated';
createTask({ createTask({
id: taskId, id: taskId,
group_folder: targetGroup, group_folder: targetGroup,
@@ -392,9 +508,12 @@ async function processTaskIpc(
context_mode: contextMode, context_mode: contextMode,
next_run: nextRun, next_run: nextRun,
status: 'active', 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; break;
@@ -403,9 +522,15 @@ async function processTaskIpc(
const task = getTask(data.taskId); const task = getTask(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) { if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'paused' }); 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 { } else {
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt'); logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task pause attempt',
);
} }
} }
break; break;
@@ -415,9 +540,15 @@ async function processTaskIpc(
const task = getTask(data.taskId); const task = getTask(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) { if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'active' }); 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 { } else {
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt'); logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task resume attempt',
);
} }
} }
break; break;
@@ -427,9 +558,15 @@ async function processTaskIpc(
const task = getTask(data.taskId); const task = getTask(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) { if (task && (isMain || task.group_folder === sourceGroup)) {
deleteTask(data.taskId); deleteTask(data.taskId);
logger.info({ taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC'); logger.info(
{ taskId: data.taskId, sourceGroup },
'Task cancelled via IPC',
);
} else { } else {
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt'); logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task cancel attempt',
);
} }
} }
break; break;
@@ -437,21 +574,36 @@ async function processTaskIpc(
case 'refresh_groups': case 'refresh_groups':
// Only main group can request a refresh // Only main group can request a refresh
if (isMain) { if (isMain) {
logger.info({ sourceGroup }, 'Group metadata refresh requested via IPC'); logger.info(
{ sourceGroup },
'Group metadata refresh requested via IPC',
);
await syncGroupMetadata(true); await syncGroupMetadata(true);
// Write updated snapshot immediately // Write updated snapshot immediately
const availableGroups = getAvailableGroups(); const availableGroups = getAvailableGroups();
const { writeGroupsSnapshot: writeGroups } = await import('./container-runner.js'); const { writeGroupsSnapshot: writeGroups } =
writeGroups(sourceGroup, true, availableGroups, new Set(Object.keys(registeredGroups))); await import('./container-runner.js');
writeGroups(
sourceGroup,
true,
availableGroups,
new Set(Object.keys(registeredGroups)),
);
} else { } else {
logger.warn({ sourceGroup }, 'Unauthorized refresh_groups attempt blocked'); logger.warn(
{ sourceGroup },
'Unauthorized refresh_groups attempt blocked',
);
} }
break; break;
case 'register_group': case 'register_group':
// Only main group can register new groups // Only main group can register new groups
if (!isMain) { if (!isMain) {
logger.warn({ sourceGroup }, 'Unauthorized register_group attempt blocked'); logger.warn(
{ sourceGroup },
'Unauthorized register_group attempt blocked',
);
break; break;
} }
if (data.jid && data.name && data.folder && data.trigger) { if (data.jid && data.name && data.folder && data.trigger) {
@@ -460,10 +612,13 @@ async function processTaskIpc(
folder: data.folder, folder: data.folder,
trigger: data.trigger, trigger: data.trigger,
added_at: new Date().toISOString(), added_at: new Date().toISOString(),
containerConfig: data.containerConfig containerConfig: data.containerConfig,
}); });
} else { } else {
logger.warn({ data }, 'Invalid register_group request - missing required fields'); logger.warn(
{ data },
'Invalid register_group request - missing required fields',
);
} }
break; break;
@@ -479,19 +634,25 @@ async function connectWhatsApp(): Promise<void> {
const { state, saveCreds } = await useMultiFileAuthState(authDir); const { state, saveCreds } = await useMultiFileAuthState(authDir);
sock = makeWASocket({ sock = makeWASocket({
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) }, auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
printQRInTerminal: false, printQRInTerminal: false,
logger, logger,
browser: ['NanoClaw', 'Chrome', '1.0.0'] browser: ['NanoClaw', 'Chrome', '1.0.0'],
}); });
sock.ev.on('connection.update', (update) => { sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update; const { connection, lastDisconnect, qr } = update;
if (qr) { 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); 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); setTimeout(() => process.exit(1), 1000);
} }
@@ -510,15 +671,19 @@ async function connectWhatsApp(): Promise<void> {
} else if (connection === 'open') { } else if (connection === 'open') {
logger.info('Connected to WhatsApp'); logger.info('Connected to WhatsApp');
// Sync group metadata on startup (respects 24h cache) // 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 // Set up daily sync timer
setInterval(() => { 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); }, GROUP_SYNC_INTERVAL_MS);
startSchedulerLoop({ startSchedulerLoop({
sendMessage, sendMessage,
registeredGroups: () => registeredGroups, registeredGroups: () => registeredGroups,
getSessions: () => sessions getSessions: () => sessions,
}); });
startIpcWatcher(); startIpcWatcher();
startMessageLoop(); startMessageLoop();
@@ -533,14 +698,21 @@ async function connectWhatsApp(): Promise<void> {
const chatJid = msg.key.remoteJid; const chatJid = msg.key.remoteJid;
if (!chatJid || chatJid === 'status@broadcast') continue; 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 // Always store chat metadata for group discovery
storeChatMetadata(chatJid, timestamp); storeChatMetadata(chatJid, timestamp);
// Only store full message content for registered groups // Only store full message content for registered groups
if (registeredGroups[chatJid]) { 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 jids = Object.keys(registeredGroups);
const { messages } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME); 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) { for (const msg of messages) {
try { try {
await processMessage(msg); await processMessage(msg);
@@ -562,7 +735,10 @@ async function startMessageLoop(): Promise<void> {
lastTimestamp = msg.timestamp; lastTimestamp = msg.timestamp;
saveState(); saveState();
} catch (err) { } 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 // Stop processing this batch - failed message will be retried next loop
break; break;
} }
@@ -570,7 +746,7 @@ async function startMessageLoop(): Promise<void> {
} catch (err) { } catch (err) {
logger.error({ err }, 'Error in message loop'); 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'); logger.info('Apple Container system started');
} catch (err) { } catch (err) {
logger.error({ err }, 'Failed to start Apple Container system'); logger.error({ err }, 'Failed to start Apple Container system');
console.error('\n╔════════════════════════════════════════════════════════════════╗'); console.error(
console.error('║ FATAL: Apple Container system failed to start ║'); '\n╔════════════════════════════════════════════════════════════════╗',
console.error('║ ║'); );
console.error('║ Agents cannot run without Apple Container. To fix: ║'); console.error(
console.error('║ 1. Install from: https://github.com/apple/container/releases ║'); '║ FATAL: Apple Container system failed to start ║',
console.error('║ 2. Run: container system start ║'); );
console.error('║ 3. Restart NanoClaw ║'); console.error(
console.error('╚════════════════════════════════════════════════════════════════╝\n'); '║ ║',
);
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'); throw new Error('Apple Container system is required but failed to start');
} }
} }
@@ -606,7 +798,7 @@ async function main(): Promise<void> {
await connectWhatsApp(); await connectWhatsApp();
} }
main().catch(err => { main().catch((err) => {
logger.error({ err }, 'Failed to start NanoClaw'); logger.error({ err }, 'Failed to start NanoClaw');
process.exit(1); process.exit(1);
}); });

View File

@@ -6,16 +6,16 @@
* *
* Allowlist location: ~/.config/nanoclaw/mount-allowlist.json * Allowlist location: ~/.config/nanoclaw/mount-allowlist.json
*/ */
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import pino from 'pino'; import pino from 'pino';
import { MOUNT_ALLOWLIST_PATH } from './config.js'; import { MOUNT_ALLOWLIST_PATH } from './config.js';
import { AdditionalMount, MountAllowlist, AllowedRoot } from './types.js'; import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js';
const logger = pino({ const logger = pino({
level: process.env.LOG_LEVEL || 'info', 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 // Cache the allowlist in memory - only reloads on process restart
@@ -63,9 +63,11 @@ export function loadMountAllowlist(): MountAllowlist | null {
try { try {
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) { if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
allowlistLoadError = `Mount allowlist not found at ${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. ' + '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; return null;
} }
@@ -87,24 +89,30 @@ export function loadMountAllowlist(): MountAllowlist | null {
// Merge with default blocked patterns // Merge with default blocked patterns
const mergedBlockedPatterns = [ const mergedBlockedPatterns = [
...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns]) ...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns]),
]; ];
allowlist.blockedPatterns = mergedBlockedPatterns; allowlist.blockedPatterns = mergedBlockedPatterns;
cachedAllowlist = allowlist; cachedAllowlist = allowlist;
logger.info({ logger.info(
path: MOUNT_ALLOWLIST_PATH, {
allowedRoots: allowlist.allowedRoots.length, path: MOUNT_ALLOWLIST_PATH,
blockedPatterns: allowlist.blockedPatterns.length allowedRoots: allowlist.allowedRoots.length,
}, 'Mount allowlist loaded successfully'); blockedPatterns: allowlist.blockedPatterns.length,
},
'Mount allowlist loaded successfully',
);
return cachedAllowlist; return cachedAllowlist;
} catch (err) { } catch (err) {
allowlistLoadError = err instanceof Error ? err.message : String(err); allowlistLoadError = err instanceof Error ? err.message : String(err);
logger.error({ logger.error(
path: MOUNT_ALLOWLIST_PATH, {
error: allowlistLoadError path: MOUNT_ALLOWLIST_PATH,
}, 'Failed to load mount allowlist - additional mounts will be BLOCKED'); error: allowlistLoadError,
},
'Failed to load mount allowlist - additional mounts will be BLOCKED',
);
return null; return null;
} }
} }
@@ -138,7 +146,10 @@ function getRealPath(p: string): string | null {
/** /**
* Check if a path matches any blocked pattern * 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); const pathParts = realPath.split(path.sep);
for (const pattern of blockedPatterns) { 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 * 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) { for (const root of allowedRoots) {
const expandedRoot = expandPath(root.path); const expandedRoot = expandPath(root.path);
const realRoot = getRealPath(expandedRoot); const realRoot = getRealPath(expandedRoot);
@@ -216,7 +230,7 @@ export interface MountValidationResult {
*/ */
export function validateMount( export function validateMount(
mount: AdditionalMount, mount: AdditionalMount,
isMain: boolean isMain: boolean,
): MountValidationResult { ): MountValidationResult {
const allowlist = loadMountAllowlist(); const allowlist = loadMountAllowlist();
@@ -224,7 +238,7 @@ export function validateMount(
if (allowlist === null) { if (allowlist === null) {
return { return {
allowed: false, 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)) { if (!isValidContainerPath(mount.containerPath)) {
return { return {
allowed: false, 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) { if (realPath === null) {
return { return {
allowed: false, 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 // Check against blocked patterns
const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns); const blockedMatch = matchesBlockedPattern(
realPath,
allowlist.blockedPatterns,
);
if (blockedMatch !== null) { if (blockedMatch !== null) {
return { return {
allowed: false, 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) { if (allowedRoot === null) {
return { return {
allowed: false, allowed: false,
reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${ reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots
allowlist.allowedRoots.map(r => expandPath(r.path)).join(', ') .map((r) => expandPath(r.path))
}` .join(', ')}`,
}; };
} }
@@ -275,16 +292,22 @@ export function validateMount(
if (!isMain && allowlist.nonMainReadOnly) { if (!isMain && allowlist.nonMainReadOnly) {
// Non-main groups forced to read-only // Non-main groups forced to read-only
effectiveReadonly = true; effectiveReadonly = true;
logger.info({ logger.info(
mount: mount.hostPath {
}, 'Mount forced to read-only for non-main group'); mount: mount.hostPath,
},
'Mount forced to read-only for non-main group',
);
} else if (!allowedRoot.allowReadWrite) { } else if (!allowedRoot.allowReadWrite) {
// Root doesn't allow read-write // Root doesn't allow read-write
effectiveReadonly = true; effectiveReadonly = true;
logger.info({ logger.info(
mount: mount.hostPath, {
root: allowedRoot.path mount: mount.hostPath,
}, 'Mount forced to read-only - root does not allow read-write'); root: allowedRoot.path,
},
'Mount forced to read-only - root does not allow read-write',
);
} else { } else {
// Read-write allowed // Read-write allowed
effectiveReadonly = false; effectiveReadonly = false;
@@ -295,7 +318,7 @@ export function validateMount(
allowed: true, allowed: true,
reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`, reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`,
realHostPath: realPath, realHostPath: realPath,
effectiveReadonly effectiveReadonly,
}; };
} }
@@ -307,7 +330,7 @@ export function validateMount(
export function validateAdditionalMounts( export function validateAdditionalMounts(
mounts: AdditionalMount[], mounts: AdditionalMount[],
groupName: string, groupName: string,
isMain: boolean isMain: boolean,
): Array<{ ): Array<{
hostPath: string; hostPath: string;
containerPath: string; containerPath: string;
@@ -326,23 +349,29 @@ export function validateAdditionalMounts(
validatedMounts.push({ validatedMounts.push({
hostPath: result.realHostPath!, hostPath: result.realHostPath!,
containerPath: `/workspace/extra/${mount.containerPath}`, containerPath: `/workspace/extra/${mount.containerPath}`,
readonly: result.effectiveReadonly! readonly: result.effectiveReadonly!,
}); });
logger.debug({ logger.debug(
group: groupName, {
hostPath: result.realHostPath, group: groupName,
containerPath: mount.containerPath, hostPath: result.realHostPath,
readonly: result.effectiveReadonly, containerPath: mount.containerPath,
reason: result.reason readonly: result.effectiveReadonly,
}, 'Mount validated successfully'); reason: result.reason,
},
'Mount validated successfully',
);
} else { } else {
logger.warn({ logger.warn(
group: groupName, {
requestedPath: mount.hostPath, group: groupName,
containerPath: mount.containerPath, requestedPath: mount.hostPath,
reason: result.reason containerPath: mount.containerPath,
}, 'Additional mount REJECTED'); reason: result.reason,
},
'Additional mount REJECTED',
);
} }
} }
@@ -358,26 +387,26 @@ export function generateAllowlistTemplate(): string {
{ {
path: '~/projects', path: '~/projects',
allowReadWrite: true, allowReadWrite: true,
description: 'Development projects' description: 'Development projects',
}, },
{ {
path: '~/repos', path: '~/repos',
allowReadWrite: true, allowReadWrite: true,
description: 'Git repositories' description: 'Git repositories',
}, },
{ {
path: '~/Documents/work', path: '~/Documents/work',
allowReadWrite: false, allowReadWrite: false,
description: 'Work documents (read-only)' description: 'Work documents (read-only)',
} },
], ],
blockedPatterns: [ blockedPatterns: [
// Additional patterns beyond defaults // Additional patterns beyond defaults
'password', 'password',
'secret', 'secret',
'token' 'token',
], ],
nonMainReadOnly: true nonMainReadOnly: true,
}; };
return JSON.stringify(template, null, 2); return JSON.stringify(template, null, 2);

View File

@@ -1,15 +1,28 @@
import { CronExpressionParser } from 'cron-parser';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import pino from 'pino'; import pino from 'pino';
import { CronExpressionParser } from 'cron-parser';
import { getDueTasks, updateTaskAfterRun, logTaskRun, getTaskById, getAllTasks } from './db.js'; import {
import { ScheduledTask, RegisteredGroup } from './types.js'; DATA_DIR,
import { GROUPS_DIR, SCHEDULER_POLL_INTERVAL, DATA_DIR, MAIN_GROUP_FOLDER, TIMEZONE } from './config.js'; GROUPS_DIR,
MAIN_GROUP_FOLDER,
SCHEDULER_POLL_INTERVAL,
TIMEZONE,
} from './config.js';
import { runContainerAgent, writeTasksSnapshot } from './container-runner.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({ const logger = pino({
level: process.env.LOG_LEVEL || 'info', level: process.env.LOG_LEVEL || 'info',
transport: { target: 'pino-pretty', options: { colorize: true } } transport: { target: 'pino-pretty', options: { colorize: true } },
}); });
export interface SchedulerDependencies { export interface SchedulerDependencies {
@@ -18,25 +31,36 @@ export interface SchedulerDependencies {
getSessions: () => Record<string, string>; 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 startTime = Date.now();
const groupDir = path.join(GROUPS_DIR, task.group_folder); const groupDir = path.join(GROUPS_DIR, task.group_folder);
fs.mkdirSync(groupDir, { recursive: true }); 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 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) { 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({ logTaskRun({
task_id: task.id, task_id: task.id,
run_at: new Date().toISOString(), run_at: new Date().toISOString(),
duration_ms: Date.now() - startTime, duration_ms: Date.now() - startTime,
status: 'error', status: 'error',
result: null, result: null,
error: `Group not found: ${task.group_folder}` error: `Group not found: ${task.group_folder}`,
}); });
return; return;
} }
@@ -44,22 +68,27 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis
// Update tasks snapshot for container to read (filtered by group) // Update tasks snapshot for container to read (filtered by group)
const isMain = task.group_folder === MAIN_GROUP_FOLDER; const isMain = task.group_folder === MAIN_GROUP_FOLDER;
const tasks = getAllTasks(); const tasks = getAllTasks();
writeTasksSnapshot(task.group_folder, isMain, tasks.map(t => ({ writeTasksSnapshot(
id: t.id, task.group_folder,
groupFolder: t.group_folder, isMain,
prompt: t.prompt, tasks.map((t) => ({
schedule_type: t.schedule_type, id: t.id,
schedule_value: t.schedule_value, groupFolder: t.group_folder,
status: t.status, prompt: t.prompt,
next_run: t.next_run schedule_type: t.schedule_type,
}))); schedule_value: t.schedule_value,
status: t.status,
next_run: t.next_run,
})),
);
let result: string | null = null; let result: string | null = null;
let error: string | null = null; let error: string | null = null;
// For group context mode, use the group's current session // For group context mode, use the group's current session
const sessions = deps.getSessions(); 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 { try {
const output = await runContainerAgent(group, { const output = await runContainerAgent(group, {
@@ -68,7 +97,7 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis
groupFolder: task.group_folder, groupFolder: task.group_folder,
chatJid: task.chat_jid, chatJid: task.chat_jid,
isMain, isMain,
isScheduledTask: true isScheduledTask: true,
}); });
if (output.status === 'error') { if (output.status === 'error') {
@@ -77,7 +106,10 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis
result = output.result; 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) { } catch (err) {
error = err instanceof Error ? err.message : String(err); error = err instanceof Error ? err.message : String(err);
logger.error({ taskId: task.id, error }, 'Task failed'); logger.error({ taskId: task.id, error }, 'Task failed');
@@ -91,12 +123,14 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis
duration_ms: durationMs, duration_ms: durationMs,
status: error ? 'error' : 'success', status: error ? 'error' : 'success',
result, result,
error error,
}); });
let nextRun: string | null = null; let nextRun: string | null = null;
if (task.schedule_type === 'cron') { 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(); nextRun = interval.next().toISOString();
} else if (task.schedule_type === 'interval') { } else if (task.schedule_type === 'interval') {
const ms = parseInt(task.schedule_value, 10); 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 // '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); updateTaskAfterRun(task.id, nextRun, resultSummary);
} }

View File

@@ -1,7 +1,7 @@
export interface AdditionalMount { 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/) 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 { export interface ContainerConfig {
additionalMounts?: AdditionalMount[]; additionalMounts?: AdditionalMount[];
timeout?: number; // Default: 300000 (5 minutes) timeout?: number; // Default: 300000 (5 minutes)
env?: Record<string, string>; env?: Record<string, string>;
} }

View File

@@ -6,16 +6,16 @@
* *
* Usage: npx tsx src/whatsapp-auth.ts * 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 fs from 'fs';
import path from 'path'; 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'; const AUTH_DIR = './store/auth';
@@ -30,7 +30,9 @@ async function authenticate(): Promise<void> {
if (state.creds.registered) { if (state.creds.registered) {
console.log('✓ Already authenticated with WhatsApp'); 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); process.exit(0);
} }