feat: enhance chatbot Telegram/Discord integrations, add /remove commands and Q&A reply handling
This commit is contained in:
@@ -2,8 +2,10 @@ package com.rit.portal;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class PortalApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -43,6 +43,10 @@ public class CollabController {
|
||||
public CollabRequest createCollabRequest(@RequestBody CollabRequest request) {
|
||||
if (request.getStatus() == null) request.setStatus("OPEN");
|
||||
if (request.getApplicationsCount() == null) request.setApplicationsCount(0);
|
||||
if (request.getCollaboratorsNeeded() == null || request.getCollaboratorsNeeded() < 1) {
|
||||
request.setCollaboratorsNeeded(1);
|
||||
}
|
||||
if (request.getAcceptedCount() == null) request.setAcceptedCount(0);
|
||||
request.setCreatedAt(LocalDateTime.now());
|
||||
return collabRequestRepository.save(request);
|
||||
}
|
||||
@@ -52,6 +56,10 @@ public class CollabController {
|
||||
@PathVariable Integer id,
|
||||
@RequestBody CollabApplication application) {
|
||||
return collabRequestRepository.findById(id).map(request -> {
|
||||
if ("CLOSED".equalsIgnoreCase(request.getStatus()) || "CANCELLED".equalsIgnoreCase(request.getStatus())) {
|
||||
return ResponseEntity.badRequest().<CollabApplication>build();
|
||||
}
|
||||
|
||||
application.setCollabRequest(request);
|
||||
if (application.getStatus() == null) application.setStatus("PENDING");
|
||||
application.setCreatedAt(LocalDateTime.now());
|
||||
@@ -95,9 +103,44 @@ public class CollabController {
|
||||
@PathVariable Integer applicationId,
|
||||
@RequestParam String status) {
|
||||
return collabApplicationRepository.findById(applicationId).map(app -> {
|
||||
app.setStatus(status.toUpperCase());
|
||||
String newStatus = status.toUpperCase();
|
||||
String oldStatus = app.getStatus();
|
||||
app.setStatus(newStatus);
|
||||
CollabApplication updated = collabApplicationRepository.save(app);
|
||||
|
||||
// Delete request entirely when accepted count meets requested collaborators count
|
||||
if ("ACCEPTED".equals(newStatus) && !"ACCEPTED".equals(oldStatus)) {
|
||||
CollabRequest parentReq = app.getCollabRequest();
|
||||
if (parentReq != null) {
|
||||
int accepted = (parentReq.getAcceptedCount() == null ? 0 : parentReq.getAcceptedCount()) + 1;
|
||||
parentReq.setAcceptedCount(accepted);
|
||||
int needed = parentReq.getCollaboratorsNeeded() == null ? 1 : parentReq.getCollaboratorsNeeded();
|
||||
if (accepted >= needed) {
|
||||
collabRequestRepository.delete(parentReq);
|
||||
} else {
|
||||
collabRequestRepository.save(parentReq);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ResponseEntity.ok(updated);
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping("/active/telegram/{chatId}")
|
||||
public List<CollabRequest> getActiveRequestsByTelegram(@PathVariable Long chatId) {
|
||||
return collabRequestRepository.findByTelegramChatIdAndStatusOrderByCreatedAtDesc(chatId, "OPEN");
|
||||
}
|
||||
|
||||
@GetMapping("/active/discord/{userId}")
|
||||
public List<CollabRequest> getActiveRequestsByDiscord(@PathVariable String userId) {
|
||||
return collabRequestRepository.findByDiscordUserIdAndStatusOrderByCreatedAtDesc(userId, "OPEN");
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteOrCancelCollabRequest(@PathVariable Integer id) {
|
||||
return collabRequestRepository.findById(id).map(req -> {
|
||||
collabRequestRepository.delete(req);
|
||||
return ResponseEntity.noContent().<Void>build();
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.rit.portal.controller;
|
||||
|
||||
import com.rit.portal.entity.LeetcodeProfile;
|
||||
import com.rit.portal.service.LeetcodeService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/leetcode")
|
||||
@CrossOrigin(originPatterns = "*")
|
||||
@RequiredArgsConstructor
|
||||
public class LeetcodeController {
|
||||
|
||||
private final LeetcodeService leetcodeService;
|
||||
|
||||
@GetMapping("/leaderboard")
|
||||
public List<LeetcodeProfile> getLeaderboard() {
|
||||
return leetcodeService.getAllRankedProfiles();
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> registerProfile(@RequestBody RegisterRequest request) {
|
||||
if (request.getLeetcodeUsername() == null || request.getLeetcodeUsername().trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "LeetCode username is required"));
|
||||
}
|
||||
if (request.getStudentName() == null || request.getStudentName().trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Student name is required"));
|
||||
}
|
||||
|
||||
try {
|
||||
LeetcodeProfile profile = leetcodeService.registerOrUpdateStudent(
|
||||
request.getStudentName(),
|
||||
request.getLeetcodeUsername(),
|
||||
request.getDepartment() != null ? request.getDepartment() : "CSE",
|
||||
request.getYear() != null ? request.getYear() : "1st Year"
|
||||
);
|
||||
return ResponseEntity.ok(profile);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("error", "Failed to register profile: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/sync")
|
||||
public ResponseEntity<?> triggerSpacedSync() {
|
||||
leetcodeService.syncAllProfilesWithSpacing();
|
||||
return ResponseEntity.ok(Map.of("message", "24h background sync initiated with 3-second request spacing between users."));
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RegisterRequest {
|
||||
private String studentName;
|
||||
private String leetcodeUsername;
|
||||
private String department;
|
||||
private String year;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,14 @@ public class CollabRequest {
|
||||
@Builder.Default
|
||||
private Integer applicationsCount = 0;
|
||||
|
||||
@Column(name = "collaborators_needed")
|
||||
@Builder.Default
|
||||
private Integer collaboratorsNeeded = 1;
|
||||
|
||||
@Column(name = "accepted_count")
|
||||
@Builder.Default
|
||||
private Integer acceptedCount = 0;
|
||||
|
||||
@Column(name = "created_at")
|
||||
@Builder.Default
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.rit.portal.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "leetcode_profiles")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class LeetcodeProfile {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "student_name", nullable = false)
|
||||
private String studentName;
|
||||
|
||||
@Column(name = "leetcode_username", nullable = false, unique = true)
|
||||
private String leetcodeUsername;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String department;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String year;
|
||||
|
||||
@Column(name = "total_solved")
|
||||
@Builder.Default
|
||||
private Integer totalSolved = 0;
|
||||
|
||||
@Column(name = "easy_solved")
|
||||
@Builder.Default
|
||||
private Integer easySolved = 0;
|
||||
|
||||
@Column(name = "medium_solved")
|
||||
@Builder.Default
|
||||
private Integer mediumSolved = 0;
|
||||
|
||||
@Column(name = "hard_solved")
|
||||
@Builder.Default
|
||||
private Integer hardSolved = 0;
|
||||
|
||||
@Column(name = "ranking")
|
||||
@Builder.Default
|
||||
private Integer ranking = 0;
|
||||
|
||||
@Column(name = "reputation")
|
||||
@Builder.Default
|
||||
private Integer reputation = 0;
|
||||
|
||||
@Column(name = "last_updated")
|
||||
private LocalDateTime lastUpdated;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||
if (lastUpdated == null) lastUpdated = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,6 @@ import java.util.List;
|
||||
@Repository
|
||||
public interface CollabRequestRepository extends JpaRepository<CollabRequest, Integer> {
|
||||
List<CollabRequest> findAllByOrderByCreatedAtDesc();
|
||||
List<CollabRequest> findByTelegramChatIdAndStatusOrderByCreatedAtDesc(Long telegramChatId, String status);
|
||||
List<CollabRequest> findByDiscordUserIdAndStatusOrderByCreatedAtDesc(String discordUserId, String status);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.rit.portal.repository;
|
||||
|
||||
import com.rit.portal.entity.LeetcodeProfile;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface LeetcodeProfileRepository extends JpaRepository<LeetcodeProfile, Integer> {
|
||||
Optional<LeetcodeProfile> findByLeetcodeUsernameIgnoreCase(String leetcodeUsername);
|
||||
List<LeetcodeProfile> findAllByOrderByTotalSolvedDescRankingAsc();
|
||||
boolean existsByLeetcodeUsernameIgnoreCase(String leetcodeUsername);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.rit.portal.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.rit.portal.entity.LeetcodeProfile;
|
||||
import com.rit.portal.repository.LeetcodeProfileRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class LeetcodeService {
|
||||
|
||||
private final LeetcodeProfileRepository repository;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
private static final String LEETCODE_GRAPHQL_URL = "https://leetcode.com/graphql";
|
||||
|
||||
public List<LeetcodeProfile> getAllRankedProfiles() {
|
||||
return repository.findAllByOrderByTotalSolvedDescRankingAsc();
|
||||
}
|
||||
|
||||
public LeetcodeProfile registerOrUpdateStudent(String studentName, String leetcodeUsername, String department, String year) {
|
||||
String cleanUsername = leetcodeUsername.trim();
|
||||
|
||||
LeetcodeProfile profile = repository.findByLeetcodeUsernameIgnoreCase(cleanUsername)
|
||||
.orElse(LeetcodeProfile.builder()
|
||||
.studentName(studentName.trim())
|
||||
.leetcodeUsername(cleanUsername)
|
||||
.department(department.trim())
|
||||
.year(year.trim())
|
||||
.build());
|
||||
|
||||
profile.setStudentName(studentName.trim());
|
||||
profile.setDepartment(department.trim());
|
||||
profile.setYear(year.trim());
|
||||
|
||||
// Fetch fresh stats immediately for registration
|
||||
boolean success = fetchAndUpdateProfile(profile);
|
||||
if (!success) {
|
||||
throw new IllegalArgumentException("LeetCode username '" + cleanUsername + "' could not be found on LeetCode.");
|
||||
}
|
||||
return repository.save(profile);
|
||||
}
|
||||
|
||||
public boolean fetchAndUpdateProfile(LeetcodeProfile profile) {
|
||||
try {
|
||||
log.info("Fetching LeetCode GraphQL stats for: {}", profile.getLeetcodeUsername());
|
||||
|
||||
String graphqlQuery = "{\"query\":\"query getUserProfile($username: String!) { matchedUser(username: $username) { username submitStats: submitStatsGlobal { acSubmissionNum { difficulty count } } profile { ranking reputation } } }\",\"variables\":{\"username\":\"" + profile.getLeetcodeUsername() + "\"}}";
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
headers.set("Referer", "https://leetcode.com/");
|
||||
|
||||
HttpEntity<String> request = new HttpEntity<>(graphqlQuery, headers);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LEETCODE_GRAPHQL_URL, request, String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
||||
JsonNode root = objectMapper.readTree(response.getBody());
|
||||
JsonNode matchedUser = root.path("data").path("matchedUser");
|
||||
|
||||
if (matchedUser.isMissingNode() || matchedUser.isNull()) {
|
||||
log.warn("LeetCode username not found: {}", profile.getLeetcodeUsername());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract ranking and reputation
|
||||
JsonNode profileNode = matchedUser.path("profile");
|
||||
if (!profileNode.isMissingNode()) {
|
||||
profile.setRanking(profileNode.path("ranking").asInt(0));
|
||||
profile.setReputation(profileNode.path("reputation").asInt(0));
|
||||
}
|
||||
|
||||
// Extract problem submission counts
|
||||
JsonNode acSubmissions = matchedUser.path("submitStats").path("acSubmissionNum");
|
||||
if (acSubmissions.isArray()) {
|
||||
for (JsonNode item : acSubmissions) {
|
||||
String diff = item.path("difficulty").asText("");
|
||||
int count = item.path("count").asInt(0);
|
||||
if ("All".equalsIgnoreCase(diff)) {
|
||||
profile.setTotalSolved(count);
|
||||
} else if ("Easy".equalsIgnoreCase(diff)) {
|
||||
profile.setEasySolved(count);
|
||||
} else if ("Medium".equalsIgnoreCase(diff)) {
|
||||
profile.setMediumSolved(count);
|
||||
} else if ("Hard".equalsIgnoreCase(diff)) {
|
||||
profile.setHardSolved(count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
profile.setLastUpdated(LocalDateTime.now());
|
||||
log.info("Successfully updated LeetCode profile for {}: Total Solved = {}", profile.getLeetcodeUsername(), profile.getTotalSolved());
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch LeetCode data for {}: {}", profile.getLeetcodeUsername(), e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic 24-hour scheduled job running at 2:00 AM every day.
|
||||
* Uses spaced requests (3-second delay between each user) to ensure ZERO IP blocking.
|
||||
*/
|
||||
@Scheduled(cron = "0 0 2 * * ?")
|
||||
public void scheduledDailySyncWithSpacedRequests() {
|
||||
log.info("Starting scheduled 24h LeetCode sync job...");
|
||||
syncAllProfilesWithSpacing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual or scheduled trigger to sync all profiles with a 3-second delay between requests.
|
||||
*/
|
||||
public void syncAllProfilesWithSpacing() {
|
||||
List<LeetcodeProfile> profiles = repository.findAll();
|
||||
log.info("Queued {} LeetCode profiles for spaced synchronization.", profiles.size());
|
||||
|
||||
new Thread(() -> {
|
||||
for (int i = 0; i < profiles.size(); i++) {
|
||||
LeetcodeProfile profile = profiles.get(i);
|
||||
try {
|
||||
fetchAndUpdateProfile(profile);
|
||||
repository.save(profile);
|
||||
|
||||
// Space out requests by 3 seconds if not the last item
|
||||
if (i < profiles.size() - 1) {
|
||||
log.info("Waiting 3 seconds before fetching next profile to space out requests...");
|
||||
Thread.sleep(5000);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("Spaced sync job interrupted.");
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
log.error("Error during spaced sync for {}: {}", profile.getLeetcodeUsername(), e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("Finished spaced sync for all LeetCode profiles.");
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user