@kybernesis/buzz 0.1.0 → 0.4.2

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
@@ -86,16 +86,29 @@ export function buzzBridge(options) {
86
86
  relay.reply(dm, text);
87
87
  log(`refused ${npubEncode(sender).slice(0, 16)}…: ${reason}`);
88
88
  }
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) {
89
+ const urls = (typeof options.relay === "string" ? [options.relay] : [...options.relay])
90
+ .map((url) => url.trim())
91
+ .filter(Boolean);
92
+ if (urls.length === 0)
93
+ throw new Error("a workspace relay is required");
94
+ /** Which connection a message arrived on, so the reply goes back the same way. */
95
+ const relays = new Map();
96
+ for (const url of urls) {
97
+ relays.set(url, new BuzzRelay({
98
+ url,
99
+ key,
100
+ pollMs: options.pollMs,
101
+ // Which community is speaking matters once there is more than one.
102
+ onLog: (message) => log(urls.length > 1 ? `${label(url)} ${message}` : message),
103
+ onMessage: (event) => {
104
+ void handle(event, url);
105
+ },
106
+ }));
107
+ }
108
+ async function handle(event, from) {
109
+ const relay = relays.get(from);
110
+ if (!relay)
111
+ return;
99
112
  const channel = event.tags.find((t) => t[0] === "h")?.[1];
100
113
  // Addressed by TAG, not by text. A name in prose is a string anyone can type; a p tag is what
101
114
  // the client emits when someone actually picks this member out of a mention list.
@@ -145,14 +158,32 @@ export function buzzBridge(options) {
145
158
  /** The key the workspace has to invite for any of this to happen. */
146
159
  npub: key.npub,
147
160
  pubkey: key.publicKey,
161
+ /** The communities this agent is a member of. */
162
+ relays: urls,
148
163
  start() {
149
- relay.connect();
150
- log("listening — turns run as whoever sent them");
164
+ for (const relay of relays.values())
165
+ relay.connect();
166
+ log(urls.length > 1
167
+ ? `listening on ${urls.length} communities — turns run as whoever sent them`
168
+ : "listening — turns run as whoever sent them");
151
169
  },
152
170
  /** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
153
171
  stop() {
154
- relay.setPresence("offline");
155
- setTimeout(() => relay.close(), 250);
172
+ for (const relay of relays.values())
173
+ relay.setPresence("offline");
174
+ setTimeout(() => {
175
+ for (const relay of relays.values())
176
+ relay.close();
177
+ }, 250);
156
178
  },
157
179
  };
158
180
  }
181
+ /** A relay's host, short enough to prefix a log line with. */
182
+ function label(url) {
183
+ try {
184
+ return `[${new URL(url).hostname.split(".")[0]}]`;
185
+ }
186
+ catch {
187
+ return "[relay]";
188
+ }
189
+ }
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
@@ -33,12 +33,27 @@ export const KIND_DM_OPEN = 41010;
33
33
  export const TYPING_INTERVAL_MS = 3_000;
34
34
  export const PRESENCE_INTERVAL_MS = 60_000;
35
35
  const now = () => Math.floor(Date.now() / 1000);
36
+ /**
37
+ * The payload out of a command acknowledgement.
38
+ *
39
+ * @remarks
40
+ * A command's answer arrives as `response:{…}` rather than as bare JSON, and the prefix is easy
41
+ * to miss because the request itself succeeds: the server accepts the command, does the work,
42
+ * and reports it — and a parser that chokes on the prefix reports the whole thing as a failure.
43
+ * The visible symptom is a feature that silently never happens.
44
+ */
45
+ function stripAckPrefix(message) {
46
+ return message.startsWith("response:") ? message.slice("response:".length) : message;
47
+ }
36
48
  export class BuzzRelay {
37
49
  socket = null;
38
50
  authenticated = false;
39
51
  presenceTimer = null;
40
52
  pollTimer = null;
41
53
  closed = false;
54
+ /** How long to wait before the next attempt, and what was last refused. */
55
+ backoff = 5_000;
56
+ refusal = null;
42
57
  /** Answered already, so a redelivery in two overlapping polls is not answered twice. */
43
58
  seen = new Set();
44
59
  cursor = now();
@@ -93,8 +108,14 @@ export class BuzzRelay {
93
108
  reconnect() {
94
109
  if (this.closed)
95
110
  return;
96
- this.log("disconnected; reconnecting in 5s");
97
- 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);
98
119
  }
99
120
  receive(data) {
100
121
  let frame;
@@ -122,10 +143,21 @@ export class BuzzRelay {
122
143
  }
123
144
  if (type === "OK" && !this.authenticated) {
124
145
  if (frame[2] !== true) {
125
- 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
+ }
126
155
  return;
127
156
  }
128
157
  this.authenticated = true;
158
+ // A connection that works resets both.
159
+ this.backoff = 5_000;
160
+ this.refusal = null;
129
161
  this.log(`authenticated as ${this.key.publicKey.slice(0, 12)}…`);
130
162
  this.keepPresent();
131
163
  this.poll();
@@ -250,10 +282,11 @@ export class BuzzRelay {
250
282
  return null;
251
283
  }
252
284
  try {
253
- const ack = JSON.parse(result.message);
285
+ const ack = JSON.parse(stripAckPrefix(result.message));
254
286
  return typeof ack.channel_id === "string" ? ack.channel_id : null;
255
287
  }
256
288
  catch {
289
+ this.log(`could not read the reply to opening a direct message: ${result.message.slice(0, 120)}`);
257
290
  return null;
258
291
  }
259
292
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.1.0",
3
+ "version": "0.4.2",
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",