← back to Claude Slack
poller.js
104 lines
'use strict';
// claude-slack poller — one pass: for each configured channel, read messages
// newer than our saved cursor, run claude on the ones addressed to it, post the
// reply in-thread, and advance the cursor. Run on an interval by launchd
// (com.steve.claude-slack). Idempotent: a per-channel `latest ts` cursor means
// no message is ever handled twice, and on FIRST sight of a channel we only set
// the cursor to "now" (we never reply to backlog).
const fs = require('node:fs');
const path = require('node:path');
const { cfg, api, gate, runClaude } = require('./lib');
const { recordSuccess, recordFailure } = require('./health');
const CURSORS = path.join(__dirname, '.cursors.json');
const log = (...a) => console.log(new Date().toISOString(), ...a);
function readCursors() {
try { return JSON.parse(fs.readFileSync(CURSORS, 'utf8')); } catch { return {}; }
}
function writeCursors(c) {
try { fs.writeFileSync(CURSORS, JSON.stringify(c, null, 2)); } catch (e) { log('cursor write failed:', e.message); }
}
async function processChannel(ch, botUserId, cursors) {
const prev = cursors[ch.id];
// conversations.history returns newest-first; `oldest` is inclusive, so we
// filter strictly greater than the cursor below.
const res = await api.history(ch.id, prev, 50);
const msgs = (res.messages || []).filter((m) => m.ts && (!prev || Number(m.ts) > Number(prev)));
if (!prev) {
// First time we've seen this channel: anchor the cursor to the latest
// message so we answer only what arrives AFTER now — never the backlog.
const newest = (res.messages || [])[0]?.ts;
if (newest) cursors[ch.id] = newest;
log(`[${ch.key}] first run — anchored cursor at ${newest || '(empty channel)'}`);
return;
}
if (msgs.length === 0) { log(`[${ch.key}] no new messages`); return; }
// Oldest → newest so the conversation is answered in order.
msgs.sort((a, b) => Number(a.ts) - Number(b.ts));
log(`[${ch.key}] ${msgs.length} new message(s)`);
for (const msg of msgs) {
const d = gate(msg, ch, botUserId);
if (!d.ok) {
log(`[${ch.key}] skip ts=${msg.ts} user=${msg.user || '?'} reason="${d.reason}"`);
cursors[ch.id] = msg.ts; // advance past anything we deliberately ignore
writeCursors(cursors);
continue;
}
log(`[${ch.key}] ANSWER ts=${msg.ts} user=${msg.user} -> running claude`);
const res2 = await runClaude(d.prompt);
let reply = res2.ok ? res2.text : `I hit a problem running that locally:\n\n${res2.text}`;
if (reply.length > cfg.maxReply) reply = reply.slice(0, cfg.maxReply) + '\n…(truncated)';
try {
// Thread the reply under the original (or its parent thread) so shared
// channels stay tidy and the exchange is self-contained.
const threadTs = msg.thread_ts || msg.ts;
await api.post(ch.id, reply, threadTs);
log(`[${ch.key}] REPLIED in thread ${threadTs}`);
} catch (e) {
// Posting failed (network/scope) — leave the cursor BEFORE this message so
// the next pass retries instead of dropping Steve's question on the floor.
log(`[${ch.key}] POST FAILED ts=${msg.ts}: ${e.message}`);
return;
}
cursors[ch.id] = msg.ts;
writeCursors(cursors);
}
}
async function main() {
if (!cfg.token) throw new Error('SLACK_BOT_TOKEN missing');
if (!cfg.channels.length) throw new Error('SLACK_CHANNELS is empty');
// Resolve the bot's own user id once so the loop guard can skip its own posts.
let botUserId = cfg.botUserId;
if (!botUserId) { try { botUserId = (await api.authTest()).user_id; } catch (e) { log('auth.test failed:', e.message); throw e; } }
const cursors = readCursors();
for (const ch of cfg.channels) {
try { await processChannel(ch, botUserId, cursors); }
catch (e) {
// A per-channel failure (e.g. not invited, missing history scope) must not
// sink the other channels. Surface it; the pass still counts as degraded.
log(`[${ch.key}] ERROR: ${e.message}`);
throw e; // bubble so health records a failure when a scope/auth issue is global
}
}
writeCursors(cursors);
}
main()
.then(async () => { await recordSuccess(); process.exit(0); })
.catch(async (e) => {
log('FATAL', e.message);
try { await recordFailure(e.message); } catch (he) { log('health.recordFailure errored:', he.message); }
process.exit(1);
});