mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-09 00:21:13 +08:00
Compare commits
1 Commits
bugfix/105
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28efd376b6 |
@@ -2,7 +2,6 @@
|
||||
SERVER_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
WEBSOCKET_PORT=8082
|
||||
OPENISLE_MCP_PORT=8085
|
||||
MYSQL_PORT=3306
|
||||
REDIS_PORT=6379
|
||||
RABBITMQ_PORT=5672
|
||||
|
||||
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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pem
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -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,7 +26,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
||||
- 集成 OpenAI 提供的 Markdown 格式化功能
|
||||
- 通过环境变量可调整密码强度、登录方式、保护码等多种配置
|
||||
- 支持图片上传,默认使用腾讯云 COS 扩展
|
||||
- Bot 集成,可在平台内快速连接自定义机器人,并通过 Telegram 的 BotFather 创建和管理消息机器人,拓展社区互动渠道
|
||||
- 默认头像使用 DiceBear Avatars,可通过 `AVATAR_STYLE` 和 `AVATAR_SIZE` 环境变量自定义主题和大小
|
||||
- 浏览器推送通知,离开网站也能及时收到提醒
|
||||
|
||||
## 🌟 项目优势
|
||||
@@ -41,7 +41,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")
|
||||
|
||||
@@ -217,24 +217,8 @@ public class PostController {
|
||||
// userVisitService.recordVisit(auth.getName());
|
||||
// }
|
||||
|
||||
return postMapper.toListDtos(postService.defaultListPosts(ids, tids, page, pageSize));
|
||||
}
|
||||
|
||||
@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)
|
||||
.defaultListPosts(ids, tids, page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
@@ -265,7 +249,11 @@ public class PostController {
|
||||
// userVisitService.recordVisit(auth.getName());
|
||||
// }
|
||||
|
||||
return postMapper.toListDtos(postService.listPostsByViews(ids, tids, page, pageSize));
|
||||
return postService
|
||||
.listPostsByViews(ids, tids, page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/latest-reply")
|
||||
@@ -297,7 +285,8 @@ public class PostController {
|
||||
// userVisitService.recordVisit(auth.getName());
|
||||
// }
|
||||
|
||||
return postMapper.toListDtos(postService.listPostsByLatestReply(ids, tids, page, pageSize));
|
||||
List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
|
||||
return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/featured")
|
||||
@@ -324,6 +313,10 @@ public class PostController {
|
||||
// if (auth != null) {
|
||||
// userVisitService.recordVisit(auth.getName());
|
||||
// }
|
||||
return postMapper.toListDtos(postService.listFeaturedPosts(ids, tids, page, pageSize));
|
||||
return postService
|
||||
.listFeaturedPosts(ids, tids, page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -48,38 +48,6 @@ public class PostMapper {
|
||||
return dto;
|
||||
}
|
||||
|
||||
public List<PostSummaryDto> toListDtos(List<Post> posts) {
|
||||
if (posts == null || posts.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<Long, List<User>> participantsMap = commentService.getParticipantsForPosts(posts, 5);
|
||||
return posts
|
||||
.stream()
|
||||
.map(post -> {
|
||||
PostSummaryDto dto = new PostSummaryDto();
|
||||
applyListFields(post, dto);
|
||||
List<User> participants = participantsMap.get(post.getId());
|
||||
if (participants != null) {
|
||||
dto.setParticipants(
|
||||
participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
|
||||
);
|
||||
} else {
|
||||
dto.setParticipants(List.of());
|
||||
}
|
||||
dto.setReactions(List.of());
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public PostSummaryDto toListDto(Post post) {
|
||||
PostSummaryDto dto = new PostSummaryDto();
|
||||
applyListFields(post, dto);
|
||||
dto.setParticipants(List.of());
|
||||
dto.setReactions(List.of());
|
||||
return dto;
|
||||
}
|
||||
|
||||
public PostDetailDto toDetailDto(Post post, String viewer) {
|
||||
PostDetailDto dto = new PostDetailDto();
|
||||
applyCommon(post, dto);
|
||||
@@ -93,25 +61,6 @@ public class PostMapper {
|
||||
return dto;
|
||||
}
|
||||
|
||||
private void applyListFields(Post post, PostSummaryDto dto) {
|
||||
dto.setId(post.getId());
|
||||
dto.setTitle(post.getTitle());
|
||||
dto.setContent(post.getContent());
|
||||
dto.setCreatedAt(post.getCreatedAt());
|
||||
dto.setAuthor(userMapper.toAuthorDto(post.getAuthor()));
|
||||
dto.setCategory(categoryMapper.toDto(post.getCategory()));
|
||||
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
|
||||
dto.setViews(post.getViews());
|
||||
dto.setCommentCount(post.getCommentCount());
|
||||
dto.setStatus(post.getStatus());
|
||||
dto.setPinnedAt(post.getPinnedAt());
|
||||
dto.setLastReplyAt(post.getLastReplyAt());
|
||||
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||
dto.setClosed(post.isClosed());
|
||||
dto.setVisibleScope(post.getVisibleScope());
|
||||
dto.setType(post.getType());
|
||||
}
|
||||
|
||||
private void applyCommon(Post post, PostSummaryDto dto) {
|
||||
dto.setId(post.getId());
|
||||
dto.setTitle(post.getTitle());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,13 +20,6 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {
|
||||
@org.springframework.data.repository.query.Param("post") Post post
|
||||
);
|
||||
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
"SELECT DISTINCT c.post.id, c.author FROM Comment c WHERE c.post.id IN :postIds"
|
||||
)
|
||||
java.util.List<Object[]> findDistinctAuthorsByPostIds(
|
||||
@org.springframework.data.repository.query.Param("postIds") java.util.List<Long> postIds
|
||||
);
|
||||
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
"SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post"
|
||||
)
|
||||
|
||||
@@ -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,12 +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> findByStatusOrderByPinnedAtDescViewsDesc(PostStatus status, Pageable pageable);
|
||||
List<Post> findByStatusOrderByPinnedAtDescLastReplyAtDesc(PostStatus status, Pageable pageable);
|
||||
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
||||
PostStatus status,
|
||||
LocalDateTime createdAt
|
||||
);
|
||||
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
|
||||
User author,
|
||||
PostStatus status,
|
||||
@@ -45,16 +39,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
PostStatus status,
|
||||
Pageable pageable
|
||||
);
|
||||
List<Post> findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
|
||||
List<Category> categories,
|
||||
PostStatus status,
|
||||
Pageable pageable
|
||||
);
|
||||
List<Post> findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
|
||||
List<Category> categories,
|
||||
PostStatus status,
|
||||
Pageable pageable
|
||||
);
|
||||
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status);
|
||||
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status, Pageable pageable);
|
||||
List<Post> findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List<Tag> tags, PostStatus status);
|
||||
@@ -144,26 +128,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Query(
|
||||
"SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.views DESC"
|
||||
)
|
||||
List<Post> findByAllTagsOrderByPinnedAtDescViewsDesc(
|
||||
@Param("tags") List<Tag> tags,
|
||||
@Param("status") PostStatus status,
|
||||
@Param("tagCount") long tagCount,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Query(
|
||||
"SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.lastReplyAt DESC"
|
||||
)
|
||||
List<Post> findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||
@Param("tags") List<Tag> tags,
|
||||
@Param("status") PostStatus status,
|
||||
@Param("tagCount") long tagCount,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Query(
|
||||
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount"
|
||||
)
|
||||
@@ -206,28 +170,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Query(
|
||||
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.views DESC"
|
||||
)
|
||||
List<Post> findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
|
||||
@Param("categories") List<Category> categories,
|
||||
@Param("tags") List<Tag> tags,
|
||||
@Param("status") PostStatus status,
|
||||
@Param("tagCount") long tagCount,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Query(
|
||||
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.lastReplyAt DESC"
|
||||
)
|
||||
List<Post> findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||
@Param("categories") List<Category> categories,
|
||||
@Param("tags") List<Tag> tags,
|
||||
@Param("status") PostStatus status,
|
||||
@Param("tagCount") long tagCount,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Query(
|
||||
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC"
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,12 +21,8 @@ import com.openisle.service.NotificationService;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -270,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
|
||||
@@ -320,37 +295,6 @@ public class CommentService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<Long, List<User>> getParticipantsForPosts(List<Post> posts, int limit) {
|
||||
if (posts == null || posts.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, LinkedHashSet<User>> map = new HashMap<>();
|
||||
List<Long> postIds = new ArrayList<>(posts.size());
|
||||
for (Post post : posts) {
|
||||
postIds.add(post.getId());
|
||||
LinkedHashSet<User> set = new LinkedHashSet<>();
|
||||
set.add(post.getAuthor());
|
||||
map.put(post.getId(), set);
|
||||
}
|
||||
for (Object[] row : commentRepository.findDistinctAuthorsByPostIds(postIds)) {
|
||||
Long postId = (Long) row[0];
|
||||
User author = (User) row[1];
|
||||
LinkedHashSet<User> set = map.get(postId);
|
||||
if (set != null) {
|
||||
set.add(author);
|
||||
}
|
||||
}
|
||||
Map<Long, List<User>> result = new HashMap<>(map.size());
|
||||
for (Map.Entry<Long, LinkedHashSet<User>> entry : map.entrySet()) {
|
||||
List<User> list = new ArrayList<>(entry.getValue());
|
||||
if (list.size() > limit) {
|
||||
list = list.subList(0, limit);
|
||||
}
|
||||
result.put(entry.getKey(), list);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
|
||||
log.debug("getCommentsByIds called for ids {}", ids);
|
||||
java.util.List<Comment> comments = commentRepository.findAllById(ids);
|
||||
|
||||
@@ -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;
|
||||
@@ -339,7 +338,6 @@ public class PostService {
|
||||
post.setCategory(category);
|
||||
post.setTags(new HashSet<>(tags));
|
||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||
post.setLastReplyAt(LocalDateTime.now());
|
||||
|
||||
// 什么都没设置的情况下,默认为ALL
|
||||
if (Objects.isNull(postVisibleScopeType)) {
|
||||
@@ -665,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,
|
||||
@@ -699,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(),
|
||||
@@ -784,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);
|
||||
}
|
||||
@@ -810,10 +784,9 @@ public class PostService {
|
||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||
|
||||
java.util.List<Post> posts;
|
||||
Pageable pageable = buildPageable(page, pageSize);
|
||||
|
||||
if (!hasCategories && !hasTags) {
|
||||
posts = postRepository.findByStatusOrderByPinnedAtDescViewsDesc(PostStatus.PUBLISHED, pageable);
|
||||
posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
|
||||
} else if (hasCategories) {
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
if (categories.isEmpty()) {
|
||||
@@ -824,18 +797,16 @@ public class PostService {
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
|
||||
categories,
|
||||
tags,
|
||||
PostStatus.PUBLISHED,
|
||||
tags.size(),
|
||||
pageable
|
||||
tags.size()
|
||||
);
|
||||
} else {
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(
|
||||
categories,
|
||||
PostStatus.PUBLISHED,
|
||||
pageable
|
||||
PostStatus.PUBLISHED
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -843,15 +814,10 @@ public class PostService {
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByAllTagsOrderByPinnedAtDescViewsDesc(
|
||||
tags,
|
||||
PostStatus.PUBLISHED,
|
||||
tags.size(),
|
||||
pageable
|
||||
);
|
||||
posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size());
|
||||
}
|
||||
|
||||
return posts;
|
||||
return paginate(sortByPinnedAndViews(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByLatestReply(Integer page, Integer pageSize) {
|
||||
@@ -868,13 +834,9 @@ public class PostService {
|
||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||
|
||||
java.util.List<Post> posts;
|
||||
Pageable pageable = buildPageable(page, pageSize);
|
||||
|
||||
if (!hasCategories && !hasTags) {
|
||||
posts = postRepository.findByStatusOrderByPinnedAtDescLastReplyAtDesc(
|
||||
PostStatus.PUBLISHED,
|
||||
pageable
|
||||
);
|
||||
posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
|
||||
} else if (hasCategories) {
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
if (categories.isEmpty()) {
|
||||
@@ -885,18 +847,16 @@ public class PostService {
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(
|
||||
categories,
|
||||
tags,
|
||||
PostStatus.PUBLISHED,
|
||||
tags.size(),
|
||||
pageable
|
||||
tags.size()
|
||||
);
|
||||
} else {
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(
|
||||
categories,
|
||||
PostStatus.PUBLISHED,
|
||||
pageable
|
||||
PostStatus.PUBLISHED
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -904,15 +864,14 @@ public class PostService {
|
||||
if (tags.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
posts = postRepository.findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||
posts = postRepository.findByAllTagsOrderByCreatedAtDesc(
|
||||
tags,
|
||||
PostStatus.PUBLISHED,
|
||||
tags.size(),
|
||||
pageable
|
||||
tags.size()
|
||||
);
|
||||
}
|
||||
|
||||
return posts;
|
||||
return paginate(sortByPinnedAndLastReply(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByCategories(
|
||||
@@ -1410,13 +1369,6 @@ public class PostService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Pageable buildPageable(Integer page, Integer pageSize) {
|
||||
if (page == null || pageSize == null) {
|
||||
return Pageable.unpaged();
|
||||
}
|
||||
return PageRequest.of(page, pageSize);
|
||||
}
|
||||
|
||||
private List<Post> paginate(List<Post> posts, Integer page, Integer pageSize) {
|
||||
if (page == null || pageSize == null) {
|
||||
return posts;
|
||||
|
||||
@@ -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,4 +0,0 @@
|
||||
-- Backfill last_reply_at for posts without comments to preserve latest-reply ordering
|
||||
UPDATE posts
|
||||
SET last_reply_at = created_at
|
||||
WHERE last_reply_at IS 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
|
||||
|
||||
@@ -178,32 +178,6 @@ services:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
mcp:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/mcp.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_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}
|
||||
ports:
|
||||
- "${OPENISLE_MCP_PORT:-8085}:${OPENISLE_MCP_PORT:-8085}"
|
||||
depends_on:
|
||||
springboot:
|
||||
condition: service_started
|
||||
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
|
||||
@@ -239,6 +213,32 @@ services:
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
mcp-service:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: mcp/Dockerfile
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
|
||||
env_file:
|
||||
- ${ENV_FILE:-../.env}
|
||||
environment:
|
||||
FASTMCP_HOST: 0.0.0.0
|
||||
FASTMCP_PORT: ${MCP_PORT:-8765}
|
||||
OPENISLE_BACKEND_URL: ${OPENISLE_BACKEND_URL:-http://springboot:8080}
|
||||
OPENISLE_BACKEND_TIMEOUT: ${OPENISLE_BACKEND_TIMEOUT:-10}
|
||||
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-sse}
|
||||
OPENISLE_MCP_SSE_MOUNT_PATH: ${OPENISLE_MCP_SSE_MOUNT_PATH:-/mcp}
|
||||
ports:
|
||||
- "${MCP_PORT:-8765}:${MCP_PORT:-8765}"
|
||||
depends_on:
|
||||
springboot:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
frontend_dev:
|
||||
image: node:20
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
mcp/Dockerfile
Normal file
17
mcp/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY mcp/pyproject.toml /app/pyproject.toml
|
||||
COPY mcp/README.md /app/README.md
|
||||
COPY mcp/src /app/src
|
||||
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install .
|
||||
|
||||
EXPOSE 8765
|
||||
|
||||
CMD ["openisle-mcp"]
|
||||
@@ -1,42 +1,39 @@
|
||||
# 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 [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP) server that exposes the OpenIsle
|
||||
search capabilities to AI assistants. The server wraps the existing Spring Boot backend and currently provides a single `search`
|
||||
tool. Future iterations can extend the server with additional functionality such as publishing new posts or moderating content.
|
||||
|
||||
## Configuration
|
||||
## Features
|
||||
|
||||
The server is configured through environment variables (all prefixed with `OPENISLE_MCP_`):
|
||||
- 🔍 **Global search** — delegates to the existing `/api/search/global` endpoint exposed by the OpenIsle backend.
|
||||
- 🧠 **Structured results** — responses include highlights and deep links so AI clients can present the results cleanly.
|
||||
- ⚙️ **Configurable** — point the server at any reachable OpenIsle backend by setting 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. |
|
||||
|
||||
## 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 -e .
|
||||
openisle-mcp --transport stdio # or "sse"/"streamable-http"
|
||||
```
|
||||
|
||||
By default the server listens on port `8085` and serves MCP over Streamable HTTP.
|
||||
Environment variables:
|
||||
|
||||
## Available tools
|
||||
| Variable | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `OPENISLE_BACKEND_URL` | Base URL of the Spring Boot backend | `http://springboot:8080` |
|
||||
| `OPENISLE_BACKEND_TIMEOUT` | Timeout (seconds) for backend HTTP calls | `10` |
|
||||
| `OPENISLE_PUBLIC_BASE_URL` | Optional base URL used to build deep links in search results | *(unset)* |
|
||||
| `OPENISLE_MCP_TRANSPORT` | MCP transport (`stdio`, `sse`, `streamable-http`) | `stdio` |
|
||||
| `OPENISLE_MCP_SSE_MOUNT_PATH` | Mount path when using SSE transport | `/mcp` |
|
||||
| `FASTMCP_HOST` | Host for SSE / HTTP transports | `127.0.0.1` |
|
||||
| `FASTMCP_PORT` | Port for SSE / HTTP transports | `8000` |
|
||||
|
||||
| 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. |
|
||||
## Docker
|
||||
|
||||
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.
|
||||
A dedicated Docker image is provided and wired into `docker-compose.yaml`. The container listens on
|
||||
`${MCP_PORT:-8765}` and connects to the backend service running in the same compose stack.
|
||||
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.25"]
|
||||
build-backend = "hatchling.build"
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[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 capabilities"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "OpenIsle", email = "engineering@openisle.example" }]
|
||||
authors = [{name = "OpenIsle Team"}]
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp>=1.19.0",
|
||||
"httpx>=0.28,<0.29",
|
||||
"pydantic>=2.12,<3",
|
||||
"pydantic-settings>=2.11,<3"
|
||||
"httpx>=0.28.0",
|
||||
"pydantic>=2.12.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
openisle-mcp = "openisle_mcp.server:main"
|
||||
|
||||
[tool.hatch.build]
|
||||
packages = ["src/openisle_mcp"]
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
openisle_mcp = ["py.typed"]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""OpenIsle MCP server package."""
|
||||
|
||||
from .config import Settings, get_settings
|
||||
from importlib import metadata
|
||||
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
try:
|
||||
__version__ = metadata.version("openisle-mcp")
|
||||
except metadata.PackageNotFoundError: # pragma: no cover - best effort during dev
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
79
mcp/src/openisle_mcp/client.py
Normal file
79
mcp/src/openisle_mcp/client.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""HTTP client for talking to the OpenIsle backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
import httpx
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .models import BackendSearchResult
|
||||
|
||||
__all__ = ["BackendClientError", "OpenIsleBackendClient"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackendClientError(RuntimeError):
|
||||
"""Raised when the backend cannot fulfil a request."""
|
||||
|
||||
|
||||
class OpenIsleBackendClient:
|
||||
"""Tiny wrapper around the Spring Boot search endpoints."""
|
||||
|
||||
def __init__(self, base_url: str, timeout: float = 10.0) -> None:
|
||||
if not base_url:
|
||||
raise ValueError("base_url must not be empty")
|
||||
self._base_url = base_url.rstrip("/")
|
||||
timeout = timeout if timeout > 0 else 10.0
|
||||
self._timeout = httpx.Timeout(timeout, connect=timeout, read=timeout)
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return self._base_url
|
||||
|
||||
async def search_global(self, keyword: str) -> List[BackendSearchResult]:
|
||||
"""Call `/api/search/global` and normalise the payload."""
|
||||
|
||||
url = f"{self._base_url}/api/search/global"
|
||||
params = {"keyword": keyword}
|
||||
headers = {"Accept": "application/json"}
|
||||
logger.debug("Calling OpenIsle backend", extra={"url": url, "params": params})
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self._timeout, headers=headers, follow_redirects=True) as client:
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors are rare in tests
|
||||
body_preview = _truncate_body(exc.response.text)
|
||||
raise BackendClientError(
|
||||
f"Backend returned HTTP {exc.response.status_code}: {body_preview}"
|
||||
) from exc
|
||||
except httpx.RequestError as exc: # pragma: no cover - network errors are rare in tests
|
||||
raise BackendClientError(f"Failed to reach backend: {exc}") from exc
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
raise BackendClientError("Backend returned invalid JSON") from exc
|
||||
|
||||
if not isinstance(payload, list):
|
||||
raise BackendClientError("Unexpected search payload type; expected a list")
|
||||
|
||||
results: list[BackendSearchResult] = []
|
||||
for item in payload:
|
||||
try:
|
||||
results.append(BackendSearchResult.model_validate(item))
|
||||
except ValidationError as exc:
|
||||
raise BackendClientError(f"Invalid search result payload: {exc}") from exc
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _truncate_body(body: str, limit: int = 200) -> str:
|
||||
body = body.strip()
|
||||
if len(body) <= limit:
|
||||
return body
|
||||
return f"{body[:limit]}…"
|
||||
@@ -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()
|
||||
58
mcp/src/openisle_mcp/models.py
Normal file
58
mcp/src/openisle_mcp/models.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Pydantic models used by the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
__all__ = [
|
||||
"BackendSearchResult",
|
||||
"SearchResult",
|
||||
"SearchResponse",
|
||||
]
|
||||
|
||||
|
||||
class BackendSearchResult(BaseModel):
|
||||
"""Shape of the payload returned by the OpenIsle backend."""
|
||||
|
||||
type: str
|
||||
id: Optional[int] = None
|
||||
text: Optional[str] = None
|
||||
sub_text: Optional[str] = Field(default=None, alias="subText")
|
||||
extra: Optional[str] = None
|
||||
post_id: Optional[int] = Field(default=None, alias="postId")
|
||||
highlighted_text: Optional[str] = Field(default=None, alias="highlightedText")
|
||||
highlighted_sub_text: Optional[str] = Field(default=None, alias="highlightedSubText")
|
||||
highlighted_extra: Optional[str] = Field(default=None, alias="highlightedExtra")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
"""Structured search result returned to MCP clients."""
|
||||
|
||||
type: str = Field(description="Entity type, e.g. post, comment, user")
|
||||
id: Optional[int] = Field(default=None, description="Primary identifier for the entity")
|
||||
title: Optional[str] = Field(default=None, description="Primary text to display")
|
||||
subtitle: Optional[str] = Field(default=None, description="Secondary text (e.g. author or category)")
|
||||
extra: Optional[str] = Field(default=None, description="Additional descriptive snippet")
|
||||
post_id: Optional[int] = Field(default=None, description="Associated post id for comment results")
|
||||
url: Optional[str] = Field(default=None, description="Deep link to the resource inside OpenIsle")
|
||||
highlights: Dict[str, Optional[str]] = Field(
|
||||
default_factory=dict,
|
||||
description="Highlighted HTML fragments keyed by field name",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Response envelope returned from the MCP search tool."""
|
||||
|
||||
keyword: str = Field(description="Sanitised keyword that was searched for")
|
||||
total_results: int = Field(description="Total number of results returned by the backend")
|
||||
limit: int = Field(description="Maximum number of results included in the response")
|
||||
results: list[SearchResult] = Field(default_factory=list, description="Search results up to the requested limit")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
0
mcp/src/openisle_mcp/py.typed
Normal file
0
mcp/src/openisle_mcp/py.typed
Normal file
@@ -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
@@ -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