Compare commits

...

16 Commits

Author SHA1 Message Date
Tim
7a2cf829c7 Fix search client reply argument order 2025-10-28 16:32:53 +08:00
Tim
12329b43d1 Merge pull request #1113 from nagisa77/codex/pass-bearer-token-to-backend-apis
feat: forward Authorization header for MCP backend requests
2025-10-28 16:22:17 +08:00
Tim
1a45603e0f feat: forward authorization headers to backend 2025-10-28 16:22:04 +08:00
Tim
4a73503399 Merge pull request #1112 from nagisa77/codex/fix-authorization-parameter-error
Fix hosted MCP authorization configuration
2025-10-28 16:00:07 +08:00
Tim
83bf8c1d5e Fix hosted MCP auth header collision 2025-10-28 15:59:57 +08:00
Tim
34e206f05d Merge pull request #1111 from nagisa77/codex/refactor-getbaseinstructions-to-remove-openisletoken
Inject OpenIsle token into MCP requests
2025-10-28 15:57:04 +08:00
Tim
dc349923e9 Inject OpenIsle token into MCP requests 2025-10-28 15:56:51 +08:00
Tim
0d44c9a823 Merge pull request #1110 from nagisa77/codex/update-coffee-bot-prize-image
Update coffee bot prize image instructions
2025-10-28 15:21:23 +08:00
Tim
02645af321 Update coffee bot prize image instructions 2025-10-28 15:21:03 +08:00
Tim
c3a175f13f Merge pull request #1109 from nagisa77/codex/create-coffee-bot-for-lottery-post
Add coffee bot lottery poster and schedule
2025-10-28 15:14:51 +08:00
Tim
0821d447f7 Add coffee bot lottery poster and schedule 2025-10-28 15:14:37 +08:00
Tim
257794ca00 feat: bot father 允许创建帖子 2025-10-28 15:12:53 +08:00
Tim
6a527de3eb Merge pull request #1108 from nagisa77/codex/update-getadditionalinstructions-for-reply_bot
Enhance reply bot persona with site background
2025-10-28 15:07:27 +08:00
Tim
2313f90eb3 Enhance reply bot persona with site background 2025-10-28 15:07:14 +08:00
Tim
7fde984e7d Merge pull request #1107 from nagisa77/codex/add-posting-tool-support-for-mcp-service
Add MCP tool for creating posts
2025-10-28 15:06:09 +08:00
Tim
042e5fdbe6 fix: make a bot father 2025-10-28 15:01:33 +08:00
6 changed files with 202 additions and 112 deletions

29
.github/workflows/coffee-bot.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Coffee Bot
on:
schedule:
- cron: "0 1 * * *"
workflow_dispatch:
jobs:
run-coffee-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 coffee bot
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
run: npx tsx bots/instance/coffee_bot.ts

View File

