Compare commits

..

84 Commits

Author SHA1 Message Date
Tim
5fe3eec815 Merge pull request #1124 from nagisa77/codex/create-daily-news-bot-with-summaries
Add daily news bot workflow
2025-10-29 18:01:21 +08:00
Tim
f0feb7a45c Add daily news bot workflow 2025-10-29 18:01:08 +08:00
Tim
784057207f feat: add websearch tools 2025-10-29 17:54:06 +08:00
Tim
bed72662b5 Update reply_bot.ts 2025-10-29 14:31:07 +08:00
Tim
895dba495b Update reply_bot.ts 2025-10-29 14:30:14 +08:00
Tim
32dc6bfaf9 Update reply_bot.ts 2025-10-29 14:25:59 +08:00
Tim
4766250577 Update reply_bot.ts 2025-10-29 14:21:27 +08:00
Tim
13baffa9f1 Merge pull request #1123 from nagisa77/codex/rename-coffee-and-reply-bots-to-system
Adjust coffee bot schedule and update bot personas
2025-10-29 14:20:40 +08:00
Tim
d0d7580ac3 Adjust bot identities and coffee bot schedule 2025-10-29 14:20:29 +08:00
Tim
fd4e651a49 Merge pull request #1122 from nagisa77/codex/update-readme.md-with-bot-integration-details
docs: highlight bot integration in README
2025-10-29 13:15:17 +08:00
Tim
58317687d7 docs: update README bot info 2025-10-29 13:15:06 +08:00
tim
006e46f4ef Revert "fix: prompt 修改"
This reverts commit 2c27766544.
2025-10-28 21:56:49 +08:00
Tim
2c27766544 fix: prompt 修改 2025-10-28 20:48:37 +08:00
Tim
c305992223 fix: prompt 修改 2025-10-28 20:25:07 +08:00
Tim
babd2c6549 Merge pull request #1121 from nagisa77/codex/add-opensourcereplybot-with-detailed-responses
feat(bot): add open source reply bot
2025-10-28 19:56:03 +08:00
Tim
d98c3644a6 fix: 添加GitHub action 2025-10-28 19:55:29 +08:00
Tim
dbb63a4039 feat(bot): add open source reply bot 2025-10-28 19:53:07 +08:00
Tim
49aeff3a83 Merge pull request #1120 from nagisa77/codex/add-is_bot-field-to-user-table
Add bot flag to user model and show comment badge
2025-10-28 19:49:44 +08:00
Tim
512e5623e1 Add bot flag to users and surface in comments 2025-10-28 19:49:33 +08:00
Tim
8db928b9a8 Merge pull request #1119 from nagisa77/codex/update-lottery-time-from-23-to-15
Adjust coffee bot draw schedule
2025-10-28 18:58:05 +08:00
Tim
46f6ccb3a8 Adjust coffee draw schedule 2025-10-28 18:57:53 +08:00
Tim
87dcebf052 fix: tools 重写 2025-10-28 18:50:34 +08:00
Tim
0ad4f4feff fix: fix tools 2025-10-28 18:48:32 +08:00
Tim
a227ac77fb Merge pull request #1118 from nagisa77/codex/add-daily-weather-lookup-for-cities
fix: 透传token
2025-10-28 18:45:30 +08:00
Tim
ef53a40ed5 fix: 透传token 2025-10-28 18:44:43 +08:00
Tim
7d8c9b68bd Merge pull request #1117 from nagisa77/codex/add-daily-weather-lookup-for-cities
Integrate weather MCP into Coffee Bot
2025-10-28 18:43:15 +08:00
Tim
dbc3d54fa1 fix: should add weather mcp 2025-10-28 18:42:47 +08:00
Tim
4c0b9e744a feat: weather mcp 2025-10-28 18:39:58 +08:00
Tim
4b4d1a2a86 Merge branch 'main' into codex/add-daily-weather-lookup-for-cities
# Conflicts:
#	bots/instance/coffee_bot.ts
2025-10-28 18:39:44 +08:00
Tim
6990aa93ed Integrate weather MCP into Coffee Bot 2025-10-28 18:32:42 +08:00
Tim
421b8b6b4f fix: prompt 修改 2025-10-28 18:08:19 +08:00
Tim
e55acc6dc4 fix: 解决时区问题 2025-10-28 18:01:03 +08:00
Tim
33ce56aa31 fix: 解决时区问题 2025-10-28 17:58:13 +08:00
Tim
339c39c6ca fix: 时区设置 2025-10-28 17:55:36 +08:00
Tim
389961c922 fix: 修正prompt 2025-10-28 17:47:00 +08:00
Tim
6db53274fb fix: 修正日期 2025-10-28 17:44:14 +08:00
Tim
a413c0be35 fix: 修正语法 2025-10-28 17:35:01 +08:00
Tim
06ecd39c8b Merge branch 'main' of github.com:nagisa77/OpenIsle
# Conflicts:
#	bots/instance/coffee_bot.ts
2025-10-28 17:34:47 +08:00
Tim
f0ba00b7e8 fix: 修正抽奖贴问题 2025-10-28 17:33:23 +08:00
Tim
092c4c36c2 Merge pull request #1116 from nagisa77/codex/create-post-for-coffee-bot-with-categories-and-tags
Update coffee bot post metadata instructions
2025-10-28 17:27:52 +08:00
Tim
db13f8145d Update coffee bot post metadata instructions 2025-10-28 17:27:37 +08:00
Tim
3be396976a Merge pull request #1115 from nagisa77/codex/fix-coffee-bot-mcp-service-error
Validate MCP post creation inputs
2025-10-28 17:23:21 +08:00
Tim
3fbaa332fc Validate required fields for MCP post creation 2025-10-28 17:22:58 +08:00
Tim
4e6cb59753 fix: 修正语法问题 2025-10-28 16:49:44 +08:00
Tim
1c6c17e577 fix: 修正语法问题 2025-10-28 16:47:00 +08:00
Tim
c968efa42a Revert "Fix search client reply argument order"
This reverts commit 7a2cf829c7.
2025-10-28 16:38:53 +08:00
Tim
0cd5ded39b Merge pull request #1114 from nagisa77/codex/fix-syntaxerror-in-search_client.py
Fix reply methods argument order in MCP search client
2025-10-28 16:33:05 +08:00
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
fc41e605e4 Add MCP tool for creating posts 2025-10-28 15:05:55 +08:00
Tim
042e5fdbe6 fix: make a bot father 2025-10-28 15:01:33 +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
Tim
26bb85f4d4 Add MCP support for clearing read notifications 2025-10-28 13:50:16 +08:00
tim
398b4b482f fix: prompt 完善 2025-10-28 13:00:42 +08:00
tim
2cfb302981 fix: add bot 2025-10-28 12:37:17 +08:00
Tim
e75bd76b71 Merge pull request #1101 from nagisa77/codex/fix-unread-notifications-data-format
Normalize null list payloads in notification schemas
2025-10-28 10:27:54 +08:00
Tim
99c3ac1837 Handle null list fields in notification schemas 2025-10-28 10:27:40 +08:00
tim
749ab560ff Revert "Cache MCP session JWT tokens"
This reverts commit 997dacdbe6.
2025-10-28 01:55:46 +08:00
tim
541ad4d149 Revert "Remove token parameters from MCP tools"
This reverts commit e585100625.
2025-10-28 01:55:41 +08:00
tim
03eb027ea4 Revert "Add MCP tool for setting session token"
This reverts commit 9dadaad5ba.
2025-10-28 01:55:36 +08:00
Tim
4194b2be91 Merge pull request #1100 from nagisa77/codex/add-initialization-tool-for-jwt-token
Add MCP tool for initializing session JWT tokens
2025-10-28 01:47:28 +08:00
Tim
9dadaad5ba Add MCP tool for setting session token 2025-10-28 01:47:16 +08:00
Tim
d4b3400c5f Merge pull request #1099 from nagisa77/codex/remove-token-parameters-from-mcp-api
Remove explicit token parameters from MCP tools
2025-10-28 01:32:18 +08:00
25 changed files with 1133 additions and 91 deletions

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

