23 KiB
Docker Cloud 24/7 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Run Maquina Comercial Pessoal 24/7 on Oracle Cloud Always Free using Docker Compose, persistent data, HTTPS, backups, and Telegram alerts when WhatsApp or campaign safety requires operator action.
Architecture: Add Docker-ready network binding and health endpoints to the app, package it in a Node 24 container, and orchestrate it with Caddy via Docker Compose. Persist all runtime state under /app/data, keep restart-safe campaign behavior, and add a focused Telegram alert module with deduplication.
Tech Stack: Node.js 24 ESM, SQLite, Baileys, Docker, Docker Compose, Caddy, Telegram Bot API over HTTPS, Node test runner.
Global Constraints
- Do not remove the current safety behavior that pauses campaigns after server restart.
- Do not send real WhatsApp messages during tests.
- Do not commit
.env.production, Telegram tokens, database files, WhatsApp auth files, or backups. - Local default host remains
127.0.0.1; Docker setsAPP_HOST=0.0.0.0. - Persist SQLite, WhatsApp auth, exports, and backups under the mounted
datadirectory. - Telegram alerts must be deduplicated with a cooldown per alert key.
- Telegram token must never be printed to logs.
- Docker healthcheck must use an endpoint that does not require login and does not expose secrets.
- Caddy handles public HTTP/HTTPS; the app container must not publish its internal port directly to the public internet.
- Oracle Cloud target is Always Free Ubuntu VM with Docker Compose.
File Structure
- Modify
src/env.mjs: addAPP_HOST, Docker-safe runtime path envs, and Telegram env helpers. - Modify
scripts/servidor.mjs: bind toAPP_HOST, initialize alerting, and notify restart/WhatsApp conditions. - Modify
src/server/index.mjs: expose a no-auth health endpoint. - Create
src/alerts/telegram.mjs: Telegram client, alert dedupe, and safe notification helpers. - Create
tests/alerts.test.mjs: unit tests for Telegram alert behavior and dedupe. - Modify existing server/env tests: cover
APP_HOSTand health route. - Create
Dockerfile: production image using Node 24. - Create
.dockerignore: exclude data, auth, backups, test artifacts, and secrets. - Create
docker-compose.yml: app + Caddy with persistent volumes and healthcheck. - Create
Caddyfile.example: reverse proxy template for a real domain. - Create
.env.production.example: production environment template without secrets. - Create
scripts/backup-data.mjs: zip SQLite + WhatsApp auth backups with retention. - Create
docs/deployment/oracle-free-docker.md: operator deployment runbook.
Task 1: Docker-Ready Environment and Health Endpoint
Files:
- Modify:
src/env.mjs - Modify:
scripts/servidor.mjs - Modify:
src/server/index.mjs - Modify/Test:
tests/server.test.mjs - Modify/Test:
tests/personal-branding.test.mjs
Interfaces:
-
Produces:
APP_HOST: stringexported fromsrc/env.mjs -
Produces: unauthenticated
GET /api/health -
Consumes: existing
APP_PORT,paths,loadDotEnv,createAppServer -
Step 1: Write failing env test for host binding
Add a test that imports src/env.mjs with process.env.APP_HOST='0.0.0.0' and asserts the exported value is 0.0.0.0. Clear the env afterward.
test('env exposes APP_HOST with local-safe fallback', async () => {
const previous = process.env.APP_HOST;
process.env.APP_HOST = '0.0.0.0';
const env = await import(`../src/env.mjs?host-test=${Date.now()}`);
assert.equal(env.APP_HOST, '0.0.0.0');
if (previous === undefined) delete process.env.APP_HOST; else process.env.APP_HOST = previous;
});
- Step 2: Write failing health route test
In tests/server.test.mjs, create an app server with fake dependencies and assert GET /api/health returns 200 without authentication.
test('health endpoint is public and does not expose secrets', async () => {
const { server, baseUrl, close } = await startTestServer();
try {
const res = await fetch(`${baseUrl}/api/health`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.ok(body.app);
assert.ok(body.database);
assert.ok(body.whatsapp);
assert.doesNotMatch(JSON.stringify(body), /token|password|secret|cookie/i);
} finally {
await close(server);
}
});
- Step 3: Run RED tests
Run: node --test tests/server.test.mjs tests/personal-branding.test.mjs
Expected: FAIL because APP_HOST and /api/health do not exist yet.
- Step 4: Implement
APP_HOST
In src/env.mjs, add:
export let APP_HOST = envString('APP_HOST', '127.0.0.1');
Update refreshEnvDerivedValues():
APP_HOST = envString('APP_HOST', '127.0.0.1');
- Step 5: Bind server to
APP_HOST
In scripts/servidor.mjs, import APP_HOST and replace:
server.listen(port, '127.0.0.1', () => {
with:
server.listen(port, APP_HOST, () => {
Update logs so local and Docker hosts are explicit:
log(`Maquina Comercial Pessoal rodando em http://${APP_HOST}:${port}`);
if (APP_HOST === '127.0.0.1') log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.');
else log('modo container/rede: proteja o acesso com proxy HTTPS e senha forte.');
- Step 6: Implement public health endpoint
In src/server/index.mjs, add a route before auth-protected application routes:
router.add('GET', '/api/health', async (req, res, { json }) => {
json(200, {
ok: true,
app: 'maquina-comercial-pessoal',
database: 'open',
whatsapp: whatsapp.getStatus?.().state || 'UNKNOWN'
});
});
If the router context does not expose whatsapp there, wire it from the existing createAppServer({ db, jobs, whatsapp, log }) closure.
- Step 7: Run GREEN tests
Run: node --test tests/server.test.mjs tests/personal-branding.test.mjs
Expected: PASS.
- Step 8: Run focused syntax check
Run: node --check src/env.mjs and node --check scripts/servidor.mjs
Expected: PASS.
Task 2: Telegram Alert Module
Files:
- Create:
src/alerts/telegram.mjs - Create/Test:
tests/alerts.test.mjs - Modify:
src/env.mjs - Modify:
.env.example
Interfaces:
-
Produces:
createTelegramAlerter({ token, chatId, enabled, fetchImpl, now, cooldownMs, log }) -
Produces:
alerter.alert(key, text): Promise<{ sent: boolean, reason?: string }> -
Consumes: Telegram Bot API
sendMessage -
Step 1: Write failing tests for disabled, missing config, send success, and dedupe
Create tests/alerts.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import { createTelegramAlerter } from '../src/alerts/telegram.mjs';
test('telegram alert skips when disabled or missing config', async () => {
const calls = [];
const alerter = createTelegramAlerter({ enabled: false, token: '', chatId: '', fetchImpl: async (...args) => calls.push(args) });
assert.deepEqual(await alerter.alert('qr', 'QR necessario'), { sent: false, reason: 'disabled' });
assert.equal(calls.length, 0);
});
test('telegram alert sends message without leaking token to text', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url, options });
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: '123:SECRET', chatId: '456', fetchImpl });
assert.deepEqual(await alerter.alert('qr', 'WhatsApp precisa de QR'), { sent: true });
assert.equal(calls.length, 1);
assert.match(calls[0].url, /sendMessage$/);
const payload = JSON.parse(calls[0].options.body);
assert.equal(payload.chat_id, '456');
assert.equal(payload.text, 'WhatsApp precisa de QR');
assert.doesNotMatch(payload.text, /SECRET/);
});
test('telegram alert deduplicates per key within cooldown', async () => {
let current = 1000;
const calls = [];
const fetchImpl = async (...args) => {
calls.push(args);
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: 't', chatId: 'c', fetchImpl, now: () => current, cooldownMs: 1000 });
assert.equal((await alerter.alert('same', 'A')).sent, true);
assert.deepEqual(await alerter.alert('same', 'A again'), { sent: false, reason: 'cooldown' });
current = 2501;
assert.equal((await alerter.alert('same', 'A later')).sent, true);
assert.equal(calls.length, 2);
});
- Step 2: Run RED test
Run: node --test tests/alerts.test.mjs
Expected: FAIL because module does not exist.
- Step 3: Implement Telegram alerter
Create src/alerts/telegram.mjs:
export function createTelegramAlerter({
token,
chatId,
enabled = false,
fetchImpl = fetch,
now = () => Date.now(),
cooldownMs = 15 * 60 * 1000,
log = () => {}
} = {}) {
const sentAt = new Map();
const configured = Boolean(token && chatId);
async function alert(key, text) {
if (!enabled) return { sent: false, reason: 'disabled' };
if (!configured) return { sent: false, reason: 'not_configured' };
const last = sentAt.get(key) || 0;
if (now() - last < cooldownMs) return { sent: false, reason: 'cooldown' };
const response = await fetchImpl(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text })
});
if (!response.ok) {
log(`telegram: falha ao enviar alerta (${response.status || 'sem status'})`);
return { sent: false, reason: 'request_failed' };
}
sentAt.set(key, now());
return { sent: true };
}
return { alert };
}
- Step 4: Add env values
In src/env.mjs, export:
export const telegram = {
enabled: process.env.TELEGRAM_ALERTS_ENABLED === 'true',
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
chatId: process.env.TELEGRAM_CHAT_ID || ''
};
After loadDotEnv() refresh, this object must reflect .env; if constants are initialized too early, use a function instead:
export function telegramConfig() {
return {
enabled: process.env.TELEGRAM_ALERTS_ENABLED === 'true',
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
chatId: process.env.TELEGRAM_CHAT_ID || ''
};
}
Prefer the function if tests expose stale values.
- Step 5: Document env placeholders
Append to .env.example:
TELEGRAM_ALERTS_ENABLED=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
- Step 6: Run GREEN tests
Run: node --test tests/alerts.test.mjs
Expected: PASS.
Task 3: Wire Operational Alerts Into Server Lifecycle
Files:
- Modify:
scripts/servidor.mjs - Modify/Test:
tests/server.test.mjsor createtests/server-alerts.test.mjs
Interfaces:
-
Consumes:
createTelegramAlerter(...) -
Consumes:
telegramConfig()fromsrc/env.mjs -
Produces: startup alerts for restart pause and WhatsApp reconnect failures
-
Step 1: Write failing server alert tests with fake alerter
Create a small exported helper if needed:
export async function notifyStartupState({ alerter, whatsappStatus, campaignsPausedByRestart }) {
if (campaignsPausedByRestart) await alerter.alert('campaigns-paused-restart', 'Campanhas pausadas apos restart. Revise antes de reiniciar.');
if (whatsappStatus === 'QR_REQUIRED') await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel.');
}
Test:
test('startup alerts notify restart pause and QR requirement once', async () => {
const alerts = [];
const alerter = { alert: async (key, text) => alerts.push({ key, text }) };
await notifyStartupState({ alerter, whatsappStatus: 'QR_REQUIRED', campaignsPausedByRestart: true });
assert.deepEqual(alerts.map((a) => a.key), ['campaigns-paused-restart', 'whatsapp-qr']);
});
- Step 2: Run RED test
Run: node --test tests/server-alerts.test.mjs
Expected: FAIL because helper/wiring does not exist.
- Step 3: Implement alert helper
Create src/alerts/runtime-alerts.mjs if keeping scripts/servidor.mjs slim:
export async function notifyStartupState({ alerter, whatsappStatus, campaignsPausedByRestart }) {
if (campaignsPausedByRestart) {
await alerter.alert('campaigns-paused-restart', 'Campanhas pausadas apos restart. Acesse o painel, revise a fila e reinicie manualmente.');
}
if (whatsappStatus === 'QR_REQUIRED') {
await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel da Maquina Comercial Pessoal.');
}
}
export async function notifyWhatsAppTransition({ alerter, previousState, nextState }) {
if (previousState === nextState) return;
if (previousState === 'READY' && nextState !== 'READY') {
await alerter.alert('whatsapp-left-ready', `WhatsApp saiu de READY e agora esta ${nextState}.`);
}
if (nextState === 'QR_REQUIRED') {
await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel.');
}
}
- Step 4: Wire startup alert in
scripts/servidor.mjs
After pauseCampaignsAfterRestart(db), determine whether any campaign has pause_reason='server_restart':
const campaignsPausedByRestart = Boolean(db.prepare(
"SELECT 1 FROM outreach_campaigns WHERE status = 'paused' AND pause_reason = 'server_restart' LIMIT 1"
).get());
Create alerter:
const tg = telegramConfig();
const alerter = createTelegramAlerter({ enabled: tg.enabled, token: tg.botToken, chatId: tg.chatId, log });
After WhatsApp manager exists, call:
await notifyStartupState({ alerter, whatsappStatus: whatsapp.getStatus().state, campaignsPausedByRestart });
- Step 5: Alert on initial reconnect failure
Change the existing reconnect catch:
whatsapp.connect().catch(async (error) => {
log(`whatsapp: reconexao inicial falhou: ${error.message}`);
await alerter.alert('whatsapp-initial-reconnect-failed', 'WhatsApp nao reconectou no startup. Acesse o painel e verifique o QR.');
});
- Step 6: Run focused tests
Run: node --test tests/alerts.test.mjs tests/server-alerts.test.mjs
Expected: PASS.
Task 4: Docker and Compose Packaging
Files:
- Create:
Dockerfile - Create:
.dockerignore - Create:
docker-compose.yml - Create:
Caddyfile.example - Create:
.env.production.example - Modify/Test:
tests/personal-branding.test.mjs
Interfaces:
-
Consumes:
APP_HOST,APP_PORT,APP_DB_PATH,APP_WHATSAPP_AUTH_DIR -
Produces: Docker image runnable by
docker compose up -d --build -
Step 1: Write failing file existence/config test
Add to tests/personal-branding.test.mjs:
test('docker deployment files preserve persistent data and do not include secrets', async () => {
const dockerfile = await read('Dockerfile');
const compose = await read('docker-compose.yml');
const envExample = await read('.env.production.example');
const caddy = await read('Caddyfile.example');
assert.match(dockerfile, /FROM node:24/);
assert.match(compose, /restart: unless-stopped/);
assert.match(compose, /APP_HOST=0\.0\.0\.0/);
assert.match(compose, /APP_DB_PATH=\/app\/data\/maquina-pessoal\.db/);
assert.match(compose, /APP_WHATSAPP_AUTH_DIR=\/app\/data\/runtime\/whatsapp-auth/);
assert.match(caddy, /reverse_proxy app:3910/);
assert.doesNotMatch(envExample, /[0-9]{5,}:[A-Za-z0-9_-]{20,}/);
});
- Step 2: Run RED test
Run: node --test tests/personal-branding.test.mjs
Expected: FAIL because files do not exist.
- Step 3: Create Dockerfile
FROM node:24-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY scripts ./scripts
COPY public ./public
COPY prospeccao/config.json ./prospeccao/config.json
RUN mkdir -p /app/data/runtime /app/data/exports /app/data/backups
EXPOSE 3910
CMD ["npm", "start"]
- Step 4: Create
.dockerignore
data
node_modules
test-results
playwright-report
.env
.env.production
*.log
*.sqlite
*.db
.git
- Step 5: Create
docker-compose.yml
services:
app:
build: .
restart: unless-stopped
env_file:
- .env.production
environment:
APP_HOST: 0.0.0.0
APP_PORT: 3910
APP_DB_PATH: /app/data/maquina-pessoal.db
APP_WHATSAPP_AUTH_DIR: /app/data/runtime/whatsapp-auth
CNPJ_BASE_DIR: /app/prospeccao/cnpj
volumes:
- ./data:/app/data
expose:
- "3910"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3910/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
app:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
- Step 6: Create
Caddyfile.example
campanhas.seudominio.com {
encode gzip
reverse_proxy app:3910
}
- Step 7: Create
.env.production.example
APP_RESET_PASSWORD=troque-esta-senha
GOOGLE_PLACES_API_KEY=
PERSONAL_COMMERCIAL_WHATSAPP=
TELEGRAM_ALERTS_ENABLED=true
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
- Step 8: Run GREEN tests
Run: node --test tests/personal-branding.test.mjs
Expected: PASS.
- Step 9: Build Docker image
Run: docker compose build app
Expected: image builds successfully.
Task 5: Backup Script With Retention
Files:
- Create:
scripts/backup-data.mjs - Create/Test:
tests/backup-data.test.mjs - Modify:
package.json
Interfaces:
-
Produces: CLI
node scripts/backup-data.mjs -
Produces: npm script
backup:data -
Consumes:
paths.data,paths.db,paths.whatsappAuth -
Step 1: Write failing backup tests
Create test that uses a temp directory, sample DB file, sample auth file, and asserts a backup file is created and old backups are removed.
test('backup creates archive marker and keeps configured retention', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-backup-'));
const dataDir = path.join(tmp, 'data');
await fs.mkdir(path.join(dataDir, 'runtime', 'whatsapp-auth'), { recursive: true });
await fs.writeFile(path.join(dataDir, 'maquina-pessoal.db'), 'db');
await fs.writeFile(path.join(dataDir, 'runtime', 'whatsapp-auth', 'session.json'), 'auth');
const result = await createBackup({ dataDir, now: () => new Date('2026-08-03T10:00:00Z'), retention: 1 });
assert.match(result.file, /maquina-pessoal-2026-08-03/);
await fs.access(result.file);
});
- Step 2: Run RED test
Run: node --test tests/backup-data.test.mjs
Expected: FAIL because backup module does not exist.
- Step 3: Implement backup helper
Because the project has no zip dependency, implement a safe tar/gzip through Node child_process only on Linux containers, or write a plain .backup directory copy. Prefer no new production dependency.
Export:
export async function createBackup({ dataDir = paths.data, now = () => new Date(), retention = 14 } = {}) {
const stamp = now().toISOString().slice(0, 10);
const backupsDir = path.join(dataDir, 'backups');
const target = path.join(backupsDir, `maquina-pessoal-${stamp}`);
await fs.mkdir(target, { recursive: true });
await fs.copyFile(path.join(dataDir, 'maquina-pessoal.db'), path.join(target, 'maquina-pessoal.db'));
await cp(path.join(dataDir, 'runtime', 'whatsapp-auth'), path.join(target, 'whatsapp-auth'), { recursive: true });
await pruneBackups(backupsDir, retention);
return { file: target };
}
Use directory backups rather than zip if avoiding dependencies. Document this in the runbook.
- Step 4: Add CLI guard
At bottom of scripts/backup-data.mjs:
if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) {
createBackup().then((result) => console.log(`backup criado: ${result.file}`)).catch((error) => {
console.error(`backup falhou: ${error.message}`);
process.exit(1);
});
}
- Step 5: Add npm script
In package.json scripts:
"backup:data": "node scripts/backup-data.mjs"
- Step 6: Run GREEN tests
Run: node --test tests/backup-data.test.mjs
Expected: PASS.
Task 6: Deployment Runbook and Final Verification
Files:
- Create:
docs/deployment/oracle-free-docker.md - Modify:
README.md
Interfaces:
-
Consumes: Docker/Compose files and Telegram envs from earlier tasks
-
Produces: operator runbook for Oracle Always Free deployment
-
Step 1: Write runbook
Create docs/deployment/oracle-free-docker.md with exact steps:
# Oracle Always Free Docker Deployment
## 1. Create VM
- Use Ubuntu Always Free.
- Prefer Ampere A1 if available.
- Keep boot/block volume within Always Free limits.
## 2. Open ports
- OCI ingress: 80/tcp and 443/tcp from internet.
- Restrict 22/tcp to your IP.
## 3. Install Docker
```bash
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
4. Configure app
cp .env.production.example .env.production
nano .env.production
cp Caddyfile.example Caddyfile
nano Caddyfile
5. Start
docker compose up -d --build
docker compose ps
docker compose logs -f app
6. Backup
docker compose exec app npm run backup:data
7. QR recovery
When Telegram says QR is required, open the panel and reconnect WhatsApp.
- Step 2: Link runbook from README
Add a short section to README.md:
## Deploy 24/7 sem custo
Veja `docs/deployment/oracle-free-docker.md` para rodar no Oracle Cloud Always Free com Docker, Caddy e alertas Telegram.
- Step 3: Run final automated checks
Run:
npm.cmd test
npm.cmd run test:browser
npm.cmd audit --omit=dev
docker compose config
docker compose build app
Expected:
-
Node tests pass.
-
Browser tests pass or any existing unrelated flake is documented.
-
Audit reports 0 production vulnerabilities.
-
Compose config validates.
-
Docker image builds.
-
Step 4: Manual non-sending smoke test
Run:
docker compose up -d --build
Invoke-WebRequest http://127.0.0.1/api/health -UseBasicParsing
docker compose restart app
docker compose logs --tail=100 app
Expected:
- Health returns 200 through Caddy if locally mapped.
- Data directory remains present after restart.
- Campaigns pause after restart.
- Telegram alert is sent for restart pause if enabled.
- No real WhatsApp message is sent.
Self-Review
- Spec coverage: Oracle Free, Docker Compose, Caddy HTTPS, persistent volumes, Telegram alerts, restart safety, backups, and verification are each assigned to tasks.
- Placeholder scan: no TBD/TODO placeholders remain; every task has concrete files, commands, and expected outcomes.
- Interface consistency:
APP_HOST,/api/health,createTelegramAlerter,notifyStartupState, andbackup:dataare defined before later tasks consume them.