From 1b66066b4f1ba4ef9488365cd9f562a943e79796 Mon Sep 17 00:00:00 2001 From: Sivert <316918682+grytchat@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:29:51 +0100 Subject: [PATCH] Calls that return the server's answer, for the MCP (GRYT-2005) history, thread, search, startThread and diagnostics each emit and wait for the matching reply, with a 10 second timeout. send resolves with the posted message by matching its nonce, and can reply or post into a thread. members and channels keep the last lists the server sent. fetch is left as it was. It never had a chat:history listener, so it has never answered anything; history is the working version. Co-Authored-By: Claude Opus 5.5 --- .changeset/bot-awaitable-calls.md | 5 + src/client.ts | 239 +++++++++++++++++++++++++++++- src/index.ts | 2 + src/types.ts | 22 +++ 4 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 .changeset/bot-awaitable-calls.md diff --git a/.changeset/bot-awaitable-calls.md b/.changeset/bot-awaitable-calls.md new file mode 100644 index 0000000..dbd911e --- /dev/null +++ b/.changeset/bot-awaitable-calls.md @@ -0,0 +1,5 @@ +--- +"@gryt/bot": minor +--- + +Calls that return what the server answered: `history`, `thread`, `search`, `startThread` and `diagnostics`. `send` now resolves with the message it posted, and takes `replyTo` and `threadId`. `bot.members` and `bot.channels` hold the last lists the server sent, and a `Message` carries its `threadId`. diff --git a/src/client.ts b/src/client.ts index a813c3a..8109057 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { decodeJwt } from "jose"; @@ -20,7 +21,9 @@ import type { Member, Message, Permission, + SearchHit, ServerInfo, + ThreadSummary, } from "./types.ts"; export interface GrytBotOptions { @@ -67,6 +70,8 @@ export interface GrytBotEvents { const DEFAULT_PREFIX = "!"; const DEFAULT_IDENTITY_PATH = "./gryt-bot-identity.json"; +const REQUEST_TIMEOUT_MS = 10_000; + /** Refresh a little before the 15-minute access token actually expires. */ const TOKEN_REFRESH_MS = 10 * 60 * 1000; @@ -77,6 +82,28 @@ function httpBase(host: string, secure?: boolean): string { return `${scheme}://${host.replace(/\/+$/, "")}`; } +function toThread(raw: unknown): ThreadSummary | null { + const t = raw as { + thread_id?: string; + conversation_id?: string; + root_message_id?: string; + title?: string | null; + reply_count?: number; + status?: string; + last_message_at?: string; + }; + if (!t?.thread_id || !t.root_message_id) return null; + return { + threadId: t.thread_id, + conversationId: t.conversation_id ?? "", + rootMessageId: t.root_message_id, + title: t.title ?? null, + replyCount: t.reply_count ?? 0, + status: t.status ?? "open", + lastMessageAt: toDate(t.last_message_at), + }; +} + function toDate(value: unknown): Date { const d = value instanceof Date ? value : new Date(String(value ?? "")); return Number.isFinite(d.getTime()) ? d : new Date(0); @@ -103,6 +130,8 @@ export class GrytBot extends EventEmitter { private serverKnows = new Set(PERMISSIONS_BEFORE_CATALOGUE); private serverUserId: string | null = null; private stopped = false; + private memberList: Member[] = []; + private channelList: Channel[] = []; private announcedWait = false; constructor(options: GrytBotOptions) { @@ -135,6 +164,16 @@ export class GrytBot extends EventEmitter { return [...this.permissionSet]; } + /** The last member list the server sent. Empty until it has. */ + get members(): Member[] { + return this.memberList; + } + + /** The channels this bot can see. Empty until `ready`. */ + get channels(): Channel[] { + return this.channelList; + } + /** The server's own view of this bot. Null until `ready`. */ get serverInfo(): ServerInfo | null { return this.info; @@ -215,9 +254,26 @@ export class GrytBot extends EventEmitter { * Post in a channel. Throws before sending if the bot lacks `send_messages`, naming the * permission, so the stack trace points at the call rather than a socket handler. */ - async send(conversationId: string, text: string): Promise { + async send( + conversationId: string, + text: string, + opts: { replyTo?: string; threadId?: string } = {}, + ): Promise { this.require("send_messages"); - this.emitAuthed("chat:send", { conversationId, text }); + const nonce = randomUUID(); + return this.request( + () => + this.emitAuthed("chat:send", { + conversationId, + text, + nonce, + ...(opts.replyTo ? { replyToMessageId: opts.replyTo } : {}), + ...(opts.threadId ? { threadId: opts.threadId } : {}), + }), + "chat:new", + (raw: { nonce?: string }) => (raw?.nonce === nonce ? this.toMessage(raw) : undefined), + ["chat:error", "thread:error"], + ); } async react(conversationId: string, messageId: string, reactionSrc: string): Promise { @@ -238,6 +294,118 @@ export class GrytBot extends EventEmitter { this.emitAuthed("chat:delete", { conversationId, messageId }); } + /** A page of a channel, oldest first. `before` is a message's ISO timestamp. */ + async history( + conversationId: string, + opts: { limit?: number; before?: string } = {}, + ): Promise<{ messages: Message[]; threads: ThreadSummary[]; hasMore: boolean }> { + this.require("read_messages"); + return this.request( + () => this.connection?.emit("chat:fetch", { conversationId, limit: opts.limit ?? 50, before: opts.before }), + "chat:history", + (raw: { conversation_id?: string; before?: string; items?: unknown[]; threads?: unknown[]; hasMore?: boolean }) => { + if (raw?.conversation_id !== conversationId || raw.before !== opts.before) return undefined; + return { + messages: this.toMessages(raw.items), + threads: (raw.threads ?? []).map(toThread).filter((t): t is ThreadSummary => t !== null), + hasMore: raw.hasMore === true, + }; + }, + ["chat:error"], + ); + } + + /** A thread's root and replies, oldest first. */ + async thread( + conversationId: string, + threadId: string, + opts: { limit?: number; before?: string } = {}, + ): Promise<{ thread: ThreadSummary | null; root: Message | null; replies: Message[]; hasMore: boolean }> { + this.require("read_messages"); + return this.request( + () => this.connection?.emit("thread:fetch", { conversationId, threadId, limit: opts.limit ?? 50, before: opts.before }), + "thread:history", + (raw: { thread?: unknown; root?: unknown; items?: unknown[]; hasMore?: boolean; before?: string }) => { + const thread = toThread(raw?.thread); + if (thread?.threadId !== threadId || raw.before !== opts.before) return undefined; + return { + thread, + root: raw.root ? this.toMessage(raw.root) : null, + replies: this.toMessages(raw.items), + hasMore: raw.hasMore === true, + }; + }, + ["thread:error"], + ); + } + + /** Text channels this bot can read. Something has to narrow it, or the server returns nothing. */ + async search(opts: { + query?: string; + conversationId?: string; + senderServerUserId?: string; + before?: string; + after?: string; + limit?: number; + }): Promise<{ hits: SearchHit[]; total: number }> { + this.require("read_messages"); + const requestId = randomUUID(); + return this.request( + () => + this.connection?.emit("chat:search", { + requestId, + query: opts.query, + conversationId: opts.conversationId, + senderServerId: opts.senderServerUserId, + before: opts.before, + after: opts.after, + limit: opts.limit, + }), + "chat:search:results", + (raw: { requestId?: string; items?: Record[]; total?: number; error?: string }) => { + if (raw?.requestId !== requestId) return undefined; + if (raw.error) throw new Error(`Search refused: ${raw.error}`); + return { + hits: (raw.items ?? []).map((h) => ({ + conversationId: String(h.conversation_id), + messageId: String(h.message_id), + threadId: (h.thread_id as string | null) ?? null, + senderServerUserId: String(h.sender_server_id), + senderNickname: (h.sender_display_name as string | null) ?? null, + createdAt: toDate(h.created_at), + snippet: String(h.snippet ?? ""), + })), + total: raw.total ?? 0, + }; + }, + ); + } + + /** Start a thread on a message. Starting one that exists returns that one. */ + async startThread(conversationId: string, rootMessageId: string, title?: string): Promise { + this.require("send_messages"); + return this.request( + () => this.emitAuthed("thread:create", { conversationId, rootMessageId, title }), + "thread:created", + (raw: unknown) => { + const t = toThread(raw); + return t?.rootMessageId === rootMessageId ? t : undefined; + }, + ["thread:error"], + ); + } + + /** Voice rooms, latency and SFU numbers. Needs `view_diagnostics`, which owners hold. */ + async diagnostics(): Promise> { + this.require("view_diagnostics"); + return this.request( + () => this.emitAuthed("server:diagnostics", {}), + "server:diagnostics", + (raw: Record) => raw, + ["server:error"], + ); + } + /** Ask for the last N messages in a channel. Answered on the `message` event. */ async fetch(conversationId: string, limit = 50): Promise { this.require("read_messages"); @@ -255,6 +423,54 @@ export class GrytBot extends EventEmitter { ); } + /** + * Emit, then resolve with the first `reply` that `pick` turns into a value. Any of `errors` + * rejects it: the server mostly does not say which call a refusal answers. + */ + private request( + emit: () => void, + reply: string, + pick: (raw: R) => T | undefined | null, + errors: string[] = [], + ): Promise { + const socket = this.connection; + if (!socket) return Promise.reject(new Error(`Not joined to ${this.host} yet — await bot.start() first.`)); + return new Promise((resolve, reject) => { + const done = () => { + clearTimeout(timer); + socket.off(reply, onReply); + for (const e of errors) socket.off(e, onError); + }; + const onReply = (raw: R) => { + try { + const value = pick(raw); + if (value === undefined || value === null) return; + done(); + resolve(value); + } catch (err) { + done(); + reject(err); + } + }; + const onError = (payload: ServerRefusal | string) => { + done(); + reject(this.toError(payload)); + }; + const timer = setTimeout(() => { + done(); + reject(new Error(`${this.host} did not answer ${reply} within ${REQUEST_TIMEOUT_MS / 1000}s.`)); + }, REQUEST_TIMEOUT_MS); + socket.on(reply, onReply); + for (const e of errors) socket.on(e, onError); + try { + emit(); + } catch (err) { + done(); + reject(err); + } + }); + } + private emitAuthed(event: string, payload: Record): void { if (!this.connection || !this.accessToken) { throw new Error(`Not joined to ${this.host} yet — await bot.start() first.`); @@ -293,7 +509,9 @@ export class GrytBot extends EventEmitter { }); socket.on("members:list", (members: unknown) => { - if (Array.isArray(members)) this.emit("members", members as Member[]); + if (!Array.isArray(members)) return; + this.memberList = members as Member[]; + this.emit("members", this.memberList); }); socket.on("chat:new", (raw: unknown) => { @@ -412,7 +630,10 @@ export class GrytBot extends EventEmitter { role: d.server_info.role ?? "", }; - if (Array.isArray(d.channels)) this.emit("channels", d.channels); + if (Array.isArray(d.channels)) { + this.channelList = d.channels; + this.emit("channels", d.channels); + } // `server:details` is re-sent whenever anybody's role changes, so this is also how a bot // learns it has been promoted mid-run. Only the first one is `ready`. @@ -429,6 +650,7 @@ export class GrytBot extends EventEmitter { created_at?: string; edited_at?: string | null; reply_to_message_id?: string | null; + thread_id?: string | null; enriched_attachments?: Attachment[]; }; if (!m?.conversation_id || !m?.message_id) return null; @@ -444,11 +666,16 @@ export class GrytBot extends EventEmitter { createdAt: toDate(m.created_at), editedAt: m.edited_at ? toDate(m.edited_at) : null, replyToMessageId: m.reply_to_message_id ?? null, + threadId: m.thread_id ?? null, isSystem: sender === "system", isOwn: sender !== "" && sender === this.serverUserId, }; } + private toMessages(items: unknown[] | undefined): Message[] { + return (items ?? []).map((m) => this.toMessage(m)).filter((m): m is Message => m !== null); + } + private async runCommands(message: Message): Promise { if (!this.prefix || !message.text || message.isSystem) return; @@ -475,7 +702,9 @@ export class GrytBot extends EventEmitter { message, rest: parsed.rest, args: splitArgs(parsed.rest), - reply: (text: string) => this.send(message.conversationId, text), + reply: async (text: string) => { + await this.send(message.conversationId, text); + }, react: (emoji: string) => this.react(message.conversationId, message.messageId, emoji), }; diff --git a/src/index.ts b/src/index.ts index 6003a78..682afee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,5 +12,7 @@ export type { Member, Message, Permission, + SearchHit, ServerInfo, + ThreadSummary, } from "./types.ts"; diff --git a/src/types.ts b/src/types.ts index 5612d5c..07d9ce6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,8 @@ export interface Message { createdAt: Date; editedAt: Date | null; replyToMessageId: string | null; + /** Set for a reply inside a thread. */ + threadId: string | null; /** True for the "X joined the server" kind of message, which has no sender. */ isSystem: boolean; /** True when this bot sent it. Handlers are not called for these. */ @@ -40,6 +42,26 @@ export interface Member { avatarFileId: string | null; } +export interface ThreadSummary { + threadId: string; + conversationId: string; + rootMessageId: string; + title: string | null; + replyCount: number; + status: string; + lastMessageAt: Date; +} + +export interface SearchHit { + conversationId: string; + messageId: string; + threadId: string | null; + senderServerUserId: string; + senderNickname: string | null; + createdAt: Date; + snippet: string; +} + export interface Channel { id: string; name: string;