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": {
"additionalMounts": [
{
"hostPath": "/Users/gavriel/projects/webapp",
"hostPath": "~/projects/webapp",
"containerPath": "webapp",
"readonly": false
}

View File

@@ -31,22 +31,6 @@ When you learn something important:
- Add recurring context directly to this CLAUDE.md
- Always index new memory files at the top of CLAUDE.md
## Qwibit Ops Access
You have access to Qwibit operations data at `/workspace/extra/qwibit-ops/` with these key areas:
- **sales/** - Pipeline, deals, playbooks, pitch materials (see `sales/CLAUDE.md`)
- **clients/** - Active accounts, service delivery, client management (see `clients/CLAUDE.md`)
- **company/** - Strategy, thesis, operational philosophy (see `company/CLAUDE.md`)
Read the CLAUDE.md files in each folder for role-specific context and workflows.
**Key context:**
- Qwibit is a B2B GEO (Generative Engine Optimization) agency
- Pricing: $2,000-$4,000/month, month-to-month contracts
- Team: Gavriel (founder, sales & client work), Lazer (founder, dealflow), Ali (PM)
- Obsidian-based workflow with Kanban boards (PIPELINE.md, PORTFOLIO.md)
## WhatsApp Formatting
Do NOT use markdown headings (##) in WhatsApp messages. Only use:
@@ -171,7 +155,7 @@ Groups can have extra directories mounted. Add `containerConfig` to their entry:
"containerConfig": {
"additionalMounts": [
{
"hostPath": "/Users/gavriel/projects/webapp",
"hostPath": "~/projects/webapp",
"containerPath": "webapp",
"readonly": false
}

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/node": "^22.10.0",
"@types/qrcode-terminal": "^0.12.2",
"prettier": "^3.8.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
},
@@ -551,6 +552,120 @@
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
@@ -602,6 +717,103 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
@@ -671,6 +883,75 @@
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
@@ -731,6 +1012,26 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@keyv/bigmap": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
@@ -863,9 +1164,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"version": "22.19.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz",
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -1282,9 +1583,9 @@
"license": "MIT"
},
"node_modules/hookified": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz",
"integrity": "sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==",
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
"integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
"license": "MIT"
},
"node_modules/ieee754": {
@@ -1452,9 +1753,9 @@
"license": "MIT"
},
"node_modules/music-metadata": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.11.1.tgz",
"integrity": "sha512-8FT+lSLznASDhn5KNJtQE6ZH95VqhxtKWNPrvdfhlqgbdZZEEAXehx+xpUvas4VuEZAu49BhQgLa3NlmPeRaww==",
"version": "11.11.2",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.11.2.tgz",
"integrity": "sha512-tJx+lsDg1bGUOxojKKj12BIvccBBUcVa6oWrvOchCF0WAQ9E5t/hK35ILp1z3wWrUSYtgg57LfRbvVMkxGIyzA==",
"funding": [
{
"type": "github",
@@ -1476,7 +1777,7 @@
"strtok3": "^10.3.4",
"token-types": "^6.1.2",
"uint8array-extras": "^1.5.0",
"win-guid": "^0.2.0"
"win-guid": "^0.2.1"
},
"engines": {
"node": ">=18"
@@ -1642,6 +1943,22 @@
"node": ">=10"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@@ -1877,306 +2194,6 @@
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/sharp/node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/sharp/node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/sharp/node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/sharp/node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/sharp/node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/sharp/node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/sharp/node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/sharp/node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/sharp/node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/sharp/node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/sharp/node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",

View File

@@ -9,7 +9,9 @@
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"auth": "tsx src/whatsapp-auth.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\""
},
"dependencies": {
"@whiskeysockets/baileys": "^7.0.0-rc.9",
@@ -24,6 +26,7 @@
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0",
"@types/qrcode-terminal": "^0.12.2",
"prettier": "^3.8.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
},

View File

@@ -9,23 +9,39 @@ const PROJECT_ROOT = process.cwd();
const HOME_DIR = process.env.HOME || '/Users/user';
// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
export const MOUNT_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'mount-allowlist.json');
export const MOUNT_ALLOWLIST_PATH = path.join(
HOME_DIR,
'.config',
'nanoclaw',
'mount-allowlist.json',
);
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
export const MAIN_GROUP_FOLDER = 'main';
export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '300000', 10);
export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default
export const CONTAINER_IMAGE =
process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
export const CONTAINER_TIMEOUT = parseInt(
process.env.CONTAINER_TIMEOUT || '300000',
10,
);
export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
10,
); // 10MB default
export const IPC_POLL_INTERVAL = 1000;
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const TRIGGER_PATTERN = new RegExp(`^@${escapeRegex(ASSISTANT_NAME)}\\b`, 'i');
export const TRIGGER_PATTERN = new RegExp(
`^@${escapeRegex(ASSISTANT_NAME)}\\b`,
'i',
);
// Timezone for scheduled tasks (cron expressions, etc.)
// Uses system timezone by default
export const TIMEZONE = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
export const TIMEZONE =
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;

View File

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

202
src/db.ts
View File

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

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

View File

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

View File

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

View File

@@ -6,16 +6,16 @@
*
* Usage: npx tsx src/whatsapp-auth.ts
*/
import makeWASocket, {
useMultiFileAuthState,
DisconnectReason,
makeCacheableSignalKeyStore,
} from '@whiskeysockets/baileys';
import pino from 'pino';
import qrcode from 'qrcode-terminal';
import fs from 'fs';
import path from 'path';
import pino from 'pino';
import qrcode from 'qrcode-terminal';
import makeWASocket, {
DisconnectReason,
makeCacheableSignalKeyStore,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
const AUTH_DIR = './store/auth';
@@ -30,7 +30,9 @@ async function authenticate(): Promise<void> {
if (state.creds.registered) {
console.log('✓ Already authenticated with WhatsApp');
console.log(' To re-authenticate, delete the store/auth folder and run again.');
console.log(
' To re-authenticate, delete the store/auth folder and run again.',
);
process.exit(0);
}