Improve quiz UX and add Telegram bot
This commit is contained in:
parent
dcab250223
commit
ffeb35922a
8 changed files with 872 additions and 73 deletions
|
|
@ -69,6 +69,18 @@ services:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
quiz-telegram-bot:
|
||||||
|
build: ./telegram-bot
|
||||||
|
env_file:
|
||||||
|
- ./telegram-bot/.env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://pedquiz:<password>@postgres:5432/pedquiz
|
||||||
|
- PUBLIC_APP_URL=${APP_URL:-https://pedshub.com}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# ── Logging: Loki + Promtail + Grafana ──────────────────────────────
|
# ── Logging: Loki + Promtail + Grafana ──────────────────────────────
|
||||||
loki:
|
loki:
|
||||||
image: grafana/loki:3.3.2
|
image: grafana/loki:3.3.2
|
||||||
|
|
|
||||||
|
|
@ -210,11 +210,68 @@ body {
|
||||||
box-shadow: var(--card-shadow);
|
box-shadow: var(--card-shadow);
|
||||||
}
|
}
|
||||||
.question-card h3 { font-size: 1.05rem; line-height: 1.6; margin-bottom: 20px; color: var(--text); font-weight: 600; }
|
.question-card h3 { font-size: 1.05rem; line-height: 1.6; margin-bottom: 20px; color: var(--text); font-weight: 600; }
|
||||||
.manual-highlight-toolbar { display: inline-flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
.question-image-preview {
|
||||||
.manual-highlight-segment { user-select: text; -webkit-user-select: text; }
|
display: block;
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: zoom-in;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.question-image-preview img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 280px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.image-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(15, 23, 42, 0.82);
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
.image-lightbox img {
|
||||||
|
max-width: min(100%, 1100px);
|
||||||
|
max-height: 92vh;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.image-lightbox-close {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.manual-highlight-toolbar {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||||
|
}
|
||||||
|
.manual-highlight-segment { user-select: text; -webkit-user-select: text; touch-action: auto; }
|
||||||
.manual-highlight-active {
|
.manual-highlight-active {
|
||||||
background: linear-gradient(transparent 38%, rgba(253, 224, 71, 0.72) 38%);
|
background: linear-gradient(transparent 38%, rgba(253, 224, 71, 0.72) 38%);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
cursor: context-menu;
|
||||||
box-decoration-break: clone;
|
box-decoration-break: clone;
|
||||||
-webkit-box-decoration-break: clone;
|
-webkit-box-decoration-break: clone;
|
||||||
}
|
}
|
||||||
|
|
@ -401,6 +458,23 @@ body {
|
||||||
.quiz-nav-controls { gap: 6px; }
|
.quiz-nav-controls { gap: 6px; }
|
||||||
.quiz-nav-controls .btn { flex: 1; justify-content: center; padding-left: 10px; padding-right: 10px; }
|
.quiz-nav-controls .btn { flex: 1; justify-content: center; padding-left: 10px; padding-right: 10px; }
|
||||||
.quiz-nav-controls .quiz-nav-toggle { flex: 0 0 auto; }
|
.quiz-nav-controls .quiz-nav-toggle { flex: 0 0 auto; }
|
||||||
|
.manual-highlight-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
position: sticky;
|
||||||
|
top: 54px;
|
||||||
|
z-index: 5;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
.manual-highlight-toolbar .btn {
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 36px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Lesson content (markdown/HTML) ───────────────────────────── */
|
/* ── Lesson content (markdown/HTML) ───────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ const FEATURES = [
|
||||||
title: 'AI Tutor Built In',
|
title: 'AI Tutor Built In',
|
||||||
desc: 'Ask anything mid-study. The AI tutor knows the current question, the correct answer, and pulls in related questions from your bank for context.',
|
desc: 'Ask anything mid-study. The AI tutor knows the current question, the correct answer, and pulls in related questions from your bank for context.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: '🩺',
|
||||||
|
title: 'Clinical Assistant',
|
||||||
|
desc: 'Access pediatric workflows, bedside calculators, dosing support, and clinical references alongside your study tools.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: '🔊',
|
icon: '🔊',
|
||||||
title: 'Audio Mode',
|
title: 'Audio Mode',
|
||||||
|
|
@ -430,7 +435,7 @@ export default function LandingPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── AI Scribe section ──────────────────────────────────────────────── */}
|
{/* ── Clinical tools section ─────────────────────────────────────────── */}
|
||||||
<section style={{ background: 'var(--navbar-bg)', color: 'var(--navbar-fg)', padding: '80px 24px' }}>
|
<section style={{ background: 'var(--navbar-bg)', color: 'var(--navbar-fg)', padding: '80px 24px' }}>
|
||||||
<div style={{ maxWidth: 1000, margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 40, alignItems: 'center' }}>
|
<div style={{ maxWidth: 1000, margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 40, alignItems: 'center' }}>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -438,22 +443,23 @@ export default function LandingPage() {
|
||||||
Also from PedsHub
|
Also from PedsHub
|
||||||
</div>
|
</div>
|
||||||
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 16px', color: '#f1f5f9' }}>
|
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 16px', color: '#f1f5f9' }}>
|
||||||
Pediatric AI Scribe
|
Pediatric Clinical Tools
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ color: '#94a3b8', lineHeight: 1.7, marginBottom: 28, fontSize: '0.95rem' }}>
|
<p style={{ color: '#94a3b8', lineHeight: 1.7, marginBottom: 28, fontSize: '0.95rem' }}>
|
||||||
A clinical assistant built for pediatric providers. Voice-to-note documentation,
|
A pediatric clinical assistant for daily practice: well visit workflows,
|
||||||
age-specific well visit workflows, developmental milestones, vaccine schedules,
|
developmental milestones, vaccine schedules, catch-up planning,
|
||||||
catch-up planners, automatic ICD-10 billing codes, and a full pediatric
|
bedside calculators, dosing support, emergency pathways, and concise
|
||||||
calculators & bedside emergency reference — all in one tool.
|
clinical references in one place, powered by Peds-AI clinical assistant tools.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 32, overflow: 'hidden' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 32, overflow: 'hidden' }}>
|
||||||
{[
|
{[
|
||||||
{ text: '🩺 Well visit planner from newborn through adolescence' },
|
{ text: '🩺 Well visit planner from newborn through adolescence' },
|
||||||
{ text: '📋 Developmental milestone tracking across 4 domains' },
|
{ text: '📋 Developmental milestone tracking across 4 domains' },
|
||||||
{ text: '💉 Full AAP/ACIP vaccine schedule with catch-up planner' },
|
{ text: '💉 Full AAP/ACIP vaccine schedule with catch-up planner' },
|
||||||
{ text: '🎙 AI scribe — speak, get a structured note' },
|
{ text: '💊 Weight-based dosing and pediatric calculators' },
|
||||||
{ text: '💊 Automatic ICD-10 and CPT billing codes' },
|
{ text: '🤖 Peds-AI clinical assistant for quick pediatric guidance' },
|
||||||
{ text: '🚨 Bedside calculators — weight-based dosing, emergency pathways (sepsis, status epilepticus, RSI, burns, anaphylaxis)', badge: 'NEW' },
|
{ text: '🚨 Emergency pathways for sepsis, status epilepticus, RSI, burns, and anaphylaxis', badge: 'NEW' },
|
||||||
|
{ text: '🎙 Optional voice-to-note support for structured documentation' },
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} style={{ fontSize: '0.88rem', color: '#cbd5e1', display: 'flex', gap: 8, alignItems: 'flex-start', overflowWrap: 'break-word', wordBreak: 'break-word', minWidth: 0 }}>
|
<div key={i} style={{ fontSize: '0.88rem', color: '#cbd5e1', display: 'flex', gap: 8, alignItems: 'flex-start', overflowWrap: 'break-word', wordBreak: 'break-word', minWidth: 0 }}>
|
||||||
<span>{item.text}</span>
|
<span>{item.text}</span>
|
||||||
|
|
@ -472,7 +478,7 @@ export default function LandingPage() {
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ display: 'inline-block', textDecoration: 'none', padding: '11px 24px', borderRadius: 10 }}
|
style={{ display: 'inline-block', textDecoration: 'none', padding: '11px 24px', borderRadius: 10 }}
|
||||||
>
|
>
|
||||||
Open AI Scribe ↗
|
Open Clinical Tools ↗
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 130px), 1fr))', gap: 10 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 130px), 1fr))', gap: 10 }}>
|
||||||
|
|
@ -480,9 +486,8 @@ export default function LandingPage() {
|
||||||
{ icon: '📅', label: 'Well Visits', sub: '2wk → 18yr' },
|
{ icon: '📅', label: 'Well Visits', sub: '2wk → 18yr' },
|
||||||
{ icon: '🧠', label: 'Milestones', sub: '2mo → 5yr' },
|
{ icon: '🧠', label: 'Milestones', sub: '2mo → 5yr' },
|
||||||
{ icon: '💉', label: 'Vaccines', sub: 'Full schedule' },
|
{ icon: '💉', label: 'Vaccines', sub: 'Full schedule' },
|
||||||
{ icon: '🎙', label: 'AI Scribe', sub: 'Voice-to-note' },
|
{ icon: '💊', label: 'Dosing', sub: 'Weight-based' },
|
||||||
{ icon: '💊', label: 'ICD-10', sub: 'Auto-suggested' },
|
{ icon: '📋', label: 'Clinical Assistant', sub: 'References + plans' },
|
||||||
{ icon: '📋', label: 'SOAP Notes', sub: 'Structured' },
|
|
||||||
{ icon: '🚨', label: 'Bedside', sub: 'Dosing + pathways' },
|
{ icon: '🚨', label: 'Bedside', sub: 'Dosing + pathways' },
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
|
|
@ -532,7 +537,7 @@ export default function LandingPage() {
|
||||||
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
|
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
|
||||||
<button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button>
|
<button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button>
|
||||||
<a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a>
|
<a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a>
|
||||||
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>AI Scribe</a>
|
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Clinical Tools</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -51,17 +51,20 @@ function getSpeechChunkRange(text, maxWords, activeChunk) {
|
||||||
return { start: words[startWord].start, end: words[endWord].end }
|
return { start: words[startWord].start, end: words[endWord].end }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getManualHighlightSelection() {
|
function getManualHighlightSelection(selection = window.getSelection?.()) {
|
||||||
const selection = window.getSelection?.()
|
|
||||||
if (!selection || selection.rangeCount === 0 || !selection.toString().trim()) return null
|
if (!selection || selection.rangeCount === 0 || !selection.toString().trim()) return null
|
||||||
|
|
||||||
const offsetFromNode = (node, offset) => {
|
const offsetFromNode = (node, offset) => {
|
||||||
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node
|
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node
|
||||||
const span = element?.closest?.('[data-manual-highlight-id]')
|
const span = element?.closest?.('[data-manual-highlight-id]')
|
||||||
if (!span) return null
|
if (!span) return null
|
||||||
|
let charOffset = offset
|
||||||
|
if (node.nodeType !== Node.TEXT_NODE) {
|
||||||
|
charOffset = offset <= 0 ? 0 : Number(span.dataset.end || span.dataset.start || 0) - Number(span.dataset.start || 0)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: span.dataset.manualHighlightId,
|
id: span.dataset.manualHighlightId,
|
||||||
offset: Number(span.dataset.start || 0) + offset,
|
offset: Number(span.dataset.start || 0) + charOffset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +77,7 @@ function getManualHighlightSelection() {
|
||||||
return { id: start.id, ...ordered }
|
return { id: start.id, ...ordered }
|
||||||
}
|
}
|
||||||
|
|
||||||
function ManualHighlightText({ text, textId, highlights = [], speechRange = null }) {
|
function ManualHighlightText({ text, textId, highlights = [], speechRange = null, onRemoveHighlight = null }) {
|
||||||
const ranges = mergeTextRanges(highlights)
|
const ranges = mergeTextRanges(highlights)
|
||||||
const boundaries = new Set([0, (text || '').length])
|
const boundaries = new Set([0, (text || '').length])
|
||||||
ranges.forEach(range => { boundaries.add(range.start); boundaries.add(range.end) })
|
ranges.forEach(range => { boundaries.add(range.start); boundaries.add(range.end) })
|
||||||
|
|
@ -94,7 +97,18 @@ function ManualHighlightText({ text, textId, highlights = [], speechRange = null
|
||||||
speechHighlighted ? 'speech-highlight-active' : '',
|
speechHighlighted ? 'speech-highlight-active' : '',
|
||||||
].filter(Boolean).join(' ')
|
].filter(Boolean).join(' ')
|
||||||
return (
|
return (
|
||||||
<span key={`${start}-${end}`} className={className} data-manual-highlight-id={textId} data-start={start}>
|
<span
|
||||||
|
key={`${start}-${end}`}
|
||||||
|
className={className}
|
||||||
|
data-manual-highlight-id={textId}
|
||||||
|
data-start={start}
|
||||||
|
data-end={end}
|
||||||
|
onContextMenu={manuallyHighlighted ? (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onRemoveHighlight?.(textId, start)
|
||||||
|
} : undefined}
|
||||||
|
title={manuallyHighlighted ? 'Right-click to remove this highlight' : undefined}
|
||||||
|
>
|
||||||
{text.slice(start, end)}
|
{text.slice(start, end)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
@ -344,8 +358,21 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unique session ID per tab — used to prevent concurrent resume on multiple devices
|
function getQuizSessionId() {
|
||||||
const SESSION_ID = Math.random().toString(36).slice(2) + Date.now().toString(36)
|
const key = 'pedshub_quiz_session_id'
|
||||||
|
try {
|
||||||
|
const existing = localStorage.getItem(key)
|
||||||
|
if (existing) return existing
|
||||||
|
const created = window.crypto?.randomUUID?.() || `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
|
||||||
|
localStorage.setItem(key, created)
|
||||||
|
return created
|
||||||
|
} catch {
|
||||||
|
return `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable per-device session ID lets the same app/webview resume after restart.
|
||||||
|
const SESSION_ID = getQuizSessionId()
|
||||||
|
|
||||||
export default function QuizPage() {
|
export default function QuizPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
|
@ -370,6 +397,7 @@ export default function QuizPage() {
|
||||||
const [totalTime, setTotalTime] = useState(null)
|
const [totalTime, setTotalTime] = useState(null)
|
||||||
const [toast, setToast] = useState('')
|
const [toast, setToast] = useState('')
|
||||||
const [navOpen, setNavOpen] = useState(false)
|
const [navOpen, setNavOpen] = useState(false)
|
||||||
|
const [expandedImagePath, setExpandedImagePath] = useState('')
|
||||||
const [startedAt, setStartedAt] = useState(null)
|
const [startedAt, setStartedAt] = useState(null)
|
||||||
const [favorites, setFavorites] = useState([])
|
const [favorites, setFavorites] = useState([])
|
||||||
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
||||||
|
|
@ -379,6 +407,8 @@ export default function QuizPage() {
|
||||||
const hasStarted = useRef(false)
|
const hasStarted = useRef(false)
|
||||||
const ttsCacheRef = useRef(new Map())
|
const ttsCacheRef = useRef(new Map())
|
||||||
const autoAdvanceRef = useRef(null)
|
const autoAdvanceRef = useRef(null)
|
||||||
|
const savedHighlightSelectionRef = useRef(null)
|
||||||
|
const autoHighlightTimerRef = useRef(null)
|
||||||
|
|
||||||
const showToast = (msg) => {
|
const showToast = (msg) => {
|
||||||
setToast(msg)
|
setToast(msg)
|
||||||
|
|
@ -406,6 +436,54 @@ export default function QuizPage() {
|
||||||
const current = questions[currentIdx]
|
const current = questions[currentIdx]
|
||||||
const isStudy = quizMode === 'study'
|
const isStudy = quizMode === 'study'
|
||||||
|
|
||||||
|
const applyManualHighlightSelection = useCallback((selected = getManualHighlightSelection() || savedHighlightSelectionRef.current) => {
|
||||||
|
if (!selected || !current) return
|
||||||
|
const [questionKey, fieldKey] = selected.id.split('::')
|
||||||
|
if (questionKey !== String(current.id)) return
|
||||||
|
|
||||||
|
const existing = manualHighlights[current.id]?.[fieldKey] || []
|
||||||
|
const removeExisting = existing.some(range => selected.start >= range.start && selected.end <= range.end)
|
||||||
|
|
||||||
|
setManualHighlights(prev => {
|
||||||
|
const questionRanges = prev[current.id] || {}
|
||||||
|
const currentRanges = questionRanges[fieldKey] || []
|
||||||
|
const nextRanges = removeExisting
|
||||||
|
? removeTextRange(currentRanges, selected)
|
||||||
|
: mergeTextRanges([...currentRanges, { start: selected.start, end: selected.end }])
|
||||||
|
const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges }
|
||||||
|
if (!nextRanges.length) delete nextQuestion[fieldKey]
|
||||||
|
const next = { ...prev, [current.id]: nextQuestion }
|
||||||
|
if (!Object.keys(nextQuestion).length) delete next[current.id]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
window.getSelection?.().removeAllRanges()
|
||||||
|
savedHighlightSelectionRef.current = null
|
||||||
|
showToast(removeExisting ? 'Highlight removed' : 'Highlighted')
|
||||||
|
}, [current?.id, manualHighlights])
|
||||||
|
|
||||||
|
const captureHighlightSelection = useCallback(() => {
|
||||||
|
const selected = getManualHighlightSelection()
|
||||||
|
if (!selected || !current) return
|
||||||
|
const [questionKey] = selected.id.split('::')
|
||||||
|
if (questionKey !== String(current.id)) return
|
||||||
|
savedHighlightSelectionRef.current = selected
|
||||||
|
clearTimeout(autoHighlightTimerRef.current)
|
||||||
|
autoHighlightTimerRef.current = setTimeout(() => applyManualHighlightSelection(selected), 450)
|
||||||
|
}, [current?.id, applyManualHighlightSelection])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('selectionchange', captureHighlightSelection)
|
||||||
|
document.addEventListener('mouseup', captureHighlightSelection)
|
||||||
|
document.addEventListener('touchend', captureHighlightSelection)
|
||||||
|
return () => {
|
||||||
|
clearTimeout(autoHighlightTimerRef.current)
|
||||||
|
document.removeEventListener('selectionchange', captureHighlightSelection)
|
||||||
|
document.removeEventListener('mouseup', captureHighlightSelection)
|
||||||
|
document.removeEventListener('touchend', captureHighlightSelection)
|
||||||
|
}
|
||||||
|
}, [captureHighlightSelection])
|
||||||
|
|
||||||
const fetchTtsAudio = useCallback(async (text, voice) => {
|
const fetchTtsAudio = useCallback(async (text, voice) => {
|
||||||
const cleanText = (text || '').trim()
|
const cleanText = (text || '').trim()
|
||||||
if (!cleanText) return null
|
if (!cleanText) return null
|
||||||
|
|
@ -439,6 +517,8 @@ export default function QuizPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveReadSegment(null)
|
setActiveReadSegment(null)
|
||||||
setTtsActive(false)
|
setTtsActive(false)
|
||||||
|
savedHighlightSelectionRef.current = null
|
||||||
|
clearTimeout(autoHighlightTimerRef.current)
|
||||||
if (!readThrough) setActiveReadSegment(null)
|
if (!readThrough) setActiveReadSegment(null)
|
||||||
clearTimeout(autoAdvanceRef.current)
|
clearTimeout(autoAdvanceRef.current)
|
||||||
}, [currentIdx, readThrough])
|
}, [currentIdx, readThrough])
|
||||||
|
|
@ -572,6 +652,17 @@ export default function QuizPage() {
|
||||||
}
|
}
|
||||||
// NOW set mode — quiz data is fully loaded, safe to render
|
// NOW set mode — quiz data is fully loaded, safe to render
|
||||||
setQuizMode(mode)
|
setQuizMode(mode)
|
||||||
|
api.post('/attempts/progress', {
|
||||||
|
quiz_id: parseInt(id),
|
||||||
|
attempt_id: aid,
|
||||||
|
answers: {},
|
||||||
|
current_idx: 0,
|
||||||
|
mode,
|
||||||
|
voice: voice || null,
|
||||||
|
time_left: mode === 'exam' && mins ? mins * 60 : null,
|
||||||
|
started_at: now,
|
||||||
|
total_time: mode === 'exam' && mins ? mins * 60 : null,
|
||||||
|
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
||||||
} catch { navigate('/') }
|
} catch { navigate('/') }
|
||||||
finally { setStarting(false) }
|
finally { setStarting(false) }
|
||||||
}
|
}
|
||||||
|
|
@ -590,55 +681,45 @@ const timerStarted = timeLeft !== null
|
||||||
if (timeLeft === 0) handleSubmit(true)
|
if (timeLeft === 0) handleSubmit(true)
|
||||||
}, [timeLeft])
|
}, [timeLeft])
|
||||||
|
|
||||||
|
const saveProgressNow = useCallback((overrides = {}) => {
|
||||||
|
if (!attemptId || !quizMode) return Promise.resolve()
|
||||||
|
return api.post('/attempts/progress', {
|
||||||
|
quiz_id: parseInt(id),
|
||||||
|
attempt_id: attemptId,
|
||||||
|
answers,
|
||||||
|
current_idx: currentIdx,
|
||||||
|
mode: quizMode,
|
||||||
|
voice: selectedVoice || null,
|
||||||
|
time_left: timeLeft,
|
||||||
|
started_at: startedAt,
|
||||||
|
total_time: totalTime,
|
||||||
|
...overrides,
|
||||||
|
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
||||||
|
}, [id, answers, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime])
|
||||||
|
|
||||||
// Save progress to Redis (survives logout/browser change)
|
// Save progress to Redis (survives logout/browser change)
|
||||||
const saveProgressRef = useRef(null)
|
const saveProgressRef = useRef(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!attemptId || !quizMode) return
|
if (!attemptId || !quizMode) return
|
||||||
clearTimeout(saveProgressRef.current)
|
clearTimeout(saveProgressRef.current)
|
||||||
saveProgressRef.current = setTimeout(() => {
|
saveProgressRef.current = setTimeout(() => { saveProgressNow() }, 500)
|
||||||
api.post('/attempts/progress', {
|
|
||||||
quiz_id: parseInt(id),
|
|
||||||
attempt_id: attemptId,
|
|
||||||
answers,
|
|
||||||
current_idx: currentIdx,
|
|
||||||
mode: quizMode,
|
|
||||||
voice: selectedVoice || null,
|
|
||||||
time_left: timeLeft,
|
|
||||||
started_at: startedAt,
|
|
||||||
total_time: totalTime,
|
|
||||||
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
|
||||||
}, 1500) // debounce 1.5s
|
|
||||||
return () => clearTimeout(saveProgressRef.current)
|
return () => clearTimeout(saveProgressRef.current)
|
||||||
}, [answers, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime])
|
}, [saveProgressNow, attemptId, quizMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!attemptId || !quizMode) return
|
||||||
|
const flush = () => { saveProgressNow() }
|
||||||
|
const flushWhenHidden = () => { if (document.visibilityState === 'hidden') flush() }
|
||||||
|
window.addEventListener('pagehide', flush)
|
||||||
|
document.addEventListener('visibilitychange', flushWhenHidden)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pagehide', flush)
|
||||||
|
document.removeEventListener('visibilitychange', flushWhenHidden)
|
||||||
|
}
|
||||||
|
}, [attemptId, quizMode, saveProgressNow])
|
||||||
|
|
||||||
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
|
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||||
|
|
||||||
const updateManualHighlights = (action) => {
|
|
||||||
if (!current) return
|
|
||||||
const selected = getManualHighlightSelection()
|
|
||||||
if (!selected) {
|
|
||||||
showToast('Select question or option text first')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const [questionKey, fieldKey] = selected.id.split('::')
|
|
||||||
if (questionKey !== String(current.id)) {
|
|
||||||
showToast('Select text from the current question')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setManualHighlights(prev => {
|
|
||||||
const questionRanges = prev[current.id] || {}
|
|
||||||
const existing = questionRanges[fieldKey] || []
|
|
||||||
const nextRanges = action === 'remove'
|
|
||||||
? removeTextRange(existing, selected)
|
|
||||||
: mergeTextRanges([...existing, { start: selected.start, end: selected.end }])
|
|
||||||
const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges }
|
|
||||||
if (!nextRanges.length) delete nextQuestion[fieldKey]
|
|
||||||
return { ...prev, [current.id]: nextQuestion }
|
|
||||||
})
|
|
||||||
window.getSelection?.().removeAllRanges()
|
|
||||||
showToast(action === 'remove' ? 'Highlight removed' : 'Highlighted')
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearCurrentHighlights = () => {
|
const clearCurrentHighlights = () => {
|
||||||
if (!current || !manualHighlights[current.id]) return
|
if (!current || !manualHighlights[current.id]) return
|
||||||
setManualHighlights(prev => {
|
setManualHighlights(prev => {
|
||||||
|
|
@ -649,6 +730,25 @@ const timerStarted = timeLeft !== null
|
||||||
showToast('Question highlights cleared')
|
showToast('Question highlights cleared')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeJoinedHighlight = (textId, offset) => {
|
||||||
|
if (!current) return
|
||||||
|
const [questionKey, fieldKey] = textId.split('::')
|
||||||
|
if (questionKey !== String(current.id)) return
|
||||||
|
const existing = manualHighlights[current.id]?.[fieldKey] || []
|
||||||
|
const joined = existing.find(range => offset >= range.start && offset < range.end)
|
||||||
|
if (!joined) return
|
||||||
|
setManualHighlights(prev => {
|
||||||
|
const questionRanges = prev[current.id] || {}
|
||||||
|
const nextRanges = (questionRanges[fieldKey] || []).filter(range => range.start !== joined.start || range.end !== joined.end)
|
||||||
|
const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges }
|
||||||
|
if (!nextRanges.length) delete nextQuestion[fieldKey]
|
||||||
|
const next = { ...prev, [current.id]: nextQuestion }
|
||||||
|
if (!Object.keys(nextQuestion).length) delete next[current.id]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
showToast('Highlight removed')
|
||||||
|
}
|
||||||
|
|
||||||
const highlightsFor = (fieldKey) => manualHighlights[current?.id]?.[fieldKey] || []
|
const highlightsFor = (fieldKey) => manualHighlights[current?.id]?.[fieldKey] || []
|
||||||
|
|
||||||
const hasActiveTextSelection = () => Boolean(window.getSelection?.().toString().trim())
|
const hasActiveTextSelection = () => Boolean(window.getSelection?.().toString().trim())
|
||||||
|
|
@ -889,6 +989,7 @@ const timerStarted = timeLeft !== null
|
||||||
textId={`${current.id}::question`}
|
textId={`${current.id}::question`}
|
||||||
highlights={highlightsFor('question')}
|
highlights={highlightsFor('question')}
|
||||||
speechRange={questionSpeechRange}
|
speechRange={questionSpeechRange}
|
||||||
|
onRemoveHighlight={removeJoinedHighlight}
|
||||||
/>
|
/>
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
|
|
@ -942,13 +1043,7 @@ const timerStarted = timeLeft !== null
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
|
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => updateManualHighlights('add')} title="Highlight selected text">
|
<button className="btn btn-secondary btn-sm" onMouseDown={e => e.preventDefault()} onClick={clearCurrentHighlights} disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
|
||||||
Highlight
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => updateManualHighlights('remove')} title="Remove highlight from selected text">
|
|
||||||
Remove highlight
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={clearCurrentHighlights} disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
|
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -957,10 +1052,15 @@ const timerStarted = timeLeft !== null
|
||||||
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
|
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
|
||||||
</span>
|
</span>
|
||||||
{current.image_path && (
|
{current.image_path && (
|
||||||
<div style={{ margin: '10px 0' }}>
|
<button className="question-image-preview" onClick={() => setExpandedImagePath(current.image_path)} title="Expand image" type="button">
|
||||||
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
|
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
|
||||||
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
|
onError={e => e.currentTarget.closest('button').style.display = 'none'} />
|
||||||
onError={e => e.target.style.display = 'none'} />
|
</button>
|
||||||
|
)}
|
||||||
|
{expandedImagePath && (
|
||||||
|
<div className="image-lightbox" role="dialog" aria-modal="true" aria-label="Expanded question image" onClick={() => setExpandedImagePath('')}>
|
||||||
|
<button className="image-lightbox-close" onClick={() => setExpandedImagePath('')} type="button" aria-label="Close expanded image">×</button>
|
||||||
|
<img src={`/uploads/${expandedImagePath}`} alt="Expanded question illustration" onClick={e => e.stopPropagation()} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
|
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
|
||||||
|
|
@ -994,6 +1094,7 @@ const timerStarted = timeLeft !== null
|
||||||
textId={`${current.id}::${optionFieldKey}`}
|
textId={`${current.id}::${optionFieldKey}`}
|
||||||
highlights={highlightsFor(optionFieldKey)}
|
highlights={highlightsFor(optionFieldKey)}
|
||||||
speechRange={optionSpeechRange}
|
speechRange={optionSpeechRange}
|
||||||
|
onRemoveHighlight={removeJoinedHighlight}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,24 @@ export default function QuizzesPage() {
|
||||||
<QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} />
|
<QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #229ed9', display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '1rem', marginBottom: 4 }}>Use our Telegram bot</div>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||||
|
Start quick random quizzes, browse categories, and study questions from Telegram.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://t.me/pedshubbot"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ textDecoration: 'none', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Open @pedshubbot
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search bar */}
|
{/* Search bar */}
|
||||||
<div className="card" style={{ marginBottom: 16 }}>
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
|
|
||||||
13
telegram-bot/Dockerfile
Normal file
13
telegram-bot/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY bot.py .
|
||||||
|
|
||||||
|
CMD ["python", "bot.py"]
|
||||||
574
telegram-bot/bot.py
Normal file
574
telegram-bot/bot.py
Normal file
|
|
@ -0,0 +1,574 @@
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
|
from telegram.constants import ChatAction, ParseMode
|
||||||
|
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, filters
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
|
||||||
|
logger = logging.getLogger("quiz-telegram-bot")
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
|
||||||
|
DATABASE_URL = os.environ["DATABASE_URL"]
|
||||||
|
DEFAULT_QUIZ_SIZE = int(os.getenv("DEFAULT_QUIZ_SIZE", "20"))
|
||||||
|
MAX_QUIZ_SIZE = int(os.getenv("MAX_QUIZ_SIZE", "50"))
|
||||||
|
PUBLIC_APP_URL = os.getenv("PUBLIC_APP_URL", "https://pedshub.com").rstrip("/")
|
||||||
|
MAX_EXPLANATION_CHARS = int(os.getenv("MAX_EXPLANATION_CHARS", "1800"))
|
||||||
|
|
||||||
|
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
NUMBER_RE = re.compile(r"^\s*(\d{1,3})\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QuizState:
|
||||||
|
questions: list[dict[str, Any]]
|
||||||
|
mode: str = "study"
|
||||||
|
index: int = 0
|
||||||
|
score: int = 0
|
||||||
|
answers: list[tuple[int, str, str, bool]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
active_quizzes: dict[int, QuizState] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def db_query(sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
|
||||||
|
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return list(cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_options(value: Any) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(item) for item in value]
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [str(item) for item in parsed]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def answer_index(question: dict[str, Any], options: list[str]) -> int | None:
|
||||||
|
raw = str(question.get("correct_answer") or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
upper = raw.upper()
|
||||||
|
if len(upper) == 1 and upper in LETTERS:
|
||||||
|
idx = LETTERS.index(upper)
|
||||||
|
return idx if idx < len(options) else None
|
||||||
|
if raw.isdigit():
|
||||||
|
idx = int(raw) - 1
|
||||||
|
return idx if 0 <= idx < len(options) else None
|
||||||
|
for idx, option in enumerate(options):
|
||||||
|
if option.strip().lower() == raw.lower():
|
||||||
|
return idx
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def question_base_sql(where: str = "") -> str:
|
||||||
|
return f"""
|
||||||
|
select q.id, q.question_text, q.options, q.correct_answer, q.explanation, qc.name as category
|
||||||
|
from questions q
|
||||||
|
left join question_categories qc on qc.id = q.question_category_id
|
||||||
|
where q.question_type = 'mcq'
|
||||||
|
and q.options is not null
|
||||||
|
and q.is_shared = 1
|
||||||
|
{where}
|
||||||
|
order by random()
|
||||||
|
limit %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def valid_questions(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
clean = []
|
||||||
|
for row in rows:
|
||||||
|
options = normalize_options(row.get("options"))
|
||||||
|
if len(options) < 2:
|
||||||
|
continue
|
||||||
|
idx = answer_index(row, options)
|
||||||
|
if idx is None:
|
||||||
|
continue
|
||||||
|
row["options"] = options
|
||||||
|
row["correct_index"] = idx
|
||||||
|
clean.append(row)
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def random_questions(limit: int) -> list[dict[str, Any]]:
|
||||||
|
rows = db_query(question_base_sql(), (limit * 2,))
|
||||||
|
return valid_questions(rows)[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def search_questions(term: str, limit: int) -> list[dict[str, Any]]:
|
||||||
|
pattern = f"%{term}%"
|
||||||
|
rows = db_query(
|
||||||
|
question_base_sql("""
|
||||||
|
and (
|
||||||
|
q.question_text ilike %s
|
||||||
|
or q.explanation ilike %s
|
||||||
|
or qc.name ilike %s
|
||||||
|
or exists (
|
||||||
|
select 1
|
||||||
|
from question_tag_links qtl
|
||||||
|
join question_tags qt on qt.id = qtl.tag_id
|
||||||
|
where qtl.question_id = q.id and qt.name ilike %s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
(pattern, pattern, pattern, pattern, limit * 2),
|
||||||
|
)
|
||||||
|
return valid_questions(rows)[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def category_questions(category_id: int, limit: int) -> list[dict[str, Any]]:
|
||||||
|
rows = db_query(question_base_sql("and q.question_category_id = %s"), (category_id, limit * 2))
|
||||||
|
return valid_questions(rows)[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def tag_questions(tag_id: int, limit: int) -> list[dict[str, Any]]:
|
||||||
|
rows = db_query(
|
||||||
|
question_base_sql("and exists (select 1 from question_tag_links qtl where qtl.question_id = q.id and qtl.tag_id = %s)"),
|
||||||
|
(tag_id, limit * 2),
|
||||||
|
)
|
||||||
|
return valid_questions(rows)[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def list_categories() -> list[dict[str, Any]]:
|
||||||
|
return db_query(
|
||||||
|
"""
|
||||||
|
select qc.id, qc.name, count(q.id)::int as count
|
||||||
|
from question_categories qc
|
||||||
|
join questions q on q.question_category_id = qc.id and q.question_type = 'mcq' and q.is_shared = 1
|
||||||
|
group by qc.id, qc.name
|
||||||
|
having count(q.id) > 0
|
||||||
|
order by qc.name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_tags(term: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
where = ""
|
||||||
|
params: list[Any] = []
|
||||||
|
if term:
|
||||||
|
where = "where t.name ilike %s"
|
||||||
|
params.append(f"%{term}%")
|
||||||
|
params.append(limit)
|
||||||
|
return db_query(
|
||||||
|
f"""
|
||||||
|
select t.id, t.name, t.type, count(qtl.question_id)::int as count
|
||||||
|
from question_tags t
|
||||||
|
join question_tag_links qtl on qtl.tag_id = t.id
|
||||||
|
join questions q on q.id = qtl.question_id and q.question_type = 'mcq' and q.is_shared = 1
|
||||||
|
{where}
|
||||||
|
group by t.id, t.name, t.type
|
||||||
|
order by count(qtl.question_id) desc, t.name
|
||||||
|
limit %s
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_count(value: int | None) -> int:
|
||||||
|
if value is None:
|
||||||
|
return DEFAULT_QUIZ_SIZE
|
||||||
|
return max(1, min(MAX_QUIZ_SIZE, value))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_count(args: list[str]) -> int:
|
||||||
|
for arg in args:
|
||||||
|
if arg.isdigit():
|
||||||
|
return clamp_count(int(arg))
|
||||||
|
return DEFAULT_QUIZ_SIZE
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mode(args: list[str]) -> str:
|
||||||
|
lowered = {arg.lower() for arg in args}
|
||||||
|
return "exam" if "exam" in lowered else "study"
|
||||||
|
|
||||||
|
|
||||||
|
async def send_text(update: Update, text: str, reply_markup: InlineKeyboardMarkup | None = None) -> None:
|
||||||
|
if update.message:
|
||||||
|
await update.message.reply_text(
|
||||||
|
text,
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def respond(update: Update, context: ContextTypes.DEFAULT_TYPE | None, text: str, reply_markup: InlineKeyboardMarkup | None = None) -> None:
|
||||||
|
if update.message:
|
||||||
|
await send_text(update, text, reply_markup)
|
||||||
|
elif update.callback_query and update.callback_query.message:
|
||||||
|
await update.callback_query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=reply_markup, disable_web_page_preview=True)
|
||||||
|
elif context and update.effective_chat:
|
||||||
|
await context.bot.send_message(update.effective_chat.id, text, parse_mode=ParseMode.HTML, reply_markup=reply_markup, disable_web_page_preview=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main_menu() -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup([
|
||||||
|
[InlineKeyboardButton("Random 20 study", callback_data="quiz:random:20:study")],
|
||||||
|
[InlineKeyboardButton("Random 20 exam", callback_data="quiz:random:20:exam")],
|
||||||
|
[InlineKeyboardButton("Categories", callback_data="list:categories:0")],
|
||||||
|
[InlineKeyboardButton("Top keywords", callback_data="list:tags:0")],
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def count_menu(kind: str, item_id: int) -> InlineKeyboardMarkup:
|
||||||
|
buttons = []
|
||||||
|
for count in (5, 10, 20, 30, 50):
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton(f"{count} study", callback_data=f"start:{kind}:{item_id}:{count}:study"),
|
||||||
|
InlineKeyboardButton(f"{count} exam", callback_data=f"start:{kind}:{item_id}:{count}:exam"),
|
||||||
|
])
|
||||||
|
return InlineKeyboardMarkup(buttons)
|
||||||
|
|
||||||
|
|
||||||
|
def format_question_review(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int, ok: bool) -> str:
|
||||||
|
lines = [
|
||||||
|
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
|
||||||
|
html.escape(question["question_text"]),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for idx, option in enumerate(question["options"][:8]):
|
||||||
|
marker = ""
|
||||||
|
if idx == correct_idx:
|
||||||
|
marker = " correct"
|
||||||
|
elif idx == chosen_idx:
|
||||||
|
marker = " your answer"
|
||||||
|
lines.append(f"{LETTERS[idx]}. {html.escape(option)}{marker}")
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"Correct." if ok else f"Incorrect. Correct answer: {LETTERS[correct_idx]}",
|
||||||
|
"",
|
||||||
|
f"<b>Explanation:</b> {html.escape(question.get('explanation') or 'No explanation available.')}",
|
||||||
|
])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_text(value: str, limit: int) -> str:
|
||||||
|
value = (value or "").strip()
|
||||||
|
if len(value) <= limit:
|
||||||
|
return value
|
||||||
|
return value[:limit].rsplit(" ", 1)[0].rstrip() + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def answer_feedback(question: dict[str, Any], chosen_idx: int, correct_idx: int, ok: bool) -> str:
|
||||||
|
chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?"
|
||||||
|
correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?"
|
||||||
|
explanation = truncate_text(question.get("explanation") or "No explanation available.", MAX_EXPLANATION_CHARS)
|
||||||
|
return "\n".join([
|
||||||
|
"Correct." if ok else f"Incorrect. You chose {chosen}; correct answer: {correct}.",
|
||||||
|
"",
|
||||||
|
f"<b>Explanation:</b> {html.escape(explanation)}",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def answered_question_text(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int) -> str:
|
||||||
|
lines = [
|
||||||
|
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
|
||||||
|
html.escape(truncate_text(question["question_text"], 1600)),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for idx, option in enumerate(question["options"][:8]):
|
||||||
|
marker = ""
|
||||||
|
if idx == correct_idx:
|
||||||
|
marker = " ✓ correct"
|
||||||
|
elif idx == chosen_idx:
|
||||||
|
marker = " ✗ your answer"
|
||||||
|
lines.append(f"{LETTERS[idx]}. {html.escape(truncate_text(option, 420))}{marker}")
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
f"Category: {html.escape(str(question.get('category') or 'Uncategorized'))}",
|
||||||
|
])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
await send_text(update, "\n".join([
|
||||||
|
"<b>PedQuiz bot</b>",
|
||||||
|
"Send <code>20</code> for a random 20-question study quiz.",
|
||||||
|
"Use /random 20 exam for exam mode.",
|
||||||
|
"Use /categories to choose from PREP/category buckets.",
|
||||||
|
"Use /keywords fever to search generated keywords/subjects.",
|
||||||
|
"Use /search sepsis 20 to quiz by text search.",
|
||||||
|
"Use /stop to end the current quiz.",
|
||||||
|
"",
|
||||||
|
"Study mode shows each answer immediately. Exam mode shows answers at the end.",
|
||||||
|
]), main_menu())
|
||||||
|
|
||||||
|
|
||||||
|
async def random_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
await start_quiz(update, random_questions(parse_count(context.args)), parse_mode(context.args))
|
||||||
|
|
||||||
|
|
||||||
|
async def search_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
if not context.args:
|
||||||
|
await send_text(update, "Usage: /search <topic> [number] [study|exam]")
|
||||||
|
return
|
||||||
|
count = parse_count(context.args)
|
||||||
|
mode = parse_mode(context.args)
|
||||||
|
term = " ".join(arg for arg in context.args if not arg.isdigit() and arg.lower() not in {"study", "exam"}).strip()
|
||||||
|
if not term:
|
||||||
|
await send_text(update, "Usage: /search <topic> [number] [study|exam]")
|
||||||
|
return
|
||||||
|
if update.message:
|
||||||
|
await update.message.chat.send_action(ChatAction.TYPING)
|
||||||
|
await start_quiz(update, search_questions(term, count), mode, label=f"Search: {term}")
|
||||||
|
|
||||||
|
|
||||||
|
async def categories_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
await show_categories(update, page=0)
|
||||||
|
|
||||||
|
|
||||||
|
async def keywords_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
term = " ".join(context.args).strip() if context.args else None
|
||||||
|
rows = search_tags(term, 30)
|
||||||
|
if not rows:
|
||||||
|
await send_text(update, "No keywords found.")
|
||||||
|
return
|
||||||
|
buttons = [
|
||||||
|
[InlineKeyboardButton(f"{row['name']} ({row['type']}, {row['count']})", callback_data=f"pick:tag:{row['id']}")]
|
||||||
|
for row in rows[:20]
|
||||||
|
]
|
||||||
|
await send_text(update, "Choose a keyword/subject, then choose quiz size:", InlineKeyboardMarkup(buttons))
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
if update.effective_chat:
|
||||||
|
active_quizzes.pop(update.effective_chat.id, None)
|
||||||
|
await send_text(update, "Quiz stopped.", main_menu())
|
||||||
|
|
||||||
|
|
||||||
|
async def show_categories(update: Update, page: int) -> None:
|
||||||
|
rows = list_categories()
|
||||||
|
per_page = 10
|
||||||
|
start = page * per_page
|
||||||
|
chunk = rows[start:start + per_page]
|
||||||
|
if not chunk:
|
||||||
|
await send_text(update, "No categories found.")
|
||||||
|
return
|
||||||
|
buttons = [
|
||||||
|
[InlineKeyboardButton(f"{row['name']} ({row['count']})", callback_data=f"pick:category:{row['id']}")]
|
||||||
|
for row in chunk
|
||||||
|
]
|
||||||
|
nav = []
|
||||||
|
if page > 0:
|
||||||
|
nav.append(InlineKeyboardButton("Prev", callback_data=f"list:categories:{page - 1}"))
|
||||||
|
if start + per_page < len(rows):
|
||||||
|
nav.append(InlineKeyboardButton("Next", callback_data=f"list:categories:{page + 1}"))
|
||||||
|
if nav:
|
||||||
|
buttons.append(nav)
|
||||||
|
text = "Choose a category, then choose quiz size and mode."
|
||||||
|
if update.callback_query:
|
||||||
|
await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(buttons))
|
||||||
|
else:
|
||||||
|
await send_text(update, text, InlineKeyboardMarkup(buttons))
|
||||||
|
|
||||||
|
|
||||||
|
async def show_tags(update: Update, page: int) -> None:
|
||||||
|
rows = search_tags(limit=80)
|
||||||
|
per_page = 10
|
||||||
|
start = page * per_page
|
||||||
|
chunk = rows[start:start + per_page]
|
||||||
|
buttons = [
|
||||||
|
[InlineKeyboardButton(f"{row['name']} ({row['type']}, {row['count']})", callback_data=f"pick:tag:{row['id']}")]
|
||||||
|
for row in chunk
|
||||||
|
]
|
||||||
|
nav = []
|
||||||
|
if page > 0:
|
||||||
|
nav.append(InlineKeyboardButton("Prev", callback_data=f"list:tags:{page - 1}"))
|
||||||
|
if start + per_page < len(rows):
|
||||||
|
nav.append(InlineKeyboardButton("Next", callback_data=f"list:tags:{page + 1}"))
|
||||||
|
if nav:
|
||||||
|
buttons.append(nav)
|
||||||
|
text = "Choose a keyword/subject, then choose quiz size and mode."
|
||||||
|
if update.callback_query:
|
||||||
|
await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(buttons))
|
||||||
|
else:
|
||||||
|
await send_text(update, text, InlineKeyboardMarkup(buttons))
|
||||||
|
|
||||||
|
|
||||||
|
async def start_quiz(
|
||||||
|
update: Update,
|
||||||
|
questions: list[dict[str, Any]],
|
||||||
|
mode: str,
|
||||||
|
label: str = "Random",
|
||||||
|
context: ContextTypes.DEFAULT_TYPE | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not update.effective_chat:
|
||||||
|
return
|
||||||
|
if not questions:
|
||||||
|
await respond(update, context, "No usable MCQ questions found for that selection. Try /keywords, /categories, or a broader /search term.", main_menu())
|
||||||
|
return
|
||||||
|
random.shuffle(questions)
|
||||||
|
active_quizzes[update.effective_chat.id] = QuizState(questions=questions, mode=mode)
|
||||||
|
start_text = f"{html.escape(label)} quiz started: {len(questions)} questions, {mode} mode."
|
||||||
|
if update.message:
|
||||||
|
await send_text(update, start_text)
|
||||||
|
await send_current_question(update.effective_chat.id, update, None)
|
||||||
|
elif context:
|
||||||
|
await context.bot.send_message(update.effective_chat.id, start_text, parse_mode=ParseMode.HTML)
|
||||||
|
await send_current_question(update.effective_chat.id, None, context)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_current_question(chat_id: int, update: Update | None, context: ContextTypes.DEFAULT_TYPE | None) -> None:
|
||||||
|
state = active_quizzes.get(chat_id)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
question = state.questions[state.index]
|
||||||
|
options = question["options"]
|
||||||
|
buttons = [
|
||||||
|
[InlineKeyboardButton(LETTERS[idx], callback_data=f"answer:{idx}")]
|
||||||
|
for idx, option in enumerate(options[:8])
|
||||||
|
]
|
||||||
|
lines = [
|
||||||
|
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
|
||||||
|
html.escape(truncate_text(question["question_text"], 1600)),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for idx, option in enumerate(options[:8]):
|
||||||
|
lines.append(f"{LETTERS[idx]}. {html.escape(truncate_text(option, 700))}")
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
f"Category: {html.escape(str(question.get('category') or 'Uncategorized'))}",
|
||||||
|
])
|
||||||
|
text = "\n".join(lines)
|
||||||
|
markup = InlineKeyboardMarkup(buttons)
|
||||||
|
if update and update.message:
|
||||||
|
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=markup)
|
||||||
|
elif context:
|
||||||
|
await context.bot.send_message(chat_id, text, parse_mode=ParseMode.HTML, reply_markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_quiz(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
state = active_quizzes.pop(chat_id, None)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
total = len(state.questions)
|
||||||
|
lines = [f"Quiz complete. Score: {state.score}/{total}"]
|
||||||
|
if state.mode == "exam":
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Answers:")
|
||||||
|
for question_id, chosen, correct, ok in state.answers:
|
||||||
|
marker = "OK" if ok else "MISS"
|
||||||
|
lines.append(f"Q{question_id}: {marker}. You: {chosen}. Correct: {correct}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Full question bank: {PUBLIC_APP_URL}")
|
||||||
|
await context.bot.send_message(chat_id, "\n".join(lines), reply_markup=main_menu(), disable_web_page_preview=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
query = update.callback_query
|
||||||
|
if not query:
|
||||||
|
return
|
||||||
|
await query.answer()
|
||||||
|
data = query.data or ""
|
||||||
|
chat_id = query.message.chat_id if query.message else update.effective_chat.id
|
||||||
|
|
||||||
|
if data.startswith("list:categories:"):
|
||||||
|
await show_categories(update, int(data.rsplit(":", 1)[1]))
|
||||||
|
return
|
||||||
|
if data.startswith("list:tags:"):
|
||||||
|
await show_tags(update, int(data.rsplit(":", 1)[1]))
|
||||||
|
return
|
||||||
|
if data.startswith("quiz:random:"):
|
||||||
|
_, _, count, mode = data.split(":", 3)
|
||||||
|
await start_quiz(update, random_questions(clamp_count(int(count))), mode, context=context)
|
||||||
|
return
|
||||||
|
if data.startswith("pick:category:"):
|
||||||
|
category_id = int(data.rsplit(":", 1)[1])
|
||||||
|
await query.edit_message_text("How many questions?", reply_markup=count_menu("category", category_id))
|
||||||
|
return
|
||||||
|
if data.startswith("pick:tag:"):
|
||||||
|
tag_id = int(data.rsplit(":", 1)[1])
|
||||||
|
await query.edit_message_text("How many questions?", reply_markup=count_menu("tag", tag_id))
|
||||||
|
return
|
||||||
|
if data.startswith("start:category:"):
|
||||||
|
_, _, category_id, count, mode = data.split(":", 4)
|
||||||
|
await start_quiz(update, category_questions(int(category_id), clamp_count(int(count))), mode, "Category", context)
|
||||||
|
return
|
||||||
|
if data.startswith("start:tag:"):
|
||||||
|
_, _, tag_id, count, mode = data.split(":", 4)
|
||||||
|
await start_quiz(update, tag_questions(int(tag_id), clamp_count(int(count))), mode, "Keyword", context)
|
||||||
|
return
|
||||||
|
if data.startswith("answer:"):
|
||||||
|
state = active_quizzes.get(chat_id)
|
||||||
|
if not state:
|
||||||
|
await query.edit_message_text("This quiz expired. Start a new one with /random or /categories.")
|
||||||
|
return
|
||||||
|
chosen_idx = int(data.split(":", 1)[1])
|
||||||
|
question = state.questions[state.index]
|
||||||
|
correct_idx = question["correct_index"]
|
||||||
|
ok = chosen_idx == correct_idx
|
||||||
|
if ok:
|
||||||
|
state.score += 1
|
||||||
|
chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?"
|
||||||
|
correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?"
|
||||||
|
state.answers.append((state.index + 1, chosen, correct, ok))
|
||||||
|
if state.mode == "study":
|
||||||
|
await query.edit_message_text(
|
||||||
|
answered_question_text(state, question, chosen_idx, correct_idx),
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
)
|
||||||
|
await context.bot.send_message(chat_id, answer_feedback(question, chosen_idx, correct_idx, ok), parse_mode=ParseMode.HTML)
|
||||||
|
else:
|
||||||
|
await query.edit_message_reply_markup(reply_markup=None)
|
||||||
|
state.index += 1
|
||||||
|
if state.index >= len(state.questions):
|
||||||
|
await finish_quiz(chat_id, context)
|
||||||
|
else:
|
||||||
|
await send_current_question(chat_id, None, context)
|
||||||
|
|
||||||
|
|
||||||
|
async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
text = update.message.text if update.message else ""
|
||||||
|
match = NUMBER_RE.match(text or "")
|
||||||
|
if match:
|
||||||
|
await start_quiz(update, random_questions(clamp_count(int(match.group(1)))), "study")
|
||||||
|
return
|
||||||
|
await send_text(update, "Send a number like 20, or use /categories, /keywords, /search, /random.", main_menu())
|
||||||
|
|
||||||
|
|
||||||
|
async def post_init(app: Application) -> None:
|
||||||
|
await app.bot.set_my_commands([
|
||||||
|
BotCommand("start", "Show help and quick quiz buttons"),
|
||||||
|
BotCommand("random", "Start a random quiz: /random 20 exam"),
|
||||||
|
BotCommand("categories", "Browse categories"),
|
||||||
|
BotCommand("keywords", "Browse/search keywords: /keywords fever"),
|
||||||
|
BotCommand("search", "Search text/topic: /search sepsis 20"),
|
||||||
|
BotCommand("stop", "Stop the current quiz"),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
app = Application.builder().token(TELEGRAM_BOT_TOKEN).post_init(post_init).build()
|
||||||
|
app.add_handler(CommandHandler(["start", "help"], help_cmd))
|
||||||
|
app.add_handler(CommandHandler("random", random_cmd))
|
||||||
|
app.add_handler(CommandHandler("search", search_cmd))
|
||||||
|
app.add_handler(CommandHandler("categories", categories_cmd))
|
||||||
|
app.add_handler(CommandHandler(["keywords", "tags"], keywords_cmd))
|
||||||
|
app.add_handler(CommandHandler("stop", stop_cmd))
|
||||||
|
app.add_handler(CallbackQueryHandler(callback_handler))
|
||||||
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler))
|
||||||
|
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
2
telegram-bot/requirements.txt
Normal file
2
telegram-bot/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
python-telegram-bot>=21.0,<22
|
||||||
|
psycopg[binary]>=3.1,<4
|
||||||
Loading…
Reference in a new issue