feat(core): initialize project structure, postgres schema and docker setup
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
*.session
|
||||||
|
*.session-journal
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
data/
|
||||||
|
tests/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Telegram Userbot Credentials (https://my.telegram.org)
|
||||||
|
API_ID=
|
||||||
|
API_HASH=
|
||||||
|
PHONE=
|
||||||
|
|
||||||
|
# Telegram Admin Bot Credentials (@BotFather)
|
||||||
|
BOT_TOKEN=
|
||||||
|
ADMIN_USER_IDS=78649634
|
||||||
|
REVIEW_CHANNEL_ID=
|
||||||
|
|
||||||
|
# AI Provider Configuration
|
||||||
|
AI_PROVIDER=gemini
|
||||||
|
AI_API_KEY=
|
||||||
|
AI_MODEL=gemini-1.5-flash
|
||||||
|
|
||||||
|
# PostgreSQL Configuration
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_DB=copykar
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/copykar
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
*.session
|
||||||
|
*.session-journal
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Prevent Python from writing .pyc files & enable unbuffered logs
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["python", "main.py"]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from prometheus_client import Counter, Histogram, Gauge, start_http_server
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Counters
|
||||||
|
COLLECTED_POSTS_TOTAL = Counter(
|
||||||
|
"copykar_posts_collected_total",
|
||||||
|
"Total posts collected by the Telethon Userbot",
|
||||||
|
["source_channel_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
AI_REQUESTS_TOTAL = Counter(
|
||||||
|
"copykar_ai_requests_total",
|
||||||
|
"Total AI API calls made",
|
||||||
|
["action", "status"]
|
||||||
|
)
|
||||||
|
|
||||||
|
DUPLICATES_DETECTED_TOTAL = Counter(
|
||||||
|
"copykar_duplicates_detected_total",
|
||||||
|
"Total duplicate posts detected",
|
||||||
|
["method"] # "hash" or "ai_semantic"
|
||||||
|
)
|
||||||
|
|
||||||
|
ADMIN_ACTIONS_TOTAL = Counter(
|
||||||
|
"copykar_admin_actions_total",
|
||||||
|
"Total review decisions by admins",
|
||||||
|
["action"] # "approved", "rejected", "routed"
|
||||||
|
)
|
||||||
|
|
||||||
|
POSTS_PUBLISHED_TOTAL = Counter(
|
||||||
|
"copykar_posts_published_total",
|
||||||
|
"Total posts successfully published to target channels",
|
||||||
|
["target_channel_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Histograms
|
||||||
|
AI_LATENCY_SECONDS = Histogram(
|
||||||
|
"copykar_ai_latency_seconds",
|
||||||
|
"Time taken for AI API operations",
|
||||||
|
["action"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gauges
|
||||||
|
QUEUE_POSTS_GAUGE = Gauge(
|
||||||
|
"copykar_posts_queue_gauge",
|
||||||
|
"Number of posts currently in various queue states",
|
||||||
|
["status"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_metrics_server(port: int = 8000):
|
||||||
|
try:
|
||||||
|
start_http_server(port)
|
||||||
|
logger.info(f"Prometheus metrics server running on port {port}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start Prometheus metrics server: {e}")
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from db.database import init_db, get_db_connection
|
||||||
|
from db.models import SourceChannel, TargetChannel, Post, Setting
|
||||||
|
from db.repository import Repository
|
||||||
|
|
||||||
|
__all__ = ["init_db", "get_db_connection", "SourceChannel", "TargetChannel", "Post", "Setting", "Repository"]
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import asyncpg
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
f"postgresql://{os.getenv('POSTGRES_USER', 'postgres')}:{os.getenv('POSTGRES_PASSWORD', 'postgres')}@{os.getenv('POSTGRES_HOST', 'localhost')}:{os.getenv('POSTGRES_PORT', '5432')}/{os.getenv('POSTGRES_DB', 'copykar')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS sources (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
channel_id BIGINT UNIQUE NOT NULL,
|
||||||
|
username VARCHAR(255),
|
||||||
|
title VARCHAR(255),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS targets (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
channel_id BIGINT UNIQUE NOT NULL,
|
||||||
|
title VARCHAR(255),
|
||||||
|
username VARCHAR(255),
|
||||||
|
post_interval_min INT DEFAULT 30,
|
||||||
|
last_post_time TIMESTAMPTZ,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
source_channel_id BIGINT NOT NULL,
|
||||||
|
source_message_id BIGINT NOT NULL,
|
||||||
|
raw_text TEXT,
|
||||||
|
media_path TEXT,
|
||||||
|
media_type VARCHAR(64),
|
||||||
|
content_hash VARCHAR(128),
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
is_duplicate BOOLEAN DEFAULT FALSE,
|
||||||
|
duplicate_of_id BIGINT REFERENCES posts(id) ON DELETE SET NULL,
|
||||||
|
similarity_reason TEXT,
|
||||||
|
subject VARCHAR(255),
|
||||||
|
ai_text TEXT,
|
||||||
|
suggested_target_id INT REFERENCES targets(id) ON DELETE SET NULL,
|
||||||
|
target_channel_id INT REFERENCES targets(id) ON DELETE SET NULL,
|
||||||
|
status VARCHAR(32) DEFAULT 'pending_ai',
|
||||||
|
review_message_id BIGINT,
|
||||||
|
scheduled_at TIMESTAMPTZ,
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_source_message UNIQUE (source_channel_id, source_message_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_content_hash ON posts(content_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_target_status ON posts(target_channel_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_tags ON posts USING GIN (tags);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key VARCHAR(128) PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
_pool: Optional[asyncpg.Pool] = None
|
||||||
|
|
||||||
|
async def get_db_pool(dsn: str = DATABASE_URL) -> asyncpg.Pool:
|
||||||
|
global _pool
|
||||||
|
if _pool is None or _pool._closed:
|
||||||
|
_pool = await asyncpg.create_pool(dsn=dsn, min_size=2, max_size=10)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
async def close_db_pool():
|
||||||
|
global _pool
|
||||||
|
if _pool is not None and not _pool._closed:
|
||||||
|
await _pool.close()
|
||||||
|
_pool = None
|
||||||
|
|
||||||
|
async def init_db(dsn: str = DATABASE_URL):
|
||||||
|
pool = await get_db_pool(dsn)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(SCHEMA)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SourceChannel:
|
||||||
|
id: Optional[int]
|
||||||
|
channel_id: int
|
||||||
|
username: Optional[str]
|
||||||
|
title: Optional[str]
|
||||||
|
is_active: bool = True
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TargetChannel:
|
||||||
|
id: Optional[int]
|
||||||
|
channel_id: int
|
||||||
|
title: Optional[str]
|
||||||
|
username: Optional[str]
|
||||||
|
post_interval_min: int = 30
|
||||||
|
last_post_time: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Post:
|
||||||
|
id: Optional[int]
|
||||||
|
source_channel_id: int
|
||||||
|
source_message_id: int
|
||||||
|
raw_text: Optional[str]
|
||||||
|
media_path: Optional[str] = None
|
||||||
|
media_type: Optional[str] = None
|
||||||
|
content_hash: Optional[str] = None
|
||||||
|
tags: List[str] = field(default_factory=list)
|
||||||
|
is_duplicate: bool = False
|
||||||
|
duplicate_of_id: Optional[int] = None
|
||||||
|
similarity_reason: Optional[str] = None
|
||||||
|
subject: Optional[str] = None
|
||||||
|
ai_text: Optional[str] = None
|
||||||
|
suggested_target_id: Optional[int] = None
|
||||||
|
target_channel_id: Optional[int] = None
|
||||||
|
status: str = "pending_ai" # pending_ai, pending_review, approved, scheduled, published, rejected
|
||||||
|
review_message_id: Optional[int] = None
|
||||||
|
scheduled_at: Optional[str] = None
|
||||||
|
published_at: Optional[str] = None
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Setting:
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
description: Optional[str] = None
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import asyncpg
|
||||||
|
from typing import List, Optional
|
||||||
|
from db.models import SourceChannel, TargetChannel, Post, Setting
|
||||||
|
from db.database import get_db_pool
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def __init__(self, dsn: Optional[str] = None):
|
||||||
|
self.dsn = dsn
|
||||||
|
|
||||||
|
async def _get_pool(self) -> asyncpg.Pool:
|
||||||
|
if self.dsn:
|
||||||
|
return await get_db_pool(self.dsn)
|
||||||
|
return await get_db_pool()
|
||||||
|
|
||||||
|
# --- Source Channels ---
|
||||||
|
async def add_source(self, channel_id: int, title: Optional[str] = None, username: Optional[str] = None) -> int:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO sources (channel_id, title, username)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT(channel_id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
is_active = TRUE
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
channel_id, title, username,
|
||||||
|
)
|
||||||
|
return row["id"]
|
||||||
|
|
||||||
|
async def get_active_sources(self) -> List[SourceChannel]:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("SELECT * FROM sources WHERE is_active = TRUE;")
|
||||||
|
return [SourceChannel(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
async def get_source_by_channel_id(self, channel_id: int) -> Optional[SourceChannel]:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("SELECT * FROM sources WHERE channel_id = $1;", channel_id)
|
||||||
|
return SourceChannel(**dict(row)) if row else None
|
||||||
|
|
||||||
|
# --- Target Channels ---
|
||||||
|
async def add_target(self, channel_id: int, title: Optional[str] = None, username: Optional[str] = None, post_interval_min: int = 30) -> int:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO targets (channel_id, title, username, post_interval_min)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT(channel_id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
post_interval_min = EXCLUDED.post_interval_min,
|
||||||
|
is_active = TRUE
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
channel_id, title, username, post_interval_min,
|
||||||
|
)
|
||||||
|
return row["id"]
|
||||||
|
|
||||||
|
async def get_active_targets(self) -> List[TargetChannel]:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("SELECT * FROM targets WHERE is_active = TRUE;")
|
||||||
|
return [TargetChannel(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
async def get_target_by_id(self, target_id: int) -> Optional[TargetChannel]:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("SELECT * FROM targets WHERE id = $1;", target_id)
|
||||||
|
return TargetChannel(**dict(row)) if row else None
|
||||||
|
|
||||||
|
async def update_target_last_post(self, target_id: int) -> None:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE targets SET last_post_time = CURRENT_TIMESTAMP WHERE id = $1;",
|
||||||
|
target_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Posts & Deduplication ---
|
||||||
|
async def find_duplicate_post_by_hash(self, content_hash: str) -> Optional[Post]:
|
||||||
|
if not content_hash:
|
||||||
|
return None
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT * FROM posts WHERE content_hash = $1 ORDER BY id ASC LIMIT 1;",
|
||||||
|
content_hash,
|
||||||
|
)
|
||||||
|
return Post(**dict(row)) if row else None
|
||||||
|
|
||||||
|
async def find_candidate_posts_by_tags(self, tags: List[str], exclude_post_id: Optional[int] = None, hours_lookback: int = 72, limit: int = 5) -> List[Post]:
|
||||||
|
if not tags:
|
||||||
|
return []
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
query = """
|
||||||
|
SELECT * FROM posts
|
||||||
|
WHERE tags && $1::text[]
|
||||||
|
AND created_at >= NOW() - ($2 || ' hours')::interval
|
||||||
|
AND ($3::bigint IS NULL OR id != $3::bigint)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $4;
|
||||||
|
"""
|
||||||
|
rows = await conn.fetch(query, tags, str(hours_lookback), exclude_post_id, limit)
|
||||||
|
return [Post(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
async def create_raw_post(
|
||||||
|
self,
|
||||||
|
source_channel_id: int,
|
||||||
|
source_message_id: int,
|
||||||
|
raw_text: Optional[str],
|
||||||
|
media_path: Optional[str] = None,
|
||||||
|
media_type: Optional[str] = None,
|
||||||
|
content_hash: Optional[str] = None,
|
||||||
|
is_duplicate: bool = False,
|
||||||
|
duplicate_of_id: Optional[int] = None,
|
||||||
|
similarity_reason: Optional[str] = None,
|
||||||
|
) -> Optional[int]:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
try:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO posts (
|
||||||
|
source_channel_id, source_message_id, raw_text, media_path,
|
||||||
|
media_type, content_hash, is_duplicate, duplicate_of_id, similarity_reason, status
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending_ai')
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
source_channel_id,
|
||||||
|
source_message_id,
|
||||||
|
raw_text,
|
||||||
|
media_path,
|
||||||
|
media_type,
|
||||||
|
content_hash,
|
||||||
|
is_duplicate,
|
||||||
|
duplicate_of_id,
|
||||||
|
similarity_reason,
|
||||||
|
)
|
||||||
|
return row["id"] if row else None
|
||||||
|
except asyncpg.UniqueViolationError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_posts_by_status(self, status: str, limit: int = 20) -> List[Post]:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT * FROM posts WHERE status = $1 ORDER BY id ASC LIMIT $2;",
|
||||||
|
status, limit,
|
||||||
|
)
|
||||||
|
return [Post(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
async def get_post_by_id(self, post_id: int) -> Optional[Post]:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("SELECT * FROM posts WHERE id = $1;", post_id)
|
||||||
|
return Post(**dict(row)) if row else None
|
||||||
|
|
||||||
|
async def update_post_tags(self, post_id: int, tags: List[str], subject: str) -> None:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE posts SET tags = $1, subject = $2 WHERE id = $3;",
|
||||||
|
tags, subject, post_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_post_duplicate_status(
|
||||||
|
self,
|
||||||
|
post_id: int,
|
||||||
|
is_duplicate: bool,
|
||||||
|
duplicate_of_id: Optional[int] = None,
|
||||||
|
similarity_reason: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE posts
|
||||||
|
SET is_duplicate = $1, duplicate_of_id = $2, similarity_reason = $3
|
||||||
|
WHERE id = $4;
|
||||||
|
""",
|
||||||
|
is_duplicate, duplicate_of_id, similarity_reason, post_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_ai_result(
|
||||||
|
self,
|
||||||
|
post_id: int,
|
||||||
|
subject: str,
|
||||||
|
ai_text: str,
|
||||||
|
tags: List[str],
|
||||||
|
suggested_target_id: Optional[int] = None,
|
||||||
|
is_duplicate: bool = False,
|
||||||
|
duplicate_of_id: Optional[int] = None,
|
||||||
|
similarity_reason: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE posts
|
||||||
|
SET subject = $1, ai_text = $2, tags = $3, suggested_target_id = $4,
|
||||||
|
is_duplicate = $5, duplicate_of_id = $6, similarity_reason = $7,
|
||||||
|
status = 'pending_review'
|
||||||
|
WHERE id = $8;
|
||||||
|
""",
|
||||||
|
subject, ai_text, tags, suggested_target_id, is_duplicate, duplicate_of_id, similarity_reason, post_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_review_message_id(self, post_id: int, review_message_id: int) -> None:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE posts SET review_message_id = $1 WHERE id = $2;",
|
||||||
|
review_message_id, post_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def approve_post(self, post_id: int, target_channel_id: int) -> None:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE posts
|
||||||
|
SET target_channel_id = $1, status = 'approved'
|
||||||
|
WHERE id = $2;
|
||||||
|
""",
|
||||||
|
target_channel_id, post_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reject_post(self, post_id: int) -> None:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute("UPDATE posts SET status = 'rejected' WHERE id = $1;", post_id)
|
||||||
|
|
||||||
|
async def mark_post_published(self, post_id: int) -> None:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE posts SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE id = $1;",
|
||||||
|
post_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_next_approved_post_for_target(self, target_id: int) -> Optional[Post]:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT * FROM posts
|
||||||
|
WHERE target_channel_id = $1 AND status = 'approved'
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1;
|
||||||
|
""",
|
||||||
|
target_id,
|
||||||
|
)
|
||||||
|
return Post(**dict(row)) if row else None
|
||||||
|
|
||||||
|
# --- Settings ---
|
||||||
|
async def get_setting(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("SELECT value FROM settings WHERE key = $1;", key)
|
||||||
|
return row["value"] if row else default
|
||||||
|
|
||||||
|
async def set_setting(self, key: str, value: str, description: Optional[str] = None) -> None:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO settings (key, value, description)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
description = COALESCE(EXCLUDED.description, settings.description);
|
||||||
|
""",
|
||||||
|
key, value, description,
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: copykar_postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-copykar}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-copykar}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
copykar:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: copykar_app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./sessions:/app/sessions
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.54.1
|
||||||
|
container_name: copykar_prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:11.2.0
|
||||||
|
container_name: copykar_grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
prometheus_data:
|
||||||
|
grafana_data:
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: 'Copykar Dashboards'
|
||||||
|
orgId: 1
|
||||||
|
folder: ''
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
editable: true
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 10s
|
||||||
|
evaluation_interval: 10s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: "copykar"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["copykar:8000"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
telethon==1.44.0
|
||||||
|
python-dotenv==1.2.3
|
||||||
|
asyncpg==0.31.0
|
||||||
|
prometheus-client==0.26.0
|
||||||
|
httpx==0.28.1
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from db.database import init_db
|
||||||
|
from db.repository import Repository
|
||||||
|
|
||||||
|
async def run_tests():
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db") as tmp:
|
||||||
|
db_path = tmp.name
|
||||||
|
print(f"Testing DB on {db_path}...")
|
||||||
|
await init_db(db_path)
|
||||||
|
repo = Repository(db_path)
|
||||||
|
|
||||||
|
# 1. Test Sources
|
||||||
|
s_id = await repo.add_source(-1001234567890, "Source Tech", "source_tech")
|
||||||
|
sources = await repo.get_active_sources()
|
||||||
|
assert len(sources) == 1
|
||||||
|
assert sources[0].channel_id == -1001234567890
|
||||||
|
|
||||||
|
# 2. Test Targets
|
||||||
|
t_id = await repo.add_target(-1009876543210, "Target Channel", "target_chan", post_interval_min=15)
|
||||||
|
targets = await repo.get_active_targets()
|
||||||
|
assert len(targets) == 1
|
||||||
|
assert targets[0].post_interval_min == 15
|
||||||
|
|
||||||
|
# 3. Test Raw Post & Deduplication
|
||||||
|
post_id = await repo.create_raw_post(
|
||||||
|
source_channel_id=-1001234567890,
|
||||||
|
source_message_id=101,
|
||||||
|
raw_text="Breaking news: AI update released!",
|
||||||
|
content_hash="hash_12345",
|
||||||
|
is_duplicate=False
|
||||||
|
)
|
||||||
|
assert post_id is not None
|
||||||
|
|
||||||
|
# Check duplicate lookup
|
||||||
|
dup_match = await repo.find_duplicate_post("hash_12345")
|
||||||
|
assert dup_match is not None
|
||||||
|
assert dup_match.id == post_id
|
||||||
|
|
||||||
|
# 4. Test AI update & Approval flow
|
||||||
|
await repo.update_ai_result(post_id, subject="AI/Tech", ai_text="Rewritten: AI update is now live!", suggested_target_id=t_id)
|
||||||
|
pending_review = await repo.get_posts_by_status("pending_review")
|
||||||
|
assert len(pending_review) == 1
|
||||||
|
assert pending_review[0].subject == "AI/Tech"
|
||||||
|
|
||||||
|
await repo.approve_post(post_id, target_channel_id=t_id)
|
||||||
|
approved_post = await repo.get_next_approved_post_for_target(t_id)
|
||||||
|
assert approved_post is not None
|
||||||
|
assert approved_post.id == post_id
|
||||||
|
|
||||||
|
await repo.mark_post_published(post_id)
|
||||||
|
assert await repo.get_next_approved_post_for_target(t_id) is None
|
||||||
|
|
||||||
|
# 5. Settings
|
||||||
|
await repo.set_setting("system_prompt", "You are a professional editor.")
|
||||||
|
val = await repo.get_setting("system_prompt")
|
||||||
|
assert val == "You are a professional editor."
|
||||||
|
|
||||||
|
print("All database tests passed successfully!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run_tests())
|
||||||
Reference in New Issue
Block a user