@@ -0,0 +1,30 @@
name: Coffee Bot
on:
schedule:
- cron: "0 23 * * 0-4"
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 }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/coffee_bot.ts

View 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

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

@@ -0,0 +1,30 @@
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 }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/reply_bot.ts

View File

@@ -26,7 +26,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
- 集成 OpenAI 提供的 Markdown 格式化功能
- 通过环境变量可调整密码强度、登录方式、保护码等多种配置
- 支持图片上传,默认使用腾讯云 COS 扩展
- 默认头像使用 DiceBear Avatars可通过 `AVATAR_STYLE``AVATAR_SIZE` 环境变量自定义主题和大小
- Bot 集成,可在平台内快速连接自定义机器人,并通过 Telegram 的 BotFather 创建和管理消息机器人,拓展社区互动渠道
- 浏览器推送通知,离开网站也能及时收到提醒
## 🌟 项目优势
@@ -41,7 +41,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
## 🏘️ 社区
欢迎彼此交流和使用 OpenIsle项目以开源方式提供,想了解更多可访问:<https://github.com/nagisa77/OpenIsle>
- 欢迎彼此交流和使用 OpenIsle项目以开源方式提供;如果遇到问题请到 GitHub 的 Issues 页面反馈,想发起话题讨论也可以前往源站 <https://www.open-isle.com>,这里提供更完整的社区板块与互动体验。
## 📋 授权

