add more social tools

This commit is contained in:
hzrd149
2025-03-28 10:53:04 +00:00
parent 27b412abcb
commit 963b4f6fdf
10 changed files with 216 additions and 69 deletions

View File

@@ -13,3 +13,22 @@ export function normalizeToHexPubkey(hex: string, require = false): string | nul
else return null;
}
}
export function normalizeToEventId(str: string, require?: boolean): string | null;
export function normalizeToEventId(str: string, require: true): string;
export function normalizeToEventId(str: string, require = false): string | null {
try {
const decode = nip19.decode(str);
switch (decode.type) {
case "note":
return decode.data;
case "nevent":
return decode.data.id;
default:
throw new Error(`Cant get event id from ${decode.type}`);
}
} catch (error) {
if (require) throw error;
else return null;
}
}

View File

@@ -1,7 +1,8 @@
import { from, tap } from "rxjs";
import { EventPacket } from "rx-nostr";
import { from, merge, single, tap } from "rxjs";
import { Filter } from "nostr-tools";
import { isFromCache, markFromCache } from "applesauce-core/helpers";
import { ReplaceableLoader, RequestLoader } from "applesauce-loaders/loaders";
import { ReplaceableLoader, RequestLoader, SingleEventLoader } from "applesauce-loaders/loaders";
import { LOOKUP_RELAYS } from "../env.js";
import { rxNostr } from "./rx-nostr.js";
@@ -13,15 +14,16 @@ function cacheRequest(filters: Filter[]) {
return from(events).pipe(tap(markFromCache));
}
export const replaceableLoader = new ReplaceableLoader(rxNostr, { cacheRequest, lookupRelays: LOOKUP_RELAYS });
replaceableLoader.subscribe((packet) => {
// add it to event store
function handleEvent(packet: EventPacket) {
const event = eventStore.add(packet.event, packet.from);
// save it to the cache if its new
if (!isFromCache(event)) eventCache.addEvent(event);
});
}
export const replaceableLoader = new ReplaceableLoader(rxNostr, { cacheRequest, lookupRelays: LOOKUP_RELAYS });
replaceableLoader.subscribe(handleEvent);
export const singleEventLoader = new SingleEventLoader(rxNostr, { cacheRequest });
singleEventLoader.subscribe(handleEvent);
export const requestLoader = new RequestLoader(queryStore);
requestLoader.replaceableLoader = replaceableLoader;

View File

