diff --git a/workers/api/src/lib/connectors/registry.test.ts b/workers/api/src/lib/connectors/registry.test.ts index 433e5cfa..dc8f8e1f 100644 --- a/workers/api/src/lib/connectors/registry.test.ts +++ b/workers/api/src/lib/connectors/registry.test.ts @@ -2,9 +2,18 @@ import { describe, expect, it } from "vitest"; import { CONNECTORS, connectorTools, getConnector } from "./registry.js"; describe("connector registry", () => { - it("declares github, http, meta, and tmux", () => { + it("declares github, http, meta, tmux, and web-search", () => { const ids = CONNECTORS.map((c) => c.id).sort(); - expect(ids).toEqual(["github", "http", "meta", "tmux"]); + expect(ids).toEqual(["github", "http", "meta", "tmux", "web-search"]); + }); + + it("web-search is a token-auth, read-only, user-grant connector with no tokenEnv (vault-backed)", () => { + const ws = getConnector("web-search"); + expect(ws?.auth).toBe("token"); + expect(ws?.tokenEnv).toBeUndefined(); // no platform env → connectorClient reads the vault key + expect(ws?.scopes).toEqual({ read: true, write: false }); + expect(ws?.grantModel).toBe("user"); + expect(ws?.tools.map((t) => t.name)).toEqual(["web_search"]); }); it("http is a token-auth, read+write, user-grant connector with no tokenEnv (vault-backed)", () => { diff --git a/workers/api/src/lib/connectors/registry.ts b/workers/api/src/lib/connectors/registry.ts index 7b78fa22..67ad21c4 100644 --- a/workers/api/src/lib/connectors/registry.ts +++ b/workers/api/src/lib/connectors/registry.ts @@ -8,6 +8,7 @@ import { GITHUB_TOOLS } from "./github.js"; import { HTTP_TOOLS } from "./http.js"; import { META_TOOLS } from "./meta.js"; import { TMUX_TOOLS } from "./tmux.js"; +import { WEB_SEARCH_TOOLS } from "./web-search.js"; export interface Connector { /** Stable id, also the `connector` stamped on its tools and the grants/consent key. */ @@ -80,6 +81,17 @@ export const CONNECTORS: Connector[] = [ grantModel: "user", tools: HTTP_TOOLS, }, + { + id: "web-search", + label: "Web Search (Google Custom Search)", + // auth:"token", no tokenEnv → connectorClient.token() reads the user's vault key + // (user_api_keys, provider "web-search") — a separate slot from the http connector's + // key. web_search injects it into the request URL itself. Read-only (search only). + auth: "token", + scopes: { read: true, write: false }, + grantModel: "user", + tools: WEB_SEARCH_TOOLS, + }, ]; const BY_ID: ReadonlyMap = new Map(CONNECTORS.map((c) => [c.id, c] as const)); diff --git a/workers/api/src/lib/connectors/web-search.test.ts b/workers/api/src/lib/connectors/web-search.test.ts new file mode 100644 index 00000000..9df555ff --- /dev/null +++ b/workers/api/src/lib/connectors/web-search.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getRegistryTool, runRegistryTool } from "../tool-registry.js"; +import type { RegistryToolCtx } from "../tool-registry.js"; +import type { ConnectorClient } from "./client.js"; + +// web_search resolved from the REGISTRY (proves it's registered → callable via runtime, +// MCP proxy, and POST …/tools/web_search with no bespoke route). +const webSearch = getRegistryTool("web_search")!; + +/** Mock globalThis.fetch (what safeFetch calls). Records the URL + init. */ +function mockFetch(status: number, body: unknown) { + const calls: Array<{ url: string; init: RequestInit }> = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url: any, init: any) => { + calls.push({ url: String(url), init: init || {} }); + return new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }); + return { calls }; +} + +/** A ctx whose connectorClient("web-search").token() returns the vault key + a cx env. */ +function ctxWithKey(key: string, cx = "CSE_ID"): RegistryToolCtx { + const client = { token: async () => key } as unknown as ConnectorClient; + return { env: { WEB_SEARCH_CX: cx } as any, connectorClient: () => client } as RegistryToolCtx; +} + +function parse(content: string): any { + try { + return JSON.parse(content); + } catch { + return undefined; + } +} + +afterEach(() => vi.restoreAllMocks()); + +// A sample Google Custom Search JSON response for "Blue Bottle Cafe Newtown". +const CSE_RESPONSE = { + items: [ + { title: "Blue Bottle Cafe (@bluebottle_syd) • Instagram photos", link: "https://www.instagram.com/bluebottle_syd/", snippet: "Newtown, Sydney NSW." }, + { title: "Blue Bottle Cafe - Home | Facebook", link: "https://www.facebook.com/BlueBottleNewtown", snippet: "Cafe · Newtown. Email hello@bluebottle.example" }, + { title: "Blue Bottle Cafe", link: "https://bluebottle.example", snippet: "Specialty coffee in Newtown." }, + ], +}; + +describe("web_search — registration & schema", () => { + it("is registered as a web-search-connector, read-scoped tool", () => { + expect(webSearch.connector).toBe("web-search"); + expect(webSearch.tier).toBe("connector"); + expect(webSearch.scope).toBe("read"); + }); + it("exposes query/num/cx in its schema and requires query", () => { + const p = webSearch.jsonSchema.properties; + for (const k of ["query", "num", "cx"]) expect(p[k]).toBeDefined(); + expect(webSearch.jsonSchema.required).toContain("query"); + }); + it("errors (not throws) when query is empty", async () => { + const r = await webSearch.handler(ctxWithKey("K"), { query: "" }); + expect(r.success).toBe(false); + expect(r.content).toMatch(/query/i); + }); +}); + +describe("web_search — Google Custom Search wiring", () => { + it("returns [{title, link, snippet}] for a business query", async () => { + mockFetch(200, CSE_RESPONSE); + const r = await webSearch.handler(ctxWithKey("SECRET_KEY"), { query: "Blue Bottle Cafe Newtown", num: 3 }); + expect(r.success).toBe(true); + const out = parse(r.content); + expect(out.count).toBe(3); + expect(out.results[0]).toEqual({ + title: "Blue Bottle Cafe (@bluebottle_syd) • Instagram photos", + link: "https://www.instagram.com/bluebottle_syd/", + snippet: "Newtown, Sydney NSW.", + }); + }); + it("hits the Custom Search endpoint with q, cx, and num", async () => { + const { calls } = mockFetch(200, CSE_RESPONSE); + await webSearch.handler(ctxWithKey("SECRET_KEY", "MY_CSE"), { query: "cafe newtown", num: 2 }); + const u = new URL(calls[0].url); + expect(u.origin + u.pathname).toBe("https://www.googleapis.com/customsearch/v1"); + expect(u.searchParams.get("q")).toBe("cafe newtown"); + expect(u.searchParams.get("cx")).toBe("MY_CSE"); + expect(u.searchParams.get("num")).toBe("2"); + }); + it("a per-call cx overrides the WEB_SEARCH_CX env default", async () => { + const { calls } = mockFetch(200, CSE_RESPONSE); + await webSearch.handler(ctxWithKey("K", "ENV_CX"), { query: "x", cx: "CALL_CX" }); + expect(new URL(calls[0].url).searchParams.get("cx")).toBe("CALL_CX"); + }); + it("caps num at 10 and defaults to 5", async () => { + const { calls } = mockFetch(200, { items: [] }); + await webSearch.handler(ctxWithKey("K"), { query: "x", num: 99 }); + expect(new URL(calls[0].url).searchParams.get("num")).toBe("10"); + await webSearch.handler(ctxWithKey("K"), { query: "y" }); + expect(new URL(calls[1].url).searchParams.get("num")).toBe("5"); + }); + it("no CSE id (no cx, no env) → clean failure, no request", async () => { + const { calls } = mockFetch(200, {}); + const noCx = { env: {} as any, connectorClient: () => ({ token: async () => "K" }) as any } as RegistryToolCtx; + const r = await webSearch.handler(noCx, { query: "x" }); + expect(r.success).toBe(false); + expect(r.content).toMatch(/cx|WEB_SEARCH_CX/i); + expect(calls).toHaveLength(0); + }); +}); + +describe("web_search — api-key from vault, never leaked", () => { + it("reads the key via connectorClient and injects it into the request URL", async () => { + const { calls } = mockFetch(200, CSE_RESPONSE); + await webSearch.handler(ctxWithKey("VAULT_KEY"), { query: "x" }); + expect(new URL(calls[0].url).searchParams.get("key")).toBe("VAULT_KEY"); + }); + it("never echoes the key into the returned result", async () => { + mockFetch(200, CSE_RESPONSE); + const r = await webSearch.handler(ctxWithKey("VAULT_KEY"), { query: "x" }); + expect(r.content).not.toContain("VAULT_KEY"); + }); + it("exposes no key input in the schema (the value comes from the vault, not the caller)", () => { + // The caller can never SUPPLY the key: there is no `key`/`apiKey`/`token` input property. + // (The description documenting "read from the vault" is intentional; the VALUE is never here.) + for (const forbidden of ["key", "apiKey", "api_key", "token", "secret"]) { + expect(webSearch.jsonSchema.properties[forbidden]).toBeUndefined(); + } + expect(Object.keys(webSearch.jsonSchema.properties).sort()).toEqual(["cx", "num", "query"]); + }); + it("fails cleanly (no request) when no key is connected", async () => { + const { calls } = mockFetch(200, {}); + const noKey = { env: { WEB_SEARCH_CX: "CSE" } as any, connectorClient: () => ({ token: async () => "" }) as any } as RegistryToolCtx; + const r = await webSearch.handler(noKey, { query: "x" }); + expect(r.success).toBe(false); + expect(calls).toHaveLength(0); + }); + it("an upstream error does not leak the request URL (which carries the key)", async () => { + mockFetch(403, { error: { message: "quota" } }); + const r = await webSearch.handler(ctxWithKey("VAULT_KEY"), { query: "x" }); + expect(r.success).toBe(false); + expect(r.content).not.toContain("VAULT_KEY"); + }); +}); + +describe("web_search — SSRF safety (uses safeFetch)", () => { + it("only ever targets the Google endpoint (public https) — a mocked SSRF host is impossible via input", async () => { + // web_search builds the URL itself from the fixed endpoint, so a caller can't redirect it + // to a private target. Confirm the outbound URL host is always googleapis.com. + const { calls } = mockFetch(200, { items: [] }); + await webSearch.handler(ctxWithKey("K"), { query: "http://169.254.169.254/latest/meta-data" }); + expect(new URL(calls[0].url).hostname).toBe("www.googleapis.com"); + }); + it("goes through safeFetch — a blocked target surfaces as a failure, not a throw", async () => { + // Simulate safeFetch rejecting (e.g. a poisoned redirect) by making the underlying fetch throw. + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("boom")); + const r = await webSearch.handler(ctxWithKey("K"), { query: "x" }); + expect(r.success).toBe(false); + expect(r.content).toMatch(/search failed/i); + }); +}); + +// ── the #99 acceptance proof: business name+suburb → instagram/facebook/email as config ── +describe("web_search + extract_contacts — enrichment (issue #99 acceptance)", () => { + it("given a business name+suburb, yields instagram/facebook/email columns (pure config)", async () => { + mockFetch(200, CSE_RESPONSE); + const ctx = ctxWithKey("VAULT_KEY"); + + // 1) search the web for the business (dispatched through runRegistryTool → audited/granted). + const search = await runRegistryTool("web_search", ctx, { query: "Blue Bottle Cafe Newtown Sydney" }); + expect(search.success).toBe(true); + + // 2) extract socials + email from the results — no bespoke code, just the step catalog. + const enriched = await runRegistryTool("extract_contacts", ctx, { items: parse(search.content) }); + expect(enriched.success).toBe(true); + + const cols = parse(enriched.content); + expect(cols.instagram).toBe("https://instagram.com/bluebottle_syd"); + expect(cols.facebook).toBe("https://facebook.com/BlueBottleNewtown"); + expect(cols.email).toBe("hello@bluebottle.example"); + }); +}); diff --git a/workers/api/src/lib/connectors/web-search.ts b/workers/api/src/lib/connectors/web-search.ts new file mode 100644 index 00000000..7485aae1 --- /dev/null +++ b/workers/api/src/lib/connectors/web-search.ts @@ -0,0 +1,105 @@ +// Web-search connector (issue #99, child of #94/#84). Enriching a lead with its socials + +// email needs a web search — a capability the generic `http` connector can't cleanly cover +// because the search KEY must live in its OWN vault slot (provider "web-search"), separate +// from the http connector's key. So this is a first-class connector: declared once in the +// registry, it provides ONE `web_search` tool. +// +// Auth (declared on the connector as auth:"token", grantModel:"user", no tokenEnv): +// the Google Custom Search API KEY is read from the vault (user_api_keys, provider +// "web-search") via ctx.connectorClient("web-search").token(). The key value is used only +// on the wire — it NEVER appears in the tool inputs, schema, or returned result. +// +// The CSE id (`cx`) is NOT a secret (it identifies the search engine, not the account), so it +// is a plain tool input (or the WEB_SEARCH_CX env default), not a vault secret. +// +// Every request goes through safeFetch (lib/ssrf.ts) — https-only, SSRF-guarded, redirects +// re-validated — the same guard the #95 http connector uses. We do NOT re-implement fetch/SSRF. +import type { ToolDef, RegistryToolCtx } from "../tool-registry.js"; +import { safeFetch, SsrfError } from "../ssrf.js"; + +// Google Custom Search JSON API endpoint. Wrapped, not hardcoded into agent code: +// GET https://www.googleapis.com/customsearch/v1?key=&cx=&q=&num= +const CSE_ENDPOINT = "https://www.googleapis.com/customsearch/v1"; + +// Per-call result cap. Google CSE returns at most 10 items per request; we also cap the +// caller-supplied `num` so a single web_search call can never fan out beyond one page. +const MAX_RESULTS = 10; +const DEFAULT_RESULTS = 5; + +interface CseItem { + title?: string; + link?: string; + snippet?: string; +} + +export const WEB_SEARCH_TOOLS: ToolDef[] = [ + { + name: "web_search", + tier: "connector", + connector: "web-search", + scope: "read", + description: + "Search the web (Google Custom Search) for a query and return the top results as [{title, link, snippet}]. Use it to look a business up by name+suburb before an extract step pulls its Instagram/Facebook/email. The API key is read from the vault (never passed in); supply the non-secret CSE id via `cx` (or the WEB_SEARCH_CX env default). `num` caps results (default 5, max 10). Results are best-effort. HTTPS-only, SSRF-guarded.", + jsonSchema: { + type: "object", + properties: { + query: { type: "string", description: "The search query, e.g. 'Blue Bottle Cafe Newtown Sydney instagram'." }, + num: { type: "number", description: "Number of results to return (default 5, max 10)." }, + cx: { type: "string", description: "Google Custom Search engine id (cx). Not a secret — defaults to the WEB_SEARCH_CX env if omitted." }, + }, + required: ["query"], + }, + handler: async (ctx: RegistryToolCtx, input) => { + const query = String(input.query ?? "").trim(); + if (!query) return { content: "query is required.", success: false }; + + // cx: non-secret search-engine id. Tool input takes precedence, else the env default. + const cx = (typeof input.cx === "string" && input.cx.trim()) || ctx.env?.WEB_SEARCH_CX?.trim() || ""; + if (!cx) return { content: "No Custom Search engine id — pass `cx` or set WEB_SEARCH_CX.", success: false }; + + const num = Math.max(1, Math.min(Number(input.num) || DEFAULT_RESULTS, MAX_RESULTS)); + + // The API key comes from the vault via the connectorClient (provider "web-search"). + // It is attached to the outgoing URL only — never returned or logged. + const key = await ctx.connectorClient?.("web-search").token().catch(() => null); + if (!key) return { content: "No API key connected for the web-search connector — add one in the instance's Connections settings.", success: false }; + + const u = new URL(CSE_ENDPOINT); + u.searchParams.set("key", key); + u.searchParams.set("cx", cx); + u.searchParams.set("q", query); + u.searchParams.set("num", String(num)); + + let res: Response; + try { + res = await safeFetch(u.toString(), { method: "GET" }); + } catch (e) { + if (e instanceof SsrfError) return { content: `Blocked: ${e.message}`, success: false }; + return { content: `Search failed: ${e instanceof Error ? e.message : String(e)}`, success: false }; + } + + const text = await res.text(); + let body: unknown = text; + try { + body = JSON.parse(text); + } catch { + /* keep as text */ + } + if (!res.ok) { + // Surface the status but NOT the request URL (which carries the key). + const msg = body && typeof body === "object" ? JSON.stringify((body as { error?: unknown }).error ?? body) : String(body); + return { content: `Search error ${res.status}: ${msg}`.slice(0, 500), success: false }; + } + + const rawItems = (body && typeof body === "object" ? (body as { items?: unknown }).items : undefined); + const items: CseItem[] = Array.isArray(rawItems) ? rawItems : []; + const results = items.slice(0, num).map((it) => ({ + title: typeof it.title === "string" ? it.title : "", + link: typeof it.link === "string" ? it.link : "", + snippet: typeof it.snippet === "string" ? it.snippet : "", + })); + + return { content: JSON.stringify({ query, count: results.length, results }, null, 2), success: true }; + }, + }, +]; diff --git a/workers/api/src/lib/steps.test.ts b/workers/api/src/lib/steps.test.ts index 1091f7e7..a7643c46 100644 --- a/workers/api/src/lib/steps.test.ts +++ b/workers/api/src/lib/steps.test.ts @@ -11,6 +11,7 @@ const dedupeT = getRegistryTool("dedupe_upsert")!; const fanOutT = getRegistryTool("fan_out")!; const reachableT = getRegistryTool("http_reachable")!; const geocodeT = getRegistryTool("geocode")!; +const extractT = getRegistryTool("extract_contacts")!; const baseCtx = { env: {} as any } as RegistryToolCtx; @@ -22,8 +23,8 @@ afterEach(() => vi.restoreAllMocks()); // ── registration ────────────────────────────────────────────────────────────── describe("step library — registration", () => { - it("registers all six steps as standard-tier, non-connector tools", () => { - for (const t of [mapT, filterT, dedupeT, fanOutT, reachableT, geocodeT]) { + it("registers all steps as standard-tier, non-connector tools", () => { + for (const t of [mapT, filterT, dedupeT, fanOutT, reachableT, geocodeT, extractT]) { expect(t).toBeDefined(); expect(t.tier).toBe("standard"); expect(t.connector).toBeUndefined(); @@ -341,6 +342,60 @@ describe("geocode", () => { }); }); +// ── 7. extract_contacts ────────────────────────────────────────────────────────── +describe("extract_contacts", () => { + it("pulls instagram/facebook/email from web_search-style rows", async () => { + const rows = [ + { title: "Blue Bottle Cafe (@bluebottle_syd) • Instagram", link: "https://www.instagram.com/bluebottle_syd/", snippet: "Newtown, Sydney" }, + { title: "Blue Bottle Cafe | Facebook", link: "https://www.facebook.com/BlueBottleNewtown", snippet: "Cafe in Newtown" }, + { title: "Contact us", link: "https://bluebottle.example/contact", snippet: "Email hello@bluebottle.example or call…" }, + ]; + const r = await extractT.handler(baseCtx, { items: rows }); + expect(parse(r.content)).toMatchObject({ + instagram: "https://instagram.com/bluebottle_syd", + facebook: "https://facebook.com/BlueBottleNewtown", + email: "hello@bluebottle.example", + precision: "best-effort", + }); + }); + + it("accepts the web_search {results:[…]} envelope directly", async () => { + const searchOutput = { + query: "cafe newtown instagram", + count: 1, + results: [{ title: "x", link: "https://instagram.com/some_cafe", snippet: "mailto:owner@cafe.test" }], + }; + const r = await extractT.handler(baseCtx, { items: searchOutput }); + const out = parse(r.content); + expect(out.instagram).toBe("https://instagram.com/some_cafe"); + expect(out.email).toBe("owner@cafe.test"); // mailto: preferred + }); + + it("ignores non-profile IG paths and share/login FB paths", async () => { + const rows = [ + { link: "https://www.instagram.com/p/ABC123/", snippet: "a post, not a profile" }, + { link: "https://www.facebook.com/sharer/sharer.php?u=x", snippet: "a share link" }, + { link: "https://instagram.com/realbiz", snippet: "the actual profile" }, + ]; + const out = parse((await extractT.handler(baseCtx, { items: rows })).content); + expect(out.instagram).toBe("https://instagram.com/realbiz"); + expect(out.facebook).toBeNull(); // only a share link was present + }); + + it("missing fields → null (best-effort, no false positives)", async () => { + const out = parse((await extractT.handler(baseCtx, { items: [{ title: "no socials here", link: "https://example.com", snippet: "nothing" }] })).content); + expect(out).toMatchObject({ instagram: null, facebook: null, email: null }); + }); + + it("honors the `fields` subset", async () => { + const rows = [{ link: "https://instagram.com/biz", snippet: "hi@biz.test facebook.com/biz" }]; + const out = parse((await extractT.handler(baseCtx, { items: rows, fields: ["email"] })).content); + expect(out.email).toBe("hi@biz.test"); + expect(out.instagram).toBeNull(); + expect(out.facebook).toBeNull(); + }); +}); + // ── composition proof: the lead-finder end-to-end from the catalog ──────────────── describe("lead-finder expressible from the catalog (issue #94 acceptance)", () => { it("map → filter → dedupe_upsert compose via runRegistryTool", async () => { diff --git a/workers/api/src/lib/steps.ts b/workers/api/src/lib/steps.ts index 5adb7414..b23f82ff 100644 --- a/workers/api/src/lib/steps.ts +++ b/workers/api/src/lib/steps.ts @@ -165,6 +165,76 @@ async function probeReachable(url: string, timeoutMs: number): Promise<{ ok: boo return attempt(); // one retry on a transport error / timeout } +// ── 7. contact extraction (pure) ────────────────────────────────────────────── +// Best-effort scrape of socials + email out of web_search results (issue #99). Given the +// [{title, link, snippet}] rows web_search returns, pull the first Instagram handle URL, +// Facebook page URL, and email address seen. PURE (no I/O) — it only reads the text the +// search connector already fetched, so there's no extra network / SSRF surface here. +// +// Precision note (returned in the output): matches are heuristic. A profile/page link is +// the FIRST instagram.com/… or facebook.com/… that isn't a bare share/login/tag path; the +// email is the first RFC-ish local@domain found in any title/snippet/link (or a mailto:). +// It can miss (business has no linked socials) or mis-attribute (a shared/aggregator link), +// so downstream should treat these as candidates, not verified truth. +const IG_RE = /https?:\/\/(?:www\.)?instagram\.com\/([A-Za-z0-9._]+)(?:\/|\?|$)/gi; +const FB_RE = /https?:\/\/(?:www\.|m\.|web\.)?facebook\.com\/([A-Za-z0-9.\-/]+?)(?:\/|\?|$)/gi; +const MAILTO_RE = /mailto:([^"'\s>?]+@[^"'\s>?]+)/gi; +const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; + +// instagram.com/ paths that are NOT a profile (share/discovery/auth surfaces). +const IG_NON_PROFILE = new Set(["p", "reel", "reels", "explore", "stories", "accounts", "tv", "direct", "about"]); +// facebook.com/ first-segment paths that are NOT a page (share/auth/apps). +const FB_NON_PAGE = new Set(["sharer", "sharer.php", "share", "login", "login.php", "dialog", "tr", "plugins", "help", "policies", "watch", "events", "groups", "marketplace", "profile.php"]); + +/** First profile-shaped instagram.com URL across the given text blobs, else null. */ +function firstInstagram(texts: string[]): string | null { + for (const t of texts) { + for (const m of t.matchAll(IG_RE)) { + const handle = m[1]; + if (!IG_NON_PROFILE.has(handle.toLowerCase())) return `https://instagram.com/${handle}`; + } + } + return null; +} + +/** First page-shaped facebook.com URL across the given text blobs, else null. */ +function firstFacebook(texts: string[]): string | null { + for (const t of texts) { + for (const m of t.matchAll(FB_RE)) { + const path = m[1].replace(/\/+$/, ""); + const firstSeg = path.split("/")[0].toLowerCase(); + if (!FB_NON_PAGE.has(firstSeg) && path.length > 1) return `https://facebook.com/${path}`; + } + } + return null; +} + +/** First email address (mailto: preferred) across the given text blobs, else null. */ +function firstEmail(texts: string[]): string | null { + for (const t of texts) { + MAILTO_RE.lastIndex = 0; + const mailto = MAILTO_RE.exec(t); + if (mailto) return mailto[1].toLowerCase(); + } + for (const t of texts) { + EMAIL_RE.lastIndex = 0; + const m = EMAIL_RE.exec(t); + if (m) return m[0].toLowerCase(); + } + return null; +} + +/** Flatten a web_search-style result row (or arbitrary record) into searchable text blobs. */ +function textsOf(item: unknown): string[] { + if (typeof item === "string") return [item]; + if (!isRecord(item)) return []; + const out: string[] = []; + for (const v of Object.values(item)) { + if (typeof v === "string") out.push(v); + } + return out; +} + // ── the catalog ─────────────────────────────────────────────────────────────── export const STEP_TOOLS: ToolDef[] = [ // 1 ─ map @@ -429,4 +499,42 @@ export const STEP_TOOLS: ToolDef[] = [ return ok(JSON.stringify(out, null, 2)); }, }, + + // 7 ─ extract_contacts + { + name: "extract_contacts", + tier: "standard", + scope: "read", + description: + "Extract socials + email from web_search results (pure, no I/O). Scans the [{title,link,snippet}] rows and pulls the first Instagram profile URL, Facebook page URL, and email address seen → {instagram, facebook, email} (missing fields are null). The companion to the web_search connector (#99): web_search a business name+suburb, then extract_contacts populates the instagram/facebook/email columns. Best-effort/heuristic — matches are candidates, not verified (precision noted in the output).", + jsonSchema: { + type: "object", + properties: { + items: { type: "array", description: "web_search result rows (or any records/strings with links + text to scan)." }, + fields: { type: "array", description: 'Subset of ["instagram","facebook","email"] to extract (default all three).' }, + }, + required: [], + }, + handler: async (_ctx, input) => { + // Accept either the raw web_search result rows, or its {results:[…]} envelope. + const raw = isRecord(input.items) && Array.isArray((input.items as Record).results) + ? (input.items as Record).results + : input.items; + const items = asArray(raw); + const want = new Set( + Array.isArray(input.fields) && input.fields.length + ? (input.fields as string[]) + : ["instagram", "facebook", "email"], + ); + // One flat pool of text blobs across every row (link + title + snippet). + const texts = items.flatMap(textsOf); + const out = { + instagram: want.has("instagram") ? firstInstagram(texts) : null, + facebook: want.has("facebook") ? firstFacebook(texts) : null, + email: want.has("email") ? firstEmail(texts) : null, + precision: "best-effort" as const, + }; + return ok(JSON.stringify(out, null, 2)); + }, + }, ]; diff --git a/workers/api/src/types.ts b/workers/api/src/types.ts index 17d4d1fa..425dca86 100644 --- a/workers/api/src/types.ts +++ b/workers/api/src/types.ts @@ -72,6 +72,11 @@ export interface Env { /** MCP worker's audit KV (read-only from the API worker) — powers the admin MCP-audit * page. Same namespace the MCP worker writes to; optional so it's inert if unbound. */ OAUTH_KV?: KVNamespace; + /** Web-search connector (issue #99): the Google Custom Search engine id (cx). NOT a + * secret — it identifies the search engine, not the account (non-secret wrangler [vars]). + * The API KEY is vault-stored (user_api_keys, provider "web-search"), never here. + * A per-call `cx` tool input overrides this default. */ + WEB_SEARCH_CX?: string; } export interface SessionPayload {