gupt-sdk 0.2.1 → 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 +26 -3
- package/package.json +1 -1
- package/src/index.js +36 -0
- package/src/wire.js +80 -0
package/README.md
CHANGED
|
@@ -52,15 +52,18 @@ await ctx.replyFile("./report.pdf", {
|
|
|
52
52
|
## Encrypted ntfy.sh-style notifications
|
|
53
53
|
|
|
54
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
|
-
|
|
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.
|
|
57
60
|
|
|
58
61
|
```js
|
|
59
62
|
import { GuptBot } from "gupt-sdk";
|
|
60
63
|
|
|
61
64
|
const bot = new GuptBot({
|
|
62
65
|
secretHex: process.env.GUPT_BOT_KEY,
|
|
63
|
-
relays: ["wss://relay
|
|
66
|
+
relays: ["wss://relay.damus.io", "wss://nos.lol"],
|
|
64
67
|
});
|
|
65
68
|
|
|
66
69
|
await bot.start();
|
|
@@ -89,3 +92,23 @@ By default, handlers accept all human senders with a one-second per-sender coold
|
|
|
89
92
|
carry `bot: true` and are ignored by other SDK bots unless `acceptBotMessages` is enabled.
|
|
90
93
|
|
|
91
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
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,
|