mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-07 07:30:54 +08:00
Compare commits
70 Commits
feature/bo
...
feature/ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fe3eec815 | ||
|
|
f0feb7a45c | ||
|
|
784057207f | ||
|
|
bed72662b5 | ||
|
|
895dba495b | ||
|
|
32dc6bfaf9 | ||
|
|
4766250577 | ||
|
|
13baffa9f1 | ||
|
|
d0d7580ac3 | ||
|
|
fd4e651a49 | ||
|
|
58317687d7 | ||
|
|
006e46f4ef | ||
|
|
2c27766544 | ||
|
|
c305992223 | ||
|
|
babd2c6549 | ||
|
|
d98c3644a6 | ||
|
|
dbb63a4039 | ||
|
|
49aeff3a83 | ||
|
|
512e5623e1 | ||
|
|
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 | ||
|
|
7a2cf829c7 | ||
|
|
12329b43d1 | ||
|
|
1a45603e0f | ||
|
|
4a73503399 | ||
|
|
83bf8c1d5e | ||
|
|
34e206f05d | ||
|
|
dc349923e9 | ||
|
|
0d44c9a823 | ||
|
|
02645af321 | ||
|
|
c3a175f13f | ||
|
|
0821d447f7 | ||
|
|
257794ca00 | ||
|
|
6a527de3eb | ||
|
|
2313f90eb3 | ||
|
|
7fde984e7d | ||
|
|
fc41e605e4 | ||
|
|
042e5fdbe6 | ||
|
|
629442bff6 | ||
|
|
7798910be0 | ||
|
|
6f036eb4fe | ||
|
|
56fc05cb3c | ||
|
|
a55a15659b | ||
|
|
ccf6e0c7ce |
30
.github/workflows/coffee-bot.yml
vendored
Normal file
30
.github/workflows/coffee-bot.yml
vendored
Normal 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
|
||||
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
|
||||
6
.github/workflows/reply-bots.yml
vendored
6
.github/workflows/reply-bots.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
run-reply-bot:
|
||||
environment: Bots
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -19,10 +20,11 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --no-save @openai/agents ts-node typescript
|
||||
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 ts-node --esm bots/reply_bots.ts
|
||||
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
|
||||
run: npx tsx bots/instance/reply_bot.ts
|
||||
|
||||
@@ -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>,这里提供更完整的社区板块与互动体验。
|
||||
|
||||
## 📋 授权
|
||||
|
||||
|
||||
@@ -13,4 +13,5 @@ public class AuthorDto {
|
||||
private String username;
|
||||
private String avatar;
|
||||
private MedalType displayMedal;
|
||||
private boolean bot;
|
||||
}
|
||||
|
||||
@@ -28,4 +28,5 @@ public class UserDto {
|
||||
private int point;
|
||||
private int currentLevel;
|
||||
private int nextLevelExp;
|
||||
private boolean bot;
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ public class UserSummaryDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String avatar;
|
||||
private boolean bot;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
182
bots/bot_father.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
bots/instance/coffee_bot.ts
Normal file
56
bots/instance/coffee_bot.ts
Normal 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 固定为 10,tagIds 设为 [36]。
|
||||
4. 帖子语言使用简体中文。
|
||||
`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const coffeeBot = new CoffeeBot();
|
||||
|
||||
export const runWorkflow = async (workflow: WorkflowInput) => {
|
||||
return coffeeBot.runWorkflow(workflow);
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
coffeeBot.runCli();
|
||||
}
|
||||
|
||||
67
bots/instance/daily_news_bot.ts
Normal file
67
bots/instance/daily_news_bot.ts
Normal 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. 发布类型为 NORMAL,categoryId = ${categoryId},tagIds = ${tagIdsText}。
|
||||
2. 正文以简洁问候开头,注明今日日期(${dateLabel})及发布时间(07:00,GMT+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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
38
bots/instance/reply_bot.ts
Normal file
38
bots/instance/reply_bot.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -257,7 +285,7 @@ class SearchClient:
|
||||
self,
|
||||
ids: list[int],
|
||||
*,
|
||||
token: str | None = None,
|
||||
token: str
|
||||
) -> None:
|
||||
"""Mark the provided notifications as read for the authenticated user."""
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from .schemas import (
|
||||
NotificationCleanupResult,
|
||||
UnreadNotificationsResponse,
|
||||
PostDetail,
|
||||
PostCreateResult,
|
||||
PostSummary,
|
||||
RecentPostsResponse,
|
||||
SearchResponse,
|
||||
@@ -51,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."""
|
||||
@@ -66,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 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 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,
|
||||
@@ -129,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(
|
||||
@@ -148,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."""
|
||||
@@ -165,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)",
|
||||
@@ -179,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:
|
||||
@@ -238,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(
|
||||
@@ -257,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."""
|
||||
@@ -274,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)",
|
||||
@@ -286,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:
|
||||
@@ -341,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.",
|
||||
@@ -403,24 +758,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:
|
||||
@@ -428,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 "
|
||||
@@ -485,21 +831,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)",
|
||||
@@ -509,7 +844,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 = (
|
||||
@@ -568,29 +903,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 "
|
||||
|
||||
Reference in New Issue
Block a user