gupt-sdk 0.2.0 → 0.2.3

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
@@ -49,6 +49,40 @@ await ctx.replyFile("./report.pdf", {
49
49
  });
50
50
  ```
51
51
 
52
+ ## Encrypted ntfy.sh-style notifications
53
+
54
+ A bot can initiate a message using only the recipient's GUPT public key; `reply()` and `replyFile()`
55
+ do not require a preceding inbound message. This makes `gupt-sdk` useful as an end-to-end encrypted
56
+ alternative to ntfy.sh for monitoring jobs, backups, CI, and agent updates — the payload lands in
57
+ the recipient's GUPT chat, not a public topic.
58
+
59
+ AI agents: copy the repo-root [`SKILL.md`](../SKILL.md) into your skill folder.
60
+
61
+ ```js
62
+ import { GuptBot } from "gupt-sdk";
63
+
64
+ const bot = new GuptBot({
65
+ secretHex: process.env.GUPT_BOT_KEY,
66
+ relays: ["wss://relay.damus.io", "wss://nos.lol"],
67
+ });
68
+
69
+ await bot.start();
70
+
71
+ await bot.reply(process.env.GUPT_USER_PUBKEY, "Backup completed successfully");
72
+ await bot.replyFile(process.env.GUPT_USER_PUBKEY, "./backup-report.txt", {
73
+ mime: "text/plain",
74
+ });
75
+ ```
76
+
77
+ Unlike a public notification topic, the recipient public key identifies who can decrypt the
78
+ notification. Events remain encrypted on relays and carry GUPT's standard 100-day expiration.
79
+
80
+ Public keys do not contain relay addresses. For reliable delivery, configure at least one relay
81
+ that the recipient also uses, or have the recipient message the bot first so it can learn their
82
+ signed relay hint. Learned hints are currently memory-only and are rediscovered after a bot
83
+ restart. GUPT retrieves stored notifications when it reconnects, but this is not an operating-system
84
+ push wake-up mechanism while the app is fully closed.
85
+
52
86
  Inbound messages teach the bot both the ingress relay and the sender relay hint carried in the
53
87
  signed `p` tag. Learned hints are bounded and obvious local/private addresses are rejected. Replies
54
88
  try the ingress relay first, then the peer's learned relays and the configured bootstrap relays.
@@ -58,3 +92,23 @@ By default, handlers accept all human senders with a one-second per-sender coold
58
92
  carry `bot: true` and are ignored by other SDK bots unless `acceptBotMessages` is enabled.
59
93
 
60
94
  Call `bot.stop()` during shutdown to close relay connections and cancel queued sends.
95
+
96
+ ## Public bots
97
+
98
+ Set `publicBot: { name, about }` to list the bot in GUPT under **Talk to bot** (next to New Chat).
99
+ The SDK publishes a Kind-1 note tagged `gupt-bot` on start and every 3 hours. Kind-4 DMs stay
100
+ encrypted and untagged. `name` and `about` are required; `owner` (64-char pubkey) and `website`
101
+ (http/https) are optional. `publicBot: true` is invalid.
102
+
103
+ ```js
104
+ const bot = new GuptBot({
105
+ secretHex: process.env.GUPT_BOT_KEY,
106
+ relays: ["wss://relay.damus.io", "wss://nos.lol"],
107
+ publicBot: {
108
+ name: "Echo",
109
+ about: "Repeats your message back.",
110
+ owner: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
111
+ website: "https://example.com",
112
+ },
113
+ });
114
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gupt-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "Node.js SDK for building end-to-end encrypted GUPT bots",
5
5
  "homepage": "https://github.com/besoeasy/gupt/tree/main/sdk#readme",
6
6
  "bugs": "https://github.com/besoeasy/gupt/issues",
package/src/index.js CHANGED
@@ -10,8 +10,10 @@ import { SendQueue } from "./queue.js";
10
10
  import { normalizeRelayUrl, RelayBook } from "./relayBook.js";
11
11
  import {
12
12
  buildDirectMessageEvent,
13
+ buildPublicBotEvent,
13
14
  getPublicKey,
14
15
  normalizePubkey,
16
+ normalizePublicBotProfile,
15
17
  normalizeSecretHex,
16
18
  } from "./wire.js";
