diff --git a/README.md b/README.md index 4130b22..bcc88f1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Multi-platform social media automation service that exposes browser-based action ## Features -- **17 MCP tools** for Xiaohongshu (login, browsing, publishing, interactions, notifications, automation) +- **20 MCP tools** for Xiaohongshu (login, browsing, publishing, interactions, notifications, automation) - **REST API** with Bearer token authentication and per-route rate limiting - **Browser automation** via `rebrowser-playwright` with per-platform serial queueing - **Cookie persistence** with file-based storage (`0600`, atomic writes) @@ -104,6 +104,9 @@ Add this in `claude_desktop_config.json`: | `xhs_like` | Toggle like state on a note | | `xhs_favorite` | Toggle favorite state on a note | | `xhs_get_unprocessed_notifications` | Get unprocessed notification tasks from local SQLite state | +| `xhs_mark_notification_task` | Manually mark notification task status (new/pending/ignored/replied/failed) | +| `xhs_list_failed_notification_tasks` | List failed notification tasks for triage/retry | +| `xhs_retry_notification_task` | Retry a failed notification task by fingerprint | | `xhs_reply_notification` | Reply to a specific notification | ## REST API diff --git a/README.zh-CN.md b/README.zh-CN.md index 6761df5..6ea163c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ ## 功能特性 -- 小红书 **17 个 MCP 工具**(登录、浏览、发布、互动、通知、自动化) +- 小红书 **20 个 MCP 工具**(登录、浏览、发布、互动、通知、自动化) - 带 Bearer Token 鉴权与按路由限流的 REST API - 基于 `rebrowser-playwright` 的浏览器自动化,按平台串行队列执行 - 文件型 Cookie 持久化(`0600` 权限、原子写入) @@ -104,6 +104,9 @@ pnpm test | `xhs_like` | 切换点赞状态 | | `xhs_favorite` | 切换收藏状态 | | `xhs_get_unprocessed_notifications` | 从本地 SQLite 获取“未处理”通知任务 | +| `xhs_mark_notification_task` | 手动标记通知任务状态(new/pending/ignored/replied/failed) | +| `xhs_list_failed_notification_tasks` | 获取失败通知任务列表(用于排障/重试) | +| `xhs_retry_notification_task` | 按 fingerprint 重试失败通知任务 | | `xhs_reply_notification` | 对通知进行回复 | ## REST API diff --git a/src/platforms/xiaohongshu/index.ts b/src/platforms/xiaohongshu/index.ts index 8767c13..fefa59d 100644 --- a/src/platforms/xiaohongshu/index.ts +++ b/src/platforms/xiaohongshu/index.ts @@ -37,6 +37,9 @@ import { FavoriteSchema, ReplyNotificationSchema, GetUnprocessedNotificationsSchema, + MarkNotificationTaskSchema, + ListFailedNotificationTasksSchema, + RetryNotificationTaskSchema, } from './schemas.js'; import type { SearchFilters } from './types.js'; import type { PlatformPlugin } from '../../server/app.js'; @@ -601,7 +604,7 @@ export const xiaohongshuPlugin: PlatformPlugin = { ); // ===================================================================== - // Notifications (2 tools) + // Notifications (5 tools) // ===================================================================== // ----------------------------------------------------------------------- @@ -639,6 +642,129 @@ export const xiaohongshuPlugin: PlatformPlugin = { }, ); + // ----------------------------------------------------------------------- + // xhs_mark_notification_task + // ----------------------------------------------------------------------- + + server.tool( + 'xhs_mark_notification_task', + 'Manually mark a notification task status (new/pending/ignored/replied/failed)', + MarkNotificationTaskSchema, + async (args) => { + return withErrorHandling('xhs_mark_notification_task', async () => { + const store = getNotificationStateStore(); + const existing = store.getByFingerprint(args.fingerprint); + if (!existing) { + throw new Error(`Notification task not found: ${args.fingerprint}`); + } + + store.setStatus(args.fingerprint, args.status, args.note); + const updated = store.getByFingerprint(args.fingerprint); + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: true, + task: updated, + }), + }], + }; + }); + }, + ); + + // ----------------------------------------------------------------------- + // xhs_list_failed_notification_tasks + // ----------------------------------------------------------------------- + + server.tool( + 'xhs_list_failed_notification_tasks', + 'List failed notification tasks from local state store for retry/triage', + ListFailedNotificationTasksSchema, + async (args) => { + return withErrorHandling('xhs_list_failed_notification_tasks', async () => { + const tasks = getNotificationStateStore().listByStatuses(['failed'], args.max_count); + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ tasks }), + }], + }; + }); + }, + ); + + // ----------------------------------------------------------------------- + // xhs_retry_notification_task + // ----------------------------------------------------------------------- + + server.tool( + 'xhs_retry_notification_task', + 'Retry a failed notification task by fingerprint, optionally overriding reply content', + RetryNotificationTaskSchema, + async (args) => { + return withErrorHandling('xhs_retry_notification_task', async () => { + const store = getNotificationStateStore(); + const task = store.getByFingerprint(args.fingerprint); + if (!task) { + throw new Error(`Notification task not found: ${args.fingerprint}`); + } + if (task.status !== 'failed') { + throw new Error(`Task status must be failed to retry, got: ${task.status}`); + } + + const replyContent = args.reply_content ?? task.replyContent; + if (!replyContent) { + throw new Error( + 'Retry requires reply_content when task has no stored replyContent', + ); + } + + store.markPending(args.fingerprint); + + const timeoutMs = + config.operationTimeouts['reply'] ?? + config.operationTimeouts['default'] ?? + 20_000; + + try { + const result = await browser.withPage( + PLATFORM, + async (page) => + replyNotification( + page, + task.notification.userId, + task.notification.content, + replyContent, + ), + timeoutMs, + ); + + if (result.success) { + store.markReplied(args.fingerprint, replyContent); + } else { + store.markFailed(args.fingerprint, 'Retry reply returned success=false'); + } + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + ...result, + fingerprint: args.fingerprint, + }), + }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + store.markFailed(args.fingerprint, message); + throw err; + } + }); + }, + ); + // ----------------------------------------------------------------------- // xhs_reply_notification // ----------------------------------------------------------------------- diff --git a/src/platforms/xiaohongshu/notification-state.ts b/src/platforms/xiaohongshu/notification-state.ts index 7c6b842..7177e0d 100644 --- a/src/platforms/xiaohongshu/notification-state.ts +++ b/src/platforms/xiaohongshu/notification-state.ts @@ -219,6 +219,20 @@ export class NotificationStateStore { return rows.map((r) => this.rowToTask(r)); } + getByFingerprint(fingerprint: string): NotificationTask | null { + const stmt = this.db.prepare(` + SELECT + fingerprint, user_id, nickname, avatar, content, type, time, + feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at, + retry_count, last_attempt_at, replied_at, reply_content, error_message + FROM notification_tasks + WHERE fingerprint = ? + LIMIT 1 + `); + const row = stmt.get(fingerprint) as NotificationRow | undefined; + return row ? this.rowToTask(row) : null; + } + findOpenFingerprint(userId: string, content: string): string | null { const stmt = this.db.prepare(` SELECT fingerprint @@ -281,6 +295,38 @@ export class NotificationStateStore { stmt.run(reason ?? 'Ignored by operator', fingerprint); } + setStatus( + fingerprint: string, + status: NotificationTaskStatus, + note?: string, + ): void { + const now = Date.now(); + const stmt = this.db.prepare(` + UPDATE notification_tasks + SET + status = ?, + last_attempt_at = CASE WHEN ? IN ('pending', 'failed', 'replied') THEN ? ELSE last_attempt_at END, + replied_at = CASE WHEN ? = 'replied' THEN ? ELSE replied_at END, + error_message = CASE WHEN ? = 'failed' THEN COALESCE(?, 'Marked as failed') WHEN ? = 'ignored' THEN COALESCE(?, 'Ignored by operator') ELSE error_message END, + reply_content = CASE WHEN ? = 'replied' THEN COALESCE(?, reply_content) ELSE reply_content END + WHERE fingerprint = ? + `); + stmt.run( + status, + status, + now, + status, + now, + status, + note ?? null, + status, + note ?? null, + status, + note ?? null, + fingerprint, + ); + } + private rowToTask(row: NotificationRow): NotificationTask { return { fingerprint: row.fingerprint, diff --git a/src/platforms/xiaohongshu/schemas.ts b/src/platforms/xiaohongshu/schemas.ts index 6e346c6..ad04630 100644 --- a/src/platforms/xiaohongshu/schemas.ts +++ b/src/platforms/xiaohongshu/schemas.ts @@ -145,7 +145,7 @@ export const LikeSchema = { /** xhs_list_my_notes — no parameters. */ export const ListMyNotesSchema = {}; -// -- Phase 5: Notifications (2 tools) -------------------------------------- +// -- Phase 5: Notifications & automation ----------------------------------- /** xhs_get_comment_notifications */ export const GetCommentNotificationsSchema = { @@ -191,6 +191,40 @@ export const GetUnprocessedNotificationsSchema = { .describe('Whether to sync latest notifications from Xiaohongshu before querying local state'), }; +/** xhs_mark_notification_task */ +export const MarkNotificationTaskSchema = { + fingerprint: z.string().describe('Notification task fingerprint'), + status: z + .enum(['new', 'pending', 'ignored', 'replied', 'failed']) + .describe('Target status for this notification task'), + note: z + .string() + .optional() + .describe('Optional note/reason (used as reply_content for replied, or error_message for failed/ignored)'), +}; + +/** xhs_list_failed_notification_tasks */ +export const ListFailedNotificationTasksSchema = { + max_count: z + .number() + .int() + .min(1) + .max(200) + .optional() + .default(20) + .describe('Maximum number of failed tasks to return (1–200, default 20)'), +}; + +/** xhs_retry_notification_task */ +export const RetryNotificationTaskSchema = { + fingerprint: z.string().describe('Notification task fingerprint to retry'), + reply_content: z + .string() + .min(1) + .optional() + .describe('Optional override reply text. If omitted, uses stored reply_content from previous attempt.'), +}; + /** xhs_favorite */ export const FavoriteSchema = { feed_id: z.string().describe('Feed ID to toggle favorite'),