mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-07 04:20:47 +08:00
Add MCP support for clearing read notifications
This commit is contained in:
@@ -3,19 +3,24 @@ import { Agent, Runner, hostedMcpTool, withTrace } from "@openai/agents";
|
|||||||
|
|
||||||
console.log("✅ Reply bot starting...");
|
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) ----
|
// ---- MCP 工具(Hosted MCP) ----
|
||||||
// 关键点:requireApproval 设为 "never",避免卡在人工批准。
|
// 关键点:requireApproval 设为 "never",避免卡在人工批准。
|
||||||
const mcp = hostedMcpTool({
|
const mcp = hostedMcpTool({
|
||||||
serverLabel: "openisle_mcp",
|
serverLabel: "openisle_mcp",
|
||||||
serverUrl: "https://www.open-isle.com/mcp",
|
serverUrl: "https://www.open-isle.com/mcp",
|
||||||
allowedTools: [
|
allowedTools: allowedMcpTools,
|
||||||
"search",
|
|
||||||
"reply_to_post",
|
|
||||||
"reply_to_comment",
|
|
||||||
"recent_posts",
|
|
||||||
"get_post",
|
|
||||||
"list_unread_messages"
|
|
||||||
],
|
|
||||||
requireApproval: "never",
|
requireApproval: "never",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,6 +29,12 @@ type WorkflowInput = { input_as_text: string };
|
|||||||
// 从环境变量读取你的站点鉴权令牌(可选)
|
// 从环境变量读取你的站点鉴权令牌(可选)
|
||||||
const OPENISLE_TOKEN = process.env.OPENISLE_TOKEN ?? "";
|
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 ----
|
// ---- 定义 Agent ----
|
||||||
const openisleBot = new Agent({
|
const openisleBot = new Agent({
|
||||||
name: "OpenIsle Bot",
|
name: "OpenIsle Bot",
|
||||||
@@ -34,6 +45,7 @@ const openisleBot = new Agent({
|
|||||||
OPENISLE_TOKEN
|
OPENISLE_TOKEN
|
||||||
? `If tools require auth, use this token exactly where the tool schema expects it: ${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.",
|
: "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"),
|
].join("\n"),
|
||||||
tools: [mcp],
|
tools: [mcp],
|
||||||
model: "gpt-4o",
|
model: "gpt-4o",
|
||||||
@@ -63,12 +75,21 @@ export const runWorkflow = async (workflow: WorkflowInput) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return await withTrace("OpenIsle Bot run", async () => {
|
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
|
// Runner.run 会自动循环执行:LLM → 工具 → 直至 finalOutput
|
||||||
|
console.log("🚦 Starting agent run with maxTurns=16...");
|
||||||
const result = await runner.run(openisleBot, workflow.input_as_text, {
|
const result = await runner.run(openisleBot, workflow.input_as_text, {
|
||||||
maxTurns: 16, // 允许更复杂任务多轮调用 MCP
|
maxTurns: 16, // 允许更复杂任务多轮调用 MCP
|
||||||
// stream: true // 如需边跑边看事件可打开,然后消费流事件
|
// stream: true // 如需边跑边看事件可打开,然后消费流事件
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("📬 Agent run completed. Result keys:", Object.keys(result));
|
||||||
|
|
||||||
if (!result.finalOutput) {
|
if (!result.finalOutput) {
|
||||||
// 若没产出最终结果,通常是启用了人工批准/工具失败/达到 maxTurns
|
// 若没产出最终结果,通常是启用了人工批准/工具失败/达到 maxTurns
|
||||||
throw new Error("Agent result is undefined (no final output).");
|
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) };
|
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;
|
return openisleBotResult;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -91,7 +116,8 @@ if (require.main === module) {
|
|||||||
2)依次处理每条“提及/评论”:如需上下文则使用 get_post 获取,生成简明中文回复;如有 commentId 则用 reply_to_comment,否则用 reply_to_post;
|
2)依次处理每条“提及/评论”:如需上下文则使用 get_post 获取,生成简明中文回复;如有 commentId 则用 reply_to_comment,否则用 reply_to_post;
|
||||||
3)跳过关注和系统事件;
|
3)跳过关注和系统事件;
|
||||||
4)保证幂等性:如该贴最后一条是你自己发的回复,则跳过;
|
4)保证幂等性:如该贴最后一条是你自己发的回复,则跳过;
|
||||||
5)最多只处理最新10条;结束时仅输出简要摘要(包含URL或ID)。
|
5)调用 mark_notifications_read,传入本次已处理的通知 ID 清理已读;
|
||||||
|
6)最多只处理最新10条;结束时仅输出简要摘要(包含URL或ID)。
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log("🔍 Running workflow...");
|
console.log("🔍 Running workflow...");
|
||||||
|
|||||||
@@ -358,3 +358,15 @@ class UnreadNotificationsResponse(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="Unread notifications returned by the backend.",
|
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.",
|
||||||
|
)
|
||||||
|
|||||||
@@ -253,6 +253,53 @@ class SearchClient:
|
|||||||
)
|
)
|
||||||
return [self._ensure_dict(entry) for entry in payload]
|
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:
|
async def aclose(self) -> None:
|
||||||
"""Dispose of the underlying HTTP client."""
|
"""Dispose of the underlying HTTP client."""
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .schemas import (
|
|||||||
CommentData,
|
CommentData,
|
||||||
CommentReplyResult,
|
CommentReplyResult,
|
||||||
NotificationData,
|
NotificationData,
|
||||||
|
NotificationCleanupResult,
|
||||||
UnreadNotificationsResponse,
|
UnreadNotificationsResponse,
|
||||||
PostDetail,
|
PostDetail,
|
||||||
PostSummary,
|
PostSummary,
|
||||||
@@ -165,6 +166,8 @@ async def reply_to_post(
|
|||||||
raise ValueError("Reply content must not be empty.")
|
raise ValueError("Reply content must not be empty.")
|
||||||
|
|
||||||
sanitized_token = token.strip() if isinstance(token, str) else None
|
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
|
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:
|
def main() -> None:
|
||||||
"""Run the MCP server using the configured transport."""
|
"""Run the MCP server using the configured transport."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user