Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
218 lines
8.9 KiB
JavaScript
218 lines
8.9 KiB
JavaScript
/**
|
|
* Bluesky (AT Protocol) Tool Handler — amplifier-graph social-listening.
|
|
*
|
|
* Handles: bluesky_search
|
|
*
|
|
* Part of the "Austin recon" kosher social-listening stack (see
|
|
* /root/austin-recon/README.md + HANDOFF-ralph.md). Purpose is dual-use —
|
|
* research / integrity / due-diligence: discover *pains* and map *who amplifies
|
|
* them* from PUBLIC posts only. ZERO surveillance, zero person-deanon (no
|
|
* face/geo/IP/breach/username-enumeration — that whole half is cut at the gate).
|
|
*
|
|
* Why authenticated, not the public AppView:
|
|
* This box's datacenter IP is reputation-flagged — unauthenticated
|
|
* `public.api.bsky.app` search returns 403. The PDS (`bsky.social`) is
|
|
* reachable (200), so we authenticate with an app-password via
|
|
* `com.atproto.server.createSession` → bearer, then call
|
|
* `app.bsky.feed.searchPosts` on the AppView with that token.
|
|
*
|
|
* Credentials live in the `_secrets` vault (ADR-0040):
|
|
* `bluesky_handle` / `bluesky_app_password`. The app-password is revocable and
|
|
* cannot change the account password/email or delete the account — if it
|
|
* leaks, blast radius is read-only public search.
|
|
*
|
|
* Amplifier signal returned per post: likeCount / repostCount / replyCount /
|
|
* quoteCount; graph node = author.handle.
|
|
*/
|
|
|
|
import { aiLogger } from '../../utils/logger.js';
|
|
import { getSecret } from '../secrets/getSecret.js';
|
|
|
|
// PDS — issues the session (app-password auth). Reachable from this box.
|
|
const PDS_BASE = 'https://bsky.social';
|
|
// AppView — serves the search index. `api.bsky.app` accepts the PDS bearer;
|
|
// `public.api.bsky.app` (unauth) is 403 from this IP, so we never use it.
|
|
const APPVIEW_BASE = 'https://api.bsky.app';
|
|
|
|
// accessJwt is valid ~2h; re-auth defensively well before that. A 401 also
|
|
// forces a re-auth regardless (see bluesky_search), so this is just a ceiling.
|
|
const SESSION_TTL_MS = 90 * 60 * 1000;
|
|
|
|
// In-module session cache (per worker process). createSession with an
|
|
// app-password is cheap and unmetered, so we don't bother with refreshJwt.
|
|
let sessionCache = null; // { accessJwt, did, handle, createdAt }
|
|
|
|
const log = aiLogger.child ? aiLogger.child({ module: 'bluesky_tools' }) : aiLogger;
|
|
|
|
/** Resolve creds: vault first (ADR-0040), env fallback during transition. */
|
|
async function getBlueskyCreds() {
|
|
const handle = await getSecret('bluesky_handle', 'BLUESKY_HANDLE');
|
|
const appPassword = await getSecret('bluesky_app_password', 'BLUESKY_APP_PASSWORD');
|
|
return { handle, appPassword };
|
|
}
|
|
|
|
/** POST com.atproto.server.createSession → { accessJwt, did, handle }. */
|
|
async function createSession(handle, appPassword) {
|
|
const ctrl = new AbortController();
|
|
const timer = setTimeout(() => ctrl.abort(), 15000);
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${PDS_BASE}/xrpc/com.atproto.server.createSession`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({ identifier: handle, password: appPassword }),
|
|
signal: ctrl.signal,
|
|
});
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
if (!resp.ok) {
|
|
const body = await resp.text().catch(() => '');
|
|
// Most common real failure: AuthFactorTokenRequired / Invalid identifier or
|
|
// password (wrong app-password, or main password used by mistake).
|
|
throw new Error(`createSession HTTP ${resp.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
|
|
}
|
|
const data = await resp.json();
|
|
if (!data?.accessJwt) throw new Error('createSession returned no accessJwt');
|
|
return { accessJwt: data.accessJwt, did: data.did, handle: data.handle, createdAt: Date.now() };
|
|
}
|
|
|
|
/**
|
|
* Get a live session, reusing the cache unless `force` or TTL-expired.
|
|
* Throws a coded error (BLUESKY_NO_CREDS) when the vault/env has no creds so the
|
|
* handler can surface a clean "not configured" message instead of a 400 dump.
|
|
*/
|
|
async function getSession({ force = false } = {}) {
|
|
if (!force && sessionCache && Date.now() - sessionCache.createdAt < SESSION_TTL_MS) {
|
|
return sessionCache;
|
|
}
|
|
const { handle, appPassword } = await getBlueskyCreds();
|
|
if (!handle || !appPassword) {
|
|
const err = new Error('Bluesky credentials not configured');
|
|
err.code = 'BLUESKY_NO_CREDS';
|
|
throw err;
|
|
}
|
|
sessionCache = await createSession(handle, appPassword);
|
|
log.info({ handle: sessionCache.handle }, 'bluesky: session established');
|
|
return sessionCache;
|
|
}
|
|
|
|
/** GET app.bsky.feed.searchPosts on the AppView. Returns the raw fetch Response. */
|
|
async function searchPostsRaw(query, { limit, sort, since, until }, accessJwt) {
|
|
const params = new URLSearchParams({
|
|
q: query,
|
|
limit: String(Math.min(Math.max(Number(limit) || 25, 1), 100)),
|
|
});
|
|
if (sort) params.set('sort', sort); // 'top' | 'latest'
|
|
if (since) params.set('since', since); // ISO8601 (indexedAt lower bound)
|
|
if (until) params.set('until', until);
|
|
|
|
const ctrl = new AbortController();
|
|
const timer = setTimeout(() => ctrl.abort(), 20000);
|
|
try {
|
|
return await fetch(`${APPVIEW_BASE}/xrpc/app.bsky.feed.searchPosts?${params.toString()}`, {
|
|
headers: { Authorization: `Bearer ${accessJwt}`, Accept: 'application/json' },
|
|
signal: ctrl.signal,
|
|
});
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/** at://did/app.bsky.feed.post/<rkey> → a clickable bsky.app permalink. */
|
|
function toBskyAppUrl(post) {
|
|
const handle = post?.author?.handle;
|
|
const m = String(post?.uri || '').match(/\/app\.bsky\.feed\.post\/([^/]+)$/);
|
|
if (handle && m) return `https://bsky.app/profile/${handle}/post/${m[1]}`;
|
|
return null;
|
|
}
|
|
|
|
/** Shape one searchPosts hit into { pain text, amplifier metrics, graph node }. */
|
|
function normalizePost(post, i) {
|
|
const rec = post?.record || {};
|
|
return {
|
|
index: i + 1,
|
|
uri: post?.uri || null,
|
|
text: String(rec.text || '').trim(), // pain
|
|
created_at: rec.createdAt || post?.indexedAt || null,
|
|
author: {
|
|
handle: post?.author?.handle || null, // amplifier graph node
|
|
display_name: post?.author?.displayName || null,
|
|
did: post?.author?.did || null,
|
|
},
|
|
amplifier: {
|
|
likes: post?.likeCount ?? 0,
|
|
reposts: post?.repostCount ?? 0,
|
|
replies: post?.replyCount ?? 0,
|
|
quotes: post?.quoteCount ?? 0,
|
|
},
|
|
url: toBskyAppUrl(post),
|
|
...(Array.isArray(rec?.langs) ? { langs: rec.langs } : {}),
|
|
};
|
|
}
|
|
|
|
export const blueskyToolHandlers = {
|
|
/**
|
|
* Search PUBLIC Bluesky posts and return pain text + amplifier metrics +
|
|
* author graph node. Sort 'top' ranks by engagement (best for "who amplifies
|
|
* this"); 'latest' is recency-first.
|
|
*/
|
|
async bluesky_search({ query, limit = 25, sort = 'top', since, until }, userId, context = {}) {
|
|
if (!query || !String(query).trim()) {
|
|
return { error: 'bluesky_search requires a non-empty `query`.' };
|
|
}
|
|
const normSort = sort === 'latest' ? 'latest' : 'top';
|
|
try {
|
|
let session = await getSession();
|
|
let resp = await searchPostsRaw(query, { limit, sort: normSort, since, until }, session.accessJwt);
|
|
|
|
// accessJwt may have expired (ExpiredToken) — re-auth once and retry.
|
|
if (resp.status === 401) {
|
|
log.info('bluesky_search: 401 — re-authenticating and retrying');
|
|
session = await getSession({ force: true });
|
|
resp = await searchPostsRaw(query, { limit, sort: normSort, since, until }, session.accessJwt);
|
|
}
|
|
|
|
if (!resp.ok) {
|
|
const body = await resp.text().catch(() => '');
|
|
throw new Error(`searchPosts HTTP ${resp.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
|
|
}
|
|
|
|
const data = await resp.json();
|
|
const posts = Array.isArray(data?.posts) ? data.posts : [];
|
|
const results = posts.map((p, i) => normalizePost(p, i));
|
|
|
|
if (!results.length) {
|
|
return { success: true, query, provider: 'bluesky', sort: normSort, results_count: 0, results: [], message: 'No public posts matched this query.' };
|
|
}
|
|
return {
|
|
success: true,
|
|
query,
|
|
provider: 'bluesky',
|
|
sort: normSort,
|
|
results_count: results.length,
|
|
results,
|
|
...(data.cursor ? { cursor: data.cursor } : {}),
|
|
};
|
|
} catch (err) {
|
|
if (err?.code === 'BLUESKY_NO_CREDS') {
|
|
return {
|
|
error: 'Bluesky amplifier graph is not configured.',
|
|
hint: 'Seed `bluesky_handle` + `bluesky_app_password` into the _secrets vault (ADR-0040), e.g. `node backend/scripts/seed-bluesky-secrets.mjs <handle> <app-password>`, then retry.',
|
|
};
|
|
}
|
|
log.error({ err: err.message, query }, 'bluesky_search failed');
|
|
return {
|
|
error: `Bluesky search failed: ${err.message}`,
|
|
hint: 'If this is a 403, the box IP may be hard-blocked on the unauth AppView — this tool authenticates via the PDS to avoid that. A 400 on createSession usually means a wrong/expired app-password (rotate in Bluesky → Settings → App Passwords, reseed the vault).',
|
|
};
|
|
}
|
|
},
|
|
};
|
|
|
|
// Test-only: reset the in-module session cache between cases.
|
|
export function __resetBlueskySessionForTests() {
|
|
sessionCache = null;
|
|
}
|
|
|
|
export default blueskyToolHandlers;
|