mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-09 00:21:13 +08:00
Compare commits
1 Commits
bugfix/113
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcd6a3249d |
10
.env.example
10
.env.example
@@ -2,12 +2,20 @@
|
||||
SERVER_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
WEBSOCKET_PORT=8082
|
||||
OPENISLE_MCP_PORT=8085
|
||||
MYSQL_PORT=3306
|
||||
REDIS_PORT=6379
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_MANAGEMENT_PORT=15672
|
||||
|
||||
# === MCP Server ===
|
||||
OPENISLE_MCP_TRANSPORT=http
|
||||
OPENISLE_MCP_HOST=0.0.0.0
|
||||
OPENISLE_MCP_PORT=8974
|
||||
OPENISLE_API_BASE_URL=http://springboot:8080
|
||||
OPENISLE_API_TIMEOUT=10
|
||||
OPENISLE_MCP_DEFAULT_LIMIT=20
|
||||
OPENISLE_MCP_SNIPPET_LENGTH=160
|
||||
|
||||
# === OpenSearch Configuration ===
|
||||
OPENSEARCH_PORT=9200
|
||||
OPENSEARCH_METRICS_PORT=9600
|
||||
|
||||
30
.github/workflows/coffee-bot.yml
vendored
30
.github/workflows/coffee-bot.yml
vendored
@@ -1,30 +0,0 @@
|
||||
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/news-bot.yml
vendored
30
.github/workflows/news-bot.yml
vendored
@@ -1,30 +0,0 @@
|
||||
name: Daily News Bot
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 22 * * 0-4"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
run-daily-news-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 daily news 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/daily_news_bot.ts
|
||||
30
.github/workflows/open_source_reply_bot.yml
vendored
30
.github/workflows/open_source_reply_bot.yml
vendored
@@ -1,30 +0,0 @@
|
||||
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
30
.github/workflows/reply-bots.yml
vendored
@@ -1,30 +0,0 @@
|
||||
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
|
||||
@@ -57,9 +57,6 @@ cd OpenIsle
|
||||
--profile dev up -d --force-recreate
|
||||
```
|
||||
|
||||
数据初始化sql会创建几个帐户供大家测试使用
|
||||
> username:admin/user1/user2 password:123456
|
||||
|
||||
3. 查看服务状态:
|
||||
```shell
|
||||
docker compose -f docker/docker-compose.yaml --env-file .env ps
|
||||
|
||||
@@ -26,8 +26,9 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
||||
- 集成 OpenAI 提供的 Markdown 格式化功能
|
||||
- 通过环境变量可调整密码强度、登录方式、保护码等多种配置
|
||||
- 支持图片上传,默认使用腾讯云 COS 扩展
|
||||
- Bot 集成,可在平台内快速连接自定义机器人,并通过 Telegram 的 BotFather 创建和管理消息机器人,拓展社区互动渠道
|
||||
- 默认头像使用 DiceBear Avatars,可通过 `AVATAR_STYLE` 和 `AVATAR_SIZE` 环境变量自定义主题和大小
|
||||
- 浏览器推送通知,离开网站也能及时收到提醒
|
||||
- 新增 Python MCP 搜索服务,方便 AI 助手通过统一协议检索社区内容
|
||||
|
||||
## 🌟 项目优势
|
||||
|
||||
@@ -41,7 +42,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
||||
|
||||
## 🏘️ 社区
|
||||
|
||||
- 欢迎彼此交流和使用 OpenIsle,项目以开源方式提供;如果遇到问题请到 GitHub 的 Issues 页面反馈,想发起话题讨论也可以前往源站 <https://www.open-isle.com>,这里提供更完整的社区板块与互动体验。
|
||||
欢迎彼此交流和使用 OpenIsle,项目以开源方式提供,想了解更多可访问:<https://github.com/nagisa77/OpenIsle>
|
||||
|
||||
## 📋 授权
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@ import com.openisle.model.User;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.EmailSender;
|
||||
import com.openisle.exception.EmailSendException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -19,7 +17,6 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/users")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AdminUserController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
@@ -38,15 +35,11 @@ public class AdminUserController {
|
||||
user.setApproved(true);
|
||||
userRepository.save(user);
|
||||
markRegisterRequestNotificationsRead(user);
|
||||
try {
|
||||
emailSender.sendEmail(
|
||||
user.getEmail(),
|
||||
"您的注册已审核通过",
|
||||
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl
|
||||
);
|
||||
} catch (EmailSendException e) {
|
||||
log.warn("Failed to send approve email to {}: {}", user.getEmail(), e.getMessage());
|
||||
}
|
||||
emailSender.sendEmail(
|
||||
user.getEmail(),
|
||||
"您的注册已审核通过",
|
||||
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl
|
||||
);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@@ -59,15 +52,11 @@ public class AdminUserController {
|
||||
user.setApproved(false);
|
||||
userRepository.save(user);
|
||||
markRegisterRequestNotificationsRead(user);
|
||||
try {
|
||||
emailSender.sendEmail(
|
||||
user.getEmail(),
|
||||
"您的注册已被管理员拒绝",
|
||||
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
|
||||
);
|
||||
} catch (EmailSendException e) {
|
||||
log.warn("Failed to send reject email to {}: {}", user.getEmail(), e.getMessage());
|
||||
}
|
||||
emailSender.sendEmail(
|
||||
user.getEmail(),
|
||||
"您的注册已被管理员拒绝",
|
||||
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
|
||||
);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.openisle.controller;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.dto.*;
|
||||
import com.openisle.exception.EmailSendException;
|
||||
import com.openisle.exception.FieldException;
|
||||
import com.openisle.model.RegisterMode;
|
||||
import com.openisle.model.User;
|
||||
@@ -20,7 +19,6 @@ import java.util.concurrent.TimeUnit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -85,17 +83,6 @@ public class AuthController {
|
||||
"INVITE_APPROVED"
|
||||
)
|
||||
);
|
||||
} catch (EmailSendException e) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"邮件发送失败: " + e.getMessage(),
|
||||
"reason_code",
|
||||
"EMAIL_SEND_FAILED"
|
||||
)
|
||||
);
|
||||
} catch (FieldException e) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("field", e.getField(), "error", e.getMessage())
|
||||
@@ -110,20 +97,7 @@ public class AuthController {
|
||||
registerModeService.getRegisterMode()
|
||||
);
|
||||
// 发送确认邮件
|
||||
try {
|
||||
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
||||
} catch (EmailSendException e) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"邮件发送失败: " + e.getMessage(),
|
||||
"reason_code",
|
||||
"EMAIL_SEND_FAILED"
|
||||
)
|
||||
);
|
||||
}
|
||||
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
||||
if (!user.isApproved()) {
|
||||
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
|
||||
}
|
||||
@@ -195,28 +169,14 @@ public class AuthController {
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (!user.isVerified()) {
|
||||
user =
|
||||
userService.register(
|
||||
user.getUsername(),
|
||||
user.getEmail(),
|
||||
user.getPassword(),
|
||||
user.getRegisterReason(),
|
||||
registerModeService.getRegisterMode()
|
||||
);
|
||||
try {
|
||||
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
||||
} catch (EmailSendException e) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Failed to send verification email: " + e.getMessage(),
|
||||
"reason_code",
|
||||
"EMAIL_SEND_FAILED"
|
||||
)
|
||||
);
|
||||
}
|
||||
user = userService.register(
|
||||
user.getUsername(),
|
||||
user.getEmail(),
|
||||
user.getPassword(),
|
||||
user.getRegisterReason(),
|
||||
registerModeService.getRegisterMode()
|
||||
);
|
||||
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
@@ -703,20 +663,7 @@ public class AuthController {
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
|
||||
}
|
||||
try {
|
||||
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
|
||||
} catch (EmailSendException e) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"邮件发送失败: " + e.getMessage(),
|
||||
"reason_code",
|
||||
"EMAIL_SEND_FAILED"
|
||||
)
|
||||
);
|
||||
}
|
||||
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
|
||||
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.CommentContextDto;
|
||||
import com.openisle.dto.CommentDto;
|
||||
import com.openisle.dto.CommentRequest;
|
||||
import com.openisle.dto.PostChangeLogDto;
|
||||
import com.openisle.dto.TimelineItemDto;
|
||||
import com.openisle.mapper.CommentMapper;
|
||||
import com.openisle.mapper.PostChangeLogMapper;
|
||||
import com.openisle.mapper.PostMapper;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.service.*;
|
||||
@@ -42,7 +40,6 @@ public class CommentController {
|
||||
private final PointService pointService;
|
||||
private final PostChangeLogService changeLogService;
|
||||
private final PostChangeLogMapper postChangeLogMapper;
|
||||
private final PostMapper postMapper;
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
@@ -187,37 +184,6 @@ public class CommentController {
|
||||
return itemDtoList;
|
||||
}
|
||||
|
||||
@GetMapping("/comments/{commentId}/context")
|
||||
@Operation(
|
||||
summary = "Comment context",
|
||||
description = "Get a comment along with its previous comments and related post"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Comment context",
|
||||
content = @Content(schema = @Schema(implementation = CommentContextDto.class))
|
||||
)
|
||||
public ResponseEntity<CommentContextDto> getCommentContext(@PathVariable Long commentId) {
|
||||
log.debug("getCommentContext called for comment {}", commentId);
|
||||
Comment comment = commentService.getComment(commentId);
|
||||
CommentContextDto dto = new CommentContextDto();
|
||||
dto.setPost(postMapper.toSummaryDto(comment.getPost()));
|
||||
dto.setTargetComment(commentMapper.toDtoWithReplies(comment));
|
||||
dto.setPreviousComments(
|
||||
commentService
|
||||
.getCommentsBefore(comment)
|
||||
.stream()
|
||||
.map(commentMapper::toDtoWithReplies)
|
||||
.collect(Collectors.toList())
|
||||
);
|
||||
log.debug(
|
||||
"getCommentContext returning {} previous comments for comment {}",
|
||||
dto.getPreviousComments().size(),
|
||||
commentId
|
||||
);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@DeleteMapping("/comments/{id}")
|
||||
@Operation(summary = "Delete comment", description = "Delete a comment")
|
||||
@ApiResponse(responseCode = "200", description = "Deleted")
|
||||
|
||||
@@ -224,26 +224,6 @@ public class PostController {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/recent")
|
||||
@Operation(
|
||||
summary = "Recent posts",
|
||||
description = "List posts created within the specified number of minutes"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Recent posts",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
public List<PostSummaryDto> recentPosts(@RequestParam("minutes") int minutes) {
|
||||
return postService
|
||||
.listRecentPosts(minutes)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/ranking")
|
||||
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
|
||||
@ApiResponse(
|
||||
|
||||
@@ -34,7 +34,6 @@ public class UserController {
|
||||
private final TagService tagService;
|
||||
private final SubscriptionService subscriptionService;
|
||||
private final LevelService levelService;
|
||||
private final PostReadService postReadService;
|
||||
private final JwtService jwtService;
|
||||
private final UserMapper userMapper;
|
||||
private final TagMapper tagMapper;
|
||||
@@ -54,9 +53,6 @@ public class UserController {
|
||||
@Value("${app.user.tags-limit:50}")
|
||||
private int defaultTagsLimit;
|
||||
|
||||
@Value("${app.user.read-posts-limit:50}")
|
||||
private int defaultReadPostsLimit;
|
||||
|
||||
@GetMapping("/me")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Current user", description = "Get current authenticated user information")
|
||||
@@ -215,33 +211,6 @@ public class UserController {
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/read-posts")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "User read posts", description = "Get post read history (self only)")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Post read history",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostReadDto.class)))
|
||||
)
|
||||
public ResponseEntity<java.util.List<PostReadDto>> userReadPosts(
|
||||
@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit,
|
||||
Authentication auth
|
||||
) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
if (auth == null || !auth.getName().equals(user.getUsername())) {
|
||||
return ResponseEntity.status(403).body(java.util.List.of());
|
||||
}
|
||||
int l = limit != null ? limit : defaultReadPostsLimit;
|
||||
return ResponseEntity.ok(
|
||||
postReadService
|
||||
.getRecentReadsByUser(user.getUsername(), l)
|
||||
.stream()
|
||||
.map(userMapper::toPostReadDto)
|
||||
.collect(java.util.stream.Collectors.toList())
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/hot-posts")
|
||||
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
|
||||
@ApiResponse(
|
||||
|
||||
@@ -13,5 +13,4 @@ public class AuthorDto {
|
||||
private String username;
|
||||
private String avatar;
|
||||
private MedalType displayMedal;
|
||||
private boolean bot;
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* DTO representing the context of a comment including its post and previous comments.
|
||||
*/
|
||||
@Data
|
||||
public class CommentContextDto {
|
||||
|
||||
private PostSummaryDto post;
|
||||
private CommentDto targetComment;
|
||||
private List<CommentDto> previousComments;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
/** DTO for a user's post read record. */
|
||||
@Data
|
||||
public class PostReadDto {
|
||||
|
||||
private PostMetaDto post;
|
||||
private LocalDateTime lastReadAt;
|
||||
}
|
||||
@@ -28,5 +28,4 @@ public class UserDto {
|
||||
private int point;
|
||||
private int currentLevel;
|
||||
private int nextLevelExp;
|
||||
private boolean bot;
|
||||
}
|
||||
|
||||
@@ -8,5 +8,4 @@ public class UserSummaryDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String avatar;
|
||||
private boolean bot;
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.openisle.exception;
|
||||
|
||||
/**
|
||||
* Thrown when email sending fails so callers can surface a clear error upstream.
|
||||
*/
|
||||
public class EmailSendException extends RuntimeException {
|
||||
|
||||
public EmailSendException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public EmailSendException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.openisle.mapper;
|
||||
import com.openisle.dto.*;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostRead;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.*;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -38,7 +37,6 @@ public class UserMapper {
|
||||
dto.setUsername(user.getUsername());
|
||||
dto.setAvatar(user.getAvatar());
|
||||
dto.setDisplayMedal(user.getDisplayMedal());
|
||||
dto.setBot(user.isBot());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -65,7 +63,6 @@ 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 {
|
||||
@@ -116,11 +113,4 @@ public class UserMapper {
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
public PostReadDto toPostReadDto(PostRead read) {
|
||||
PostReadDto dto = new PostReadDto();
|
||||
dto.setPost(toMetaDto(read.getPost()));
|
||||
dto.setLastReadAt(read.getLastReadAt());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +62,6 @@ 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;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.openisle.repository;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.User;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
@@ -11,10 +10,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
public interface CommentRepository extends JpaRepository<Comment, Long> {
|
||||
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
|
||||
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
|
||||
List<Comment> findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
|
||||
Post post,
|
||||
LocalDateTime createdAt
|
||||
);
|
||||
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
|
||||
List<Comment> findByContentContainingIgnoreCase(String keyword);
|
||||
|
||||
|
||||
@@ -3,14 +3,11 @@ package com.openisle.repository;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostRead;
|
||||
import com.openisle.model.User;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PostReadRepository extends JpaRepository<PostRead, Long> {
|
||||
Optional<PostRead> findByUserAndPost(User user, Post post);
|
||||
List<PostRead> findByUserOrderByLastReadAtDesc(User user, Pageable pageable);
|
||||
long countByUser(User user);
|
||||
void deleteByPost(Post post);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
|
||||
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
||||
PostStatus status,
|
||||
LocalDateTime createdAt
|
||||
);
|
||||
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
|
||||
User author,
|
||||
PostStatus status,
|
||||
|
||||
@@ -105,7 +105,6 @@ 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;
|
||||
|
||||
@@ -266,27 +266,6 @@ public class CommentService {
|
||||
return replies;
|
||||
}
|
||||
|
||||
public Comment getComment(Long commentId) {
|
||||
log.debug("getComment called for id {}", commentId);
|
||||
return commentRepository
|
||||
.findById(commentId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
|
||||
}
|
||||
|
||||
public List<Comment> getCommentsBefore(Comment comment) {
|
||||
log.debug("getCommentsBefore called for comment {}", comment.getId());
|
||||
List<Comment> comments = commentRepository.findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
|
||||
comment.getPost(),
|
||||
comment.getCreatedAt()
|
||||
);
|
||||
log.debug(
|
||||
"getCommentsBefore returning {} comments for comment {}",
|
||||
comments.size(),
|
||||
comment.getId()
|
||||
);
|
||||
return comments;
|
||||
}
|
||||
|
||||
public List<Comment> getRecentCommentsByUser(String username, int limit) {
|
||||
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
|
||||
User user = userRepository
|
||||
|
||||
@@ -211,7 +211,6 @@ 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) {
|
||||
@@ -223,7 +222,6 @@ 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);
|
||||
}
|
||||
@@ -318,7 +316,6 @@ 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())
|
||||
@@ -368,7 +365,6 @@ 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());
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.EmailSender;
|
||||
import com.openisle.exception.EmailSendException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashSet;
|
||||
@@ -18,7 +17,6 @@ import java.util.concurrent.Executor;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -28,7 +26,6 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
/** Service for creating and retrieving notifications. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class NotificationService {
|
||||
|
||||
private final NotificationRepository notificationRepository;
|
||||
@@ -111,11 +108,7 @@ public class NotificationService {
|
||||
post.getId(),
|
||||
comment.getId()
|
||||
);
|
||||
try {
|
||||
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
|
||||
} catch (EmailSendException e) {
|
||||
log.warn("Failed to send notification email to {}: {}", user.getEmail(), e.getMessage());
|
||||
}
|
||||
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
|
||||
sendCustomPush(user, "有人回复了你", url);
|
||||
} else if (type == NotificationType.REACTION && comment != null) {
|
||||
// long count = reactionRepository.countReceived(comment.getAuthor().getUsername());
|
||||
|
||||
@@ -7,10 +7,7 @@ import com.openisle.repository.PostReadRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@@ -46,14 +43,6 @@ public class PostReadService {
|
||||
);
|
||||
}
|
||||
|
||||
public List<PostRead> getRecentReadsByUser(String username, int limit) {
|
||||
User user = userRepository
|
||||
.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Pageable pageable = PageRequest.of(0, limit);
|
||||
return postReadRepository.findByUserOrderByLastReadAtDesc(user, pageable);
|
||||
}
|
||||
|
||||
public long countReads(String username) {
|
||||
User user = userRepository
|
||||
.findByUsername(username)
|
||||
|
||||
@@ -19,7 +19,6 @@ import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import com.openisle.service.EmailSender;
|
||||
import com.openisle.exception.EmailSendException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
@@ -664,15 +663,11 @@ public class PostService {
|
||||
w.getEmail() != null &&
|
||||
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)
|
||||
) {
|
||||
try {
|
||||
emailSender.sendEmail(
|
||||
w.getEmail(),
|
||||
"你中奖了",
|
||||
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
|
||||
);
|
||||
} catch (EmailSendException e) {
|
||||
log.warn("Failed to send lottery win email to {}: {}", w.getEmail(), e.getMessage());
|
||||
}
|
||||
emailSender.sendEmail(
|
||||
w.getEmail(),
|
||||
"你中奖了",
|
||||
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
|
||||
);
|
||||
}
|
||||
notificationService.createNotification(
|
||||
w,
|
||||
@@ -698,19 +693,11 @@ public class PostService {
|
||||
.getDisabledEmailNotificationTypes()
|
||||
.contains(NotificationType.LOTTERY_DRAW)
|
||||
) {
|
||||
try {
|
||||
emailSender.sendEmail(
|
||||
lp.getAuthor().getEmail(),
|
||||
"抽奖已开奖",
|
||||
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
|
||||
);
|
||||
} catch (EmailSendException e) {
|
||||
log.warn(
|
||||
"Failed to send lottery draw email to {}: {}",
|
||||
lp.getAuthor().getEmail(),
|
||||
e.getMessage()
|
||||
);
|
||||
}
|
||||
emailSender.sendEmail(
|
||||
lp.getAuthor().getEmail(),
|
||||
"抽奖已开奖",
|
||||
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
|
||||
);
|
||||
}
|
||||
notificationService.createNotification(
|
||||
lp.getAuthor(),
|
||||
@@ -783,18 +770,6 @@ public class PostService {
|
||||
return listPostsByCategories(null, null, null);
|
||||
}
|
||||
|
||||
public List<Post> listRecentPosts(int minutes) {
|
||||
if (minutes <= 0) {
|
||||
throw new IllegalArgumentException("Minutes must be positive");
|
||||
}
|
||||
LocalDateTime since = LocalDateTime.now().minusMinutes(minutes);
|
||||
List<Post> posts = postRepository.findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
||||
PostStatus.PUBLISHED,
|
||||
since
|
||||
);
|
||||
return sortByPinnedAndCreated(posts);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
|
||||
return listPostsByViews(null, null, page, pageSize);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.exception.EmailSendException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -8,9 +7,8 @@ import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Service
|
||||
@@ -25,6 +23,7 @@ public class ResendEmailSender extends EmailSender {
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
@Override
|
||||
@Async("notificationExecutor")
|
||||
public void sendEmail(String to, String subject, String text) {
|
||||
String url = "https://api.resend.com/emails"; // hypothetical endpoint
|
||||
|
||||
@@ -39,20 +38,6 @@ public class ResendEmailSender extends EmailSender {
|
||||
body.put("from", "openisle <" + fromEmail + ">");
|
||||
|
||||
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers);
|
||||
try {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
url,
|
||||
HttpMethod.POST,
|
||||
entity,
|
||||
String.class
|
||||
);
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw new EmailSendException(
|
||||
"Email service returned status " + response.getStatusCodeValue()
|
||||
);
|
||||
}
|
||||
} catch (RestClientException e) {
|
||||
throw new EmailSendException("Failed to send email: " + e.getMessage(), e);
|
||||
}
|
||||
restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ public class UserService {
|
||||
* @param user
|
||||
*/
|
||||
public void sendVerifyMail(User user, VerifyType verifyType) {
|
||||
// 缓存验证码
|
||||
String code = genCode();
|
||||
String key;
|
||||
String subject;
|
||||
@@ -132,9 +133,8 @@ public class UserService {
|
||||
subject = "请填写验证码以重置密码(有效期为5分钟)";
|
||||
}
|
||||
|
||||
emailService.sendEmail(user.getEmail(), subject, content);
|
||||
// 邮件发送成功后再缓存验证码,避免发送失败时用户收不到但验证被要求
|
||||
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES); // 五分钟后验证码过期
|
||||
emailService.sendEmail(user.getEmail(), subject, content);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,6 @@ 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,28 +8,10 @@ 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`,
|
||||
`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 `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 `categories` (`id`,`description`,`icon`,`name`,`small_icon`) VALUES
|
||||
(1,'测试用分类1','star','测试用分类1',NULL),
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN is_bot BIT(1) NOT NULL DEFAULT b'0';
|
||||
@@ -1,12 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS post_reads (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
post_id BIGINT NOT NULL,
|
||||
last_read_at DATETIME(6) DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY UK_post_reads_user_post (user_id, post_id),
|
||||
KEY IDX_post_reads_user (user_id),
|
||||
KEY IDX_post_reads_post (post_id),
|
||||
CONSTRAINT FK_post_reads_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT FK_post_reads_post FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1,186 +0,0 @@
|
||||
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: this.getModel(),
|
||||
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 getModel(): string {
|
||||
return "gpt-4o-mini";
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { BotFather, WorkflowInput } from "../bot_father";
|
||||
|
||||
const WEEKDAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"] as const;
|
||||
|
||||
class DailyNewsBot extends BotFather {
|
||||
constructor() {
|
||||
super("Daily News Bot");
|
||||
}
|
||||
|
||||
protected override getModel(): string {
|
||||
return "gpt-4o";
|
||||
}
|
||||
|
||||
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 ?? "6");
|
||||
const tagIdsEnv = process.env.DAILY_NEWS_TAG_IDS ?? "3,33";
|
||||
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. 正文以简洁问候开头, 不用再重复标题
|
||||
3. 使用 web_search 工具按以下顺序收集资讯,并在正文中以 Markdown 小节呈现, 需要调用3次web_search:
|
||||
- 「全球区块链与加密」:汇总 coindesk.com 版面所有重点新闻, 列出至少5条
|
||||
- 「国际新闻速览」:汇总 reuters.com 版面重点头条,关注宏观经济、市场波动或政策变化。列出至少5条
|
||||
- 「AI 行业快讯」:检索今天全球 AI 领域的重要发布或事件(例如 OpenAI、Google、Meta、国内大模型厂商等)。列出至少5条
|
||||
4. 每条新闻采用项目符号,先写结论再给出关键数字或细节,末尾添加来源超链接,格式示例:「**结论** —— 关键细节。(来源:[Reuters](URL))」
|
||||
5. 资讯整理完毕后,调用 weather_mcp_server.get_current_weather,列出北京、上海、广州、深圳今日天气,放置在「城市天气」小节下, 本小节可加emoji。
|
||||
6. 最后一节为「今日提醒」,给出 2-3 条与新闻或天气相关的行动建议。
|
||||
7. 若在资讯搜集过程中发现相互矛盾的信息,须在正文中以「⚠️ 风险提示」说明原因及尚待确认的点。
|
||||
9. 发布完成后,不要再次调用 create_post。
|
||||
`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const dailyNewsBot = new DailyNewsBot();
|
||||
|
||||
export const runWorkflow = async (workflow: WorkflowInput) => {
|
||||
return dailyNewsBot.runWorkflow(workflow);
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
dailyNewsBot.runCli();
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
@@ -40,12 +40,12 @@ echo "👉 Build images ..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=production \
|
||||
frontend_service mcp
|
||||
frontend_service
|
||||
|
||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
up -d --force-recreate --remove-orphans --no-deps \
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
||||
|
||||
echo "👉 Current status:"
|
||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||
|
||||
@@ -39,12 +39,12 @@ echo "👉 Build images (staging)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=staging \
|
||||
frontend_service mcp
|
||||
frontend_service
|
||||
|
||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
up -d --force-recreate --remove-orphans --no-deps \
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
||||
|
||||
echo "👉 Current status:"
|
||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||
|
||||
@@ -30,62 +30,62 @@ services:
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
# # OpenSearch Service
|
||||
# opensearch:
|
||||
# user: "1000:1000"
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: opensearch.Dockerfile
|
||||
# container_name: ${COMPOSE_PROJECT_NAME}-opensearch
|
||||
# environment:
|
||||
# - cluster.name=os-single
|
||||
# - node.name=os-node-1
|
||||
# - discovery.type=single-node
|
||||
# - bootstrap.memory_lock=true
|
||||
# - OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
|
||||
# - DISABLE_SECURITY_PLUGIN=true
|
||||
# - cluster.blocks.create_index=false
|
||||
# ulimits:
|
||||
# memlock: { soft: -1, hard: -1 }
|
||||
# nofile: { soft: 65536, hard: 65536 }
|
||||
# volumes:
|
||||
# - opensearch-data:/usr/share/opensearch/data
|
||||
# - opensearch-snapshots:/snapshots
|
||||
# ports:
|
||||
# - "${OPENSEARCH_PORT:-9200}:9200"
|
||||
# - "${OPENSEARCH_METRICS_PORT:-9600}:9600"
|
||||
# restart: unless-stopped
|
||||
# healthcheck:
|
||||
# test:
|
||||
# - CMD-SHELL
|
||||
# - curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 30
|
||||
# start_period: 60s
|
||||
# networks:
|
||||
# - openisle-network
|
||||
# profiles:
|
||||
# - dev
|
||||
# - dev_local_backend
|
||||
# OpenSearch Service
|
||||
opensearch:
|
||||
user: "1000:1000"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: opensearch.Dockerfile
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-opensearch
|
||||
environment:
|
||||
- cluster.name=os-single
|
||||
- node.name=os-node-1
|
||||
- discovery.type=single-node
|
||||
- bootstrap.memory_lock=true
|
||||
- OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
|
||||
- DISABLE_SECURITY_PLUGIN=true
|
||||
- cluster.blocks.create_index=false
|
||||
ulimits:
|
||||
memlock: { soft: -1, hard: -1 }
|
||||
nofile: { soft: 65536, hard: 65536 }
|
||||
volumes:
|
||||
- opensearch-data:/usr/share/opensearch/data
|
||||
- opensearch-snapshots:/snapshots
|
||||
ports:
|
||||
- "${OPENSEARCH_PORT:-9200}:9200"
|
||||
- "${OPENSEARCH_METRICS_PORT:-9600}:9600"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
|
||||
# dashboards:
|
||||
# image: opensearchproject/opensearch-dashboards:3.0.0
|
||||
# container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
|
||||
# environment:
|
||||
# OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
|
||||
# DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
|
||||
# ports:
|
||||
# - "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
|
||||
# depends_on:
|
||||
# - opensearch
|
||||
# restart: unless-stopped
|
||||
# networks:
|
||||
# - openisle-network
|
||||
# profiles:
|
||||
# - dev
|
||||
# - dev_local_backend
|
||||
# - prod
|
||||
dashboards:
|
||||
image: opensearchproject/opensearch-dashboards:3.0.0
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
|
||||
environment:
|
||||
OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
|
||||
DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
|
||||
ports:
|
||||
- "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
|
||||
depends_on:
|
||||
- opensearch
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.13-management
|
||||
@@ -178,31 +178,34 @@ services:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
mcp:
|
||||
mcp-server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/mcp.Dockerfile
|
||||
context: ../mcp
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
|
||||
env_file:
|
||||
- ${ENV_FILE:-../.env}
|
||||
environment:
|
||||
OPENISLE_MCP_BACKEND_BASE_URL: http://springboot:${SERVER_PORT:-8080}
|
||||
OPENISLE_API_BASE_URL: ${OPENISLE_API_BASE_URL:-http://springboot:8080}
|
||||
OPENISLE_API_TIMEOUT: ${OPENISLE_API_TIMEOUT:-10}
|
||||
OPENISLE_MCP_DEFAULT_LIMIT: ${OPENISLE_MCP_DEFAULT_LIMIT:-20}
|
||||
OPENISLE_MCP_SNIPPET_LENGTH: ${OPENISLE_MCP_SNIPPET_LENGTH:-160}
|
||||
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-http}
|
||||
OPENISLE_MCP_HOST: 0.0.0.0
|
||||
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8085}
|
||||
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-streamable-http}
|
||||
OPENISLE_MCP_REQUEST_TIMEOUT: ${OPENISLE_MCP_REQUEST_TIMEOUT:-10.0}
|
||||
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8974}
|
||||
ports:
|
||||
- "${OPENISLE_MCP_PORT:-8085}:${OPENISLE_MCP_PORT:-8085}"
|
||||
- "${OPENISLE_MCP_PORT:-8974}:${OPENISLE_MCP_PORT:-8974}"
|
||||
depends_on:
|
||||
springboot:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
|
||||
websocket-service:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY mcp/pyproject.toml mcp/README.md ./
|
||||
COPY mcp/src ./src
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir .
|
||||
|
||||
ENV OPENISLE_MCP_HOST=0.0.0.0 \
|
||||
OPENISLE_MCP_PORT=8085 \
|
||||
OPENISLE_MCP_TRANSPORT=streamable-http
|
||||
|
||||
EXPOSE 8085
|
||||
|
||||
CMD ["openisle-mcp"]
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
<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"
|
||||
@@ -523,21 +522,6 @@ 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;
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
<template>
|
||||
<div class="timeline-container">
|
||||
<div class="timeline-header">
|
||||
<div class="timeline-title">浏览了文章</div>
|
||||
<div class="timeline-date">{{ formattedDate }}</div>
|
||||
</div>
|
||||
<div class="article-container">
|
||||
<NuxtLink :to="postLink" class="timeline-article-link">
|
||||
{{ item.post?.title }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-snippet">
|
||||
{{ strippedSnippet }}
|
||||
</div>
|
||||
<div class="article-meta" v-if="hasMeta">
|
||||
<ArticleCategory v-if="item.post?.category" :category="item.post.category" />
|
||||
<ArticleTags :tags="item.post?.tags" />
|
||||
<div class="article-comment-count" v-if="item.post?.commentCount !== undefined">
|
||||
<comment-one class="article-comment-count-icon" />
|
||||
<span>{{ item.post?.commentCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import TimeManager from '~/utils/time'
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const postLink = computed(() => {
|
||||
const id = props.item.post?.id
|
||||
return id ? `/posts/${id}` : '#'
|
||||
})
|
||||
|
||||
const formattedDate = computed(() =>
|
||||
TimeManager.format(props.item.lastReadAt ?? props.item.createdAt),
|
||||
)
|
||||
const strippedSnippet = computed(() => stripMarkdown(props.item.post?.snippet ?? ''))
|
||||
const hasMeta = computed(() => {
|
||||
const tags = props.item.post?.tags ?? []
|
||||
const hasTags = Array.isArray(tags) && tags.length > 0
|
||||
const hasCategory = !!props.item.post?.category
|
||||
const hasCommentCount =
|
||||
props.item.post?.commentCount !== undefined && props.item.post?.commentCount !== null
|
||||
return hasTags || hasCategory || hasCommentCount
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 5px;
|
||||
gap: 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--timeline-card-background, transparent);
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
font-size: 12px;
|
||||
color: var(--timeline-date-color, #888);
|
||||
}
|
||||
|
||||
.article-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.timeline-article-link {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.timeline-article-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.timeline-snippet {
|
||||
color: var(--timeline-snippet-color, #666);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.article-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.article-tag {
|
||||
background-color: var(--article-info-background-color);
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.article-comment-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.article-comment-count-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timeline-article-link {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.timeline-snippet {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -58,15 +58,12 @@ const submitLogin = async () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
toast.success('登录成功')
|
||||
registerPush()
|
||||
await navigateTo('/', { replace: true })
|
||||
} else if (data.reason_code === 'EMAIL_SEND_FAILED') {
|
||||
const msg = data.error || data.message || res.statusText || '登录失败'
|
||||
toast.error(`${res.status} ${msg} (${data.reason_code})`)
|
||||
} else if (data.reason_code === 'NOT_VERIFIED') {
|
||||
toast.info('当前邮箱未验证,已经为您重新发送验证码')
|
||||
await navigateTo(
|
||||
@@ -79,12 +76,10 @@ const submitLogin = async () => {
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
|
||||
} else {
|
||||
const msg = data.error || data.message || res.statusText || '登录失败'
|
||||
const reason = data.reason_code ? ` (${data.reason_code})` : ''
|
||||
toast.error(`${res.status} ${msg}${reason}`)
|
||||
toast.error(data.error || '登录失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(`登录失败: ${e.message}`)
|
||||
toast.error('登录失败')
|
||||
} finally {
|
||||
isWaitingForLogin.value = false
|
||||
}
|
||||
|
||||
@@ -377,7 +377,6 @@ 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),
|
||||
),
|
||||
@@ -533,7 +532,7 @@ const {
|
||||
} catch (err) {}
|
||||
},
|
||||
{
|
||||
server: true,
|
||||
server: false,
|
||||
lazy: false,
|
||||
},
|
||||
)
|
||||
@@ -1395,6 +1394,10 @@ onMounted(async () => {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.reaction-action.copy-link:hover {
|
||||
background-color: #e2e2e2;
|
||||
}
|
||||
|
||||
.comment-editor-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,8 @@ const sendVerification = async () => {
|
||||
inviteToken: inviteToken.value,
|
||||
}),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
isWaitingForEmailSent.value = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
emailStep.value = 1
|
||||
toast.success('验证码已发送,请查看邮箱')
|
||||
@@ -148,14 +149,10 @@ const sendVerification = async () => {
|
||||
if (data.field === 'email') emailError.value = data.error
|
||||
if (data.field === 'password') passwordError.value = data.error
|
||||
} else {
|
||||
const msg = data.error || data.message || res.statusText || '发送失败'
|
||||
const reason = data.reason_code ? ` (${data.reason_code})` : ''
|
||||
toast.error(`${res.status} ${msg}${reason}`)
|
||||
toast.error(data.error || '发送失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(`发送失败: ${e.message}`)
|
||||
} finally {
|
||||
isWaitingForEmailSent.value = false
|
||||
toast.error('发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,25 +191,14 @@
|
||||
>
|
||||
评论和回复
|
||||
</div>
|
||||
<div
|
||||
v-if="isMine"
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'reads' }]"
|
||||
@click="timelineFilter = 'reads'"
|
||||
>
|
||||
浏览记录
|
||||
</div>
|
||||
</div>
|
||||
<BasePlaceholder
|
||||
v-if="
|
||||
timelineFilter === 'reads'
|
||||
? readPosts.length === 0
|
||||
: filteredTimelineItems.length === 0
|
||||
"
|
||||
:text="timelineFilter === 'reads' ? '暂无浏览记录' : '暂无时间线'"
|
||||
v-if="filteredTimelineItems.length === 0"
|
||||
text="暂无时间线"
|
||||
icon="inbox"
|
||||
/>
|
||||
<div class="timeline-list">
|
||||
<BaseTimeline v-if="timelineFilter !== 'reads'" :items="filteredTimelineItems">
|
||||
<BaseTimeline :items="filteredTimelineItems">
|
||||
<template #item="{ item }">
|
||||
<template v-if="item.type === 'post'">
|
||||
<TimelinePostItem :item="item" />
|
||||
@@ -225,11 +214,6 @@
|
||||
</template>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
<BaseTimeline v-else :items="readPosts">
|
||||
<template #item="{ item }">
|
||||
<TimelineReadItem :item="item" />
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -292,7 +276,6 @@ import BaseTabs from '~/components/BaseTabs.vue'
|
||||
import LevelProgress from '~/components/LevelProgress.vue'
|
||||
import TimelineCommentGroup from '~/components/TimelineCommentGroup.vue'
|
||||
import TimelinePostItem from '~/components/TimelinePostItem.vue'
|
||||
import TimelineReadItem from '~/components/TimelineReadItem.vue'
|
||||
import TimelineTagItem from '~/components/TimelineTagItem.vue'
|
||||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||||
import UserList from '~/components/UserList.vue'
|
||||
@@ -316,15 +299,12 @@ const hotReplies = ref([])
|
||||
const hotTags = ref([])
|
||||
const favoritePosts = ref([])
|
||||
const timelineItems = ref([])
|
||||
const readPosts = ref([])
|
||||
const timelineFilter = ref('all')
|
||||
const filteredTimelineItems = computed(() => {
|
||||
if (timelineFilter.value === 'articles') {
|
||||
return timelineItems.value.filter((item) => item.type === 'post')
|
||||
} else if (timelineFilter.value === 'comments') {
|
||||
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
||||
} else if (timelineFilter.value === 'reads') {
|
||||
return []
|
||||
}
|
||||
return timelineItems.value
|
||||
})
|
||||
@@ -497,27 +477,6 @@ const fetchTimeline = async () => {
|
||||
timelineItems.value = combineDiscussionItems(mapped)
|
||||
}
|
||||
|
||||
const fetchReadHistory = async () => {
|
||||
if (!isMine.value) {
|
||||
readPosts.value = []
|
||||
return
|
||||
}
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
readPosts.value = []
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/${username}/read-posts?limit=50`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
readPosts.value = data.map((r) => ({ ...r, icon: 'file-text' }))
|
||||
} else {
|
||||
readPosts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const fetchFollowUsers = async () => {
|
||||
const [followerRes, followingRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
||||
@@ -549,12 +508,6 @@ const loadTimeline = async () => {
|
||||
tabLoading.value = false
|
||||
}
|
||||
|
||||
const loadReadHistory = async () => {
|
||||
tabLoading.value = true
|
||||
await fetchReadHistory()
|
||||
tabLoading.value = false
|
||||
}
|
||||
|
||||
const loadFollow = async () => {
|
||||
tabLoading.value = true
|
||||
await fetchFollowUsers()
|
||||
@@ -671,14 +624,8 @@ onMounted(init)
|
||||
|
||||
watch(selectedTab, async (val) => {
|
||||
// navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||
if (val === 'timeline') {
|
||||
if (timelineFilter.value === 'reads') {
|
||||
if (readPosts.value.length === 0) {
|
||||
await loadReadHistory()
|
||||
}
|
||||
} else if (timelineItems.value.length === 0) {
|
||||
await loadTimeline()
|
||||
}
|
||||
if (val === 'timeline' && timelineItems.value.length === 0) {
|
||||
await loadTimeline()
|
||||
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
||||
await loadFollow()
|
||||
} else if (val === 'favorites' && favoritePosts.value.length === 0) {
|
||||
@@ -687,23 +634,6 @@ watch(selectedTab, async (val) => {
|
||||
await loadAchievements()
|
||||
}
|
||||
})
|
||||
|
||||
watch(timelineFilter, async (val) => {
|
||||
if (selectedTab.value !== 'timeline') return
|
||||
if (val === 'reads') {
|
||||
if (readPosts.value.length === 0) {
|
||||
await loadReadHistory()
|
||||
}
|
||||
} else if (timelineItems.value.length === 0) {
|
||||
await loadTimeline()
|
||||
}
|
||||
})
|
||||
|
||||
watch(isMine, (val) => {
|
||||
if (!val && timelineFilter.value === 'reads') {
|
||||
timelineFilter.value = 'all'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
27
mcp/Dockerfile
Normal file
27
mcp/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install --no-cache-dir . \
|
||||
&& pip cache purge
|
||||
|
||||
ENV OPENISLE_MCP_TRANSPORT=http \
|
||||
OPENISLE_MCP_HOST=0.0.0.0 \
|
||||
OPENISLE_MCP_PORT=8974
|
||||
|
||||
EXPOSE 8974
|
||||
|
||||
ENTRYPOINT ["openisle-mcp"]
|
||||
@@ -1,42 +1,51 @@
|
||||
# OpenIsle MCP Server
|
||||
|
||||
This package provides a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server
|
||||
that exposes OpenIsle's search capabilities as MCP tools. The initial release focuses on the
|
||||
global search endpoint so the agent ecosystem can retrieve relevant posts, users, tags, and
|
||||
other resources.
|
||||
This package provides a Python implementation of a Model Context Protocol (MCP) server for OpenIsle. The server focuses on the community search APIs so that AI assistants and other MCP-aware clients can discover OpenIsle users, posts, categories, comments, and tags. Additional capabilities such as content creation tools can be layered on later without changing the transport or deployment model.
|
||||
|
||||
## Configuration
|
||||
## Features
|
||||
|
||||
The server is configured through environment variables (all prefixed with `OPENISLE_MCP_`):
|
||||
- ✅ Implements the MCP tooling interface using [FastMCP](https://github.com/modelcontextprotocol/fastmcp).
|
||||
- 🔍 Exposes a `search` tool that proxies requests to the existing OpenIsle REST endpoints and normalises the response payload.
|
||||
- ⚙️ Configurable through environment variables for API base URL, timeout, result limits, and snippet size.
|
||||
- 🐳 Packaged with a Docker image so it can be launched alongside the other OpenIsle services.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `BACKEND_BASE_URL` | `http://springboot:8080` | Base URL of the OpenIsle backend. |
|
||||
| `PORT` | `8085` | TCP port when running with the `streamable-http` transport. |
|
||||
| `HOST` | `0.0.0.0` | Interface to bind when serving HTTP. |
|
||||
| `TRANSPORT` | `streamable-http` | Transport to use (`stdio`, `sse`, or `streamable-http`). |
|
||||
| `REQUEST_TIMEOUT` | `10.0` | Timeout (seconds) for backend HTTP requests. |
|
||||
| `OPENISLE_API_BASE_URL` | `http://springboot:8080` | Base URL of the OpenIsle backend REST API. |
|
||||
| `OPENISLE_API_TIMEOUT` | `10` | Timeout (in seconds) used when calling the backend search endpoints. |
|
||||
| `OPENISLE_MCP_DEFAULT_LIMIT` | `20` | Default maximum number of search results to return when the caller does not provide a limit. Use `0` or a negative number to disable limiting. |
|
||||
| `OPENISLE_MCP_SNIPPET_LENGTH` | `160` | Maximum length (in characters) of the normalised summary snippet returned by the MCP tool. |
|
||||
| `OPENISLE_MCP_TRANSPORT` | `stdio` | Transport used when running the server directly. Set to `http` when running inside Docker. |
|
||||
| `OPENISLE_MCP_HOST` | `127.0.0.1` | Bind host used when the transport is HTTP/SSE. |
|
||||
| `OPENISLE_MCP_PORT` | `8974` | Bind port used when the transport is HTTP/SSE. |
|
||||
|
||||
## Running locally
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
OPENISLE_MCP_BACKEND_BASE_URL="http://localhost:8080" openisle-mcp
|
||||
cd mcp
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -e .
|
||||
OPENISLE_API_BASE_URL=http://localhost:8080 OPENISLE_MCP_TRANSPORT=http openisle-mcp
|
||||
```
|
||||
|
||||
By default the server listens on port `8085` and serves MCP over Streamable HTTP.
|
||||
By default the server listens over stdio, which is useful when integrating with MCP-aware IDEs. When the `OPENISLE_MCP_TRANSPORT` variable is set to `http` the server will expose an HTTP transport on `OPENISLE_MCP_HOST:OPENISLE_MCP_PORT`.
|
||||
|
||||
## Available tools
|
||||
## Docker image
|
||||
|
||||
| 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. |
|
||||
The accompanying `Dockerfile` builds a minimal image that installs the package and starts the MCP server. The root Docker Compose manifest is configured to launch this service and connect it to the same internal network as the Spring Boot API so the MCP tools can reach the search endpoints.
|
||||
|
||||
The tools return structured data mirroring the backend DTOs, including highlighted snippets for
|
||||
search results, the full comment payload for post replies and comment replies, and detailed
|
||||
metadata for recent posts.
|
||||
## MCP tool contract
|
||||
|
||||
The `search` tool accepts the following arguments:
|
||||
|
||||
- `keyword` (string, required): Search phrase passed directly to the OpenIsle API.
|
||||
- `scope` (string, optional): One of `global`, `posts`, `posts_content`, `posts_title`, or `users`. Defaults to `global`.
|
||||
- `limit` (integer, optional): Overrides the default limit from `OPENISLE_MCP_DEFAULT_LIMIT`.
|
||||
|
||||
The tool returns a JSON object containing both the raw API response and a normalised representation with concise titles, subtitles, and snippets for each result.
|
||||
|
||||
Future tools (for example posting or moderation helpers) can be added within this package and exposed via additional decorators without changing the deployment setup.
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.25"]
|
||||
requires = ["hatchling>=1.25.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "openisle-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Model Context Protocol server exposing OpenIsle search capabilities."
|
||||
description = "Model Context Protocol server exposing OpenIsle search functionality."
|
||||
readme = "README.md"
|
||||
authors = [{ name = "OpenIsle", email = "engineering@openisle.example" }]
|
||||
license = {text = "MIT"}
|
||||
authors = [{name = "OpenIsle Contributors"}]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp>=1.19.0",
|
||||
"httpx>=0.28,<0.29",
|
||||
"pydantic>=2.12,<3",
|
||||
"pydantic-settings>=2.11,<3"
|
||||
"fastmcp>=2.12.5",
|
||||
"httpx>=0.28.1",
|
||||
"pydantic>=2.7",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
openisle-mcp = "openisle_mcp.server:main"
|
||||
|
||||
[tool.hatch.build]
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openisle_mcp"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"src/openisle_mcp",
|
||||
"README.md",
|
||||
"pyproject.toml",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""OpenIsle MCP server package."""
|
||||
|
||||
from .config import Settings, get_settings
|
||||
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
from .server import main
|
||||
|
||||
__all__ = ["main"]
|
||||
|
||||
218
mcp/src/openisle_mcp/client.py
Normal file
218
mcp/src/openisle_mcp/client.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""HTTP client wrappers for interacting with the OpenIsle backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import NormalizedSearchResult, SearchResponse, SearchScope
|
||||
from .settings import Settings
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_WHITESPACE_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
class SearchClient:
|
||||
"""High level client around the OpenIsle search API."""
|
||||
|
||||
_ENDPOINTS: dict[SearchScope, str] = {
|
||||
SearchScope.GLOBAL: "/api/search/global",
|
||||
SearchScope.POSTS: "/api/search/posts",
|
||||
SearchScope.POSTS_CONTENT: "/api/search/posts/content",
|
||||
SearchScope.POSTS_TITLE: "/api/search/posts/title",
|
||||
SearchScope.USERS: "/api/search/users",
|
||||
}
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._base_url = settings.sanitized_base_url()
|
||||
self._timeout = settings.request_timeout
|
||||
self._default_limit = settings.default_limit
|
||||
self._snippet_length = settings.snippet_length
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
timeout=self._timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
def endpoint_path(self, scope: SearchScope) -> str:
|
||||
return self._ENDPOINTS[scope]
|
||||
|
||||
def endpoint_url(self, scope: SearchScope) -> str:
|
||||
return f"{self._base_url}{self.endpoint_path(scope)}"
|
||||
|
||||
async def search(
|
||||
self,
|
||||
keyword: str,
|
||||
scope: SearchScope,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> SearchResponse:
|
||||
"""Execute a search request and normalise the results."""
|
||||
|
||||
keyword = keyword.strip()
|
||||
effective_limit = self._resolve_limit(limit)
|
||||
|
||||
if not keyword:
|
||||
return SearchResponse(
|
||||
keyword=keyword,
|
||||
scope=scope,
|
||||
endpoint=self.endpoint_url(scope),
|
||||
limit=effective_limit,
|
||||
total_results=0,
|
||||
returned_results=0,
|
||||
normalized=[],
|
||||
raw=[],
|
||||
)
|
||||
|
||||
response = await self._client.get(
|
||||
self.endpoint_path(scope),
|
||||
params={"keyword": keyword},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list): # pragma: no cover - defensive programming
|
||||
raise ValueError("Search API did not return a JSON array")
|
||||
|
||||
total_results = len(payload)
|
||||
items = payload if effective_limit is None else payload[:effective_limit]
|
||||
normalized = [self._normalise_item(scope, item) for item in items]
|
||||
|
||||
return SearchResponse(
|
||||
keyword=keyword,
|
||||
scope=scope,
|
||||
endpoint=self.endpoint_url(scope),
|
||||
limit=effective_limit,
|
||||
total_results=total_results,
|
||||
returned_results=len(items),
|
||||
normalized=normalized,
|
||||
raw=items,
|
||||
)
|
||||
|
||||
def _resolve_limit(self, requested: int | None) -> int | None:
|
||||
value = requested if requested is not None else self._default_limit
|
||||
if value is None:
|
||||
return None
|
||||
if value <= 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
def _normalise_item(
|
||||
self,
|
||||
scope: SearchScope,
|
||||
item: Any,
|
||||
) -> NormalizedSearchResult:
|
||||
"""Normalise raw API objects into a consistent structure."""
|
||||
|
||||
if not isinstance(item, dict): # pragma: no cover - defensive programming
|
||||
return NormalizedSearchResult(type=scope.value, metadata={"raw": item})
|
||||
|
||||
if scope == SearchScope.GLOBAL:
|
||||
return self._normalise_global(item)
|
||||
if scope in {SearchScope.POSTS, SearchScope.POSTS_CONTENT, SearchScope.POSTS_TITLE}:
|
||||
return self._normalise_post(item)
|
||||
if scope == SearchScope.USERS:
|
||||
return self._normalise_user(item)
|
||||
return NormalizedSearchResult(type=scope.value, metadata=item)
|
||||
|
||||
def _normalise_global(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
highlights = {
|
||||
"title": item.get("highlightedText"),
|
||||
"subtitle": item.get("highlightedSubText"),
|
||||
"snippet": item.get("highlightedExtra"),
|
||||
}
|
||||
snippet_source = highlights.get("snippet") or item.get("extra")
|
||||
metadata = {
|
||||
"postId": item.get("postId"),
|
||||
"highlights": {k: v for k, v in highlights.items() if v},
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type=str(item.get("type", "result")),
|
||||
id=_safe_int(item.get("id")),
|
||||
title=highlights.get("title") or _safe_str(item.get("text")),
|
||||
subtitle=highlights.get("subtitle") or _safe_str(item.get("subText")),
|
||||
snippet=self._snippet(snippet_source),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, {}, [])},
|
||||
)
|
||||
|
||||
def _normalise_post(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
author = _safe_dict(item.get("author"))
|
||||
category = _safe_dict(item.get("category"))
|
||||
tags = [tag.get("name") for tag in _safe_iter(item.get("tags")) if isinstance(tag, dict)]
|
||||
metadata = {
|
||||
"author": author.get("username"),
|
||||
"category": category.get("name"),
|
||||
"tags": tags,
|
||||
"views": item.get("views"),
|
||||
"commentCount": item.get("commentCount"),
|
||||
"status": item.get("status"),
|
||||
"apiUrl": f"{self._base_url}/api/posts/{item.get('id')}" if item.get("id") else None,
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type="post",
|
||||
id=_safe_int(item.get("id")),
|
||||
title=_safe_str(item.get("title")),
|
||||
subtitle=_safe_str(category.get("name")),
|
||||
snippet=self._snippet(item.get("content")),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, [], {})},
|
||||
)
|
||||
|
||||
def _normalise_user(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
metadata = {
|
||||
"followers": item.get("followers"),
|
||||
"following": item.get("following"),
|
||||
"totalViews": item.get("totalViews"),
|
||||
"role": item.get("role"),
|
||||
"subscribed": item.get("subscribed"),
|
||||
"apiUrl": f"{self._base_url}/api/users/{item.get('id')}" if item.get("id") else None,
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type="user",
|
||||
id=_safe_int(item.get("id")),
|
||||
title=_safe_str(item.get("username")),
|
||||
subtitle=_safe_str(item.get("email") or item.get("role")),
|
||||
snippet=self._snippet(item.get("introduction")),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, [], {})},
|
||||
)
|
||||
|
||||
def _snippet(self, value: Any) -> str | None:
|
||||
text = _safe_str(value)
|
||||
if not text:
|
||||
return None
|
||||
text = html.unescape(text)
|
||||
text = _TAG_RE.sub(" ", text)
|
||||
text = _WHITESPACE_RE.sub(" ", text).strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= self._snippet_length:
|
||||
return text
|
||||
return text[: self._snippet_length - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive
|
||||
return None
|
||||
|
||||
|
||||
def _safe_str(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _safe_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _safe_iter(value: Any) -> Iterable[Any]:
|
||||
if isinstance(value, list | tuple | set):
|
||||
return value
|
||||
return []
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Application configuration helpers for the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, SecretStr
|
||||
from pydantic.networks import AnyHttpUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration for the MCP server."""
|
||||
|
||||
backend_base_url: AnyHttpUrl = Field(
|
||||
"http://springboot:8080",
|
||||
description="Base URL for the OpenIsle backend service.",
|
||||
)
|
||||
host: str = Field(
|
||||
"0.0.0.0",
|
||||
description="Host interface to bind when running with HTTP transports.",
|
||||
)
|
||||
port: int = Field(
|
||||
8085,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="TCP port for HTTP transports.",
|
||||
)
|
||||
transport: Literal["stdio", "sse", "streamable-http"] = Field(
|
||||
"streamable-http",
|
||||
description="MCP transport to use when running the server.",
|
||||
)
|
||||
request_timeout: float = Field(
|
||||
10.0,
|
||||
gt=0,
|
||||
description="Timeout (seconds) for backend search requests.",
|
||||
)
|
||||
access_token: SecretStr | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional JWT bearer token used for authenticated backend calls. "
|
||||
"When set, tools that support authentication will use this token "
|
||||
"automatically unless an explicit token override is provided."
|
||||
),
|
||||
)
|
||||
log_level: str = Field(
|
||||
"INFO",
|
||||
description=(
|
||||
"Logging level for the MCP server (e.g. DEBUG, INFO, WARNING)."
|
||||
),
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="OPENISLE_MCP_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Return cached application settings."""
|
||||
|
||||
return Settings()
|
||||
71
mcp/src/openisle_mcp/models.py
Normal file
71
mcp/src/openisle_mcp/models.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Shared models for the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchScope(str, Enum):
|
||||
"""Supported search endpoints."""
|
||||
|
||||
GLOBAL = "global"
|
||||
POSTS = "posts"
|
||||
POSTS_CONTENT = "posts_content"
|
||||
POSTS_TITLE = "posts_title"
|
||||
USERS = "users"
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - convenience for logging
|
||||
return self.value
|
||||
|
||||
|
||||
class NormalizedSearchResult(BaseModel):
|
||||
"""Compact structure returned by the MCP search tool."""
|
||||
|
||||
type: str = Field(description="Entity type, e.g. user, post, comment.")
|
||||
id: int | None = Field(default=None, description="Primary identifier of the entity.")
|
||||
title: str | None = Field(default=None, description="Display title for the result.")
|
||||
subtitle: str | None = Field(default=None, description="Secondary line of context.")
|
||||
snippet: str | None = Field(default=None, description="Short summary of the result.")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Additional attributes extracted from the API response.",
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
}
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Payload returned to MCP clients."""
|
||||
|
||||
keyword: str
|
||||
scope: SearchScope
|
||||
endpoint: str
|
||||
limit: int | None = Field(
|
||||
default=None,
|
||||
description="Result limit applied to the request. None means unlimited.",
|
||||
)
|
||||
total_results: int = Field(
|
||||
default=0,
|
||||
description="Total number of items returned by the OpenIsle API before limiting.",
|
||||
)
|
||||
returned_results: int = Field(
|
||||
default=0,
|
||||
description="Number of items returned to the MCP client after limiting.",
|
||||
)
|
||||
normalized: list[NormalizedSearchResult] = Field(
|
||||
default_factory=list,
|
||||
description="Normalised representation of each search hit.",
|
||||
)
|
||||
raw: list[Any] = Field(
|
||||
default_factory=list,
|
||||
description="Raw response objects from the OpenIsle REST API.",
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
"""Pydantic models describing tool inputs and outputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict, field_validator
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result entry."""
|
||||
|
||||
type: str = Field(description="Entity type for the result (post, user, tag, etc.).")
|
||||
id: Optional[int] = Field(default=None, description="Identifier of the matched entity.")
|
||||
text: Optional[str] = Field(default=None, description="Primary text associated with the result.")
|
||||
sub_text: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="subText",
|
||||
description="Secondary text, e.g. a username or excerpt.",
|
||||
)
|
||||
extra: Optional[str] = Field(default=None, description="Additional contextual information.")
|
||||
post_id: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="postId",
|
||||
description="Associated post identifier when relevant.",
|
||||
)
|
||||
highlighted_text: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="highlightedText",
|
||||
description="Highlighted snippet of the primary text if available.",
|
||||
)
|
||||
highlighted_sub_text: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="highlightedSubText",
|
||||
description="Highlighted snippet of the secondary text if available.",
|
||||
)
|
||||
highlighted_extra: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="highlightedExtra",
|
||||
description="Highlighted snippet of extra information if available.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Structured response returned by the search tool."""
|
||||
|
||||
keyword: str = Field(description="The keyword that was searched.")
|
||||
total: int = Field(description="Total number of matches returned by the backend.")
|
||||
results: list[SearchResultItem] = Field(
|
||||
default_factory=list,
|
||||
description="Ordered collection of search results.",
|
||||
)
|
||||
|
||||
|
||||
class AuthorInfo(BaseModel):
|
||||
"""Summary of a post or comment author."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Author identifier.")
|
||||
username: Optional[str] = Field(default=None, description="Author username.")
|
||||
avatar: Optional[str] = Field(default=None, description="URL of the author's avatar.")
|
||||
display_medal: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="displayMedal",
|
||||
description="Medal displayed next to the author, when available.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class CategoryInfo(BaseModel):
|
||||
"""Basic information about a post category."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Category identifier.")
|
||||
name: Optional[str] = Field(default=None, description="Category name.")
|
||||
description: Optional[str] = Field(
|
||||
default=None, description="Human friendly description of the category."
|
||||
)
|
||||
icon: Optional[str] = Field(default=None, description="Icon URL associated with the category.")
|
||||
small_icon: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="smallIcon",
|
||||
description="Compact icon URL for the category.",
|
||||
)
|
||||
count: Optional[int] = Field(default=None, description="Number of posts within the category.")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class TagInfo(BaseModel):
|
||||
"""Details for a tag assigned to a post."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Tag identifier.")
|
||||
name: Optional[str] = Field(default=None, description="Tag name.")
|
||||
description: Optional[str] = Field(default=None, description="Description of the tag.")
|
||||
icon: Optional[str] = Field(default=None, description="Icon URL for the tag.")
|
||||
small_icon: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="smallIcon",
|
||||
description="Compact icon URL for the tag.",
|
||||
)
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="createdAt",
|
||||
description="When the tag was created.",
|
||||
)
|
||||
count: Optional[int] = Field(default=None, description="Number of posts using the tag.")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class ReactionInfo(BaseModel):
|
||||
"""Representation of a reaction on a post or comment."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Reaction identifier.")
|
||||
type: Optional[str] = Field(default=None, description="Reaction type (emoji, like, etc.).")
|
||||
user: Optional[str] = Field(default=None, description="Username of the reacting user.")
|
||||
post_id: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="postId",
|
||||
description="Related post identifier when applicable.",
|
||||
)
|
||||
comment_id: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="commentId",
|
||||
description="Related comment identifier when applicable.",
|
||||
)
|
||||
message_id: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="messageId",
|
||||
description="Related message identifier when applicable.",
|
||||
)
|
||||
reward: Optional[int] = Field(default=None, description="Reward granted for the reaction, if any.")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class CommentData(BaseModel):
|
||||
"""Comment information returned by the backend."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Comment identifier.")
|
||||
content: Optional[str] = Field(default=None, description="Markdown content of the comment.")
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="createdAt",
|
||||
description="Timestamp when the comment was created.",
|
||||
)
|
||||
pinned_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="pinnedAt",
|
||||
description="Timestamp when the comment was pinned, if applicable.",
|
||||
)
|
||||
author: Optional[AuthorInfo] = Field(default=None, description="Author of the comment.")
|
||||
replies: list["CommentData"] = Field(
|
||||
default_factory=list,
|
||||
description="Nested replies associated with the comment.",
|
||||
)
|
||||
reactions: list[ReactionInfo] = Field(
|
||||
default_factory=list,
|
||||
description="Reactions applied to the comment.",
|
||||
)
|
||||
reward: Optional[int] = Field(default=None, description="Reward gained by posting the comment.")
|
||||
point_reward: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="pointReward",
|
||||
description="Points rewarded for the comment.",
|
||||
)
|
||||
|
||||
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."""
|
||||
|
||||
comment: CommentData = Field(description="Reply comment returned by the backend.")
|
||||
|
||||
|
||||
class CommentCreateResult(BaseModel):
|
||||
"""Structured response returned when creating a comment on a post."""
|
||||
|
||||
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."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Post identifier.")
|
||||
title: Optional[str] = Field(default=None, description="Title of the post.")
|
||||
content: Optional[str] = Field(default=None, description="Excerpt or content of the post.")
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="createdAt",
|
||||
description="When the post was created.",
|
||||
)
|
||||
author: Optional[AuthorInfo] = Field(default=None, description="Author who created the post.")
|
||||
category: Optional[CategoryInfo] = Field(default=None, description="Category of the post.")
|
||||
tags: list[TagInfo] = Field(default_factory=list, description="Tags assigned to the post.")
|
||||
views: Optional[int] = Field(default=None, description="Total view count for the post.")
|
||||
comment_count: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="commentCount",
|
||||
description="Number of comments on the post.",
|
||||
)
|
||||
status: Optional[str] = Field(default=None, description="Workflow status of the post.")
|
||||
pinned_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="pinnedAt",
|
||||
description="When the post was pinned, if ever.",
|
||||
)
|
||||
last_reply_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="lastReplyAt",
|
||||
description="Timestamp of the most recent reply.",
|
||||
)
|
||||
reactions: list[ReactionInfo] = Field(
|
||||
default_factory=list,
|
||||
description="Reactions received by the post.",
|
||||
)
|
||||
participants: list[AuthorInfo] = Field(
|
||||
default_factory=list,
|
||||
description="Users participating in the discussion.",
|
||||
)
|
||||
subscribed: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Whether the current user is subscribed to the post.",
|
||||
)
|
||||
reward: Optional[int] = Field(default=None, description="Reward granted for the post.")
|
||||
point_reward: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="pointReward",
|
||||
description="Points granted for the post.",
|
||||
)
|
||||
type: Optional[str] = Field(default=None, description="Type of the post.")
|
||||
lottery: Optional[dict[str, Any]] = Field(
|
||||
default=None, description="Lottery information for the post."
|
||||
)
|
||||
poll: Optional[dict[str, Any]] = Field(
|
||||
default=None, description="Poll information for the post."
|
||||
)
|
||||
rss_excluded: Optional[bool] = Field(
|
||||
default=None,
|
||||
alias="rssExcluded",
|
||||
description="Whether the post is excluded from RSS feeds.",
|
||||
)
|
||||
closed: Optional[bool] = Field(default=None, description="Whether the post is closed for replies.")
|
||||
visible_scope: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="visibleScope",
|
||||
description="Visibility scope configuration for the post.",
|
||||
)
|
||||
|
||||
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."""
|
||||
|
||||
minutes: int = Field(description="Time window, in minutes, used for the query.")
|
||||
total: int = Field(description="Number of posts returned by the backend.")
|
||||
posts: list[PostSummary] = Field(
|
||||
default_factory=list,
|
||||
description="Posts created within the requested time window.",
|
||||
)
|
||||
|
||||
|
||||
CommentData.model_rebuild()
|
||||
|
||||
|
||||
class PostDetail(PostSummary):
|
||||
"""Detailed information for a single post, including comments."""
|
||||
|
||||
comments: list[CommentData] = Field(
|
||||
default_factory=list,
|
||||
description="Comments that belong to the post.",
|
||||
)
|
||||
|
||||
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."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Notification identifier.")
|
||||
type: Optional[str] = Field(default=None, description="Type of the notification.")
|
||||
post: Optional[PostSummary] = Field(
|
||||
default=None, description="Post associated with the notification if applicable."
|
||||
)
|
||||
comment: Optional[CommentData] = Field(
|
||||
default=None, description="Comment referenced by the notification when available."
|
||||
)
|
||||
parent_comment: Optional[CommentData] = Field(
|
||||
default=None,
|
||||
alias="parentComment",
|
||||
description="Parent comment for nested replies, when present.",
|
||||
)
|
||||
from_user: Optional[AuthorInfo] = Field(
|
||||
default=None,
|
||||
alias="fromUser",
|
||||
description="User who triggered the notification.",
|
||||
)
|
||||
reaction_type: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="reactionType",
|
||||
description="Reaction type for reaction-based notifications.",
|
||||
)
|
||||
content: Optional[str] = Field(
|
||||
default=None, description="Additional content or message for the notification."
|
||||
)
|
||||
approved: Optional[bool] = Field(
|
||||
default=None, description="Approval status for moderation notifications."
|
||||
)
|
||||
read: Optional[bool] = Field(default=None, description="Whether the notification is read.")
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="createdAt",
|
||||
description="Timestamp when the notification was created.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class UnreadNotificationsResponse(BaseModel):
|
||||
"""Structured response for unread notification queries."""
|
||||
|
||||
page: int = Field(description="Requested page index for the unread notifications.")
|
||||
size: int = Field(description="Requested page size for the unread notifications.")
|
||||
total: int = Field(description="Number of unread notifications returned in this page.")
|
||||
notifications: list[NotificationData] = Field(
|
||||
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.",
|
||||
)
|
||||
@@ -1,343 +0,0 @@
|
||||
"""HTTP client helpers for talking to the OpenIsle backend endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SearchClient:
|
||||
"""Client for calling the OpenIsle HTTP APIs used by the MCP server."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
access_token: str | None = None,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._access_token = self._sanitize_token(access_token)
|
||||
|
||||
def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
logger.debug(
|
||||
"Creating httpx.AsyncClient for base URL %s with timeout %.2fs",
|
||||
self._base_url,
|
||||
self._timeout,
|
||||
)
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
return self._client
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_token(token: str | None) -> str | None:
|
||||
if token is None:
|
||||
return None
|
||||
stripped = token.strip()
|
||||
return stripped or None
|
||||
|
||||
def update_access_token(self, token: str | None) -> None:
|
||||
"""Update the default access token used for authenticated requests."""
|
||||
|
||||
self._access_token = self._sanitize_token(token)
|
||||
if self._access_token:
|
||||
logger.debug("Configured default access token for SearchClient requests.")
|
||||
else:
|
||||
logger.debug("Cleared default access token for SearchClient requests.")
|
||||
|
||||
def _resolve_token(self, token: str | None) -> str | None:
|
||||
candidate = self._sanitize_token(token)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
return self._access_token
|
||||
|
||||
def _require_token(self, token: str | None) -> str:
|
||||
resolved = self._resolve_token(token)
|
||||
if resolved is None:
|
||||
raise ValueError(
|
||||
"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,
|
||||
accept: str = "application/json",
|
||||
include_json: bool = False,
|
||||
) -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": accept}
|
||||
resolved = self._resolve_token(token)
|
||||
if resolved:
|
||||
headers["Authorization"] = f"Bearer {resolved}"
|
||||
if include_json:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
async def global_search(self, keyword: str) -> list[dict[str, Any]]:
|
||||
"""Call the global search endpoint and return the parsed JSON payload."""
|
||||
|
||||
client = self._get_client()
|
||||
logger.debug("Calling global search with keyword=%s", keyword)
|
||||
response = await client.get(
|
||||
"/api/search/global",
|
||||
params={"keyword": keyword},
|
||||
headers=self._build_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||
raise ValueError(f"Unexpected response format from search endpoint: {formatted}")
|
||||
logger.info(
|
||||
"Global search returned %d results for keyword '%s'",
|
||||
len(payload),
|
||||
keyword,
|
||||
)
|
||||
return [self._ensure_dict(entry) for entry in payload]
|
||||
|
||||
async def reply_to_comment(
|
||||
self,
|
||||
comment_id: int,
|
||||
token: str,
|
||||
content: str,
|
||||
captcha: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reply to an existing comment and return the created reply."""
|
||||
|
||||
client = self._get_client()
|
||||
resolved_token = self._require_token(token)
|
||||
headers = self._build_headers(token=resolved_token, include_json=True)
|
||||
payload: dict[str, Any] = {"content": content}
|
||||
if captcha is not None:
|
||||
stripped_captcha = captcha.strip()
|
||||
if stripped_captcha:
|
||||
payload["captcha"] = stripped_captcha
|
||||
|
||||
logger.debug(
|
||||
"Posting reply to comment_id=%s (captcha=%s)",
|
||||
comment_id,
|
||||
bool(captcha),
|
||||
)
|
||||
response = await client.post(
|
||||
f"/api/comments/{comment_id}/replies",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = self._ensure_dict(response.json())
|
||||
logger.info("Reply to comment_id=%s succeeded with id=%s", comment_id, body.get("id"))
|
||||
return body
|
||||
|
||||
async def reply_to_post(
|
||||
self,
|
||||
post_id: int,
|
||||
token: str,
|
||||
content: str,
|
||||
captcha: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a comment on a post and return the backend payload."""
|
||||
|
||||
client = self._get_client()
|
||||
resolved_token = self._require_token(token)
|
||||
headers = self._build_headers(token=resolved_token, include_json=True)
|
||||
payload: dict[str, Any] = {"content": content}
|
||||
if captcha is not None:
|
||||
stripped_captcha = captcha.strip()
|
||||
if stripped_captcha:
|
||||
payload["captcha"] = stripped_captcha
|
||||
|
||||
logger.debug(
|
||||
"Posting comment to post_id=%s (captcha=%s)",
|
||||
post_id,
|
||||
bool(captcha),
|
||||
)
|
||||
response = await client.post(
|
||||
f"/api/posts/{post_id}/comments",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = self._ensure_dict(response.json())
|
||||
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."""
|
||||
|
||||
client = self._get_client()
|
||||
logger.debug(
|
||||
"Fetching recent posts within last %s minutes",
|
||||
minutes,
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/posts/recent",
|
||||
params={"minutes": minutes},
|
||||
headers=self._build_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||
raise ValueError(
|
||||
f"Unexpected response format from recent posts endpoint: {formatted}"
|
||||
)
|
||||
logger.info(
|
||||
"Fetched %d recent posts for window=%s minutes",
|
||||
len(payload),
|
||||
minutes,
|
||||
)
|
||||
return [self._ensure_dict(entry) for entry in payload]
|
||||
|
||||
async def get_post(self, post_id: int, token: str | None = None) -> dict[str, Any]:
|
||||
"""Retrieve the detailed payload for a single post."""
|
||||
|
||||
client = self._get_client()
|
||||
headers = self._build_headers(token=token)
|
||||
logger.debug("Fetching post details for post_id=%s", post_id)
|
||||
response = await client.get(f"/api/posts/{post_id}", headers=headers)
|
||||
response.raise_for_status()
|
||||
body = self._ensure_dict(response.json())
|
||||
logger.info(
|
||||
"Retrieved post_id=%s successfully with %d top-level comments",
|
||||
post_id,
|
||||
len(body.get("comments", []) if isinstance(body.get("comments"), list) else []),
|
||||
)
|
||||
return body
|
||||
|
||||
async def list_unread_notifications(
|
||||
self,
|
||||
*,
|
||||
page: int = 0,
|
||||
size: int = 30,
|
||||
token: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return unread notifications for the authenticated user."""
|
||||
|
||||
client = self._get_client()
|
||||
resolved_token = self._require_token(token)
|
||||
logger.debug(
|
||||
"Fetching unread notifications with page=%s, size=%s",
|
||||
page,
|
||||
size,
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/notifications/unread",
|
||||
params={"page": page, "size": size},
|
||||
headers=self._build_headers(token=resolved_token),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||
raise ValueError(
|
||||
"Unexpected response format from unread notifications endpoint: "
|
||||
f"{formatted}"
|
||||
)
|
||||
logger.info(
|
||||
"Fetched %d unread notifications (page=%s, size=%s)",
|
||||
len(payload),
|
||||
page,
|
||||
size,
|
||||
)
|
||||
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."""
|
||||
|
||||
if self._client is not None:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
logger.debug("Closed httpx.AsyncClient for SearchClient.")
|
||||
|
||||
@staticmethod
|
||||
def _ensure_dict(entry: Any) -> dict[str, Any]:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError(f"Expected JSON object, got: {type(entry)!r}")
|
||||
return entry
|
||||
File diff suppressed because it is too large
Load Diff
102
mcp/src/openisle_mcp/settings.py
Normal file
102
mcp/src/openisle_mcp/settings.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Environment configuration for the MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
"""Runtime configuration sourced from environment variables."""
|
||||
|
||||
api_base_url: str = Field(
|
||||
default="http://springboot:8080",
|
||||
description="Base URL of the OpenIsle backend REST API.",
|
||||
)
|
||||
request_timeout: float = Field(
|
||||
default=10.0,
|
||||
description="Timeout in seconds for outgoing HTTP requests.",
|
||||
ge=0.1,
|
||||
)
|
||||
default_limit: int = Field(
|
||||
default=20,
|
||||
description="Default maximum number of results returned by the search tool.",
|
||||
)
|
||||
snippet_length: int = Field(
|
||||
default=160,
|
||||
description="Maximum length for the normalised snippet field.",
|
||||
ge=40,
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
"validate_assignment": True,
|
||||
}
|
||||
|
||||
@field_validator("api_base_url", mode="before")
|
||||
@classmethod
|
||||
def _strip_trailing_slash(cls, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if value.endswith("/"):
|
||||
return value.rstrip("/")
|
||||
return value
|
||||
|
||||
@field_validator("default_limit", mode="before")
|
||||
@classmethod
|
||||
def _parse_default_limit(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("default_limit must be an integer") from exc
|
||||
return value
|
||||
|
||||
@field_validator("snippet_length", mode="before")
|
||||
@classmethod
|
||||
def _parse_snippet_length(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("snippet_length must be an integer") from exc
|
||||
return value
|
||||
|
||||
@field_validator("request_timeout", mode="before")
|
||||
@classmethod
|
||||
def _parse_timeout(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("request_timeout must be a number") from exc
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
"""Build a settings object using environment variables."""
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
mapping = {
|
||||
"api_base_url": "OPENISLE_API_BASE_URL",
|
||||
"request_timeout": "OPENISLE_API_TIMEOUT",
|
||||
"default_limit": "OPENISLE_MCP_DEFAULT_LIMIT",
|
||||
"snippet_length": "OPENISLE_MCP_SNIPPET_LENGTH",
|
||||
}
|
||||
for field, env_key in mapping.items():
|
||||
value = os.getenv(env_key)
|
||||
if value is not None and value != "":
|
||||
data[field] = value
|
||||
try:
|
||||
return cls.model_validate(data)
|
||||
except ValidationError as exc: # pragma: no cover - validation errors surface early
|
||||
raise ValueError(
|
||||
"Invalid MCP settings derived from environment variables"
|
||||
) from exc
|
||||
|
||||
def sanitized_base_url(self) -> str:
|
||||
"""Return the API base URL without trailing slashes."""
|
||||
|
||||
return self.api_base_url.rstrip("/")
|
||||
@@ -100,28 +100,10 @@ server {
|
||||
# auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
}
|
||||
|
||||
|
||||
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
|
||||
location ^~ /websocket/ {
|
||||
proxy_pass http://127.0.0.1:8084/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location /mcp {
|
||||
proxy_pass http://127.0.0.1:8085;
|
||||
proxy_pass http://127.0.0.1:8084/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
|
||||
@@ -8,8 +8,11 @@ server {
|
||||
listen 443 ssl;
|
||||
server_name staging.open-isle.com www.staging.open-isle.com;
|
||||
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/staging.open-isle.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/staging.open-isle.com/privkey.pem;
|
||||
# ssl_certificate /etc/letsencrypt/live/open-isle.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/open-isle.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
@@ -37,13 +40,59 @@ server {
|
||||
add_header X-Upstream $upstream_addr always;
|
||||
}
|
||||
|
||||
# 1) 原生 WebSocket
|
||||
location ^~ /api/ws {
|
||||
proxy_pass http://127.0.0.1:8081; # 不要尾随 /,保留原样 URI
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# 升级所需
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# 统一透传这些头(你在 /api/ 有,/api/ws 也要有)
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# 2) SockJS(包含 /info、/iframe.html、/.../websocket 等)
|
||||
location ^~ /api/sockjs {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# 如要同源 iframe 回退,下面两行二选一(或者交给 Spring Security 的 sameOrigin)
|
||||
# proxy_hide_header X-Frame-Options;
|
||||
# add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
}
|
||||
|
||||
# ---------- API ----------
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8081/api/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
|
||||
proxy_set_header Host $host;
|
||||
@@ -60,6 +109,7 @@ server {
|
||||
proxy_cache_bypass 1;
|
||||
}
|
||||
|
||||
# ---------- WEBSOCKET GATEWAY TO :8083 ----------
|
||||
location ^~ /websocket/ {
|
||||
proxy_pass http://127.0.0.1:8083/;
|
||||
proxy_http_version 1.1;
|
||||
@@ -80,24 +130,4 @@ server {
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location /mcp {
|
||||
proxy_pass http://127.0.0.1:8086;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user