@@ -5,22 +5,19 @@ export type WorkflowInput = { input_as_text: string };
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 mcp = hostedMcpTool({
serverLabel: "openisle_mcp",
serverUrl: "https://www.open-isle.com/mcp",
allowedTools: this.allowedMcpTools,
requireApproval: "never",
});
protected readonly openisleToken = (process.env.OPENISLE_TOKEN ?? "").trim();
protected readonly openisleToken = process.env.OPENISLE_TOKEN ?? "";
protected readonly mcp = this.createHostedMcpTool();
protected readonly agent: Agent;
constructor(protected readonly name: string) {
@@ -32,8 +29,8 @@ export abstract class BotFather {
console.log(
this.openisleToken
? "🔑 OPENISLE_TOKEN detected in environment."
: "🔓 OPENISLE_TOKEN not set; agent will request it if required."
? "🔑 OPENISLE_TOKEN detected in environment; it will be attached to MCP requests."
: "🔓 OPENISLE_TOKEN not set; authenticated MCP tools may be unavailable."
);
this.agent = new Agent({
@@ -64,13 +61,29 @@ export abstract class BotFather {
"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.",
];
}
private createHostedMcpTool() {
const token = this.openisleToken;
const authConfig = token
? {
headers: {
Authorization: `Bearer ${token}`,
},
}
: {};
return hostedMcpTool({
serverLabel: "openisle_mcp",
serverUrl: "https://www.open-isle.com/mcp",
allowedTools: this.allowedMcpTools,
requireApproval: "never",
...authConfig,
});
}
protected getAdditionalInstructions(): string[] {
return [];
}

View File

@@ -0,0 +1,63 @@
import { BotFather, WorkflowInput } from "../bot_father";
const WEEKDAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"] as const;
class CoffeeBot extends BotFather {
constructor() {
super("Coffee Bot");
}
protected override getAdditionalInstructions(): string[] {
return [
"You are responsible for 发布每日抽奖早安贴。",
"创建帖子时,确保标题、奖品信息、开奖时间以及领奖方式完全符合 CLI 查询提供的细节。",
"正文需亲切友好,简洁明了,鼓励社区成员互动。",
"开奖说明需明确告知中奖者需私聊站长 @nagisa 领取奖励。",
"确保只发布一个帖子,避免重复调用 create_post。",
];
}
protected override getCliQuery(): string {
const now = new Date();
const beijingNow = new Date(
now.toLocaleString("en-US", { timeZone: "Asia/Shanghai" })
);
const weekday = WEEKDAY_NAMES[beijingNow.getDay()];
const drawTime = new Date(beijingNow);
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 `
请立即在 https://www.open-isle.com 使用 create_post 发表一篇全新帖子,遵循以下要求:
1. 标题固定为「大家星期${weekday}早安--抽一杯咖啡」。
2. 正文包含:
- 亲切的早安问候;
- 明确奖品写作“Coffee x 1”
- 奖品图片链接https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/0d6a9b33e9ca4fe5a90540187d3f9ecb.png
- 公布开奖时间为今天下午 15:00北京时间写成 ${drawTimeText}
- 标注“领奖请私聊站长 @nagisa”
- 鼓励大家留言互动。
3. 帖子语言使用简体中文,格式可用 Markdown使关键信息醒目。
4. 完成后只输出“已发布咖啡抽奖贴”,不额外生成总结。
`.trim();
}
}
const coffeeBot = new CoffeeBot();
export const runWorkflow = async (workflow: WorkflowInput) => {
return coffeeBot.runWorkflow(workflow);
};
if (require.main === module) {
coffeeBot.runCli();
}

View File

@@ -1,5 +1,5 @@
// reply_bot.ts
import { BotFather, WorkflowInput } from "./bot_father";
import { BotFather, WorkflowInput } from "../bot_father";
class ReplyBot extends BotFather {
constructor() {
@@ -8,8 +8,10 @@ class ReplyBot extends BotFather {
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.",
"You are a helpful and cute assistant for https://www.open-isle.com. Keep the lovable tone with plentiful kawaii kaomoji (颜表情) such as (๑˃ᴗ˂)ﻭ, (•̀ω•́)✧, (。•ᴗ-)_♡, (⁎⁍̴̛ᴗ⁍̴̛⁎), etc., while staying professional and informative.",
"OpenIsle 是一个由 Spring Boot + Vue 3 打造的开源社区平台提供注册登录、OAuth 登录Google/GitHub/Discord/Twitter、帖子与评论互动、标签分类、草稿、统计分析、通知消息、全局搜索、Markdown 支持、图片上传(默认腾讯云 COS、浏览器推送、DiceBear 头像等功能,旨在帮助团队快速搭建属于自己的技术社区。",
"回复时请主动结合上述站点背景,为用户提供有洞察力、可执行的建议或答案,并在需要时引用官网 https://www.open-isle.com、GitHub 仓库 https://github.com/nagisa77/OpenIsle 或相关文档链接,避免空泛的安慰或套话。",
"When presenting the result, reply in Chinese with a concise yet content-rich summary filled with kaomoji,并清晰列出关键结论、操作步骤、重要 URL 或 ID确保用户能直接采取行动。",
];
}

View File

@@ -66,7 +66,8 @@ class SearchClient:
resolved = self._resolve_token(token)
if resolved is None:
raise ValueError(
"Authenticated request requires an access token but none was provided."
"Authenticated request requires an access token. Provide a Bearer token "
"via the MCP Authorization header or configure a default token for the server."
)
return resolved
@@ -110,8 +111,9 @@ class SearchClient:
async def reply_to_comment(
self,
comment_id: int,
token: str,
content: str,
*,
token: str | None = None,
captcha: str | None = None,
) -> dict[str, Any]:
"""Reply to an existing comment and return the created reply."""
@@ -143,8 +145,9 @@ class SearchClient:
async def reply_to_post(
self,
post_id: int,
token: str,
content: str,
*,
token: str | None = None,
captcha: str | None = None,
) -> dict[str, Any]:
"""Create a comment on a post and return the backend payload."""

View File

@@ -52,6 +52,37 @@ search_client = SearchClient(
)
def _extract_authorization_token(ctx: Context | None) -> str | None:
"""Return the Bearer token from the incoming MCP request headers."""
if ctx is None:
return None
try:
request_context = ctx.request_context
except ValueError:
return None
request = getattr(request_context, "request", None)
if request is None:
return None
headers = getattr(request, "headers", None)
if headers is None:
return None
authorization = headers.get("authorization")
if not authorization:
return None
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer":
return None
stripped = token.strip()
return stripped or None
@asynccontextmanager
async def lifespan(_: FastMCP):
"""Lifecycle hook that disposes shared resources when the server stops."""
@@ -68,8 +99,9 @@ app = FastMCP(
name="openisle-mcp",
instructions=(
"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."
"comments using the Authorization header or configured access 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,
@@ -130,7 +162,10 @@ async def search(
@app.tool(
name="reply_to_post",
description="Create a comment on a post using an authentication token.",
description=(
"Create a comment on a post using the request Authorization header or the configured "
"access token."
),
structured_output=True,
)
async def reply_to_post(
@@ -149,15 +184,6 @@ async def reply_to_post(
description="Optional captcha solution if the backend requires it.",
),
] = 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,
) -> CommentCreateResult:
"""Create a comment on a post and return the backend payload."""
@@ -166,12 +192,10 @@ async def reply_to_post(
if not sanitized_content:
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
request_token = _extract_authorization_token(ctx)
try:
logger.info(
"Creating reply for post_id=%s (captcha=%s)",
@@ -180,20 +204,20 @@ async def reply_to_post(
)
raw_comment = await search_client.reply_to_post(
post_id,
sanitized_token,
sanitized_content,
sanitized_captcha,
token=request_token,
content=sanitized_content,
captcha=sanitized_captcha,
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
status_code = exc.response.status_code
if status_code == 401:
message = (
"Authentication failed while replying to post "
f"{post_id}. Please verify the token."
f"{post_id}. Please verify the Authorization header or configured token."
)
elif status_code == 403:
message = (
"The provided token is not authorized to reply to post "
"The provided Authorization token is not authorized to reply to post "
f"{post_id}."
)
elif status_code == 404:
@@ -239,7 +263,10 @@ async def reply_to_post(
@app.tool(
name="reply_to_comment",
description="Reply to an existing comment using an authentication token.",
description=(
"Reply to an existing comment using the request Authorization header or the configured "
"access token."
),
structured_output=True,
)
async def reply_to_comment(
@@ -258,15 +285,6 @@ async def reply_to_comment(
description="Optional captcha solution if the backend requires it.",
),
] = 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,
) -> CommentReplyResult:
"""Create a reply for a comment and return the backend payload."""
@@ -275,10 +293,10 @@ async def reply_to_comment(
if not sanitized_content:
raise ValueError("Reply content must not be empty.")
sanitized_token = token.strip() if isinstance(token, str) else None
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
request_token = _extract_authorization_token(ctx)
try:
logger.info(
"Creating reply for comment_id=%s (captcha=%s)",
@@ -287,20 +305,20 @@ async def reply_to_comment(
)
raw_comment = await search_client.reply_to_comment(
comment_id,
sanitized_token,
sanitized_content,
sanitized_captcha,
token=request_token,
content=sanitized_content,
captcha=sanitized_captcha,
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
status_code = exc.response.status_code
if status_code == 401:
message = (
"Authentication failed while replying to comment "
f"{comment_id}. Please verify the token."
f"{comment_id}. Please verify the Authorization header or configured token."
)
elif status_code == 403:
message = (
"The provided token is not authorized to reply to comment "
"The provided Authorization token is not authorized to reply to comment "
f"{comment_id}."
)
else:
@@ -344,7 +362,10 @@ async def reply_to_comment(
@app.tool(
name="create_post",
description="Publish a new post using an authentication token.",
description=(
"Publish a new post using the request Authorization header or the configured access "
"token."
),
structured_output=True,
)
async def create_post(
@@ -466,15 +487,6 @@ async def create_post(
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."""
@@ -487,10 +499,6 @@ async def create_post(
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):
@@ -635,7 +643,7 @@ async def create_post(
try:
logger.info("Creating post with title='%s'", sanitized_title)
raw_post = await search_client.create_post(payload, token=sanitized_token)
raw_post = await search_client.create_post(payload, token=_extract_authorization_token(ctx))
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
status_code = exc.response.status_code
if status_code == 400:
@@ -643,9 +651,12 @@ async def create_post(
"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."
message = (
"Authentication failed while creating the post. Please verify the "
"Authorization header or configured token."
)
elif status_code == 403:
message = "The provided token is not authorized to create posts."
message = "The provided Authorization token is not authorized to create posts."
else:
message = (
"OpenIsle backend returned HTTP "
@@ -741,24 +752,15 @@ async def get_post(
int,
PydanticField(ge=1, description="Identifier of the post to retrieve."),
],
token: Annotated[
str | None,
PydanticField(
default=None,
description="Optional JWT bearer token to view the post as an authenticated user.",
),
] = None,
ctx: Context | None = None,
) -> PostDetail:
"""Fetch post details from the backend and validate the response."""
sanitized_token = token.strip() if isinstance(token, str) else None
if sanitized_token == "":
sanitized_token = None
try:
logger.info("Fetching post details for post_id=%s", post_id)
raw_post = await search_client.get_post(post_id, sanitized_token)
raw_post = await search_client.get_post(
post_id, _extract_authorization_token(ctx)
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
status_code = exc.response.status_code
if status_code == 404:
@@ -766,7 +768,7 @@ async def get_post(
elif status_code == 401:
message = "Authentication failed while retrieving the post."
elif status_code == 403:
message = "The provided token is not authorized to view this post."
message = "The provided Authorization token is not authorized to view this post."
else:
message = (
"OpenIsle backend returned HTTP "
@@ -823,21 +825,10 @@ async def list_unread_messages(
description="Number of unread notifications to include per page.",
),
] = 30,
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,
) -> UnreadNotificationsResponse:
"""Retrieve unread notifications and return structured data."""
sanitized_token = token.strip() if isinstance(token, str) else None
try:
logger.info(
"Fetching unread notifications (page=%s, size=%s)",
@@ -847,7 +838,7 @@ async def list_unread_messages(
raw_notifications = await search_client.list_unread_notifications(
page=page,
size=size,
token=sanitized_token,
token=_extract_authorization_token(ctx),
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
message = (
@@ -906,29 +897,18 @@ async def mark_notifications_read(
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)
await search_client.mark_notifications_read(
ids, token=_extract_authorization_token(ctx)
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
message = (
"OpenIsle backend returned HTTP "