Compare commits

...

10 Commits

Author SHA1 Message Date
Tim
fc41e605e4 Add MCP tool for creating posts 2025-10-28 15:05:55 +08:00
Tim
629442bff6 fix: make a bot father 2025-10-28 14:58:38 +08:00
Tim
7798910be0 Merge pull request #1106 from nagisa77/codex/refactor-bots-directory-to-oop
Refactor reply bots to extend BotFather base class
2025-10-28 14:55:02 +08:00
Tim
6f036eb4fe Refactor reply bot with BotFather base class 2025-10-28 14:54:49 +08:00
Tim
56fc05cb3c fix: 新增环境 2025-10-28 14:15:03 +08:00
Tim
a55a15659b fix: 解决脚本失败问题 2025-10-28 14:11:29 +08:00
Tim
ccf6e0c7ce Merge pull request #1104 from nagisa77/feature/bot
Feature/bot
2025-10-28 13:57:23 +08:00
Tim
87677f5968 Merge pull request #1103 from nagisa77/codex/add-git-action-to-run-reply_bots.ts
Add scheduled workflow to run reply bots
2025-10-28 13:56:31 +08:00
Tim
fd93a2dc61 Add scheduled reply bot workflow 2025-10-28 13:56:18 +08:00
Tim
80f862a226 Merge pull request #1102 from nagisa77/codex/add-read-cleanup-interface-for-mcp
Add MCP support for clearing read notifications
2025-10-28 13:50:33 +08:00
8 changed files with 578 additions and 134 deletions

29
.github/workflows/reply-bots.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Reply Bots
on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
jobs:
run-reply-bot:
environment: Bots
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 tsx typescript
- name: Run reply bot
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
run: npx tsx bots/instance/reply_bot.ts

137
bots/bot_father.ts Normal file
View File

@@ -0,0 +1,137 @@
import { Agent, Runner, hostedMcpTool, withTrace } from "@openai/agents";
export type WorkflowInput = { input_as_text: string };
export abstract class BotFather {
protected readonly allowedMcpTools = [
"search",
"reply_to_post",
"reply_to_comment",
"recent_posts",
"get_post",
"list_unread_messages",
"mark_notifications_read",
];
protected readonly mcp = hostedMcpTool({
serverLabel: "openisle_mcp",
serverUrl: "https://www.open-isle.com/mcp",
allowedTools: this.allowedMcpTools,
requireApproval: "never",
});
protected readonly openisleToken = process.env.OPENISLE_TOKEN ?? "";
protected readonly agent: Agent;
constructor(protected readonly name: string) {
console.log(`${this.name} starting...`);
console.log(
"🛠️ Configured Hosted MCP tools:",
this.allowedMcpTools.join(", ")
);
console.log(
this.openisleToken
? "🔑 OPENISLE_TOKEN detected in environment."
: "🔓 OPENISLE_TOKEN not set; agent will request it if required."
);
this.agent = new Agent({
name: this.name,
instructions: this.buildInstructions(),
tools: [this.mcp],
model: "gpt-4o",
modelSettings: {
temperature: 0.7,
topP: 1,
maxTokens: 2048,
toolChoice: "auto",
store: true,
},
});
}
protected buildInstructions(): string {
const instructions = [
...this.getBaseInstructions(),
...this.getAdditionalInstructions(),
].filter(Boolean);
return instructions.join("\n");
}
protected getBaseInstructions(): string[] {
return [
"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 presenting the result, reply in Chinese with a concise summary and include any important URLs or IDs.",
this.openisleToken
? `If tools require auth, use this token exactly where the tool schema expects it: ${this.openisleToken}`
: "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.",
];
}
protected getAdditionalInstructions(): string[] {
return [];
}
protected createRunner(): Runner {
return new Runner({
workflowName: this.name,
traceMetadata: {
__trace_source__: "agent-builder",
workflow_id: "wf_69003cbd47e08190928745d3c806c0b50d1a01cfae052be8",
},
});
}
public async runWorkflow(workflow: WorkflowInput) {
if (!process.env.OPENAI_API_KEY) {
throw new Error("Missing OPENAI_API_KEY");
}
const runner = this.createRunner();
return await withTrace(`${this.name} run`, async () => {
const preview = workflow.input_as_text.trim();
console.log(
"📝 Received workflow input (preview):",
preview.length > 200 ? `${preview.slice(0, 200)}` : preview
);
console.log("🚦 Starting agent run with maxTurns=16...");
const result = await runner.run(this.agent, workflow.input_as_text, {
maxTurns: 16,
});
console.log("📬 Agent run completed. Result keys:", Object.keys(result));
if (!result.finalOutput) {
throw new Error("Agent result is undefined (no final output).");
}
const openisleBotResult = { output_text: String(result.finalOutput) };
console.log(
"🤖 Agent result (length=%d):\n%s",
openisleBotResult.output_text.length,
openisleBotResult.output_text
);
return openisleBotResult;
});
}
protected abstract getCliQuery(): string;
public async runCli(): Promise<void> {
try {
const query = this.getCliQuery();
console.log("🔍 Running workflow...");
await this.runWorkflow({ input_as_text: query });
process.exit(0);
} catch (err: any) {
console.error("❌ Agent failed:", err?.stack || err);
process.exit(1);
}
}
}