@@ -1,14 +1,12 @@
import database from "../../database.js";
import mcpServer from "../server.js";
mcpServer.tool("total_events", "Get the total number of events in the database", {}, async () => {
const result = database.db.prepare<[], { events: number }>(`SELECT COUNT(*) AS events FROM events`).get();
return { content: [{ type: "text", text: `Total events: ${result?.events ?? 0}` }] };
});
mcpServer.tool("get_database_stats", "Get the total number of events in the database", {}, async () => {
const { events } = database.db.prepare<[], { events: number }>(`SELECT COUNT(*) AS events FROM events`).get() || {};
const { users } =
database.db.prepare<[], { users: number }>(`SELECT COUNT(*) AS users FROM events GROUP BY pubkey`).get() || {};
mcpServer.tool("total_users", "Get the total number of users in the database", {}, async () => {
const result = database.db
.prepare<[], { users: number }>(`SELECT COUNT(*) AS users FROM events GROUP BY pubkey`)
.get();
return { content: [{ type: "text", text: `Total users: ${result?.users ?? 0}` }] };
return {
content: [{ type: "text", text: [`Total events: ${events ?? 0}`, `Total users: ${users ?? 0}`].join("\n") }],
};
});

View File

@@ -1,22 +0,0 @@
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { NoteBlueprint } from "applesauce-factory/blueprints";
import { EventTemplate } from "nostr-tools";
import { z } from "zod";
import mcpServer from "../server.js";
import { ownerFactory } from "../../owner.js";
async function returnUnsigned(draft: EventTemplate | Promise<EventTemplate>): Promise<CallToolResult> {
return {
content: [{ type: "text", text: JSON.stringify(await ownerFactory.stamp(await draft)) }],
};
}
mcpServer.tool(
"short_text_note_draft",
"Create a short text note draft event",
{
content: z.string(),
},
async ({ content }) => returnUnsigned(ownerFactory.create(NoteBlueprint, content)),
);

View File

@@ -1,6 +1,8 @@
import "./actions.js";
import "./config.js";
import "./connection.js";
import "./database.js";
import "./events.js";
import "./lists.js";
import "./social.js";
import "./signer.js";
import "./profile.js";

View File

@@ -1,5 +1,5 @@
import z from "zod";
import { FollowUser, PinNote, UnfollowUser, UnpinNote, UpdateProfile } from "applesauce-actions/actions";
import { FollowUser, PinNote, UnfollowUser, UnpinNote } from "applesauce-actions/actions";
import mcpServer from "../server.js";
import { ownerActions, ownerPublish } from "../../owner.js";
@@ -73,16 +73,3 @@ mcpServer.tool(
return { content: [{ type: "text", text: "Unpinned note" }] };
},
);
mcpServer.tool(
"set_profile_field",
"Sets a field in the owners profile",
{
field: z.enum(["name", "about", "picture", "nip05", "website"]).describe("The field to set"),
value: z.string().describe("The value to set the field to"),
},
async ({ field, value }) => {
await ownerActions.exec(UpdateProfile, { [field]: value }).forEach(ownerPublish);
return { content: [{ type: "text", text: "Set profile field" }] };
},
);

View File

@@ -0,0 +1,22 @@
import { UpdateProfile } from "applesauce-actions/actions";
import { z } from "zod";
import { ownerPublish } from "../../owner.js";
import mcpServer from "../server.js";
import { ownerActions } from "../../owner.js";
mcpServer.tool(
"update_profile",
"Updates the owners profile",
{
name: z.string().optional().describe("The name of the owner"),
about: z.string().optional().describe("The about text of the owner"),
picture: z.string().url().optional().describe("The picture of the owner"),
nip05: z.string().email().optional().describe("The nip05 of the owner"),
website: z.string().url().optional().describe("The website of the owner"),
},
async (content) => {
await ownerActions.exec(UpdateProfile, content).forEach(ownerPublish);
return { content: [{ type: "text", text: "Updated profile" }] };
},
);

View File

@@ -0,0 +1,148 @@
import { OkPacketAgainstEvent } from "rx-nostr";
import { CommentBlueprint, NoteBlueprint, NoteReplyBlueprint, ReactionBlueprint } from "applesauce-factory/blueprints";
import { nip19, NostrEvent } from "nostr-tools";
import { lastValueFrom } from "rxjs";
import { isHex } from "applesauce-core/helpers";
import { simpleTimeout } from "applesauce-core/observable";
import { z } from "zod";
import mcpServer from "../server.js";
import { ownerFactory, ownerPublish } from "../../owner.js";
import eventCache from "../../event-cache.js";
import { requestLoader, singleEventLoader } from "../../loaders.js";
import { eventStore } from "../../stores.js";
function publishFeedback(results: OkPacketAgainstEvent[]) {
return [
"Relays:",
results.map((r) => `${r.from} ${r.ok ? "success" : "failed"} ${r.notice}`).join("\n"),
"Total: " + results.length,
].join("\n");
}
async function resolveEventInput(input: string | { id: string }): Promise<NostrEvent | undefined> {
if (typeof input === "string") {
// get the event based on the id in the string
if (isHex(input)) return eventCache.getEventsForFilters([{ ids: [input] }])[0];
else if (input.startsWith("nevent")) {
const decode = nip19.decode(input);
if (decode.type !== "nevent") throw new Error("Invalid event id");
let event = eventCache.getEventsForFilters([{ ids: [decode.data.id] }])[0];
if (event) return event;
// try to load the event from the loader
singleEventLoader.next(decode.data);
return await lastValueFrom(
eventStore.event(decode.data.id).pipe(simpleTimeout<NostrEvent | undefined>(10_000, "Failed to find event")),
);
} else if (input.startsWith("naddr")) {
const decode = nip19.decode(input);
if (decode.type !== "naddr") throw new Error("Invalid event address");
let event = eventCache.getEventsForFilters([
{ kinds: [decode.data.kind], authors: [decode.data.pubkey], "#d": [decode.data.identifier] },
])[0];
if (event) return event;
// try to load the event from the replaceable loader
return await requestLoader.replaceable(decode.data);
}
} else {
// get the event based on the id in the object
return eventCache.getEventsForFilters([{ ids: [input.id] }])[0];
}
}
mcpServer.tool(
"publish_short_text_note",
"Publishes a kind 1 short text note",
{
content: z.string(),
},
async ({ content }) => {
const draft = await ownerFactory.create(NoteBlueprint, content);
const signed = await ownerFactory.sign(draft);
const results = await ownerPublish(signed);
return {
content: [
{ type: "text", text: `Published note event ${signed.id}` },
{ type: "text", text: publishFeedback(results) },
],
};
},
);
mcpServer.tool(
"reply_to_text_note",
"Publishes a reply to a text note",
{
event: z.union([z.string(), z.object({ id: z.string() })]).describe("The event to reply to"),
content: z.string(),
},
async ({ content, event: eventId }) => {
let event = await resolveEventInput(eventId);
if (!event) throw new Error("Event not found");
const reply = await ownerFactory.create(NoteReplyBlueprint, event, content);
const signed = await ownerFactory.sign(reply);
const results = await ownerPublish(signed);
return {
content: [
{ type: "text", text: `Published reply event ${signed.id}` },
{ type: "text", text: publishFeedback(results) },
],
};
},
);
mcpServer.tool(
"add_reaction_to_event",
"Publishes a new reaction to an event",
{
event: z.union([z.string(), z.object({ id: z.string() })]).describe("The event to react to"),
emoji: z.union([z.literal("+"), z.literal("-"), z.string().emoji()]).describe("The emoji to react with"),
},
async ({ event: eventId, emoji }) => {
let event = await resolveEventInput(eventId);
if (!event) throw new Error("Event not found");
const reaction = await ownerFactory.create(ReactionBlueprint, event, emoji);
const signed = await ownerFactory.sign(reaction);
const results = await ownerPublish(signed);
return {
content: [
{ type: "text", text: `Published reaction event ${signed.id}` },
{ type: "text", text: publishFeedback(results) },
],
};
},
);
mcpServer.tool(
"comment_on_event",
"Publishes a comment on an event or replies to an existing comment event",
{
event: z.union([z.string(), z.object({ id: z.string() })]).describe("The event to comment on"),
content: z.string(),
},
async ({ content, event: eventId }) => {
let event = await resolveEventInput(eventId);
if (!event) throw new Error("Event not found");
const comment = await ownerFactory.create(CommentBlueprint, event, content);
const signed = await ownerFactory.sign(comment);
const results = await ownerPublish(signed);
return {
content: [
{ type: "text", text: `Published comment event ${signed.id}` },
{ type: "text", text: publishFeedback(results) },
],
};
},
);

View File

@@ -101,13 +101,8 @@ export async function ownerPublish(event: NostrEvent, relays?: string[]) {
eventCache.addEvent(event);
// publish event to owners outboxes
if (bakeryConfig.data.owner) {
try {
relays = relays || (await requestLoader.mailboxes({ pubkey: bakeryConfig.data.owner })).outboxes;
return await lastValueFrom(rxNostr.send(event, { on: { relays } }).pipe(toArray()));
} catch (error) {
// Failed to publish to outboxes, ignore error for now
// TODO: this should retried at some point
}
}
if (!relays && bakeryConfig.data.owner)
relays = (await requestLoader.mailboxes({ pubkey: bakeryConfig.data.owner })).outboxes;
return await lastValueFrom(rxNostr.send(event, { on: { relays } }).pipe(toArray()));
}

View File

@@ -210,9 +210,7 @@ export class SQLiteEventStore extends EventEmitter<EventMap> {
// before inserting it into the database
// get event d value so it can be indexed
const d = kinds.isParameterizedReplaceableKind(event.kind)
? event.tags.find((t) => t[0] === "d" && t[1])?.[1]
: undefined;
const d = kinds.isAddressableKind(event.kind) ? event.tags.find((t) => t[0] === "d" && t[1])?.[1] : undefined;
const insert = this.db
.prepare(
@@ -278,8 +276,6 @@ export class SQLiteEventStore extends EventEmitter<EventMap> {
.slice(1)
.map((item) => item.id);
if (!removeIds.includes(event.id)) this.log("Removed", removeIds.length, "old replaceable events");
this.removeEvents(removeIds);
// If the event that was just inserted was one of