17
19
 
@@ -21,6 +23,7 @@ export const DEFAULT_SENDER_COOLDOWN_MS = 1_000;
21
23
  export const DEFAULT_REPLY_COOLDOWN_MS = 1_000;
22
24
  export const DEFAULT_MAX_REPLIES_PER_MINUTE = 20;
23
25
  export const DEFAULT_MAX_HANDLER_BACKLOG = 100;
26
+ export const PUBLIC_BOT_ANNOUNCE_MS = 3 * 60 * 60 * 1000;
24
27
 
25
28
  function normalizeOriginlessUrl(value, allowPrivate) {
26
29
  try {
@@ -54,6 +57,7 @@ export class GuptBot {
54
57
  maxHandlerBacklog = DEFAULT_MAX_HANDLER_BACKLOG,
55
58
  acceptBotMessages = false,
56
59
  allowPrivateRelays = false,
60
+ publicBot = null,
57
61
  WebSocketImpl = globalThis.WebSocket,
58
62
  onDrop = null,
59
63
  logger = console,
@@ -97,6 +101,8 @@ export class GuptBot {
97
101
  this.maxRepliesPerMinute = Math.max(1, Number(maxRepliesPerMinute) || 1);
98
102
  this.maxHandlerBacklog = Math.max(1, Number(maxHandlerBacklog) || 1);
99
103
  this.acceptBotMessages = Boolean(acceptBotMessages);
104
+ this.publicBot = normalizePublicBotProfile(publicBot);
105
+ this.publicBotAnnounceTimer = null;
100
106
  this.logger = logger;
101
107
  this.mediaOptions = {
102
108
  fetchImpl: mediaOptions.fetchImpl || globalThis.fetch,
@@ -164,6 +170,13 @@ export class GuptBot {
164
170
  limit: 200,
165
171
  }));
166
172
  this.status = "running";
173
+ if (this.publicBot) {
174
+ await this.announcePublicBot();
175
+ this.publicBotAnnounceTimer = setInterval(() => {
176
+ void this.announcePublicBot();
177
+ }, PUBLIC_BOT_ANNOUNCE_MS);
178
+ this.publicBotAnnounceTimer.unref?.();
179
+ }
167
180
  return this;
168
181
  } catch (error) {
169
182
  this.status = "idle";
@@ -174,6 +187,10 @@ export class GuptBot {
174
187
  stop() {
175
188
  if (this.status === "stopped") return;
176
189
  this.status = "stopped";
190
+ if (this.publicBotAnnounceTimer) {
191
+ clearInterval(this.publicBotAnnounceTimer);
192
+ this.publicBotAnnounceTimer = null;
193
+ }
177
194
  this.pool.stop();
178
195
  this.queue.stop();
179
196
  this.ingestion.close();
@@ -333,6 +350,24 @@ export class GuptBot {
333
350
  });
334
351
  }
335
352
 
353
+ async announcePublicBot() {
354
+ if (this.status !== "running" || !this.publicBot) return;
355
+ try {
356
+ const event = buildPublicBotEvent({
357
+ secretHex: this.secretHex,
358
+ ...this.publicBot,
359
+ relays: this.relays,
360
+ });
361
+ await this.queue.enqueue({
362
+ id: event.id,
363
+ lane: "__public-bot__",
364
+ fn: () => this.pool.publish(this.relays, event),
365
+ });
366
+ } catch (error) {
367
+ this.emitError(error, { kind: "public-bot" });
368
+ }
369
+ }
370
+
336
371
  sendPayload(peer, payload, ingressRelay) {
337
372
  const event = buildDirectMessageEvent({
338
373
  secretHex: this.secretHex,
@@ -375,6 +410,7 @@ export class GuptBot {
375
410
  pubkey: this.pubkey,
376
411
  relays: [...this.relays],
377
412
  originlessServers: [...this.originlessServers],
413
+ publicBot: this.publicBot ? { ...this.publicBot } : null,
378
414
  relayBook: this.relayBook.snapshot(),
379
415
  relayPool: this.pool.snapshot(),
380
416
  sendQueue: this.queue.snapshot(),
package/src/wire.js CHANGED
@@ -9,6 +9,12 @@ secp.hashes.hmacSha256 = (key, ...messages) => hmac(sha256, key, secp.etc.concat
9
9
 
10
10
  export const DM_KIND = 4;
11
11
  export const DM_TAG = "gupt-dm";
12
+ export const BOT_KIND = 1;
13
+ export const BOT_TAG = "gupt-bot";
14
+ export const PUBLIC_BOT_CONTENT = "GUPT bot : https://github.com/besoeasy/gupt";
15
+ export const PUBLIC_BOT_NAME_MAX = 80;
16
+ export const PUBLIC_BOT_ABOUT_MAX = 280;
17
+ export const PUBLIC_BOT_MAX_RELAY_TAGS = 8;
12
18
  export const RETENTION_DAYS = 100;
13
19
  export const MAX_EVENT_BYTES = 128 * 1024;
14
20
  export const MAX_CONTENT_BYTES = 96 * 1024;
@@ -167,6 +173,80 @@ export function decryptDm(secretHex, pubkey, blob) {
167
173
  return textDecoder.decode(plaintext);
168
174
  }
169
175
 
176
+ export function normalizePublicBotProfile(value) {
177
+ if (value == null || value === false) return null;
178
+ if (value === true || typeof value !== "object" || Array.isArray(value)) {
179
+ throw new TypeError("publicBot must be { name, about }");
180
+ }
181
+ const name = String(value.name || "").trim();
182
+ const about = String(value.about || "").trim();
183
+ if (!name) throw new TypeError("publicBot.name is required");
184
+ if (!about) throw new TypeError("publicBot.about is required");
185
+ if (name.length > PUBLIC_BOT_NAME_MAX) throw new TypeError("publicBot.name is too long");
186
+ if (about.length > PUBLIC_BOT_ABOUT_MAX) throw new TypeError("publicBot.about is too long");
187
+ const profile = { name, about };
188
+ const ownerRaw = String(value.owner || "").trim();
189
+ if (ownerRaw) profile.owner = normalizePubkey(ownerRaw);
190
+ const websiteRaw = String(value.website || "").trim();
191
+ if (websiteRaw) profile.website = normalizePublicBotWebsite(websiteRaw);
192
+ return profile;
193
+ }
194
+
195
+ function normalizePublicBotWebsite(value) {
196
+ let parsed;
197
+ try {
198
+ parsed = new URL(value);
199
+ } catch {
200
+ try {
201
+ parsed = new URL(`https://${value}`);
202
+ } catch {
203
+ throw new TypeError("publicBot.website must be an http(s) URL");
204
+ }
205
+ }
206
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
207
+ throw new TypeError("publicBot.website must be an http(s) URL");
208
+ }
209
+ if (parsed.username || parsed.password) {
210
+ throw new TypeError("publicBot.website must be an http(s) URL");
211
+ }
212
+ parsed.hash = "";
213
+ return parsed.toString();
214
+ }
215
+
216
+ export function buildPublicBotEvent({
217
+ secretHex,
218
+ name,
219
+ about,
220
+ owner,
221
+ website,
222
+ relays = [],
223
+ now = Date.now(),
224
+ }) {
225
+ const profile = normalizePublicBotProfile({ name, about, owner, website });
226
+ const tags = [
227
+ ["t", BOT_TAG],
228
+ [BOT_TAG, JSON.stringify(profile)],
229
+ ["expiration", String(getExpiryTimestampSec(now))],
230
+ ];
231
+ const seen = new Set();
232
+ for (const relay of relays) {
233
+ if (tags.length >= 3 + PUBLIC_BOT_MAX_RELAY_TAGS) break;
234
+ const url = String(relay || "").trim();
235
+ if (!url.startsWith("wss://") || seen.has(url)) continue;
236
+ seen.add(url);
237
+ tags.push(["r", url]);
238
+ }
239
+ return finalizeEvent(
240
+ {
241
+ kind: BOT_KIND,
242
+ created_at: Math.floor(now / 1000),
243
+ tags,
244
+ content: PUBLIC_BOT_CONTENT,
245
+ },
246
+ secretHex,
247
+ );
248
+ }
249
+
170
250
  export function buildDirectMessageEvent({
171
251
  secretHex,
172
252
  recipientPubkey,