feat: implement Q&A database persistence and Telegram bot integration

This commit is contained in:
Devesh S
2026-07-22 12:30:21 +05:30
parent 2bf7985f90
commit 8cacd1f23e
16 changed files with 750 additions and 219 deletions

1
.gitignore vendored
View File

@@ -22,3 +22,4 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
*.db

View File

@@ -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());
}
}

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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<>();
}

View File

@@ -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;

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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);

View File

@@ -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
View File

@@ -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": [

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
{
"telegram_bot_token": "8859374355:AAH0dhwstkTBhRerRTjzmb2RG2fjPbigzvo",
"helper_chat_ids": [971749136],
"spring_backend_url": "http://localhost:8080"
}

View File

@@ -0,0 +1,4 @@
requests>=2.28.0
fastapi>=0.95.0
uvicorn>=0.20.0
pydantic>=2.0

View 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)