@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 +14 -1
- package/dist/bridge.d.ts +13 -2
- package/dist/bridge.js +45 -14
- package/dist/cli.js +110 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/profile.d.ts +63 -0
- package/dist/profile.js +276 -0
- package/dist/relay.d.ts +3 -0
- package/dist/relay.js +37 -4
- package/package.json +1 -1
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
|
-
/**
|
|
3
|
-
|
|
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
|
|
90
|
-
url
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
150
|
-
|
|
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.
|
|
155
|
-
|
|
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
|
-
|
|
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 === "
|
|
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;
|
package/dist/profile.js
ADDED
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|