tinyrelaycommit

Commit 15a0a11c52f8

browse files at this commit | history

1diff --git a/docs/webmcp.md b/docs/webmcp.md
2index df765aa..603b76e 100644
3--- a/docs/webmcp.md
4+++ b/docs/webmcp.md
5@@ -1,6 +1,10 @@
6 # Browser tools
7
8-Every page registers tools for your browser agent; **Manage > Health** reports whether they are ready, and Chrome's Application panel lists them with their calls. The agent can browse repositories, files, issues and pull requests, list and read wiki pages and merge requests, list the requests that wait for your decision, inspect service status, manage background jobs, create backups, update relay settings, open Nostr links, the Files page, wiki pages and the Approvals page, read this device's notification state and, as the owner, send a test notification. Turning notifications on stays a manual step, since the browser asks the person for permission.
9+Every page registers tools for your browser agent; **Manage > Health** reports whether they are ready, and Chrome's Application panel lists them with their calls. The agent can browse repositories, files, issues and pull requests, read chat rooms and threads, list and read wiki pages and merge requests, list the requests that wait for your decision, inspect service status, manage background jobs, create backups, update relay settings, open Nostr links, the Files page, rooms, wiki pages and the Approvals page, read this device's notification state and, as the owner, send a test notification. Turning notifications on stays a manual step, since the browser asks the person for permission.
10+
11+## Rooms
12+
13+Rooms have their own tools. `tiny.list_rooms` lists the rooms you can see, `tiny.read_room` reads a room's members and newest messages, `tiny.read_thread` reads a thread's root and replies, and `tiny.open_room` opens a room in this tab. `tiny.post_message` posts a chat message to a room; it signs the message with your connected signer and lists mentioned keys as `p` tags so the relay notifies them.
14
15 ## Wiki
16
17diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js
18index ae11deb..5928f4c 100644
19--- a/internal/webui/webmcp.js
20+++ b/internal/webui/webmcp.js
21@@ -112,6 +112,14 @@
22 register("tiny.read_merge_request", "Read a wiki merge request by event id with its answer, the proposed version and the destination author's current version.",
23 object({id: hash}, ["id"]), reads, (input, signal) => query("browsewikimerge", input, signal));
24
25+ const room = {type: "string", pattern: "^[a-z0-9_-]{1,64}$"};
26+ register("tiny.list_rooms", "List the chat rooms you can see with their access rule, member count and last message time.",
27+ object({cursor: text, limit}), reads, (input, signal) => query("browserooms", input, signal));
28+ register("tiny.read_room", "Read a room: its members with roles and its newest messages, threads, replies and reactions, newest first. Use cursor for older messages.",
29+ object({id: room, cursor: text, limit}, ["id"]), reads, (input, signal) => query("browseroom", input, signal));
30+ register("tiny.read_thread", "Read a thread in a room: the root message and its replies, newest first.",
31+ object({id: room, event: hash, cursor: text, limit}, ["id", "event"]), reads, (input, signal) => query("browsethread", input, signal));
32+
33 const readMethods = ["stats", "getpolicy", "listaudit", "listjobs", "listbackups", "listdumps", "deliverystatus", "storagestats", "gitstorage", "listconnections", "listmembers"];
34 register("tiny.read_management", "Read relay configuration, jobs, backups, delivery status or members using your connected signer. gitstorage requires owner and repo.",
35 object({method: {type: "string", enum: readMethods}, owner: pubkey, repo: id}, ["method"]), reads,
36@@ -159,6 +167,30 @@
37 const search = params.toString();
38 return open("/wiki/" + encodeURIComponent(input.d) + (search ? "?" + search : ""));
39 });
40+ register("tiny.open_room", "Open a chat room in this tab, or the rooms list when no id is given.",
41+ object({id: room}), {}, input => open(input.id ? "/rooms/" + encodeURIComponent(input.id) : "/rooms"));
42+
43+ // post_message signs a chat message with the connected signer and
44+ // publishes it once. Keys named in mentions become p tags.
45+ async function postMessage(input, signal) {
46+ const content = String(input.text || "").trim();
47+ if (!content) throw new Error("Enter a message.");
48+ if (!window.nostr?.signEvent) throw new Error("Connect a Nostr signer first.");
49+ if (!window.tinySignedFetch) throw new Error("The signer is still loading. Try again.");
50+ const tags = [["h", input.room]];
51+ for (const key of input.mentions || []) if (!tags.some(tag => tag[0] === "p" && tag[1] === key)) tags.push(["p", key]);
52+ const unsigned = {kind: 9, created_at: Math.floor(Date.now() / 1000), tags, content};
53+ const expected = JSON.stringify(unsigned);
54+ const event = await window.nostr.signEvent(JSON.parse(expected));
55+ const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});
56+ if (actual !== expected || (window.NostrSigner?.verifyEvent && !window.NostrSigner.verifyEvent(event))) throw new Error("The signer returned an invalid or changed event.");
57+ const response = await window.tinySignedFetch(local("/events"), "POST", JSON.stringify(event), {contentType: "application/json", signal});
58+ const value = await responseJSON(response);
59+ if (value.accepted !== true) throw new Error(value.message || "The relay rejected the message.");
60+ return {id: event.id, room: input.room, created_at: event.created_at};
61+ }
62+ register("tiny.post_message", "Post a chat message to a room as the connected signer. mentions lists 64-character hex keys to notify.",
63+ object({room, text: {type: "string", minLength: 1, maxLength: 4000}, mentions: {type: "array", items: pubkey}}, ["room", "text"]), changes, postMessage);
64 register("tiny.open_link", "Open a nostr link in this tab: an npub, nprofile, note, nevent, naddr or 64-character event id, with or without a nostr: or web+nostr: prefix.",
65 object({target: {type: "string", minLength: 1, maxLength: 512}}, ["target"]), {}, input => open("/open?target=" + encodeURIComponent(input.target)));
66 register("tiny.read_notifications", "Read whether this device receives relay notifications, which categories it chose and the browser permission state.",
67diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs
68index 408b475..6a2813e 100644
69--- a/scripts/webmcp.test.mjs
70+++ b/scripts/webmcp.test.mjs
71@@ -246,3 +246,47 @@ test("wiki tools read pages and merge requests and open pages in the tenant", as
72 assert.equal(opened.searchParams.get("edit"), "1");
73 assert.equal(opened.searchParams.get("version"), "b".repeat(64));
74 });
75+
76+test("room tools read rooms and threads, open rooms and post signed messages", async () => {
77+ const {finalizeEvent, generateSecretKey, verifyEvent} = await import("nostr-tools/pure");
78+ const requests = [], calls = [];
79+ const {tools, sandbox} = await browser({path: "/r/work/tools", fetch: async url => {
80+ requests.push(new URL(url, "https://tiny.example"));
81+ return new Response(JSON.stringify({items: [{id: "build"}]}));
82+ }, signedFetch: async (url, method, body, options) => {
83+ calls.push({url, method, body: JSON.parse(body), options});
84+ return new Response(JSON.stringify(calls.length > 1 ? {accepted: false, message: "restricted: members only"} : {accepted: true}));
85+ }});
86+ const secret = generateSecretKey();
87+ sandbox.nostr = {signEvent: async event => finalizeEvent(JSON.parse(JSON.stringify(event)), secret)};
88+ sandbox.NostrSigner = {verifyEvent};
89+ for (const [name, method, input] of [
90+ ["tiny.list_rooms", "browserooms", {limit: 5}],
91+ ["tiny.read_room", "browseroom", {id: "build", cursor: "c1"}],
92+ ["tiny.read_thread", "browsethread", {id: "build", event: "a".repeat(64)}]
93+ ]) {
94+ const tool = tools.get(name);
95+ assert.equal(tool.annotations.readOnlyHint, true);
96+ await tool.execute(input);
97+ const url = requests.at(-1);
98+ assert.equal(url.pathname, "/r/work/webmcp/query");
99+ assert.equal(url.searchParams.get("method"), method);
100+ assert.deepEqual(JSON.parse(url.searchParams.get("params")), [input]);
101+ }
102+ await tools.get("tiny.open_room").execute({id: "build"});
103+ assert.equal(new URL(sandbox.opened).pathname, "/r/work/rooms/build");
104+ await tools.get("tiny.open_room").execute({});
105+ assert.equal(new URL(sandbox.opened).pathname, "/r/work/rooms");
106+ const post = tools.get("tiny.post_message");
107+ assert.equal(post.annotations.consequentialHint, true);
108+ const result = await post.execute({room: "build", text: "hello", mentions: ["b".repeat(64), "b".repeat(64)]});
109+ assert.equal(calls[0].url, "/r/work/events");
110+ assert.equal(calls[0].method, "POST");
111+ assert.equal(calls[0].options.contentType, "application/json");
112+ assert.equal(calls[0].body.kind, 9);
113+ assert.deepEqual(calls[0].body.tags, [["h", "build"], ["p", "b".repeat(64)]]);
114+ assert.equal(verifyEvent(calls[0].body), true);
115+ assert.equal(result.structuredContent.result.id, calls[0].body.id);
116+ await assert.rejects(post.execute({room: "build", text: "again"}), /members only/);
117+ await assert.rejects(post.execute({room: "build", text: " "}), /Enter a message/);
118+});

About

ownerc01503e8035f
visibilitypublic
head15a0a11c52f8

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs