@decentnetwork/beagle 0.1.119 → 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.
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.119";
1
+ window.__DK_UI_VERSION="0.1.121";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -617,6 +617,20 @@ export class EmbeddedHost {
617
617
  });
618
618
  return {};
619
619
  }
620
+ case "chat-retry": {
621
+ // Drain oldest-first (the named message among them) rather than sending
622
+ // that one message alone: a retry must not let a newer message overtake
623
+ // an older one in the same thread. The daemon path behaves the same.
624
+ if (!this.#sessionUsable(uid))
625
+ throw new Error("Peer is not online yet");
626
+ await this.#chainFlush(uid);
627
+ const still = this.#messages.queuedOutgoing(uid);
628
+ const id = typeof req.id === "string" ? req.id : undefined;
629
+ if (id && still.some((m) => m.id === id)) {
630
+ throw new Error("Still could not deliver it — it stays queued and will be retried");
631
+ }
632
+ return { queued: still.length };
633
+ }
620
634
  case "chat-log-local":
621
635
  this.#messages.append(uid, req.dir ?? "in", String(req.text ?? ""), Date.now());
622
636
  this.#meta.ensure(uid);
@@ -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/ipc.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Ops the daemon answers. Every one is peer-level — none of them needs the
2
2
  * TUN — which is what makes the beagle/decentlan split possible at all. */
3
- export type IpcOp = "ping" | "diag" | "friend-request" | "friends-pending" | "friends-accept" | "friends-reject" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "sign" | "chat-send" | "chat-history" | "chat-log-local" | "chat-mark-read" | "file-send" | "file-delete" | "file-cancel" | "file-retry" | "file-log-local" | "call-signal" | "call-poll";
3
+ export type IpcOp = "ping" | "diag" | "friend-request" | "friends-pending" | "friends-accept" | "friends-reject" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "sign" | "chat-send" | "chat-history" | "chat-log-local" | "chat-mark-read" | "chat-retry" | "file-send" | "file-delete" | "file-cancel" | "file-retry" | "file-log-local" | "call-signal" | "call-poll";
4
4
  export interface IpcRequest {
5
5
  op: IpcOp;
6
6
  address?: string;
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 = friendStoreStamps(opts.callIce?.keyFile);
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: it is
2130
- // what they are using on their device right now, it needs no
2131
- // gateway, and it is the only avatar a friend who never registered
2132
- // a beagles.eth name will ever have.
2133
- punkId: f.punkId ?? ensPub.get(uid)?.punkId ?? null,
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 = friendStoreStamps(opts.callIce?.keyFile);
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) => {
@@ -2448,6 +2407,17 @@ export function startBeagleServer(opts) {
2448
2407
  sendJson(res, r.ok ? 200 : 400, r);
2449
2408
  return;
2450
2409
  }
2410
+ // The UI has posted this since 0.1.119, but there was no route and no
2411
+ // daemon op behind it: the retry button on a queued message 404'd, so a
2412
+ // message that failed while the friend stayed connected had no way out
2413
+ // at all (mac-dev, 2026-10-31). Needs @decentnetwork/lan >= 0.1.308 in
2414
+ // daemon mode; embedded mode handles it in-process.
2415
+ if (req.method === "POST" && url === "/api/chat-retry") {
2416
+ const { userid, id } = await readBody(req);
2417
+ const r = await opts.call({ op: "chat-retry", userid, id });
2418
+ sendJson(res, r.ok ? 200 : 400, r);
2419
+ return;
2420
+ }
2451
2421
  if (req.method === "POST" && url === "/api/chat-log-local") {
2452
2422
  const { userid, dir, text } = await readBody(req);
2453
2423
  const r = await opts.call({ op: "chat-log-local", userid, dir: dir === "out" ? "out" : "in", text });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.119",
3
+ "version": "0.1.121",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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;
@@ -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
- }