From 2cfb302981bc0fabcec38f830c5ddde327069588 Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 28 Oct 2025 12:37:17 +0800 Subject: [PATCH 1/4] fix: add bot --- bots/reply_bots.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 bots/reply_bots.ts diff --git a/bots/reply_bots.ts b/bots/reply_bots.ts new file mode 100644 index 000000000..c4a02cf4d --- /dev/null +++ b/bots/reply_bots.ts @@ -0,0 +1,98 @@ +// reply_bot.ts +import { Agent, Runner, hostedMcpTool, withTrace } from "@openai/agents"; + +console.log("✅ Reply bot starting..."); + +// ---- MCP 工具(Hosted MCP) ---- +// 关键点:requireApproval 设为 "never",避免卡在人工批准。 +const mcp = hostedMcpTool({ + serverLabel: "openisle_mcp", + serverUrl: "https://www.open-isle.com/mcp", + allowedTools: [ + "search", + "reply_to_post", + "reply_to_comment", + "recent_posts", + "get_post", + "list_unread_messages" + ], + requireApproval: "never", +}); + +type WorkflowInput = { input_as_text: string }; + +// 从环境变量读取你的站点鉴权令牌(可选) +const OPENISLE_TOKEN = process.env.OPENISLE_TOKEN ?? ""; + +// ---- 定义 Agent ---- +const openisleBot = new Agent({ + name: "OpenIsle Bot", + instructions: [ + "You are a helpful assistant for https://www.open-isle.com.", + "Finish tasks end-to-end before replying. If multiple MCP tools are needed, call them sequentially until the task is truly done.", + "When showing the result, reply in Chinese with a concise summary and include any important URLs or IDs.", + OPENISLE_TOKEN + ? `If tools require auth, use this token exactly where the tool schema expects it: ${OPENISLE_TOKEN}` + : "If a tool requires auth, ask me to provide OPENISLE_TOKEN via env.", + ].join("\n"), + tools: [mcp], + model: "gpt-4o", + modelSettings: { + temperature: 0.7, + topP: 1, + maxTokens: 2048, + toolChoice: "auto", + store: true, + }, +}); + +// ---- 入口函数:跑到拿到 finalOutput 为止,然后输出并退出 ---- +export const runWorkflow = async (workflow: WorkflowInput) => { + // 强烈建议在外部(shell)设置 OPENAI_API_KEY + if (!process.env.OPENAI_API_KEY) { + throw new Error("Missing OPENAI_API_KEY"); + } + + const runner = new Runner({ + workflowName: "OpenIsle Bot", + traceMetadata: { + __trace_source__: "agent-builder", + workflow_id: "wf_69003cbd47e08190928745d3c806c0b50d1a01cfae052be8", + }, + // 如需完全禁用上报可加:tracingDisabled: true + }); + + return await withTrace("OpenIsle Bot run", async () => { + // Runner.run 会自动循环执行:LLM → 工具 → 直至 finalOutput + const result = await runner.run(openisleBot, workflow.input_as_text, { + maxTurns: 16, // 允许更复杂任务多轮调用 MCP + // stream: true // 如需边跑边看事件可打开,然后消费流事件 + }); + + if (!result.finalOutput) { + // 若没产出最终结果,通常是启用了人工批准/工具失败/达到 maxTurns + throw new Error("Agent result is undefined (no final output)."); + } + + const openisleBotResult = { output_text: String(result.finalOutput) }; + + console.log("🤖 Agent result:\n" + openisleBotResult.output_text); + return openisleBotResult; + }); +}; + +// ---- CLI 运行(示范)---- +if (require.main === module) { + (async () => { + try { + const query = + process.argv.slice(2).join(" ") || "你好,协助看看有什么未读消息,并结合帖子内容/评论内容予以回复"; + console.log("🔍 Running workflow..."); + await runWorkflow({ input_as_text: query }); + process.exit(0); + } catch (err: any) { + console.error("❌ Agent failed:", err?.stack || err); + process.exit(1); + } + })(); +} From 398b4b482f4a2031453cdd07015d03a12ef23d3a Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 28 Oct 2025 13:00:42 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20prompt=20=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bots/reply_bots.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bots/reply_bots.ts b/bots/reply_bots.ts index c4a02cf4d..1ad44c699 100644 --- a/bots/reply_bots.ts +++ b/bots/reply_bots.ts @@ -28,9 +28,9 @@ const OPENISLE_TOKEN = process.env.OPENISLE_TOKEN ?? ""; const openisleBot = new Agent({ name: "OpenIsle Bot", instructions: [ - "You are a helpful assistant for https://www.open-isle.com.", + "You are a helpful and cute assistant for https://www.open-isle.com. Please use plenty of kawaii kaomoji (颜表情), such as (๑˃ᴗ˂)ﻭ, (•̀ω•́)✧, (。•ᴗ-)_♡, (⁎⁍̴̛ᴗ⁍̴̛⁎), etc., in your replies to create a friendly, adorable vibe.", "Finish tasks end-to-end before replying. If multiple MCP tools are needed, call them sequentially until the task is truly done.", - "When showing the result, reply in Chinese with a concise summary and include any important URLs or IDs.", + "When presenting the result, reply in Chinese with a concise, cute summary filled with kaomoji and include any important URLs or IDs.", OPENISLE_TOKEN ? `If tools require auth, use this token exactly where the tool schema expects it: ${OPENISLE_TOKEN}` : "If a tool requires auth, ask me to provide OPENISLE_TOKEN via env.", @@ -85,8 +85,15 @@ export const runWorkflow = async (workflow: WorkflowInput) => { if (require.main === module) { (async () => { try { - const query = - process.argv.slice(2).join(" ") || "你好,协助看看有什么未读消息,并结合帖子内容/评论内容予以回复"; + const query = ` + 【AUTO】无需确认,自动处理所有未读的提及与评论: + 1)调用 list_unread_messages; + 2)依次处理每条“提及/评论”:如需上下文则使用 get_post 获取,生成简明中文回复;如有 commentId 则用 reply_to_comment,否则用 reply_to_post; + 3)跳过关注和系统事件; + 4)保证幂等性:如该贴最后一条是你自己发的回复,则跳过; + 5)最多只处理最新10条;结束时仅输出简要摘要(包含URL或ID)。 + `; + console.log("🔍 Running workflow..."); await runWorkflow({ input_as_text: query }); process.exit(0); From 26bb85f4d4e6f46da538289279de4b702ffedccc Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:50:16 +0800 Subject: [PATCH 3/4] Add MCP support for clearing read notifications --- bots/reply_bots.ts | 46 ++++++++++++---- mcp/src/openisle_mcp/schemas.py | 12 +++++ mcp/src/openisle_mcp/search_client.py | 47 +++++++++++++++++ mcp/src/openisle_mcp/server.py | 76 +++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 10 deletions(-) diff --git a/bots/reply_bots.ts b/bots/reply_bots.ts index 1ad44c699..4242e86c8 100644 --- a/bots/reply_bots.ts +++ b/bots/reply_bots.ts @@ -3,19 +3,24 @@ import { Agent, Runner, hostedMcpTool, withTrace } from "@openai/agents"; console.log("✅ Reply bot starting..."); +const allowedMcpTools = [ + "search", + "reply_to_post", + "reply_to_comment", + "recent_posts", + "get_post", + "list_unread_messages", + "mark_notifications_read", +]; + +console.log("🛠️ Configured Hosted MCP tools:", allowedMcpTools.join(", ")); + // ---- MCP 工具(Hosted MCP) ---- // 关键点:requireApproval 设为 "never",避免卡在人工批准。 const mcp = hostedMcpTool({ serverLabel: "openisle_mcp", serverUrl: "https://www.open-isle.com/mcp", - allowedTools: [ - "search", - "reply_to_post", - "reply_to_comment", - "recent_posts", - "get_post", - "list_unread_messages" - ], + allowedTools: allowedMcpTools, requireApproval: "never", }); @@ -24,6 +29,12 @@ type WorkflowInput = { input_as_text: string }; // 从环境变量读取你的站点鉴权令牌(可选) const OPENISLE_TOKEN = process.env.OPENISLE_TOKEN ?? ""; +console.log( + OPENISLE_TOKEN + ? "🔑 OPENISLE_TOKEN detected in environment." + : "🔓 OPENISLE_TOKEN not set; agent will request it if required." +); + // ---- 定义 Agent ---- const openisleBot = new Agent({ name: "OpenIsle Bot", @@ -34,6 +45,7 @@ const openisleBot = new Agent({ OPENISLE_TOKEN ? `If tools require auth, use this token exactly where the tool schema expects it: ${OPENISLE_TOKEN}` : "If a tool requires auth, ask me to provide OPENISLE_TOKEN via env.", + "After finishing replies, call mark_notifications_read with all processed notification IDs to keep the inbox clean.", ].join("\n"), tools: [mcp], model: "gpt-4o", @@ -63,12 +75,21 @@ export const runWorkflow = async (workflow: WorkflowInput) => { }); return await withTrace("OpenIsle Bot run", async () => { + const preview = workflow.input_as_text.trim(); + console.log( + "📝 Received workflow input (preview):", + preview.length > 200 ? `${preview.slice(0, 200)}…` : preview + ); + // Runner.run 会自动循环执行:LLM → 工具 → 直至 finalOutput + console.log("🚦 Starting agent run with maxTurns=16..."); const result = await runner.run(openisleBot, workflow.input_as_text, { maxTurns: 16, // 允许更复杂任务多轮调用 MCP // stream: true // 如需边跑边看事件可打开,然后消费流事件 }); + console.log("📬 Agent run completed. Result keys:", Object.keys(result)); + if (!result.finalOutput) { // 若没产出最终结果,通常是启用了人工批准/工具失败/达到 maxTurns throw new Error("Agent result is undefined (no final output)."); @@ -76,7 +97,11 @@ export const runWorkflow = async (workflow: WorkflowInput) => { const openisleBotResult = { output_text: String(result.finalOutput) }; - console.log("🤖 Agent result:\n" + openisleBotResult.output_text); + console.log( + "🤖 Agent result (length=%d):\n%s", + openisleBotResult.output_text.length, + openisleBotResult.output_text + ); return openisleBotResult; }); }; @@ -91,7 +116,8 @@ if (require.main === module) { 2)依次处理每条“提及/评论”:如需上下文则使用 get_post 获取,生成简明中文回复;如有 commentId 则用 reply_to_comment,否则用 reply_to_post; 3)跳过关注和系统事件; 4)保证幂等性:如该贴最后一条是你自己发的回复,则跳过; - 5)最多只处理最新10条;结束时仅输出简要摘要(包含URL或ID)。 + 5)调用 mark_notifications_read,传入本次已处理的通知 ID 清理已读; + 6)最多只处理最新10条;结束时仅输出简要摘要(包含URL或ID)。 `; console.log("🔍 Running workflow..."); diff --git a/mcp/src/openisle_mcp/schemas.py b/mcp/src/openisle_mcp/schemas.py index 8bcb11ff5..30edf271b 100644 --- a/mcp/src/openisle_mcp/schemas.py +++ b/mcp/src/openisle_mcp/schemas.py @@ -358,3 +358,15 @@ class UnreadNotificationsResponse(BaseModel): default_factory=list, description="Unread notifications returned by the backend.", ) + + +class NotificationCleanupResult(BaseModel): + """Structured response returned after marking notifications as read.""" + + processed_ids: list[int] = Field( + default_factory=list, + description="Identifiers that were marked as read in the backend.", + ) + total_marked: int = Field( + description="Total number of notifications successfully marked as read.", + ) diff --git a/mcp/src/openisle_mcp/search_client.py b/mcp/src/openisle_mcp/search_client.py index fa8a07931..d378e0816 100644 --- a/mcp/src/openisle_mcp/search_client.py +++ b/mcp/src/openisle_mcp/search_client.py @@ -253,6 +253,53 @@ class SearchClient: ) return [self._ensure_dict(entry) for entry in payload] + async def mark_notifications_read( + self, + ids: list[int], + *, + token: str | None = None, + ) -> None: + """Mark the provided notifications as read for the authenticated user.""" + + if not ids: + raise ValueError( + "At least one notification identifier must be provided to mark as read." + ) + + sanitized_ids: list[int] = [] + for value in ids: + if isinstance(value, bool): + raise ValueError("Notification identifiers must be integers, not booleans.") + try: + converted = int(value) + except (TypeError, ValueError) as exc: # pragma: no cover - defensive + raise ValueError( + "Notification identifiers must be integers." + ) from exc + if converted <= 0: + raise ValueError( + "Notification identifiers must be positive integers." + ) + sanitized_ids.append(converted) + + client = self._get_client() + resolved_token = self._require_token(token) + logger.debug( + "Marking %d notifications as read: ids=%s", + len(sanitized_ids), + sanitized_ids, + ) + response = await client.post( + "/api/notifications/read", + json={"ids": sanitized_ids}, + headers=self._build_headers(token=resolved_token, include_json=True), + ) + response.raise_for_status() + logger.info( + "Successfully marked %d notifications as read.", + len(sanitized_ids), + ) + async def aclose(self) -> None: """Dispose of the underlying HTTP client.""" diff --git a/mcp/src/openisle_mcp/server.py b/mcp/src/openisle_mcp/server.py index 9e4670701..4db5d4041 100644 --- a/mcp/src/openisle_mcp/server.py +++ b/mcp/src/openisle_mcp/server.py @@ -17,6 +17,7 @@ from .schemas import ( CommentData, CommentReplyResult, NotificationData, + NotificationCleanupResult, UnreadNotificationsResponse, PostDetail, PostSummary, @@ -165,6 +166,8 @@ async def reply_to_post( raise ValueError("Reply content must not be empty.") sanitized_token = token.strip() if isinstance(token, str) else None + if sanitized_token == "": + sanitized_token = None sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None @@ -552,6 +555,79 @@ async def list_unread_messages( ) +@app.tool( + name="mark_notifications_read", + description="Mark specific notification messages as read to remove them from the unread list.", + structured_output=True, +) +async def mark_notifications_read( + ids: Annotated[ + list[int], + PydanticField( + min_length=1, + description="Notification identifiers that should be marked as read.", + ), + ], + token: Annotated[ + str | None, + PydanticField( + default=None, + description=( + "Optional JWT bearer token. When omitted the configured access token is used." + ), + ), + ] = None, + ctx: Context | None = None, +) -> NotificationCleanupResult: + """Mark the supplied notifications as read and report the processed identifiers.""" + + sanitized_token = token.strip() if isinstance(token, str) else None + if sanitized_token == "": + sanitized_token = None + + try: + logger.info( + "Marking %d notifications as read", # pragma: no branch - logging + len(ids), + ) + await search_client.mark_notifications_read(ids, token=sanitized_token) + except httpx.HTTPStatusError as exc: # pragma: no cover - network errors + message = ( + "OpenIsle backend returned HTTP " + f"{exc.response.status_code} while marking notifications as read." + ) + if ctx is not None: + await ctx.error(message) + raise ValueError(message) from exc + except httpx.RequestError as exc: # pragma: no cover - network errors + message = f"Unable to reach OpenIsle backend notification service: {exc}." + if ctx is not None: + await ctx.error(message) + raise ValueError(message) from exc + + processed_ids: list[int] = [] + for value in ids: + if isinstance(value, bool): + raise ValueError("Notification identifiers must be integers, not booleans.") + converted = int(value) + if converted <= 0: + raise ValueError("Notification identifiers must be positive integers.") + processed_ids.append(converted) + if ctx is not None: + await ctx.info( + f"Marked {len(processed_ids)} notifications as read.", + ) + logger.debug( + "Successfully marked notifications as read: ids=%s", + processed_ids, + ) + + return NotificationCleanupResult( + processed_ids=processed_ids, + total_marked=len(processed_ids), + ) + + def main() -> None: """Run the MCP server using the configured transport.""" From fd93a2dc614d60b25363ac4096500978be07f7f4 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:56:18 +0800 Subject: [PATCH 4/4] Add scheduled reply bot workflow --- .github/workflows/reply-bots.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/reply-bots.yml diff --git a/.github/workflows/reply-bots.yml b/.github/workflows/reply-bots.yml new file mode 100644 index 000000000..e7b38bcf9 --- /dev/null +++ b/.github/workflows/reply-bots.yml @@ -0,0 +1,28 @@ +name: Reply Bots + +on: + schedule: + - cron: "*/30 * * * *" + workflow_dispatch: + +jobs: + run-reply-bot: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm install --no-save @openai/agents ts-node typescript + + - name: Run reply bot + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }} + run: npx ts-node --esm bots/reply_bots.ts