View File

@@ -0,0 +1,37 @@
// reply_bot.ts
import { BotFather, WorkflowInput } from "./bot_father";
class ReplyBot extends BotFather {
constructor() {
super("OpenIsle Bot");
}
protected override getAdditionalInstructions(): string[] {
return [
"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.",
"When presenting the result, reply in Chinese with a concise, cute summary filled with kaomoji and include any important URLs or IDs.",
];
}
protected override getCliQuery(): string {
return `
【AUTO】无需确认自动处理所有未读的提及与评论
1调用 list_unread_messages
2依次处理每条“提及/评论”:如需上下文则使用 get_post 获取,生成简明中文回复;如有 commentId 则用 reply_to_comment否则用 reply_to_post
3跳过关注和系统事件
4保证幂等性如该贴最后一条是你自己发的回复则跳过
5调用 mark_notifications_read传入本次已处理的通知 ID 清理已读;
6最多只处理最新10条结束时仅输出简要摘要包含URL或ID
`.trim();
}
}
const replyBot = new ReplyBot();
export const runWorkflow = async (workflow: WorkflowInput) => {
return replyBot.runWorkflow(workflow);
};
if (require.main === module) {
replyBot.runCli();
}

View File

@@ -1,131 +0,0 @@
// reply_bot.ts
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: allowedMcpTools,
requireApproval: "never",
});
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",
instructions: [
"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 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.",
"After finishing replies, call mark_notifications_read with all processed notification IDs to keep the inbox clean.",
].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 () => {
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).");
}
const openisleBotResult = { output_text: String(result.finalOutput) };
console.log(
"🤖 Agent result (length=%d):\n%s",
openisleBotResult.output_text.length,
openisleBotResult.output_text
);
return openisleBotResult;
});
};
// ---- CLI 运行(示范)----
if (require.main === module) {
(async () => {
try {
const query = `
【AUTO】无需确认自动处理所有未读的提及与评论
1调用 list_unread_messages
2依次处理每条“提及/评论”:如需上下文则使用 get_post 获取,生成简明中文回复;如有 commentId 则用 reply_to_comment否则用 reply_to_post
3跳过关注和系统事件
4保证幂等性如该贴最后一条是你自己发的回复则跳过
5调用 mark_notifications_read传入本次已处理的通知 ID 清理已读;
6最多只处理最新10条结束时仅输出简要摘要包含URL或ID
`;
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);
}
})();
}

View File

@@ -31,6 +31,7 @@ By default the server listens on port `8085` and serves MCP over Streamable HTTP
| Tool | Description |
| --- | --- |
| `search` | Perform a global search against the OpenIsle backend. |
| `create_post` | Publish a new post using a JWT token. |
| `reply_to_post` | Create a new comment on a post using a JWT token. |
| `reply_to_comment` | Reply to an existing comment using a JWT token. |
| `recent_posts` | Retrieve posts created within the last *N* minutes. |

View File

@@ -192,6 +192,12 @@ class CommentCreateResult(BaseModel):
comment: CommentData = Field(description="Comment returned by the backend.")
class PostCreateResult(BaseModel):
"""Structured response returned when creating a new post."""
post: PostDetail = Field(description="Detailed post payload returned by the backend.")
class PostSummary(BaseModel):
"""Summary information for a post."""

View File

@@ -173,6 +173,33 @@ class SearchClient:
logger.info("Reply to post_id=%s succeeded with id=%s", post_id, body.get("id"))
return body
async def create_post(
self,
payload: dict[str, Any],
*,
token: str | None = None,
) -> dict[str, Any]:
"""Create a new post and return the detailed backend payload."""
client = self._get_client()
resolved_token = self._require_token(token)
headers = self._build_headers(token=resolved_token, include_json=True)
logger.debug(
"Creating post with category_id=%s and %d tag(s)",
payload.get("categoryId"),
len(payload.get("tagIds", []) if isinstance(payload.get("tagIds"), list) else []),
)
response = await client.post(
"/api/posts",
json=payload,
headers=headers,
)
response.raise_for_status()
body = self._ensure_dict(response.json())
logger.info("Post creation succeeded with id=%s", body.get("id"))
return body
async def recent_posts(self, minutes: int) -> list[dict[str, Any]]:
"""Return posts created within the given timeframe."""

View File

