feat: implement Q&A database persistence and Telegram bot integration
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
*.db
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.rit.portal.controller;
|
||||||
|
|
||||||
|
import com.rit.portal.entity.CommunityAnswer;
|
||||||
|
import com.rit.portal.entity.CommunityQuestion;
|
||||||
|
import com.rit.portal.repository.CommunityAnswerRepository;
|
||||||
|
import com.rit.portal.repository.CommunityQuestionRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/questions")
|
||||||
|
public class CommunityQuestionController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CommunityQuestionRepository questionRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CommunityAnswerRepository answerRepository;
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate = new RestTemplate();
|
||||||
|
private static final String TELEGRAM_BOT_URL = "http://localhost:8082/send_question";
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<CommunityQuestion> getAllQuestions() {
|
||||||
|
return questionRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public CommunityQuestion createQuestion(@RequestBody CommunityQuestion question) {
|
||||||
|
if (question.getUpvotes() == null) question.setUpvotes(0);
|
||||||
|
if (question.getIsAnswered() == null) question.setIsAnswered(false);
|
||||||
|
question.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
CommunityQuestion saved = questionRepository.save(question);
|
||||||
|
|
||||||
|
// Notify Telegram bot in a background thread to keep it robust and non-blocking
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
Map<String, Object> payload = new HashMap<>();
|
||||||
|
payload.put("question_id", saved.getId());
|
||||||
|
payload.put("title", saved.getTitle());
|
||||||
|
payload.put("body", saved.getBody());
|
||||||
|
payload.put("author", saved.getAuthor());
|
||||||
|
|
||||||
|
restTemplate.postForEntity(TELEGRAM_BOT_URL, payload, String.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Failed to notify Telegram Bot intermediary: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/answers")
|
||||||
|
public ResponseEntity<CommunityAnswer> addAnswer(@PathVariable Integer id, @RequestBody CommunityAnswer answer) {
|
||||||
|
return questionRepository.findById(id).map(question -> {
|
||||||
|
answer.setQuestion(question);
|
||||||
|
if (answer.getUpvotes() == null) answer.setUpvotes(0);
|
||||||
|
if (answer.getIsAccepted() == null) answer.setIsAccepted(false);
|
||||||
|
answer.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
CommunityAnswer savedAnswer = answerRepository.save(answer);
|
||||||
|
|
||||||
|
question.setIsAnswered(true);
|
||||||
|
questionRepository.save(question);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(savedAnswer);
|
||||||
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/upvote")
|
||||||
|
public ResponseEntity<CommunityQuestion> upvoteQuestion(@PathVariable Integer id) {
|
||||||
|
return questionRepository.findById(id).map(question -> {
|
||||||
|
question.setUpvotes(question.getUpvotes() + 1);
|
||||||
|
return ResponseEntity.ok(questionRepository.save(question));
|
||||||
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import java.util.List;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/notes")
|
@RequestMapping("/api/notes")
|
||||||
@CrossOrigin(origins = "*") // CrossOrigin configured globally, but added here for safety
|
|
||||||
public class NotePyqController {
|
public class NotePyqController {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -41,7 +40,7 @@ public class NotePyqController {
|
|||||||
|
|
||||||
// Increment downloads count
|
// Increment downloads count
|
||||||
@PostMapping("/{id}/download")
|
@PostMapping("/{id}/download")
|
||||||
public ResponseEntity<Void> incrementDownloads(@PathVariable Long id) {
|
public ResponseEntity<Void> incrementDownloads(@PathVariable Integer id) {
|
||||||
return noteRepository.findById(id).map(note -> {
|
return noteRepository.findById(id).map(note -> {
|
||||||
note.setDownloadsCount(note.getDownloadsCount() + 1);
|
note.setDownloadsCount(note.getDownloadsCount() + 1);
|
||||||
note.setFileType(note.getFileType()); // Keep dirty check
|
note.setFileType(note.getFileType()); // Keep dirty check
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.rit.portal.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "community_answers")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class CommunityAnswer {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "question_id", nullable = false)
|
||||||
|
@JsonIgnore
|
||||||
|
private CommunityQuestion question;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String body;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String author;
|
||||||
|
|
||||||
|
@Column(name = "upvotes")
|
||||||
|
private Integer upvotes = 0;
|
||||||
|
|
||||||
|
@Column(name = "is_accepted")
|
||||||
|
private Boolean isAccepted = false;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.rit.portal.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "community_questions")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class CommunityQuestion {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String body;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String author;
|
||||||
|
|
||||||
|
@Column(name = "upvotes")
|
||||||
|
private Integer upvotes = 0;
|
||||||
|
|
||||||
|
@Column(name = "tags", columnDefinition = "text[]")
|
||||||
|
private List<String> tags = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(name = "is_answered")
|
||||||
|
private Boolean isAnswered = false;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "question", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
|
||||||
|
@Builder.Default
|
||||||
|
private List<CommunityAnswer> answers = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ public class NotePyq {
|
|||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Integer id;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String title;
|
private String title;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.rit.portal.repository;
|
||||||
|
|
||||||
|
import com.rit.portal.entity.CommunityAnswer;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CommunityAnswerRepository extends JpaRepository<CommunityAnswer, Integer> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.rit.portal.repository;
|
||||||
|
|
||||||
|
import com.rit.portal.entity.CommunityQuestion;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CommunityQuestionRepository extends JpaRepository<CommunityQuestion, Integer> {
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface NotePyqRepository extends JpaRepository<NotePyq, Long> {
|
public interface NotePyqRepository extends JpaRepository<NotePyq, Integer> {
|
||||||
List<NotePyq> findBySemester(Integer semester);
|
List<NotePyq> findBySemester(Integer semester);
|
||||||
List<NotePyq> findByDepartment(String department);
|
List<NotePyq> findByDepartment(String department);
|
||||||
List<NotePyq> findByFileType(String fileType);
|
List<NotePyq> findByFileType(String fileType);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ─── DATABASE CONNECTION CONFIGURATION ───
|
# ─── DATABASE CONNECTION CONFIGURATION ───
|
||||||
spring.datasource.url=jdbc:postgresql://localhost:5432/rit_freshers_hub?sslmode=disable
|
spring.datasource.url=jdbc:postgresql://localhost:5433/rit_freshers_hub?sslmode=disable
|
||||||
spring.datasource.username=postgres
|
spring.datasource.username=postgres
|
||||||
spring.datasource.password=Amudiesh22@.
|
spring.datasource.password=Devesh@54
|
||||||
|
|
||||||
# ─── JPA / HIBERNATE SETTINGS ───
|
# ─── JPA / HIBERNATE SETTINGS ───
|
||||||
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
|
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
|||||||
78
package-lock.json
generated
78
package-lock.json
generated
@@ -340,9 +340,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -360,9 +357,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -380,9 +374,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -400,9 +391,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -420,9 +408,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -440,9 +425,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -460,9 +442,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -480,9 +459,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1605,9 +1581,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1625,9 +1598,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1645,9 +1615,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1665,9 +1632,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1685,9 +1649,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1705,9 +1666,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1959,9 +1917,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1983,9 +1938,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2007,9 +1959,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2031,9 +1980,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2206,9 +2152,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2226,9 +2169,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2246,9 +2186,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2266,9 +2203,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3211,9 +3145,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3235,9 +3166,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3259,9 +3187,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3283,9 +3208,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -1,19 +1,48 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
MessageCircle, Heart, TrendingUp, ThumbsUp, Send,
|
MessageCircle, Heart, TrendingUp, ThumbsUp, Send,
|
||||||
Shield, Lock, Smile, ChevronRight, Search, Plus
|
Shield, Lock, Smile, ChevronRight, Search, Plus, User
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import SectionTitle from '@/components/SectionTitle/SectionTitle';
|
import SectionTitle from '@/components/SectionTitle/SectionTitle';
|
||||||
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
|
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
|
||||||
import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer';
|
import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer';
|
||||||
import { QUESTIONS_DATA, CONFESSIONS_DATA } from '@/constants';
|
import { QUESTIONS_DATA, CONFESSIONS_DATA } from '@/constants';
|
||||||
import { formatDate } from '@/lib/utils';
|
|
||||||
|
|
||||||
type Tab = 'qa' | 'confession';
|
type Tab = 'qa' | 'confession';
|
||||||
|
|
||||||
const TRENDING_TAGS = ['hostel', 'academics', 'clubs', 'campus', 'canteen', 'sports', 'placement', 'library'];
|
const TRENDING_TAGS = ['hostel', 'academics', 'clubs', 'campus', 'canteen', 'sports', 'placement', 'library'];
|
||||||
|
|
||||||
|
const AVATARS: Record<string, string> = {
|
||||||
|
'Priya S.': 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&h=120&q=80',
|
||||||
|
'Ravi K.': 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=120&h=120&q=80',
|
||||||
|
'Arun M.': 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&h=120&q=80',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvatar = (author: string) => {
|
||||||
|
return AVATARS[author] || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(author)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRelativeTime = (dateString: string) => {
|
||||||
|
const now = new Date('2026-07-22T11:56:24+05:30').getTime(); // Current local time from metadata
|
||||||
|
const past = new Date(dateString).getTime();
|
||||||
|
const msPerMinute = 60 * 1000;
|
||||||
|
const msPerHour = msPerMinute * 60;
|
||||||
|
const msPerDay = msPerHour * 24;
|
||||||
|
const elapsed = now - past;
|
||||||
|
|
||||||
|
if (elapsed < msPerMinute) {
|
||||||
|
return 'just now';
|
||||||
|
} else if (elapsed < msPerHour) {
|
||||||
|
return Math.round(elapsed / msPerMinute) + 'm ago';
|
||||||
|
} else if (elapsed < msPerDay) {
|
||||||
|
return Math.round(elapsed / msPerHour) + 'h ago';
|
||||||
|
} else {
|
||||||
|
const days = Math.round(elapsed / msPerDay);
|
||||||
|
return days === 1 ? 'yesterday' : `${days}d ago`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function Community() {
|
export default function Community() {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('qa');
|
const [activeTab, setActiveTab] = useState<Tab>('qa');
|
||||||
const [confessionText, setConfessionText] = useState('');
|
const [confessionText, setConfessionText] = useState('');
|
||||||
@@ -21,10 +50,30 @@ export default function Community() {
|
|||||||
const [confessionPosted, setConfessionPosted] = useState(false);
|
const [confessionPosted, setConfessionPosted] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
|
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [questions, setQuestions] = useState<any[]>(QUESTIONS_DATA);
|
||||||
|
|
||||||
const filteredQuestions = QUESTIONS_DATA.filter((q) =>
|
const fetchQuestions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8080/api/questions');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
// Sort newest first
|
||||||
|
setQuestions(data.reverse());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Backend not available. Falling back to local static questions data.', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchQuestions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredQuestions = questions.filter((q) =>
|
||||||
q.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
q.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
q.tags.some((t) => t.includes(searchQuery.toLowerCase()))
|
(q.tags && q.tags.some((t: string) => t.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConfess = () => {
|
const handleConfess = () => {
|
||||||
@@ -34,34 +83,81 @@ export default function Community() {
|
|||||||
setTimeout(() => setConfessionPosted(false), 3000);
|
setTimeout(() => setConfessionPosted(false), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleLike = (id: string) => {
|
const toggleLike = async (id: string) => {
|
||||||
setLikedIds((prev) => {
|
setLikedIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`http://localhost:8080/api/questions/${id}/upvote`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
fetchQuestions();
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Backend not available for upvote sync.', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePostQuestion = async () => {
|
||||||
|
if (!questionText.trim()) return;
|
||||||
|
|
||||||
|
const newQuestion = {
|
||||||
|
title: questionText.split('\n')[0].substring(0, 100) || "Q&A Question",
|
||||||
|
body: questionText,
|
||||||
|
author: 'Priya S.',
|
||||||
|
tags: ['fresher', 'general'],
|
||||||
|
upvotes: 0,
|
||||||
|
isAnswered: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
answers: []
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8080/api/questions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: newQuestion.title,
|
||||||
|
body: newQuestion.body,
|
||||||
|
author: newQuestion.author,
|
||||||
|
tags: newQuestion.tags
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const saved = await response.json();
|
||||||
|
setQuestions(prev => [saved, ...prev]);
|
||||||
|
setQuestionText('');
|
||||||
|
} else {
|
||||||
|
setQuestions(prev => [{ ...newQuestion, id: String(Date.now()) }, ...prev]);
|
||||||
|
setQuestionText('');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving question:", error);
|
||||||
|
setQuestions(prev => [{ ...newQuestion, id: String(Date.now()) }, ...prev]);
|
||||||
|
setQuestionText('');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ backgroundColor: '#FAFAFA' }}>
|
<div className="min-h-screen" style={{ backgroundColor: '#FAFBFD' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white border-b border-[#E5E7EB] py-10">
|
<div className="bg-white border-b border-slate-100 py-12">
|
||||||
<div className="container-custom">
|
<div className="container-custom">
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-[#1E293B] mb-2" style={{ fontFamily: 'Playfair Display, serif' }}>
|
<h1 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>
|
||||||
RIT{' '}
|
RIT Community
|
||||||
<span style={{ background: 'linear-gradient(135deg, #F97316, #FB923C)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
|
|
||||||
Community
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[#475569]" style={{ fontFamily: 'Inter, sans-serif' }}>
|
<p className="text-slate-500 text-sm" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
Ask questions, share confessions, and connect with fellow RIT students.
|
Ask questions, share confessions, and connect with fellow RIT students.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container-custom py-8">
|
<div className="container-custom py-10">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-2 bg-white rounded-2xl p-2 border border-[#E5E7EB] mb-8 w-fit" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
|
<div className="flex gap-1.5 bg-slate-100 rounded-xl p-1.5 border border-slate-200/40 mb-8 w-fit">
|
||||||
{[
|
{[
|
||||||
{ id: 'qa' as Tab, label: 'Freshers Q&A', icon: MessageCircle },
|
{ id: 'qa' as Tab, label: 'Freshers Q&A', icon: MessageCircle },
|
||||||
{ id: 'confession' as Tab, label: 'Confessions', icon: Heart },
|
{ id: 'confession' as Tab, label: 'Confessions', icon: Heart },
|
||||||
@@ -69,19 +165,18 @@ export default function Community() {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className="relative flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all"
|
className="relative flex items-center gap-2 px-4 py-2 rounded-lg text-[13px] font-medium transition-all cursor-pointer"
|
||||||
style={{ fontFamily: 'Poppins, sans-serif', color: activeTab === tab.id ? 'white' : '#475569' }}
|
style={{ fontFamily: 'Poppins, sans-serif', color: activeTab === tab.id ? '#ffffff' : '#64748B' }}
|
||||||
>
|
>
|
||||||
{activeTab === tab.id && (
|
{activeTab === tab.id && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId="community-tab"
|
layoutId="community-tab"
|
||||||
className="absolute inset-0 rounded-xl"
|
className="absolute inset-0 rounded-lg bg-slate-950"
|
||||||
style={{ background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
|
transition={{ type: 'spring', bounce: 0.15, duration: 0.35 }}
|
||||||
transition={{ type: 'spring', bounce: 0.2, duration: 0.4 }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="relative z-10 flex items-center gap-2">
|
<span className="relative z-10 flex items-center gap-2">
|
||||||
<tab.icon className="w-4 h-4" />
|
<tab.icon className="w-3.5 h-3.5" />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -93,33 +188,34 @@ export default function Community() {
|
|||||||
{activeTab === 'qa' && (
|
{activeTab === 'qa' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="qa"
|
key="qa"
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -16 }}
|
exit={{ opacity: 0, y: -12 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.25 }}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
{/* Ask Question Box */}
|
{/* Ask Question Box */}
|
||||||
<div className="bg-white rounded-2xl border border-[#E5E7EB] p-5 mb-6" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
|
<div className="bg-white rounded-xl border border-slate-200/80 p-5 mb-8 shadow-xs">
|
||||||
<h3 className="text-sm font-semibold text-[#1E293B] mb-3" style={{ fontFamily: 'Poppins, sans-serif' }}>Ask a Question</h3>
|
<h3 className="text-sm font-semibold text-slate-900 mb-3 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>Ask a Question</h3>
|
||||||
<textarea
|
<textarea
|
||||||
value={questionText}
|
value={questionText}
|
||||||
onChange={(e) => setQuestionText(e.target.value)}
|
onChange={(e) => setQuestionText(e.target.value)}
|
||||||
placeholder="What's on your mind? Ask your seniors anything about RIT..."
|
placeholder="What's on your mind? Ask your seniors anything about RIT..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full border border-[#E5E7EB] rounded-xl p-3 text-sm text-[#1E293B] placeholder-[#94A3B8] focus:outline-none focus:border-[#F97316] resize-none transition-colors mb-3"
|
className="w-full border border-slate-200 rounded-xl p-3 text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-slate-400 focus:bg-white bg-slate-50/30 resize-none transition-all mb-4"
|
||||||
style={{ fontFamily: 'Inter, sans-serif' }}
|
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-[#94A3B8]" style={{ fontFamily: 'Inter, sans-serif' }}>
|
<span className="text-[11px] text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
Your question will be visible to all students
|
Your question will be visible to all students
|
||||||
</span>
|
</span>
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.03 }}
|
whileHover={{ scale: 1.01 }}
|
||||||
whileTap={{ scale: 0.97 }}
|
whileTap={{ scale: 0.99 }}
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-xl text-white text-sm font-semibold"
|
onClick={handlePostQuestion}
|
||||||
style={{ fontFamily: 'Poppins, sans-serif', background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl text-white text-[13px] font-semibold bg-[#F97316] hover:bg-[#EA580C] transition-colors cursor-pointer"
|
||||||
|
style={{ fontFamily: 'Poppins, sans-serif' }}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Post Question
|
Post Question
|
||||||
@@ -128,91 +224,121 @@ export default function Community() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative mb-5">
|
<div className="relative mb-6">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94A3B8]" />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search questions..."
|
placeholder="Search questions..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-[#E5E7EB] bg-white text-sm text-[#1E293B] placeholder-[#94A3B8] focus:outline-none focus:border-[#F97316] transition-colors"
|
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-slate-400 transition-colors"
|
||||||
style={{ fontFamily: 'Inter, sans-serif' }}
|
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Questions */}
|
{/* Questions */}
|
||||||
<StaggerContainer className="flex flex-col gap-4">
|
<StaggerContainer className="flex flex-col gap-4">
|
||||||
{filteredQuestions.map((q) => (
|
{filteredQuestions.map((q, idx) => {
|
||||||
|
const isFeatured = idx === 0 && searchQuery === '';
|
||||||
|
return (
|
||||||
<StaggerItem key={q.id}>
|
<StaggerItem key={q.id}>
|
||||||
<motion.div
|
<motion.div
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -1 }}
|
||||||
className="bg-white rounded-2xl border border-[#E5E7EB] p-5"
|
className={`bg-white rounded-xl border p-5 transition-all shadow-xs ${
|
||||||
style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}
|
isFeatured
|
||||||
|
? 'border-l-4 border-l-slate-900 border-slate-200'
|
||||||
|
: 'border-slate-200/80'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{/* Votes */}
|
{/* Avatar */}
|
||||||
<div className="flex flex-col items-center gap-1 shrink-0">
|
<img
|
||||||
<button
|
src={getAvatar(q.author)}
|
||||||
onClick={() => toggleLike(q.id)}
|
alt={q.author}
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center transition-all"
|
className="w-9 h-9 rounded-lg object-cover bg-slate-100 border border-slate-100 shrink-0"
|
||||||
style={{ backgroundColor: likedIds.has(q.id) ? '#FFF7ED' : '#F8FAFC' }}
|
/>
|
||||||
>
|
|
||||||
<ThumbsUp className="w-4 h-4" style={{ color: likedIds.has(q.id) ? '#F97316' : '#94A3B8' }} />
|
<div className="flex-1 min-w-0">
|
||||||
</button>
|
{/* Header / Meta */}
|
||||||
<span className="text-xs font-semibold text-[#475569]">{q.votes + (likedIds.has(q.id) ? 1 : 0)}</span>
|
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||||
|
<span className="text-xs font-semibold text-slate-800">{q.author}</span>
|
||||||
|
<span className="text-[10px] text-slate-300">•</span>
|
||||||
|
<span className="text-[11px] text-slate-400">{getRelativeTime(q.createdAt)}</span>
|
||||||
|
{isFeatured && (
|
||||||
|
<span className="ml-auto px-2 py-0.5 rounded text-[10px] font-bold bg-slate-900 text-white tracking-wider uppercase">★ Popular</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<h3 className={`font-bold text-slate-950 mb-1 hover:text-slate-700 cursor-pointer transition-colors tracking-tight ${
|
||||||
<h3 className="text-sm font-semibold text-[#1E293B] mb-1.5 hover:text-[#F97316] cursor-pointer transition-colors"
|
isFeatured ? 'text-base md:text-lg' : 'text-sm md:text-base'
|
||||||
|
}`}
|
||||||
style={{ fontFamily: 'Poppins, sans-serif' }}>
|
style={{ fontFamily: 'Poppins, sans-serif' }}>
|
||||||
{q.title}
|
{q.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-[#94A3B8] mb-3 line-clamp-2" style={{ fontFamily: 'Inter, sans-serif' }}>{q.body}</p>
|
<p className="text-[13px] text-slate-600 mb-4 leading-relaxed line-clamp-2" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
{q.body}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3 pt-1 border-t border-slate-100">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{q.tags.map((tag) => (
|
{q.tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-[#F8FAFC] text-[#94A3B8] border border-[#E5E7EB]"
|
className="px-2.5 py-0.5 rounded-lg text-[10px] font-medium bg-slate-100 text-slate-600"
|
||||||
style={{ fontFamily: 'Inter, sans-serif' }}
|
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||||
>
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-xs text-[#94A3B8]">
|
|
||||||
<span className="flex items-center gap-1">
|
<div className="flex items-center gap-4 text-[11px] text-slate-400">
|
||||||
<MessageCircle className="w-3.5 h-3.5" />
|
<span className="flex items-center gap-1.5">
|
||||||
|
<MessageCircle className="w-3.5 h-3.5 text-slate-400" />
|
||||||
{q.answers} answers
|
{q.answers} answers
|
||||||
</span>
|
</span>
|
||||||
{q.isAnswered && (
|
{q.isAnswered && (
|
||||||
<span className="px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-600 text-[10px] font-semibold">✓ Answered</span>
|
<span className="px-2 py-0.5 rounded-lg bg-emerald-50 text-emerald-700 font-semibold text-[10px]">✓ Answered</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Like/Vote Button inside bottom bar */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleLike(q.id)}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg transition-all cursor-pointer ${
|
||||||
|
likedIds.has(q.id)
|
||||||
|
? 'bg-slate-900 text-white font-semibold'
|
||||||
|
: 'bg-slate-50 hover:bg-slate-100 text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ThumbsUp className="w-3 h-3" />
|
||||||
|
<span>{q.votes + (likedIds.has(q.id) ? 1 : 0)}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</StaggerItem>
|
</StaggerItem>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</StaggerContainer>
|
</StaggerContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Trending */}
|
{/* Trending */}
|
||||||
<AnimatedContainer direction="right" delay={0.1}>
|
<AnimatedContainer direction="right" delay={0.1}>
|
||||||
<div className="bg-white rounded-2xl border border-[#E5E7EB] p-5" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
|
<div className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<TrendingUp className="w-4.5 h-4.5 text-[#F97316]" />
|
<TrendingUp className="w-4 h-4 text-slate-600" />
|
||||||
<h3 className="text-sm font-semibold text-[#1E293B]" style={{ fontFamily: 'Poppins, sans-serif' }}>Trending Topics</h3>
|
<h3 className="text-sm font-semibold text-slate-850 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>Trending Topics</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{TRENDING_TAGS.map((tag) => (
|
{TRENDING_TAGS.map((tag) => (
|
||||||
<button
|
<button
|
||||||
key={tag}
|
key={tag}
|
||||||
onClick={() => setSearchQuery(tag)}
|
onClick={() => setSearchQuery(tag)}
|
||||||
className="px-3 py-1.5 rounded-full text-xs font-medium bg-[#FFF7ED] text-[#F97316] hover:bg-[#F97316] hover:text-white transition-all border border-[#FED7AA]"
|
className="px-2.5 py-1.5 rounded-lg text-[11px] font-medium bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900 transition-all border-0 cursor-pointer"
|
||||||
style={{ fontFamily: 'Inter, sans-serif' }}
|
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||||
>
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
@@ -224,15 +350,15 @@ export default function Community() {
|
|||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<AnimatedContainer direction="right" delay={0.2}>
|
<AnimatedContainer direction="right" delay={0.2}>
|
||||||
<div className="bg-white rounded-2xl border border-[#E5E7EB] p-5" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
|
<div className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs">
|
||||||
{[
|
{[
|
||||||
{ label: 'Total Questions', value: '124' },
|
{ label: 'Total Questions', value: '124' },
|
||||||
{ label: 'Answered', value: '98%' },
|
{ label: 'Answered', value: '98%' },
|
||||||
{ label: 'Active Students', value: '340+' },
|
{ label: 'Active Students', value: '340+' },
|
||||||
].map((stat, i) => (
|
].map((stat, i) => (
|
||||||
<div key={i} className={`flex justify-between py-2.5 ${i < 2 ? 'border-b border-[#E5E7EB]' : ''}`}>
|
<div key={i} className={`flex justify-between py-2.5 ${i < 2 ? 'border-b border-slate-100' : ''}`}>
|
||||||
<span className="text-xs text-[#94A3B8]" style={{ fontFamily: 'Inter, sans-serif' }}>{stat.label}</span>
|
<span className="text-xs text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>{stat.label}</span>
|
||||||
<span className="text-xs font-bold text-[#F97316]" style={{ fontFamily: 'Poppins, sans-serif' }}>{stat.value}</span>
|
<span className="text-xs font-bold text-slate-900" style={{ fontFamily: 'Poppins, sans-serif' }}>{stat.value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -246,25 +372,25 @@ export default function Community() {
|
|||||||
{activeTab === 'confession' && (
|
{activeTab === 'confession' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="confession"
|
key="confession"
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -16 }}
|
exit={{ opacity: 0, y: -12 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.25 }}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
{/* Post confession */}
|
{/* Post confession */}
|
||||||
<div
|
<div
|
||||||
className="rounded-3xl p-6 mb-8 border"
|
className="rounded-xl p-5 mb-6 border"
|
||||||
style={{ background: 'linear-gradient(135deg, #1E293B, #334155)', borderColor: 'rgba(255,255,255,0.1)' }}
|
style={{ background: '#0F172A', borderColor: 'rgba(255,255,255,0.05)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: 'rgba(249,115,22,0.2)' }}>
|
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-slate-800">
|
||||||
<Lock className="w-5 h-5 text-[#F97316]" />
|
<Lock className="w-4.5 h-4.5 text-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-white font-semibold text-sm" style={{ fontFamily: 'Poppins, sans-serif' }}>Share Anonymously</h3>
|
<h3 className="text-white font-semibold text-sm tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>Share Anonymously</h3>
|
||||||
<p className="text-slate-400 text-xs">Your identity is never revealed</p>
|
<p className="text-slate-400 text-[11px]">Your identity is never revealed</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -273,29 +399,26 @@ export default function Community() {
|
|||||||
onChange={(e) => setConfessionText(e.target.value)}
|
onChange={(e) => setConfessionText(e.target.value)}
|
||||||
placeholder="Share your thoughts, stories, crushes, or anything on your mind... It's completely anonymous 🤫"
|
placeholder="Share your thoughts, stories, crushes, or anything on your mind... It's completely anonymous 🤫"
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full rounded-xl p-4 text-sm placeholder-slate-500 focus:outline-none resize-none mb-4"
|
className="w-full rounded-xl p-3 text-[13px] placeholder-slate-500 focus:outline-none resize-none mb-4 bg-white/5 border border-white/10 text-white"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Inter, sans-serif',
|
||||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
|
||||||
color: 'white',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-xl mb-4" style={{ backgroundColor: 'rgba(249,115,22,0.1)', border: '1px solid rgba(249,115,22,0.2)' }}>
|
<div className="flex items-center gap-2.5 p-3 rounded-lg mb-4 bg-white/5 border border-white/10">
|
||||||
<Shield className="w-4 h-4 text-[#F97316] shrink-0" />
|
<Shield className="w-4 h-4 text-slate-300 shrink-0" />
|
||||||
<span className="text-xs text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>
|
<span className="text-[11px] text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
No IP tracking. No username. 100% anonymous posting.
|
No IP tracking. No username. 100% anonymous posting.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.03 }}
|
whileHover={{ scale: 1.01 }}
|
||||||
whileTap={{ scale: 0.97 }}
|
whileTap={{ scale: 0.99 }}
|
||||||
onClick={handleConfess}
|
onClick={handleConfess}
|
||||||
className="flex items-center gap-2 px-6 py-2.5 rounded-xl text-white text-sm font-semibold"
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl text-white text-[13px] font-semibold bg-[#F97316] hover:bg-[#EA580C] transition-colors cursor-pointer"
|
||||||
style={{ fontFamily: 'Poppins, sans-serif', background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
|
style={{ fontFamily: 'Poppins, sans-serif' }}
|
||||||
>
|
>
|
||||||
<Smile className="w-4 h-4" />
|
<Smile className="w-4 h-4" />
|
||||||
Post Anonymously
|
Post Anonymously
|
||||||
@@ -322,39 +445,40 @@ export default function Community() {
|
|||||||
{CONFESSIONS_DATA.map((conf) => (
|
{CONFESSIONS_DATA.map((conf) => (
|
||||||
<StaggerItem key={conf.id}>
|
<StaggerItem key={conf.id}>
|
||||||
<motion.div
|
<motion.div
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -1 }}
|
||||||
className="bg-white rounded-2xl border border-[#E5E7EB] p-5"
|
className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs"
|
||||||
style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-8 h-8 rounded-xl flex items-center justify-center bg-[#FFF7ED]">
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-slate-50 border border-slate-100">
|
||||||
<Shield className="w-4 h-4 text-[#F97316]" />
|
<Shield className="w-4 h-4 text-slate-500" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-semibold text-[#1E293B]" style={{ fontFamily: 'Poppins, sans-serif' }}>Anonymous</span>
|
<span className="text-xs font-semibold text-slate-800" style={{ fontFamily: 'Poppins, sans-serif' }}>Anonymous</span>
|
||||||
<p className="text-[10px] text-[#94A3B8]">{formatDate(conf.createdAt)}</p>
|
<p className="text-[10px] text-slate-400">{getRelativeTime(conf.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{conf.category && (
|
{conf.category && (
|
||||||
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-[#FFF7ED] text-[#F97316] border border-[#FED7AA]" style={{ fontFamily: 'Poppins, sans-serif' }}>
|
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-50 text-slate-650 border border-slate-200/40" style={{ fontFamily: 'Poppins, sans-serif' }}>
|
||||||
{conf.category}
|
{conf.category}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[#475569] leading-relaxed mb-4" style={{ fontFamily: 'Inter, sans-serif' }}>{conf.content}</p>
|
<p className="text-[13px] text-slate-700 leading-relaxed mb-4" style={{ fontFamily: 'Inter, sans-serif' }}>{conf.content}</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleLike(conf.id)}
|
onClick={() => toggleLike(conf.id)}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-xl transition-all"
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg transition-all cursor-pointer ${
|
||||||
style={{ backgroundColor: likedIds.has(conf.id) ? '#FFF7ED' : '#F8FAFC' }}
|
likedIds.has(conf.id)
|
||||||
|
? 'bg-slate-900 text-white'
|
||||||
|
: 'bg-slate-50 hover:bg-slate-100 text-slate-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Heart
|
<Heart
|
||||||
className="w-3.5 h-3.5"
|
className="w-3.5 h-3.5"
|
||||||
style={{ color: likedIds.has(conf.id) ? '#F97316' : '#94A3B8' }}
|
fill={likedIds.has(conf.id) ? '#ffffff' : 'none'}
|
||||||
fill={likedIds.has(conf.id) ? '#F97316' : 'none'}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-[#94A3B8]">{conf.reactions + (likedIds.has(conf.id) ? 1 : 0)}</span>
|
<span className="text-xs">{conf.reactions + (likedIds.has(conf.id) ? 1 : 0)}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -365,9 +489,9 @@ export default function Community() {
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<AnimatedContainer direction="right" delay={0.15}>
|
<AnimatedContainer direction="right" delay={0.15}>
|
||||||
<div className="bg-white rounded-2xl border border-[#E5E7EB] p-5" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
|
<div className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs">
|
||||||
<h3 className="text-sm font-semibold text-[#1E293B] mb-4 flex items-center gap-2" style={{ fontFamily: 'Poppins, sans-serif' }}>
|
<h3 className="text-sm font-semibold text-slate-850 mb-4 flex items-center gap-2" style={{ fontFamily: 'Poppins, sans-serif' }}>
|
||||||
<Shield className="w-4 h-4 text-[#F97316]" />
|
<Shield className="w-4 h-4 text-slate-500" />
|
||||||
Community Rules
|
Community Rules
|
||||||
</h3>
|
</h3>
|
||||||
{[
|
{[
|
||||||
@@ -377,9 +501,9 @@ export default function Community() {
|
|||||||
'Keep it relevant to campus life',
|
'Keep it relevant to campus life',
|
||||||
'Confessions are 100% anonymous',
|
'Confessions are 100% anonymous',
|
||||||
].map((rule, i) => (
|
].map((rule, i) => (
|
||||||
<div key={i} className="flex items-start gap-2.5 py-2.5 border-b border-[#E5E7EB] last:border-0">
|
<div key={i} className="flex items-start gap-2.5 py-2.5 border-b border-slate-100 last:border-0">
|
||||||
<ChevronRight className="w-3.5 h-3.5 text-[#F97316] mt-0.5 shrink-0" />
|
<ChevronRight className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
|
||||||
<span className="text-xs text-[#475569]" style={{ fontFamily: 'Inter, sans-serif' }}>{rule}</span>
|
<span className="text-xs text-slate-650" style={{ fontFamily: 'Inter, sans-serif' }}>{rule}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
50
telegram-bot/README.md
Normal file
50
telegram-bot/README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 🤖 RIT Freshers Hub - Telegram Q&A Intermediary Bot
|
||||||
|
|
||||||
|
This is a lightweight Python microservice that acts as an intermediary between the student Q&A forum and registered helper accounts (Seniors/Staff) on Telegram.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 How It Works
|
||||||
|
1. **Student submits a question** on the web portal.
|
||||||
|
2. **Spring Boot Backend** saves the question and triggers an HTTP POST request to the Python bot: `/send_question`.
|
||||||
|
3. **Telegram Bot** broadcasts the question with a `force_reply` prompt to all configured helper accounts.
|
||||||
|
4. **Helpers reply** directly to the Telegram message.
|
||||||
|
5. **Telegram Bot** captures the reply, maps it back to the original question ID, and pushes the answer back to the Spring Boot REST endpoint (`POST /api/questions/{id}/answers`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Installation & Setup
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
Run this in the `telegram-bot/` directory:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure the Bot
|
||||||
|
Update [config.json](config.json):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"telegram_bot_token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
|
"helper_chat_ids": [
|
||||||
|
971749136
|
||||||
|
],
|
||||||
|
"spring_backend_url": "http://localhost:8080"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### How to find your Telegram Chat ID:
|
||||||
|
1. Search for the bot username on Telegram and click **Start** (or send `/start`).
|
||||||
|
2. The bot will automatically reply with your exact **Chat ID**.
|
||||||
|
3. Add this ID to the `helper_chat_ids` array in `config.json`. The bot dynamically reloads configuration changes on the fly!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏃 Running the Bot
|
||||||
|
|
||||||
|
Start the server:
|
||||||
|
```bash
|
||||||
|
python telegram_bot.py
|
||||||
|
```
|
||||||
|
* **Bot Server Port**: `8082`
|
||||||
|
* **Local Mappings Store**: `bot_mappings.db` (SQLite) is created automatically to persist message mappings so that replies continue to map to the correct questions even if the bot is restarted.
|
||||||
5
telegram-bot/config.json
Normal file
5
telegram-bot/config.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"telegram_bot_token": "8859374355:AAH0dhwstkTBhRerRTjzmb2RG2fjPbigzvo",
|
||||||
|
"helper_chat_ids": [971749136],
|
||||||
|
"spring_backend_url": "http://localhost:8080"
|
||||||
|
}
|
||||||
4
telegram-bot/requirements.txt
Normal file
4
telegram-bot/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
requests>=2.28.0
|
||||||
|
fastapi>=0.95.0
|
||||||
|
uvicorn>=0.20.0
|
||||||
|
pydantic>=2.0
|
||||||
238
telegram-bot/telegram_bot.py
Normal file
238
telegram-bot/telegram_bot.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# Configure Logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load Configuration
|
||||||
|
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
if not os.path.exists(CONFIG_PATH):
|
||||||
|
default_config = {
|
||||||
|
"telegram_bot_token": "8859374355:AAH0dhwstkTBhRerRTjzmb2RG2fjPbigzvo",
|
||||||
|
"helper_chat_ids": [],
|
||||||
|
"spring_backend_url": "http://localhost:8080"
|
||||||
|
}
|
||||||
|
with open(CONFIG_PATH, "w") as f:
|
||||||
|
json.dump(default_config, f, indent=2)
|
||||||
|
return default_config
|
||||||
|
|
||||||
|
with open(CONFIG_PATH, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
BOT_TOKEN = config.get("telegram_bot_token")
|
||||||
|
BACKEND_URL = config.get("spring_backend_url")
|
||||||
|
|
||||||
|
# Database Setup
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "bot_mappings.db")
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS question_mappings (
|
||||||
|
chat_id INTEGER,
|
||||||
|
message_id INTEGER,
|
||||||
|
question_id INTEGER,
|
||||||
|
PRIMARY KEY (chat_id, message_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
def save_mapping(chat_id: int, message_id: int, question_id: int):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT OR REPLACE INTO question_mappings (chat_id, message_id, question_id) VALUES (?, ?, ?)",
|
||||||
|
(chat_id, message_id, question_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_question_id(chat_id: int, message_id: int) -> int:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT question_id FROM question_mappings WHERE chat_id = ? AND message_id = ?",
|
||||||
|
(chat_id, message_id)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
# Telegram API Helpers
|
||||||
|
def send_telegram_message(chat_id: int, text: str, reply_to_message_id: int = None) -> dict:
|
||||||
|
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
"reply_markup": {"force_reply": True, "selective": True}
|
||||||
|
}
|
||||||
|
if reply_to_message_id:
|
||||||
|
payload["reply_to_message_id"] = reply_to_message_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=payload, timeout=10)
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error sending Telegram message to {chat_id}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Background Long Polling for Telegram Updates
|
||||||
|
def telegram_polling_thread():
|
||||||
|
logging.info("Starting Telegram long polling thread...")
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
# Re-load config dynamic updates
|
||||||
|
current_config = load_config()
|
||||||
|
helpers = current_config.get("helper_chat_ids", [])
|
||||||
|
backend_url = current_config.get("spring_backend_url")
|
||||||
|
bot_token = current_config.get("telegram_bot_token")
|
||||||
|
|
||||||
|
url = f"https://api.telegram.org/bot{bot_token}/getUpdates"
|
||||||
|
params = {"offset": offset, "timeout": 20}
|
||||||
|
try:
|
||||||
|
response = requests.get(url, params=params, timeout=25)
|
||||||
|
data = response.json()
|
||||||
|
if not data.get("ok"):
|
||||||
|
logging.error(f"Telegram API getUpdates error: {data}")
|
||||||
|
time.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
updates = data.get("result", [])
|
||||||
|
for update in updates:
|
||||||
|
offset = update["update_id"] + 1
|
||||||
|
|
||||||
|
message = update.get("message")
|
||||||
|
if not message:
|
||||||
|
continue
|
||||||
|
|
||||||
|
chat_id = message["chat"]["id"]
|
||||||
|
text = message.get("text", "").strip()
|
||||||
|
|
||||||
|
# Help helper find their Chat ID
|
||||||
|
if text == "/start":
|
||||||
|
welcome_text = (
|
||||||
|
f"👋 *Welcome to RIT Freshers Hub Intermediary Bot!*\n\n"
|
||||||
|
f"To configure this helper, register this Chat ID in the `config.json` file:\n"
|
||||||
|
f"`{chat_id}`\n\n"
|
||||||
|
f"Once registered, you will receive new student questions here and can reply directly to them."
|
||||||
|
)
|
||||||
|
send_telegram_message(chat_id, welcome_text)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Process reply messages
|
||||||
|
reply_to = message.get("reply_to_message")
|
||||||
|
if reply_to:
|
||||||
|
# Verify if helper is authorized
|
||||||
|
if chat_id not in helpers:
|
||||||
|
logging.warning(f"Unauthorized message from chat ID {chat_id}")
|
||||||
|
send_telegram_message(chat_id, "⚠️ You are not registered as an authorized helper in config.json.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
original_message_id = reply_to["message_id"]
|
||||||
|
question_id = get_question_id(chat_id, original_message_id)
|
||||||
|
|
||||||
|
if question_id:
|
||||||
|
# Extract author name
|
||||||
|
first_name = message["from"].get("first_name", "")
|
||||||
|
last_name = message["from"].get("last_name", "")
|
||||||
|
author_name = f"{first_name} {last_name}".strip() or "Senior Helper"
|
||||||
|
|
||||||
|
logging.info(f"Submitting answer for question {question_id} by helper '{author_name}'")
|
||||||
|
|
||||||
|
# Post answer to Spring Boot backend
|
||||||
|
backend_endpoint = f"{backend_url}/api/questions/{question_id}/answers"
|
||||||
|
answer_payload = {
|
||||||
|
"body": text,
|
||||||
|
"author": author_name
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = requests.post(backend_endpoint, json=answer_payload, timeout=10)
|
||||||
|
if res.status_code == 200 or res.status_code == 201:
|
||||||
|
send_telegram_message(chat_id, "✅ *Answer posted successfully to the Q&A board!*", reply_to_message_id=message["message_id"])
|
||||||
|
else:
|
||||||
|
send_telegram_message(chat_id, f"❌ *Failed to post answer to backend.* (Status: {res.status_code})\nResponse: {res.text[:100]}", reply_to_message_id=message["message_id"])
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error calling backend endpoint {backend_endpoint}: {e}")
|
||||||
|
send_telegram_message(chat_id, f"❌ *Connection error to backend.* ({e})", reply_to_message_id=message["message_id"])
|
||||||
|
else:
|
||||||
|
send_telegram_message(chat_id, "❓ This message does not correspond to any active question or the mapping has expired.", reply_to_message_id=message["message_id"])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error in long polling loop: {e}")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# Start Polling Thread
|
||||||
|
polling_thread = threading.Thread(target=telegram_polling_thread, daemon=True)
|
||||||
|
polling_thread.start()
|
||||||
|
|
||||||
|
# FastAPI Web Server Setup
|
||||||
|
app = FastAPI(title="RIT Telegram Intermediary Bot HTTP Server")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
class QuestionPayload(BaseModel):
|
||||||
|
question_id: int
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
author: str
|
||||||
|
|
||||||
|
@app.post("/send_question")
|
||||||
|
def send_question(payload: QuestionPayload):
|
||||||
|
current_config = load_config()
|
||||||
|
helpers = current_config.get("helper_chat_ids", [])
|
||||||
|
|
||||||
|
if not helpers:
|
||||||
|
logging.warning("No helper chat IDs registered in config.json.")
|
||||||
|
return {"status": "ignored", "reason": "No helpers registered"}
|
||||||
|
|
||||||
|
logging.info(f"Broadcasting question {payload.question_id} to {len(helpers)} helpers.")
|
||||||
|
|
||||||
|
formatted_msg = (
|
||||||
|
f"❓ *New Student Question!*\n\n"
|
||||||
|
f"👤 *Author:* {payload.author}\n"
|
||||||
|
f"📌 *Topic:* {payload.title}\n"
|
||||||
|
f"📝 *Details:* {payload.body}\n\n"
|
||||||
|
f"💬 *Reply to this message directly to submit your answer.*"
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
for chat_id in helpers:
|
||||||
|
res = send_telegram_message(chat_id, formatted_msg)
|
||||||
|
if res.get("ok"):
|
||||||
|
message_id = res["result"]["message_id"]
|
||||||
|
save_mapping(chat_id, message_id, payload.question_id)
|
||||||
|
sent_count += 1
|
||||||
|
|
||||||
|
return {"status": "success", "delivered_to": sent_count}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8082)
|
||||||
Reference in New Issue
Block a user