@kybernesis/buzz 0.12.1 → 0.12.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 +13 -2
- package/dist/bridge.js +5 -1
- package/dist/cli.js +10 -8
- package/dist/face.d.ts +16 -4
- package/dist/face.js +71 -25
- package/dist/media.d.ts +2 -0
- package/dist/media.js +17 -3
- package/dist/profile.js +3 -1
- package/dist/relay.d.ts +42 -0
- package/dist/relay.js +166 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,6 +27,16 @@ of the account it names, so it is delivered to a direct conversation and never p
|
|
|
27
27
|
Administrators can also link identities directly in the control plane, which is the path for
|
|
28
28
|
bulk provisioning.
|
|
29
29
|
|
|
30
|
+
## When it answers
|
|
31
|
+
|
|
32
|
+
In a room, when someone @mentions it, or replies to a question it asked. In a one-to-one direct
|
|
33
|
+
message, every message: a DM is addressed to the other member, mention or not, as with Buzz's
|
|
34
|
+
own agents. A group DM still needs the mention. The bridge learns which DMs it is in from the
|
|
35
|
+
relay's signed channel rosters, every thirty seconds, and reads a newly found DM back from the
|
|
36
|
+
last check so nothing sent in between is missed. Messages written by another agent (they carry
|
|
37
|
+
an owner attestation) are answered only when they mention it, so two agents in a DM never
|
|
38
|
+
answer each other forever.
|
|
39
|
+
|
|
30
40
|
## What the host holds
|
|
31
41
|
|
|
32
42
|
Only the agent's own key and the agent's own credential — neither of which can act as any
|
|
@@ -61,8 +71,9 @@ NIP-05 handle). The bridge reads the owner's choice from the control plane
|
|
|
61
71
|
(`GET /api/agent/profile`, with the agent's credential) and the name's public profile document
|
|
62
72
|
(`https://<name>.agentid.dev/.well-known/agent-profile.json`) when it starts and every fifteen
|
|
63
73
|
minutes, and writes the profile only in communities whose copy differs. The control plane holds
|
|
64
|
-
the picture as a small image, so it is uploaded to
|
|
65
|
-
and
|
|
74
|
+
the picture as a small image, so it is uploaded to each community's own media store once per
|
|
75
|
+
change, and each community's profile points at its own copy: the Buzz app loads a community's
|
|
76
|
+
media only through that community, so a picture held by another one shows as no picture. A new picture or name reaches every community without a
|
|
66
77
|
command on the host. The `.agent` name comes from `ARP_AGENT_DID`, or from
|
|
67
78
|
`.eve/arp-identity.json` in the agent's directory.
|
|
68
79
|
|
package/dist/bridge.js
CHANGED
|
@@ -318,7 +318,11 @@ export function buzzBridge(options) {
|
|
|
318
318
|
const repliedTo = event.tags.find((t) => t[0] === "e")?.[1];
|
|
319
319
|
const open = channel ? sessions.get(from, channel) : undefined;
|
|
320
320
|
const answersPrompt = Boolean(repliedTo && open?.pendingInputRequests?.length && open.promptEventIds?.includes(repliedTo));
|
|
321
|
-
|
|
321
|
+
// In a one-to-one DM every message is to the agent, mention or not, as with Buzz's own
|
|
322
|
+
// agents. Not one written by another agent (it carries its owner's `auth` attestation):
|
|
323
|
+
// two agents in a DM would otherwise answer each other forever.
|
|
324
|
+
const direct = Boolean(channel && relay.isDirect(channel) && !event.tags.some((t) => t[0] === "auth"));
|
|
325
|
+
if (!channel || !(addressed || answersPrompt || direct))
|
|
322
326
|
return;
|
|
323
327
|
const text = String(event.content ?? "").trim();
|
|
324
328
|
const attachments = parseMedia(event);
|
package/dist/cli.js
CHANGED
|
@@ -157,9 +157,11 @@ async function setProfile() {
|
|
|
157
157
|
console.error(" or: kybernesis-buzz profile --copy-from <wss://…>");
|
|
158
158
|
process.exit(1);
|
|
159
159
|
}
|
|
160
|
-
// A local file is uploaded to
|
|
161
|
-
// as given.
|
|
160
|
+
// A local file is uploaded to each community and becomes that community's URL; a URL is
|
|
161
|
+
// used as given. Each community needs its own copy: the Buzz app loads a community's media
|
|
162
|
+
// only through that community, so another community's address shows no picture at all.
|
|
162
163
|
let picture = flag("picture");
|
|
164
|
+
const pictures = new Map();
|
|
163
165
|
if (picture && !/^https?:\/\//.test(picture)) {
|
|
164
166
|
const path = picture.startsWith("~/")
|
|
165
167
|
? join(homedir(), picture.slice(2))
|
|
@@ -169,22 +171,20 @@ async function setProfile() {
|
|
|
169
171
|
process.exit(1);
|
|
170
172
|
}
|
|
171
173
|
const bytes = readFileSync(path);
|
|
172
|
-
let uploaded;
|
|
173
174
|
for (const url of urls) {
|
|
174
175
|
try {
|
|
175
|
-
|
|
176
|
+
pictures.set(url, await profile.uploadImage(url, key, bytes, profile.mediaTypeOf(path)));
|
|
176
177
|
console.log(` uploaded ${path.split("/").pop()} to ${url}`);
|
|
177
|
-
break;
|
|
178
178
|
}
|
|
179
179
|
catch (error) {
|
|
180
180
|
console.log(` · ${url} — ${error.message}`);
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
-
if (
|
|
183
|
+
if (pictures.size === 0) {
|
|
184
184
|
console.error("\n Could not upload the image to any community.\n");
|
|
185
185
|
process.exit(1);
|
|
186
186
|
}
|
|
187
|
-
picture =
|
|
187
|
+
picture = undefined;
|
|
188
188
|
}
|
|
189
189
|
const wanted = {
|
|
190
190
|
name: name.toLowerCase().replace(/\s+/g, "-"),
|
|
@@ -200,7 +200,9 @@ async function setProfile() {
|
|
|
200
200
|
let published = 0;
|
|
201
201
|
for (const url of urls) {
|
|
202
202
|
try {
|
|
203
|
-
|
|
203
|
+
// A community that refused the upload still gets a picture, if not one it can show.
|
|
204
|
+
const own = pictures.get(url) ?? pictures.values().next().value;
|
|
205
|
+
await profile.write(url, key, own ? { ...wanted, picture: own } : wanted);
|
|
204
206
|
console.log(` ✓ ${url}`);
|
|
205
207
|
published += 1;
|
|
206
208
|
}
|
package/dist/face.d.ts
CHANGED
|
@@ -57,15 +57,22 @@ export declare function resolveFace(options: {
|
|
|
57
57
|
}): Promise<Face>;
|
|
58
58
|
/** The profile fields the face decides, given where its picture now lives. */
|
|
59
59
|
export declare function profileFromFace(face: Face, pictureUrl: string | null): Profile;
|
|
60
|
+
/** The hash a community media URL addresses (`…/media/<sha256>[.ext]`), or null for any other URL. */
|
|
61
|
+
export declare function relayMediaHash(url: string): string | null;
|
|
60
62
|
/**
|
|
61
63
|
* Bring this agent's profile in every community in line with its face.
|
|
62
64
|
*
|
|
63
65
|
* @remarks
|
|
64
66
|
* Each community is read first; only one whose copy differs is written, so running this on a
|
|
65
|
-
* timer costs one read per community when nothing has changed.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
67
|
+
* timer costs one read per community when nothing has changed. Fields the face does not decide
|
|
68
|
+
* are kept. A community that refuses or cannot be reached is reported, not fatal.
|
|
69
|
+
*
|
|
70
|
+
* Every community gets its OWN copy of the picture. A community's media store refuses
|
|
71
|
+
* anonymous reads, so the Buzz app loads a picture through the session of the community it is
|
|
72
|
+
* showing, and only when the address is that community's. An address on another community is
|
|
73
|
+
* fetched anonymously and refused, and the agent shows no face there: that is how an agent in
|
|
74
|
+
* two communities lost its picture in one of them when the app changed. So the picture is
|
|
75
|
+
* uploaded to each community that does not already hold it, and each profile points at its own.
|
|
69
76
|
*/
|
|
70
77
|
export declare function syncProfile(options: {
|
|
71
78
|
face: Face;
|
|
@@ -74,5 +81,10 @@ export declare function syncProfile(options: {
|
|
|
74
81
|
read?: typeof profile.read;
|
|
75
82
|
write?: typeof profile.write;
|
|
76
83
|
upload?: typeof profile.uploadImage;
|
|
84
|
+
/** Reads a picture held in one community's media store, to copy it to another. */
|
|
85
|
+
fetchBlob?: (url: string, sha256: string) => Promise<{
|
|
86
|
+
bytes: Uint8Array;
|
|
87
|
+
mediaType: string;
|
|
88
|
+
}>;
|
|
77
89
|
}): Promise<SyncOutcome[]>;
|
|
78
90
|
export {};
|
package/dist/face.js
CHANGED
|
@@ -77,21 +77,48 @@ export function profileFromFace(face, pictureUrl) {
|
|
|
77
77
|
bot: true,
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
/** A relay's own HTTP origin, where its media store answers. */
|
|
81
|
+
function originOf(relay) {
|
|
82
|
+
return relay.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/+$/, "");
|
|
83
|
+
}
|
|
84
|
+
/** The hash a community media URL addresses (`…/media/<sha256>[.ext]`), or null for any other URL. */
|
|
85
|
+
export function relayMediaHash(url) {
|
|
86
|
+
try {
|
|
87
|
+
return /^\/media\/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$/.exec(new URL(url).pathname)?.[1] ?? null;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Whether a URL is this community's own copy of the blob with this hash. */
|
|
94
|
+
function heldBy(relay, url, sha256) {
|
|
95
|
+
return Boolean(url?.startsWith(`${originOf(relay)}/media/`) && relayMediaHash(url) === sha256);
|
|
96
|
+
}
|
|
80
97
|
/**
|
|
81
98
|
* Bring this agent's profile in every community in line with its face.
|
|
82
99
|
*
|
|
83
100
|
* @remarks
|
|
84
101
|
* Each community is read first; only one whose copy differs is written, so running this on a
|
|
85
|
-
* timer costs one read per community when nothing has changed.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
102
|
+
* timer costs one read per community when nothing has changed. Fields the face does not decide
|
|
103
|
+
* are kept. A community that refuses or cannot be reached is reported, not fatal.
|
|
104
|
+
*
|
|
105
|
+
* Every community gets its OWN copy of the picture. A community's media store refuses
|
|
106
|
+
* anonymous reads, so the Buzz app loads a picture through the session of the community it is
|
|
107
|
+
* showing, and only when the address is that community's. An address on another community is
|
|
108
|
+
* fetched anonymously and refused, and the agent shows no face there: that is how an agent in
|
|
109
|
+
* two communities lost its picture in one of them when the app changed. So the picture is
|
|
110
|
+
* uploaded to each community that does not already hold it, and each profile points at its own.
|
|
89
111
|
*/
|
|
90
112
|
export async function syncProfile(options) {
|
|
91
113
|
const read = options.read ?? profile.read;
|
|
92
114
|
const write = options.write ?? profile.write;
|
|
93
115
|
const upload = options.upload ?? profile.uploadImage;
|
|
94
116
|
const { face, key } = options;
|
|
117
|
+
const fetchBlob = options.fetchBlob ??
|
|
118
|
+
(async (url, sha256) => {
|
|
119
|
+
const { fetchMedia } = await import("./media.js");
|
|
120
|
+
return fetchMedia(key, { url, sha256 });
|
|
121
|
+
});
|
|
95
122
|
const current = new Map();
|
|
96
123
|
const outcomes = new Map();
|
|
97
124
|
for (const relay of options.relays) {
|
|
@@ -103,33 +130,52 @@ export async function syncProfile(options) {
|
|
|
103
130
|
outcomes.set(relay, { relay, outcome: why.includes("not a relay member") ? "not-member" : "failed", detail: why });
|
|
104
131
|
}
|
|
105
132
|
}
|
|
106
|
-
//
|
|
107
|
-
|
|
133
|
+
// What the picture is. An ordinary https address is used as it is, everywhere. A data URL,
|
|
134
|
+
// or a picture already in some community's media store, is copied into each community.
|
|
135
|
+
let external = null;
|
|
136
|
+
let sha256 = null;
|
|
137
|
+
let source = null;
|
|
138
|
+
let bytes = null;
|
|
108
139
|
let pictureProblem = null;
|
|
109
|
-
if (face.picture
|
|
110
|
-
pictureUrl = face.picture;
|
|
111
|
-
}
|
|
112
|
-
else if (face.picture) {
|
|
140
|
+
if (face.picture?.startsWith("data:")) {
|
|
113
141
|
const picture = await pictureBytes(face.picture);
|
|
114
|
-
if (
|
|
115
|
-
|
|
142
|
+
if (picture) {
|
|
143
|
+
sha256 = picture.sha256;
|
|
144
|
+
bytes = picture;
|
|
116
145
|
}
|
|
117
146
|
else {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
147
|
+
pictureProblem = "the picture is not an image this can send";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (face.picture && /^https:\/\//.test(face.picture)) {
|
|
151
|
+
sha256 = relayMediaHash(face.picture);
|
|
152
|
+
if (sha256)
|
|
153
|
+
source = face.picture;
|
|
154
|
+
else
|
|
155
|
+
external = face.picture;
|
|
156
|
+
}
|
|
157
|
+
/** Where this community's copy of the picture lives; null leaves its picture as it is. */
|
|
158
|
+
async function pictureFor(relay) {
|
|
159
|
+
if (external)
|
|
160
|
+
return { url: external, problem: null };
|
|
161
|
+
if (!sha256)
|
|
162
|
+
return { url: null, problem: pictureProblem };
|
|
163
|
+
const existing = current.get(relay)?.picture;
|
|
164
|
+
if (heldBy(relay, existing, sha256))
|
|
165
|
+
return { url: existing, problem: null };
|
|
166
|
+
if (source && heldBy(relay, source, sha256))
|
|
167
|
+
return { url: source, problem: null };
|
|
168
|
+
try {
|
|
169
|
+
bytes ??= await fetchBlob(source, sha256);
|
|
170
|
+
return { url: await upload(relay, key, bytes.bytes, bytes.mediaType), problem: null };
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
return { url: null, problem: error.message };
|
|
129
174
|
}
|
|
130
175
|
}
|
|
131
|
-
const wanted = profileFromFace(face, pictureUrl);
|
|
132
176
|
for (const [relay, existing] of current) {
|
|
177
|
+
const picture = await pictureFor(relay);
|
|
178
|
+
const wanted = profileFromFace(face, picture.url);
|
|
133
179
|
const matches = existing && Object.keys(wanted).every((field) => existing[field] === wanted[field]);
|
|
134
180
|
if (matches) {
|
|
135
181
|
outcomes.set(relay, { relay, outcome: "unchanged" });
|
|
@@ -137,7 +183,7 @@ export async function syncProfile(options) {
|
|
|
137
183
|
}
|
|
138
184
|
try {
|
|
139
185
|
await write(relay, key, { ...(existing ?? {}), ...wanted });
|
|
140
|
-
outcomes.set(relay, { relay, outcome: "updated", ...(
|
|
186
|
+
outcomes.set(relay, { relay, outcome: "updated", ...(picture.problem ? { detail: `picture not sent: ${picture.problem}` } : {}) });
|
|
141
187
|
}
|
|
142
188
|
catch (error) {
|
|
143
189
|
const why = error.message;
|
package/dist/media.d.ts
CHANGED
|
@@ -44,5 +44,7 @@ export declare function parseMedia(event: NostrEvent): MediaRef[];
|
|
|
44
44
|
* us one, so the authorisation is scoped to reading that specific blob.
|
|
45
45
|
*/
|
|
46
46
|
export declare function fetchMedia(key: AgentKey, ref: MediaRef, fetchImpl?: typeof fetch): Promise<FetchedMedia>;
|
|
47
|
+
/** How long a Blossom proof may live under NIP-FI's strict rules. */
|
|
48
|
+
export declare const BLOSSOM_PROOF_SECONDS = 60;
|
|
47
49
|
/** Whether the model can be shown this directly, or only told about it. */
|
|
48
50
|
export declare function isImage(mediaType: string): boolean;
|
package/dist/media.js
CHANGED
|
@@ -47,14 +47,17 @@ export function parseMedia(event) {
|
|
|
47
47
|
* us one, so the authorisation is scoped to reading that specific blob.
|
|
48
48
|
*/
|
|
49
49
|
export async function fetchMedia(key, ref, fetchImpl = fetch) {
|
|
50
|
-
|
|
50
|
+
// NIP-FI's strict Blossom rules, which a community may switch on: a proof lives at most a
|
|
51
|
+
// minute and names the server it is for. Older servers accept the same proof.
|
|
52
|
+
const issued = Math.floor(Date.now() / 1000);
|
|
51
53
|
const auth = finalizeEvent({
|
|
52
54
|
kind: 24242,
|
|
53
|
-
created_at:
|
|
55
|
+
created_at: issued,
|
|
54
56
|
tags: [
|
|
55
57
|
["t", "get"],
|
|
56
58
|
...(ref.sha256 ? [["x", ref.sha256]] : []),
|
|
57
|
-
["expiration", String(
|
|
59
|
+
["expiration", String(issued + BLOSSOM_PROOF_SECONDS)],
|
|
60
|
+
...serverTag(ref.url),
|
|
58
61
|
],
|
|
59
62
|
content: "Fetch buzz-media",
|
|
60
63
|
}, key.secretKey);
|
|
@@ -79,6 +82,17 @@ export async function fetchMedia(key, ref, fetchImpl = fetch) {
|
|
|
79
82
|
}
|
|
80
83
|
return { url: ref.url, mediaType, bytes };
|
|
81
84
|
}
|
|
85
|
+
/** How long a Blossom proof may live under NIP-FI's strict rules. */
|
|
86
|
+
export const BLOSSOM_PROOF_SECONDS = 60;
|
|
87
|
+
/** The `server` tag a strict Blossom server requires: the host the proof is for. */
|
|
88
|
+
function serverTag(url) {
|
|
89
|
+
try {
|
|
90
|
+
return [["server", new URL(url).host]];
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
82
96
|
/** Whether the model can be shown this directly, or only told about it. */
|
|
83
97
|
export function isImage(mediaType) {
|
|
84
98
|
return mediaType.startsWith("image/");
|
package/dist/profile.js
CHANGED
|
@@ -153,7 +153,9 @@ export async function uploadImage(relay, key, bytes, mediaType) {
|
|
|
153
153
|
// Blossom server checks that for itself.
|
|
154
154
|
const body = stripMetadata(bytes, mediaType);
|
|
155
155
|
const sha256 = createHash("sha256").update(body).digest("hex");
|
|
156
|
-
|
|
156
|
+
// At most a minute: NIP-FI's strict Blossom rules, which a community may switch on, refuse
|
|
157
|
+
// a longer-lived proof. The `server` tag below is the other thing they require.
|
|
158
|
+
const expires = now() + 60;
|
|
157
159
|
const auth = finalizeEvent({
|
|
158
160
|
kind: 24242,
|
|
159
161
|
created_at: now(),
|
package/dist/relay.d.ts
CHANGED
|
@@ -25,6 +25,10 @@ export declare const KIND_PRESENCE = 20001;
|
|
|
25
25
|
export declare const KIND_TYPING = 20002;
|
|
26
26
|
/** Open (or find) a direct conversation with a set of people. */
|
|
27
27
|
export declare const KIND_DM_OPEN = 41010;
|
|
28
|
+
/** Relay-signed channel metadata (NIP-29); its first `t` tag is the channel type. */
|
|
29
|
+
export declare const KIND_CHANNEL_METADATA = 39000;
|
|
30
|
+
/** Relay-signed channel roster (NIP-29): one `p` tag per member. */
|
|
31
|
+
export declare const KIND_CHANNEL_MEMBERS = 39002;
|
|
28
32
|
/**
|
|
29
33
|
* A receiving client expires a typing indicator shortly after the event's own timestamp, so
|
|
30
34
|
* anything slower than that has to keep saying so.
|
|
@@ -33,6 +37,8 @@ export declare const TYPING_INTERVAL_MS = 3000;
|
|
|
33
37
|
export declare const PRESENCE_INTERVAL_MS = 60000;
|
|
34
38
|
/** Enough overlap for same-second redelivery without retaining every event forever. */
|
|
35
39
|
export declare const MAX_SEEN_EVENT_IDS = 10000;
|
|
40
|
+
/** How often to re-read which direct conversations this agent is in. */
|
|
41
|
+
export declare const DIRECT_REFRESH_MS = 30000;
|
|
36
42
|
export type NostrEvent = {
|
|
37
43
|
id: string;
|
|
38
44
|
pubkey: string;
|
|
@@ -66,6 +72,26 @@ export declare class BuzzRelay {
|
|
|
66
72
|
private cursor;
|
|
67
73
|
/** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
|
|
68
74
|
private readonly replyWatch;
|
|
75
|
+
/**
|
|
76
|
+
* One-to-one direct conversations this agent is in, by channel id.
|
|
77
|
+
*
|
|
78
|
+
* @remarks
|
|
79
|
+
* A direct message used to reach the bridge because the client tagged every other
|
|
80
|
+
* participant, so the `#p` poll found it like a mention. The current Buzz app tags only
|
|
81
|
+
* explicit mentions, so a person typing to the agent in a DM was never seen: the agent
|
|
82
|
+
* answered in a DM only when named, which is not how anyone talks to one person. Buzz's own
|
|
83
|
+
* agents decide by the channel instead (a DM of exactly two members is addressed to the
|
|
84
|
+
* other one), and so does this: the relay's signed rosters say which DMs the agent is in,
|
|
85
|
+
* and those channels are polled whole.
|
|
86
|
+
*/
|
|
87
|
+
private direct;
|
|
88
|
+
/** The relay's own signing key (NIP-11 `self`), which every roster must carry. */
|
|
89
|
+
private relaySelf;
|
|
90
|
+
private discoveryTimer;
|
|
91
|
+
/** One discovery at a time: rosters first, then the metadata for those channels. */
|
|
92
|
+
private discovery;
|
|
93
|
+
/** Since when a newly found DM is read back, so a message sent before it was found is not lost. */
|
|
94
|
+
private discoveredAt;
|
|
69
95
|
private readonly url;
|
|
70
96
|
private readonly rest;
|
|
71
97
|
private readonly key;
|
|
@@ -78,6 +104,8 @@ export declare class BuzzRelay {
|
|
|
78
104
|
watchReplies(ids: Iterable<string>): void;
|
|
79
105
|
unwatchReplies(ids: Iterable<string>): void;
|
|
80
106
|
connect(): void;
|
|
107
|
+
/** Whether this channel is a one-to-one DM with this agent: every message in it is addressed. */
|
|
108
|
+
isDirect(channel: string): boolean;
|
|
81
109
|
close(): void;
|
|
82
110
|
private reconnect;
|
|
83
111
|
private receive;
|
|
@@ -92,6 +120,20 @@ export declare class BuzzRelay {
|
|
|
92
120
|
* ignores every message addressed to it.
|
|
93
121
|
*/
|
|
94
122
|
poll(): void;
|
|
123
|
+
/**
|
|
124
|
+
* Find the one-to-one DMs this agent is in: its rosters, then which of those channels are DMs.
|
|
125
|
+
*
|
|
126
|
+
* @remarks
|
|
127
|
+
* Rosters and metadata are relay-signed; anything else claiming to be one is ignored, so a
|
|
128
|
+
* member cannot publish a roster that makes the agent answer every message in a room.
|
|
129
|
+
*/
|
|
130
|
+
private discoverDirect;
|
|
131
|
+
/** NIP-11 `self`: null when the relay names none, undefined when it could not be asked. */
|
|
132
|
+
private relayKey;
|
|
133
|
+
private discovered;
|
|
134
|
+
private discoveryDone;
|
|
135
|
+
/** Take the DMs just found; read back any that are new, from when they could last have been missed. */
|
|
136
|
+
private adopt;
|
|
95
137
|
/** Publish one event; returns its id when it left this process, null when the socket was down. */
|
|
96
138
|
publish(event: {
|
|
97
139
|
kind: number;
|
package/dist/relay.js
CHANGED
|
@@ -26,6 +26,10 @@ export const KIND_PRESENCE = 20001;
|
|
|
26
26
|
export const KIND_TYPING = 20002;
|
|
27
27
|
/** Open (or find) a direct conversation with a set of people. */
|
|
28
28
|
export const KIND_DM_OPEN = 41010;
|
|
29
|
+
/** Relay-signed channel metadata (NIP-29); its first `t` tag is the channel type. */
|
|
30
|
+
export const KIND_CHANNEL_METADATA = 39000;
|
|
31
|
+
/** Relay-signed channel roster (NIP-29): one `p` tag per member. */
|
|
32
|
+
export const KIND_CHANNEL_MEMBERS = 39002;
|
|
29
33
|
/**
|
|
30
34
|
* A receiving client expires a typing indicator shortly after the event's own timestamp, so
|
|
31
35
|
* anything slower than that has to keep saying so.
|
|
@@ -34,6 +38,11 @@ export const TYPING_INTERVAL_MS = 3_000;
|
|
|
34
38
|
export const PRESENCE_INTERVAL_MS = 60_000;
|
|
35
39
|
/** Enough overlap for same-second redelivery without retaining every event forever. */
|
|
36
40
|
export const MAX_SEEN_EVENT_IDS = 10_000;
|
|
41
|
+
/** How often to re-read which direct conversations this agent is in. */
|
|
42
|
+
export const DIRECT_REFRESH_MS = 30_000;
|
|
43
|
+
/** Ids per `#d`/`#h` filter, and filters per request: the relay caps a request at ten. */
|
|
44
|
+
const IDS_PER_FILTER = 200;
|
|
45
|
+
const MAX_FILTERS = 8;
|
|
37
46
|
const now = () => Math.floor(Date.now() / 1000);
|
|
38
47
|
/**
|
|
39
48
|
* The payload out of a command acknowledgement.
|
|
@@ -62,6 +71,26 @@ export class BuzzRelay {
|
|
|
62
71
|
cursor = now();
|
|
63
72
|
/** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
|
|
64
73
|
replyWatch = new Set();
|
|
74
|
+
/**
|
|
75
|
+
* One-to-one direct conversations this agent is in, by channel id.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* A direct message used to reach the bridge because the client tagged every other
|
|
79
|
+
* participant, so the `#p` poll found it like a mention. The current Buzz app tags only
|
|
80
|
+
* explicit mentions, so a person typing to the agent in a DM was never seen: the agent
|
|
81
|
+
* answered in a DM only when named, which is not how anyone talks to one person. Buzz's own
|
|
82
|
+
* agents decide by the channel instead (a DM of exactly two members is addressed to the
|
|
83
|
+
* other one), and so does this: the relay's signed rosters say which DMs the agent is in,
|
|
84
|
+
* and those channels are polled whole.
|
|
85
|
+
*/
|
|
86
|
+
direct = new Set();
|
|
87
|
+
/** The relay's own signing key (NIP-11 `self`), which every roster must carry. */
|
|
88
|
+
relaySelf = undefined;
|
|
89
|
+
discoveryTimer = null;
|
|
90
|
+
/** One discovery at a time: rosters first, then the metadata for those channels. */
|
|
91
|
+
discovery = null;
|
|
92
|
+
/** Since when a newly found DM is read back, so a message sent before it was found is not lost. */
|
|
93
|
+
discoveredAt = null;
|
|
65
94
|
url;
|
|
66
95
|
rest;
|
|
67
96
|
key;
|
|
@@ -106,6 +135,10 @@ export class BuzzRelay {
|
|
|
106
135
|
}
|
|
107
136
|
});
|
|
108
137
|
}
|
|
138
|
+
/** Whether this channel is a one-to-one DM with this agent: every message in it is addressed. */
|
|
139
|
+
isDirect(channel) {
|
|
140
|
+
return this.direct.has(channel);
|
|
141
|
+
}
|
|
109
142
|
close() {
|
|
110
143
|
this.closed = true;
|
|
111
144
|
if (this.presenceTimer)
|
|
@@ -114,9 +147,12 @@ export class BuzzRelay {
|
|
|
114
147
|
clearTimeout(this.pollTimer);
|
|
115
148
|
if (this.reconnectTimer)
|
|
116
149
|
clearTimeout(this.reconnectTimer);
|
|
150
|
+
if (this.discoveryTimer)
|
|
151
|
+
clearTimeout(this.discoveryTimer);
|
|
117
152
|
this.presenceTimer = null;
|
|
118
153
|
this.pollTimer = null;
|
|
119
154
|
this.reconnectTimer = null;
|
|
155
|
+
this.discoveryTimer = null;
|
|
120
156
|
try {
|
|
121
157
|
this.socket?.close();
|
|
122
158
|
}
|
|
@@ -131,8 +167,12 @@ export class BuzzRelay {
|
|
|
131
167
|
clearInterval(this.presenceTimer);
|
|
132
168
|
if (this.pollTimer)
|
|
133
169
|
clearTimeout(this.pollTimer);
|
|
170
|
+
if (this.discoveryTimer)
|
|
171
|
+
clearTimeout(this.discoveryTimer);
|
|
134
172
|
this.presenceTimer = null;
|
|
135
173
|
this.pollTimer = null;
|
|
174
|
+
this.discoveryTimer = null;
|
|
175
|
+
this.discovery = null;
|
|
136
176
|
if (this.reconnectTimer)
|
|
137
177
|
return;
|
|
138
178
|
// Backs off rather than hammering. A membership refusal is the common case
|
|
@@ -190,6 +230,7 @@ export class BuzzRelay {
|
|
|
190
230
|
this.refusal = null;
|
|
191
231
|
this.log(`authenticated as ${this.key.publicKey.slice(0, 12)}…`);
|
|
192
232
|
this.keepPresent();
|
|
233
|
+
this.discoverDirect();
|
|
193
234
|
this.poll();
|
|
194
235
|
return;
|
|
195
236
|
}
|
|
@@ -197,10 +238,20 @@ export class BuzzRelay {
|
|
|
197
238
|
// One poll's worth delivered. Closing it matters: the relay caps how many subscriptions one
|
|
198
239
|
// connection may hold, and a bridge that never closes them stops receiving within minutes.
|
|
199
240
|
this.send(["CLOSE", String(frame[1])]);
|
|
241
|
+
this.discoveryDone(String(frame[1]));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (type === "CLOSED") {
|
|
245
|
+
// A refused discovery must not wedge the next one.
|
|
246
|
+
this.discoveryDone(String(frame[1]), true);
|
|
200
247
|
return;
|
|
201
248
|
}
|
|
202
249
|
if (type !== "EVENT")
|
|
203
250
|
return;
|
|
251
|
+
if (this.discovery && (frame[1] === this.discovery.rosterSub || frame[1] === this.discovery.metadataSub)) {
|
|
252
|
+
this.discovered(frame[2]);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
204
255
|
const event = frame[2];
|
|
205
256
|
if (!event || event.pubkey === this.key.publicKey)
|
|
206
257
|
return;
|
|
@@ -243,9 +294,116 @@ export class BuzzRelay {
|
|
|
243
294
|
if (this.replyWatch.size > 0) {
|
|
244
295
|
filters.push({ kinds: [KIND_MESSAGE], "#e": [...this.replyWatch], since: this.cursor });
|
|
245
296
|
}
|
|
297
|
+
for (const ids of chunks([...this.direct], MAX_FILTERS - filters.length)) {
|
|
298
|
+
filters.push({ kinds: [KIND_MESSAGE], "#h": ids, since: this.cursor });
|
|
299
|
+
}
|
|
246
300
|
this.send(["REQ", `m${Date.now()}`, ...filters]);
|
|
247
301
|
this.pollTimer = setTimeout(() => this.poll(), this.pollMs);
|
|
248
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Find the one-to-one DMs this agent is in: its rosters, then which of those channels are DMs.
|
|
305
|
+
*
|
|
306
|
+
* @remarks
|
|
307
|
+
* Rosters and metadata are relay-signed; anything else claiming to be one is ignored, so a
|
|
308
|
+
* member cannot publish a roster that makes the agent answer every message in a room.
|
|
309
|
+
*/
|
|
310
|
+
discoverDirect() {
|
|
311
|
+
if (this.closed)
|
|
312
|
+
return;
|
|
313
|
+
if (this.discoveryTimer)
|
|
314
|
+
clearTimeout(this.discoveryTimer);
|
|
315
|
+
this.discoveryTimer = setTimeout(() => this.discoverDirect(), DIRECT_REFRESH_MS);
|
|
316
|
+
if (this.discovery && now() - this.discovery.startedAt < 60)
|
|
317
|
+
return;
|
|
318
|
+
void this.relayKey().then((self) => {
|
|
319
|
+
if (this.closed || self === undefined)
|
|
320
|
+
return;
|
|
321
|
+
const rosterSub = `r${Date.now()}`;
|
|
322
|
+
this.discovery = { rosterSub, metadataSub: null, rosters: new Map(), dms: new Set(), startedAt: now() };
|
|
323
|
+
this.send(["REQ", rosterSub, { kinds: [KIND_CHANNEL_MEMBERS], "#p": [this.key.publicKey], limit: 1000 }]);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
/** NIP-11 `self`: null when the relay names none, undefined when it could not be asked. */
|
|
327
|
+
async relayKey() {
|
|
328
|
+
if (this.relaySelf !== undefined)
|
|
329
|
+
return this.relaySelf;
|
|
330
|
+
try {
|
|
331
|
+
const response = await fetch(this.rest, {
|
|
332
|
+
headers: { accept: "application/nostr+json" },
|
|
333
|
+
signal: AbortSignal.timeout(10_000),
|
|
334
|
+
});
|
|
335
|
+
const info = (await response.json());
|
|
336
|
+
const self = [info.self, info.pubkey].find((value) => typeof value === "string" && /^[0-9a-f]{64}$/.test(value));
|
|
337
|
+
this.relaySelf = self ?? null;
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
this.log(`could not read the relay's information document (${error.message}); direct messages wait for the next try`);
|
|
341
|
+
}
|
|
342
|
+
return this.relaySelf;
|
|
343
|
+
}
|
|
344
|
+
discovered(event) {
|
|
345
|
+
const discovery = this.discovery;
|
|
346
|
+
if (!discovery || !event)
|
|
347
|
+
return;
|
|
348
|
+
if (this.relaySelf && event.pubkey !== this.relaySelf)
|
|
349
|
+
return;
|
|
350
|
+
const id = event.tags.find((t) => t[0] === "d")?.[1];
|
|
351
|
+
if (!id)
|
|
352
|
+
return;
|
|
353
|
+
if (event.kind === KIND_CHANNEL_MEMBERS) {
|
|
354
|
+
discovery.rosters.set(id, new Set(event.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1])));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// NIP-29 as Buzz writes it: the FIRST `t` is the channel's type; later ones are labels.
|
|
358
|
+
const type = event.tags.find((t) => t[0] === "t")?.[1];
|
|
359
|
+
const members = discovery.rosters.get(id);
|
|
360
|
+
if (event.kind === KIND_CHANNEL_METADATA && type === "dm" && members?.size === 2 && members.has(this.key.publicKey)) {
|
|
361
|
+
discovery.dms.add(id);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
discoveryDone(sub, refused = false) {
|
|
365
|
+
const discovery = this.discovery;
|
|
366
|
+
if (!discovery)
|
|
367
|
+
return;
|
|
368
|
+
if (refused) {
|
|
369
|
+
// Only a subscription still in flight can be refused. This relay answers every CLOSE we
|
|
370
|
+
// send with a CLOSED of its own, so the roster's echo arrives after the metadata request
|
|
371
|
+
// has gone out; treating it as a refusal dropped every discovery.
|
|
372
|
+
const inFlight = discovery.metadataSub === null ? discovery.rosterSub : discovery.metadataSub;
|
|
373
|
+
if (sub === inFlight)
|
|
374
|
+
this.discovery = null;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (sub === discovery.rosterSub) {
|
|
378
|
+
const ids = [...discovery.rosters.keys()];
|
|
379
|
+
if (ids.length === 0)
|
|
380
|
+
return this.adopt(new Set());
|
|
381
|
+
discovery.metadataSub = `g${Date.now()}`;
|
|
382
|
+
this.send([
|
|
383
|
+
"REQ",
|
|
384
|
+
discovery.metadataSub,
|
|
385
|
+
...chunks(ids, MAX_FILTERS).map((chunk) => ({ kinds: [KIND_CHANNEL_METADATA], "#d": chunk })),
|
|
386
|
+
]);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (sub === discovery.metadataSub)
|
|
390
|
+
this.adopt(discovery.dms);
|
|
391
|
+
}
|
|
392
|
+
/** Take the DMs just found; read back any that are new, from when they could last have been missed. */
|
|
393
|
+
adopt(found) {
|
|
394
|
+
// Overlap the last discovery by a few seconds; the first one starts where polling did.
|
|
395
|
+
const since = this.discoveredAt === null ? this.cursor : this.discoveredAt - 5;
|
|
396
|
+
const fresh = [...found].filter((id) => !this.direct.has(id));
|
|
397
|
+
this.direct = new Set(found);
|
|
398
|
+
this.discovery = null;
|
|
399
|
+
this.discoveredAt = now();
|
|
400
|
+
if (fresh.length > 0) {
|
|
401
|
+
this.log(`direct messages: ${found.size} conversation(s), ${fresh.length} new`);
|
|
402
|
+
// The poll's cursor may already be past a message sent in a DM before it was found.
|
|
403
|
+
const filters = chunks(fresh, MAX_FILTERS).map((ids) => ({ kinds: [KIND_MESSAGE], "#h": ids, since }));
|
|
404
|
+
this.send(["REQ", `m${Date.now()}c`, ...filters]);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
249
407
|
/** Publish one event; returns its id when it left this process, null when the socket was down. */
|
|
250
408
|
publish(event) {
|
|
251
409
|
const signed = finalizeEvent({ created_at: now(), ...event }, this.key.secretKey);
|
|
@@ -345,3 +503,11 @@ export class BuzzRelay {
|
|
|
345
503
|
}
|
|
346
504
|
}
|
|
347
505
|
}
|
|
506
|
+
/** Split ids into at most `max` filters' worth; ids beyond that are dropped, never a filter too many. */
|
|
507
|
+
function chunks(ids, max) {
|
|
508
|
+
const out = [];
|
|
509
|
+
for (let at = 0; at < ids.length && out.length < max; at += IDS_PER_FILTER) {
|
|
510
|
+
out.push(ids.slice(at, at + IDS_PER_FILTER));
|
|
511
|
+
}
|
|
512
|
+
return out;
|
|
513
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/buzz",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.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",
|