@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 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 a community's own media store once per change
65
- and that address is used everywhere. A new picture or name reaches every community without a
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
- if (!channel || !(addressed || answersPrompt))
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 the community and becomes a URL; a URL is used
161
- // as given. Nobody should need storage of their own to give an agent a face.
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
- uploaded = await profile.uploadImage(url, key, bytes, profile.mediaTypeOf(path));
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 (!uploaded) {
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 = uploaded;
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
- await profile.write(url, key, wanted);
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. A picture is uploaded at most
66
- * once per change, to the first community that takes it, and that address is used everywhere,
67
- * the way `kybernesis-buzz profile --picture` always has. Fields the face does not decide are
68
- * kept. A community that refuses or cannot be reached is reported, not fatal.
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. A picture is uploaded at most
86
- * once per change, to the first community that takes it, and that address is used everywhere,
87
- * the way `kybernesis-buzz profile --picture` always has. Fields the face does not decide are
88
- * kept. A community that refuses or cannot be reached is reported, not fatal.
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
- // Where the picture lives. Null leaves each community's picture as it is.
107
- let pictureUrl = null;
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 && /^https:\/\//.test(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 (!picture) {
115
- pictureProblem = "the picture is not an image this can send";
142
+ if (picture) {
143
+ sha256 = picture.sha256;
144
+ bytes = picture;
116
145
  }
117
146
  else {
118
- pictureUrl = [...current.values()].map((p) => p?.picture).find((url) => url?.includes(picture.sha256)) ?? null;
119
- for (const relay of current.keys()) {
120
- if (pictureUrl)
121
- break;
122
- try {
123
- pictureUrl = await upload(relay, key, picture.bytes, picture.mediaType);
124
- }
125
- catch (error) {
126
- pictureProblem = error.message;
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", ...(pictureProblem ? { detail: `picture not sent: ${pictureProblem}` } : {}) });
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
- const expires = Math.floor(Date.now() / 1000) + 300;
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: Math.floor(Date.now() / 1000),
55
+ created_at: issued,
54
56
  tags: [
55
57
  ["t", "get"],
56
58
  ...(ref.sha256 ? [["x", ref.sha256]] : []),
57
- ["expiration", String(expires)],
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
- const expires = now() + 300;
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.1",
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",