Files
COMERCIAL-GTI/docs/superpowers/plans/2026-08-03-docker-cloud-247.md
T
2026-09-28 15:48:44 -03:00

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 sets APP_HOST=0.0.0.0.
  • Persist SQLite, WhatsApp auth, exports, and backups under the mounted data directory.
  • 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: add APP_HOST, Docker-safe runtime path envs, and Telegram env helpers.
  • Modify scripts/servidor.mjs: bind to APP_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_HOST and 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: string exported from src/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.mjs or create tests/server-alerts.test.mjs

Interfaces:

  • Consumes: createTelegramAlerter(...)

  • Consumes: telegramConfig() from src/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, and backup:data are defined before later tasks consume them.