From eaaa7edc78c5e299f4758e5420d00b4987243efa Mon Sep 17 00:00:00 2001 From: kieran Date: Sun, 20 Oct 2024 22:11:34 +0100 Subject: [PATCH] feat: upgrade bot --- packages/bot/README.md | 30 ++++++ packages/bot/example/simple.ts | 20 ++++ packages/bot/package.json | 4 +- packages/bot/src/index.ts | 178 +++++++++++++++++++++++---------- packages/system/package.json | 2 +- packages/system/src/signer.ts | 9 ++ yarn.lock | 43 +++++++- 7 files changed, 229 insertions(+), 57 deletions(-) create mode 100644 packages/bot/README.md create mode 100644 packages/bot/example/simple.ts diff --git a/packages/bot/README.md b/packages/bot/README.md new file mode 100644 index 00000000..a78125ec --- /dev/null +++ b/packages/bot/README.md @@ -0,0 +1,30 @@ +# @snort/bot + +Simple live stream event chat bot (NIP-53) + +## Example + +```typescript +import { parseNostrLink } from "@snort/system"; +import { SnortBot } from "../src/index"; + +// listen to chat events on every NoGood live stream +const noGoodLink = parseNostrLink("npub12hcytyr8fumy3axde8wgeced523gyp6v6zczqktwuqeaztfc2xzsz3rdp4"); + +// Run a simple bot +SnortBot.simple("example") + .link(noGoodLink) + .relay("wss://relay.damus.io") + .relay("wss://nos.lol") + .relay("wss://relay.nostr.band") + .profile({ + name: "PingBot", + picture: "https://nostr.download/572f5ff8286e8c719196f904fed24aef14586ec8181c14b09efa726682ef48ef", + lud16: "kieran@zap.stream", + about: "An example bot", + }) + .command("!ping", h => { + h.reply("PONG!"); + }) + .run(); +``` diff --git a/packages/bot/example/simple.ts b/packages/bot/example/simple.ts new file mode 100644 index 00000000..123d6615 --- /dev/null +++ b/packages/bot/example/simple.ts @@ -0,0 +1,20 @@ +import { parseNostrLink } from "@snort/system"; +import { SnortBot } from "../src/index"; + +const noGoodLink = parseNostrLink("npub12hcytyr8fumy3axde8wgeced523gyp6v6zczqktwuqeaztfc2xzsz3rdp4"); + +SnortBot.simple("example") + .link(noGoodLink) + .relay("wss://relay.damus.io") + .relay("wss://nos.lol") + .relay("wss://relay.nostr.band") + .profile({ + name: "PingBot", + picture: "https://nostr.download/572f5ff8286e8c719196f904fed24aef14586ec8181c14b09efa726682ef48ef", + lud16: "kieran@zap.stream", + about: "An example bot", + }) + .command("!ping", h => { + h.reply("PONG!"); + }) + .run(); diff --git a/packages/bot/package.json b/packages/bot/package.json index c15aedce..9fddc6eb 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,6 +1,6 @@ { "name": "@snort/bot", - "version": "1.1.1", + "version": "1.2.0", "description": "Simple bot framework", "type": "module", "module": "src/index.ts", @@ -13,7 +13,7 @@ "build": "rm -rf dist && tsc" }, "dependencies": { - "@snort/system": "^1.5.1", + "@snort/system": "^1.5.2", "eventemitter3": "^5.0.1" }, "devDependencies": { diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts index b0bbf774..82d6e276 100644 --- a/packages/bot/src/index.ts +++ b/packages/bot/src/index.ts @@ -6,6 +6,10 @@ import { type SystemInterface, NostrPrefix, EventKind, + TaggedNostrEvent, + NostrSystem, + PrivateKeySigner, + UserMetadata, } from "@snort/system"; import EventEmitter from "eventemitter3"; @@ -15,11 +19,26 @@ export interface BotEvents { } export interface BotMessage { + /** + * Event which this message belongs to + */ link: NostrLink; + /** + * Pubkey of the message author + */ from: string; + /** + * Message content string + */ message: string; + /** + * Original message event + */ event: NostrEvent; - reply: (msg: string) => void; + /** + * Reply handler for this message + */ + reply: (msg: string) => Promise; } export type CommandHandler = (msg: BotMessage) => void; @@ -35,48 +54,15 @@ export class SnortBot extends EventEmitter { readonly publisher: EventPublisher, ) { super(); - system.pool.on("event", (addr, sub, e) => { - this.emit("event", e); - if (e.kind === 30311) { - const links = [e, ...this.activeStreams].map(v => NostrLink.fromEvent(v)); - const linkStr = links.map(e => e.encode()); - if (linkStr.every(a => this.#activeStreamSub.has(a))) { - return; - } - const rb = new RequestBuilder("stream-chat"); - rb.withOptions({ replaceable: true, leaveOpen: true }); - rb.withFilter() - .kinds([1311 as EventKind]) - .replyToLink(links) - .since(Math.floor(new Date().getTime() / 1000)); - this.system.Query(rb); - console.log("Looking for chat messages from: ", linkStr); - this.#activeStreamSub = new Set(linkStr); - } else if (e.kind === 1311) { - // skip my own messages - if (e.pubkey === this.publisher.pubKey) { - return; - } - // skip already seen chat messages - if (this.#seen.has(e.id)) { - return; - } - this.#seen.add(e.id); - const streamTag = e.tags.find(a => a[0] === "a" && a[1].startsWith("30311:")); - if (streamTag) { - const link = NostrLink.fromTag(streamTag); - this.emit("message", { - link, - from: e.pubkey, - message: e.content, - event: e, - reply: (msg: string) => { - this.#sendReplyTo(link, msg); - }, - }); - } - } - }); + } + + /** + * Create a new simple bot + */ + static simple(name: string) { + const system = new NostrSystem({}); + const signer = PrivateKeySigner.random(); + return new SnortBot(name, system, new EventPublisher(signer, signer.getPubKey())); } get activeStreams() { @@ -94,6 +80,22 @@ export class SnortBot extends EventEmitter { return this; } + /** + * Add a relay for communication + */ + relay(r: string) { + this.system.ConnectToRelay(r, { read: true, write: true }); + return this; + } + + /** + * Create a profile + */ + profile(p: UserMetadata) { + this.publisher.metadata(p).then(ev => this.system.BroadcastEvent(ev)); + return this; + } + /** * Simple command handler */ @@ -106,6 +108,9 @@ export class SnortBot extends EventEmitter { return this; } + /** + * Start the bot + */ run() { const req = new RequestBuilder("streams"); req.withOptions({ leaveOpen: true }); @@ -113,21 +118,90 @@ export class SnortBot extends EventEmitter { if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) { req.withFilter().authors([link.id]).kinds([30311]); req.withFilter().tag("p", [link.id]).kinds([30311]); - } else if (link.type === NostrPrefix.Address) { - const f = req.withFilter().tag("d", [link.id]); - if (link.author) { - f.authors([link.author]); - } - if (link.kind) { - f.kinds([link.kind]); - } + } else { + req.withFilter().link(link); } } - this.system.Query(req); + // requst streams by input links + const q = this.system.Query(req); + q.on("event", evs => { + for (const e of evs) { + this.#handleEvent(e); + } + }); + + // setup chat query, its empty for now + const rbChat = new RequestBuilder("stream-chat"); + rbChat.withOptions({ replaceable: true, leaveOpen: true }); + const qChat = this.system.Query(rbChat); + qChat.on("event", evs => { + for (const e of evs) { + this.#handleEvent(e); + } + }); + return this; } + /** + * Send a message to all active streams + */ + async notify(msg: string) { + for (const stream of this.activeStreams) { + const ev = await this.publisher.reply(stream, msg, eb => { + return eb.kind(1311 as EventKind); + }); + await this.system.BroadcastEvent(ev); + } + } + + #handleEvent(e: TaggedNostrEvent) { + this.emit("event", e); + if (e.kind === 30311) { + this.#checkActiveStreams(e); + } else if (e.kind === 1311) { + // skip my own messages + if (e.pubkey === this.publisher.pubKey) { + return; + } + // skip already seen chat messages + if (this.#seen.has(e.id)) { + return; + } + this.#seen.add(e.id); + const streamTag = e.tags.find(a => a[0] === "a" && a[1].startsWith("30311:")); + if (streamTag) { + const link = NostrLink.fromTag(streamTag); + this.emit("message", { + link, + from: e.pubkey, + message: e.content, + event: e, + reply: (msg: string) => this.#sendReplyTo(link, msg), + }); + } + } + } + + #checkActiveStreams(e: TaggedNostrEvent) { + const links = [e, ...this.activeStreams].map(v => NostrLink.fromEvent(v)); + const linkStr = [...new Set(links.map(e => e.encode()))]; + if (linkStr.every(a => this.#activeStreamSub.has(a))) { + return; + } + + const rb = new RequestBuilder("stream-chat"); + rb.withFilter() + .kinds([1311 as EventKind]) + .replyToLink(links) + .since(Math.floor(new Date().getTime() / 1000)); + this.system.Query(rb); + + console.log("Looking for chat messages from: ", linkStr); + this.#activeStreamSub = new Set(linkStr); + } + async #sendReplyTo(link: NostrLink, msg: string) { const ev = await this.publisher.generic(eb => { eb.kind(1311 as EventKind) diff --git a/packages/system/package.json b/packages/system/package.json index 3701b7d9..593a244a 100644 --- a/packages/system/package.json +++ b/packages/system/package.json @@ -1,6 +1,6 @@ { "name": "@snort/system", - "version": "1.5.1", + "version": "1.5.2", "description": "Snort nostr system package", "type": "module", "main": "dist/index.js", diff --git a/packages/system/src/signer.ts b/packages/system/src/signer.ts index f6b1045c..8830893f 100644 --- a/packages/system/src/signer.ts +++ b/packages/system/src/signer.ts @@ -4,6 +4,7 @@ import { EventExt } from "./event-ext"; import { Nip4WebCryptoEncryptor } from "./impl/nip4"; import { Nip44Encryptor } from "./impl/nip44"; import { NostrEvent, NotSignedNostrEvent } from "./nostr"; +import { randomBytes } from "@noble/hashes/utils"; export type SignerSupports = "nip04" | "nip44" | string; @@ -31,6 +32,14 @@ export class PrivateKeySigner implements EventSigner { this.#publicKey = getPublicKey(this.#privateKey); } + /** + * Generate a new private key + */ + static random() { + const k = randomBytes(32); + return new PrivateKeySigner(k); + } + get supports(): string[] { return ["nip04", "nip44"]; } diff --git a/yarn.lock b/yarn.lock index 873e6028..8dcc7609 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4796,9 +4796,10 @@ __metadata: version: 0.0.0-use.local resolution: "@snort/bot@workspace:packages/bot" dependencies: - "@snort/system": "npm:^1.5.1" + "@snort/system": "npm:^1.5.2" "@types/debug": "npm:^4.1.8" eventemitter3: "npm:^5.0.1" + ts-node: "npm:^10.9.2" typescript: "npm:^5.2.2" languageName: unknown linkType: soft @@ -4858,7 +4859,7 @@ __metadata: languageName: unknown linkType: soft -"@snort/system@npm:^1.0.21, @snort/system@npm:^1.2.11, @snort/system@npm:^1.5.1, @snort/system@workspace:*, @snort/system@workspace:packages/system": +"@snort/system@npm:^1.0.21, @snort/system@npm:^1.2.11, @snort/system@npm:^1.5.1, @snort/system@npm:^1.5.2, @snort/system@workspace:*, @snort/system@workspace:packages/system": version: 0.0.0-use.local resolution: "@snort/system@workspace:packages/system" dependencies: @@ -14534,6 +14535,44 @@ __metadata: languageName: node linkType: hard +"ts-node@npm:^10.9.2": + version: 10.9.2 + resolution: "ts-node@npm:10.9.2" + dependencies: + "@cspotcode/source-map-support": "npm:^0.8.0" + "@tsconfig/node10": "npm:^1.0.7" + "@tsconfig/node12": "npm:^1.0.7" + "@tsconfig/node14": "npm:^1.0.0" + "@tsconfig/node16": "npm:^1.0.2" + acorn: "npm:^8.4.1" + acorn-walk: "npm:^8.1.1" + arg: "npm:^4.1.0" + create-require: "npm:^1.1.0" + diff: "npm:^4.0.1" + make-error: "npm:^1.1.1" + v8-compile-cache-lib: "npm:^3.0.1" + yn: "npm:3.1.1" + peerDependencies: + "@swc/core": ">=1.2.50" + "@swc/wasm": ">=1.2.50" + "@types/node": "*" + typescript: ">=2.7" + peerDependenciesMeta: + "@swc/core": + optional: true + "@swc/wasm": + optional: true + bin: + ts-node: dist/bin.js + ts-node-cwd: dist/bin-cwd.js + ts-node-esm: dist/bin-esm.js + ts-node-script: dist/bin-script.js + ts-node-transpile-only: dist/bin-transpile.js + ts-script: dist/bin-script-deprecated.js + checksum: 10/a91a15b3c9f76ac462f006fa88b6bfa528130dcfb849dd7ef7f9d640832ab681e235b8a2bc58ecde42f72851cc1d5d4e22c901b0c11aa51001ea1d395074b794 + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.15.0": version: 3.15.0 resolution: "tsconfig-paths@npm:3.15.0"