app
adom-courier
Public Made by Adomby adom
App-neutral Google Chat human-in-the-loop middleware for Adom apps. Two directions: apps POST /requests to ask a human (Marv posts via adom-gchat, the reply comes back with identity + attachment bytes
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749
#!/usr/bin/env node
/*
* adom-courier — app-neutral Google Chat <-> Adom-apps middleware (PULL model).
*
* POST (prompts, acks, outcomes) -> adom-gchat webhook => appears as "Marv · App" (bot-badged, no seat)
* RECEIVE (replies, identity, files) -> adom-google poll => reads as you, invisible
*
* Apps never touch Google. They POST a request here and get structured response
* events back (responder identity + attachment bytes) when a human replies.
*
* Correlation: adom-gchat (webhook) returns nothing and doesn't expose threadKey
* on read, so after posting a prompt the courier FINDS its own just-posted Marv
* message (the only BOT message with that exact text) to learn the thread.name,
* then matches human replies to the request by thread.name.
*
* A space configured with "gchatSpace" posts via Marv; without it, posts fall
* back to adom-google (posts as you) — handy for dev before a webhook exists.
*/
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { URL } = require('url');
const crypto = require('crypto');
const DIR = __dirname;
const PORT = parseInt(process.env.PORT || '8870', 10);
const HOST = process.env.HOST || '127.0.0.1';
const ADOM_GOOGLE = process.env.ADOM_GOOGLE || path.join(process.env.HOME || '/home/adom', '.local/bin/adom-google');
const ADOM_GCHAT = process.env.ADOM_GCHAT || 'adom-gchat';
// Inbound (human-initiated) replies post into a human-started thread via a RAW webhook
// POST (thread.name based) so Marv lands as sender=BOT. The webhook URL is read from this
// file — same one adom-gchat uses. spaces[gchatSpace].url. (See human-inbound-plan §1 spike.)
const GCHAT_WEBHOOKS = process.env.GCHAT_WEBHOOKS || path.join(process.env.HOME || '/home/adom', '.config/gchat-webhooks.json');
// Process start time — the cold-start gate for NEW inbound conversations (branch C only).
// NEVER used to mutate lastSeen (that would drop app-initiated replies). See R5.
const PROCESS_START = Date.now();
// Attachment caps (R3): per-file byte cap + cumulative ATT_DIR cap. Enforced PRE-download (size
// metadata when present) AND mid-download (MEDIA_MAX_BUFFER aborts the spawnSync child the moment
// it exceeds the limit, so an oversized file never buffers the full 96MB into memory / blocks the
// loop — R3/R10). MEDIA_MAX_BUFFER sits a little above MAX_ATT_BYTES so an exactly-at-cap file still
// downloads, but a much larger one trips ENOBUFS fast.
const MAX_ATT_BYTES = parseInt(process.env.ADOM_COURIER_MAX_ATT_BYTES || String(10 * 1024 * 1024), 10);
const MAX_ATT_DIR_BYTES = parseInt(process.env.ADOM_COURIER_MAX_ATT_DIR_BYTES || String(500 * 1024 * 1024), 10);
const MEDIA_MAX_BUFFER = parseInt(process.env.ADOM_COURIER_MEDIA_MAX_BUFFER || String(MAX_ATT_BYTES + 1024 * 1024), 10);
// Inbound text cap (R9) + bounds on the conversations map + processed-message dedup set.
const MAX_INBOUND_TEXT = parseInt(process.env.ADOM_COURIER_MAX_INBOUND_TEXT || String(16 * 1024), 10);
const MAX_CONVERSATIONS = parseInt(process.env.ADOM_COURIER_MAX_CONVERSATIONS || '500', 10);
const CONV_IDLE_MS = parseInt(process.env.ADOM_COURIER_CONV_IDLE_MS || String(24 * 3600 * 1000), 10);
const PROCESSED_MAX = parseInt(process.env.ADOM_COURIER_PROCESSED_MAX || '5000', 10);
const MAX_POLL_PAGES = parseInt(process.env.ADOM_COURIER_MAX_POLL_PAGES || '10', 10); // R7: page back up to this many 30-msg pages/poll
const IDENTITY_NEG_TTL_MS = parseInt(process.env.ADOM_COURIER_IDENTITY_NEG_TTL_MS || String(5 * 60 * 1000), 10); // negative-resolve cache TTL (R10)
const STALE_THRESHOLD = parseInt(process.env.ADOM_COURIER_STALE_THRESHOLD || '3', 10); // consecutive poll errors before a space is "stale"
const MAX_IDENTITY_NEG = parseInt(process.env.ADOM_COURIER_MAX_IDENTITY_NEG || '2000', 10); // cap on the negative-identity cache
const DISPATCH_TIMEOUT_MS = parseInt(process.env.ADOM_COURIER_DISPATCH_TIMEOUT_MS || '20000', 10);
// External data dir — config/state/attachments live OUTSIDE the package dir so
// `adom-wiki pkg update` (which re-extracts the package) can't clobber them.
const DATA_DIR = process.env.ADOM_COURIER_HOME || path.join(process.env.HOME || '/home/adom', '.config/adom-courier');
fs.mkdirSync(DATA_DIR, { recursive: true });
const STATE_FILE = path.join(DATA_DIR, 'state.json');
const ATT_DIR = path.join(DATA_DIR, 'attachments');
// Deployment config: external if present (real spaces/apps), else the bundled template.
const EXT_CONFIG = path.join(DATA_DIR, 'config.json');
const CONFIG_PATH = process.env.ADOM_COURIER_CONFIG || (fs.existsSync(EXT_CONFIG) ? EXT_CONFIG : path.join(DIR, 'config.json'));
const CONFIG = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
fs.mkdirSync(ATT_DIR, { recursive: true });
// Normalize spaces: alias -> { id, gchatSpace?, inboundApp?, inboundAllowlist? }.
// Accepts a bare id string too. `alias` is stored so logs/boot-warnings can name the space.
const SPACES = {};
for (const [alias, v] of Object.entries(CONFIG.spaces || {})) SPACES[alias] = (typeof v === 'string') ? { id: v, alias } : { ...v, alias };
function resolveSpace(aliasOrId) {
if (SPACES[aliasOrId]) return SPACES[aliasOrId];
for (const s of Object.values(SPACES)) if (s.id === aliasOrId) return s;
return /^spaces\//.test(aliasOrId) ? { id: aliasOrId } : null;
}
function log(...a) { console.log(new Date().toISOString(), ...a); }
// Per-key async mutex: serializes the gchatSend+findOwnMessage critical section per space so two
// concurrent /requests posting IDENTICAL text can't interleave and bind to each other's thread
// (findOwnMessage matches by exact text). Keyed by space id; a global key would also be correct.
// The tail Promise resolves when the current holder finishes; the next waiter chains off it.
const _locks = new Map();
function withLock(key, fn) {
const prev = _locks.get(key) || Promise.resolve();
let release;
const tail = prev.then(() => new Promise((r) => { release = r; }));
_locks.set(key, tail);
return prev.then(() => fn()).finally(() => {
release();
if (_locks.get(key) === tail) _locks.delete(key); // GC the chain when no one else is waiting
});
}
// I/O boundary indirection — production uses the real functions (assigned after they're defined).
// Unit tests overwrite these to feed canned messages / capture dispatches, exercising the REAL
// poll loop + handlers. Untouched in production (require.main === module).
const hooks = { listMessages: null, resolveUser: null, downloadAttachment: null, dispatch: null };
// Inbound routing validation (BLOCKING req 1 / R2). A space that declares inboundApp MUST:
// - have a gchatSpace (else replies would post as HUMAN via adom-google → self-dispatch loop), and
// - name an app that exists in CONFIG.apps.
// If either fails we DISABLE inbound on that space (clear inboundApp) and boot-warn loudly,
// so a misconfigured space silently behaves like today (ignore human-initiated) rather than loop.
function validateInboundSpaces() {
for (const c of Object.values(SPACES)) {
if (!c.inboundApp) continue;
if (!c.gchatSpace) {
log(`⚠ INBOUND DISABLED on space '${c.alias}' (${c.id}): inboundApp='${c.inboundApp}' but no gchatSpace — refusing to route inbound (would reply as HUMAN → self-dispatch loop). Set gchatSpace to enable.`);
c.inboundApp = null; continue;
}
if (!(CONFIG.apps || {})[c.inboundApp]) {
log(`⚠ INBOUND DISABLED on space '${c.alias}' (${c.id}): inboundApp='${c.inboundApp}' not found in apps{}.`);
c.inboundApp = null; continue;
}
}
}
// Read the raw webhook URL for a gchatSpace from ~/.config/gchat-webhooks.json → spaces[gchatSpace].url.
// Cached, but INVALIDATED on file mtime change so a rotated webhook is picked up without a restart
// (F3) — without paying a JSON parse on every send. Throws if the file/space is missing.
let _webhookCache = null; let _webhookMtime = 0;
function webhookUrlFor(gchatSpace) {
let mtime = 0; try { mtime = fs.statSync(GCHAT_WEBHOOKS).mtimeMs; } catch (_) {}
if (!_webhookCache || mtime !== _webhookMtime) {
const raw = fs.readFileSync(GCHAT_WEBHOOKS, 'utf8');
_webhookCache = JSON.parse(raw).spaces || {};
_webhookMtime = mtime;
}
const e = _webhookCache[gchatSpace];
if (!e || !e.url) throw new Error(`no webhook url for gchatSpace '${gchatSpace}' in ${GCHAT_WEBHOOKS}`);
return e.url;
}
// ---------------------------------------------------------------- state
// conversations{threadName:conv} is the human-initiated counterpart to requests{} (app-initiated).
// processed[] is a bounded, ORDERED list of already-handled message.name values — the CODE loop
// guard (R2/R8): a message is never processed twice, which also closes the poll↔/reply race.
// backfill{spaceId:{token,newest}} holds an in-progress deep-pagination cursor when a single poll
// couldn't reach the watermark (>MAX_POLL_PAGES×30 messages); the next poll resumes from `token` and
// advances lastSeen to `newest` only once the backlog is fully drained (F2).
// spaceHealth{spaceId:{consecutiveErrors,lastErrorAt,lastError,lastOkAt,alerted}} tracks per-space
// poll health so a space whose poll keeps erroring isn't silently frozen (Phase-2 staleness alarm).
function freshState() { return { counter: 0, lastSeen: {}, requests: {}, responses: [], identity: {}, conversations: {}, processed: [], backfill: {}, identityNeg: {}, spaceHealth: {} }; }
let state = freshState();
try { if (fs.existsSync(STATE_FILE)) state = { ...freshState(), ...JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) }; } catch (_) {}
// Rehydrate the processed-dedup set from the persisted ordered list.
const processedSet = new Set(state.processed || []);
function markProcessed(name) {
if (!name || processedSet.has(name)) return;
processedSet.add(name); state.processed.push(name);
while (state.processed.length > PROCESSED_MAX) processedSet.delete(state.processed.shift());
}
function saveState() { try { fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); } catch (e) { log('saveState err', e.message); } }
// ---------------------------------------------------------------- Google read (adom-google)
function gapi(url, { method = 'GET', data = null, query = [], raw = false, maxBuffer = null } = {}) {
const args = ['api', url, '-X', method];
for (const q of query) args.push('-q', q);
if (data != null) args.push('-d', typeof data === 'string' ? data : JSON.stringify(data));
if (raw) args.push('--raw');
// maxBuffer caps how many bytes spawnSync will buffer before it ABORTS the child (ENOBUFS).
// For media (raw) downloads we cap it just above the per-file attachment limit so an oversized
// file fails FAST instead of buffering up to 96MB into memory + blocking the event loop (R3/R10).
const mb = maxBuffer != null ? maxBuffer : (raw ? MEDIA_MAX_BUFFER : 96 * 1024 * 1024);
const r = spawnSync(process.env.ADOM_GOOGLE || ADOM_GOOGLE, args, { encoding: raw ? 'buffer' : 'utf8', maxBuffer: mb, timeout: 45000, env: process.env });
if (r.error) throw new Error('adom-google spawn failed: ' + r.error.message);
if (raw) {
const err = (r.stderr || Buffer.alloc(0)).toString(); const m = err.match(/HTTP\s+(\d+)/);
return { status: m ? +m[1] : (r.status === 0 ? 200 : 0), buffer: r.stdout || Buffer.alloc(0) };
}
const stderr = (r.stderr || '').toString();
const out = (r.stdout || '').toString(); const i = out.search(/[[{]/);
let json = null; if (i >= 0) { try { json = JSON.parse(out.slice(i)); } catch (_) {} }
// Surface API/HTTP errors instead of silently returning a null/empty body. The CLI exits non-zero
// and/or the JSON carries an `error` object on 4xx/5xx; either is an ERROR, NOT an empty result.
// Callers (e.g. listMessages pagination) must not conflate "request failed" with "no messages".
const httpStatus = (stderr.match(/HTTP\s+(\d+)/) || [])[1];
const apiError = (json && json.error) ? json.error
: (r.status !== 0 || (httpStatus && +httpStatus >= 400)) ? { code: httpStatus ? +httpStatus : r.status, message: (stderr.trim().split('\n').pop() || `adom-google exit ${r.status}`) }
: null;
return { json, raw: out, apiError };
}
function resolveUser(userName, forceResolve) {
if (!userName) return { name: 'unknown', email: '' };
if (state.identity[userName]) return state.identity[userName]; // permanent positive cache (success only)
const fallback = { name: userName, email: '', userId: userName };
// Negative cache (R10): if a recent resolve FAILED, don't re-spawn a blocking People-API call on
// every message — return the email-less fallback until the short TTL elapses, then retry. This
// keeps the F1 fix (a transient blip retries soon, ~5min) while a permanently-unresolvable chatty
// sender (valid id, no Google profile) doesn't stall the event loop on every message.
// EXCEPTION (item 3): on an allowlist space the caller passes forceResolve=true so a just-provisioned
// allowlisted user isn't fail-closed for up to the TTL — always attempt a fresh resolve there.
const neg = state.identityNeg[userName];
if (!forceResolve && neg && Date.now() < neg) return fallback;
let out = fallback;
let resolved = false; // did People actually return identity data this call?
try {
const { json } = gapi(`https://people.googleapis.com/v1/people/${num(userName)}`, { query: ['personFields=names,emailAddresses'] });
if (json && !json.error) {
out = { name: (json.names || [{}])[0].displayName || userName, email: ((json.emailAddresses || []).find((e) => (e.value || '').includes('@')) || {}).value || '', userId: userName };
resolved = true;
}
} catch (e) { log('resolveUser err', e.message); }
if (resolved) {
state.identity[userName] = out; // cache success permanently
delete state.identityNeg[userName]; // clear any negative entry
} else {
state.identityNeg[userName] = Date.now() + IDENTITY_NEG_TTL_MS; // back off re-resolving for a short TTL
capIdentityNeg(); // keep the negative cache bounded (item 2)
}
return out;
}
function num(userName) { return userName.replace(/^users\//, ''); }
// List messages newest-first, PAGINATING toward the watermark `sinceTs` (so a burst of >30 messages
// between polls is never silently dropped — R7/F2). Bounded by MAX_POLL_PAGES per call so one poll
// can't spin forever. When `resumeToken` is given, pagination CONTINUES from that deeper cursor
// (a backfill in progress from a previous poll) instead of restarting at the newest message — that's
// what lets a backlog LARGER than the per-poll page budget actually drain across successive polls.
// Without `sinceTs`, returns just the first page (back-compat for findOwnMessage). `isSeen(name)` is
// an optional predicate (the processed-dedup check): a page whose messages are ALL already-seen is
// "free" — it doesn't count against MAX_POLL_PAGES — so a restart-from-newest after an expired cursor
// can blow past the already-drained prefix to reach the remaining gap (without re-dispatching).
// Returns an array (newest/where-resumed first) carrying flags the poll loop reconciles:
// .truncated = hit the page bound (of PRODUCTIVE pages) before reaching the watermark (gap remains)
// .nextPageToken = cursor to resume from next poll (when truncated)
// .error = a page FETCH failed (HTTP/API error, NOT an empty page) — poll must NOT treat
// this as "drained"; it must hold lastSeen + keep the cursor and retry. (R-#1/#2)
// .expiredToken = the failure was on a RESUME page (likely an expired/invalid cursor) — poll
// should drop the dead cursor and RESTART the drain from the watermark.
function listMessages(spaceId, sinceTs, resumeToken, isSeen) {
const all = []; let pageToken = resumeToken || null; let truncated = false; let lastToken = null;
let fetchError = null; let expiredToken = false; let productive = 0; // pages that contained NEW (unseen) messages
// Total page ceiling: MAX_POLL_PAGES PRODUCTIVE pages, plus headroom to skip an already-processed
// prefix (all-seen = "free") on a restart. Caps blocking spawnSync calls so a pathological space
// can't stall one poll indefinitely while still letting a restart blow past the drained prefix.
const PAGE_CEILING = MAX_POLL_PAGES * 6;
let done = false; // did we cleanly reach watermark / end of pages?
for (let guard = 0; guard < PAGE_CEILING; guard++) {
const query = ['pageSize=30', 'orderBy=createTime desc'];
if (pageToken) query.push(`pageToken=${encodeURIComponent(pageToken)}`);
const usedResume = !!pageToken && pageToken === resumeToken; // are we hitting the resumed deep cursor?
const { json, apiError } = gapi(`https://chat.googleapis.com/v1/${spaceId}/messages`, { query });
if (apiError) { // page FAILED — stop, surface it (do NOT drain)
fetchError = apiError;
if (usedResume && (apiError.code === 400 || apiError.code === 404)) expiredToken = true; // expired/invalid cursor
log(`poll ${spaceId}: page fetch error (${apiError.code || '?'}: ${apiError.message || ''})${usedResume ? ' [resume cursor]' : ''} — NOT draining; will retry`);
break;
}
const msgs = (json && json.messages) || [];
all.push(...msgs);
pageToken = json && json.nextPageToken; lastToken = pageToken;
// A page counts against the budget only if it carried at least one UNSEEN message; an all-seen
// page (the already-drained prefix on a restart) is free so we can reach the gap below it.
const hasNew = !isSeen || msgs.some((m) => !isSeen(m.name));
if (hasNew) productive += 1;
if (sinceTs == null) { done = true; break; } // no watermark -> single page (back-compat)
if (!pageToken || !msgs.length) { done = true; break; } // no more pages -> fully drained
const oldest = msgs[msgs.length - 1]; // desc order -> last is oldest on this page
if (oldest && new Date(oldest.createTime).getTime() <= sinceTs) { done = true; break; } // reached watermark -> done
if (productive >= MAX_POLL_PAGES) { // budget of PRODUCTIVE pages hit -> gap remains
truncated = true;
log(`poll ${spaceId}: hit MAX_POLL_PAGES (${MAX_POLL_PAGES} productive×30) before reaching watermark — older messages DEFERRED to next poll (resume cursor saved; lastSeen held)`);
break;
}
}
// Exited via the page ceiling (huge all-seen prefix) without a clean finish AND more pages exist
// -> treat as truncated so lastSeen is held and we resume next poll (never silently "drained").
if (!done && !fetchError && !truncated && pageToken) truncated = true;
all.truncated = truncated;
all.nextPageToken = truncated ? lastToken : null;
all.error = fetchError;
all.expiredToken = expiredToken;
return all;
}
function downloadAttachment(att) {
const rn = att.attachmentDataRef && att.attachmentDataRef.resourceName;
if (!rn) throw new Error('no attachmentDataRef (Drive files unsupported)');
// PRE-download cap (cheap): if the message carries a size hint and it already exceeds the cap,
// skip without spawning a download at all.
const hinted = Number(att.size || att.contentLength || (att.attachmentDataRef && att.attachmentDataRef.size) || 0);
if (hinted && hinted > MAX_ATT_BYTES) throw new Error(`OVERSIZE: ${hinted} bytes > per-file cap ${MAX_ATT_BYTES} (skipped pre-download)`);
let res;
try {
res = gapi(`https://chat.googleapis.com/v1/media/${encodeURIComponent(rn)}?alt=media`, { raw: true });
} catch (e) {
// spawnSync aborts with ENOBUFS the instant the response exceeds MEDIA_MAX_BUFFER -> oversize.
if (/ENOBUFS|maxBuffer/i.test(e.message)) throw new Error(`OVERSIZE: exceeds media buffer cap ${MEDIA_MAX_BUFFER} bytes (download aborted, not buffered to memory)`);
throw e;
}
const { status, buffer } = res;
if (status !== 200 || !buffer.length) throw new Error(`media.download status ${status}`);
return buffer;
}
// ---------------------------------------------------------------- Chat posting
// Marv (adom-gchat webhook). Returns nothing useful — correlation is by findOwnMessage.
function gchatSend(gchatSpace, text, threadKey) {
const args = ['send', '--space', gchatSpace, '--no-attribution', '--permission-to-post-given'];
if (threadKey) args.push('--thread', threadKey);
args.push(text);
const r = spawnSync(process.env.ADOM_GCHAT || ADOM_GCHAT, args, { encoding: 'utf8', timeout: 25000, env: process.env });
if (r.status !== 0) throw new Error('adom-gchat send failed: ' + ((r.stderr || r.stdout || '').trim().split('\n').pop() || `exit ${r.status}`));
}
// Marv reply INTO a human-started thread (inbound). A human thread has thread.name but NO
// threadKey, so adom-gchat's --thread (threadKey-only) would FORK a new thread. Instead we POST
// raw to the webhook with thread:{name} + REPLY_MESSAGE_OR_FAIL so Marv lands as sender=BOT in the
// SAME thread. (SPIKE-confirmed; see plan §1.) Uses the https module only — zero npm deps.
function gchatReplyByThreadName(gchatSpace, text, threadName) {
const base = webhookUrlFor(gchatSpace);
const url = base + (base.includes('?') ? '&' : '?') + 'messageReplyOption=REPLY_MESSAGE_OR_FAIL';
const body = Buffer.from(JSON.stringify({ text, thread: { name: threadName } }));
const u = new URL(url); const lib = u.protocol === 'https:' ? require('https') : http;
return new Promise((resolve, reject) => {
const r = lib.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8', 'Content-Length': body.length } }, (res) => {
let out = ''; res.on('data', (c) => { out += c; }); res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) return resolve(out);
reject(new Error(`webhook reply status ${res.statusCode}: ${out.slice(0, 300)}`));
});
});
r.on('error', reject); r.setTimeout(20000, () => r.destroy(new Error('webhook reply timeout')));
r.write(body); r.end();
});
}
// adom-google direct post (fallback when a space has no gchatSpace). Returns the message.
function adomGooglePost(spaceId, text, threadKey, threadName) {
const thread = threadName ? { name: threadName } : { threadKey };
const replyOpt = threadName ? 'REPLY_MESSAGE_OR_FAIL' : 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD';
const { json } = gapi(`https://chat.googleapis.com/v1/${spaceId}/messages`, { method: 'POST', data: { text, thread }, query: [`messageReplyOption=${replyOpt}`] });
if (!json || json.error || !json.name) throw new Error(`adom-google post failed: ${json && json.error ? JSON.stringify(json.error) : 'no message'}`);
return json;
}
// After a Marv post, locate our own message (the BOT message with this exact text,
// in a thread not already tracked) to learn its thread.name. Retries for propagation.
function findOwnMessage(spaceId, text) {
const want = (text || '').trim();
for (let i = 0; i < 6; i++) {
let msgs = []; try { msgs = listMessages(spaceId); } catch (_) {}
const m = msgs.find((x) => x.sender && x.sender.type === 'BOT' && (x.text || '').trim() === want
&& x.thread && x.thread.name && !state.requests[x.thread.name] && !state.conversations[x.thread.name]);
if (m) return { threadName: m.thread.name, messageName: m.name };
spawnSync('sleep', ['0.9']);
}
return null;
}
// ---------------------------------------------------------------- dispatch to owning app
function dispatch(appName, event) {
const app = (CONFIG.apps || {})[appName];
// Delivery visibility (item 4): mutate the SHARED event ref (also stored in state.responses) so
// delivery status is queryable at /state. No retry — best-effort, just observable.
const tag = event.itemKey || event.conversationId || '?'; // app-initiated has itemKey; inbound falls back to conversationId
if (!app || !app.dispatchUrl) { event.delivered = false; event.held = 'no dispatchUrl'; log(`dispatch: no URL for '${appName}' — held in /state`); return; }
const body = Buffer.from(JSON.stringify({ ...event, attachments: event.attachments.map((a) => ({ ...a, base64: a.path ? fs.readFileSync(a.path).toString('base64') : undefined })) }));
const u = new URL(app.dispatchUrl); const lib = u.protocol === 'https:' ? require('https') : http;
const headers = { 'Content-Type': 'application/json', 'Content-Length': body.length };
if (app.dispatchToken) headers.Authorization = `Bearer ${app.dispatchToken}`; // courier proves identity to the app's receiver
const fail = (msg) => { if (event.delivered === undefined) { event.delivered = false; event.deliveryError = msg; } }; // record once
const r = lib.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', headers },
(res) => { res.on('data', () => {}); res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) { event.delivered = true; delete event.deliveryError; }
else fail(`HTTP ${res.statusCode}`);
log(`dispatched ${appName}/${tag}: ${res.statusCode}`);
}); });
r.on('error', (e) => { fail(e.message); log(`dispatch error ${appName}/${tag}: ${e.message}`); });
r.setTimeout(DISPATCH_TIMEOUT_MS, () => r.destroy(new Error('dispatch timeout'))); // R6: no socket leak on a hung receiver
r.write(body); r.end();
}
// Wire the I/O-boundary indirection to the real implementations (tests overwrite these).
hooks.listMessages = listMessages;
hooks.resolveUser = resolveUser;
hooks.downloadAttachment = downloadAttachment;
hooks.dispatch = dispatch;
// ---------------------------------------------------------------- reply into a request's thread (Marv or adom-google)
function replyInThread(rec, text) {
if (rec.gchatSpace) { gchatSend(rec.gchatSpace, text, rec.threadKey); return; }
adomGooglePost(rec.space, text, rec.threadKey, rec.threadName);
}
// ---------------------------------------------------------------- attachments (shared by app-initiated + inbound)
// Current ATT_DIR size, computed lazily and tracked so we can enforce the cumulative cap (R3)
// without statting the whole dir on every file.
let _attDirBytes = null;
function attDirBytes() {
if (_attDirBytes != null) return _attDirBytes;
let t = 0; try { for (const f of fs.readdirSync(ATT_DIR)) { try { t += fs.statSync(path.join(ATT_DIR, f)).size; } catch (_) {} } } catch (_) {}
_attDirBytes = t; return t;
}
// Download a message's attachments to ATT_DIR, enforcing per-file (MAX_ATT_BYTES) and cumulative
// (MAX_ATT_DIR_BYTES) caps BEFORE writing. Skipped/oversized files are recorded with an `error`,
// not written. `prefix` namespaces files by request/conversation id.
function collectAttachments(m, prefix) {
const out = [];
for (const a of (m.attachment || [])) {
try {
const buf = hooks.downloadAttachment(a);
if (buf.length > MAX_ATT_BYTES) { log(`att skip ${a.contentName}: ${buf.length}B > per-file cap ${MAX_ATT_BYTES}B`); out.push({ name: a.contentName, contentType: a.contentType, size: buf.length, error: `exceeds per-file cap (${MAX_ATT_BYTES} bytes)` }); continue; }
if (attDirBytes() + buf.length > MAX_ATT_DIR_BYTES) { log(`att skip ${a.contentName}: ATT_DIR cap ${MAX_ATT_DIR_BYTES}B would be exceeded`); out.push({ name: a.contentName, contentType: a.contentType, size: buf.length, error: 'attachment store full (ATT_DIR cap)' }); continue; }
const safe = (a.contentName || 'file').replace(/[^\w.-]/g, '_');
const p = path.join(ATT_DIR, `${prefix}-${Date.now()}-${safe}`);
fs.writeFileSync(p, buf); _attDirBytes = attDirBytes() + buf.length;
out.push({ name: a.contentName, contentType: a.contentType, path: p, size: buf.length });
} catch (e) { out.push({ name: a.contentName, error: e.message }); }
}
return out;
}
// ---------------------------------------------------------------- poller
function handleReply(req, m, spaceId) {
const responder = hooks.resolveUser(m.sender && m.sender.name);
const attachments = collectAttachments(m, req.requestId);
const event = { type: 'response', requestId: req.requestId, app: req.app, itemKey: req.itemKey, turn: req.turn || 1, responder, text: m.text || '', attachments, space: spaceId, threadName: req.threadName, messageName: m.name, at: new Date().toISOString() };
state.responses.unshift(event); if (state.responses.length > 200) state.responses.pop();
req.status = 'answered'; req.answeredAt = event.at; req.responder = responder;
log(`✓ ${req.app}/${req.itemKey} from ${responder.name} <${responder.email}> — ${attachments.length} attachment(s)`);
if (CONFIG.ackReceipt !== false) {
try { const first = (responder.name || 'there').split(' ')[0]; const what = attachments.length ? (attachments.length > 1 ? `${attachments.length} photos` : 'photo') : 'reply'; replyInThread(req, `✅ Got it, ${first} — received your ${what}. Thanks!`); req.acked = true; }
catch (e) { log('ack failed', e.message); }
}
hooks.dispatch(req.app, event);
}
// allowlist gate (R4): when inboundAllowlist is configured on the space, fail-closed — drop
// messages from senders whose resolved email isn't listed (no ack, no dispatch, no conversation
// churn). Returns {ok} plus a `reason` so the caller can log WHY a drop happened — distinguishing
// "identity unresolved" (People API failed -> no email) from "not on allowlist" (R8). Both still
// fail closed; the reason is purely diagnostic so an operator can tell a misconfig from an attack.
function inboundSenderAllowed(cfg, responder) {
const list = cfg.inboundAllowlist;
if (!Array.isArray(list) || !list.length) return { ok: true }; // not configured -> open (space membership is the boundary)
const email = (responder.email || '').toLowerCase();
if (!email) return { ok: false, reason: 'identity unresolved (no email — People API failed?)' };
if (list.some((e) => String(e).toLowerCase() === email)) return { ok: true };
return { ok: false, reason: 'sender not on allowlist' };
}
// handleInbound: a human-initiated message in an inbound space. Resolve identity, allowlist-gate,
// collect attachments, dispatch a type:'inbound' event to the owning app. NO auto-ack — the app's
// /reply IS the ack. Returns true if dispatched, false if dropped (allowlist).
function handleInbound(conv, m, spaceId, cfg) {
// On an allowlist space, force a fresh resolve (item 3): bypass the negative cache so a sender who
// just gained a Google profile within the TTL isn't suppressed (fail-closed) for up to 5 minutes.
const forceResolve = Array.isArray(cfg.inboundAllowlist) && cfg.inboundAllowlist.length > 0;
const responder = hooks.resolveUser(m.sender && m.sender.name, forceResolve);
const gate = inboundSenderAllowed(cfg, responder);
if (!gate.ok) {
log(`✗ inbound DROPPED (${gate.reason}) ${conv.app} from ${responder.name} <${responder.email || '?'}> in ${spaceId}`);
return false;
}
const attachments = collectAttachments(m, 'conv-' + conv.conversationId.replace(/[^\w]/g, '_'));
let text = m.text || '';
if (text.length > MAX_INBOUND_TEXT) text = text.slice(0, MAX_INBOUND_TEXT); // R9: cap attacker-controlled text length
const event = { type: 'inbound', conversationId: conv.conversationId, app: conv.app, space: spaceId,
turn: conv.turn, responder, text, attachments, trusted: false, threadName: conv.threadName, messageName: m.name, at: new Date().toISOString() };
state.responses.unshift(event); if (state.responses.length > 200) state.responses.pop();
conv.turn += 1; conv.lastHumanAt = event.at; conv.responder = responder;
log(`→ inbound ${conv.app} from ${responder.name} <${responder.email}> — turn ${event.turn}, ${attachments.length} attachment(s)`);
hooks.dispatch(conv.app, event);
return true;
}
// Bound the conversations map (R3/caps): drop closed + idle conversations, then cap total count
// (oldest-by-lastHumanAt first). Called after each poll.
// Bound the negative-identity cache (item 2): expire entries past TTL, then if still over the cap,
// evict the oldest (lowest expiry == earliest inserted, since the TTL is fixed). Called from the
// poll prune AND right after each insert, so a burst between polls can't blow the cap unbounded.
function capIdentityNeg() {
const now = Date.now();
for (const [u, exp] of Object.entries(state.identityNeg)) if (exp <= now) delete state.identityNeg[u];
const keys = Object.keys(state.identityNeg);
if (keys.length > MAX_IDENTITY_NEG) {
keys.sort((a, b) => state.identityNeg[a] - state.identityNeg[b]); // oldest expiry first
for (const u of keys.slice(0, keys.length - MAX_IDENTITY_NEG)) delete state.identityNeg[u];
}
}
function pruneConversations() {
const now = Date.now();
capIdentityNeg();
const entries = Object.entries(state.conversations);
for (const [tn, c] of entries) {
const last = new Date(c.lastHumanAt || c.startedAt || 0).getTime();
if (c.status === 'closed' && now - last > CONV_IDLE_MS) delete state.conversations[tn];
else if (now - last > CONV_IDLE_MS) { c.status = 'closed'; delete state.conversations[tn]; } // idle-close + drop
}
let remaining = Object.entries(state.conversations);
if (remaining.length > MAX_CONVERSATIONS) {
remaining.sort((a, b) => new Date(a[1].lastHumanAt || a[1].startedAt || 0) - new Date(b[1].lastHumanAt || b[1].startedAt || 0));
for (const [tn] of remaining.slice(0, remaining.length - MAX_CONVERSATIONS)) delete state.conversations[tn];
}
}
// Per-space poll-health bookkeeping for the staleness alarm. A poll that FAILED to fetch (throw or
// page .error) increments consecutiveErrors and, when it first crosses STALE_THRESHOLD, logs a loud
// one-shot line (re-armed on recovery so a later episode re-alerts). A successful fetch resets it.
function recordPollHealth(spaceId, errMsg) {
const h = state.spaceHealth[spaceId] || (state.spaceHealth[spaceId] = { consecutiveErrors: 0, lastErrorAt: null, lastError: null, lastOkAt: null, alerted: false });
if (errMsg) {
h.consecutiveErrors += 1; h.lastError = errMsg; h.lastErrorAt = new Date().toISOString();
if (h.consecutiveErrors >= STALE_THRESHOLD && !h.alerted) { // one-shot per stale episode
h.alerted = true; h.staleSince = h.lastErrorAt;
log(`⚠ SPACE STALE: ${spaceId} — ${h.consecutiveErrors} consecutive poll errors (last: ${errMsg})`);
}
} else {
if (h.alerted) log(`✓ SPACE RECOVERED: ${spaceId} (was stale; poll succeeded)`);
h.consecutiveErrors = 0; h.lastOkAt = new Date().toISOString(); h.alerted = false; h.staleSince = null; // re-arm
}
}
function staleSpaceList() {
return Object.entries(state.spaceHealth)
.filter(([, h]) => h.consecutiveErrors >= STALE_THRESHOLD)
.map(([space, h]) => ({ space, consecutiveErrors: h.consecutiveErrors, lastError: h.lastError, staleSince: h.staleSince || h.lastErrorAt }));
}
let polling = false;
function poll() {
if (polling) return; polling = true;
try {
for (const cfg of Object.values(SPACES)) {
const spaceId = cfg.id;
// New INBOUND space with no lastSeen yet -> persist lastSeen=NOW once (not now-60s), so we
// never replay backlog as inbound AND the watermark doesn't drift forward on every empty poll
// (which would silently swallow a message that arrives between polls). App-initiated spaces
// keep the ephemeral now-60s window (proven byte-for-byte behavior — never persisted).
if (cfg.inboundApp && !state.lastSeen[spaceId]) state.lastSeen[spaceId] = new Date().toISOString();
const priorLastSeen = state.lastSeen[spaceId]; // preserved to restore if the fetch is truncated (F2)
const sinceTs = priorLastSeen ? new Date(priorLastSeen).getTime() : Date.now() - 60000;
// If a backfill is in progress (a previous poll couldn't drain a >budget backlog), RESUME from
// its deep cursor instead of restarting at the newest message — this is what makes the backlog
// actually drain across polls. `resumeNewest` is the watermark to advance to once fully drained.
const bf = state.backfill[spaceId];
let msgs;
try { msgs = hooks.listMessages(spaceId, sinceTs, bf && bf.token, (name) => processedSet.has(name)); }
catch (e) { log(`poll ${spaceId} err: ${e.message}`); recordPollHealth(spaceId, e.message); continue; } // throw -> poll error (staleness)
// A page-level fetch error (.error) also counts as a poll failure for staleness; otherwise the
// fetch reached Google fine. (The .error reconciliation below still holds lastSeen / cursor.)
recordPollHealth(spaceId, msgs.error ? ((msgs.error.code ? msgs.error.code + ': ' : '') + (msgs.error.message || 'fetch error')) : null);
const fresh = msgs.filter((m) => new Date(m.createTime).getTime() > sinceTs).sort((a, b) => new Date(a.createTime) - new Date(b.createTime));
for (const m of fresh) {
state.lastSeen[spaceId] = m.createTime; // NEVER gated by inbound — keeps app-initiated replies (R5)
if (m.sender && m.sender.type && m.sender.type !== 'HUMAN') continue; // (outermost) Marv/bot posts are BOT — skipped here
if (processedSet.has(m.name)) continue; // CODE loop guard (R2/R8): never process a message twice
const threadName = m.thread && m.thread.name;
// Threadless guard: a message with no thread.name can't be keyed into requests/conversations.
// Without this, branch C would write state.conversations[undefined] (one shared slot that
// every threadless msg overwrites, then matches branch B) — corrupt + unanswerable. Skip it.
if (!threadName) { markProcessed(m.name); log(`poll ${spaceId}: skipped message ${m.name} with no thread.name`); continue; }
const req = state.requests[threadName];
const conv = state.conversations[threadName];
// (A) app-initiated reply — requests win.
if (req && req.status !== 'answered') { markProcessed(m.name); handleReply(req, m, spaceId); saveState(); continue; }
// (B) ongoing inbound conversation.
if (conv && conv.status === 'open') { markProcessed(m.name); handleInbound(conv, m, spaceId, cfg); saveState(); continue; }
// answered request / closed conversation -> ignore (but mark processed so we don't re-eval).
if (req || conv) { markProcessed(m.name); continue; }
// (C) NEW human-initiated — only in an inbound space, and only for messages AFTER process start (cold-start gate R5).
if (cfg.inboundApp && (CONFIG.apps || {})[cfg.inboundApp]) {
if (new Date(m.createTime).getTime() <= PROCESS_START) continue; // pre-start backlog -> never spawn a conversation
markProcessed(m.name);
const newConv = { conversationId: threadName, threadName, app: cfg.inboundApp, space: spaceId, gchatSpace: cfg.gchatSpace,
status: 'open', turn: 1, startedAt: new Date().toISOString(), startedBy: m.sender && m.sender.name, lastHumanAt: new Date().toISOString() };
// Only persist the conversation if it actually dispatched. A drop (allowlist / unresolved
// identity) must NOT create a conversation record (no churn for fail-closed senders).
if (handleInbound(newConv, m, spaceId, cfg)) state.conversations[threadName] = newConv;
saveState(); // persist processed (+conversation if any)+lastSeen right after -> a crash before poll-end can't replay (R4)
}
// else: non-inbound space, no tracked thread -> ignore (today's behavior, unchanged).
}
// F2 watermark + backfill bookkeeping. The per-message loop above tentatively advanced lastSeen
// to the newest message it touched; now reconcile. INVARIANT: lastSeen only advances when the
// gap above it was actually fetched+processed WITHOUT error. Precedence: error > truncated > drained.
const holdLastSeen = () => { if (priorLastSeen === undefined) delete state.lastSeen[spaceId]; else state.lastSeen[spaceId] = priorLastSeen; };
if (msgs.error) {
// A page FETCH FAILED (HTTP/API error, NOT an empty page). An undrained gap may remain below
// what we fetched, so HOLD lastSeen and DO NOT clear the cursor — retry next poll. (R-#1/#2)
holdLastSeen();
if (msgs.expiredToken) {
// The dead page was a RESUME cursor (expired/invalid). Drop the cursor but KEEP draining:
// restart from the watermark next poll (re-paginate newest->down; processedSet dedup stops
// re-dispatch of already-handled messages). Preserve `newest` so we still know where to
// advance lastSeen once the restarted drain completes.
log(`poll ${spaceId}: resume cursor expired — restarting drain from watermark next poll (dedup-protected)`);
if (bf) state.backfill[spaceId] = { token: null, newest: bf.newest };
}
// else: non-token error -> keep the existing cursor (if any) untouched and just retry.
} else if (msgs.truncated) {
// Older gap remains (page budget). HOLD lastSeen and SAVE the resume cursor + the newest
// createTime seen at the START of this backlog so later polls page DEEPER and only advance
// lastSeen once fully drained.
const newest = bf ? bf.newest : (fresh.length ? fresh[fresh.length - 1].createTime : priorLastSeen);
state.backfill[spaceId] = { token: msgs.nextPageToken, newest };
holdLastSeen();
} else if (bf) {
// Backlog fully drained this poll -> advance lastSeen to the newest message of the whole
// backlog (captured when the backfill began) and clear the cursor.
state.lastSeen[spaceId] = bf.newest;
delete state.backfill[spaceId];
}
}
pruneConversations();
saveState();
} finally { polling = false; }
}
// ---------------------------------------------------------------- http api
function json(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(obj, null, 2)); }
function readBody(req) { return new Promise((r) => { let b = ''; req.on('data', (c) => { b += c; }); req.on('end', () => r(b)); }); }
// ---- auth: per-app bearer (app->courier). /health open; /state+/reset behind adminToken.
function bearerToken(req) { return ((req.headers.authorization || '').match(/^Bearer\s+(\S+)/i) || [])[1] || ''; }
function tokenEq(a, b) { const A = Buffer.from(String(a)), B = Buffer.from(String(b)); return A.length === B.length && crypto.timingSafeEqual(A, B); }
const APP_KEYS = Object.entries(CONFIG.apps || {}).filter(([, c]) => c && c.apiKey);
const ADMIN_TOKEN = CONFIG.adminToken || '';
function authApp(req) { // {app}=valid | {open:true}=no keys set | null=bad/missing
if (!APP_KEYS.length) return { open: true };
const tok = bearerToken(req);
for (const [name, c] of APP_KEYS) if (tokenEq(tok, c.apiKey)) return { app: name };
return null;
}
const authAdmin = (req) => !ADMIN_TOKEN || tokenEq(bearerToken(req), ADMIN_TOKEN);
const server = http.createServer(async (req, res) => {
const u = new URL(req.url, `http://localhost:${PORT}`); const p = u.pathname.replace(/\/+$/, '') || '/';
// /health is open (unauth'd) -> return only liveness, no space names/counts (R12). When ≥1 space is
// stale, add a COUNT ONLY (no names) so it's externally monitorable without leaking config.
if (p === '/health') { const stale = staleSpaceList().length; return json(res, 200, stale ? { ok: true, stale } : { ok: true }); }
if (p === '/requests' && req.method === 'POST') {
let b = {}; try { b = JSON.parse(await readBody(req) || '{}'); } catch (_) {}
const auth = authApp(req);
if (!auth) return json(res, 401, { ok: false, error: 'missing or invalid bearer token' });
if (!auth.open && b.app && auth.app !== b.app) return json(res, 403, { ok: false, error: `token authenticates '${auth.app}', not '${b.app}'` });
const sc = resolveSpace(b.space); if (!sc) return json(res, 400, { ok: false, error: 'unknown space' });
const app = b.app || 'unknown'; const itemKey = b.itemKey || `item-${Date.now()}`; const text = b.text || `Request ${itemKey}`;
// requestId from the (now externally-persisted) counter; threadKey carries a time-based
// suffix so it's globally unique even across restarts/instances — never reuses a stale thread.
state.counter += 1; const requestId = `req-${state.counter}`; const threadKey = `${app}--${itemKey}--${requestId}--${Date.now().toString(36)}`;
try {
// Serialize post+bind per space (item 1): hold the lock across gchatSend -> findOwnMessage ->
// state.requests[threadName]=rec so two identical-text /requests can't bind the same Marv thread.
const result = await withLock(sc.id, () => {
let threadName; let messageName;
if (sc.gchatSpace) {
gchatSend(sc.gchatSpace, text, threadKey); // post as Marv
const found = findOwnMessage(sc.id, text); // learn thread.name (excludes already-bound threads)
if (!found) return { error: 'posted via Marv but could not locate the message to bind its thread' };
threadName = found.threadName; messageName = found.messageName;
} else {
const msg = adomGooglePost(sc.id, text, threadKey); // fallback: post as you
threadName = msg.thread.name; messageName = msg.name;
}
const rec = { requestId, app, itemKey, space: sc.id, gchatSpace: sc.gchatSpace || null, text, threadKey, threadName, messageName, status: 'open', turn: 1, postedAt: new Date().toISOString() };
state.requests[threadName] = rec; saveState(); // bind INSIDE the lock so the next post's findOwnMessage skips it
log(`posted ${app}/${itemKey} (${requestId}) via ${sc.gchatSpace ? 'Marv' : 'adom-google'} -> ${threadName}`);
return { threadName };
});
if (result.error) return json(res, 200, { ok: false, error: result.error });
return json(res, 200, { ok: true, requestId, threadName: result.threadName, threadKey, via: sc.gchatSpace ? 'marv' : 'adom-google' });
} catch (e) { return json(res, 200, { ok: false, error: e.message }); }
}
if (p === '/reply' && req.method === 'POST') {
let b = {}; try { b = JSON.parse(await readBody(req) || '{}'); } catch (_) {}
const auth = authApp(req);
if (!auth) return json(res, 401, { ok: false, error: 'missing or invalid bearer token' });
// Resolve target: a tracked request (app-initiated, kind='request') OR a conversation
// (human-initiated, kind='conversation'). Conversations REQUIRE threadName (no requestId scheme).
let kind = 'request';
let rec = b.threadName ? state.requests[b.threadName] : Object.values(state.requests).find((r) => r.requestId === b.requestId);
if (!rec && b.threadName && state.conversations[b.threadName]) { rec = state.conversations[b.threadName]; kind = 'conversation'; }
if (!rec) return json(res, 400, { ok: false, error: 'no such request or conversation (pass requestId, or threadName)' });
if (!auth.open && auth.app !== rec.app) return json(res, 403, { ok: false, error: `token authenticates '${auth.app}', not '${rec.app}'` });
if (!b.text) return json(res, 400, { ok: false, error: 'text required' });
try {
if (kind === 'conversation') {
// Inbound reply: Marv lands as BOT via the raw webhook (REQUIRES gchatSpace — guaranteed
// by validateInboundSpaces). NEVER the adom-google fallback (would post as HUMAN → loop).
if (!rec.gchatSpace) return json(res, 200, { ok: false, error: 'conversation has no gchatSpace (inbound reply impossible)' });
// DELIVERY CONTRACT (best-effort, lossy, NO retry/held-queue — documented): if the webhook
// POST fails, the human never sees this reply. We log it LOUDLY, leave the conversation OPEN
// (no close applied, turn unchanged) so the app can retry by calling /reply again, and return
// {ok:false, delivered:false, error} so the app KNOWS delivery failed (not just an HTTP blip).
try {
await gchatReplyByThreadName(rec.gchatSpace, b.text, rec.threadName);
} catch (we) {
log(`✗✗ INBOUND REPLY UNDELIVERED ${rec.app}/${rec.conversationId}: ${we.message} — conversation left OPEN, app must retry`);
return json(res, 200, { ok: false, delivered: false, conversationId: rec.conversationId, error: `webhook reply failed: ${we.message}` });
}
if (b.close) { rec.status = 'closed'; rec.closedAt = new Date().toISOString(); }
saveState(); log(`app reply (inbound) -> ${rec.app}/${rec.conversationId}${b.close ? ' [closed]' : ''}`);
return json(res, 200, { ok: true, delivered: true, conversationId: rec.conversationId, turn: rec.turn, closed: !!b.close });
}
// Request (app-initiated) path — unchanged byte-for-byte from 0.1.4.
replyInThread(rec, b.text);
if (b.expectReply) { rec.status = 'open'; rec.turn = (rec.turn || 1) + 1; rec.reopenedAt = new Date().toISOString(); }
saveState(); log(`app reply -> ${rec.app}/${rec.itemKey}${b.expectReply ? ' (awaiting turn ' + rec.turn + ')' : ''}`);
return json(res, 200, { ok: true, awaitingReply: !!b.expectReply, turn: rec.turn });
} catch (e) { return json(res, 200, { ok: false, error: e.message }); }
}
if (p === '/state' && req.method === 'GET') {
if (!authAdmin(req)) return json(res, 401, { ok: false, error: 'admin token required' });
return json(res, 200, { ok: true, openRequests: Object.values(state.requests).filter((r) => r.status === 'open'), answered: Object.values(state.requests).filter((r) => r.status === 'answered').length,
conversations: Object.values(state.conversations).map((c) => ({ conversationId: c.conversationId, app: c.app, space: c.space, status: c.status, turn: c.turn, startedBy: c.startedBy, responder: c.responder, startedAt: c.startedAt, lastHumanAt: c.lastHumanAt })),
openConversations: Object.values(state.conversations).filter((c) => c.status === 'open').length,
recentResponses: state.responses.slice(0, 10).map((e) => ({ type: e.type, app: e.app, itemKey: e.itemKey, conversationId: e.conversationId, turn: e.turn, responder: e.responder, text: e.text, attachments: (e.attachments || []).map((a) => ({ name: a.name, contentType: a.contentType, size: a.size, path: a.path, error: a.error })), delivered: e.delivered, deliveryError: e.deliveryError, held: e.held, at: e.at })),
undelivered: state.responses.filter((e) => e.delivered === false).length,
staleSpaces: staleSpaceList(), identityCacheSize: Object.keys(state.identity).length, processedSize: processedSet.size });
}
if (p === '/reset' && req.method === 'POST') { if (!authAdmin(req)) return json(res, 401, { ok: false, error: 'admin token required' }); state = freshState(); processedSet.clear(); _attDirBytes = null; saveState(); return json(res, 200, { ok: true }); }
res.writeHead(404); res.end('not found');
});
validateInboundSpaces();
// When required as a module (unit tests), DON'T bind the port or start polling — just expose the
// internals so a test can stub listMessages/dispatch and drive poll()/handlers directly. Production
// always runs the file directly (require.main === module), so its behavior is untouched.
if (require.main !== module) {
// Test surface. `hooks` lets a unit test substitute the three I/O boundaries (Chat read,
// identity resolve, attachment download, app dispatch) so poll()/handlers run for real against
// canned messages. These are the SAME functions the poll loop calls (declared with `var` rebind
// shims below), so overriding them genuinely intercepts the real code path.
module.exports = {
state, SPACES, CONFIG, processedSet, markProcessed, poll, handleReply, handleInbound,
pruneConversations, validateInboundSpaces, collectAttachments, inboundSenderAllowed,
gchatReplyByThreadName, webhookUrlFor, server, json, PROCESS_START,
downloadAttachment, listMessages, resolveUser, MAX_ATT_BYTES, MEDIA_MAX_BUFFER, MAX_POLL_PAGES, IDENTITY_NEG_TTL_MS,
recordPollHealth, staleSpaceList, STALE_THRESHOLD, withLock, capIdentityNeg, dispatch, MAX_IDENTITY_NEG,
hooks,
};
} else {
server.listen(PORT, HOST, () => {
log(`adom-courier listening on ${HOST}:${PORT}`);
log(` config: ${CONFIG_PATH}${CONFIG_PATH.startsWith(DIR) ? ' (bundled template — no external config found)' : ' (external)'} | data: ${DATA_DIR}`);
log(` auth: /requests+/reply ${APP_KEYS.length ? 'require bearer (' + APP_KEYS.length + ' app key[s])' : 'OPEN — no apiKeys set ⚠'} | /state+/reset ${ADMIN_TOKEN ? 'require adminToken' : 'OPEN ⚠'} | /health open`);
for (const [alias, c] of Object.entries(SPACES)) {
const inbound = c.inboundApp ? `inbound -> ${c.inboundApp}${Array.isArray(c.inboundAllowlist) && c.inboundAllowlist.length ? ' (allowlist: ' + c.inboundAllowlist.length + ')' : ' (any member)'}` : 'inbound OFF';
log(` space ${alias}: ${c.id} | post via ${c.gchatSpace ? 'Marv (adom-gchat "' + c.gchatSpace + '")' : 'adom-google (as you)'} | ${inbound}`);
}
poll(); setInterval(poll, CONFIG.pollIntervalMs);
});
}