diff --git a/sdk/API.md b/sdk/API.md index 28f568a5b..e6b7fece9 100644 --- a/sdk/API.md +++ b/sdk/API.md @@ -530,6 +530,10 @@ interface BotWorldState { combatStyle?: CombatStyleState; combatEvents: CombatEvent[]; prayers: PrayerState; + /** Absent when the connected client predates authoritative PK-skull telemetry. */ + isSkulled?: boolean; + /** Absent when the connected client predates authoritative auto-retaliate telemetry. */ + autoRetaliateEnabled?: boolean; /** Absent when the connected client predates the signal. */ opFeedback?: OpFeedbackState; } diff --git a/sdk/chat.ts b/sdk/chat.ts index 391617a4c..e2f0695b3 100644 --- a/sdk/chat.ts +++ b/sdk/chat.ts @@ -1,9 +1,12 @@ #!/usr/bin/env bun // SDK Chat CLI - send and read chat without taking control of the bot // -// Connects in 'observe' mode: the gateway lets observers dispatch 'say' (and -// nothing else), and observers neither pre-empt nor get pre-empted by whatever -// controller currently owns the bot. Safe to use while a script is running. +// Talks to the gateway's HTTP chat endpoints (POST /say/:bot, GET /chat/:bot), +// so every invocation is one bounded request/response - there is no +// per-invocation WebSocket handshake to stall on a flaky link, and a dead +// game-client relay fails fast with a clear message instead of hanging. +// Gateways that predate the HTTP endpoints get the legacy observer-WebSocket +// path automatically; --watch always uses it (it needs the live feed). // // Usage: // bun sdk/chat.ts # Show recent chat @@ -15,6 +18,11 @@ import type { GameMessage } from './types'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; +// No invocation (except --watch) may outlive this, whatever goes wrong - a +// chat check that silently eats a shell tool's whole timeout is worse than a +// failed one. +const HARD_DEADLINE_MS = 90_000; + function printUsage() { console.log(` SDK Chat CLI - send and read chat without stealing bot control @@ -28,7 +36,7 @@ Options: --limit Messages of history to show (default: 20) --system Include system/game messages, not just player chat --server Server hostname (default: from bot.env or rs-sdk-demo.fly.dev) - --timeout Budget for connecting / first state (default: 5000) + --timeout Budget per network request (default: 5000) --help Show this help Examples: @@ -77,6 +85,212 @@ function formatMessage(m: GameMessage): string { const PLAYER_AND_SYSTEM_TYPES = [0, 1, 2, 3, 6, 7] as const; +/** ws://host -> http://host, wss://host/path -> https://host/path (the gateway serves both on one port). */ +function gatewayHttpBase(gatewayUrl: string): string { + return gatewayUrl.replace(/^ws(s?):\/\//, 'http$1://').replace(/\/$/, ''); +} + +/** + * fetch with a hard per-attempt timeout and one retry on network-level + * failure (refused/reset/stalled connect - the flaky-tunnel cases). HTTP + * error statuses are answers, not failures, and are returned as-is. + */ +async function boundedFetch(url: string, init: RequestInit, timeoutMs: number): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt < 2; attempt++) { + try { + return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); + } catch (err) { + lastErr = err; + if (attempt === 0) await new Promise(resolve => setTimeout(resolve, 500)); + } + } + throw lastErr; +} + +/** True if this response came from a gateway that has the HTTP chat endpoints. */ +function isChatEndpointResponse(res: Response): boolean { + // Old gateways answer unknown paths with a 200 text/plain banner; the chat + // endpoints always answer JSON (including their error statuses). + return (res.headers.get('content-type') ?? '').includes('json'); +} + +interface CliContext { + username: string; + password: string; + gatewayUrl: string; + timeout: number; + limit: number; + system: boolean; +} + +// ============ HTTP paths (primary) ============ + +/** Returns exit code, or null if the gateway lacks the HTTP endpoints. */ +async function sendViaHttp(ctx: CliContext, text: string): Promise { + const url = `${gatewayHttpBase(ctx.gatewayUrl)}/say/${encodeURIComponent(ctx.username)}`; + let res: Response; + try { + // The gateway bounds each chunk at 10s and aborts on first failure; + // this attempt timeout only has to outlast a legitimate slow send. + res = await boundedFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, password: ctx.password, timeoutMs: 10_000 }) + }, 60_000); + } catch (err: any) { + console.error(`Error: Failed to reach gateway at ${url}`); + console.error(` ${err?.message ?? err}`); + return 1; + } + if (!isChatEndpointResponse(res)) return null; + + const body: any = await res.json().catch(() => null); + if (res.status === 409) { + console.error(`Error: Failed to send: ${body?.error ?? 'Bot not connected'}`); + console.error(` No game client is logged in as '${ctx.username}' - chat is relayed through it.`); + console.error(` Log the bot in (browser or lite client), then retry.`); + return 1; + } + if (!body || (!res.ok && !Array.isArray(body.results))) { + console.error(`Error: Failed to send: ${body?.error ?? `gateway answered ${res.status}`}`); + return 1; + } + + let ok = true; + for (const result of body.results ?? []) { + if (result.success) { + console.log(`Sent: ${result.data?.finalText ?? result.chunk}`); + if (result.data?.truncated) console.log(' (truncated by server cap)'); + if (result.data?.filtered) console.log(' (altered by word filter)'); + } else { + ok = false; + console.error(`Error: Failed to send: ${result.message}`); + } + } + return ok ? 0 : 1; +} + +/** Returns exit code, or null if the gateway lacks the HTTP endpoints. */ +async function readViaHttp(ctx: CliContext): Promise { + const params = new URLSearchParams({ limit: String(ctx.limit) }); + if (ctx.system) params.set('system', '1'); + if (ctx.password) params.set('password', ctx.password); + const url = `${gatewayHttpBase(ctx.gatewayUrl)}/chat/${encodeURIComponent(ctx.username)}?${params}`; + + let res: Response; + try { + res = await boundedFetch(url, {}, ctx.timeout); + } catch (err: any) { + console.error(`Error: Failed to reach gateway at ${url}`); + console.error(` ${err?.message ?? err}`); + return 1; + } + if (!isChatEndpointResponse(res)) return null; + + const body: any = await res.json().catch(() => null); + if (!res.ok || !body || !Array.isArray(body.messages)) { + console.error(`Error: Failed to read chat: ${body?.error ?? `gateway answered ${res.status}`}`); + return 1; + } + + if (body.messages.length === 0) { + console.log('(no chat in recent history)'); + if (body.botStatus && body.botStatus !== 'active') { + console.log(`(bot client is ${body.botStatus} - history only accumulates while it is connected)`); + } + } + for (const m of body.messages as GameMessage[]) { + console.log(formatMessage(m)); + } + return 0; +} + +// ============ Observer-WebSocket path (fallback + --watch) ============ + +async function runOverWebSocket(ctx: CliContext, messageToSend: string, watch: boolean): Promise { + const sdk = new BotSDK({ + botUsername: ctx.username, + password: ctx.password, + gatewayUrl: ctx.gatewayUrl, + connectionMode: 'observe', + autoReconnect: watch, // one-shots fail fast; watch survives blips + autoLaunchBrowser: false, + readyTimeout: 0, + connectTimeout: ctx.timeout, // SDK default (30s) is far past our budget + actionTimeout: 15_000, // a dead relay reports failure, not a 60s stall + showChat: true + }); + + try { + await sdk.connect(); + } catch (err: any) { + console.error(`Error: Failed to connect to ${ctx.gatewayUrl}`); + console.error(` ${err.message}`); + return 1; + } + + if (messageToSend) { + const results = await sdk.say(messageToSend); + let ok = true; + for (const result of results) { + if (result.success) { + const data = result.data as { finalText?: string; truncated?: boolean; filtered?: boolean } | undefined; + console.log(`Sent: ${data?.finalText ?? messageToSend}`); + if (data?.truncated) console.log(' (truncated by server cap)'); + if (data?.filtered) console.log(' (altered by word filter)'); + } else { + ok = false; + console.error(`Error: Failed to send: ${result.message}`); + if (/bot not connected/i.test(result.message)) { + console.error(` No game client is logged in as '${ctx.username}' - chat is relayed through it.`); + console.error(` Log the bot in (browser or lite client), then retry.`); + } + } + } + sdk.disconnect(); + return ok ? 0 : 1; + } + + // Reading (history or watch) needs the state feed. + const types = ctx.system ? PLAYER_AND_SYSTEM_TYPES : undefined; + try { + await sdk.waitForCondition(s => s !== null, ctx.timeout); + } catch { + if (sdk.isAuthenticated()) { + console.error(`Error: No game state for '${ctx.username}' - nothing is logged in as this bot, so there's no chat feed.`); + } else { + console.error(`Error: No state received for '${ctx.username}'`); + } + sdk.disconnect(); + return 1; + } + + const history = sdk.getChat({ limit: ctx.limit, types, includeSelf: true }); + if (history.length === 0) { + console.log('(no chat in recent history)'); + } + for (const m of history) { + console.log(formatMessage(m)); + } + // Drain the new-message cursor so watch mode only prints what arrives next. + sdk.getNewChat({ types, includeSelf: true }); + + if (!watch) { + sdk.disconnect(); + return 0; + } + + console.log(`--- watching chat for '${ctx.username}' (Ctrl+C to stop) ---`); + setInterval(() => { + for (const m of sdk.getNewChat({ types, includeSelf: true })) { + const time = new Date().toTimeString().slice(0, 8); + console.log(`[${time}] ${formatMessage(m)}`); + } + }, 500); + return await new Promise(() => {}); // runs until Ctrl+C +} + async function main() { const args = process.argv.slice(2); @@ -135,88 +349,31 @@ async function main() { process.exit(1); } - const gatewayUrl = deriveGatewayUrl(server); - - const sdk = new BotSDK({ - botUsername: username, + const ctx: CliContext = { + username, password, - gatewayUrl, - connectionMode: 'observe', - autoReconnect: watch, // one-shots fail fast; watch survives blips - autoLaunchBrowser: false, - readyTimeout: 0, - showChat: true - }); - - try { - const connectTimeout = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Connection timeout')), timeout); - }); - await Promise.race([sdk.connect(), connectTimeout]); - } catch (err: any) { - console.error(`Error: Failed to connect to ${gatewayUrl}`); - console.error(` ${err.message}`); - process.exit(1); - } - - if (messageToSend) { - const results = await sdk.say(messageToSend); - let ok = true; - for (const result of results) { - if (result.success) { - const data = result.data as { finalText?: string; truncated?: boolean; filtered?: boolean } | undefined; - console.log(`Sent: ${data?.finalText ?? messageToSend}`); - if (data?.truncated) console.log(' (truncated by server cap)'); - if (data?.filtered) console.log(' (altered by word filter)'); - } else { - ok = false; - console.error(`Error: Failed to send: ${result.message}`); - if (/bot not connected/i.test(result.message)) { - console.error(` No game client is logged in as '${username}' - chat is relayed through it.`); - console.error(` Log the bot in (browser or lite client), then retry.`); - } - } - } - sdk.disconnect(); - process.exit(ok ? 0 : 1); - } - - // Reading (history or watch) needs the state feed. - const types = system ? PLAYER_AND_SYSTEM_TYPES : undefined; - try { - await sdk.waitForCondition(s => s !== null, timeout); - } catch { - if (sdk.isAuthenticated()) { - console.error(`Error: No game state for '${username}' - nothing is logged in as this bot, so there's no chat feed.`); - } else { - console.error(`Error: No state received for '${username}'`); - } - sdk.disconnect(); - process.exit(1); - } - - const history = sdk.getChat({ limit, types, includeSelf: true }); - if (history.length === 0) { - console.log('(no chat in recent history)'); - } - for (const m of history) { - console.log(formatMessage(m)); - } - // Drain the new-message cursor so watch mode only prints what arrives next. - sdk.getNewChat({ types, includeSelf: true }); + gatewayUrl: deriveGatewayUrl(server), + timeout, + limit, + system + }; if (!watch) { - sdk.disconnect(); - process.exit(0); + // Absolute backstop: no code path (including the WS fallback) may park + // this process past the deadline. + const deadline = setTimeout(() => { + console.error(`Error: chat CLI exceeded its ${HARD_DEADLINE_MS / 1000}s deadline - giving up`); + process.exit(1); + }, HARD_DEADLINE_MS); + if (typeof (deadline as any).unref === 'function') (deadline as any).unref(); + + const httpExit = messageToSend ? await sendViaHttp(ctx, messageToSend) : await readViaHttp(ctx); + if (httpExit !== null) process.exit(httpExit); + // Gateway predates the HTTP chat endpoints - use the observer socket. + process.exit(await runOverWebSocket(ctx, messageToSend, false)); } - console.log(`--- watching chat for '${username}' (Ctrl+C to stop) ---`); - setInterval(() => { - for (const m of sdk.getNewChat({ types, includeSelf: true })) { - const time = new Date().toTimeString().slice(0, 8); - console.log(`[${time}] ${formatMessage(m)}`); - } - }, 500); + await runOverWebSocket(ctx, '', true); } main(); diff --git a/sdk/chunking.ts b/sdk/chunking.ts new file mode 100644 index 000000000..929d177fc --- /dev/null +++ b/sdk/chunking.ts @@ -0,0 +1,58 @@ +/** + * Chat message chunking, shared by the SDK's say() and the gateway's HTTP say + * endpoint. Lives in its own module so the gateway can import it without + * pulling in the full SDK (pathfinding, collision data, puppeteer glue). + */ + +/** + * Nibble-cost mirror of the client's WordPack encoding: the 13 most common + * chars pack to 1 nibble, everything else (digits, punctuation, rarer letters) + * to 2. Chat frames carry a 1-byte length, so packed text is limited to ~240 + * bytes (480 nibbles) regardless of the char cap - digit-heavy text hits it + * well before 400 chars. + */ +const WORDPACK_COMMON_CHARS = ' etaoihnsrdlu'; +const MAX_PACKED_NIBBLES = 240 * 2; + +function packedNibbles(text: string): number { + let nibbles = 0; + for (const ch of text.toLowerCase()) { + nibbles += WORDPACK_COMMON_CHARS.includes(ch) ? 1 : 2; + } + return nibbles; +} + +/** + * Split text into chunks on word boundaries such that each chunk fits both the + * char cap and the wire's packed-byte budget (so the client never silently + * truncates a chunk). Words too big for one chunk are hard-split so nothing is + * dropped. Exported for tests. + */ +export function chunkMessage(text: string, maxLen: number): string[] { + const fits = (s: string) => s.length <= maxLen && packedNibbles(s) <= MAX_PACKED_NIBBLES; + const chunks: string[] = []; + let current = ''; + for (const word of text.trim().split(/\s+/)) { + // A single oversized word: flush, then hard-split it greedily. + if (!fits(word)) { + if (current) { chunks.push(current); current = ''; } + let rest = word; + while (rest.length > 0) { + let take = Math.min(rest.length, maxLen); + while (take > 1 && !fits(rest.slice(0, take))) take--; + chunks.push(rest.slice(0, take)); + rest = rest.slice(take); + } + continue; + } + const candidate = current ? `${current} ${word}` : word; + if (fits(candidate)) { + current = candidate; + } else { + if (current) chunks.push(current); + current = word; + } + } + if (current) chunks.push(current); + return chunks; +} diff --git a/sdk/index.ts b/sdk/index.ts index 1ac09321d..e58c5cf17 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -114,58 +114,10 @@ interface PendingScreenshot { timeout: ReturnType; } -/** - * Nibble-cost mirror of the client's WordPack encoding: the 13 most common - * chars pack to 1 nibble, everything else (digits, punctuation, rarer letters) - * to 2. Chat frames carry a 1-byte length, so packed text is limited to ~240 - * bytes (480 nibbles) regardless of the char cap - digit-heavy text hits it - * well before 400 chars. - */ -const WORDPACK_COMMON_CHARS = ' etaoihnsrdlu'; -const MAX_PACKED_NIBBLES = 240 * 2; - -function packedNibbles(text: string): number { - let nibbles = 0; - for (const ch of text.toLowerCase()) { - nibbles += WORDPACK_COMMON_CHARS.includes(ch) ? 1 : 2; - } - return nibbles; -} - -/** - * Split text into chunks on word boundaries such that each chunk fits both the - * char cap and the wire's packed-byte budget (so the client never silently - * truncates a chunk). Words too big for one chunk are hard-split so nothing is - * dropped. Exported for tests. - */ -export function chunkMessage(text: string, maxLen: number): string[] { - const fits = (s: string) => s.length <= maxLen && packedNibbles(s) <= MAX_PACKED_NIBBLES; - const chunks: string[] = []; - let current = ''; - for (const word of text.trim().split(/\s+/)) { - // A single oversized word: flush, then hard-split it greedily. - if (!fits(word)) { - if (current) { chunks.push(current); current = ''; } - let rest = word; - while (rest.length > 0) { - let take = Math.min(rest.length, maxLen); - while (take > 1 && !fits(rest.slice(0, take))) take--; - chunks.push(rest.slice(0, take)); - rest = rest.slice(take); - } - continue; - } - const candidate = current ? `${current} ${word}` : word; - if (fits(candidate)) { - current = candidate; - } else { - if (current) chunks.push(current); - current = word; - } - } - if (current) chunks.push(current); - return chunks; -} +// Chat chunking lives in ./chunking so the gateway can share it without +// importing the full SDK; re-exported here for existing consumers and tests. +export { chunkMessage } from './chunking'; +import { chunkMessage } from './chunking'; export class BotSDK { readonly config: Required; diff --git a/sdk/test/gateway-http-chat.test.ts b/sdk/test/gateway-http-chat.test.ts new file mode 100644 index 000000000..f8c4ae43b --- /dev/null +++ b/sdk/test/gateway-http-chat.test.ts @@ -0,0 +1,219 @@ +// The gateway's HTTP chat endpoints (GET /chat/:bot, POST /say/:bot) exist so +// chat tools can work in one bounded request/response instead of a fresh +// observer WebSocket per invocation - over TLS tunnels that per-invocation +// handshake stalls, and the old CLI could eat a shell tool's whole 120s +// timeout in silence. These tests run the real gateway as a subprocess with a +// scripted bot client on the other side. + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'path'; + +const PORT = 20000 + Math.floor(Math.random() * 10000); +const BASE = `http://localhost:${PORT}`; +const REPO_ROOT = join(import.meta.dir, '..', '..'); + +let gateway: ReturnType | null = null; +let bot: WebSocket | null = null; + +/** Say actions the fake bot client has received, in order. */ +const receivedSays: string[] = []; + +async function waitFor(check: () => boolean | Promise, timeoutMs: number, what: string) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) return; + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${what}`); +} + +beforeAll(async () => { + gateway = Bun.spawn({ + cmd: ['bun', join(REPO_ROOT, 'server', 'gateway', 'gateway.ts')], + env: { ...process.env, AGENT_PORT: String(PORT), LOGIN_SERVER: 'false' }, + stdout: 'ignore', + stderr: 'ignore' + }); + await waitFor(async () => { + try { + return (await fetch(`${BASE}/status`, { signal: AbortSignal.timeout(500) })).ok; + } catch { + return false; + } + }, 15000, 'gateway to boot'); + + // Fake bot client: registers as 'tbot', answers say actions unless the + // text contains 'noreply' (which simulates a dead game client). + bot = new WebSocket(`ws://localhost:${PORT}`); + bot.onopen = () => { + bot!.send(JSON.stringify({ type: 'connected', clientId: 'bot-test', username: 'tbot', maxMessageLength: 400 })); + bot!.send(JSON.stringify({ + type: 'state', + state: { + tick: 1, + inGame: true, + player: { name: 'tbot', worldX: 3222, worldZ: 3218 }, + gameMessages: [ + { type: 0, text: 'Welcome to RuneScape', sender: '', tick: 1, observationId: 1, fromSelf: false }, + { type: 2, text: 'meet me at the bank', sender: 'Partner', tick: 1, observationId: 1, fromSelf: false } + ] + } + })); + }; + bot.onmessage = (event) => { + const msg = JSON.parse(String(event.data)); + if (msg.type === 'action' && msg.action?.type === 'say') { + receivedSays.push(msg.action.message); + if (msg.action.message.includes('noreply')) return; + bot!.send(JSON.stringify({ + type: 'actionResult', + actionId: msg.actionId, + result: { success: true, message: 'Said', data: { finalText: msg.action.message } } + })); + } + }; + await waitFor(async () => { + try { + const status: any = await (await fetch(`${BASE}/status/tbot`)).json(); + return status.status === 'active'; + } catch { + return false; + } + }, 10000, 'fake bot to register'); +}); + +afterAll(() => { + try { bot?.close(); } catch {} + gateway?.kill(); +}); + +describe('gateway HTTP chat endpoints', () => { + test('/status/:bot reports the relay cap alongside liveness', async () => { + const status: any = await (await fetch(`${BASE}/status/tbot`)).json(); + expect(status.status).toBe('active'); + expect(status.inGame).toBe(true); + expect(status.maxMessageLength).toBe(400); + }); + + test('GET /chat/:bot returns player chat and hides system lines by default', async () => { + const body: any = await (await fetch(`${BASE}/chat/tbot`)).json(); + expect(body.messages.map((m: any) => m.text)).toEqual(['meet me at the bank']); + expect(body.botStatus).toBe('active'); + + const withSystem: any = await (await fetch(`${BASE}/chat/tbot?system=1`)).json(); + expect(withSystem.messages.map((m: any) => m.text)).toEqual(['Welcome to RuneScape', 'meet me at the bank']); + }); + + test('GET /chat for an unknown bot answers empty, not an error', async () => { + const res = await fetch(`${BASE}/chat/ghost`); + expect(res.status).toBe(200); + const body: any = await res.json(); + expect(body.messages).toEqual([]); + expect(body.botStatus).toBe('dead'); + }); + + test('POST /say/:bot relays through the client and echoes the final text', async () => { + const res = await fetch(`${BASE}/say/tbot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'heading to the anvil now' }) + }); + expect(res.status).toBe(200); + const body: any = await res.json(); + expect(body.ok).toBe(true); + expect(body.results).toHaveLength(1); + expect(body.results[0].data.finalText).toBe('heading to the anvil now'); + expect(receivedSays).toContain('heading to the anvil now'); + }); + + test('POST /say splits long messages using the relay cap', async () => { + const before = receivedSays.length; + const text = Array.from({ length: 60 }, (_, i) => `word${i}`).join(' '); // > 400 chars + const res = await fetch(`${BASE}/say/tbot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }) + }); + const body: any = await res.json(); + expect(body.ok).toBe(true); + expect(body.results.length).toBeGreaterThan(1); + const chunks = receivedSays.slice(before); + expect(chunks.length).toBe(body.results.length); + for (const chunk of chunks) expect(chunk.length).toBeLessThanOrEqual(400); + expect(chunks.join(' ')).toBe(text); + }, 15000); + + test('POST /say for an unconnected bot fails fast with 409', async () => { + const start = Date.now(); + const res = await fetch(`${BASE}/say/ghost`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'anyone home?' }) + }); + expect(res.status).toBe(409); + const body: any = await res.json(); + expect(body.error).toMatch(/not connected/i); + expect(Date.now() - start).toBeLessThan(2000); + }); + + test('POST /say answers a failed result within its budget when the client never replies', async () => { + const start = Date.now(); + const res = await fetch(`${BASE}/say/tbot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'noreply please', timeoutMs: 2000 }) + }); + expect(res.status).toBe(502); + const body: any = await res.json(); + expect(body.ok).toBe(false); + expect(body.results[0].reason).toBe('timeout'); + expect(Date.now() - start).toBeLessThan(5000); + }, 10000); + + test('POST /say rejects a missing text body without dispatching', async () => { + const res = await fetch(`${BASE}/say/tbot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + expect(res.status).toBe(400); + }); +}); + +describe('chat CLI over HTTP', () => { + async function runCli(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const proc = Bun.spawn({ + cmd: ['bun', join(REPO_ROOT, 'sdk', 'chat.ts'), ...args, '--server', `localhost:${PORT}`], + cwd: REPO_ROOT, + stdout: 'pipe', + stderr: 'pipe' + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited + ]); + return { exitCode, stdout, stderr }; + } + + test('sends a message end-to-end', async () => { + const { exitCode, stdout } = await runCli(['tbot', 'cli says hi']); + expect(exitCode).toBe(0); + expect(stdout).toContain('Sent: cli says hi'); + expect(receivedSays).toContain('cli says hi'); + }, 20000); + + test('reads chat end-to-end', async () => { + const { exitCode, stdout } = await runCli(['tbot', '--limit', '50']); + expect(exitCode).toBe(0); + expect(stdout).toContain('Partner: meet me at the bank'); + }, 20000); + + test('reports a dead relay within the chunk budget instead of stalling', async () => { + const start = Date.now(); + const { exitCode, stderr } = await runCli(['tbot', 'noreply again']); + expect(exitCode).toBe(1); + expect(stderr).toMatch(/timed out/i); + expect(Date.now() - start).toBeLessThan(20000); + }, 30000); +}); diff --git a/server/gateway/gateway.ts b/server/gateway/gateway.ts index 7a6cd4412..0a21a9cee 100644 --- a/server/gateway/gateway.ts +++ b/server/gateway/gateway.ts @@ -10,6 +10,9 @@ import type { SyncToSDKMessage, SDKConnectionMode } from './types'; +import { PLAYER_CHAT_TYPES } from './types'; +import { ChatHistory } from '../../sdk/chat-history'; +import { chunkMessage } from '../../sdk/chunking'; const GATEWAY_PORT = parseInt(process.env.AGENT_PORT || '7780'); @@ -129,7 +132,7 @@ interface BotSession { // Session status for diagnostics type SessionStatus = 'active' | 'stale' | 'dead'; -const STALE_THRESHOLD_MS = 8000; // 30 seconds without state = stale +const STALE_THRESHOLD_MS = 8000; // 8 seconds without state = stale (bots publish ~every tick) function getSessionStatus(session: BotSession): SessionStatus { if (!session.ws) return 'dead'; @@ -174,6 +177,26 @@ const botSessions = new Map(); // username -> BotSessio const sdkSessions = new Map(); // sessionId -> SDKSession const wsToType = new Map(); +// Per-bot accumulated chat, fed from state snapshots (same merge logic the SDK +// uses). Keyed by username so history survives bot reconnects/takeovers. +// Backs the HTTP GET /chat/:username endpoint, which lets chat tools read +// recent messages with a single bounded request instead of opening an observer +// WebSocket and waiting for a state frame. +const chatHistories = new Map(); +function chatHistoryFor(username: string): ChatHistory { + let history = chatHistories.get(username); + if (!history) { + history = new ChatHistory(); + chatHistories.set(username, history); + } + return history; +} + +// Actions dispatched from HTTP requests (POST /say/:username) awaiting their +// actionResult from the bot client. Keyed by actionId; entries self-expire. +const pendingHttpActions = new Map void; timeout: ReturnType }>(); +let httpActionSeq = 0; + // Monotonic counter so server-generated session ids can never collide, even // within the same millisecond. let sdkSessionSeq = 0; @@ -351,6 +374,18 @@ const SyncModule = { const actionId = message.actionId || session.currentActionId || undefined; console.log(`[Gateway] [${session.username}] Action result: ${message.result.success ? 'success' : 'failed'} - ${message.result.message}`); + // Actions dispatched over HTTP resolve their waiting request here. + if (actionId) { + const pendingHttp = pendingHttpActions.get(actionId); + if (pendingHttp) { + pendingHttpActions.delete(actionId); + clearTimeout(pendingHttp.timeout); + pendingHttp.resolve(message.result); + session.currentActionId = null; + return; + } + } + for (const sdkSession of this.getSDKSessionsForBot(session.username)) { this.sendToSDK(sdkSession, { type: 'sdk_action_result', @@ -365,6 +400,9 @@ const SyncModule = { if (message.type === 'state' && message.state) { session.lastState = message.state; session.lastStateReceivedAt = Date.now(); + if (message.state.gameMessages?.length) { + chatHistoryFor(session.username).record(message.state.gameMessages); + } for (const sdkSession of this.getSDKSessionsForBot(session.username)) { this.sendToSDK(sdkSession, { type: 'sdk_state', @@ -602,6 +640,47 @@ const SyncModule = { } }; +// ============ HTTP Chat Helpers ============ + +/** + * Auth for the HTTP chat endpoints - same per-bot credential check as + * sdk_connect. Password comes from the JSON body, an Authorization: Bearer + * header, or a ?password= query param (a no-op unless LOGIN_SERVER is on, + * matching the WebSocket paths). + */ +async function authenticateHttpRequest(req: Request, url: URL, username: string, bodyPassword?: unknown): Promise<{ success: boolean; error?: string }> { + const header = req.headers.get('authorization'); + const bearer = header?.startsWith('Bearer ') ? header.slice(7) : undefined; + const password = typeof bodyPassword === 'string' && bodyPassword !== '' + ? bodyPassword + : (bearer ?? url.searchParams.get('password') ?? ''); + return authenticateSDK(username, password); +} + +/** + * Dispatch one say chunk to the bot client and wait (bounded) for its + * actionResult. Never rejects: a client that doesn't answer inside the budget + * yields a failed result, so the HTTP handler always responds. + */ +function dispatchHttpSay(botSession: BotSession, text: string, timeoutMs: number): Promise<{ success: boolean; message: string; data?: any; reason?: string }> { + const actionId = `http-${++httpActionSeq}-${Math.random().toString(36).slice(2, 8)}`; + return new Promise(resolve => { + const timeout = setTimeout(() => { + pendingHttpActions.delete(actionId); + resolve({ success: false, message: `Say timed out after ${timeoutMs}ms (game client did not answer)`, reason: 'timeout' }); + }, timeoutMs); + pendingHttpActions.set(actionId, { resolve, timeout }); + // Also track on the session so clients that don't echo actionId still route back. + botSession.currentActionId = actionId; + SyncModule.sendToBot(botSession, { + type: 'action', + action: { type: 'say', message: text, reason: 'HTTP chat' }, + actionId, + actionTimeoutMs: timeoutMs + }); + }); +} + // ============ Message Router ============ /** @@ -679,7 +758,7 @@ console.log(`[Gateway] Starting Gateway Service on port ${GATEWAY_PORT}...`); const server = Bun.serve({ port: GATEWAY_PORT, - fetch(req, server) { + async fetch(req, server) { const url = new URL(req.url); // WebSocket upgrade @@ -692,8 +771,8 @@ const server = Bun.serve({ // CORS headers const corsHeaders = { 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' }; if (req.method === 'OPTIONS') { @@ -718,6 +797,7 @@ const server = Bun.serve({ status: botSession ? getSessionStatus(botSession) : 'dead', inGame: isConnected ? (botSession?.lastState?.inGame || false) : false, stateAge, + maxMessageLength: botSession?.maxMessageLength ?? null, controllers, observers, player: isConnected && botSession?.lastState?.player ? { @@ -732,6 +812,95 @@ const server = Bun.serve({ }); } + const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body, null, 2), { + status, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + + // Read recent chat: GET /chat/:username?limit=20&system=1 + // + // Serves from the gateway's accumulated per-bot history, so one bounded + // HTTP request replaces the observer-WebSocket dance (connect, wait for + // a state frame, read, disconnect). Over TLS tunnels that dance is a + // real handshake per invocation and the dominant source of chat-tool + // flakiness. + const chatMatch = url.pathname.match(/^\/chat\/(.+)$/); + if (chatMatch && chatMatch[1] && req.method === 'GET') { + const username = decodeURIComponent(chatMatch[1]); + const auth = await authenticateHttpRequest(req, url, username); + if (!auth.success) { + return jsonResponse({ error: `Authentication failed: ${auth.error}` }, 401); + } + + const limitRaw = parseInt(url.searchParams.get('limit') || '20', 10); + const limit = Number.isFinite(limitRaw) ? limitRaw : 20; + const systemParam = url.searchParams.get('system'); + const includeSystem = systemParam === '1' || systemParam === 'true'; + const types = new Set(includeSystem ? [0, ...PLAYER_CHAT_TYPES] : PLAYER_CHAT_TYPES); + + const all = (chatHistories.get(username)?.all() ?? []).filter(m => types.has(m.type)); + const botSession = botSessions.get(username); + return jsonResponse({ + messages: limit > 0 ? all.slice(-limit) : all, + botStatus: botSession ? getSessionStatus(botSession) : 'dead' + }); + } + + // Send chat: POST /say/:username body: {"text": "...", "password"?: "...", "timeoutMs"?: n} + // + // Relays through the bot's game client like observer 'say' does, but as + // a single request/response with a hard per-chunk deadline - a caller + // can trust that this either answers or fails within its budget. + const sayMatch = url.pathname.match(/^\/say\/(.+)$/); + if (sayMatch && sayMatch[1] && req.method === 'POST') { + const username = decodeURIComponent(sayMatch[1]); + let body: any; + try { + body = await req.json(); + } catch { + return jsonResponse({ error: 'Body must be JSON: {"text": "..."}' }, 400); + } + const text = typeof body?.text === 'string' ? body.text.trim() : ''; + if (!text) { + return jsonResponse({ error: 'Missing "text"' }, 400); + } + const auth = await authenticateHttpRequest(req, url, username, body?.password); + if (!auth.success) { + return jsonResponse({ error: `Authentication failed: ${auth.error}` }, 401); + } + + const botSession = botSessions.get(username); + if (!botSession || !botSession.ws) { + return jsonResponse({ + error: `Bot not connected - no game client is logged in as '${username}', so there is nothing to relay chat through` + }, 409); + } + + const timeoutRaw = Number(body?.timeoutMs); + const perChunkTimeout = Math.min(Math.max(Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 10000, 2000), 30000); + const chunks = chunkMessage(text, botSession.maxMessageLength ?? 80); + + const results: any[] = []; + let ok = true; + for (let i = 0; i < chunks.length; i++) { + const result = await dispatchHttpSay(botSession, chunks[i]!, perChunkTimeout); + results.push({ chunk: chunks[i], ...result }); + if (!result.success) { + // The relay is broken; burning the timeout again per + // remaining chunk helps nobody. + ok = false; + for (let j = i + 1; j < chunks.length; j++) { + results.push({ chunk: chunks[j], success: false, message: 'Skipped: previous chunk failed' }); + } + break; + } + if (i < chunks.length - 1) { + await new Promise(resolve => setTimeout(resolve, 600)); // ~1 tick so chunks don't collide + } + } + return jsonResponse({ ok, results }, ok ? 200 : 502); + } + // Status endpoint (admin/debugging - more detailed) if (url.pathname === '/' || url.pathname === '/status') { const bots: Record = {}; @@ -769,6 +938,8 @@ const server = Bun.serve({ Endpoints: - GET /status All connections status - GET /status/:username Per-bot status (controllers, observers) +- GET /chat/:username Recent chat (?limit=20&system=1) +- POST /say/:username Send chat ({"text": "..."}); relayed through the bot's game client WebSocket: - ws://localhost:${GATEWAY_PORT} Bot/SDK connections