mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-22 22:21:09 +08:00
Compare commits
30 Commits
codex/fix-
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d98c3644a6 | ||
|
|
dbb63a4039 | ||
|
|
8db928b9a8 | ||
|
|
46f6ccb3a8 | ||
|
|
87dcebf052 | ||
|
|
0ad4f4feff | ||
|
|
a227ac77fb | ||
|
|
ef53a40ed5 | ||
|
|
7d8c9b68bd | ||
|
|
dbc3d54fa1 | ||
|
|
4c0b9e744a | ||
|
|
4b4d1a2a86 | ||
|
|
6990aa93ed | ||
|
|
421b8b6b4f | ||
|
|
e55acc6dc4 | ||
|
|
33ce56aa31 | ||
|
|
339c39c6ca | ||
|
|
389961c922 | ||
|
|
6db53274fb | ||
|
|
a413c0be35 | ||
|
|
06ecd39c8b | ||
|
|
f0ba00b7e8 | ||
|
|
092c4c36c2 | ||
|
|
db13f8145d | ||
|
|
3be396976a | ||
|
|
3fbaa332fc | ||
|
|
4e6cb59753 | ||
|
|
1c6c17e577 | ||
|
|
c968efa42a | ||
|
|
0cd5ded39b |
3
.github/workflows/coffee-bot.yml
vendored
3
.github/workflows/coffee-bot.yml
vendored
@@ -2,7 +2,7 @@ name: Coffee Bot
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 1 * * *"
|
- cron: "0 1 * * 1-5"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -26,4 +26,5 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
|
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
|
||||||
|
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
|
||||||
run: npx tsx bots/instance/coffee_bot.ts
|
run: npx tsx bots/instance/coffee_bot.ts
|
||||||
|
|||||||
30
.github/workflows/open_source_reply_bot.yml
vendored
Normal file
30
.github/workflows/open_source_reply_bot.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Open Source Reply Bot
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "*/30 * * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-open-source-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 open source reply bot
|
||||||
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN_BOT_1 }}
|
||||||
|
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
|
||||||
|
run: npx tsx bots/instance/open_source_reply_bot.ts
|
||||||
1
.github/workflows/reply-bots.yml
vendored
1
.github/workflows/reply-bots.yml
vendored
@@ -26,4 +26,5 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
|
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
|
||||||
|
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
|
||||||
run: npx tsx bots/instance/reply_bot.ts
|
run: npx tsx bots/instance/reply_bot.ts
|
||||||
|
|||||||
@@ -3,40 +3,31 @@ import { Agent, Runner, hostedMcpTool, withTrace } from "@openai/agents";
|
|||||||
export type WorkflowInput = { input_as_text: string };
|
export type WorkflowInput = { input_as_text: string };
|
||||||
|
|
||||||
export abstract class BotFather {
|
export abstract class BotFather {
|
||||||
protected readonly allowedMcpTools = [
|
|
||||||
"search",
|
|
||||||
"create_post",
|
|
||||||
"reply_to_post",
|
|
||||||
"reply_to_comment",
|
|
||||||
"recent_posts",
|
|
||||||
"get_post",
|
|
||||||
"list_unread_messages",
|
|
||||||
"mark_notifications_read",
|
|
||||||
"create_post",
|
|
||||||
];
|
|
||||||
|
|
||||||
protected readonly openisleToken = (process.env.OPENISLE_TOKEN ?? "").trim();
|
protected readonly openisleToken = (process.env.OPENISLE_TOKEN ?? "").trim();
|
||||||
|
protected readonly weatherToken = (process.env.APIFY_API_TOKEN ?? "").trim();
|
||||||
|
|
||||||
protected readonly mcp = this.createHostedMcpTool();
|
protected readonly mcp = this.createHostedMcpTool();
|
||||||
|
protected readonly weatherMcp = this.createWeatherMcpTool();
|
||||||
protected readonly agent: Agent;
|
protected readonly agent: Agent;
|
||||||
|
|
||||||
constructor(protected readonly name: string) {
|
constructor(protected readonly name: string) {
|
||||||
console.log(`✅ ${this.name} starting...`);
|
console.log(`✅ ${this.name} starting...`);
|
||||||
console.log(
|
|
||||||
"🛠️ Configured Hosted MCP tools:",
|
|
||||||
this.allowedMcpTools.join(", ")
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
this.openisleToken
|
this.openisleToken
|
||||||
? "🔑 OPENISLE_TOKEN detected in environment; it will be attached to MCP requests."
|
? "🔑 OPENISLE_TOKEN detected in environment; it will be attached to MCP requests."
|
||||||
: "🔓 OPENISLE_TOKEN not set; authenticated MCP tools may be unavailable."
|
: "🔓 OPENISLE_TOKEN not set; authenticated MCP tools may be unavailable."
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
this.weatherToken
|
||||||
|
? "☁️ APIFY_API_TOKEN detected; weather MCP server will be available."
|
||||||
|
: "🌥️ APIFY_API_TOKEN not set; weather updates will be unavailable."
|
||||||
|
);
|
||||||
|
|
||||||
this.agent = new Agent({
|
this.agent = new Agent({
|
||||||
name: this.name,
|
name: this.name,
|
||||||
instructions: this.buildInstructions(),
|
instructions: this.buildInstructions(),
|
||||||
tools: [this.mcp],
|
tools: [this.mcp, this.weatherMcp],
|
||||||
model: "gpt-4o",
|
model: "gpt-4o",
|
||||||
modelSettings: {
|
modelSettings: {
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
@@ -78,12 +69,35 @@ export abstract class BotFather {
|
|||||||
return hostedMcpTool({
|
return hostedMcpTool({
|
||||||
serverLabel: "openisle_mcp",
|
serverLabel: "openisle_mcp",
|
||||||
serverUrl: "https://www.open-isle.com/mcp",
|
serverUrl: "https://www.open-isle.com/mcp",
|
||||||
allowedTools: this.allowedMcpTools,
|
allowedTools: [
|
||||||
|
"search", // 用于搜索帖子、内容等
|
||||||
|
"create_post", // 创建新帖子
|
||||||
|
"reply_to_post", // 回复帖子
|
||||||
|
"reply_to_comment", // 回复评论
|
||||||
|
"recent_posts", // 获取最新帖子
|
||||||
|
"get_post", // 获取特定帖子的详细信息
|
||||||
|
"list_unread_messages", // 列出未读消息或通知
|
||||||
|
"mark_notifications_read", // 标记通知为已读
|
||||||
|
],
|
||||||
requireApproval: "never",
|
requireApproval: "never",
|
||||||
...authConfig,
|
...authConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createWeatherMcpTool(): ReturnType<typeof hostedMcpTool> {
|
||||||
|
return hostedMcpTool({
|
||||||
|
serverLabel: "weather_mcp_server",
|
||||||
|
serverUrl: "https://jiri-spilka--weather-mcp-server.apify.actor/mcp",
|
||||||
|
requireApproval: "never",
|
||||||
|
allowedTools: [
|
||||||
|
"get_current_weather", // 天气 MCP 工具
|
||||||
|
],
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.weatherToken || ""}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected getAdditionalInstructions(): string[] {
|
protected getAdditionalInstructions(): string[] {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,39 +14,31 @@ class CoffeeBot extends BotFather {
|
|||||||
"正文需亲切友好,简洁明了,鼓励社区成员互动。",
|
"正文需亲切友好,简洁明了,鼓励社区成员互动。",
|
||||||
"开奖说明需明确告知中奖者需私聊站长 @nagisa 领取奖励。",
|
"开奖说明需明确告知中奖者需私聊站长 @nagisa 领取奖励。",
|
||||||
"确保只发布一个帖子,避免重复调用 create_post。",
|
"确保只发布一个帖子,避免重复调用 create_post。",
|
||||||
|
"使用标签为 weather_mcp_server 的 MCP 工具获取北京、上海、广州、深圳当天的天气信息,并把结果写入早安问候之后。",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override getCliQuery(): string {
|
protected override getCliQuery(): string {
|
||||||
const now = new Date();
|
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
|
||||||
const beijingNow = new Date(
|
const weekday = WEEKDAY_NAMES[now.getDay()];
|
||||||
now.toLocaleString("en-US", { timeZone: "Asia/Shanghai" })
|
const drawTime = new Date(now);
|
||||||
);
|
|
||||||
const weekday = WEEKDAY_NAMES[beijingNow.getDay()];
|
|
||||||
|
|
||||||
const drawTime = new Date(beijingNow);
|
|
||||||
drawTime.setHours(15, 0, 0, 0);
|
drawTime.setHours(15, 0, 0, 0);
|
||||||
const drawTimeText = drawTime
|
|
||||||
.toLocaleTimeString("zh-CN", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
timeZone: "Asia/Shanghai",
|
|
||||||
})
|
|
||||||
.replace(/^24:/, "00:");
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
请立即在 https://www.open-isle.com 使用 create_post 发表一篇全新帖子,遵循以下要求:
|
请立即在 https://www.open-isle.com 使用 create_post 发表一篇帖子,遵循以下要求:
|
||||||
1. 标题固定为「大家星期${weekday}早安--抽一杯咖啡」。
|
1. 标题固定为「大家星期${weekday}早安--抽一杯咖啡」。
|
||||||
2. 正文包含:
|
2. 正文包含:
|
||||||
- 亲切的早安问候;
|
- 亲切的早安问候;
|
||||||
- 明确奖品写作“Coffee x 1”;
|
- 早安问候后立即列出北京、上海、广州、深圳当天的天气信息,每行格式为“城市:天气描述,最低温~最高温”;天气需调用 weather_mcp_server 获取;
|
||||||
- 奖品图片链接:https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/0d6a9b33e9ca4fe5a90540187d3f9ecb.png;
|
- 标注“领奖请私聊站长 @[nagisa]”;
|
||||||
- 公布开奖时间为今天下午 15:00(北京时间,写成 ${drawTimeText});
|
|
||||||
- 标注“领奖请私聊站长 @nagisa”;
|
|
||||||
- 鼓励大家留言互动。
|
- 鼓励大家留言互动。
|
||||||
3. 帖子语言使用简体中文,格式可用 Markdown,使关键信息醒目。
|
3. 奖品信息
|
||||||
4. 完成后只输出“已发布咖啡抽奖贴”,不额外生成总结。
|
- 明确奖品写作“Coffee”;
|
||||||
|
- 帖子类型必须为 LOTTERY;
|
||||||
|
- 奖品图片链接:https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/0d6a9b33e9ca4fe5a90540187d3f9ecb.png;
|
||||||
|
- 公布开奖时间为 ${drawTime}, 直接传UTC时间给接口,不要考虑时区问题
|
||||||
|
- categoryId 固定为 10,tagIds 设为 [36]。
|
||||||
|
4. 帖子语言使用简体中文。
|
||||||
`.trim();
|
`.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
bots/instance/open_source_reply_bot.ts
Normal file
65
bots/instance/open_source_reply_bot.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { BotFather, WorkflowInput } from "../bot_father";
|
||||||
|
|
||||||
|
class OpenSourceReplyBot extends BotFather {
|
||||||
|
constructor() {
|
||||||
|
super("OpenSource Reply Bot");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getAdditionalInstructions(): string[] {
|
||||||
|
const knowledgeBase = this.loadKnowledgeBase();
|
||||||
|
|
||||||
|
return [
|
||||||
|
"You are OpenSourceReplyBot, a professional helper who focuses on answering open-source development and code-related questions for the OpenIsle community.",
|
||||||
|
"Respond in Chinese using well-structured Markdown sections such as 标题、列表、代码块等,让回复清晰易读。",
|
||||||
|
"保持语气专业、耐心、详尽,绝不使用表情符号或颜文字,也不要卖萌。",
|
||||||
|
"优先解答与项目代码、贡献流程、架构设计或排错相关的问题;如果消息与此无关,请礼貌说明并跳过。",
|
||||||
|
"在需要时引用 README.md 与 CONTRIBUTING.md 中的要点,帮助用户快速定位文档位置。",
|
||||||
|
knowledgeBase,
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getCliQuery(): string {
|
||||||
|
return `
|
||||||
|
【AUTO】每30分钟自动巡检未读提及与评论,严格遵守以下流程:
|
||||||
|
1)调用 list_unread_messages 获取待处理的“提及/评论”;
|
||||||
|
2)按时间从新到旧逐条处理(最多10条);如需上下文请调用 get_post;
|
||||||
|
3)仅对与开源项目、代码实现或贡献流程直接相关的问题生成详尽的 Markdown 中文回复,
|
||||||
|
若与主题无关则礼貌说明并跳过;
|
||||||
|
4)回复时引用 README 或 CONTRIBUTING 中的要点(如适用),并优先给出可执行的排查步骤或代码建议;
|
||||||
|
5)回复评论使用 reply_to_comment,回复帖子使用 reply_to_post;
|
||||||
|
6)若某通知最后一条已由本 bot 回复,则跳过避免重复;
|
||||||
|
7)整理已处理通知 ID 调用 mark_notifications_read;
|
||||||
|
8)结束时输出包含处理条目概览(URL或ID)的总结。`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadKnowledgeBase(): string {
|
||||||
|
const docs = ["../../README.md", "../../CONTRIBUTING.md"];
|
||||||
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
for (const relativePath of docs) {
|
||||||
|
try {
|
||||||
|
const absolutePath = path.resolve(__dirname, relativePath);
|
||||||
|
const content = readFileSync(absolutePath, "utf-8").trim();
|
||||||
|
if (content) {
|
||||||
|
sections.push(`以下是 ${path.basename(absolutePath)} 的内容:\n${content}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
sections.push(`未能加载 ${relativePath},请检查文件路径或权限。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections.join("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSourceReplyBot = new OpenSourceReplyBot();
|
||||||
|
|
||||||
|
export const runWorkflow = async (workflow: WorkflowInput) => {
|
||||||
|
return openSourceReplyBot.runWorkflow(workflow);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
openSourceReplyBot.runCli();
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ class SearchClient:
|
|||||||
def _build_headers(
|
def _build_headers(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
token: str | None = None,
|
token: str,
|
||||||
accept: str = "application/json",
|
accept: str = "application/json",
|
||||||
include_json: bool = False,
|
include_json: bool = False,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -111,9 +111,8 @@ class SearchClient:
|
|||||||
async def reply_to_comment(
|
async def reply_to_comment(
|
||||||
self,
|
self,
|
||||||
comment_id: int,
|
comment_id: int,
|
||||||
|
token: str,
|
||||||
content: str,
|
content: str,
|
||||||
*,
|
|
||||||
token: str | None = None,
|
|
||||||
captcha: str | None = None,
|
captcha: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Reply to an existing comment and return the created reply."""
|
"""Reply to an existing comment and return the created reply."""
|
||||||
@@ -145,9 +144,8 @@ class SearchClient:
|
|||||||
async def reply_to_post(
|
async def reply_to_post(
|
||||||
self,
|
self,
|
||||||
post_id: int,
|
post_id: int,
|
||||||
|
token: str,
|
||||||
content: str,
|
content: str,
|
||||||
*,
|
|
||||||
token: str | None = None,
|
|
||||||
captcha: str | None = None,
|
captcha: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a comment on a post and return the backend payload."""
|
"""Create a comment on a post and return the backend payload."""
|
||||||
@@ -180,7 +178,7 @@ class SearchClient:
|
|||||||
self,
|
self,
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
token: str | None = None,
|
token: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a new post and return the detailed backend payload."""
|
"""Create a new post and return the detailed backend payload."""
|
||||||
|
|
||||||
@@ -200,7 +198,7 @@ class SearchClient:
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
body = self._ensure_dict(response.json())
|
body = self._ensure_dict(response.json())
|
||||||
logger.info("Post creation succeeded with id=%s", body.get("id"))
|
logger.info("Post creation succeeded with id=%s, token=%s", body.get("id"), token)
|
||||||
return body
|
return body
|
||||||
|
|
||||||
async def recent_posts(self, minutes: int) -> list[dict[str, Any]]:
|
async def recent_posts(self, minutes: int) -> list[dict[str, Any]]:
|
||||||
@@ -251,7 +249,7 @@ class SearchClient:
|
|||||||
*,
|
*,
|
||||||
page: int = 0,
|
page: int = 0,
|
||||||
size: int = 30,
|
size: int = 30,
|
||||||
token: str | None = None,
|
token: str,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return unread notifications for the authenticated user."""
|
"""Return unread notifications for the authenticated user."""
|
||||||
|
|
||||||
@@ -287,7 +285,7 @@ class SearchClient:
|
|||||||
self,
|
self,
|
||||||
ids: list[int],
|
ids: list[int],
|
||||||
*,
|
*,
|
||||||
token: str | None = None,
|
token: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mark the provided notifications as read for the authenticated user."""
|
"""Mark the provided notifications as read for the authenticated user."""
|
||||||
|
|
||||||
|
|||||||
@@ -509,6 +509,8 @@ async def create_post(
|
|||||||
raise ValueError("Category identifier must be an integer.") from exc
|
raise ValueError("Category identifier must be an integer.") from exc
|
||||||
if sanitized_category_id <= 0:
|
if sanitized_category_id <= 0:
|
||||||
raise ValueError("Category identifier must be a positive integer.")
|
raise ValueError("Category identifier must be a positive integer.")
|
||||||
|
if sanitized_category_id is None:
|
||||||
|
raise ValueError("A category identifier is required to create a post.")
|
||||||
|
|
||||||
sanitized_tag_ids: list[int] | None = None
|
sanitized_tag_ids: list[int] | None = None
|
||||||
if tag_ids is not None:
|
if tag_ids is not None:
|
||||||
@@ -525,6 +527,10 @@ async def create_post(
|
|||||||
sanitized_tag_ids.append(converted)
|
sanitized_tag_ids.append(converted)
|
||||||
if not sanitized_tag_ids:
|
if not sanitized_tag_ids:
|
||||||
sanitized_tag_ids = None
|
sanitized_tag_ids = None
|
||||||
|
if not sanitized_tag_ids:
|
||||||
|
raise ValueError("At least one tag identifier is required to create a post.")
|
||||||
|
if len(sanitized_tag_ids) > 2:
|
||||||
|
raise ValueError("At most two tag identifiers can be provided for a post.")
|
||||||
|
|
||||||
sanitized_post_type = post_type.strip() if isinstance(post_type, str) else None
|
sanitized_post_type = post_type.strip() if isinstance(post_type, str) else None
|
||||||
if sanitized_post_type == "":
|
if sanitized_post_type == "":
|
||||||
|
|||||||
Reference in New Issue
Block a user