Add the media MCP tools and live-verified slopbot path
This commit is contained in:
+101
-1
@@ -317,7 +317,16 @@ const READ_ONLY_TOOLS: Record<string, (args: any) => Promise<unknown> | unknown>
|
||||
get_action_log: args => requirePuppet().readActionLog(args?.limit ?? 100),
|
||||
get_puppet_config: () => requirePuppet().getConfig(),
|
||||
whitelist_list: () => ({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist }),
|
||||
read_messages: args => paced(() => requireCore().readMessages(args?.channelId, args?.limit ?? 20))
|
||||
read_messages: args => paced(() => requireCore().readMessages(args?.channelId, args?.limit ?? 20)),
|
||||
list_media_scrapes: () => media.listScrapes(),
|
||||
get_media_storage: () => media.refreshRetention(),
|
||||
query_media: args =>
|
||||
media.queryMedia({
|
||||
kind: args?.kind,
|
||||
scrapeId: args?.scrapeId,
|
||||
limit: args?.limit,
|
||||
offset: args?.offset
|
||||
})
|
||||
};
|
||||
|
||||
function buildServer(): McpServer {
|
||||
@@ -749,6 +758,97 @@ function buildServer(): McpServer {
|
||||
async ({ userIds }) => jsonText({ whitelist: requirePuppet().removeFromWhitelist(userIds) })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"scrape_media",
|
||||
{
|
||||
description:
|
||||
"Start a media scrape of a channel. Uses slopbot for guild channels it is in, otherwise the client. Near the storage limit it fails with MEDIA_LIMIT unless confirm is true.",
|
||||
inputSchema: {
|
||||
channelId: z.string().describe("Channel ID to scrape"),
|
||||
guildId: z.string().optional().describe("Guild ID; slopbot needs it to match"),
|
||||
mode: z
|
||||
.enum(["links", "download"])
|
||||
.optional()
|
||||
.describe("links indexes URLs (default); download fetches attachment bytes"),
|
||||
confirm: z.boolean().optional().describe("Accept the near-limit warning")
|
||||
}
|
||||
},
|
||||
async ({ channelId, guildId, mode, confirm }) =>
|
||||
jsonText(await media.startScrape({ channelId, guildId: guildId ?? null, mode, confirm }))
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_media_scrapes",
|
||||
{ description: "List media scrapes with state, pages, messages, and media counts." },
|
||||
async () => jsonText(media.listScrapes())
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_media_scrape",
|
||||
{
|
||||
description: "Get one media scrape by ID.",
|
||||
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
|
||||
},
|
||||
async ({ scrapeId }) => jsonText(media.getScrape(scrapeId))
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"stop_media_scrape",
|
||||
{
|
||||
description: "Stop a media scrape. It pauses after the current page.",
|
||||
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
|
||||
},
|
||||
async ({ scrapeId }) => {
|
||||
media.stopScrape(scrapeId);
|
||||
return jsonText({ stopped: scrapeId });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_media_storage",
|
||||
{
|
||||
description:
|
||||
"Media storage usage against the limit, per-kind sizes, and the scrape list. Marks old scrapes when over the limit."
|
||||
},
|
||||
async () => jsonText(media.refreshRetention())
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"query_media",
|
||||
{
|
||||
description: "Query media items across scrapes, newest scrape first.",
|
||||
inputSchema: {
|
||||
kind: z.enum(["image", "gif", "video", "audio"]).optional(),
|
||||
scrapeId: z.string().optional().describe("Restrict to one scrape"),
|
||||
limit: z.number().int().min(1).max(500).optional(),
|
||||
offset: z.number().int().min(0).optional()
|
||||
}
|
||||
},
|
||||
async ({ kind, scrapeId, limit, offset }) =>
|
||||
jsonText(media.queryMedia({ kind, scrapeId, limit, offset }))
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_media_item",
|
||||
{
|
||||
description: "Get one media item by scrape ID and item ID.",
|
||||
inputSchema: {
|
||||
scrapeId: z.string().describe("Scrape ID"),
|
||||
itemId: z.number().int().min(1).describe("Media row ID inside the scrape")
|
||||
}
|
||||
},
|
||||
async ({ scrapeId, itemId }) => jsonText(media.getMediaItem(scrapeId, itemId))
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"delete_media_scrape",
|
||||
{
|
||||
description: "Delete a scrape and its files. Irreversible.",
|
||||
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
|
||||
},
|
||||
async ({ scrapeId }) => jsonText({ deleted: media.cleanMedia([scrapeId]) === 1 })
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
+20
-2
@@ -15,6 +15,7 @@ import {
|
||||
scrapeDir,
|
||||
resolveDbRoot,
|
||||
type MediaKind,
|
||||
type MediaRow,
|
||||
type ScrapeBackend,
|
||||
type ScrapeMeta
|
||||
} from "../../media-scrape/src/index.js";
|
||||
@@ -160,7 +161,7 @@ export class MediaService {
|
||||
try {
|
||||
const store = new ScrapeStore(dir);
|
||||
try {
|
||||
const sums = store.sumBytesByKind();
|
||||
const sums = store.storedBytesByKind();
|
||||
for (const kind of Object.keys(byKind) as MediaKind[]) byKind[kind] += sums[kind];
|
||||
} finally {
|
||||
store.close();
|
||||
@@ -279,8 +280,24 @@ export class MediaService {
|
||||
};
|
||||
}
|
||||
|
||||
/** One media row inside a scrape, with its scrape and channel for context. */
|
||||
getMediaItem(scrapeId: string, itemId: number): (MediaRow & { scrapeId: string; channelId: string }) | null {
|
||||
const meta = this.engine.get(scrapeId);
|
||||
if (!meta) return null;
|
||||
|
||||
const store = new ScrapeStore(scrapeDir(this.rootDir, scrapeId));
|
||||
try {
|
||||
const row = store.getMedia(itemId);
|
||||
return row ? { ...row, scrapeId, channelId: meta.channelId } : null;
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read media rows across every scrape, newest scrape first. */
|
||||
queryMedia(options: { kind?: MediaKind; limit?: number; offset?: number } = {}): MediaItemResult[] {
|
||||
queryMedia(
|
||||
options: { kind?: MediaKind; scrapeId?: string; limit?: number; offset?: number } = {}
|
||||
): MediaItemResult[] {
|
||||
const limit = Math.max(1, Math.min(options.limit ?? 60, 500));
|
||||
const offset = Math.max(0, options.offset ?? 0);
|
||||
const results: MediaItemResult[] = [];
|
||||
@@ -288,6 +305,7 @@ export class MediaService {
|
||||
|
||||
for (const meta of this.engine.list()) {
|
||||
if (meta.state === "deleted") continue;
|
||||
if (options.scrapeId && meta.id !== options.scrapeId) continue;
|
||||
|
||||
const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id));
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user