diff --git a/src/sense/social/drafts.test.ts b/src/sense/social/drafts.test.ts index 3240fa7..4f888db 100644 --- a/src/sense/social/drafts.test.ts +++ b/src/sense/social/drafts.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, test } from "node:test"; import { approveSocialDraft, + cancelSocialDraft, canonicalJson, claimApprovedSocialDraft, createSocialDraft, @@ -113,5 +114,14 @@ describe("social drafts", () => { claimApprovedSocialDraft(draft.id, digest, 3100), /approval expired/, ); + assert.equal((await listSocialDrafts())[0]?.state, "expired"); + }); + + test("cancelling clears a pending approval", async () => { + const draft = await createSocialDraft(input(), 1000); + await requestSocialDraftApproval(draft.id, 1, 2000); + const cancelled = await cancelSocialDraft(draft.id, 3000); + assert.equal(cancelled.state, "cancelled"); + assert.equal(cancelled.approval, undefined); }); }); diff --git a/src/sense/social/drafts.ts b/src/sense/social/drafts.ts index 7abd47a..82bf949 100644 --- a/src/sense/social/drafts.ts +++ b/src/sense/social/drafts.ts @@ -293,7 +293,7 @@ export async function claimApprovedSocialDraft( expectedDigest: string, now: number = Date.now(), ): Promise { - return mutate((store) => { + const result = await mutate((store) => { const draft = requireDraft(store, id); if (draft.state !== "approved" || !draft.approval) { throw new Error(`social draft in state "${draft.state}" is not publishable`); @@ -301,7 +301,8 @@ export async function claimApprovedSocialDraft( if (Date.parse(draft.approval.expiresAt) <= now) { draft.state = "expired"; draft.updatedAt = new Date(now).toISOString(); - throw new Error("social draft approval expired"); + draft.events.push({ at: draft.updatedAt, kind: "outcome", detail: "approval-expired" }); + return { expired: true as const }; } const actual = socialDraftDigest(draft); if (actual !== expectedDigest || draft.approval.digest !== expectedDigest) { @@ -312,6 +313,26 @@ export async function claimApprovedSocialDraft( draft.approval.claimedAt = at; draft.updatedAt = at; draft.events.push({ at, kind: "claimed", detail: actual.slice(0, 12) }); + return { expired: false as const, draft: clone(draft) }; + }); + if (result.expired) throw new Error("social draft approval expired"); + return result.draft; +} + +export async function cancelSocialDraft( + id: string, + now: number = Date.now(), +): Promise { + return mutate((store) => { + const draft = requireDraft(store, id); + if (["publishing", "published", "partial"].includes(draft.state)) { + throw new Error(`social draft in state "${draft.state}" cannot be cancelled`); + } + const at = new Date(now).toISOString(); + draft.state = "cancelled"; + draft.approval = undefined; + draft.updatedAt = at; + draft.events.push({ at, kind: "cancelled" }); return clone(draft); }); } diff --git a/src/sense/social/tool.test.ts b/src/sense/social/tool.test.ts new file mode 100644 index 0000000..1d43790 --- /dev/null +++ b/src/sense/social/tool.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, test } from "node:test"; +import { socialComposeTool } from "./tool.js"; + +let home: string; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.LISA_HOME; + home = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-social-tool-")); + process.env.LISA_HOME = home; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.LISA_HOME; + else process.env.LISA_HOME = previousHome; + fs.rmSync(home, { recursive: true, force: true }); +}); + +async function call(input: unknown): Promise { + const text = await socialComposeTool.execute( + input as never, + {} as never, + ); + return JSON.parse(text); +} + +describe("social_compose tool", () => { + test("creates, updates, and requests confirmation without a publish action", async () => { + const created = (await call({ + action: "new_draft", + draft: { + targets: [ + { + connectorId: "bluesky-official", + accountId: "alice", + platform: "bluesky", + }, + ], + canonical: { text: "hello", media: [] }, + }, + })) as { id: string; revision: number }; + assert.equal(created.revision, 1); + + const updated = (await call({ + action: "update_draft", + id: created.id, + expectedRevision: 1, + patch: { canonical: { text: "hello world", media: [] } }, + })) as { revision: number }; + assert.equal(updated.revision, 2); + + const preview = (await call({ + action: "request_confirmation", + id: created.id, + expectedRevision: 2, + })) as { approvalDigest: string; instruction: string }; + assert.equal(preview.approvalDigest.length, 64); + assert.match(preview.instruction, /Publishing remains blocked/); + assert.doesNotMatch( + JSON.stringify(socialComposeTool.inputSchema), + /"publish"/, + ); + }); +}); diff --git a/src/sense/social/tool.ts b/src/sense/social/tool.ts new file mode 100644 index 0000000..0fd2b57 --- /dev/null +++ b/src/sense/social/tool.ts @@ -0,0 +1,148 @@ +import type { ToolDefinition } from "../../types.js"; +import { + createSocialDraft, + getSocialDraft, + listSocialDrafts, + requestSocialDraftApproval, + updateSocialDraft, +} from "./drafts.js"; +import { discoverSocialConnectors } from "./manifest.js"; +import type { + NewSocialDraft, + SocialDraftPatch, +} from "./types.js"; + +interface SocialComposeInput { + action: + | "connectors" + | "drafts" + | "view" + | "new_draft" + | "update_draft" + | "request_confirmation"; + id?: string; + expectedRevision?: number; + draft?: NewSocialDraft; + patch?: SocialDraftPatch; +} + +/** + * Model-visible social surface. Deliberately no approve/publish actions: + * confirmation is a local UI/CLI action and publishing is a deterministic host + * runner, never a capability handed to the model. + */ +export const socialComposeTool: ToolDefinition = { + name: "social_compose", + description: + "Prepare social-media posts through LISA's host-enforced draft workflow. " + + "Can list connectors/drafts, create or update a draft, inspect it, and request a confirmation preview. " + + "It CANNOT approve or publish: the user must confirm the immutable preview in a trusted local UI.", + annotations: { + title: "Social post composer", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: { + type: "object", + properties: { + action: { + type: "string", + enum: [ + "connectors", + "drafts", + "view", + "new_draft", + "update_draft", + "request_confirmation", + ], + }, + id: { type: "string" }, + expectedRevision: { type: "number" }, + draft: { type: "object" }, + patch: { type: "object" }, + }, + required: ["action"], + additionalProperties: false, + }, + async execute(input) { + switch (input.action) { + case "connectors": { + const connectors = await discoverSocialConnectors(); + return JSON.stringify( + connectors.map((item) => + item.manifest + ? { + id: item.manifest.id, + displayName: item.manifest.displayName, + platform: item.manifest.platform, + skill: item.manifest.skill, + available: true, + } + : { + plugin: item.plugin, + available: false, + error: item.error, + }, + ), + ); + } + case "drafts": { + const drafts = await listSocialDrafts(); + return JSON.stringify( + drafts.map(({ id, revision, state, targets, updatedAt }) => ({ + id, + revision, + state, + targets, + updatedAt, + })), + ); + } + case "view": { + if (!input.id) throw new Error("social_compose view requires id"); + const draft = await getSocialDraft(input.id); + if (!draft) throw new Error(`social draft "${input.id}" not found`); + return JSON.stringify(draft); + } + case "new_draft": { + if (!input.draft) { + throw new Error("social_compose new_draft requires draft"); + } + return JSON.stringify(await createSocialDraft(input.draft)); + } + case "update_draft": { + if (!input.id || input.expectedRevision == null || !input.patch) { + throw new Error( + "social_compose update_draft requires id, expectedRevision, and patch", + ); + } + return JSON.stringify( + await updateSocialDraft( + input.id, + input.expectedRevision, + input.patch, + ), + ); + } + case "request_confirmation": { + if (!input.id || input.expectedRevision == null) { + throw new Error( + "social_compose request_confirmation requires id and expectedRevision", + ); + } + const result = await requestSocialDraftApproval( + input.id, + input.expectedRevision, + ); + return JSON.stringify({ + draft: result.draft, + approvalDigest: result.digest, + instruction: + "Show this exact preview to the user. Publishing remains blocked until they approve it in the trusted local Sense UI.", + }); + } + } + }, +}; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 08cbcb3..4ac82a8 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -46,6 +46,7 @@ import { webSearchTool } from "./web_search.js"; import { takoapiTool } from "./takoapi.js"; import { writeTool } from "./write.js"; import { kbTools, restrictKbIngestToWatchlist } from "../kb/tool.js"; +import { socialComposeTool } from "../sense/social/tool.js"; export interface ToolRegistryOptions { includeVoice?: boolean; @@ -102,6 +103,7 @@ export function buildToolRegistry(opts: ToolRegistryOptions = {}): ToolDefinitio githubTool as ToolDefinition, npmInfoTool as ToolDefinition, mcpTool as ToolDefinition, + socialComposeTool as ToolDefinition, signalAgentTool as ToolDefinition, agentRecapTool as ToolDefinition, // Personal knowledge base (docs/PLAN_KNOWLEDGE_BASE_v1.0.md): @@ -171,6 +173,9 @@ export const AUTONOMOUS_BLOCKED_TOOL_NAMES = new Set([ "run_checks", "github", "mcp", + // Social drafts must originate in an attended user conversation. Publishing + // is never model-visible, but unattended draft spam is still undesirable. + "social_compose", // takoapi calls spend the user's TAKO_KEY and send data to a remote agent — // not something an unattended/remote-origin run should do on its own. "takoapi", diff --git a/src/web/lisa-client.ts b/src/web/lisa-client.ts index 8fd4113..95450d9 100644 --- a/src/web/lisa-client.ts +++ b/src/web/lisa-client.ts @@ -2454,10 +2454,58 @@ if ('serviceWorker' in navigator) { function renderSense() { var scroll = document.getElementById('senseScroll'); if (!scroll) return; - Promise.all([getJSON('/api/consent'), getJSON('/api/sense/recent')]).then(function (res) { + Promise.all([ + getJSON('/api/consent'), + getJSON('/api/sense/recent'), + getJSON('/api/sense/social/connectors'), + getJSON('/api/sense/social/drafts') + ]).then(function (res) { var grants = (res[0] && res[0].grants) || []; var events = (res[1] && res[1].events) || []; - var html = '
Consent
'; + var connectors = (res[2] && res[2].connectors) || []; + var drafts = (res[3] && res[3].drafts) || []; + var html = '
Connected media
'; + if (!connectors.length) { + html += '
No social connector installed. Ask Lisa to help connect a supported account.
'; + } + connectors.forEach(function (item) { + var c = item.manifest; + html += '
' + + esc(c ? c.displayName : item.plugin) + '
' + + esc(c ? c.platform + ' · ready to link' : item.error || 'unavailable') + + '
'; + }); + html += '
Post drafts
'; + if (!drafts.length) { + html += '
No drafts yet. Tell Lisa what you want to publish.
'; + } + drafts.slice().reverse().slice(0, 20).forEach(function (d) { + var targetLabel = (d.targets || []).map(function (t) { return t.platform + ' · ' + t.accountId; }).join(', '); + var body = (d.canonical && (d.canonical.text || d.canonical.title || d.canonical.link)) || ''; + var mediaCount = (d.canonical && d.canonical.media && d.canonical.media.length) || 0; + html += ''; + }); + html += '
Consent
'; if (!grants.length) html += '
No signals configured.
'; grants.forEach(function (g) { html += '
' + esc(g.signal) + '
' + @@ -2481,6 +2529,34 @@ if ('serviceWorker' in navigator) { .then(function () { renderSense(); }).catch(function () {}); }); } + var approveButtons = scroll.querySelectorAll('[data-social-approve]'); + for (var a = 0; a < approveButtons.length; a++) { + approveButtons[a].addEventListener('click', function () { + var id = this.getAttribute('data-social-approve'); + var digest = this.getAttribute('data-social-digest'); + this.disabled = true; + fetch('/api/sense/social/drafts/' + encodeURIComponent(id) + '/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ digest: digest }) + }).then(function (r) { + if (!r.ok) throw new Error('approval failed'); + renderSense(); + }).catch(function () { renderSense(); }); + }); + } + var cancelButtons = scroll.querySelectorAll('[data-social-cancel]'); + for (var c = 0; c < cancelButtons.length; c++) { + cancelButtons[c].addEventListener('click', function () { + var id = this.getAttribute('data-social-cancel'); + this.disabled = true; + fetch('/api/sense/social/drafts/' + encodeURIComponent(id) + '/cancel', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}' + }).then(function () { renderSense(); }).catch(function () { renderSense(); }); + }); + } }); } diff --git a/src/web/lisa-css.ts b/src/web/lisa-css.ts index 6bd44ca..3a92108 100644 --- a/src/web/lisa-css.ts +++ b/src/web/lisa-css.ts @@ -975,6 +975,67 @@ export const MAIN_CSS = ` :root { flex-shrink: 0; } .v-toggle.on { color: var(--proactive); border-color: var(--proactive-glow); background: var(--proactive-soft); } + .social-draft { + padding: 11px 0; + border-top: 1px solid var(--border-new); + } + .social-draft:first-child { border-top: 0; } + .social-draft-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + } + .social-state { + display: inline-flex; + align-items: center; + border: 1px solid var(--border-new); + border-radius: 999px; + padding: 2px 7px; + color: var(--fg-3); + background: var(--bg-3); + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + .social-state.ready, + .social-state.approved, + .social-state.published { color: var(--proactive); border-color: var(--proactive-glow); background: var(--proactive-soft); } + .social-state.awaiting-approval, + .social-state.publishing { color: var(--warm); } + .social-state.failed, + .social-state.partial, + .social-state.expired { color: var(--err-color); } + .social-preview-note { + margin: 8px 0; + padding: 8px 9px; + border-left: 2px solid var(--warm); + background: var(--bg-3); + color: var(--fg-2); + font-size: 11px; + line-height: 1.45; + } + .social-approval-snapshot { + max-height: 220px; + overflow: auto; + padding: 9px; + border: 1px solid var(--border-new); + border-radius: 7px; + background: var(--bg-3); + } + .social-action { + margin: 9px 7px 0 0; + padding: 5px 9px; + border: 1px solid var(--border-new); + border-radius: 7px; + background: var(--bg-3); + color: var(--fg-2); + font: 600 11px inherit; + cursor: pointer; + } + .social-action.primary { color: var(--proactive); border-color: var(--proactive-glow); background: var(--proactive-soft); } + .social-action:disabled { opacity: 0.5; cursor: wait; } .v-sel { font-family: inherit; font-size: 12px; diff --git a/src/web/lisa-html-snapshot.test.ts b/src/web/lisa-html-snapshot.test.ts index bd66618..11f4208 100644 --- a/src/web/lisa-html-snapshot.test.ts +++ b/src/web/lisa-html-snapshot.test.ts @@ -83,10 +83,12 @@ import { MAIN_HTML } from "./lisa-html.js"; * POST /api/kb/ingest and opening the saved entry; a 存入知识库 chip * (maybeOfferKbIngest) under chat bubbles whose message contains a bare URL; * window.lisaKbToast shared from the capture block; and their CSS. + * Then: Sense social publishing host — discovered connector status, post-draft + * list, immutable approval snapshot, local approve, and cancel controls. */ -const EXPECTED_LENGTH = 224728; +const EXPECTED_LENGTH = 230791; const EXPECTED_SHA256 = - "95356791a521378fe62c76bb7ad393f76f8332b2d0eaf281257e2c9912c47d76"; + "2bdb0aa1a787d29a81c28e6a1f6bc7b27803d6a14cf92fc356fe5c5bc8526aa4"; test("MAIN_HTML length is byte-identical to the pre-split snapshot", () => { assert.equal(MAIN_HTML.length, EXPECTED_LENGTH); diff --git a/src/web/server.ts b/src/web/server.ts index a797d0b..b4e35ca 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -152,6 +152,7 @@ import { SenseService } from "../sense/service.js"; import { ScreenSource } from "../sense/screen.js"; import { VoiceSource } from "../sense/voice.js"; import { appendSenseEvent, readSenseEvents } from "../sense/log.js"; +import { handleSocialApi } from "./social-api.js"; import { loadScreenAdvisorConfig, saveScreenAdvisorConfig, @@ -1915,6 +1916,14 @@ export async function startWebServer(opts: WebServerOptions): Promise { + previousHome = process.env.LISA_HOME; + home = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-social-api-")); + process.env.LISA_HOME = home; + server = http.createServer((req, res) => { + void handleSocialApi(req, res, req.url ?? "/", { + allowApproval: req.headers["x-local-owner"] === "1", + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert.ok(address && typeof address === "object"); + origin = `http://127.0.0.1:${address.port}`; +}); + +afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + if (previousHome === undefined) delete process.env.LISA_HOME; + else process.env.LISA_HOME = previousHome; + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe("social API", () => { + test("draft preview can be prepared remotely but approval needs a trusted caller", async () => { + const createdResponse = await fetch(`${origin}/api/sense/social/drafts`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + targets: [ + { + connectorId: "mastodon-official", + accountId: "alice@example.social", + platform: "mastodon", + }, + ], + canonical: { text: "hello", media: [] }, + }), + }); + assert.equal(createdResponse.status, 201); + const created = (await createdResponse.json()) as { + draft: { id: string }; + }; + + const previewResponse = await fetch( + `${origin}/api/sense/social/drafts/${created.draft.id}/request-approval`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ expectedRevision: 1 }), + }, + ); + const preview = (await previewResponse.json()) as { + approvalDigest: string; + }; + assert.equal(preview.approvalDigest.length, 64); + + const denied = await fetch( + `${origin}/api/sense/social/drafts/${created.draft.id}/approve`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ digest: preview.approvalDigest }), + }, + ); + assert.equal(denied.status, 403); + + const listedResponse = await fetch( + `${origin}/api/sense/social/drafts`, + ); + const listed = (await listedResponse.json()) as { + drafts: Array<{ approvalDigest?: string }>; + }; + assert.equal(listed.drafts[0]?.approvalDigest, preview.approvalDigest); + + const approved = await fetch( + `${origin}/api/sense/social/drafts/${created.draft.id}/approve`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-local-owner": "1", + }, + body: JSON.stringify({ digest: preview.approvalDigest }), + }, + ); + assert.equal(approved.status, 200); + }); +}); diff --git a/src/web/social-api.ts b/src/web/social-api.ts new file mode 100644 index 0000000..99019cc --- /dev/null +++ b/src/web/social-api.ts @@ -0,0 +1,176 @@ +import type http from "node:http"; +import { readCappedText, CTRL_BODY_LIMIT } from "./http-body.js"; +import { + approveSocialDraft, + cancelSocialDraft, + createSocialDraft, + getSocialDraft, + listSocialDrafts, + requestSocialDraftApproval, + socialDraftDigest, + updateSocialDraft, +} from "../sense/social/drafts.js"; +import { discoverSocialConnectors } from "../sense/social/manifest.js"; +import type { + NewSocialDraft, + SocialDraftPatch, +} from "../sense/social/types.js"; + +export interface SocialApiOptions { + /** True only for loopback or an authenticated per-user cloud session. */ + allowApproval: boolean; +} + +function json( + res: http.ServerResponse, + status: number, + value: unknown, +): void { + res.writeHead(status, { + "content-type": "application/json", + "cache-control": "no-store", + }); + res.end(JSON.stringify(value)); +} + +async function bodyObject( + req: http.IncomingMessage, +): Promise> { + const raw = await readCappedText(req, CTRL_BODY_LIMIT); + const parsed = JSON.parse(raw || "{}") as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("JSON body must be an object"); + } + return parsed as Record; +} + +/** + * Handle authenticated `/api/sense/social/*` routes. Returns false when the + * route is outside this domain. There is intentionally no publish endpoint: + * a later deterministic runner consumes an approved snapshot directly. + */ +export async function handleSocialApi( + req: http.IncomingMessage, + res: http.ServerResponse, + rawUrl: string, + opts: SocialApiOptions, +): Promise { + const pathname = new URL(rawUrl, "http://127.0.0.1").pathname; + if (!pathname.startsWith("/api/sense/social/")) return false; + + try { + if (req.method === "GET" && pathname === "/api/sense/social/connectors") { + const connectors = await discoverSocialConnectors(); + json(res, 200, { connectors }); + return true; + } + if (req.method === "GET" && pathname === "/api/sense/social/drafts") { + const drafts = await listSocialDrafts(); + json(res, 200, { + drafts: drafts.map((draft) => ({ + ...draft, + approvalDigest: + draft.state === "awaiting-approval" + ? socialDraftDigest(draft) + : undefined, + })), + }); + return true; + } + if (req.method === "POST" && pathname === "/api/sense/social/drafts") { + const payload = await bodyObject(req); + const draft = await createSocialDraft(payload as unknown as NewSocialDraft); + json(res, 201, { draft }); + return true; + } + + const match = pathname.match( + /^\/api\/sense\/social\/drafts\/([^/]+)(?:\/(request-approval|approve|cancel))?$/, + ); + if (!match) { + json(res, 404, { error: "social_route_not_found" }); + return true; + } + const id = decodeURIComponent(match[1]!); + const action = match[2]; + + if (req.method === "GET" && !action) { + const draft = await getSocialDraft(id); + if (!draft) json(res, 404, { error: "social_draft_not_found" }); + else { + json(res, 200, { + draft: { + ...draft, + approvalDigest: + draft.state === "awaiting-approval" + ? socialDraftDigest(draft) + : undefined, + }, + }); + } + return true; + } + if (req.method === "PATCH" && !action) { + const payload = await bodyObject(req); + const expectedRevision = payload.expectedRevision; + if (typeof expectedRevision !== "number") { + json(res, 400, { error: "expectedRevision_required" }); + return true; + } + const patch = payload.patch; + if (!patch || typeof patch !== "object" || Array.isArray(patch)) { + json(res, 400, { error: "patch_required" }); + return true; + } + const draft = await updateSocialDraft( + id, + expectedRevision, + patch as SocialDraftPatch, + ); + json(res, 200, { draft }); + return true; + } + if (req.method === "POST" && action === "request-approval") { + const payload = await bodyObject(req); + if (typeof payload.expectedRevision !== "number") { + json(res, 400, { error: "expectedRevision_required" }); + return true; + } + const result = await requestSocialDraftApproval( + id, + payload.expectedRevision, + ); + json(res, 200, { + draft: result.draft, + approvalDigest: result.digest, + }); + return true; + } + if (req.method === "POST" && action === "approve") { + if (!opts.allowApproval) { + json(res, 403, { error: "trusted_local_confirmation_required" }); + return true; + } + const payload = await bodyObject(req); + if (typeof payload.digest !== "string") { + json(res, 400, { error: "digest_required" }); + return true; + } + const draft = await approveSocialDraft(id, payload.digest); + json(res, 200, { draft }); + return true; + } + if (req.method === "POST" && action === "cancel") { + const draft = await cancelSocialDraft(id); + json(res, 200, { draft }); + return true; + } + + json(res, 405, { error: "method_not_allowed" }); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + json(res, 400, { error: "social_request_failed", message }); + return true; + } +}