@@ -20,6 +20,7 @@ from .schemas import (
NotificationCleanupResult,
UnreadNotificationsResponse,
PostDetail,
PostCreateResult,
PostSummary,
RecentPostsResponse,
SearchResponse,
@@ -66,9 +67,9 @@ async def lifespan(_: FastMCP):
app = FastMCP(
name="openisle-mcp",
instructions=(
"Use this server to search OpenIsle content, reply to posts and comments with an "
"authentication token, retrieve details for a specific post, list posts created "
"within a recent time window, and review unread notification messages."
"Use this server to search OpenIsle content, create new posts, reply to posts and "
"comments with an authentication token, retrieve details for a specific post, list "
"posts created within a recent time window, and review unread notification messages."
),
host=settings.host,
port=settings.port,
@@ -341,6 +342,343 @@ async def reply_to_comment(
return CommentReplyResult(comment=comment)
@app.tool(
name="create_post",
description="Publish a new post using an authentication token.",
structured_output=True,
)
async def create_post(
title: Annotated[
str,
PydanticField(description="Title of the post to be created."),
],
content: Annotated[
str,
PydanticField(description="Markdown content of the post."),
],
category_id: Annotated[
int | None,
PydanticField(
default=None,
ge=1,
description="Optional category identifier for the post.",
),
] = None,
tag_ids: Annotated[
list[int] | None,
PydanticField(
default=None,
min_length=1,
description="Optional list of tag identifiers to assign to the post.",
),
] = None,
post_type: Annotated[
str | None,
PydanticField(
default=None,
description="Optional post type value (e.g. LOTTERY, POLL).",
),
] = None,
visible_scope: Annotated[
str | None,
PydanticField(
default=None,
description="Optional visibility scope for the post.",
),
] = None,
prize_description: Annotated[
str | None,
PydanticField(
default=None,
description="Description of the prize for lottery posts.",
),
] = None,
prize_icon: Annotated[
str | None,
PydanticField(
default=None,
description="Icon URL for the lottery prize.",
),
] = None,
prize_count: Annotated[
int | None,
PydanticField(
default=None,
ge=1,
description="Total number of prizes available for lottery posts.",
),
] = None,
point_cost: Annotated[
int | None,
PydanticField(
default=None,
ge=0,
description="Point cost required to participate in the post, when applicable.",
),
] = None,
start_time: Annotated[
str | None,
PydanticField(
default=None,
description="ISO 8601 start time for lottery or poll posts.",
),
] = None,
end_time: Annotated[
str | None,
PydanticField(
default=None,
description="ISO 8601 end time for lottery or poll posts.",
),
] = None,
options: Annotated[
list[str] | None,
PydanticField(
default=None,
min_length=1,
description="Poll options when creating a poll post.",
),
] = None,
multiple: Annotated[
bool | None,
PydanticField(
default=None,
description="Whether the poll allows selecting multiple options.",
),
] = None,
proposed_name: Annotated[
str | None,
PydanticField(
default=None,
description="Proposed category name for suggestion posts.",
),
] = None,
proposal_description: Annotated[
str | None,
PydanticField(
default=None,
description="Supporting description for the proposed category.",
),
] = None,
captcha: Annotated[
str | None,
PydanticField(
default=None,
description="Captcha solution if the backend requires one to create posts.",
),
] = None,
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,
) -> PostCreateResult:
"""Create a new post in OpenIsle and return the detailed backend payload."""
sanitized_title = title.strip()
if not sanitized_title:
raise ValueError("Post title must not be empty.")
sanitized_content = content.strip()
if not sanitized_content:
raise ValueError("Post content must not be empty.")
sanitized_token = token.strip() if isinstance(token, str) else None
if sanitized_token == "":
sanitized_token = None
sanitized_category_id: int | None = None
if category_id is not None:
if isinstance(category_id, bool):
raise ValueError("Category identifier must be an integer, not a boolean.")
try:
sanitized_category_id = int(category_id)
except (TypeError, ValueError) as exc:
raise ValueError("Category identifier must be an integer.") from exc
if sanitized_category_id <= 0:
raise ValueError("Category identifier must be a positive integer.")
sanitized_tag_ids: list[int] | None = None
if tag_ids is not None:
sanitized_tag_ids = []
for value in tag_ids:
if isinstance(value, bool):
raise ValueError("Tag identifiers must be integers, not booleans.")
try:
converted = int(value)
except (TypeError, ValueError) as exc:
raise ValueError("Tag identifiers must be integers.") from exc
if converted <= 0:
raise ValueError("Tag identifiers must be positive integers.")
sanitized_tag_ids.append(converted)
if not sanitized_tag_ids:
sanitized_tag_ids = None
sanitized_post_type = post_type.strip() if isinstance(post_type, str) else None
if sanitized_post_type == "":
sanitized_post_type = None
sanitized_visible_scope = (
visible_scope.strip() if isinstance(visible_scope, str) else None
)
if sanitized_visible_scope == "":
sanitized_visible_scope = None
sanitized_prize_description = (
prize_description.strip() if isinstance(prize_description, str) else None
)
if sanitized_prize_description == "":
sanitized_prize_description = None
sanitized_prize_icon = prize_icon.strip() if isinstance(prize_icon, str) else None
if sanitized_prize_icon == "":
sanitized_prize_icon = None
sanitized_prize_count: int | None = None
if prize_count is not None:
if isinstance(prize_count, bool):
raise ValueError("Prize count must be an integer, not a boolean.")
try:
sanitized_prize_count = int(prize_count)
except (TypeError, ValueError) as exc:
raise ValueError("Prize count must be an integer.") from exc
if sanitized_prize_count <= 0:
raise ValueError("Prize count must be a positive integer.")
sanitized_point_cost: int | None = None
if point_cost is not None:
if isinstance(point_cost, bool):
raise ValueError("Point cost must be an integer, not a boolean.")
try:
sanitized_point_cost = int(point_cost)
except (TypeError, ValueError) as exc:
raise ValueError("Point cost must be an integer.") from exc
if sanitized_point_cost < 0:
raise ValueError("Point cost cannot be negative.")
sanitized_start_time = start_time.strip() if isinstance(start_time, str) else None
if sanitized_start_time == "":
sanitized_start_time = None
sanitized_end_time = end_time.strip() if isinstance(end_time, str) else None
if sanitized_end_time == "":
sanitized_end_time = None
sanitized_options: list[str] | None = None
if options is not None:
sanitized_options = []
for option in options:
if option is None:
continue
stripped_option = option.strip()
if stripped_option:
sanitized_options.append(stripped_option)
if not sanitized_options:
sanitized_options = None
sanitized_multiple = bool(multiple) if isinstance(multiple, bool) else None
sanitized_proposed_name = (
proposed_name.strip() if isinstance(proposed_name, str) else None
)
if sanitized_proposed_name == "":
sanitized_proposed_name = None
sanitized_proposal_description = (
proposal_description.strip() if isinstance(proposal_description, str) else None
)
if sanitized_proposal_description == "":
sanitized_proposal_description = None
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
if sanitized_captcha == "":
sanitized_captcha = None
payload: dict[str, object] = {
"title": sanitized_title,
"content": sanitized_content,
}
if sanitized_category_id is not None:
payload["categoryId"] = sanitized_category_id
if sanitized_tag_ids is not None:
payload["tagIds"] = sanitized_tag_ids
if sanitized_post_type is not None:
payload["type"] = sanitized_post_type
if sanitized_visible_scope is not None:
payload["postVisibleScopeType"] = sanitized_visible_scope
if sanitized_prize_description is not None:
payload["prizeDescription"] = sanitized_prize_description
if sanitized_prize_icon is not None:
payload["prizeIcon"] = sanitized_prize_icon
if sanitized_prize_count is not None:
payload["prizeCount"] = sanitized_prize_count
if sanitized_point_cost is not None:
payload["pointCost"] = sanitized_point_cost
if sanitized_start_time is not None:
payload["startTime"] = sanitized_start_time
if sanitized_end_time is not None:
payload["endTime"] = sanitized_end_time
if sanitized_options is not None:
payload["options"] = sanitized_options
if sanitized_multiple is not None:
payload["multiple"] = sanitized_multiple
if sanitized_proposed_name is not None:
payload["proposedName"] = sanitized_proposed_name
if sanitized_proposal_description is not None:
payload["proposalDescription"] = sanitized_proposal_description
if sanitized_captcha is not None:
payload["captcha"] = sanitized_captcha
try:
logger.info("Creating post with title='%s'", sanitized_title)
raw_post = await search_client.create_post(payload, token=sanitized_token)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
status_code = exc.response.status_code
if status_code == 400:
message = (
"Post creation failed due to invalid input or captcha verification errors."
)
elif status_code == 401:
message = "Authentication failed while creating the post. Please verify the token."
elif status_code == 403:
message = "The provided token is not authorized to create posts."
else:
message = (
"OpenIsle backend returned HTTP "
f"{status_code} while creating the post."
)
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 post service: {exc}."
if ctx is not None:
await ctx.error(message)
raise ValueError(message) from exc
try:
post = PostDetail.model_validate(raw_post)
except ValidationError as exc:
message = "Received malformed data from the post creation endpoint."
if ctx is not None:
await ctx.error(message)
raise ValueError(message) from exc
if ctx is not None:
await ctx.info(f"Post '{post.title}' created successfully.")
logger.debug(
"Validated created post payload with id=%s and title='%s'",
post.id,
post.title,
)
return PostCreateResult(post=post)
@app.tool(
name="recent_posts",
description="Retrieve posts created in the last N minutes.",