@kybernesis/buzz 0.6.3 → 0.7.0
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/dist/bridge.js +76 -8
- package/dist/media.d.ts +48 -0
- package/dist/media.js +85 -0
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { channelIdentity } from "@kybernesis/enterprise";
|
|
2
|
+
import { fetchMedia, isImage, parseMedia } from "./media.js";
|
|
2
3
|
import { speakerCredentials } from "./credentials.js";
|
|
3
4
|
import { Client } from "eve/client";
|
|
4
5
|
import { BuzzRelay } from "./relay.js";
|
|
@@ -103,7 +104,49 @@ export function buzzBridge(options) {
|
|
|
103
104
|
* nothing. The bridge knew the answer the whole time: it is the connection
|
|
104
105
|
* the message arrived on.
|
|
105
106
|
*/
|
|
106
|
-
|
|
107
|
+
/**
|
|
108
|
+
* One inbound message, as the parts a turn can carry.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* Returns a plain string when there is nothing but text, so the common case
|
|
112
|
+
* stays exactly as it was — a change in the shape of every ordinary turn is
|
|
113
|
+
* not worth paying for the rare one.
|
|
114
|
+
*
|
|
115
|
+
* A failed fetch does NOT throw. The person is owed an answer either way, and
|
|
116
|
+
* an agent told "there was an attachment I could not retrieve" can say so;
|
|
117
|
+
* an agent told nothing answers the caption and looks like it ignored the
|
|
118
|
+
* picture, which is the bug being fixed.
|
|
119
|
+
*/
|
|
120
|
+
async function composeMessage(text, attachments) {
|
|
121
|
+
if (attachments.length === 0)
|
|
122
|
+
return text;
|
|
123
|
+
const parts = [];
|
|
124
|
+
if (text)
|
|
125
|
+
parts.push({ type: "text", text });
|
|
126
|
+
for (const ref of attachments) {
|
|
127
|
+
try {
|
|
128
|
+
const media = await fetchMedia(key, ref);
|
|
129
|
+
if (isImage(media.mediaType)) {
|
|
130
|
+
parts.push({ type: "image", image: media.bytes, mediaType: media.mediaType });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
parts.push({
|
|
134
|
+
type: "text",
|
|
135
|
+
text: `[The sender attached a ${media.mediaType} file, which cannot be read as an image. Tell them what you received and ask for it in a readable form if you need it.]`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
log(`could not fetch an attachment: ${error.message}`);
|
|
141
|
+
parts.push({
|
|
142
|
+
type: "text",
|
|
143
|
+
text: `[The sender attached a file, and it could not be retrieved (${error.message}). Say so rather than answering as if there were no attachment.]`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return parts;
|
|
148
|
+
}
|
|
149
|
+
async function ask(channel, message, pubkey, community) {
|
|
107
150
|
// Built per turn, for the person whose turn it is — and kept current for as
|
|
108
151
|
// long as that turn runs.
|
|
109
152
|
const client = clientFor(pubkey);
|
|
@@ -138,7 +181,7 @@ export function buzzBridge(options) {
|
|
|
138
181
|
if (unread > 0) {
|
|
139
182
|
log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
|
|
140
183
|
}
|
|
141
|
-
const response = await session.send(
|
|
184
|
+
const response = await session.send(message, { clientContext: { buzzCommunity: community, buzzChannel: channel } });
|
|
142
185
|
const result = await response.result();
|
|
143
186
|
// Where this turn left the conversation, so the next one starts after it.
|
|
144
187
|
sessions.set(channel, { id: existing.id, streamIndex: session.state.streamIndex });
|
|
@@ -152,15 +195,15 @@ export function buzzBridge(options) {
|
|
|
152
195
|
}
|
|
153
196
|
}
|
|
154
197
|
const created = await client.sessions.create({
|
|
155
|
-
message
|
|
198
|
+
message,
|
|
156
199
|
clientContext: { buzzCommunity: community, buzzChannel: channel },
|
|
157
200
|
});
|
|
158
|
-
const
|
|
201
|
+
const reply = (await created.response.result()).message ?? "";
|
|
159
202
|
sessions.set(channel, {
|
|
160
203
|
id: created.session.state.sessionId,
|
|
161
204
|
streamIndex: created.session.state.streamIndex,
|
|
162
205
|
});
|
|
163
|
-
return
|
|
206
|
+
return reply;
|
|
164
207
|
}
|
|
165
208
|
/**
|
|
166
209
|
* Send someone the link that makes them known, privately.
|
|
@@ -223,8 +266,21 @@ export function buzzBridge(options) {
|
|
|
223
266
|
if (!channel || !addressed)
|
|
224
267
|
return;
|
|
225
268
|
const text = String(event.content ?? "").trim();
|
|
226
|
-
|
|
269
|
+
const attachments = parseMedia(event);
|
|
270
|
+
/**
|
|
271
|
+
* Drop only a message with NOTHING in it — and say so when we do.
|
|
272
|
+
*
|
|
273
|
+
* This guard used to test the text alone, and sat above the log line, so an
|
|
274
|
+
* image sent without a caption was discarded before anything was written
|
|
275
|
+
* down. From the outside the agent had simply ignored someone; from the
|
|
276
|
+
* inside there was no record that a message had ever arrived. The reply
|
|
277
|
+
* path was hardened against exactly this failure — an empty answer now
|
|
278
|
+
* says so out loud — and the receiving path was left as it was.
|
|
279
|
+
*/
|
|
280
|
+
if (!text && attachments.length === 0) {
|
|
281
|
+
log(`nothing to answer in ${channel.slice(0, 8)} from ${event.pubkey.slice(0, 8)} — no text, no attachments`);
|
|
227
282
|
return;
|
|
283
|
+
}
|
|
228
284
|
let speaker;
|
|
229
285
|
try {
|
|
230
286
|
speaker = await identity.resolve(PROVIDER, event.pubkey, npubEncode(event.pubkey));
|
|
@@ -242,11 +298,23 @@ export function buzzBridge(options) {
|
|
|
242
298
|
await refuse(relay, event.pubkey, speaker.reason);
|
|
243
299
|
return;
|
|
244
300
|
}
|
|
245
|
-
log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}`
|
|
301
|
+
log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}` +
|
|
302
|
+
(attachments.length ? ` [+${attachments.length} attachment(s)]` : ""));
|
|
303
|
+
/**
|
|
304
|
+
* Attachments become parts of the turn, or become words about themselves.
|
|
305
|
+
*
|
|
306
|
+
* An image the model can see goes in as bytes. Anything else — a zip, a
|
|
307
|
+
* PDF the model cannot read, a fetch that failed — goes in as a sentence
|
|
308
|
+
* saying so, because the alternative is an agent that answers the caption
|
|
309
|
+
* while the person waits for a reply about the file they sent. Being told
|
|
310
|
+
* "I got a file I cannot read" is a worse answer than the truth only if
|
|
311
|
+
* you never wanted the truth.
|
|
312
|
+
*/
|
|
313
|
+
const message = await composeMessage(text, attachments);
|
|
246
314
|
void relay.react(event.id, SEEN);
|
|
247
315
|
const stopTyping = relay.typingIn(channel);
|
|
248
316
|
try {
|
|
249
|
-
const reply = await serialize(channel, () => ask(channel,
|
|
317
|
+
const reply = await serialize(channel, () => ask(channel, message, event.pubkey, from));
|
|
250
318
|
if (!reply) {
|
|
251
319
|
/**
|
|
252
320
|
* Silence is the worst answer available.
|
package/dist/media.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { AgentKey } from "./keys.js";
|
|
2
|
+
import type { NostrEvent } from "./relay.js";
|
|
3
|
+
/** One attachment on an inbound message. */
|
|
4
|
+
export interface MediaRef {
|
|
5
|
+
url: string;
|
|
6
|
+
/** From the `m` field when the client sends one; the response may correct it. */
|
|
7
|
+
mediaType?: string;
|
|
8
|
+
/** From the `x` field: the hash the blob is addressed by. */
|
|
9
|
+
sha256?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Fetched bytes, ready to become a message part. */
|
|
12
|
+
export interface FetchedMedia {
|
|
13
|
+
url: string;
|
|
14
|
+
mediaType: string;
|
|
15
|
+
bytes: Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The attachments on a message, from its `imeta` tags (NIP-92).
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* A Buzz client does NOT inline an attachment into the message text. The
|
|
22
|
+
* content stays prose — often prose *about* the picture — and the media lives
|
|
23
|
+
* in a tag. A bridge that reads only `event.content` therefore sees a caption
|
|
24
|
+
* with nothing attached, and, when there is no caption, sees nothing at all.
|
|
25
|
+
*
|
|
26
|
+
* Both were reported from a live deployment: an image with a caption was
|
|
27
|
+
* answered as though the caption were the whole message, and an image without
|
|
28
|
+
* one produced no response and no log line. The agent could not tell either had
|
|
29
|
+
* happened, so it speculated about its own vision support — which was fine.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseMedia(event: NostrEvent): MediaRef[];
|
|
32
|
+
/**
|
|
33
|
+
* Fetch one attachment as the agent.
|
|
34
|
+
*
|
|
35
|
+
* @remarks
|
|
36
|
+
* Buzz media is authenticated: an anonymous GET answers 401, not 404. So the
|
|
37
|
+
* URL cannot simply be handed to the model — a provider fetching it would be
|
|
38
|
+
* refused, and the failure would surface as a broken image rather than as an
|
|
39
|
+
* auth problem. The bytes are fetched here, with the same Blossom
|
|
40
|
+
* authorisation this package already signs to upload an agent's avatar, and
|
|
41
|
+
* passed inline.
|
|
42
|
+
*
|
|
43
|
+
* `t get` rather than `t upload`, and the blob's own hash when the sender told
|
|
44
|
+
* us one, so the authorisation is scoped to reading that specific blob.
|
|
45
|
+
*/
|
|
46
|
+
export declare function fetchMedia(key: AgentKey, ref: MediaRef, fetchImpl?: typeof fetch): Promise<FetchedMedia>;
|
|
47
|
+
/** Whether the model can be shown this directly, or only told about it. */
|
|
48
|
+
export declare function isImage(mediaType: string): boolean;
|
package/dist/media.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { finalizeEvent } from "nostr-tools/pure";
|
|
3
|
+
/**
|
|
4
|
+
* The attachments on a message, from its `imeta` tags (NIP-92).
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* A Buzz client does NOT inline an attachment into the message text. The
|
|
8
|
+
* content stays prose — often prose *about* the picture — and the media lives
|
|
9
|
+
* in a tag. A bridge that reads only `event.content` therefore sees a caption
|
|
10
|
+
* with nothing attached, and, when there is no caption, sees nothing at all.
|
|
11
|
+
*
|
|
12
|
+
* Both were reported from a live deployment: an image with a caption was
|
|
13
|
+
* answered as though the caption were the whole message, and an image without
|
|
14
|
+
* one produced no response and no log line. The agent could not tell either had
|
|
15
|
+
* happened, so it speculated about its own vision support — which was fine.
|
|
16
|
+
*/
|
|
17
|
+
export function parseMedia(event) {
|
|
18
|
+
const media = [];
|
|
19
|
+
for (const tag of event.tags) {
|
|
20
|
+
if (tag[0] !== "imeta")
|
|
21
|
+
continue;
|
|
22
|
+
// An imeta tag is space-separated "key value" pairs, one per element.
|
|
23
|
+
const fields = new Map();
|
|
24
|
+
for (const entry of tag.slice(1)) {
|
|
25
|
+
const space = entry.indexOf(" ");
|
|
26
|
+
if (space > 0)
|
|
27
|
+
fields.set(entry.slice(0, space), entry.slice(space + 1));
|
|
28
|
+
}
|
|
29
|
+
const url = fields.get("url");
|
|
30
|
+
if (url)
|
|
31
|
+
media.push({ url, mediaType: fields.get("m"), sha256: fields.get("x") });
|
|
32
|
+
}
|
|
33
|
+
return media;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Fetch one attachment as the agent.
|
|
37
|
+
*
|
|
38
|
+
* @remarks
|
|
39
|
+
* Buzz media is authenticated: an anonymous GET answers 401, not 404. So the
|
|
40
|
+
* URL cannot simply be handed to the model — a provider fetching it would be
|
|
41
|
+
* refused, and the failure would surface as a broken image rather than as an
|
|
42
|
+
* auth problem. The bytes are fetched here, with the same Blossom
|
|
43
|
+
* authorisation this package already signs to upload an agent's avatar, and
|
|
44
|
+
* passed inline.
|
|
45
|
+
*
|
|
46
|
+
* `t get` rather than `t upload`, and the blob's own hash when the sender told
|
|
47
|
+
* us one, so the authorisation is scoped to reading that specific blob.
|
|
48
|
+
*/
|
|
49
|
+
export async function fetchMedia(key, ref, fetchImpl = fetch) {
|
|
50
|
+
const expires = Math.floor(Date.now() / 1000) + 300;
|
|
51
|
+
const auth = finalizeEvent({
|
|
52
|
+
kind: 24242,
|
|
53
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
54
|
+
tags: [
|
|
55
|
+
["t", "get"],
|
|
56
|
+
...(ref.sha256 ? [["x", ref.sha256]] : []),
|
|
57
|
+
["expiration", String(expires)],
|
|
58
|
+
],
|
|
59
|
+
content: "Fetch buzz-media",
|
|
60
|
+
}, key.secretKey);
|
|
61
|
+
const response = await fetchImpl(ref.url, {
|
|
62
|
+
headers: { authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64url")}` },
|
|
63
|
+
});
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw new Error(`${response.status} fetching ${ref.url}`);
|
|
66
|
+
}
|
|
67
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
68
|
+
// The server's answer wins over the sender's claim: the sender's `m` field is
|
|
69
|
+
// whatever their client asserted, and a wrong media type reaches the model as
|
|
70
|
+
// an image it cannot decode.
|
|
71
|
+
const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim() || ref.mediaType || "application/octet-stream";
|
|
72
|
+
if (ref.sha256) {
|
|
73
|
+
// Blossom addresses a blob BY its hash, so a mismatch means the bytes are
|
|
74
|
+
// not the ones that were sent. Cheap to check and not worth trusting past.
|
|
75
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
76
|
+
if (actual !== ref.sha256) {
|
|
77
|
+
throw new Error(`content hash mismatch for ${ref.url}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { url: ref.url, mediaType, bytes };
|
|
81
|
+
}
|
|
82
|
+
/** Whether the model can be shown this directly, or only told about it. */
|
|
83
|
+
export function isImage(mediaType) {
|
|
84
|
+
return mediaType.startsWith("image/");
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/buzz",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|