Enhance Telegram bot with database integration and API features
- Added SQLite database support with Alembic for migrations. - Implemented FastAPI for HTTP API to manage duties. - Updated configuration to include database URL and HTTP port. - Created entrypoint script for Docker to handle migrations and permissions. - Expanded command handlers to register users and display duties. - Developed a web application for calendar display of duties. - Included necessary Pydantic schemas and SQLAlchemy models for data handling. - Updated requirements.txt to include new dependencies for FastAPI and SQLAlchemy.
This commit is contained in:
@@ -1 +1,4 @@
|
|||||||
BOT_TOKEN=your_bot_token_here
|
BOT_TOKEN=your_bot_token_here
|
||||||
|
DATABASE_URL=sqlite:///data/duty_teller.db
|
||||||
|
MINI_APP_BASE_URL=
|
||||||
|
HTTP_PORT=8080
|
||||||
|
|||||||
33
Dockerfile
33
Dockerfile
@@ -1,18 +1,37 @@
|
|||||||
|
# Multi-stage: builder installs deps; runtime copies only site-packages and app code.
|
||||||
# Single image for both dev and prod; Compose files differentiate behavior.
|
# Single image for both dev and prod; Compose files differentiate behavior.
|
||||||
|
|
||||||
|
# --- Stage 1: builder (dependencies only) ---
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# --- Stage 2: runtime (minimal final image) ---
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
# Install gosu (drop privileges in entrypoint)
|
||||||
COPY requirements.txt .
|
RUN apt-get update && apt-get install -y --no-install-recommends gosu \
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy installed packages and console scripts from builder (no requirements.txt, no pip layer)
|
||||||
|
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
|
||||||
# Application code
|
# Application code
|
||||||
COPY config.py main.py ./
|
COPY config.py main.py alembic.ini entrypoint.sh ./
|
||||||
|
COPY db/ ./db/
|
||||||
|
COPY api/ ./api/
|
||||||
COPY handlers/ ./handlers/
|
COPY handlers/ ./handlers/
|
||||||
|
COPY alembic/ ./alembic/
|
||||||
|
COPY webapp/ ./webapp/
|
||||||
|
|
||||||
# Run as non-root
|
# Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser
|
||||||
RUN adduser --disabled-password --gecos "" botuser && chown -R botuser:botuser /app
|
RUN adduser --disabled-password --gecos "" botuser \
|
||||||
USER botuser
|
&& mkdir -p /app/data && chown -R botuser:botuser /app
|
||||||
|
|
||||||
|
# Entrypoint runs as root: fix /app/data ownership (for volume mount), run migrations, then exec as botuser
|
||||||
|
ENTRYPOINT ["/bin/sh", "./entrypoint.sh"]
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
|
|||||||
39
alembic.ini
Normal file
39
alembic.ini
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Alembic config; url is set in env.py from config
|
||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
50
alembic/env.py
Normal file
50
alembic/env.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Alembic env: use config DATABASE_URL and db.models.Base."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from db.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
database_url = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
|
||||||
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
connect_args = {"check_same_thread": False} if "sqlite" in database_url else {}
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=database_url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
engine = create_engine(database_url, connect_args=connect_args)
|
||||||
|
with engine.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
alembic/script.py.mako
Normal file
26
alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
44
alembic/versions/001_initial_users_and_duties.py
Normal file
44
alembic/versions/001_initial_users_and_duties.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Initial users and duties tables
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-02-17
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "001"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("telegram_user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("full_name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("username", sa.Text(), nullable=True),
|
||||||
|
sa.Column("first_name", sa.Text(), nullable=True),
|
||||||
|
sa.Column("last_name", sa.Text(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("telegram_user_id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"duties",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("start_at", sa.Text(), nullable=False),
|
||||||
|
sa.Column("end_at", sa.Text(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("duties")
|
||||||
|
op.drop_table("users")
|
||||||
1
api/__init__.py
Normal file
1
api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# HTTP API for Mini App
|
||||||
47
api/app.py
Normal file
47
api/app.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""FastAPI app: /api/duties and static webapp."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import config
|
||||||
|
from fastapi import FastAPI, Query
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from db.session import get_session
|
||||||
|
from db.repository import get_duties
|
||||||
|
from db.schemas import DutyWithUser
|
||||||
|
|
||||||
|
app = FastAPI(title="Duty Teller API")
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/duties", response_model=list[DutyWithUser])
|
||||||
|
def list_duties(
|
||||||
|
from_date: str = Query(..., description="ISO date YYYY-MM-DD"),
|
||||||
|
to_date: str = Query(..., description="ISO date YYYY-MM-DD"),
|
||||||
|
) -> list[DutyWithUser]:
|
||||||
|
session = get_session(config.DATABASE_URL)
|
||||||
|
try:
|
||||||
|
rows = get_duties(session, from_date=from_date, to_date=to_date)
|
||||||
|
return [
|
||||||
|
DutyWithUser(
|
||||||
|
id=duty.id,
|
||||||
|
user_id=duty.user_id,
|
||||||
|
start_at=duty.start_at,
|
||||||
|
end_at=duty.end_at,
|
||||||
|
full_name=full_name,
|
||||||
|
)
|
||||||
|
for duty, full_name in rows
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
webapp_path = Path(__file__).resolve().parent.parent / "webapp"
|
||||||
|
if webapp_path.is_dir():
|
||||||
|
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Load configuration from environment. Fail fast if BOT_TOKEN is missing."""
|
"""Load configuration from environment. Fail fast if BOT_TOKEN is missing."""
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
@@ -8,3 +9,8 @@ load_dotenv()
|
|||||||
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||||
if not BOT_TOKEN:
|
if not BOT_TOKEN:
|
||||||
raise SystemExit("BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather.")
|
raise SystemExit("BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather.")
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
|
||||||
|
MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/")
|
||||||
|
HTTP_PORT = int(os.getenv("HTTP_PORT", "8080"))
|
||||||
|
DATA_DIR = Path(__file__).resolve().parent / "data"
|
||||||
|
|||||||
BIN
data/duty_teller.db
Normal file
BIN
data/duty_teller.db
Normal file
Binary file not shown.
11
db/__init__.py
Normal file
11
db/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Database layer: SQLAlchemy models, Pydantic schemas, repository, init."""
|
||||||
|
from db.models import Base, User, Duty
|
||||||
|
from db.schemas import UserCreate, UserInDb, DutyCreate, DutyInDb, DutyWithUser
|
||||||
|
from db.session import get_engine, get_session_factory, get_session
|
||||||
|
from db.repository import get_or_create_user, get_duties, insert_duty
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(database_url: str) -> None:
|
||||||
|
"""Create tables from metadata (Alembic migrations handle schema in production)."""
|
||||||
|
engine = get_engine(database_url)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
32
db/models.py
Normal file
32
db/models.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""SQLAlchemy ORM models for users and duties."""
|
||||||
|
from sqlalchemy import ForeignKey, Integer, BigInteger, Text
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Declarative base for all models."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
telegram_user_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False)
|
||||||
|
full_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
username: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
first_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
last_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
duties: Mapped[list["Duty"]] = relationship("Duty", back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
|
class Duty(Base):
|
||||||
|
__tablename__ = "duties"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
start_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601
|
||||||
|
end_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship("User", back_populates="duties")
|
||||||
61
db/repository.py
Normal file
61
db/repository.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Repository: get_or_create_user, get_duties, insert_duty."""
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from db.models import User, Duty
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_user(
|
||||||
|
session: Session,
|
||||||
|
telegram_user_id: int,
|
||||||
|
full_name: str,
|
||||||
|
username: str | None = None,
|
||||||
|
first_name: str | None = None,
|
||||||
|
last_name: str | None = None,
|
||||||
|
) -> User:
|
||||||
|
user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()
|
||||||
|
if user:
|
||||||
|
user.full_name = full_name
|
||||||
|
user.username = username
|
||||||
|
user.first_name = first_name
|
||||||
|
user.last_name = last_name
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
user = User(
|
||||||
|
telegram_user_id=telegram_user_id,
|
||||||
|
full_name=full_name,
|
||||||
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_duties(
|
||||||
|
session: Session,
|
||||||
|
from_date: str,
|
||||||
|
to_date: str,
|
||||||
|
) -> list[tuple[Duty, str]]:
|
||||||
|
"""Return list of (Duty, full_name) overlapping the given date range (ISO date strings)."""
|
||||||
|
q = (
|
||||||
|
session.query(Duty, User.full_name)
|
||||||
|
.join(User, Duty.user_id == User.id)
|
||||||
|
.filter(Duty.start_at <= to_date, Duty.end_at >= from_date)
|
||||||
|
)
|
||||||
|
return list(q.all())
|
||||||
|
|
||||||
|
|
||||||
|
def insert_duty(
|
||||||
|
session: Session,
|
||||||
|
user_id: int,
|
||||||
|
start_at: str,
|
||||||
|
end_at: str,
|
||||||
|
) -> Duty:
|
||||||
|
duty = Duty(user_id=user_id, start_at=start_at, end_at=end_at)
|
||||||
|
session.add(duty)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(duty)
|
||||||
|
return duty
|
||||||
43
db/schemas.py
Normal file
43
db/schemas.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Pydantic schemas for API and validation."""
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
full_name: str
|
||||||
|
username: str | None = None
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
telegram_user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserInDb(UserBase):
|
||||||
|
id: int
|
||||||
|
telegram_user_id: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DutyBase(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
start_at: str # ISO 8601
|
||||||
|
end_at: str # ISO 8601
|
||||||
|
|
||||||
|
|
||||||
|
class DutyCreate(DutyBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DutyInDb(DutyBase):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DutyWithUser(DutyInDb):
|
||||||
|
"""Duty with full_name for calendar display."""
|
||||||
|
full_name: str
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
31
db/session.py
Normal file
31
db/session.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""SQLAlchemy engine and session factory."""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from db.models import Base
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
_SessionLocal = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine(database_url: str):
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
_engine = create_engine(
|
||||||
|
database_url,
|
||||||
|
connect_args={"check_same_thread": False} if "sqlite" in database_url else {},
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_factory(database_url: str) -> sessionmaker[Session]:
|
||||||
|
global _SessionLocal
|
||||||
|
if _SessionLocal is None:
|
||||||
|
engine = get_engine(database_url)
|
||||||
|
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
return _SessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
def get_session(database_url: str) -> Session:
|
||||||
|
return get_session_factory(database_url)()
|
||||||
@@ -5,8 +5,15 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
env_file: .env
|
env_file: .env
|
||||||
restart: always
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:-8080}:8080"
|
||||||
|
volumes:
|
||||||
|
- duty_data:/app/data
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
duty_data: {}
|
||||||
|
|||||||
11
entrypoint.sh
Normal file
11
entrypoint.sh
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
# Ensure data dir exists and is writable by botuser (volume may be root-owned)
|
||||||
|
mkdir -p /app/data
|
||||||
|
chown botuser:botuser /app/data
|
||||||
|
# Apply Alembic migrations (runs as root, creates DB in /app/data)
|
||||||
|
alembic upgrade head
|
||||||
|
# Ensure new DB file is owned by botuser so the app can write
|
||||||
|
chown -R botuser:botuser /app/data
|
||||||
|
# Run the app as botuser
|
||||||
|
exec gosu botuser "$@"
|
||||||
@@ -1,19 +1,59 @@
|
|||||||
"""Command handlers: /start, /help (and placeholder for more)."""
|
"""Command handlers: /start, /help; /start registers user and shows Calendar button."""
|
||||||
from telegram import Update
|
import asyncio
|
||||||
|
|
||||||
|
import config
|
||||||
|
from telegram import Update, WebAppInfo, KeyboardButton, ReplyKeyboardMarkup
|
||||||
from telegram.ext import CommandHandler, ContextTypes
|
from telegram.ext import CommandHandler, ContextTypes
|
||||||
|
|
||||||
|
from db.session import get_session
|
||||||
|
from db.repository import get_or_create_user
|
||||||
|
|
||||||
|
|
||||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
if update.message:
|
if not update.message:
|
||||||
await update.message.reply_text("Hello! I'm your bot. Use /help to see available commands.")
|
return
|
||||||
|
user = update.effective_user
|
||||||
|
if not user:
|
||||||
|
return
|
||||||
|
full_name = " ".join(filter(None, [user.first_name or "", user.last_name or ""])).strip() or "User"
|
||||||
|
telegram_user_id = user.id
|
||||||
|
username = user.username
|
||||||
|
first_name = user.first_name
|
||||||
|
last_name = user.last_name
|
||||||
|
|
||||||
|
def do_get_or_create() -> None:
|
||||||
|
session = get_session(config.DATABASE_URL)
|
||||||
|
try:
|
||||||
|
get_or_create_user(
|
||||||
|
session,
|
||||||
|
telegram_user_id=telegram_user_id,
|
||||||
|
full_name=full_name,
|
||||||
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
await asyncio.get_event_loop().run_in_executor(None, do_get_or_create)
|
||||||
|
|
||||||
|
text = "Привет! Я бот календаря дежурств. Используй /help для списка команд."
|
||||||
|
if config.MINI_APP_BASE_URL:
|
||||||
|
keyboard = ReplyKeyboardMarkup(
|
||||||
|
[[KeyboardButton("📅 Календарь", web_app=WebAppInfo(url=config.MINI_APP_BASE_URL + "/app/"))]],
|
||||||
|
resize_keyboard=True,
|
||||||
|
)
|
||||||
|
await update.message.reply_text(text, reply_markup=keyboard)
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(text)
|
||||||
|
|
||||||
|
|
||||||
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
if update.message:
|
if update.message:
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
"Available commands:\n"
|
"Доступные команды:\n"
|
||||||
"/start - Start the bot\n"
|
"/start — Начать и открыть календарь\n"
|
||||||
"/help - Show this help"
|
"/help — Показать эту справку"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
26
main.py
26
main.py
@@ -1,5 +1,7 @@
|
|||||||
"""Single entry point: build Application, register handlers, run polling."""
|
"""Single entry point: build Application, run HTTP server + polling. Migrations run in Docker entrypoint."""
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from telegram.ext import ApplicationBuilder
|
from telegram.ext import ApplicationBuilder
|
||||||
@@ -13,10 +15,30 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_uvicorn(web_app, port: int) -> None:
|
||||||
|
"""Run uvicorn in a dedicated thread with its own event loop."""
|
||||||
|
import uvicorn
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
server = uvicorn.Server(
|
||||||
|
uvicorn.Config(web_app, host="0.0.0.0", port=port, log_level="info"),
|
||||||
|
)
|
||||||
|
loop.run_until_complete(server.serve())
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
app = ApplicationBuilder().token(config.BOT_TOKEN).build()
|
app = ApplicationBuilder().token(config.BOT_TOKEN).build()
|
||||||
register_handlers(app)
|
register_handlers(app)
|
||||||
logger.info("Bot starting (polling)...")
|
|
||||||
|
from api.app import app as web_app
|
||||||
|
t = threading.Thread(
|
||||||
|
target=_run_uvicorn,
|
||||||
|
args=(web_app, config.HTTP_PORT),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT)
|
||||||
app.run_polling(allowed_updates=["message"])
|
app.run_polling(allowed_updates=["message"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
python-telegram-bot[job-queue]>=22.0,<23.0
|
python-telegram-bot[job-queue]>=22.0,<23.0
|
||||||
python-dotenv>=1.0,<2.0
|
python-dotenv>=1.0,<2.0
|
||||||
|
fastapi>=0.115,<1.0
|
||||||
|
uvicorn[standard]>=0.32,<1.0
|
||||||
|
sqlalchemy>=2.0,<3.0
|
||||||
|
alembic>=1.14,<2.0
|
||||||
|
pydantic>=2.0,<3.0
|
||||||
|
|||||||
145
webapp/app.js
Normal file
145
webapp/app.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
(function () {
|
||||||
|
const MONTHS = [
|
||||||
|
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
|
||||||
|
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"
|
||||||
|
];
|
||||||
|
|
||||||
|
let current = new Date();
|
||||||
|
const calendarEl = document.getElementById("calendar");
|
||||||
|
const monthTitleEl = document.getElementById("monthTitle");
|
||||||
|
const dutyListEl = document.getElementById("dutyList");
|
||||||
|
const loadingEl = document.getElementById("loading");
|
||||||
|
const errorEl = document.getElementById("error");
|
||||||
|
|
||||||
|
function isoDate(d) {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstDayOfMonth(d) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastDayOfMonth(d) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonday(d) {
|
||||||
|
const day = d.getDay();
|
||||||
|
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDuties(from, to) {
|
||||||
|
const base = window.location.origin;
|
||||||
|
const url = base + "/api/duties?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to);
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error("Ошибка загрузки");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar(year, month, dutiesByDate) {
|
||||||
|
const first = firstDayOfMonth(new Date(year, month, 1));
|
||||||
|
const last = lastDayOfMonth(new Date(year, month, 1));
|
||||||
|
const start = getMonday(first);
|
||||||
|
const today = isoDate(new Date());
|
||||||
|
|
||||||
|
calendarEl.innerHTML = "";
|
||||||
|
let d = new Date(start);
|
||||||
|
const cells = 42;
|
||||||
|
for (let i = 0; i < cells; i++) {
|
||||||
|
const key = isoDate(d);
|
||||||
|
const isOther = d.getMonth() !== month;
|
||||||
|
const dayDuties = dutiesByDate[key] || [];
|
||||||
|
const isToday = key === today;
|
||||||
|
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "");
|
||||||
|
cell.innerHTML =
|
||||||
|
"<span class=\"num\">" + d.getDate() + "</span>" +
|
||||||
|
(dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return x.full_name; }).join(", ") + "</span>" : "");
|
||||||
|
calendarEl.appendChild(cell);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
monthTitleEl.textContent = MONTHS[month] + " " + year;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDutyList(duties) {
|
||||||
|
if (duties.length === 0) {
|
||||||
|
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце дежурств нет.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const grouped = {};
|
||||||
|
duties.forEach(function (d) {
|
||||||
|
const date = d.start_at.slice(0, 10);
|
||||||
|
if (!grouped[date]) grouped[date] = [];
|
||||||
|
grouped[date].push(d);
|
||||||
|
});
|
||||||
|
const dates = Object.keys(grouped).sort();
|
||||||
|
let html = "";
|
||||||
|
dates.forEach(function (date) {
|
||||||
|
const list = grouped[date];
|
||||||
|
html += "<h2>" + date + "</h2>";
|
||||||
|
list.forEach(function (d) {
|
||||||
|
const start = d.start_at.slice(11, 16);
|
||||||
|
const end = d.end_at.slice(11, 16);
|
||||||
|
html += "<div class=\"duty-item\"><span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " – " + end + "</div></div>";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
dutyListEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = s;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dutiesByDate(duties) {
|
||||||
|
const byDate = {};
|
||||||
|
duties.forEach(function (d) {
|
||||||
|
const start = new Date(d.start_at);
|
||||||
|
const end = new Date(d.end_at);
|
||||||
|
for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) {
|
||||||
|
const key = isoDate(t);
|
||||||
|
if (!byDate[key]) byDate[key] = [];
|
||||||
|
byDate[key].push(d);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return byDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorEl.textContent = msg;
|
||||||
|
errorEl.hidden = false;
|
||||||
|
loadingEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMonth() {
|
||||||
|
loadingEl.classList.remove("hidden");
|
||||||
|
errorEl.hidden = true;
|
||||||
|
const from = isoDate(firstDayOfMonth(current));
|
||||||
|
const to = isoDate(lastDayOfMonth(current));
|
||||||
|
try {
|
||||||
|
const duties = await fetchDuties(from, to);
|
||||||
|
const byDate = dutiesByDate(duties);
|
||||||
|
renderCalendar(current.getFullYear(), current.getMonth(), byDate);
|
||||||
|
renderDutyList(duties);
|
||||||
|
} catch (e) {
|
||||||
|
showError(e.message || "Не удалось загрузить данные.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("prevMonth").addEventListener("click", function () {
|
||||||
|
current.setMonth(current.getMonth() - 1);
|
||||||
|
loadMonth();
|
||||||
|
});
|
||||||
|
document.getElementById("nextMonth").addEventListener("click", function () {
|
||||||
|
current.setMonth(current.getMonth() + 1);
|
||||||
|
loadMonth();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadMonth();
|
||||||
|
})();
|
||||||
26
webapp/index.html
Normal file
26
webapp/index.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
|
<title>Календарь дежурств</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header class="header">
|
||||||
|
<button type="button" class="nav" id="prevMonth" aria-label="Предыдущий месяц">‹</button>
|
||||||
|
<h1 class="title" id="monthTitle"></h1>
|
||||||
|
<button type="button" class="nav" id="nextMonth" aria-label="Следующий месяц">›</button>
|
||||||
|
</header>
|
||||||
|
<div class="weekdays">
|
||||||
|
<span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span>
|
||||||
|
</div>
|
||||||
|
<div class="calendar" id="calendar"></div>
|
||||||
|
<div class="duty-list" id="dutyList"></div>
|
||||||
|
<div class="loading" id="loading">Загрузка…</div>
|
||||||
|
<div class="error" id="error" hidden></div>
|
||||||
|
</div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
152
webapp/style.css
Normal file
152
webapp/style.css
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #1a1b26;
|
||||||
|
--surface: #24283b;
|
||||||
|
--text: #c0caf5;
|
||||||
|
--muted: #565f89;
|
||||||
|
--accent: #7aa2f7;
|
||||||
|
--duty: #9ece6a;
|
||||||
|
--today: #bb9af7;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 12px;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day.other-month {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day.today {
|
||||||
|
background: var(--today);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day.has-duty .num {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-duties {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--duty);
|
||||||
|
margin-top: 2px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duty-list {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duty-list h2 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duty-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-left: 3px solid var(--duty);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duty-item .name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duty-item .time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #f7768e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error[hidden], .loading.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user