@kybernesis/buzz 0.1.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,11 +40,24 @@ A member that only ever speaks looks offline between sentences, so the bridge al
40
40
  what a person's client publishes: presence on a heartbeat, a typing indicator while a turn is
41
41
  running, and 👀 on a message the moment it is picked up.
42
42
 
43
+ ## More than one community
44
+
45
+ One agent, one identity, many communities. Membership is each workspace's to
46
+ grant, so being in two is two connections rather than two agents — invite the
47
+ same public key to each and list both relays:
48
+
49
+ ```bash
50
+ BUZZ_RELAY="wss://one.communities.buzz.xyz,wss://two.communities.buzz.xyz"
51
+ ```
52
+
53
+ Conversations never mix: a channel belongs to exactly one community, and
54
+ sessions are keyed by channel.
55
+
43
56
  ## Configuration
44
57
 
45
58
  | Variable | Meaning |
46
59
  | --- | --- |
47
- | `BUZZ_RELAY` | The workspace relay (`wss://…`) |
60
+ | `BUZZ_RELAY` | The workspace relay (`wss://…`) — or several, comma-separated |
48
61
  | `BUZZ_AGENT_URL` | Where the agent listens (default `http://127.0.0.1:8000`) |
49
62
  | `BUZZ_KEYFILE` | The agent's key (default `~/.kybernesis/buzz-agent.json`) |
50
63
  | `KYBERNESIS_ISSUER` | The control plane |
