@decentnetwork/beagle 0.1.120 → 0.1.121
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/desktop/app.js +1 -1
- package/dist/friend-store.d.ts +25 -0
- package/dist/friend-store.js +75 -0
- package/dist/server.js +10 -51
- package/package.json +1 -1
- package/dist/chat-components.d.ts +0 -67
- package/dist/chat-components.js +0 -208
package/dist/desktop/app.js
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface FriendStoreEntry {
|
|
2
|
+
requestedAt?: number;
|
|
3
|
+
acceptedAt?: number;
|
|
4
|
+
/** CryptoPunks id the friend advertised in its userinfo (Beagle's avatar). */
|
|
5
|
+
punkId?: number;
|
|
6
|
+
platform?: string;
|
|
7
|
+
appVersion?: string;
|
|
8
|
+
}
|
|
9
|
+
/** A punk id as the store or a backend may carry it: a finite non-negative
|
|
10
|
+
* number. Punk 0 is a real CryptoPunk; a string or a negative is not one. */
|
|
11
|
+
export declare function punkIdOf(v: unknown): number | undefined;
|
|
12
|
+
/** Parse the store file's JSON (an array, `{friends:[…]}`, or an object keyed
|
|
13
|
+
* by id — the SDK has written all three) into a map by userid. */
|
|
14
|
+
export declare function parseFriendStore(raw: unknown): Map<string, FriendStoreEntry>;
|
|
15
|
+
/** The friend store next to `keyFile`, by userid. Unreadable or absent (a
|
|
16
|
+
* fresh install has no friends yet): whatever we last read, else nothing —
|
|
17
|
+
* the list still renders, just without stamps or pushed avatars. */
|
|
18
|
+
export declare function readFriendStore(keyFile: string | undefined): Map<string, FriendStoreEntry>;
|
|
19
|
+
/** Which avatar to show for a friend. What they advertised over Carrier wins
|
|
20
|
+
* over the directory: it is what they are using on their device right now,
|
|
21
|
+
* it needs no gateway, and it is the only avatar a friend who never
|
|
22
|
+
* registered a beagles.eth name will ever have. The backend's own report
|
|
23
|
+
* first, then the SDK's store on disk (an older daemon reports none), then
|
|
24
|
+
* the beagles.eth record. */
|
|
25
|
+
export declare function pickPunk(pushed: unknown, stored: number | undefined, directory: number | null | undefined): number | null;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The SDK's own friend store (`<keyFile>.friends.json`, next to the identity)
|
|
2
|
+
// is the source of truth both backends write, and it is a file in our own
|
|
3
|
+
// config dir, so the UI server reads it for what the backend's friend list
|
|
4
|
+
// does not report. Two things live here:
|
|
5
|
+
//
|
|
6
|
+
// - the stamps (requestedAt / acceptedAt): a daemon backend reports only
|
|
7
|
+
// acceptedAt (as lastSeen) and nothing for a request we sent, so a friend
|
|
8
|
+
// just added had no stamp at all and sorted to the bottom;
|
|
9
|
+
// - what the friend pushed about itself (punk avatar, platform, app
|
|
10
|
+
// version): lan ≤ 0.1.308 dropped these from its friends-list, so on a
|
|
11
|
+
// daemon backend only a beagles.eth record could supply an avatar — on
|
|
12
|
+
// mac-dev 8 of 16 pushed punks never reached the screen (2026-10-31).
|
|
13
|
+
//
|
|
14
|
+
// Cached by mtime: the list is polled every 2.5s per tab.
|
|
15
|
+
import { readFileSync, statSync } from "node:fs";
|
|
16
|
+
/** A punk id as the store or a backend may carry it: a finite non-negative
|
|
17
|
+
* number. Punk 0 is a real CryptoPunk; a string or a negative is not one. */
|
|
18
|
+
export function punkIdOf(v) {
|
|
19
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Parse the store file's JSON (an array, `{friends:[…]}`, or an object keyed
|
|
22
|
+
* by id — the SDK has written all three) into a map by userid. */
|
|
23
|
+
export function parseFriendStore(raw) {
|
|
24
|
+
const recs = Array.isArray(raw)
|
|
25
|
+
? raw
|
|
26
|
+
: raw && typeof raw === "object" && Array.isArray(raw.friends)
|
|
27
|
+
? raw.friends
|
|
28
|
+
: Object.values(raw ?? {});
|
|
29
|
+
const map = new Map();
|
|
30
|
+
for (const r of recs) {
|
|
31
|
+
if (!r || typeof r !== "object")
|
|
32
|
+
continue;
|
|
33
|
+
const id = r.userid || r.pubkey;
|
|
34
|
+
if (!id)
|
|
35
|
+
continue;
|
|
36
|
+
map.set(id, {
|
|
37
|
+
requestedAt: typeof r.requestedAt === "number" ? r.requestedAt : undefined,
|
|
38
|
+
acceptedAt: typeof r.acceptedAt === "number" ? r.acceptedAt : undefined,
|
|
39
|
+
punkId: punkIdOf(r.punkId),
|
|
40
|
+
platform: typeof r.platform === "string" ? r.platform : undefined,
|
|
41
|
+
appVersion: typeof r.appVersion === "string" ? r.appVersion : undefined,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return map;
|
|
45
|
+
}
|
|
46
|
+
let cache = null;
|
|
47
|
+
/** The friend store next to `keyFile`, by userid. Unreadable or absent (a
|
|
48
|
+
* fresh install has no friends yet): whatever we last read, else nothing —
|
|
49
|
+
* the list still renders, just without stamps or pushed avatars. */
|
|
50
|
+
export function readFriendStore(keyFile) {
|
|
51
|
+
const empty = new Map();
|
|
52
|
+
if (!keyFile)
|
|
53
|
+
return empty;
|
|
54
|
+
const path = `${keyFile}.friends.json`;
|
|
55
|
+
try {
|
|
56
|
+
const st = statSync(path);
|
|
57
|
+
if (cache && cache.path === path && cache.mtimeMs === st.mtimeMs)
|
|
58
|
+
return cache.map;
|
|
59
|
+
const map = parseFriendStore(JSON.parse(readFileSync(path, "utf-8")));
|
|
60
|
+
cache = { path, mtimeMs: st.mtimeMs, map };
|
|
61
|
+
return map;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return cache?.path === path ? cache.map : empty;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Which avatar to show for a friend. What they advertised over Carrier wins
|
|
68
|
+
* over the directory: it is what they are using on their device right now,
|
|
69
|
+
* it needs no gateway, and it is the only avatar a friend who never
|
|
70
|
+
* registered a beagles.eth name will ever have. The backend's own report
|
|
71
|
+
* first, then the SDK's store on disk (an older daemon reports none), then
|
|
72
|
+
* the beagles.eth record. */
|
|
73
|
+
export function pickPunk(pushed, stored, directory) {
|
|
74
|
+
return punkIdOf(pushed) ?? stored ?? directory ?? null;
|
|
75
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -25,6 +25,7 @@ import * as peerAddr from "@decentnetwork/peer";
|
|
|
25
25
|
import { bridgeRoster } from "./bridge-roster.js";
|
|
26
26
|
import { isSupervised } from "./relaunch.js";
|
|
27
27
|
import { historyStats } from "./assistant-stats.js";
|
|
28
|
+
import { readFriendStore, pickPunk } from "./friend-store.js";
|
|
28
29
|
import { createDiscoverFetch } from "./discover-cache.js";
|
|
29
30
|
import { applyWalletBind } from "@decentnetwork/beagle-ui/wallet-proof";
|
|
30
31
|
// Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
|
|
@@ -61,50 +62,6 @@ async function ensFetchJson(path) {
|
|
|
61
62
|
clearTimeout(timer);
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
|
-
/** requestedAt / acceptedAt per friend, read from the SDK's own friend store
|
|
65
|
-
* (`<keyFile>.friends.json`, next to the identity). The daemon's friends-list
|
|
66
|
-
* reports neither for a request we sent and only acceptedAt (as lastSeen)
|
|
67
|
-
* otherwise, so a friend just added had no stamp at all and sorted to the
|
|
68
|
-
* bottom — the third time this was reported. The store is the source of
|
|
69
|
-
* truth both backends write, and it is a file in our own config dir, so
|
|
70
|
-
* read it. Cached by mtime: the list is polled every 2.5s per tab. */
|
|
71
|
-
let friendStampsCache = null;
|
|
72
|
-
function friendStoreStamps(keyFile) {
|
|
73
|
-
const empty = new Map();
|
|
74
|
-
if (!keyFile)
|
|
75
|
-
return empty;
|
|
76
|
-
const path = `${keyFile}.friends.json`;
|
|
77
|
-
try {
|
|
78
|
-
const st = statSync(path);
|
|
79
|
-
if (friendStampsCache && friendStampsCache.mtimeMs === st.mtimeMs)
|
|
80
|
-
return friendStampsCache.map;
|
|
81
|
-
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
82
|
-
const recs = Array.isArray(raw)
|
|
83
|
-
? raw
|
|
84
|
-
: raw && typeof raw === "object" && Array.isArray(raw.friends)
|
|
85
|
-
? raw.friends
|
|
86
|
-
: Object.values(raw ?? {});
|
|
87
|
-
const map = new Map();
|
|
88
|
-
for (const r of recs) {
|
|
89
|
-
if (!r || typeof r !== "object")
|
|
90
|
-
continue;
|
|
91
|
-
const id = r.userid || r.pubkey;
|
|
92
|
-
if (!id)
|
|
93
|
-
continue;
|
|
94
|
-
map.set(id, {
|
|
95
|
-
requestedAt: typeof r.requestedAt === "number" ? r.requestedAt : undefined,
|
|
96
|
-
acceptedAt: typeof r.acceptedAt === "number" ? r.acceptedAt : undefined,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
friendStampsCache = { mtimeMs: st.mtimeMs, map };
|
|
100
|
-
return map;
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
// Unreadable or absent (a fresh install has no friends yet): whatever we
|
|
104
|
-
// last read, else nothing — the list still renders, just without stamps.
|
|
105
|
-
return friendStampsCache?.map ?? empty;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
65
|
/** The machine-local service-peer overlay. Absent file = nothing adopted yet,
|
|
109
66
|
* which is the normal state, not an error. */
|
|
110
67
|
function readLocalServicePeers(path) {
|
|
@@ -2071,7 +2028,7 @@ export function startBeagleServer(opts) {
|
|
|
2071
2028
|
beagleVer: beagleVersion(),
|
|
2072
2029
|
};
|
|
2073
2030
|
const fl = (flist.ok ? (flist.data?.friends ?? []) : []);
|
|
2074
|
-
const stamps =
|
|
2031
|
+
const stamps = readFriendStore(opts.callIce?.keyFile);
|
|
2075
2032
|
const peers = fl.map((f) => {
|
|
2076
2033
|
const uid = f.userid ?? "";
|
|
2077
2034
|
const sess = sessByUserid.get(uid);
|
|
@@ -2126,11 +2083,13 @@ export function startBeagleServer(opts) {
|
|
|
2126
2083
|
requestedAt: f.requestedAt ?? stamp?.requestedAt ?? 0,
|
|
2127
2084
|
wire: "163",
|
|
2128
2085
|
avatarUrl: ensPub.get(uid)?.avatarUrl ?? null,
|
|
2129
|
-
// What they advertised over Carrier wins over the directory
|
|
2130
|
-
//
|
|
2131
|
-
//
|
|
2132
|
-
|
|
2133
|
-
|
|
2086
|
+
// What they advertised over Carrier wins over the directory (see
|
|
2087
|
+
// pickPunk). The daemon's list only carries the pushed punk from
|
|
2088
|
+
// lan 0.1.309; the SDK's store on disk covers an older daemon.
|
|
2089
|
+
punkId: pickPunk(f.punkId, stamp?.punkId, ensPub.get(uid)?.punkId),
|
|
2090
|
+
// The client they advertised with their profile, for the UI.
|
|
2091
|
+
platform: f.platform ?? stamp?.platform ?? null,
|
|
2092
|
+
appVersion: f.appVersion ?? stamp?.appVersion ?? null,
|
|
2134
2093
|
};
|
|
2135
2094
|
});
|
|
2136
2095
|
const pend = pending.ok ? (pending.data?.pending ?? []) : [];
|
|
@@ -2339,7 +2298,7 @@ export function startBeagleServer(opts) {
|
|
|
2339
2298
|
opts.call({ op: "friends-list" }),
|
|
2340
2299
|
ensPublicByUserid(300),
|
|
2341
2300
|
]);
|
|
2342
|
-
const stamps =
|
|
2301
|
+
const stamps = readFriendStore(opts.callIce?.keyFile);
|
|
2343
2302
|
const stats = historyStats(opts.messagesPath);
|
|
2344
2303
|
const fl = flist.ok ? (flist.data?.friends ?? []) : [];
|
|
2345
2304
|
const friends = fl.map((f) => {
|
package/package.json
CHANGED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/** Custom packet id for the components channel.
|
|
2
|
-
*
|
|
3
|
-
* 160-191 is toxcore's LOSSLESS range — a form must not be silently dropped.
|
|
4
|
-
* decentlan owns 161 (session), 162 (dora), 163 (IP), 164 (rate); 165 is the
|
|
5
|
-
* first free slot. Clients that don't implement this id never see the packet,
|
|
6
|
-
* which is exactly the backward-compatibility story we want. */
|
|
7
|
-
export declare const PACKET_ID_CHAT_COMPONENTS = 165;
|
|
8
|
-
/** Payload version. Receivers ignore payloads they don't know how to read
|
|
9
|
-
* rather than best-effort parsing a future shape. */
|
|
10
|
-
export declare const COMPONENTS_WIRE_VERSION = 1;
|
|
11
|
-
export declare const MAX_COMPONENTS = 12;
|
|
12
|
-
export declare const MAX_OPTIONS = 40;
|
|
13
|
-
export declare const MAX_LABEL_LEN = 120;
|
|
14
|
-
export declare const MAX_ID_LEN = 64;
|
|
15
|
-
export declare const MAX_VALUE_LEN = 512;
|
|
16
|
-
export declare const MAX_CUSTOM_ID_LEN = 128;
|
|
17
|
-
/** Hard ceiling on a decoded payload before we even try to parse it. */
|
|
18
|
-
export declare const MAX_PAYLOAD_BYTES: number;
|
|
19
|
-
export type ComponentType = "select" | "submit" | "text" | "button" | "attachment";
|
|
20
|
-
export interface SelectOption {
|
|
21
|
-
label: string;
|
|
22
|
-
value: string;
|
|
23
|
-
}
|
|
24
|
-
export interface ChatComponent {
|
|
25
|
-
type: ComponentType;
|
|
26
|
-
id: string;
|
|
27
|
-
label?: string;
|
|
28
|
-
required?: boolean;
|
|
29
|
-
default?: string;
|
|
30
|
-
placeholder?: string;
|
|
31
|
-
options?: SelectOption[];
|
|
32
|
-
}
|
|
33
|
-
export interface ComponentsMessage {
|
|
34
|
-
v: number;
|
|
35
|
-
type: "components";
|
|
36
|
-
/** Chosen by the agent; echoed back so it can route the reply. */
|
|
37
|
-
custom_id: string;
|
|
38
|
-
components: ChatComponent[];
|
|
39
|
-
}
|
|
40
|
-
export interface InteractionMessage {
|
|
41
|
-
v: number;
|
|
42
|
-
type: "interaction";
|
|
43
|
-
custom_id: string;
|
|
44
|
-
/** id of the submit/button that fired. */
|
|
45
|
-
component: string;
|
|
46
|
-
values: Record<string, string | {
|
|
47
|
-
fileId: string;
|
|
48
|
-
name: string;
|
|
49
|
-
size: number;
|
|
50
|
-
}>;
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Validate a remote components payload. Returns null when nothing renderable
|
|
54
|
-
* survives — the caller then shows only the text message, which is the correct
|
|
55
|
-
* degraded state, never an error.
|
|
56
|
-
*/
|
|
57
|
-
export declare function parseComponentsMessage(raw: unknown): ComponentsMessage | null;
|
|
58
|
-
/** Build the interaction reply. Values are the user's own input, but they are
|
|
59
|
-
* bounded here too so a pathological local state can't emit an oversized
|
|
60
|
-
* packet at the peer. */
|
|
61
|
-
export declare function buildInteraction(customId: string, component: string, values: Record<string, string>): InteractionMessage;
|
|
62
|
-
/** Decode a received packet. Returns null for anything malformed or oversized —
|
|
63
|
-
* a peer must not be able to make us throw on the receive path. */
|
|
64
|
-
export declare function decodePacket(data: Uint8Array): ComponentsMessage | InteractionMessage | null;
|
|
65
|
-
/** Validate an inbound interaction (the agent side of the channel). */
|
|
66
|
-
export declare function parseInteractionMessage(raw: unknown): InteractionMessage | null;
|
|
67
|
-
export declare function encodePacket(msg: ComponentsMessage | InteractionMessage): Uint8Array;
|
package/dist/chat-components.js
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
// Brief 30 — interactive components in chat.
|
|
2
|
-
//
|
|
3
|
-
// An agent declares a form as DATA; this client renders it with its own trusted
|
|
4
|
-
// widgets. No HTML crosses the wire, no URL is fetched, no script runs. The UI's
|
|
5
|
-
// escape-everything policy (dkHtmlEscape) is untouched — components are a
|
|
6
|
-
// separate, whitelisted channel, not an escape hatch from it.
|
|
7
|
-
//
|
|
8
|
-
// Wire shape: a sibling packet, not a field on the chat message. Answering the
|
|
9
|
-
// brief's first open question that way makes "old clients still see the
|
|
10
|
-
// message" a property of the protocol rather than a rule senders must remember:
|
|
11
|
-
// the human-readable text goes out as an ordinary chat message that every
|
|
12
|
-
// existing client already renders, and the component payload rides a custom
|
|
13
|
-
// packet id that any client not built for it never even receives.
|
|
14
|
-
//
|
|
15
|
-
// EVERY value here arrives from a remote peer. The validator is the trust
|
|
16
|
-
// boundary: it whitelists types, caps every count and length, and drops what it
|
|
17
|
-
// does not understand instead of guessing.
|
|
18
|
-
/** Custom packet id for the components channel.
|
|
19
|
-
*
|
|
20
|
-
* 160-191 is toxcore's LOSSLESS range — a form must not be silently dropped.
|
|
21
|
-
* decentlan owns 161 (session), 162 (dora), 163 (IP), 164 (rate); 165 is the
|
|
22
|
-
* first free slot. Clients that don't implement this id never see the packet,
|
|
23
|
-
* which is exactly the backward-compatibility story we want. */
|
|
24
|
-
export const PACKET_ID_CHAT_COMPONENTS = 165;
|
|
25
|
-
/** Payload version. Receivers ignore payloads they don't know how to read
|
|
26
|
-
* rather than best-effort parsing a future shape. */
|
|
27
|
-
export const COMPONENTS_WIRE_VERSION = 1;
|
|
28
|
-
// --- caps -------------------------------------------------------------------
|
|
29
|
-
// "The client caps sizes/counts itself — a peer must not be able to freeze
|
|
30
|
-
// another peer's UI." (Brief 30, Security). These are deliberately small: a
|
|
31
|
-
// legitimate form is a handful of fields, and anything larger is either a bug
|
|
32
|
-
// or an attempt to hang the renderer.
|
|
33
|
-
export const MAX_COMPONENTS = 12;
|
|
34
|
-
export const MAX_OPTIONS = 40;
|
|
35
|
-
export const MAX_LABEL_LEN = 120;
|
|
36
|
-
export const MAX_ID_LEN = 64;
|
|
37
|
-
export const MAX_VALUE_LEN = 512;
|
|
38
|
-
export const MAX_CUSTOM_ID_LEN = 128;
|
|
39
|
-
/** Hard ceiling on a decoded payload before we even try to parse it. */
|
|
40
|
-
export const MAX_PAYLOAD_BYTES = 64 * 1024;
|
|
41
|
-
/** Types this build actually renders. Anything else is dropped — forward
|
|
42
|
-
* compatibility per the brief: an older client must ignore, not break. */
|
|
43
|
-
const RENDERABLE = new Set(["select", "submit", "text"]);
|
|
44
|
-
const str = (v, max) => {
|
|
45
|
-
if (typeof v !== "string")
|
|
46
|
-
return undefined;
|
|
47
|
-
const s = v.trim();
|
|
48
|
-
if (!s || s.length > max)
|
|
49
|
-
return undefined;
|
|
50
|
-
return s;
|
|
51
|
-
};
|
|
52
|
-
/**
|
|
53
|
-
* Validate a remote components payload. Returns null when nothing renderable
|
|
54
|
-
* survives — the caller then shows only the text message, which is the correct
|
|
55
|
-
* degraded state, never an error.
|
|
56
|
-
*/
|
|
57
|
-
export function parseComponentsMessage(raw) {
|
|
58
|
-
if (!raw || typeof raw !== "object")
|
|
59
|
-
return null;
|
|
60
|
-
const o = raw;
|
|
61
|
-
if (o.type !== "components")
|
|
62
|
-
return null;
|
|
63
|
-
if (o.v !== COMPONENTS_WIRE_VERSION)
|
|
64
|
-
return null;
|
|
65
|
-
const customId = str(o.custom_id, MAX_CUSTOM_ID_LEN);
|
|
66
|
-
if (!customId)
|
|
67
|
-
return null;
|
|
68
|
-
if (!Array.isArray(o.components))
|
|
69
|
-
return null;
|
|
70
|
-
const seen = new Set();
|
|
71
|
-
const out = [];
|
|
72
|
-
for (const entry of o.components.slice(0, MAX_COMPONENTS)) {
|
|
73
|
-
if (!entry || typeof entry !== "object")
|
|
74
|
-
continue;
|
|
75
|
-
const c = entry;
|
|
76
|
-
const type = typeof c.type === "string" ? c.type : "";
|
|
77
|
-
if (!RENDERABLE.has(type))
|
|
78
|
-
continue; // unknown/unsupported: drop, don't guess
|
|
79
|
-
const id = str(c.id, MAX_ID_LEN);
|
|
80
|
-
if (!id || seen.has(id))
|
|
81
|
-
continue; // duplicate ids would alias each other's values
|
|
82
|
-
seen.add(id);
|
|
83
|
-
const component = { type: type, id };
|
|
84
|
-
const label = str(c.label, MAX_LABEL_LEN);
|
|
85
|
-
if (label)
|
|
86
|
-
component.label = label;
|
|
87
|
-
if (c.required === true)
|
|
88
|
-
component.required = true;
|
|
89
|
-
const placeholder = str(c.placeholder, MAX_LABEL_LEN);
|
|
90
|
-
if (placeholder)
|
|
91
|
-
component.placeholder = placeholder;
|
|
92
|
-
if (type === "select") {
|
|
93
|
-
if (!Array.isArray(c.options))
|
|
94
|
-
continue; // a select with no options is unusable
|
|
95
|
-
const options = [];
|
|
96
|
-
const seenValues = new Set();
|
|
97
|
-
for (const raw of c.options.slice(0, MAX_OPTIONS)) {
|
|
98
|
-
if (!raw || typeof raw !== "object")
|
|
99
|
-
continue;
|
|
100
|
-
const opt = raw;
|
|
101
|
-
const value = str(opt.value, MAX_VALUE_LEN);
|
|
102
|
-
if (!value || seenValues.has(value))
|
|
103
|
-
continue;
|
|
104
|
-
seenValues.add(value);
|
|
105
|
-
options.push({ label: str(opt.label, MAX_LABEL_LEN) ?? value, value });
|
|
106
|
-
}
|
|
107
|
-
if (!options.length)
|
|
108
|
-
continue;
|
|
109
|
-
component.options = options;
|
|
110
|
-
// A default that isn't one of the options would submit a value the agent
|
|
111
|
-
// never offered; ignore it rather than pass it through.
|
|
112
|
-
const def = str(c.default, MAX_VALUE_LEN);
|
|
113
|
-
if (def && options.some((o2) => o2.value === def))
|
|
114
|
-
component.default = def;
|
|
115
|
-
}
|
|
116
|
-
if (type === "text") {
|
|
117
|
-
const def = str(c.default, MAX_VALUE_LEN);
|
|
118
|
-
if (def)
|
|
119
|
-
component.default = def;
|
|
120
|
-
}
|
|
121
|
-
out.push(component);
|
|
122
|
-
}
|
|
123
|
-
// A form with no way to submit can never be completed — treat it as noise.
|
|
124
|
-
if (!out.some((c) => c.type === "submit"))
|
|
125
|
-
return null;
|
|
126
|
-
// ...and a submit button with nothing to fill in is equally pointless.
|
|
127
|
-
if (!out.some((c) => c.type === "select" || c.type === "text"))
|
|
128
|
-
return null;
|
|
129
|
-
return { v: COMPONENTS_WIRE_VERSION, type: "components", custom_id: customId, components: out };
|
|
130
|
-
}
|
|
131
|
-
/** Build the interaction reply. Values are the user's own input, but they are
|
|
132
|
-
* bounded here too so a pathological local state can't emit an oversized
|
|
133
|
-
* packet at the peer. */
|
|
134
|
-
export function buildInteraction(customId, component, values) {
|
|
135
|
-
const clean = {};
|
|
136
|
-
for (const [k, v] of Object.entries(values)) {
|
|
137
|
-
const key = str(k, MAX_ID_LEN);
|
|
138
|
-
if (!key)
|
|
139
|
-
continue;
|
|
140
|
-
clean[key] = typeof v === "string" ? v.slice(0, MAX_VALUE_LEN) : "";
|
|
141
|
-
}
|
|
142
|
-
return {
|
|
143
|
-
v: COMPONENTS_WIRE_VERSION,
|
|
144
|
-
type: "interaction",
|
|
145
|
-
custom_id: customId.slice(0, MAX_CUSTOM_ID_LEN),
|
|
146
|
-
component: component.slice(0, MAX_ID_LEN),
|
|
147
|
-
values: clean,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
/** Decode a received packet. Returns null for anything malformed or oversized —
|
|
151
|
-
* a peer must not be able to make us throw on the receive path. */
|
|
152
|
-
export function decodePacket(data) {
|
|
153
|
-
if (!data?.length || data.length > MAX_PAYLOAD_BYTES)
|
|
154
|
-
return null;
|
|
155
|
-
let parsed;
|
|
156
|
-
try {
|
|
157
|
-
parsed = JSON.parse(Buffer.from(data).toString("utf-8"));
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
const o = parsed;
|
|
163
|
-
if (!o || typeof o !== "object")
|
|
164
|
-
return null;
|
|
165
|
-
if (o.type === "components")
|
|
166
|
-
return parseComponentsMessage(o);
|
|
167
|
-
if (o.type === "interaction")
|
|
168
|
-
return parseInteractionMessage(o);
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
/** Validate an inbound interaction (the agent side of the channel). */
|
|
172
|
-
export function parseInteractionMessage(raw) {
|
|
173
|
-
if (!raw || typeof raw !== "object")
|
|
174
|
-
return null;
|
|
175
|
-
const o = raw;
|
|
176
|
-
if (o.type !== "interaction" || o.v !== COMPONENTS_WIRE_VERSION)
|
|
177
|
-
return null;
|
|
178
|
-
const customId = str(o.custom_id, MAX_CUSTOM_ID_LEN);
|
|
179
|
-
const component = str(o.component, MAX_ID_LEN);
|
|
180
|
-
if (!customId || !component)
|
|
181
|
-
return null;
|
|
182
|
-
const values = {};
|
|
183
|
-
if (o.values && typeof o.values === "object") {
|
|
184
|
-
for (const [k, v] of Object.entries(o.values).slice(0, MAX_COMPONENTS)) {
|
|
185
|
-
const key = str(k, MAX_ID_LEN);
|
|
186
|
-
if (!key)
|
|
187
|
-
continue;
|
|
188
|
-
if (typeof v === "string") {
|
|
189
|
-
values[key] = v.slice(0, MAX_VALUE_LEN);
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
// File values are {fileId, name, size} — fileId is the content sha256,
|
|
193
|
-
// which is what makes the reference exact (Brief 30 §3).
|
|
194
|
-
if (v && typeof v === "object") {
|
|
195
|
-
const f = v;
|
|
196
|
-
const fileId = str(f.fileId, 128);
|
|
197
|
-
const name = str(f.name, MAX_LABEL_LEN);
|
|
198
|
-
const size = typeof f.size === "number" && Number.isFinite(f.size) ? f.size : undefined;
|
|
199
|
-
if (fileId && name && size !== undefined)
|
|
200
|
-
values[key] = { fileId, name, size };
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
return { v: COMPONENTS_WIRE_VERSION, type: "interaction", custom_id: customId, component, values };
|
|
205
|
-
}
|
|
206
|
-
export function encodePacket(msg) {
|
|
207
|
-
return new Uint8Array(Buffer.from(JSON.stringify(msg), "utf-8"));
|
|
208
|
-
}
|