View File

@@ -13,4 +13,5 @@ public class AuthorDto {
private String username;
private String avatar;
private MedalType displayMedal;
private boolean bot;
}

View File

@@ -28,4 +28,5 @@ public class UserDto {
private int point;
private int currentLevel;
private int nextLevelExp;
private boolean bot;
}

View File

@@ -8,4 +8,5 @@ public class UserSummaryDto {
private Long id;
private String username;
private String avatar;
private boolean bot;
}

View File

@@ -37,6 +37,7 @@ public class UserMapper {
dto.setUsername(user.getUsername());
dto.setAvatar(user.getAvatar());
dto.setDisplayMedal(user.getDisplayMedal());
dto.setBot(user.isBot());
return dto;
}
@@ -63,6 +64,7 @@ public class UserMapper {
dto.setPoint(user.getPoint());
dto.setCurrentLevel(levelService.getLevel(user.getExperience()));
dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience()));
dto.setBot(user.isBot());
if (viewer != null) {
dto.setSubscribed(subscriptionService.isSubscribed(viewer.getName(), user.getUsername()));
} else {

View File

@@ -62,6 +62,9 @@ public class User {
@Column(nullable = false)
private Role role = Role.USER;
@Column(name = "is_bot", nullable = false)
private boolean bot = false;
@Enumerated(EnumType.STRING)
private MedalType displayMedal;

View File

@@ -105,6 +105,7 @@ public class ChannelService {
userDto.setId(message.getSender().getId());
userDto.setUsername(message.getSender().getUsername());
userDto.setAvatar(message.getSender().getAvatar());
userDto.setBot(message.getSender().isBot());
dto.setSender(userDto);
return dto;

View File

@@ -211,6 +211,7 @@ public class MessageService {
userSummaryDto.setId(message.getSender().getId());
userSummaryDto.setUsername(message.getSender().getUsername());
userSummaryDto.setAvatar(message.getSender().getAvatar());
userSummaryDto.setBot(message.getSender().isBot());
dto.setSender(userSummaryDto);
if (message.getReplyTo() != null) {
@@ -222,6 +223,7 @@ public class MessageService {
replySender.setId(reply.getSender().getId());
replySender.setUsername(reply.getSender().getUsername());
replySender.setAvatar(reply.getSender().getAvatar());
replySender.setBot(reply.getSender().isBot());
replyDto.setSender(replySender);
dto.setReplyTo(replyDto);
}
@@ -316,6 +318,7 @@ public class MessageService {
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
userDto.setBot(p.getUser().isBot());
return userDto;
})
.collect(Collectors.toList())
@@ -365,6 +368,7 @@ public class MessageService {
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
userDto.setBot(p.getUser().isBot());
return userDto;
})
.collect(Collectors.toList());

View File

@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`username` varchar(50) NOT NULL,
`verification_code` varchar(255) DEFAULT NULL,
`verified` bit(1) DEFAULT NULL,
`is_bot` bit(1) NOT NULL DEFAULT b'0',
PRIMARY KEY (`id`),
UNIQUE KEY `UK_users_email` (`email`),
UNIQUE KEY `UK_users_username` (`username`)

View File

@@ -8,10 +8,28 @@ DELETE FROM `users`;
-- 插入用户,两个普通用户,一个管理员
-- username:admin/user1/user2 password:123456
INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES
(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1'),
(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1'),
(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1');
INSERT INTO `users` (
`id`,
`approved`,
`avatar`,
`created_at`,
`display_medal`,
`email`,
`experience`,
`introduction`,
`password`,
`password_reset_code`,
`point`,
`register_reason`,
`role`,
`username`,
`verification_code`,
`verified`,
`is_bot`
) VALUES
(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1', b'0'),
(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1', b'0'),
(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1', b'0');
INSERT INTO `categories` (`id`,`description`,`icon`,`name`,`small_icon`) VALUES
(1,'测试用分类1','star','测试用分类1',NULL),

View File

@@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN is_bot BIT(1) NOT NULL DEFAULT b'0';

182
bots/bot_father.ts Normal file
View File

@@ -0,0 +1,182 @@
import { Agent, Runner, hostedMcpTool, withTrace, webSearchTool } from "@openai/agents";
export type WorkflowInput = { input_as_text: string };
export abstract class BotFather {
protected readonly openisleToken = (process.env.OPENISLE_TOKEN ?? "").trim();
protected readonly weatherToken = (process.env.APIFY_API_TOKEN ?? "").trim();
protected readonly openisleMcp = this.createHostedMcpTool();
protected readonly weatherMcp = this.createWeatherMcpTool();
protected readonly webSearchPreview = this.createWebSearchPreviewTool();
protected readonly agent: Agent;
constructor(protected readonly name: string) {
console.log(`${this.name} starting...`);
console.log(
this.openisleToken
? "🔑 OPENISLE_TOKEN detected in environment; it will be attached to MCP requests."
: "🔓 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({
name: this.name,
instructions: this.buildInstructions(),
tools: [
this.openisleMcp,
this.weatherMcp,
this.webSearchPreview
],
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.",
"After finishing replies, call mark_notifications_read with all processed notification IDs to keep the inbox clean.",
];
}
private createWebSearchPreviewTool() {
return webSearchTool({
userLocation: {
type: "approximate",
country: undefined,
region: undefined,
city: undefined,
timezone: undefined
},
searchContextSize: "medium"
})
}
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: [
"search", // 用于搜索帖子、内容等
"create_post", // 创建新帖子
"reply_to_post", // 回复帖子
"reply_to_comment", // 回复评论
"recent_posts", // 获取最新帖子
"get_post", // 获取特定帖子的详细信息
"list_unread_messages", // 列出未读消息或通知
"mark_notifications_read", // 标记通知为已读
],
requireApproval: "never",
...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[] {
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,56 @@
import { BotFather, WorkflowInput } from "../bot_father";
const WEEKDAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"] as const;
class CoffeeBot extends BotFather {
constructor() {
super("Coffee Bot");
}
protected override getAdditionalInstructions(): string[] {
return [
"记住你的系统代号是 system有需要自称或签名时都要使用这个名字。",
"You are responsible for 发布每日抽奖早安贴。",
"创建帖子时,确保标题、奖品信息、开奖时间以及领奖方式完全符合 CLI 查询提供的细节。",
"正文需亲切友好,简洁明了,鼓励社区成员互动。",
"开奖说明需明确告知中奖者需私聊站长 @nagisa 领取奖励。",
"确保只发布一个帖子,避免重复调用 create_post。",
"使用标签为 weather_mcp_server 的 MCP 工具获取北京、上海、广州、深圳当天的天气信息,并把结果写入早安问候之后。",
];
}
protected override getCliQuery(): string {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
const weekday = WEEKDAY_NAMES[now.getDay()];
const drawTime = new Date(now);
drawTime.setHours(15, 0, 0, 0);
return `
请立即在 https://www.open-isle.com 使用 create_post 发表一篇帖子,遵循以下要求:
1. 标题固定为「大家星期${weekday}早安--抽一杯咖啡」。
2. 正文包含:
- 亲切的早安问候;
- 早安问候后立即列出北京、上海、广州、深圳当天的天气信息,每行格式为“城市:天气描述,最低温~最高温”;天气需调用 weather_mcp_server 获取;
- 标注“领奖请私聊站长 @[nagisa]”;
- 鼓励大家留言互动。
3. 奖品信息
- 明确奖品写作“Coffee”
- 帖子类型必须为 LOTTERY
- 奖品图片链接https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/0d6a9b33e9ca4fe5a90540187d3f9ecb.png
- 公布开奖时间为 ${drawTime}, 直接传UTC时间给接口不要考虑时区问题
- categoryId 固定为 10tagIds 设为 [36]。
4. 帖子语言使用简体中文。
`.trim();
}
}
const coffeeBot = new CoffeeBot();
export const runWorkflow = async (workflow: WorkflowInput) => {
return coffeeBot.runWorkflow(workflow);
};
if (require.main === module) {
coffeeBot.runCli();
}

View File

@@ -0,0 +1,67 @@
import { BotFather, WorkflowInput } from "../bot_father";
const WEEKDAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"] as const;
class DailyNewsBot extends BotFather {
constructor() {
super("Daily News Bot");
}
protected override getAdditionalInstructions(): string[] {
return [
"You are DailyNewsBot专职在 OpenIsle 发布每日新闻速递。",
"始终使用简体中文回复,并以结构化 Markdown 呈现内容。",
"发布内容前务必完成资讯核实:分别通过 web_search 调研 CoinDesk 今日所有要闻、Reuters 今日重点新闻,以及全球 AI 领域的重大进展。",
"整合新闻时,将同源资讯合并,突出影响力、涉及主体与潜在影响,保持语句简洁。",
"所有新闻要点都要附带来源链接,并在括号中标注来源站点名。",
"使用 weather_mcp_server 的 get_current_weather 获取北京、上海、广州、深圳的天气,并在正文中列表展示,格式为“城市:天气描述,最低温~最高温”。",
"正文结尾补充一个行动建议或提醒,帮助读者快速把握重点。",
"确保整篇帖子逻辑清晰:问候 > 新闻分区 > 天气列表 > 总结/提醒。",
"严禁发布超过一篇帖子create_post 只调用一次。",
];
}
protected override getCliQuery(): string {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const weekday = WEEKDAY_NAMES[now.getDay()];
const dateLabel = `${year}${month}${day}日 星期${weekday}`;
const isoDate = `${year}-${month}-${day}`;
const categoryId = Number(process.env.DAILY_NEWS_CATEGORY_ID ?? "1");
const tagIdsEnv = process.env.DAILY_NEWS_TAG_IDS ?? "1";
const tagIds = tagIdsEnv
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => !Number.isNaN(id));
const finalTagIds = tagIds.length > 0 ? tagIds : [1];
const tagIdsText = `[${finalTagIds.join(", ")}]`;
return `
请立即在 https://www.open-isle.com 使用 create_post 发布一篇名为「OpenIsle 每日新闻速递|${dateLabel}」的帖子,并遵循以下要求:
1. 发布类型为 NORMALcategoryId = ${categoryId}tagIds = ${tagIdsText}
2. 正文以简洁问候开头,注明今日日期(${dateLabel}及发布时间07:00GMT+8
3. 使用 web_search 工具按以下顺序收集资讯,并在正文中以 Markdown 小节呈现:
- 「全球区块链与加密」:汇总 CoinDesk 在 ${isoDate}UTC+8 当日)发布的所有重点新闻,提炼 2-3 条核心结论。
- 「国际财经速览」:汇总 Reuters 当日重点头条,关注宏观经济、市场波动或政策变化。
- 「AI 行业快讯」:检索全球 AI 领域的重要发布或事件(例如 OpenAI、Google、Meta、国内大模型厂商等
4. 每条新闻采用项目符号,先写结论再给出关键数字或细节,末尾添加来源超链接,格式示例:「**结论** —— 关键细节。(来源:[Reuters](URL))」。
5. 资讯整理完毕后,调用 weather_mcp_server.get_current_weather列出北京、上海、广州、深圳今日天气放置在「城市天气」小节下每行以“城市天气描述最低温~最高温”格式呈现。
6. 最后一节为「今日提醒」,给出 1-2 条与新闻或天气相关的行动建议。
7. 若在资讯搜集过程中发现相互矛盾的信息,须在正文中以「⚠️ 风险提示」说明原因及尚待确认的点。
8. 帖子整体保持在 400 字以内,避免冗长赘述。
9. 发布完成后,不要再次调用 create_post。
`.trim();
}
}
const dailyNewsBot = new DailyNewsBot();
export const runWorkflow = async (workflow: WorkflowInput) => {
return dailyNewsBot.runWorkflow(workflow);
};
if (require.main === module) {
dailyNewsBot.runCli();
}

View 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();
}

View File

@@ -0,0 +1,38 @@
// reply_bot.ts
import { BotFather, WorkflowInput } from "../bot_father";
class ReplyBot extends BotFather {
constructor() {
super("OpenIsle Bot");
}
protected override getAdditionalInstructions(): string[] {
return [
"记住你的系统代号是 system任何需要自称、署名或解释身份的时候都使用这个名字。",
"以阴阳怪气的方式回复各种互动",
"你每天会发布咖啡抽奖贴,跟大家互动",
];
}
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

@@ -16,6 +16,7 @@
<div class="info-content-header-left">
<span class="user-name">{{ comment.userName }}</span>
<span v-if="isCommentFromPostAuthor" class="op-badge" title="楼主">OP</span>
<span v-if="comment.isBot" class="bot-badge" title="Bot">Bot</span>
<medal-one class="medal-icon" />
<NuxtLink
v-if="comment.medal"
@@ -522,6 +523,21 @@ const handleContentClick = (e) => {
line-height: 1;
}
.bot-badge {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 6px;
padding: 0 6px;
height: 18px;
border-radius: 9px;
background-color: rgba(76, 175, 80, 0.16);
color: #2e7d32;
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.medal-icon {
font-size: 12px;
opacity: 0.6;

View File

@@ -377,6 +377,7 @@ const mapComment = (
text: c.content,
reactions: c.reactions || [],
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
isBot: Boolean(c.author?.bot),
reply: (c.replies || []).map((r) =>
mapComment(r, c.author.username, c.author.avatar, c.author.id, level + 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

@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field, ConfigDict, field_validator
class SearchResultItem(BaseModel):
@@ -170,6 +170,15 @@ class CommentData(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="allow")
@field_validator("replies", "reactions", mode="before")
@classmethod
def _ensure_comment_lists(cls, value: Any) -> list[Any]:
"""Convert ``None`` payloads to empty lists for comment collections."""
if value is None:
return []
return value
class CommentReplyResult(BaseModel):
"""Structured response returned when replying to a comment."""
@@ -183,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."""
@@ -253,6 +268,15 @@ class PostSummary(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="allow")
@field_validator("tags", "reactions", "participants", mode="before")
@classmethod
def _ensure_post_lists(cls, value: Any) -> list[Any]:
"""Normalize ``None`` values returned by the backend to empty lists."""
if value is None:
return []
return value
class RecentPostsResponse(BaseModel):
"""Structured response for the recent posts tool."""
@@ -278,6 +302,15 @@ class PostDetail(PostSummary):
model_config = ConfigDict(populate_by_name=True, extra="allow")
@field_validator("comments", mode="before")
@classmethod
def _ensure_comments_list(cls, value: Any) -> list[Any]:
"""Treat ``None`` comments payloads as empty lists."""
if value is None:
return []
return value
class NotificationData(BaseModel):
"""Unread notification payload returned by the backend."""
@@ -331,3 +364,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.",
)

View File

@@ -66,14 +66,15 @@ 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
def _build_headers(
self,
*,
token: str | None = None,
token: str,
accept: str = "application/json",
include_json: bool = False,
) -> dict[str, str]:
@@ -173,6 +174,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,
) -> 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, token=%s", body.get("id"), token)
return body
async def recent_posts(self, minutes: int) -> list[dict[str, Any]]:
"""Return posts created within the given timeframe."""
@@ -221,7 +249,7 @@ class SearchClient:
*,
page: int = 0,
size: int = 30,
token: str | None = None,
token: str,
) -> list[dict[str, Any]]:
"""Return unread notifications for the authenticated user."""
@@ -253,6 +281,53 @@ class SearchClient:
)
return [self._ensure_dict(entry) for entry in payload]
async def mark_notifications_read(
self,
ids: list[int],
*,
token: str
) -> 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."""

View File

@@ -4,13 +4,12 @@ from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import Annotated, Any
from typing import Annotated
import httpx
from mcp.server.fastmcp import Context, FastMCP
from pydantic import ValidationError
from pydantic import Field as PydanticField
from weakref import WeakKeyDictionary
from .config import get_settings
from .schemas import (
@@ -18,8 +17,10 @@ from .schemas import (
CommentData,
CommentReplyResult,
NotificationData,
NotificationCleanupResult,
UnreadNotificationsResponse,
PostDetail,
PostCreateResult,
PostSummary,
RecentPostsResponse,
SearchResponse,
@@ -51,67 +52,35 @@ search_client = SearchClient(
)
class SessionTokenManager:
"""Cache JWT access tokens on a per-session basis."""
def __init__(self) -> None:
self._tokens: WeakKeyDictionary[Any, str] = WeakKeyDictionary()
def resolve(
self, ctx: Context | None, token: str | None = None
) -> str | None:
"""Resolve and optionally persist the token for the current session."""
session = self._get_session(ctx)
if isinstance(token, str):
stripped = token.strip()
if stripped:
if session is not None:
self._tokens[session] = stripped
logger.debug(
"Stored JWT token for session %s.",
self._describe_session(session),
)
return stripped
if session is not None and session in self._tokens:
logger.debug(
"Clearing stored JWT token for session %s due to empty input.",
self._describe_session(session),
)
del self._tokens[session]
return None
if session is not None:
cached = self._tokens.get(session)
if cached:
logger.debug(
"Reusing cached JWT token for session %s.",
self._describe_session(session),
)
return cached
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
@staticmethod
def _get_session(ctx: Context | None) -> Any | None:
if ctx is None:
return None
try:
return ctx.session
except Exception: # pragma: no cover - defensive guard
return None
try:
request_context = ctx.request_context
except ValueError:
return None
@staticmethod
def _describe_session(session: Any) -> str:
identifier = getattr(session, "mcp_session_id", None)
if isinstance(identifier, str) and identifier:
return identifier
return hex(id(session))
request = getattr(request_context, "request", None)
if request is None:
return None
headers = getattr(request, "headers", None)
if headers is None:
return None
session_token_manager = SessionTokenManager()
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
@@ -129,9 +98,10 @@ async def lifespan(_: FastMCP):
app = FastMCP(
name="openisle-mcp",
instructions=(
"Use this server to search OpenIsle content, reply to posts and comments with "
"session-managed authentication, 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 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,
@@ -192,7 +162,10 @@ async def search(
@app.tool(
name="reply_to_post",
description="Create a comment on a post using session authentication.",
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(
@@ -221,7 +194,7 @@ async def reply_to_post(
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
resolved_token = session_token_manager.resolve(ctx)
request_token = _extract_authorization_token(ctx)
try:
logger.info(
@@ -231,20 +204,20 @@ async def reply_to_post(
)
raw_comment = await search_client.reply_to_post(
post_id,
resolved_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:
@@ -290,7 +263,10 @@ async def reply_to_post(
@app.tool(
name="reply_to_comment",
description="Reply to an existing comment using session authentication.",
description=(
"Reply to an existing comment using the request Authorization header or the configured "
"access token."
),
structured_output=True,
)
async def reply_to_comment(
@@ -319,7 +295,7 @@ async def reply_to_comment(
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
resolved_token = session_token_manager.resolve(ctx)
request_token = _extract_authorization_token(ctx)
try:
logger.info(
@@ -329,20 +305,20 @@ async def reply_to_comment(
)
raw_comment = await search_client.reply_to_comment(
comment_id,
resolved_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:
@@ -384,6 +360,342 @@ async def reply_to_comment(
return CommentReplyResult(comment=comment)
@app.tool(
name="create_post",
description=(
"Publish a new post using the request Authorization header or the configured access "
"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,
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_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.")
if sanitized_category_id is None:
raise ValueError("A category identifier is required to create a post.")
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
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
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=_extract_authorization_token(ctx))
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 "
"Authorization header or configured token."
)
elif status_code == 403:
message = "The provided Authorization 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.",
@@ -450,11 +762,11 @@ async def get_post(
) -> PostDetail:
"""Fetch post details from the backend and validate the response."""
resolved_token = session_token_manager.resolve(ctx)
try:
logger.info("Fetching post details for post_id=%s", post_id)
raw_post = await search_client.get_post(post_id, resolved_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:
@@ -462,7 +774,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 "
@@ -523,8 +835,6 @@ async def list_unread_messages(
) -> UnreadNotificationsResponse:
"""Retrieve unread notifications and return structured data."""
resolved_token = session_token_manager.resolve(ctx)
try:
logger.info(
"Fetching unread notifications (page=%s, size=%s)",
@@ -534,7 +844,7 @@ async def list_unread_messages(
raw_notifications = await search_client.list_unread_notifications(
page=page,
size=size,
token=resolved_token,
token=_extract_authorization_token(ctx),
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
message = (
@@ -580,6 +890,68 @@ 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.",
),
],
ctx: Context | None = None,
) -> NotificationCleanupResult:
"""Mark the supplied notifications as read and report the processed identifiers."""
try:
logger.info(
"Marking %d notifications as read", # pragma: no branch - logging
len(ids),
)
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 "
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."""