Telegram Message Storage Fix — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Fix 4 broken aspects of the telegram message storage system: messages not saving since 2026-02-21, auto-backup not working, 101 records with NULL received_at, and message sort order issues.
Architecture: The worker process (apps/worker/) runs a Fastify server with multi-bot Telegram polling. Messages are received via node-telegram-bot-api, saved to Turso (LibSQL) via @libsql/client, and injected into tmux sessions. A separate macclaw-logger.ts script was writing to the OLD NeonDB (not Turso), which explains the cutoff. The fix requires: (1) ensuring the worker actually runs and saves to Turso, (2) adding received_at to the bot INSERT, (3) backfilling NULL timestamps, and (4) fixing sort order to use received_at instead of created_at.
Tech Stack: Node.js, TypeScript, Turso (LibSQL/SQLite), @libsql/client, node-telegram-bot-api, Fastify
Root Cause Analysis
Problem 1: Messages Not Saving Since 2026-02-21
Root Cause: Multi-factor
-
Worker NOT running (primary): The worker process (
apps/worker/) needs to be running locally for Telegram bots to poll and save messages. If the worker process crashed or was not restarted after a system reboot, no messages get saved. There is NO auto-restart mechanism (no LaunchAgent, no pm2, no systemd). -
macclaw-logger targets NeonDB, not Turso: The
scripts/macclaw-logger.tsfile (lines 19-23) connects to NeonDB PostgreSQL (ep-divine-darkness-a1gvyg6j-pooler.ap-southeast-1.aws.neon.tech/neondb), NOT Turso. Since the DB migration from NeonDB to Turso happened around 2026-02-21, this script became effectively dead — it writes to the old DB that nothing reads from anymore. -
Bot 1 disabled, Bot 4 removed: The
.envshowsBOT1is commented out (deleted),BOT3isENABLED=false,BOT4is removed. OnlyBOT2(vice-claude) andBOT6(contents-builder) are active. If only the macclaw-logger was producing records before, its NeonDB targeting explains the exact cutoff date.
Evidence:
.envline 22-23:DB_URL(NeonDB) is commented out,TURSO_DB_URLis setmacclaw-logger.tslines 19-23: Hardcoded NeonDB host/credentials, no Turso supporttelegram.tsline 37:const dbUrl = process.env.TURSO_DB_URL || process.env.DB_URL— correctly reads Turso, but only if the worker process is runningdb.tslines 9-10: UsesTURSO_DB_URLenv var — correct for Turso
Problem 2: Auto-Backup Not Working
Root Cause: The macclaw-logger.ts was the de facto "backup" mechanism — it scraped OpenClaw session JSONL files and wrote to NeonDB. But:
- It targets NeonDB, not Turso (the active DB)
- It's not scheduled as a LaunchAgent or cron job — it must be run manually (
npx tsx scripts/macclaw-logger.ts --cron) or kept running in a terminal - The
vice-reply.shscript saves reports to Turso but NOT telegram_messages - There is no dedicated auto-backup mechanism for telegram_messages in Turso
Problem 3: 101 Records with NULL received_at
Root Cause: The bot message handler in telegram.ts lines 201-206 does NOT set received_at:
INSERT INTO telegram_messages (id, chat_id, sender, bot, message_text, processing_status, injected_at, created_at)
VALUES (?, ?, 'ceo', ?, ?, 'injected', ?, ?)
The column received_at is omitted entirely. Only injected_at and created_at are set (both to nowEpoch() = Date.now()).
In contrast, the /telegram-log REST endpoint in index.ts line 213 DOES include received_at:
INSERT INTO telegram_messages (id, chat_id, sender, bot, message_text, processing_status, received_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
So messages saved by the bot handler have received_at = NULL, while messages saved via the REST endpoint have it set.
Problem 4: Sort Order Issues
Root Cause: The gatherState() function in scheduler.ts line 873 queries telegram_messages by processing_status but doesn't specify an ORDER BY. Other queries throughout the codebase use ORDER BY created_at — but for messages from the bot handler, created_at is set to Date.now() (the epoch when the worker saved it), NOT the actual Telegram message timestamp (msg.date). This means:
- Messages from different sources (bot vs REST vs macclaw-logger) have inconsistent timestamp semantics
received_atis NULL for bot-saved messages, making it useless for sortingcreated_atrepresents "when saved to DB" not "when message was sent"
The Telegram API provides msg.date (Unix timestamp in seconds) which represents the actual send time. This should be used for received_at.
Implementation Tasks
Task 1: Fix telegram.ts — Add received_at to Bot INSERT
Files:
- Modify:
projects/jarvis-system/apps/worker/src/telegram.ts:199-207
Step 1: Write the fix for the INSERT statement
Change the INSERT in telegram.ts to include received_at using the Telegram message's msg.date field (Unix seconds, needs conversion to milliseconds):
// telegram.ts, inside bot.on("message") handler, around line 199
// Save to DB (best-effort)
if (hasDb) {
try {
const receivedAt = msg.date ? msg.date * 1000 : nowEpoch();
await withDbRetry(() =>
dbExecute(
`INSERT INTO telegram_messages (id, chat_id, sender, bot, message_text, processing_status, received_at, injected_at, created_at)
VALUES (?, ?, 'ceo', ?, ?, 'injected', ?, ?, ?)`,
[newId(), String(chatId), name, fullPrompt, receivedAt, nowEpoch(), nowEpoch()]
),
);
} catch (dbErr: unknown) {
const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
console.error(`[${name}] DB save error (continuing):`, dbMsg);
}
}
Step 2: Verify the change compiles
Run:
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/jarvis-system/apps/worker"
npx tsc --noEmit
Expected: No errors
Step 3: Commit
git add projects/jarvis-system/apps/worker/src/telegram.ts
git commit -m "fix(worker): add received_at to telegram message INSERT using msg.date"
Task 2: Fix macclaw-logger — Migrate from NeonDB to Turso
Files:
- Modify:
scripts/macclaw-logger.ts
Step 1: Replace NeonDB connection with Turso (@libsql/client)
The entire neonQuery() function and Neon config (lines 18-81) need to be replaced with a Turso client using @libsql/client. The script must also adapt:
- PostgreSQL
$1, $2parameterized queries to SQLite?, ?positional params $3::timestampcasting to integer epoch (Turso stores timestamps as integer ms)BETWEEN timestamp - intervalto epoch arithmetic- UUID generation from
gen_random_uuid()tocrypto.randomUUID() received_atstored as epoch ms (not ISO string)
// Replace lines 18-81 with:
import { createClient, type Client } from "@libsql/client";
const TURSO_DB_URL = process.env.TURSO_DB_URL || "libsql://kanban-migkjy.aws-ap-northeast-1.turso.io";
const TURSO_DB_TOKEN = process.env.TURSO_DB_TOKEN || "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhIjoicnciLCJnaWQiOiI0NDk0MjU5MC0wNWZlLTQ2MjMtYWYzNy05MzhlZDU4MGUyMDciLCJpYXQiOjE3NzE2ODk5NDUsInJpZCI6ImZlM2I4ZDE2LTQ1MjUtNGFlYS1hNTljLWJiN2E3ODAyYzNhMyJ9.melTmvkT5Vt07T2Ye2VwE-ljUyr_YOhNZSWjHwPMSYGXYjwZpuzEufeL_vLxFTOG5pcHp_blcTPK9ETYdBHBDw";
let _client: Client | null = null;
function getClient(): Client {
if (!_client) {
_client = createClient({ url: TURSO_DB_URL, authToken: TURSO_DB_TOKEN });
}
return _client;
}
async function tursoQuery(sql: string, args: unknown[] = []): Promise<Record<string, unknown>[]> {
const client = getClient();
const result = await client.execute({ sql, args: args as any });
return result.rows as unknown as Record<string, unknown>[];
}
Step 2: Update the dedup query (run() function, ~line 170-175)
// Change from NeonDB PostgreSQL to Turso SQLite
// Old: "SELECT MAX(received_at) as last_ts FROM telegram_messages WHERE bot = $1 AND processing_status = 'logged'"
// New:
const lastSaved = await tursoQuery(
"SELECT MAX(received_at) as last_ts FROM telegram_messages WHERE bot = ? AND processing_status = 'logged'",
[BOT_NAME],
);
// received_at is now integer (epoch ms), not timestamp string
const lastEpoch = lastSaved[0]?.last_ts ? Number(lastSaved[0].last_ts) : null;
Step 3: Update the INSERT query (run() function, ~lines 208-228)
// Old: PostgreSQL $1-$5 with ::timestamp casting
// New: SQLite positional params with epoch ms
const receivedAtMs = new Date(msg.timestamp).getTime();
const existing = await tursoQuery(
`SELECT id FROM telegram_messages
WHERE bot = ? AND message_text = ?
AND received_at BETWEEN ? AND ?
LIMIT 1`,
[BOT_NAME, text, receivedAtMs - 1000, receivedAtMs + 1000],
);
if (existing.length > 0) { skipped++; continue; }
const id = crypto.randomUUID();
await tursoQuery(
`INSERT INTO telegram_messages (id, chat_id, sender, bot, message_text, processing_status, received_at, created_at)
VALUES (?, ?, ?, ?, ?, 'logged', ?, ?)`,
[id, CHAT_ID, sender, BOT_NAME, text, receivedAtMs, Date.now()],
);
inserted++;
Step 4: Update the filter for new messages
// Old: compare ISO strings
// New: compare epoch ms
const newMessages = lastEpoch
? allMessages.filter((m) => new Date(m.timestamp).getTime() > lastEpoch)
: allMessages;
Step 5: Build and test
Run:
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder"
npx tsx scripts/macclaw-logger.ts
Expected: Connects to Turso, reports message count, inserts or skips as appropriate
Step 6: Commit
git add scripts/macclaw-logger.ts
git commit -m "fix(macclaw-logger): migrate from NeonDB to Turso for telegram_messages"
Task 3: Backfill NULL received_at Records
Files:
- Create:
scripts/backfill-received-at.ts(one-time migration script)
Step 1: Write the backfill script
This script will:
- Find all telegram_messages where
received_at IS NULL - Set
received_at = injected_atifinjected_atis not null (best available proxy for receive time) - Set
received_at = created_atas fallback
#!/usr/bin/env npx tsx
/**
* One-time backfill: set received_at for telegram_messages where it's NULL.
* Uses injected_at as best proxy, falls back to created_at.
*/
import { createClient } from "@libsql/client";
const TURSO_DB_URL = process.env.TURSO_DB_URL || "libsql://kanban-migkjy.aws-ap-northeast-1.turso.io";
const TURSO_DB_TOKEN = process.env.TURSO_DB_TOKEN || "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhIjoicnciLCJnaWQiOiI0NDk0MjU5MC0wNWZlLTQ2MjMtYWYzNy05MzhlZDU4MGUyMDciLCJpYXQiOjE3NzE2ODk5NDUsInJpZCI6ImZlM2I4ZDE2LTQ1MjUtNGFlYS1hNTljLWJiN2E3ODAyYzNhMyJ9.melTmvkT5Vt07T2Ye2VwE-ljUyr_YOhNZSWjHwPMSYGXYjwZpuzEufeL_vLxFTOG5pcHp_blcTPK9ETYdBHBDw";
async function main() {
const client = createClient({ url: TURSO_DB_URL, authToken: TURSO_DB_TOKEN });
// Count NULL received_at records
const countResult = await client.execute({
sql: "SELECT count(*) as cnt FROM telegram_messages WHERE received_at IS NULL",
args: [],
});
const nullCount = Number((countResult.rows[0] as any)?.cnt ?? 0);
console.log(`[backfill] Found ${nullCount} records with NULL received_at`);
if (nullCount === 0) {
console.log("[backfill] Nothing to do.");
return;
}
// Backfill: use injected_at first, then created_at as fallback
const result = await client.execute({
sql: `UPDATE telegram_messages
SET received_at = COALESCE(injected_at, created_at)
WHERE received_at IS NULL`,
args: [],
});
console.log(`[backfill] Updated ${result.rowsAffected} records`);
// Verify
const verifyResult = await client.execute({
sql: "SELECT count(*) as cnt FROM telegram_messages WHERE received_at IS NULL",
args: [],
});
const remaining = Number((verifyResult.rows[0] as any)?.cnt ?? 0);
console.log(`[backfill] Remaining NULL received_at: ${remaining}`);
}
main().catch((err) => {
console.error("[backfill] Fatal:", err);
process.exit(1);
});
Step 2: Run the backfill
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder"
npx tsx scripts/backfill-received-at.ts
Expected output:
[backfill] Found 101 records with NULL received_at
[backfill] Updated 101 records
[backfill] Remaining NULL received_at: 0
Step 3: Commit
git add scripts/backfill-received-at.ts
git commit -m "fix(db): backfill NULL received_at in telegram_messages using injected_at/created_at"
Task 4: Fix Sort Order — Use received_at as Primary Sort Key
Files:
- Modify:
projects/jarvis-system/apps/worker/src/scheduler.ts(line 873 area)
Step 1: Verify current queries that read telegram_messages
Check all queries that read from telegram_messages across the codebase:
-
scheduler.ts:873—gatherState():SELECT count(*) ... WHERE processing_status IN ('pending', 'injected')— This is a count, no sort needed. OK. -
Any dashboard queries in
apps/kanban/— Check forORDER BY created_atthat should beORDER BY received_at.
Run:
grep -rn "telegram_messages" projects/jarvis-system/apps/kanban/src/ --include="*.ts"
Step 2: Update kanban dashboard queries (if applicable)
If any kanban Server Action queries telegram_messages with ORDER BY created_at, change to ORDER BY received_at DESC (or ORDER BY COALESCE(received_at, created_at) DESC for safety).
Look specifically at:
projects/kanban-dashboard/src/actions/directory- Any
/api/route that returns telegram messages
-- Before (incorrect sort — uses DB save time)
SELECT * FROM telegram_messages ORDER BY created_at DESC;
-- After (correct sort — uses actual message receive time)
SELECT * FROM telegram_messages ORDER BY received_at DESC;
Step 3: Commit
git add -A
git commit -m "fix(kanban): sort telegram_messages by received_at instead of created_at"
Task 5: Add Worker Auto-Restart (LaunchAgent)
Files:
- Create:
scripts/launchagents/com.jarvis.worker.plist(macOS LaunchAgent)
Step 1: Create the LaunchAgent plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.jarvis.worker</string>
<key>ProgramArguments</key>
<array>
<string>/Users/nbs22/.nvm/versions/node/v22.12.0/bin/node</string>
<string>dist/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/jarvis-system/apps/worker</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/nbs22/.nvm/versions/node/v22.12.0/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>DOTENV_CONFIG_PATH</key>
<string>/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/jarvis-system/apps/worker/.env</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/jarvis-worker.stdout.log</string>
<key>StandardErrorPath</key>
<string>/tmp/jarvis-worker.stderr.log</string>
</dict>
</plist>
Step 2: Install instructions
# Copy to LaunchAgents directory
cp scripts/launchagents/com.jarvis.worker.plist ~/Library/LaunchAgents/
# Build the worker first
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/jarvis-system/apps/worker"
npm run build
# Load the agent
launchctl load ~/Library/LaunchAgents/com.jarvis.worker.plist
# Verify it's running
launchctl list | grep jarvis.worker
curl http://localhost:5555/status
Step 3: Commit
git add scripts/launchagents/com.jarvis.worker.plist
git commit -m "feat(worker): add macOS LaunchAgent for auto-restart on crash/reboot"
Task 6: Verify End-to-End — Send Test Message and Confirm DB Save
Step 1: Ensure worker is running
curl http://localhost:5555/status
Expected: {"bots":["vice-claude","contents-builder"],"sessions":[...],"dbWatcher":"enabled"}
Step 2: Send a test message via Telegram bot
Send any text message to the vice-claude Telegram bot from the CEO account.
Step 3: Verify message saved to Turso
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/jarvis-system/apps/worker"
TURSO_DB_URL="libsql://kanban-migkjy.aws-ap-northeast-1.turso.io" \
TURSO_DB_TOKEN="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhIjoicnciLCJnaWQiOiI0NDk0MjU5MC0wNWZlLTQ2MjMtYWYzNy05MzhlZDU4MGUyMDciLCJpYXQiOjE3NzE2ODk5NDUsInJpZCI6ImZlM2I4ZDE2LTQ1MjUtNGFlYS1hNTljLWJiN2E3ODAyYzNhMyJ9.melTmvkT5Vt07T2Ye2VwE-ljUyr_YOhNZSWjHwPMSYGXYjwZpuzEufeL_vLxFTOG5pcHp_blcTPK9ETYdBHBDw" \
npx tsx -e "
import { createClient } from '@libsql/client';
const c = createClient({ url: process.env.TURSO_DB_URL!, authToken: process.env.TURSO_DB_TOKEN });
const r = await c.execute({ sql: 'SELECT id, chat_id, sender, bot, received_at, created_at FROM telegram_messages ORDER BY created_at DESC LIMIT 3', args: [] });
console.log(JSON.stringify(r.rows, null, 2));
"
Expected: Latest message appears with both received_at and created_at populated.
Step 4: Verify sort order
# Same script but with ORDER BY received_at DESC
# Verify received_at is not NULL and messages are in chronological order
Step 5: Commit verification report (optional)
No code change needed. This is a manual verification step.
Summary of Changes
| # | File | Change | Fixes |
|---|---|---|---|
| 1 | apps/worker/src/telegram.ts:199-207 | Add received_at = msg.date * 1000 to INSERT | Problem 3 (NULL received_at for new messages) |
| 2 | scripts/macclaw-logger.ts | NeonDB -> Turso migration | Problem 1 (messages not saving), Problem 2 (backup not working) |
| 3 | scripts/backfill-received-at.ts (new) | One-time backfill script | Problem 3 (existing 101 NULL records) |
| 4 | Kanban queries (if applicable) | ORDER BY received_at DESC | Problem 4 (sort order) |
| 5 | scripts/launchagents/com.jarvis.worker.plist (new) | LaunchAgent for auto-restart | Problem 1 (worker not running) |
Risk Assessment
| Risk | Mitigation |
|---|---|
| Worker restart kills active Telegram polling | LaunchAgent KeepAlive ensures immediate restart |
| Backfill sets wrong timestamps | Uses best available proxy (injected_at > created_at); no data loss |
| macclaw-logger breaks | Turso connection tested in Task 2 Step 5 before committing |
| Sort change breaks dashboard | COALESCE(received_at, created_at) ensures backward compatibility |
Prerequisites
- Worker must be built (
npm run buildinapps/worker/) @libsql/clientmust be available (already in worker'snode_modules)- Turso DB credentials must be in
.envor exported
Plan complete and saved to docs/plans/2026-02-25-telegram-fix.md. Two execution options:
1. Subagent-Driven (this session) - I dispatch fresh subagent per task, review between tasks, fast iteration
2. Parallel Session (separate) - Open new session with executing-plans, batch execution with checkpoints
Which approach?