mirror of
https://github.com/aljazceru/bakery.git
synced 2025-12-17 04:35:13 +01:00
add more social tools
This commit is contained in:
@@ -13,3 +13,22 @@ export function normalizeToHexPubkey(hex: string, require = false): string | nul
|
|||||||
else return null;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { Filter } from "nostr-tools";
|
||||||
import { isFromCache, markFromCache } from "applesauce-core/helpers";
|
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 { LOOKUP_RELAYS } from "../env.js";
|
||||||
import { rxNostr } from "./rx-nostr.js";
|
import { rxNostr } from "./rx-nostr.js";
|
||||||
@@ -13,15 +14,16 @@ function cacheRequest(filters: Filter[]) {
|
|||||||
return from(events).pipe(tap(markFromCache));
|
return from(events).pipe(tap(markFromCache));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const replaceableLoader = new ReplaceableLoader(rxNostr, { cacheRequest, lookupRelays: LOOKUP_RELAYS });
|
function handleEvent(packet: EventPacket) {
|
||||||
|
|
||||||
replaceableLoader.subscribe((packet) => {
|
|
||||||
// add it to event store
|
|
||||||
const event = eventStore.add(packet.event, packet.from);
|
const event = eventStore.add(packet.event, packet.from);
|
||||||
|
|
||||||
// save it to the cache if its new
|
|
||||||
if (!isFromCache(event)) eventCache.addEvent(event);
|
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);
|
export const requestLoader = new RequestLoader(queryStore);
|
||||||
requestLoader.replaceableLoader = replaceableLoader;
|
requestLoader.replaceableLoader = replaceableLoader;
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import database from "../../database.js";
|
import database from "../../database.js";
|
||||||
import mcpServer from "../server.js";
|
import mcpServer from "../server.js";
|
||||||
|
|
||||||
mcpServer.tool("total_events", "Get the total number of events in the database", {}, async () => {
|
mcpServer.tool("get_database_stats", "Get the total number of events in the database", {}, async () => {
|
||||||
const result = database.db.prepare<[], { events: number }>(`SELECT COUNT(*) AS events FROM events`).get();
|
const { events } = database.db.prepare<[], { events: number }>(`SELECT COUNT(*) AS events FROM events`).get() || {};
|
||||||
return { content: [{ type: "text", text: `Total events: ${result?.events ?? 0}` }] };
|
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 () => {
|
return {
|
||||||
const result = database.db
|
content: [{ type: "text", text: [`Total events: ${events ?? 0}`, `Total users: ${users ?? 0}`].join("\n") }],
|
||||||
.prepare<[], { users: number }>(`SELECT COUNT(*) AS users FROM events GROUP BY pubkey`)
|
};
|
||||||
.get();
|
|
||||||
return { content: [{ type: "text", text: `Total users: ${result?.users ?? 0}` }] };
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)),
|
|
||||||
);
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import "./actions.js";
|
|
||||||
import "./config.js";
|
import "./config.js";
|
||||||
import "./connection.js";
|
import "./connection.js";
|
||||||
import "./database.js";
|
import "./database.js";
|
||||||
import "./events.js";
|
import "./events.js";
|
||||||
|
import "./lists.js";
|
||||||
|
import "./social.js";
|
||||||
import "./signer.js";
|
import "./signer.js";
|
||||||
|
import "./profile.js";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import z from "zod";
|
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 mcpServer from "../server.js";
|
||||||
import { ownerActions, ownerPublish } from "../../owner.js";
|
import { ownerActions, ownerPublish } from "../../owner.js";
|
||||||
@@ -73,16 +73,3 @@ mcpServer.tool(
|
|||||||
return { content: [{ type: "text", text: "Unpinned note" }] };
|
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" }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
22
src/services/mcp/tools/profile.ts
Normal file
22
src/services/mcp/tools/profile.ts
Normal 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" }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
148
src/services/mcp/tools/social.ts
Normal file
148
src/services/mcp/tools/social.ts
Normal 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) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -101,13 +101,8 @@ export async function ownerPublish(event: NostrEvent, relays?: string[]) {
|
|||||||
eventCache.addEvent(event);
|
eventCache.addEvent(event);
|
||||||
|
|
||||||
// publish event to owners outboxes
|
// publish event to owners outboxes
|
||||||
if (bakeryConfig.data.owner) {
|
if (!relays && bakeryConfig.data.owner)
|
||||||
try {
|
relays = (await requestLoader.mailboxes({ pubkey: bakeryConfig.data.owner })).outboxes;
|
||||||
relays = relays || (await requestLoader.mailboxes({ pubkey: bakeryConfig.data.owner })).outboxes;
|
|
||||||
return await lastValueFrom(rxNostr.send(event, { on: { relays } }).pipe(toArray()));
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,9 +210,7 @@ export class SQLiteEventStore extends EventEmitter<EventMap> {
|
|||||||
// before inserting it into the database
|
// before inserting it into the database
|
||||||
|
|
||||||
// get event d value so it can be indexed
|
// get event d value so it can be indexed
|
||||||
const d = kinds.isParameterizedReplaceableKind(event.kind)
|
const d = kinds.isAddressableKind(event.kind) ? event.tags.find((t) => t[0] === "d" && t[1])?.[1] : undefined;
|
||||||
? event.tags.find((t) => t[0] === "d" && t[1])?.[1]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const insert = this.db
|
const insert = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -278,8 +276,6 @@ export class SQLiteEventStore extends EventEmitter<EventMap> {
|
|||||||
.slice(1)
|
.slice(1)
|
||||||
.map((item) => item.id);
|
.map((item) => item.id);
|
||||||
|
|
||||||
if (!removeIds.includes(event.id)) this.log("Removed", removeIds.length, "old replaceable events");
|
|
||||||
|
|
||||||
this.removeEvents(removeIds);
|
this.removeEvents(removeIds);
|
||||||
|
|
||||||
// If the event that was just inserted was one of
|
// If the event that was just inserted was one of
|
||||||
|
|||||||
Reference in New Issue
Block a user