package/dist/bridge.d.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  export type BuzzBridgeOptions = {
2
- /** The workspace relay, e.g. `wss://example.communities.buzz.xyz`. */
3
- relay: string;
2
+ /**
3
+ * The workspace relay, or several of them.
4
+ *
5
+ * @remarks
6
+ * One agent, one identity, many communities. A workspace's membership is the
7
+ * relay's to grant, so being in two of them is two connections rather than
8
+ * two agents — and the same public key is invited to each. Sessions stay
9
+ * keyed by channel, and a channel belongs to exactly one community, so the
10
+ * conversations never mix.
11
+ */
12
+ relay: string | readonly string[];
4
13
  /** Where the agent is listening, e.g. `http://127.0.0.1:8000`. */
5
14
  agentUrl: string;
6
15
  /** The agent's own key file. Its public half is what the workspace invited. */
@@ -16,6 +25,8 @@ export declare function buzzBridge(options: BuzzBridgeOptions): {
16
25
  /** The key the workspace has to invite for any of this to happen. */
17
26
  npub: string;
18
27
  pubkey: string;
28
+ /** The communities this agent is a member of. */
29
+ relays: string[];
19
30
  start(): void;
20
31
  /** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
21
32
  stop(): void;
package/dist/bridge.js CHANGED
@@ -24,7 +24,22 @@ export function buzzBridge(options) {
24
24
  const key = loadKey(options.keyFile);
25
25
  const identity = channelIdentity({ issuer: options.issuer, credential: options.credential });
26
26
  const log = options.onLog ?? ((message) => console.log(new Date().toISOString().slice(11, 19), message));
27
- /** One conversation per channel, which is how a channel already behaves. */
27
+ /**
28
+ * One conversation per channel — the SESSION ID, not a handle to it.
29
+ *
30
+ * @remarks
31
+ * This distinction is the difference between multiplayer working and quietly
32
+ * not. A session handle carries the client that made it, and that client
33
+ * carries one person's bearer token. Cache the handle and every later turn in
34
+ * the channel runs as whoever spoke first: a second person asks about their
35
+ * own mail and is shown the first person's, with nothing anywhere reporting
36
+ * an error.
37
+ *
38
+ * So the id is shared and the credentials are not. Each turn attaches to the
39
+ * same conversation through a client built for the person sending it, which
40
+ * is what the runtime reads to decide whose memory and whose connections the
41
+ * turn may touch.
42
+ */
28
43
  const sessions = new Map();
29
44
  /** Who has already been sent a link, so a room full of strangers is not a room full of spam. */
30
45
  const invited = new Map();
@@ -37,10 +52,13 @@ export function buzzBridge(options) {
37
52
  headers: bundle ? { "x-kybernesis-bundle": bundle } : {},
38
53
  });
39
54
  async function ask(channel, text, token, bundle) {
55
+ // Built per turn, for the person whose turn it is.
56
+ const client = clientFor(token, bundle);
40
57
  const existing = sessions.get(channel);
41
58
  if (existing) {
42
59
  try {
43
- const response = await existing.send(text);
60
+ const session = client.sessions.attach(existing);
61
+ const response = await session.send(text);
44
62
  return (await response.result()).message ?? "";
45
63
  }
46
64
  catch (error) {
@@ -50,9 +68,8 @@ export function buzzBridge(options) {
50
68
  sessions.delete(channel);
51
69
  }
52
70
  }
53
- const client = clientFor(token, bundle);
54
71
  const created = await client.sessions.create({ message: text });
55
- sessions.set(channel, created.session);
72
+ sessions.set(channel, created.session.state.sessionId);
56
73
  return (await created.response.result()).message ?? "";
57
74
  }
58
75
  /**
@@ -86,16 +103,29 @@ export function buzzBridge(options) {
86
103
  relay.reply(dm, text);
87
104
  log(`refused ${npubEncode(sender).slice(0, 16)}…: ${reason}`);
88
105
  }
89
- const relay = new BuzzRelay({
90
- url: options.relay,
91
- key,
92
- pollMs: options.pollMs,
93
- onLog: log,
94
- onMessage: (event) => {
95
- void handle(event);
96
- },
97
- });
98
- async function handle(event) {
106
+ const urls = (typeof options.relay === "string" ? [options.relay] : [...options.relay])
107
+ .map((url) => url.trim())
108
+ .filter(Boolean);
109
+ if (urls.length === 0)
110
+ throw new Error("a workspace relay is required");
111
+ /** Which connection a message arrived on, so the reply goes back the same way. */
112
+ const relays = new Map();
113
+ for (const url of urls) {
114
+ relays.set(url, new BuzzRelay({
115
+ url,
116
+ key,
117
+ pollMs: options.pollMs,
118
+ // Which community is speaking matters once there is more than one.
119
+ onLog: (message) => log(urls.length > 1 ? `${label(url)} ${message}` : message),
120
+ onMessage: (event) => {
121
+ void handle(event, url);
122
+ },
123
+ }));
124
+ }
125
+ async function handle(event, from) {
126
+ const relay = relays.get(from);
127
+ if (!relay)
128
+ return;
99
129
  const channel = event.tags.find((t) => t[0] === "h")?.[1];
100
130
  // Addressed by TAG, not by text. A name in prose is a string anyone can type; a p tag is what
101
131
  // the client emits when someone actually picks this member out of a mention list.
@@ -145,14 +175,32 @@ export function buzzBridge(options) {
145
175
  /** The key the workspace has to invite for any of this to happen. */
146
176
  npub: key.npub,
147
177
  pubkey: key.publicKey,
178
+ /** The communities this agent is a member of. */
179
+ relays: urls,
148
180
  start() {
149
- relay.connect();
150
- log("listening — turns run as whoever sent them");
181
+ for (const relay of relays.values())
182
+ relay.connect();
183
+ log(urls.length > 1
184
+ ? `listening on ${urls.length} communities — turns run as whoever sent them`
185
+ : "listening — turns run as whoever sent them");
151
186
  },
152
187
  /** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
153
188
  stop() {
154
- relay.setPresence("offline");
155
- setTimeout(() => relay.close(), 250);
189
+ for (const relay of relays.values())
190
+ relay.setPresence("offline");
191
+ setTimeout(() => {
192
+ for (const relay of relays.values())
193
+ relay.close();
194
+ }, 250);
156
195
  },
157
196
  };
158
197
  }
198
+ /** A relay's host, short enough to prefix a log line with. */
199
+ function label(url) {
200
+ try {
201
+ return `[${new URL(url).hostname.split(".")[0]}]`;
202
+ }
203
+ catch {
204
+ return "[relay]";
205
+ }
206
+ }
package/dist/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { writeFileSync } from "node:fs";
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { buzzBridge } from "./bridge.js";
6
- import { asHexPubkey, loadOrCreateKey, npubEncode } from "./keys.js";
6
+ import { asHexPubkey, loadKey, loadOrCreateKey, npubEncode } from "./keys.js";
7
+ import * as profile from "./profile.js";
7
8
  /**
8
9
  * Setup and operation for an agent that lives in a workspace.
9
10
  *
@@ -32,9 +33,105 @@ function init() {
32
33
  console.log(" Anyone who talks to it is sent a sign-in link the first time, and after that");
33
34
  console.log(" their turns run as them.\n");
34
35
  }
36
+ function relayList() {
37
+ return env("BUZZ_RELAY")
38
+ .split(",")
39
+ .map((url) => url.trim())
40
+ .filter(Boolean);
41
+ }
42
+ /** Take a `--flag value` off the argument list. */
43
+ function flag(name) {
44
+ const at = process.argv.indexOf(`--${name}`);
45
+ return at === -1 ? undefined : process.argv[at + 1];
46
+ }
47
+ /**
48
+ * Say who this agent is, in every community it belongs to.
49
+ *
50
+ * A member with no profile shows as a truncated public key. Each community
51
+ * stores its own copy, so joining a second one means publishing again — which
52
+ * is what `--copy-from` is for.
53
+ */
54
+ async function setProfile() {
55
+ const key = loadKey(KEY_FILE);
56
+ const urls = relayList();
57
+ const source = flag("copy-from");
58
+ if (source) {
59
+ const copied = await profile.copy(source, urls, key);
60
+ console.log(`\n Copied ${copied.display_name ?? copied.name ?? "the profile"} from ${source} to:\n`);
61
+ for (const url of urls.filter((url) => url !== source))
62
+ console.log(` ${url}`);
63
+ console.log();
64
+ return;
65
+ }
66
+ const name = flag("name");
67
+ if (!name) {
68
+ console.error("usage: kybernesis-buzz profile --name <name> [--about …] [--picture <file|url>]");
69
+ console.error(" or: kybernesis-buzz profile --copy-from <wss://…>");
70
+ process.exit(1);
71
+ }
72
+ // A local file is uploaded to the community and becomes a URL; a URL is used
73
+ // as given. Nobody should need storage of their own to give an agent a face.
74
+ let picture = flag("picture");
75
+ if (picture && !/^https?:\/\//.test(picture)) {
76
+ const path = picture.startsWith("~/")
77
+ ? join(homedir(), picture.slice(2))
78
+ : picture;
79
+ if (!existsSync(path)) {
80
+ console.error(`\n No such image: ${path}\n`);
81
+ process.exit(1);
82
+ }
83
+ const bytes = readFileSync(path);
84
+ let uploaded;
85
+ for (const url of urls) {
86
+ try {
87
+ uploaded = await profile.uploadImage(url, key, bytes, profile.mediaTypeOf(path));
88
+ console.log(` uploaded ${path.split("/").pop()} to ${url}`);
89
+ break;
90
+ }
91
+ catch (error) {
92
+ console.log(` · ${url} — ${error.message}`);
93
+ }
94
+ }
95
+ if (!uploaded) {
96
+ console.error("\n Could not upload the image to any community.\n");
97
+ process.exit(1);
98
+ }
99
+ picture = uploaded;
100
+ }
101
+ const wanted = {
102
+ name: name.toLowerCase().replace(/\s+/g, "-"),
103
+ display_name: name,
104
+ ...(flag("about") ? { about: flag("about") } : {}),
105
+ ...(picture ? { picture } : {}),
106
+ // Said plainly rather than left for people to work out.
107
+ bot: true,
108
+ };
109
+ // Each community is asked separately, and one refusal does not stop the
110
+ // others. Membership is per community, so being in one and not another is
111
+ // the ordinary case rather than an error worth aborting on.
112
+ let published = 0;
113
+ for (const url of urls) {
114
+ try {
115
+ await profile.write(url, key, wanted);
116
+ console.log(` ✓ ${url}`);
117
+ published += 1;
118
+ }
119
+ catch (error) {
120
+ const why = error.message;
121
+ console.log(why.includes("not a relay member")
122
+ ? ` · ${url} — not a member yet, so nothing to be known as there`
123
+ : ` ✕ ${url} — ${why}`);
124
+ }
125
+ }
126
+ console.log(published > 0
127
+ ? `\n ${name} is ${key.npub.slice(0, 20)}…\n`
128
+ : "\n Nothing published — invite this key to a community first.\n");
129
+ }
35
130
  function run() {
36
131
  const bridge = buzzBridge({
37
- relay: env("BUZZ_RELAY"),
132
+ // Comma-separated: one agent can be a member of several communities, and
133
+ // each is a connection rather than another process to supervise.
134
+ relay: relayList(),
38
135
  agentUrl: process.env.BUZZ_AGENT_URL ?? "http://127.0.0.1:8000",
39
136
  keyFile: KEY_FILE,
40
137
  issuer: env("KYBERNESIS_ISSUER", "https://agent.kybernesis.ai"),
@@ -87,7 +184,13 @@ WantedBy=multi-user.target
87
184
  process.stdout.write(unit);
88
185
  }
89
186
  const command = process.argv[2];
90
- if (command === "init")
187
+ if (command === "profile") {
188
+ setProfile().catch((error) => {
189
+ console.error(`\n ${error.message}\n`);
190
+ process.exit(1);
191
+ });
192
+ }
193
+ else if (command === "init")
91
194
  init();
92
195
  else if (command === "run")
93
196
  run();
@@ -107,13 +210,15 @@ else {
107
210
  kybernesis-buzz — put an agent in a workspace
108
211
 
109
212
  init create or show this agent's identity, and what to invite
213
+ profile say who this agent is, in every community it belongs to
110
214
  run run the bridge
111
215
  service [path] print (or write) a systemd unit for it
112
216
  id <npub|hex> show a public key in both forms
113
217
 
114
218
  Environment:
115
219
 
116
- BUZZ_RELAY the workspace relay (wss://…)
220
+ BUZZ_RELAY the workspace relay (wss://…), or several,
221
+ comma-separated
117
222
  BUZZ_AGENT_URL where the agent listens (default http://127.0.0.1:8000)
118
223
  BUZZ_KEYFILE this agent's key (default ~/.kybernesis/buzz-agent.json)
119
224
  KYBERNESIS_ISSUER the control plane
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { buzzBridge, type BuzzBridge, type BuzzBridgeOptions } from "./bridge.js";
2
2
  export { BuzzRelay, type NostrEvent, type PresenceStatus, type RelayOptions, KIND_MESSAGE, KIND_REACTION, KIND_PRESENCE, KIND_TYPING, KIND_DM_OPEN, } from "./relay.js";
3
3
  export { asHexPubkey, loadKey, loadOrCreateKey, npubEncode, type AgentKey } from "./keys.js";
4
+ export * as profile from "./profile.js";
5
+ export type { Profile } from "./profile.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { buzzBridge } from "./bridge.js";
2
2
  export { BuzzRelay, KIND_MESSAGE, KIND_REACTION, KIND_PRESENCE, KIND_TYPING, KIND_DM_OPEN, } from "./relay.js";
3
3
  export { asHexPubkey, loadKey, loadOrCreateKey, npubEncode } from "./keys.js";
4
+ export * as profile from "./profile.js";
@@ -0,0 +1,63 @@
1
+ import type { AgentKey } from "./keys.js";
2
+ /**
3
+ * How an agent appears to the people in a community.
4
+ *
5
+ * @remarks
6
+ * A member with no profile shows as a truncated public key, which reads as a
7
+ * stranger rather than as the agent someone was told to talk to. So this is
8
+ * part of joining a community, not a nicety afterwards.
9
+ *
10
+ * Profiles are per relay: each community stores its own copy, and being a
11
+ * member of two means publishing to both. That is the protocol's doing, not a
12
+ * choice here — which is why `copy` exists.
13
+ */
14
+ /** NIP-01 metadata. */
15
+ export declare const KIND_PROFILE = 0;
16
+ export type Profile = {
17
+ /** The handle, lowercase and without spaces. */
18
+ name?: string;
19
+ /** The name as people should read it. */
20
+ display_name?: string;
21
+ about?: string;
22
+ /** A URL. Relays store the address, never the image. */
23
+ picture?: string;
24
+ /** Says plainly that this is an agent. Clients use it; so should we. */
25
+ bot?: boolean;
26
+ };
27
+ /** This agent's profile on one relay, if it has one there. */
28
+ export declare function read(url: string, key: AgentKey): Promise<Profile | null>;
29
+ /** Publish this agent's profile to one relay. */
30
+ export declare function write(url: string, key: AgentKey, profile: Profile): Promise<void>;
31
+ /** Take the profile an agent already has somewhere, and give it the same one here. */
32
+ export declare function copy(from: string, to: readonly string[], key: AgentKey): Promise<Profile>;
33
+ /**
34
+ * Put an image where the community can serve it, and answer with its URL.
35
+ *
36
+ * @remarks
37
+ * Blossom (BUD-01): the file is addressed by the hash of its own bytes, and the
38
+ * upload is authorised by an event carrying that hash. So the server can check
39
+ * that what arrived is what was signed for, and the same image uploaded twice
40
+ * is the same URL rather than two.
41
+ *
42
+ * It goes to the community rather than to storage of ours, which is the point:
43
+ * an agent's picture should not depend on a bucket belonging to whoever set it
44
+ * up, and nobody should need a blob token to give their agent a face.
45
+ */
46
+ export declare function uploadImage(relay: string, key: AgentKey, bytes: Uint8Array, mediaType: string): Promise<string>;
47
+ /**
48
+ * Remove everything from an image that is not the image.
49
+ *
50
+ * @remarks
51
+ * The community refuses uploads carrying metadata, and it is right to: a photo
52
+ * taken on a phone carries the phone, the time and often the place, and an
53
+ * avatar is exactly the kind of file nobody thinks about before sharing. So
54
+ * this strips rather than asks — the alternative is telling people to run
55
+ * `exiftool` before they can give their agent a face.
56
+ *
57
+ * Both formats are containers of tagged sections, and dropping a section needs
58
+ * no re-encoding: PNG chunks carry their own CRC, and JPEG segments their own
59
+ * length. The pixels are untouched.
60
+ */
61
+ export declare function stripMetadata(bytes: Uint8Array, mediaType: string): Uint8Array;
62
+ /** What kind of image this is, from its name. */
63
+ export declare function mediaTypeOf(path: string): string;
@@ -0,0 +1,276 @@
1
+ import { finalizeEvent } from "nostr-tools/pure";
2
+ /**
3
+ * How an agent appears to the people in a community.
4
+ *
5
+ * @remarks
6
+ * A member with no profile shows as a truncated public key, which reads as a
7
+ * stranger rather than as the agent someone was told to talk to. So this is
8
+ * part of joining a community, not a nicety afterwards.
9
+ *
10
+ * Profiles are per relay: each community stores its own copy, and being a
11
+ * member of two means publishing to both. That is the protocol's doing, not a
12
+ * choice here — which is why `copy` exists.
13
+ */
14
+ /** NIP-01 metadata. */
15
+ export const KIND_PROFILE = 0;
16
+ const now = () => Math.floor(Date.now() / 1000);
17
+ /**
18
+ * Connect and wait until the relay has ACCEPTED this identity.
19
+ *
20
+ * @remarks
21
+ * Answering the challenge is not the same as being authenticated, and the
22
+ * difference is one round trip. Resolving on the send races the acceptance, and
23
+ * anything published in that window is refused with `auth-required: not
24
+ * authenticated` — which reads as a permissions problem rather than as a
25
+ * client that spoke too early.
26
+ */
27
+ function connect(url, key) {
28
+ return new Promise((resolve, reject) => {
29
+ const socket = new WebSocket(url);
30
+ const failed = setTimeout(() => reject(new Error(`${url} did not answer`)), 15_000);
31
+ let challenged = null;
32
+ socket.addEventListener("message", (raw) => {
33
+ const frame = JSON.parse(String(raw.data));
34
+ if (frame[0] === "AUTH") {
35
+ const auth = finalizeEvent({
36
+ kind: 22242,
37
+ created_at: now(),
38
+ tags: [
39
+ ["relay", url],
40
+ ["challenge", String(frame[1])],
41
+ ],
42
+ content: "",
43
+ }, key.secretKey);
44
+ challenged = auth.id;
45
+ socket.send(JSON.stringify(["AUTH", auth]));
46
+ return;
47
+ }
48
+ if (frame[0] === "OK" && frame[1] === challenged) {
49
+ clearTimeout(failed);
50
+ if (frame[2] === true) {
51
+ resolve(socket);
52
+ }
53
+ else {
54
+ reject(new Error(String(frame[3] ?? "this identity was refused")));
55
+ }
56
+ }
57
+ });
58
+ socket.addEventListener("error", () => {
59
+ clearTimeout(failed);
60
+ reject(new Error(`could not reach ${url}`));
61
+ });
62
+ });
63
+ }
64
+ /** This agent's profile on one relay, if it has one there. */
65
+ export async function read(url, key) {
66
+ const socket = await connect(url, key);
67
+ return new Promise((resolve) => {
68
+ const done = (value) => {
69
+ try {
70
+ socket.close();
71
+ }
72
+ catch {
73
+ /* closing a closed socket is not an error worth raising */
74
+ }
75
+ resolve(value);
76
+ };
77
+ socket.addEventListener("message", (raw) => {
78
+ const frame = JSON.parse(String(raw.data));
79
+ if (frame[0] === "EVENT") {
80
+ const event = frame[2];
81
+ try {
82
+ done(JSON.parse(String(event.content ?? "{}")));
83
+ }
84
+ catch {
85
+ done(null);
86
+ }
87
+ }
88
+ if (frame[0] === "EOSE" || frame[0] === "CLOSED")
89
+ done(null);
90
+ });
91
+ socket.send(JSON.stringify(["REQ", "profile", { kinds: [KIND_PROFILE], authors: [key.publicKey] }]));
92
+ setTimeout(() => done(null), 12_000);
93
+ });
94
+ }
95
+ /** Publish this agent's profile to one relay. */
96
+ export async function write(url, key, profile) {
97
+ const socket = await connect(url, key);
98
+ return new Promise((resolve, reject) => {
99
+ const event = finalizeEvent({ kind: KIND_PROFILE, created_at: now(), tags: [], content: JSON.stringify(profile) }, key.secretKey);
100
+ socket.addEventListener("message", (raw) => {
101
+ const frame = JSON.parse(String(raw.data));
102
+ if (frame[0] === "OK" && frame[1] === event.id) {
103
+ try {
104
+ socket.close();
105
+ }
106
+ catch {
107
+ /* already closing */
108
+ }
109
+ frame[2] === true ? resolve() : reject(new Error(String(frame[3] ?? "refused")));
110
+ }
111
+ });
112
+ socket.send(JSON.stringify(["EVENT", event]));
113
+ setTimeout(() => reject(new Error(`${url} did not acknowledge the profile`)), 15_000);
114
+ });
115
+ }
116
+ /** Take the profile an agent already has somewhere, and give it the same one here. */
117
+ export async function copy(from, to, key) {
118
+ const profile = await read(from, key);
119
+ if (!profile)
120
+ throw new Error(`no profile to copy from ${from}`);
121
+ for (const url of to) {
122
+ if (url === from)
123
+ continue;
124
+ // A community this agent has not been invited to yet is not a failure of
125
+ // the copy; it simply has nowhere to put it.
126
+ try {
127
+ await write(url, key, profile);
128
+ }
129
+ catch (error) {
130
+ if (!error.message.includes("not a relay member"))
131
+ throw error;
132
+ }
133
+ }
134
+ return profile;
135
+ }
136
+ /**
137
+ * Put an image where the community can serve it, and answer with its URL.
138
+ *
139
+ * @remarks
140
+ * Blossom (BUD-01): the file is addressed by the hash of its own bytes, and the
141
+ * upload is authorised by an event carrying that hash. So the server can check
142
+ * that what arrived is what was signed for, and the same image uploaded twice
143
+ * is the same URL rather than two.
144
+ *
145
+ * It goes to the community rather than to storage of ours, which is the point:
146
+ * an agent's picture should not depend on a bucket belonging to whoever set it
147
+ * up, and nobody should need a blob token to give their agent a face.
148
+ */
149
+ export async function uploadImage(relay, key, bytes, mediaType) {
150
+ const { createHash } = await import("node:crypto");
151
+ const base = relay.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/$/, "");
152
+ // Stripped BEFORE hashing: the hash has to be of what is actually sent, and a
153
+ // Blossom server checks that for itself.
154
+ const body = stripMetadata(bytes, mediaType);
155
+ const sha256 = createHash("sha256").update(body).digest("hex");
156
+ const expires = now() + 300;
157
+ const auth = finalizeEvent({
158
+ kind: 24242,
159
+ created_at: now(),
160
+ tags: [
161
+ ["t", "upload"],
162
+ ["x", sha256],
163
+ ["expiration", String(expires)],
164
+ ["server", new URL(base).host],
165
+ ],
166
+ content: "Upload buzz-media",
167
+ }, key.secretKey);
168
+ const header = `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64url")}`;
169
+ // PUT, not POST: Blossom addresses a blob by the hash of its bytes, so the
170
+ // upload is idempotent — the same image twice is the same URL, and the verb
171
+ // says so. POST answers 405 here, which reads as a missing endpoint rather
172
+ // than as the wrong method.
173
+ //
174
+ // Newer servers take /upload; older ones only /media/upload. Trying both is
175
+ // cheaper than asking an operator which one they are running.
176
+ let last = "";
177
+ for (const path of ["/upload", "/media/upload"]) {
178
+ const response = await fetch(`${base}${path}`, {
179
+ method: "PUT",
180
+ headers: { authorization: header, "content-type": mediaType, "x-sha-256": sha256 },
181
+ body: body,
182
+ });
183
+ if (response.ok) {
184
+ const body = (await response.json().catch(() => ({})));
185
+ if (typeof body.url === "string")
186
+ return body.url;
187
+ return `${base}/${sha256}`;
188
+ }
189
+ last = `${response.status} ${(await response.text().catch(() => "")).slice(0, 120)}`;
190
+ }
191
+ throw new Error(`the community refused the image: ${last}`);
192
+ }
193
+ /**
194
+ * Remove everything from an image that is not the image.
195
+ *
196
+ * @remarks
197
+ * The community refuses uploads carrying metadata, and it is right to: a photo
198
+ * taken on a phone carries the phone, the time and often the place, and an
199
+ * avatar is exactly the kind of file nobody thinks about before sharing. So
200
+ * this strips rather than asks — the alternative is telling people to run
201
+ * `exiftool` before they can give their agent a face.
202
+ *
203
+ * Both formats are containers of tagged sections, and dropping a section needs
204
+ * no re-encoding: PNG chunks carry their own CRC, and JPEG segments their own
205
+ * length. The pixels are untouched.
206
+ */
207
+ export function stripMetadata(bytes, mediaType) {
208
+ if (mediaType === "image/png")
209
+ return stripPng(bytes);
210
+ if (mediaType === "image/jpeg")
211
+ return stripJpeg(bytes);
212
+ return bytes;
213
+ }
214
+ /** The chunks that ARE the image. Everything else is commentary. */
215
+ const PNG_KEEP = new Set(["IHDR", "PLTE", "IDAT", "IEND", "tRNS"]);
216
+ function stripPng(bytes) {
217
+ const signature = bytes.subarray(0, 8);
218
+ if (signature[0] !== 0x89 || signature[1] !== 0x50)
219
+ return bytes;
220
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
221
+ const kept = [signature];
222
+ let at = 8;
223
+ while (at + 8 <= bytes.length) {
224
+ const length = view.getUint32(at);
225
+ const name = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
226
+ const total = 12 + length; // length + type + data + crc
227
+ if (PNG_KEEP.has(name))
228
+ kept.push(bytes.subarray(at, at + total));
229
+ at += total;
230
+ if (name === "IEND")
231
+ break;
232
+ }
233
+ const size = kept.reduce((sum, part) => sum + part.length, 0);
234
+ const out = new Uint8Array(size);
235
+ let offset = 0;
236
+ for (const part of kept) {
237
+ out.set(part, offset);
238
+ offset += part.length;
239
+ }
240
+ return out;
241
+ }
242
+ function stripJpeg(bytes) {
243
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8)
244
+ return bytes;
245
+ const kept = [bytes.subarray(0, 2)];
246
+ let at = 2;
247
+ while (at + 4 <= bytes.length) {
248
+ if (bytes[at] !== 0xff)
249
+ break;
250
+ const marker = bytes[at + 1];
251
+ // Start of scan: everything after it is pixel data.
252
+ if (marker === 0xda) {
253
+ kept.push(bytes.subarray(at));
254
+ break;
255
+ }
256
+ const length = (bytes[at + 2] << 8) | bytes[at + 3];
257
+ const isMetadata = (marker >= 0xe0 && marker <= 0xef) || marker === 0xfe; // APPn, COM
258
+ if (!isMetadata)
259
+ kept.push(bytes.subarray(at, at + 2 + length));
260
+ at += 2 + length;
261
+ }
262
+ const size = kept.reduce((sum, part) => sum + part.length, 0);
263
+ const out = new Uint8Array(size);
264
+ let offset = 0;
265
+ for (const part of kept) {
266
+ out.set(part, offset);
267
+ offset += part.length;
268
+ }
269
+ return out;
270
+ }
271
+ /** What kind of image this is, from its name. */
272
+ export function mediaTypeOf(path) {
273
+ const ext = path.toLowerCase().split(".").pop() ?? "";
274
+ return ({ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" }[ext] ??
275
+ "application/octet-stream");
276
+ }
package/dist/relay.d.ts CHANGED
@@ -55,6 +55,9 @@ export declare class BuzzRelay {
55
55
  private presenceTimer;
56
56
  private pollTimer;
57
57
  private closed;
58
+ /** How long to wait before the next attempt, and what was last refused. */
59
+ private backoff;
60
+ private refusal;
58
61
  /** Answered already, so a redelivery in two overlapping polls is not answered twice. */
59
62
  private readonly seen;
60
63
  private cursor;
package/dist/relay.js CHANGED
@@ -51,6 +51,9 @@ export class BuzzRelay {
51
51
  presenceTimer = null;
52
52
  pollTimer = null;
53
53
  closed = false;
54
+ /** How long to wait before the next attempt, and what was last refused. */
55
+ backoff = 5_000;
56
+ refusal = null;
54
57
  /** Answered already, so a redelivery in two overlapping polls is not answered twice. */
55
58
  seen = new Set();
56
59
  cursor = now();
@@ -105,8 +108,14 @@ export class BuzzRelay {
105
108
  reconnect() {
106
109
  if (this.closed)
107
110
  return;
108
- this.log("disconnected; reconnecting in 5s");
109
- setTimeout(() => this.connect(), 5_000);
111
+ // Backs off rather than hammering. A membership refusal is the common case
112
+ // here — an agent whose invite has not happened yet — and it is not
113
+ // something a retry a second from now can fix, though a retry eventually
114
+ // can, which is why this slows down instead of giving up.
115
+ const wait = this.backoff;
116
+ this.backoff = Math.min(this.backoff * 2, 60_000);
117
+ this.log(`disconnected; reconnecting in ${Math.round(wait / 1000)}s`);
118
+ setTimeout(() => this.connect(), wait);
110
119
  }
111
120
  receive(data) {
112
121
  let frame;
@@ -134,10 +143,21 @@ export class BuzzRelay {
134
143
  }
135
144
  if (type === "OK" && !this.authenticated) {
136
145
  if (frame[2] !== true) {
137
- this.log(`the relay refused this identity: ${String(frame[3] ?? "")}`);
146
+ // Said once. Repeating it every few seconds buries everything else in
147
+ // the log while somebody works out who has to send the invite.
148
+ const reason = String(frame[3] ?? "");
149
+ if (this.refusal !== reason) {
150
+ this.refusal = reason;
151
+ this.log(reason.includes("not a relay member")
152
+ ? `not a member of this community yet — invite ${this.key.npub}`
153
+ : `the relay refused this identity: ${reason}`);
154
+ }
138
155
  return;
139
156
  }
140
157
  this.authenticated = true;
158
+ // A connection that works resets both.
159
+ this.backoff = 5_000;
160
+ this.refusal = null;
141
161
  this.log(`authenticated as ${this.key.publicKey.slice(0, 12)}…`);
142
162
  this.keepPresent();
143
163
  this.poll();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.1.1",
3
+ "version": "0.4.3",
4
4
  "description": "Put an eve agent in a Buzz workspace as a member: per-speaker verified identity resolved through the control plane, self-service identity linking, presence, typing and seen signals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",