diff --git a/session-server/chat-manager.mjs b/session-server/chat-manager.mjs index f81890f54c..dd835344a6 100644 --- a/session-server/chat-manager.mjs +++ b/session-server/chat-manager.mjs @@ -13,6 +13,11 @@ import { ensureIndexes as ensureHeartsIndexes, toggleHeart, countHearts } from " import { MongoClient, ObjectId } from "mongodb"; import { broadcastToTopic } from "../shared/push.mjs"; // Standard push (no Firebase). +import { + MAX_CHARS, + chatCapabilities, + profanityFiltered, +} from "../shared/chat-capabilities.mjs"; const MAX_MESSAGES = 500; @@ -306,6 +311,9 @@ export class ChatManager { handles: this.getOnlineHandles(instance), messages: instance.messages, heartCounts, + // The handshake: what this channel accepts. Clients read their limit + // and syntax from here instead of hardcoding a copy of it. + capabilities: chatCapabilities(instance.config.name), id, }, id, @@ -358,10 +366,16 @@ export class ChatManager { return; } - // Length limit - const len = 128; - if (msg.content.text.length > len) { - ws.send(this.pack("too-long", { message: `Please limit to ${len} characters.` })); + // Length limit. Counted in UTF-16 code units — see shared/chat-capabilities. + if (msg.content.text.length > MAX_CHARS) { + ws.send( + this.pack("too-long", { + message: `Please limit to ${MAX_CHARS} characters.`, + maxChars: MAX_CHARS, + countedAs: "utf16-code-units", + was: msg.content.text.length, + }), + ); return; } @@ -422,7 +436,9 @@ export class ChatManager { redact(message); filteredText = message.text; } else { - filteredText = instance.config.name === "chat-clock" ? message.text : filter(message.text, this.filterDebug); + filteredText = profanityFiltered(instance.config.name) + ? filter(message.text, this.filterDebug) + : message.text; } // Get server time @@ -888,6 +904,27 @@ export class ChatManager { })); } + // The same handshake the `connected` packet carries, for callers with no + // socket. `host` may be a chat host or a bare channel name; without one you + // get every channel, since the profanity policy differs between them. + getCapabilities(host = null) { + if (host) { + const instance = + this.getInstance(host) || + Object.values(this.instances).find((i) => i.config.name === host); + if (!instance) return null; + return { + host: instance.config.allowedHost, + ...chatCapabilities(instance.config.name), + }; + } + + return Object.values(this.instances).map((instance) => ({ + host: instance.config.allowedHost, + ...chatCapabilities(instance.config.name), + })); + } + // Get recent messages for a specific instance (for dashboard) getRecentMessages(host, count = 10) { const instance = this.getInstance(host); diff --git a/session-server/session.mjs b/session-server/session.mjs index 35ee2ba8e1..a9211bc4c2 100644 --- a/session-server/session.mjs +++ b/session-server/session.mjs @@ -845,6 +845,23 @@ fastify.get("/chat/status", async (req) => { return chatManager.getStatus(); }); +// *** Chat Capabilities Endpoint *** +// The limit and syntax a bot needs before it posts, without opening a socket. +// Served from every chat host, so `https://chat-clock.aesthetic.computer/chat/ +// capabilities` answers for the room you're actually in. Defaults to the host +// you asked over; `?channel=` (or `all`) overrides. +fastify.get("/chat/capabilities", async (req, reply) => { + reply.header("Access-Control-Allow-Origin", "*"); + const asked = req.query?.channel || req.query?.instance; + if (asked === "all") return chatManager.getCapabilities(); + const caps = chatManager.getCapabilities(asked || req.headers.host); + if (caps) return caps; + // Reached over a non-chat host (session-server itself) with no ?channel. + if (!asked) return chatManager.getCapabilities(); + reply.status(404); + return { status: "error", message: `Unknown chat channel "${asked}".` }; +}); + const PROFILE_SECRET_CACHE_MS = 60 * 1000; let profileSecretCacheValue = null; let profileSecretCacheAt = 0; diff --git a/shared/chat-capabilities.mjs b/shared/chat-capabilities.mjs new file mode 100644 index 0000000000..88b67e3199 --- /dev/null +++ b/shared/chat-capabilities.mjs @@ -0,0 +1,97 @@ +// chat capabilities, 26.08.08 +// What the AC chat server will accept, stated once. +// +// The 128-char cap used to live in four unlinked places — the `chat` piece, the +// MCP tool, a comment, and the server itself — so they drifted. The server is +// the only authority, and this is the server's copy. Everything else reads it: +// +// session-server/chat-manager.mjs enforces it, and hands it to every client +// in the `connected` packet +// .../disks/chat.mjs takes its limit from that packet +// /chat/capabilities (session-server, per chat host) +// /api/chat-capabilities (lith, for bots that only know the apex) +// slab/bin/chat-mcp.mjs builds the chat_send tool contract from it +// +// If you change the cap or the syntax, change it here and nowhere else. + +export const MAX_CHARS = 128; + +// JS `String.length` — UTF-16 code units, not codepoints, graphemes, or bytes. +// A non-BMP emoji costs 2 and a flag costs 4, so "128 characters" and "128 of +// what a human would call characters" are different numbers. Every surface that +// advertises the cap has to say which one it means or people count graphemes +// and get a `too-long` they don't understand. +export const COUNTED_AS = "utf16-code-units"; + +// Channels that skip session-server/filter.mjs. `chat-clock` is the Danish +// laer-klokken room; the English wordlist mauls it. +const UNFILTERED = new Set(["chat-clock"]); + +export function profanityFiltered(channel) { + return !UNFILTERED.has(channel); +} + +export function tooLong(text) { + return String(text ?? "").length > MAX_CHARS; +} + +// Message syntax — the tokens `lib/chat-highlighting.mjs` finds and makes +// tappable. Kept in that file's scan order so the two stay comparable. +export const TOKENS = [ + { + token: "'…'", + name: "prompt", + example: "try 'starfield'", + does: + "Single-quoted text becomes a tappable jump to `prompt `. This is the ONLY way to link a piece — a bare piece name is plain text. Quoted KidLisp source is syntax-highlighted and runs as KidLisp instead. Contractions (I'll, you'll) are not matched.", + }, + { token: "@handle", name: "handle", example: "@jeffrey", does: "Opens that user's profile." }, + { token: "#code", name: "painting", example: "#Lv2", does: "Opens the painting with that code." }, + { token: "$code", name: "kidlisp", example: "$cow", does: "Opens the stored KidLisp piece." }, + { token: "*code", name: "clock", example: "*bell", does: "Opens that clock piece." }, + { token: "!code", name: "tape", example: "!x7q", does: "Opens that tape recording." }, + { token: "r8dio", name: "r8dio", example: "r8dio", does: "Bare word — starts the radio player. `@r8dio` stays a handle." }, + { token: "https://… | www.…", name: "url", example: "https://prompt.ac", does: "Opens in the browser. A URL that trips the sensitive-word list renders as [click to reveal link]." }, + { token: "youtube link", name: "youtube", example: "https://youtu.be/dQw4w9WgXcQ", does: "Watch/shorts/embed/youtu.be links get an inline player." }, + { token: "name@host.tld", name: "email", example: "me@aesthetic.computer", does: "Opens a mailto:." }, +]; + +// Bare-word commands. There are no slash commands. These are intercepted by the +// chat piece BEFORE the socket send, so they are UI, not protocol — a bot that +// pushes "radio" through `chat:message` just posts the word "radio". +export const COMMANDS = [ + { command: "radio", does: "Toggle the radio player.", local: true }, + { command: "radio off | radio stop | hush | mute radio", does: "Stop the radio.", local: true }, + { command: "r8dio | bj", does: "Play that station.", local: true }, + { command: "fight @handle", does: "Challenge someone to a fight (login required).", local: true }, + { command: "fight accept | fight decline", does: "Answer a pending challenge.", local: true }, +]; + +export function chatCapabilities(channel = "chat-system") { + return { + channel, + maxChars: MAX_CHARS, + countedAs: COUNTED_AS, + profanityFiltered: profanityFiltered(channel), + tokens: TOKENS, + commands: COMMANDS, + }; +} + +// A plain-text rendering of the above, for places that can only carry prose — +// an MCP tool description, a `help` reply. Generated so the numbers can't drift +// from the object. +export function capabilitiesBrief(caps = chatCapabilities()) { + const tokens = caps.tokens.map((t) => ` ${t.token} — ${t.does}`).join("\n"); + const commands = caps.commands.map((c) => ` ${c.command} — ${c.does}`).join("\n"); + return [ + `Limit: ${caps.maxChars} characters, counted as ${caps.countedAs} (JS String.length — a non-BMP emoji costs 2, a flag 4). Over that, the server answers "too-long" and drops the message.`, + `Profanity filter: ${caps.profanityFiltered ? "on" : "off"} for ${caps.channel}.`, + ``, + `Syntax (tokens become tappable links):`, + tokens, + ``, + `Bare-word commands — typed in the chat UI only, no slash commands. Sending these as a message just posts the words:`, + commands, + ].join("\n"); +} diff --git a/slab/bin/chat-mcp.mjs b/slab/bin/chat-mcp.mjs index 590e1c6b3a..17fb009259 100755 --- a/slab/bin/chat-mcp.mjs +++ b/slab/bin/chat-mcp.mjs @@ -21,6 +21,13 @@ // no SDK, only node builtins + the shared http-front. import { httpPort, serveHttp, serveStdio } from "../../toolchain/mcp/http-front.mjs"; import { UA, whoami } from "../../toolchain/mcp/ac-token.mjs"; +// The limit and the message syntax come from the server's own copy, so this +// tool's contract can't drift from what chat-manager.mjs actually enforces. +import { + MAX_CHARS, + capabilitiesBrief, + chatCapabilities, +} from "../../shared/chat-capabilities.mjs"; // `instance` is what /api/chat-messages calls a channel ("system" | "clock") — // NOT the Mongo collection name. Passing the collection, or any unknown param, @@ -31,7 +38,7 @@ const CHANNELS = { clock: { host: "chat-clock.aesthetic.computer", collection: "chat-clock" }, }; const READ_API = "https://aesthetic.computer/api/chat-messages"; -const MAX_TEXT = 128; // chat-manager.mjs rejects anything longer with "too-long" +const MAX_TEXT = MAX_CHARS; // chat-manager.mjs rejects anything longer with "too-long" function channel(name = "system") { const key = String(name).replace(/^chat-/, ""); @@ -172,13 +179,23 @@ const TOOLS = [ }, { name: "chat_send", - description: + description: [ "Post a message to an AC chat channel AS @jeffrey, using his signed-in session. PUBLIC AND PERMANENT — it appears immediately to everyone in the channel and this server has no delete. Confirm the exact wording with him before calling.", + "", + capabilitiesBrief(chatCapabilities("chat-system")), + ].join("\n"), inputSchema: { type: "object", properties: { - text: { type: "string", description: "The message to post, verbatim." }, - channel: { type: "string", description: '"system" (default) or "clock".' }, + text: { + type: "string", + description: `The message to post, verbatim. Max ${MAX_CHARS} characters — see this tool's description for the syntax that makes pieces, handles, and codes tappable.`, + }, + channel: { + type: "string", + description: + '"system" (default) or "clock". `clock` is the Danish laer-klokken room and skips the profanity filter.', + }, }, required: ["text"], }, diff --git a/system/netlify/functions/chat-capabilities.mjs b/system/netlify/functions/chat-capabilities.mjs new file mode 100644 index 0000000000..483127d3a1 --- /dev/null +++ b/system/netlify/functions/chat-capabilities.mjs @@ -0,0 +1,53 @@ +// chat-capabilities, 26.08.08 +// GET: What the AC chat server will accept — the length cap, how it's counted, +// whether the channel filters, the message syntax, and the bare-word +// commands. For bots and LLMs that want the contract before they post. +// +// Query params: +// channel — "system" | "clock" | "sotce" (also accepts the full +// `chat-system` / host form). Omit for every channel. +// +// The authority is the session server, which enforces these on the socket and +// hands the same object to every client in its `connected` packet; both read +// shared/chat-capabilities.mjs. This route exists because a bot that only knows +// `aesthetic.computer/api/...` shouldn't have to learn the chat hostnames — +// `https://chat-clock.aesthetic.computer/chat/capabilities` is the same answer +// straight from the server. + +import { respond } from "../../backend/http.mjs"; +import { chatCapabilities } from "../../../shared/chat-capabilities.mjs"; + +const CHANNELS = { + system: "chat-system.aesthetic.computer", + clock: "chat-clock.aesthetic.computer", + sotce: "chat.sotce.net", +}; + +const forChannel = (key) => ({ + ...chatCapabilities(`chat-${key}`), + host: CHANNELS[key], + socket: `wss://${CHANNELS[key]}`, + handshake: "The `connected` packet carries this same object as `capabilities`.", +}); + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(204, null); + if (event.httpMethod !== "GET") return respond(405, { message: "Method Not Allowed" }); + + const asked = (event.queryStringParameters?.channel || "") + .trim() + .replace(/^chat[-.]/, "") + .replace(/\..*$/, ""); + + if (!asked) { + return respond(200, { channels: Object.keys(CHANNELS).map(forChannel) }); + } + + if (!CHANNELS[asked]) { + return respond(400, { + message: `Unknown channel "${asked}" — expected one of: ${Object.keys(CHANNELS).join(", ")}`, + }); + } + + return respond(200, forChannel(asked)); +} diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index 9789733377..f5342dd6e3 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -661,6 +661,19 @@ async function boot( // 🤖 Runs on every message... client.receiver = (id, type, content, extra) => { if (type === "connected") { + // The server states its own limit in the handshake — see + // shared/chat-capabilities.mjs. Older servers omit it, so the + // hardcoded default above stays as the fallback. + const advertised = content?.capabilities?.maxChars; + if (advertised && advertised !== chatMaxChars) { + console.log( + "💬 Chat limit from server:", + advertised, + `(${content.capabilities.countedAs})`, + ); + chatMaxChars = advertised; + send({ type: "keyboard:set-max-chars", content: chatMaxChars }); + } messagesNeedLayout = true; return; }