diff --git a/SCORE.md b/SCORE.md index c4ca2ee07b..32ac417250 100644 --- a/SCORE.md +++ b/SCORE.md @@ -295,6 +295,76 @@ ac-piece-logs-json --slug notepat | jq # raw JSON for scripting The CLI ships with every `fish lith/deploy.fish`. If you add new telemetry, bump the payload in `disk.mjs` and the phase handler in `netlify/functions/piece-log.mjs`; no schema migration needed (MongoDB collection is schemaless). +### Pulling Chat Messages (clock / system channels) + +Chat lives in MongoDB. Each channel is a separate collection: + +- `chat-system` — the main `chat` piece (`/chat`) +- `chat-clock` — the `laer-klokken` / r8dio chat piece (connects via `client.connect("clock")` in [`disks/laer-klokken.mjs`](system/public/aesthetic.computer/disks/laer-klokken.mjs)) + +**Public read endpoint:** [`/api/chat-messages`](system/netlify/functions/chat-messages.mjs) (GET, 2-min Redis cache): + +```fish +# Latest 100 clock-channel messages as chronological JSON (oldest → newest) +curl -s "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100" | jq + +# Just handle + text +curl -s "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100" \ + | jq -r '.messages[] | "\(.when) \(.from) | \(.text)"' + +# Filter by sender or URL pattern (e.g. YouTube links from @prutti) +curl -s "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100" \ + | jq -r '.messages[] + | select((.from == "@prutti") or (.text | test("youtu\\.?be|youtube\\.com"; "i"))) + | "\(.when) \(.from) | \(.text)"' +``` + +Query params: +- `instance` — `system` (default) or `clock`. Any other value hits `chat-system`. +- `limit` — up to **100** (over 100 returns HTTP 400). Sort is `when` descending, then reversed to chronological before returning. + +Response shape: `{ instance, count, messages: [{ id, from, text, when, hearts }], nextBefore }`. `from` is resolved to `@handle` via the `@handles` collection, falling back to `"anon"` for unclaimed user ids. `hearts` joins the shared `hearts` collection (`type: "chat-"`). `nextBefore` is the oldest `when` in the page, ready to hand back as `before=` for the previous page. + +**Going back further than 100 messages** — pass `before=` to walk back (or use `nextBefore` from the previous response): + +```fish +# All @prutti YouTube links in the clock channel, paginating back +cursor="" +while true + set url "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100" + test -n "$cursor"; and set url "$url&before=$cursor" + set page (curl -s $url) + test (echo $page | jq '.count') -eq 0; and break + echo $page | jq -r '.messages[] + | select(.from == "@prutti" and (.text | test("youtu"; "i"))) + | "\(.when) \(.text)"' + set cursor (echo $page | jq -r '.nextBefore') +end +``` + +Full docs and `curl`/JS/Python examples live at [`/api/chat-messages` on api.aesthetic.computer](https://api.aesthetic.computer) (served by [`system/netlify/functions/api-docs.mjs`](system/netlify/functions/api-docs.mjs)). + +If you ever need raw Mongo access (deleted messages, admin edits, heavier aggregations), go direct from lith: + +```fish +# On lith (or any machine with backend creds loaded): +ac-host # pick lith +# then in the ssh session: +cd aesthetic.computer/system +node -e ' + import("./backend/database.mjs").then(async ({ connect }) => { + const { db, disconnect } = await connect(); + const rows = await db.collection("chat-clock") + .find({ when: { $lt: new Date("2026-04-22T00:00:00Z") } }) + .sort({ when: -1 }).limit(500).toArray(); + console.log(JSON.stringify(rows, null, 2)); + await disconnect(); + }); +' +``` + +When adding `before` pagination, update the TODO at the top of [`chat-messages.mjs`](system/netlify/functions/chat-messages.mjs) and bump the cache key so stale entries don't mask the new param. + ### Keeps Market Stats (Tezos / Objkt) Use this flow for live Keeps market checks (`jas.tez`, `keeps.tez`, contract-level stats). diff --git a/system/netlify/functions/api-docs.mjs b/system/netlify/functions/api-docs.mjs index dcdde0890a..ac74f759ba 100644 --- a/system/netlify/functions/api-docs.mjs +++ b/system/netlify/functions/api-docs.mjs @@ -240,6 +240,115 @@ print(f"Listen at: https://aesthetic.computer/clock~{data['code']}")`, ] }, + { + name: "List Chat Messages", + method: "GET", + path: "/api/chat-messages", + description: "Read recent messages from a chat channel. `system` backs the main `/chat` piece; `clock` backs `laer-klokken` (r8Dio). Results are chronological (oldest → newest) within each page. Paginate further back with `before`.", + authentication: "None (public read)", + queryParameters: { + instance: { + type: "string", + enum: ["system", "clock"], + default: "system", + description: "Chat channel to read. `system` = main chat, `clock` = laer-klokken." + }, + limit: { + type: "number", + default: 50, + max: 100, + description: "How many messages to return. Values over 100 return HTTP 400." + }, + before: { + type: "string", + required: false, + description: "ISO-8601 timestamp. Returns messages strictly older than this — pass the `nextBefore` from the previous response to page back." + } + }, + responseBody: { + schema: { + instance: { type: "string", description: "Echoes the queried channel." }, + count: { type: "number", description: "Number of messages in this page." }, + messages: { + type: "array", + description: "Chronological (oldest → newest).", + items: { + id: { type: "string", description: "Mongo ObjectId string." }, + from: { type: "string", description: "`@handle` of the sender, or `anon` if unresolved." }, + text: { type: "string", description: "Message body as posted." }, + when: { type: "string", description: "ISO timestamp." }, + hearts: { type: "number", description: "Heart-reaction count from the shared `hearts` collection." } + } + }, + nextBefore: { + type: "string", + description: "ISO timestamp of the oldest message in this page — pass as `before=` to fetch the previous page. `null` when the page is empty." + } + } + }, + examples: [ + { + title: "Latest 50 messages from the main chat", + description: "Default channel is `system`.", + curl: `curl "https://aesthetic.computer/api/chat-messages"`, + javascript: `const res = await fetch("https://aesthetic.computer/api/chat-messages"); +const { messages } = await res.json(); +for (const m of messages) console.log(m.when, m.from, m.text);`, + python: `import requests + +data = requests.get("https://aesthetic.computer/api/chat-messages").json() +for m in data["messages"]: + print(m["when"], m["from"], m["text"])` + }, + { + title: "Latest 100 from the laer-klokken (clock) channel", + curl: `curl "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100"`, + javascript: `const res = await fetch( + "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100" +); +const { messages, nextBefore } = await res.json(); +console.log(messages.length, "messages; older page cursor:", nextBefore);` + }, + { + title: "Paginate back through older messages", + description: "Use `nextBefore` from each response to walk back in time.", + javascript: `async function* allClockMessages() { + let before; + while (true) { + const url = new URL("https://aesthetic.computer/api/chat-messages"); + url.searchParams.set("instance", "clock"); + url.searchParams.set("limit", "100"); + if (before) url.searchParams.set("before", before); + const res = await fetch(url); + const page = await res.json(); + if (page.count === 0) break; + yield page.messages; + before = page.nextBefore; + } +}`, + python: `import requests + +def all_clock_messages(): + before = None + while True: + params = {"instance": "clock", "limit": 100} + if before: + params["before"] = before + page = requests.get("https://aesthetic.computer/api/chat-messages", + params=params).json() + if page["count"] == 0: + break + yield page["messages"] + before = page["nextBefore"]` + } + ], + notes: [ + "Responses are cached in Redis for 2 minutes, keyed on (instance, limit, before).", + "`from` falls back to `anon` when a message's author has no resolved `@handle`.", + "`hearts` comes from the shared `hearts` collection (`type: chat-`)." + ] + }, + { name: "Store JavaScript Piece", method: "POST", diff --git a/system/netlify/functions/chat-messages.mjs b/system/netlify/functions/chat-messages.mjs index d513f86598..8fb1babf27 100644 --- a/system/netlify/functions/chat-messages.mjs +++ b/system/netlify/functions/chat-messages.mjs @@ -1,12 +1,13 @@ // chat-messages, 25.11.21.18.30 // GET: Returns recent chat messages from a specific chat instance. // Examples: "clock" for Laer-Klokken, "system" for main chat -// Now with Redis caching (2 min TTL). - -/* #region 🏁 TODO - - [] Add pagination support - - [] Add date range filtering -#endregion */ +// Query params: +// instance — "clock" or "system" (default: "system") +// limit — max messages to return, up to 100 (default: 50) +// before — ISO timestamp; return messages strictly older than this +// (use the oldest `when` from the previous page to paginate back) +// Response is still chronological (oldest → newest) within each page. +// Redis caching: 2 min TTL, keyed on (instance, limit, before). import { connect } from "../../backend/database.mjs"; import { respond } from "../../backend/http.mjs"; @@ -31,13 +32,29 @@ export async function handler(event, context) { return respond(400, { message: "Limit cannot exceed 100" }); } - // Cache key includes instance and limit - const cacheKey = `give:chat:${instance}:${limit}`; + // Optional `before` cursor for paginating further back in history. + // Accepts ISO timestamp; rejected if it doesn't parse. + let before = null; + if (params.before) { + const parsed = new Date(params.before); + if (isNaN(parsed.getTime())) { + return respond(400, { + message: "Invalid `before` timestamp; expected ISO 8601 (e.g. 2026-04-20T00:00:00Z)", + }); + } + before = parsed; + } + + // Cache key includes instance, limit, and pagination cursor + const cacheKey = `give:chat:${instance}:${limit}:${before ? before.toISOString() : "head"}`; const result = await getOrCompute( cacheKey, async () => { - shell.log(`📨 Fetching ${limit} messages for chat instance: ${instance}`); + shell.log( + `📨 Fetching ${limit} messages for chat instance: ${instance}` + + (before ? ` (before ${before.toISOString()})` : ""), + ); const database = await connect(); @@ -48,8 +65,9 @@ export async function handler(event, context) { shell.log(`📂 Using collection: ${collectionName} for instance: ${instance}`); // Query for messages, sorted by timestamp descending + const filter = before ? { when: { $lt: before } } : {}; const messages = await collection - .find({}) + .find(filter) .sort({ when: -1 }) .limit(limit) .toArray(); @@ -101,10 +119,20 @@ export async function handler(event, context) { shell.log(`✅ Found ${messagesWithHandles.length} messages for instance: ${instance}`); + // `nextBefore` is the oldest `when` in this page, ready to be passed + // back as the `before=` query to fetch the previous page. + const nextBefore = + messagesWithHandles.length > 0 + ? (messagesWithHandles[0].when instanceof Date + ? messagesWithHandles[0].when.toISOString() + : new Date(messagesWithHandles[0].when).toISOString()) + : null; + return { instance, count: messagesWithHandles.length, messages: messagesWithHandles, + nextBefore, }; }, CACHE_TTLS.CHAT // 2 minutes