Files
copykar/db/repository.py
T

1242 lines
53 KiB
Python

import json
import asyncpg
from datetime import datetime, timezone
from db.models import (
SourceChannel, SourceWebsite, TargetChannel, Post, Setting,
AILog, AIProviderProfile, ChannelCategory, AdminReviewChannel
)
from db.database import get_db_pool
def _parse_admin_channel_row(row: asyncpg.Record) -> AdminReviewChannel:
return AdminReviewChannel(**dict(row))
def _parse_post_row(row: asyncpg.Record) -> Post:
data = dict(row)
if isinstance(data.get("published_to"), str):
try:
data["published_to"] = json.loads(data["published_to"])
except Exception:
data["published_to"] = []
elif data.get("published_to") is None:
data["published_to"] = []
return Post(**data)
def _parse_source_website_row(row: asyncpg.Record) -> SourceWebsite:
data = dict(row)
if isinstance(data.get("api_config"), str):
try:
data["api_config"] = json.loads(data["api_config"])
except Exception:
data["api_config"] = {}
elif data.get("api_config") is None:
data["api_config"] = {}
return SourceWebsite(**data)
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 ORDER BY id ASC;")
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
async def get_source_by_id(self, source_id: int) -> Optional[SourceChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM sources WHERE id = $1;", source_id)
return SourceChannel(**dict(row)) if row else None
async def update_source_context_count(self, source_id: int, count: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE sources SET context_message_count = $1 WHERE id = $2;", max(0, count), source_id)
async def delete_source(self, source_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE sources SET is_active = FALSE WHERE id = $1;", source_id)
# --- Source Websites ---
async def add_source_website(
self,
name: str,
url: str,
category_id: Optional[int] = None,
check_interval_min: int = 30,
auto_reanalyze_hours: int = 24,
api_config: Optional[Dict[str, Any]] = None,
custom_instructions: str = ""
) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
cfg_json = json.dumps(api_config or {})
row = await conn.fetchrow(
"""
INSERT INTO source_websites (name, url, category_id, check_interval_min, auto_reanalyze_hours, api_config, custom_instructions)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)
ON CONFLICT(url) DO UPDATE SET
name = EXCLUDED.name,
category_id = COALESCE(EXCLUDED.category_id, source_websites.category_id),
custom_instructions = COALESCE(NULLIF(EXCLUDED.custom_instructions, ''), source_websites.custom_instructions),
is_active = TRUE
RETURNING id;
""",
name, url, category_id, check_interval_min, auto_reanalyze_hours, cfg_json, custom_instructions
)
return row["id"]
async def get_active_source_websites(self) -> List[SourceWebsite]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM source_websites WHERE is_active = TRUE ORDER BY id ASC;")
return [_parse_source_website_row(r) for r in rows]
async def get_source_websites(self) -> List[SourceWebsite]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM source_websites WHERE is_active = TRUE ORDER BY id ASC;")
return [_parse_source_website_row(r) for r in rows]
async def get_source_website_by_id(self, site_id: int) -> Optional[SourceWebsite]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM source_websites WHERE id = $1;", site_id)
return _parse_source_website_row(row) if row else None
async def get_source_websites_by_category(self, category_id: Optional[int]) -> List[SourceWebsite]:
pool = await self._get_pool()
async with pool.acquire() as conn:
if category_id is None:
rows = await conn.fetch("SELECT * FROM source_websites WHERE is_active = TRUE AND category_id IS NULL ORDER BY id ASC;")
else:
rows = await conn.fetch("SELECT * FROM source_websites WHERE is_active = TRUE AND category_id = $1 ORDER BY id ASC;", category_id)
return [_parse_source_website_row(r) for r in rows]
async def update_source_website_category(self, site_id: int, category_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE source_websites SET category_id = $1 WHERE id = $2;", category_id, site_id)
async def update_source_website_api_config(self, site_id: int, api_config: Dict[str, Any]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE source_websites
SET api_config = $1::jsonb,
last_reanalyzed_at = CURRENT_TIMESTAMP,
last_error = NULL,
last_error_at = NULL
WHERE id = $2;
""",
json.dumps(api_config), site_id
)
async def update_source_website_fetch_status(self, site_id: int, error: Optional[str] = None) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
if error:
await conn.execute(
"""
UPDATE source_websites
SET last_error = $1, last_error_at = CURRENT_TIMESTAMP
WHERE id = $2;
""",
error, site_id
)
else:
await conn.execute(
"""
UPDATE source_websites
SET last_fetched_at = CURRENT_TIMESTAMP, last_error = NULL, last_error_at = NULL
WHERE id = $1;
""",
site_id
)
async def update_source_website_interval(self, site_id: int, interval_min: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE source_websites SET check_interval_min = $1 WHERE id = $2;", max(1, interval_min), site_id)
async def update_source_website_reanalyze_hours(self, site_id: int, hours: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE source_websites SET auto_reanalyze_hours = $1 WHERE id = $2;", max(0, hours), site_id)
async def update_source_website_custom_instructions(self, site_id: int, instructions: str) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE source_websites SET custom_instructions = $1 WHERE id = $2;", instructions.strip(), site_id)
async def delete_source_website(self, site_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE source_websites SET is_active = FALSE WHERE id = $1;", site_id)
async def update_target_context_count(self, target_id: int, count: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE targets SET context_message_count = $1 WHERE id = $2;", max(0, count), target_id)
async def get_recent_target_posts(
self,
target_id: int,
limit: int = 10,
exclude_post_id: Optional[int] = None
) -> List[Post]:
if limit <= 0:
return []
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE is_deleted = FALSE
AND ($2::BIGINT IS NULL OR id != $2)
AND (
target_channel_id = $1
OR (
published_to IS NOT NULL
AND jsonb_typeof(published_to) = 'array'
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(published_to) elem
WHERE (elem->>'target_id')::bigint = $1
)
)
)
ORDER BY COALESCE(published_at, created_at) DESC, id DESC
LIMIT $3;
""",
target_id, exclude_post_id, limit
)
posts = [_parse_post_row(r) for r in rows]
posts.reverse()
return posts
async def delete_target(self, target_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE targets SET is_active = FALSE WHERE id = $1;", target_id)
# --- Target Channels ---
async def add_target(
self,
channel_id: int,
title: Optional[str] = None,
username: Optional[str] = None,
post_interval_min: int = 30,
personality: str = "",
custom_footer: str = ""
) -> 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, personality, custom_footer)
VALUES ($1, $2, $3, $4, $5, $6)
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, personality, custom_footer,
)
return row["id"]
async def update_target_personality(self, target_id: int, personality: str, custom_footer: Optional[str] = None) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
if custom_footer is not None:
await conn.execute(
"UPDATE targets SET personality = $1, custom_footer = $2 WHERE id = $3;",
personality, custom_footer, target_id
)
else:
await conn.execute(
"UPDATE targets SET personality = $1 WHERE id = $2;",
personality, target_id
)
async def update_target_footer(self, target_id: int, custom_footer: str) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE targets SET custom_footer = $1 WHERE id = $2;",
custom_footer, target_id
)
async def update_target_language(self, target_id: int, language: str) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE targets SET language = $1 WHERE id = $2;",
language, target_id
)
async def update_target_custom_prompt(self, target_id: int, custom_prompt: str) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE targets SET custom_prompt = $1 WHERE id = $2;",
custom_prompt, target_id
)
async def update_target_dispatch_order(self, target_id: int, dispatch_order: str) -> None:
order = "random" if str(dispatch_order).strip().lower() == "random" else "order"
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE targets SET dispatch_order = $1 WHERE id = $2;",
order, target_id
)
async def update_target_schedule(
self,
target_id: int,
post_interval_min: Optional[int] = None,
sleep_start_hour: Optional[int] = None,
sleep_end_hour: Optional[int] = None,
is_sleep_enabled: Optional[bool] = None
) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
target = await self.get_target_by_id(target_id)
if not target:
return
new_interval = post_interval_min if post_interval_min is not None else target.post_interval_min
new_start = sleep_start_hour if sleep_start_hour is not None else target.sleep_start_hour
new_end = sleep_end_hour if sleep_end_hour is not None else target.sleep_end_hour
new_enabled = is_sleep_enabled if is_sleep_enabled is not None else target.is_sleep_enabled
await conn.execute(
"""
UPDATE targets
SET post_interval_min = $1, sleep_start_hour = $2, sleep_end_hour = $3, is_sleep_enabled = $4
WHERE id = $5;
""",
new_interval, new_start, new_end, new_enabled, target_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 ORDER BY id ASC;")
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 set_target_auto_sources(self, target_id: int, source_channel_ids: List[int]) -> None:
"""Replace the set of source channels auto-routed into this target."""
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE targets SET auto_source_ids = $1::bigint[] WHERE id = $2;",
sorted(set(source_channel_ids)), target_id,
)
async def toggle_target_auto_source(self, target_id: int, source_channel_id: int) -> bool:
"""Add or remove one source from a target's auto-route list. Returns the new state."""
target = await self.get_target_by_id(target_id)
if not target:
return False
current = set(target.auto_source_ids or [])
enabled = source_channel_id not in current
if enabled:
current.add(source_channel_id)
else:
current.discard(source_channel_id)
await self.set_target_auto_sources(target_id, list(current))
return enabled
async def get_targets_auto_routed_from(self, source_channel_id: int) -> List[TargetChannel]:
"""Active targets that have subscribed to this source channel."""
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM targets
WHERE is_active = TRUE AND $1 = ANY(auto_source_ids)
ORDER BY id ASC;
""",
source_channel_id,
)
return [TargetChannel(**dict(r)) for r in rows]
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 & Multi-Channel Dispatch ---
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,
tags: Optional[List[str]] = None,
subject: Optional[str] = None,
is_duplicate: bool = False,
duplicate_of_id: Optional[int] = None,
similarity_reason: Optional[str] = None,
source_created_at: Optional[datetime] = 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, tags, subject, is_duplicate, duplicate_of_id, similarity_reason, source_created_at, status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'pending_review')
RETURNING id;
""",
source_channel_id,
source_message_id,
raw_text,
media_path,
media_type,
content_hash,
tags or [],
subject,
is_duplicate,
duplicate_of_id,
similarity_reason,
source_created_at,
)
return row["id"] if row else None
except asyncpg.UniqueViolationError:
return None
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 _parse_post_row(row) if row else None
async def get_recent_source_posts(
self,
source_channel_id: int,
limit: int = 5,
exclude_post_id: Optional[int] = None
) -> List[Post]:
"""Fetch the most recent posts from this source channel for narrative context."""
if limit <= 0:
return []
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE source_channel_id = $1
AND is_deleted = FALSE
AND ($2::bigint IS NULL OR id <> $2)
ORDER BY COALESCE(source_created_at, created_at) DESC, id DESC
LIMIT $3;
""",
source_channel_id, exclude_post_id, limit
)
# Return in chronological order so the AI sees the natural progression (earliest to latest)
posts = [_parse_post_row(r) for r in rows]
posts.reverse()
return posts
async def find_candidate_posts_by_tags(
self,
tags: List[str],
exclude_post_id: Optional[int] = None,
limit: int = 10
) -> List[Post]:
"""Find recent posts that share at least one tag."""
if not tags:
return []
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE tags && $1::text[]
AND is_deleted = FALSE
AND ($2::bigint IS NULL OR id <> $2)
ORDER BY id DESC
LIMIT $3;
""",
tags, exclude_post_id, limit
)
return [_parse_post_row(r) for r in rows]
async def find_duplicate_post(self, content_hash: Optional[str], exclude_post_id: Optional[int] = None) -> Optional[Post]:
"""Return the earliest post already carrying this content hash, if any."""
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
AND is_deleted = FALSE
AND ($2::bigint IS NULL OR id <> $2)
ORDER BY id ASC
LIMIT 1;
""",
content_hash, exclude_post_id,
)
return _parse_post_row(row) if row else None
async def count_posts_by_status(self, status: str) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
return await conn.fetchval("SELECT COUNT(*) FROM posts WHERE status = $1;", status) or 0
async def count_posts_from_source(self, source_channel_id: int) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
return await conn.fetchval(
"SELECT COUNT(*) FROM posts WHERE source_channel_id = $1 AND is_deleted = FALSE;",
source_channel_id,
) or 0
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 [_parse_post_row(r) for r in rows]
async def get_unreviewed_posts(self, limit: int = 50) -> List[Post]:
"""Pending posts that never made it onto a review card in the admin channel."""
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE status = 'pending_review'
AND is_deleted = FALSE
AND review_message_id IS NULL
ORDER BY id ASC
LIMIT $1;
""",
limit,
)
return [_parse_post_row(r) for r in rows]
async def update_review_message_id(self, post_id: int, review_message_id: Optional[int], review_channel_id: Optional[int] = None) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
if review_channel_id is not None:
await conn.execute(
"UPDATE posts SET review_message_id = $1, review_channel_id = $2 WHERE id = $3;",
review_message_id, review_channel_id, post_id,
)
else:
await conn.execute(
"UPDATE posts SET review_message_id = $1 WHERE id = $2;",
review_message_id, post_id,
)
async def _upsert_published_entry(
self,
post_id: int,
target_id: int,
target_title: str,
published_at: Optional[datetime],
mark_published: bool,
) -> None:
"""Add or stamp this target's entry in posts.published_to.
The list is read, edited and written back as a single parameterised jsonb value so
that titles containing quotes or backslashes cannot corrupt the document.
"""
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
row = await conn.fetchrow("SELECT published_to FROM posts WHERE id = $1 FOR UPDATE;", post_id)
if not row:
return
entries = row["published_to"]
if isinstance(entries, str):
try:
entries = json.loads(entries)
except Exception:
entries = []
if not isinstance(entries, list):
entries = []
stamp = published_at.isoformat() if published_at else None
for entry in entries:
if isinstance(entry, dict) and entry.get("target_id") == target_id:
entry["target_title"] = target_title
if stamp:
entry["published_at"] = stamp
break
else:
entries.append({
"target_id": target_id,
"target_title": target_title,
"published_at": stamp,
})
if mark_published:
await conn.execute(
"""
UPDATE posts
SET published_to = $1::jsonb,
status = 'published',
published_at = COALESCE(published_at, CURRENT_TIMESTAMP)
WHERE id = $2;
""",
json.dumps(entries, ensure_ascii=False), post_id,
)
else:
await conn.execute(
"UPDATE posts SET published_to = $1::jsonb WHERE id = $2;",
json.dumps(entries, ensure_ascii=False), post_id,
)
async def record_post_queued_to_target(self, post_id: int, target_id: int, target_title: str) -> None:
"""Note that a post is waiting in a target's delivery queue. Status is left untouched."""
await self._upsert_published_entry(post_id, target_id, target_title, published_at=None, mark_published=False)
async def record_post_published_to_target(self, post_id: int, target_id: int, target_title: str) -> None:
"""Stamp the post as actually delivered to a target channel."""
await self._upsert_published_entry(
post_id, target_id, target_title,
published_at=datetime.now(timezone.utc), mark_published=True,
)
# --- Error tracking ---
async def get_open_error_summary(self, limit: int = 20) -> List[Dict[str, Any]]:
"""Unresolved errors grouped by service + exception type, newest group first."""
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT service_name, error_type, COUNT(*) AS occurrences,
MAX(created_at) AS last_seen,
(ARRAY_AGG(error_message ORDER BY created_at DESC))[1] AS last_message
FROM error_logs
WHERE resolved = FALSE
GROUP BY service_name, error_type
ORDER BY MAX(created_at) DESC
LIMIT $1;
""",
limit,
)
return [dict(r) for r in rows]
async def count_open_errors(self) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
return await conn.fetchval("SELECT COUNT(*) FROM error_logs WHERE resolved = FALSE;") or 0
async def resolve_errors(
self,
service_name: Optional[str] = None,
error_type: Optional[str] = None,
note: Optional[str] = None,
) -> int:
"""Mark matching unresolved errors as fixed. Both filters None resolves everything."""
pool = await self._get_pool()
async with pool.acquire() as conn:
return await conn.fetchval(
"""
WITH updated AS (
UPDATE error_logs
SET resolved = TRUE,
resolved_at = CURRENT_TIMESTAMP,
resolved_note = COALESCE($3, resolved_note)
WHERE resolved = FALSE
AND ($1::text IS NULL OR service_name = $1)
AND ($2::text IS NULL OR error_type = $2)
RETURNING 1
)
SELECT COUNT(*) FROM updated;
""",
service_name, error_type, note,
) or 0
async def reject_post(self, post_id: int, rejection_reason: Optional[str] = None) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
if rejection_reason:
await conn.execute(
"UPDATE posts SET status = 'rejected', rejection_reason = $1 WHERE id = $2;",
rejection_reason, post_id
)
else:
await conn.execute("UPDATE posts SET status = 'rejected' WHERE id = $1;", post_id)
async def soft_delete_post(self, post_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE posts SET is_deleted = TRUE, status = 'deleted' WHERE id = $1;", post_id)
# --- System 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:
val = await conn.fetchval("SELECT value FROM settings WHERE key = $1;", key)
return val if val is not None 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
)
async def get_all_settings(self) -> Dict[str, str]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT key, value FROM settings;")
return {r["key"]: r["value"] for r in rows}
# --- AI Logs ---
async def record_ai_log(
self,
action_name: str,
provider: str,
model: str,
prompt: str,
system_prompt: Optional[str] = None,
response_text: Optional[str] = None,
duration_sec: float = 0.0,
status: str = "success",
error_message: Optional[str] = None,
) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
return await conn.fetchval(
"""
INSERT INTO ai_logs (action_name, provider, model, prompt, system_prompt, response_text, duration_sec, status, error_message)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id;
""",
action_name, provider, model, prompt, system_prompt, response_text, duration_sec, status, error_message
)
async def get_recent_ai_logs(self, limit: int = 10) -> List[AILog]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, action_name, provider, model, prompt, system_prompt, response_text, duration_sec, status, error_message,
to_char(created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
FROM ai_logs
ORDER BY id DESC
LIMIT $1;
""",
limit
)
return [AILog(**dict(r)) for r in rows]
async def get_ai_log_by_id(self, log_id: int) -> Optional[AILog]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT id, action_name, provider, model, prompt, system_prompt, response_text, duration_sec, status, error_message,
to_char(created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
FROM ai_logs
WHERE id = $1;
""",
log_id
)
return AILog(**dict(row)) if row else None
# --- AI Providers Management ---
async def ensure_default_providers(self) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
count = await conn.fetchval("SELECT COUNT(*) FROM ai_providers;")
if count == 0:
await conn.execute(
"""
INSERT INTO ai_providers (name, provider_type, model, base_url, api_key, reasoning_effort, is_active)
VALUES
('AGY (سرور داخلی)', 'agy', 'antigravity', 'http://host.docker.internal:8088/v1', '', '', TRUE),
('OpenRouter / OpenAI', 'openai', 'google/gemini-3.5-flash', 'https://openrouter.ai/api/v1', '', '', FALSE),
('Google Gemini Direct', 'gemini', 'gemini-1.5-flash', 'https://generativelanguage.googleapis.com/v1beta', '', '', FALSE);
"""
)
async def add_provider_profile(
self,
name: str,
provider_type: str,
model: str,
base_url: str = "",
api_key: str = "",
reasoning_effort: str = "",
is_active: bool = False,
supports_vision: bool = False
) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
if is_active:
await conn.execute("UPDATE ai_providers SET is_active = FALSE;")
return await conn.fetchval(
"""
INSERT INTO ai_providers (name, provider_type, model, base_url, api_key, reasoning_effort, is_active, supports_vision)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id;
""",
name, provider_type, model, base_url, api_key, reasoning_effort, is_active, supports_vision
)
async def get_provider_profiles(self) -> List[AIProviderProfile]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT p.id, p.name, p.provider_type, p.model, p.base_url, p.api_key, p.reasoning_effort, p.is_active,
p.fallback_provider_id, p.supports_vision,
fb.name as fallback_provider_name,
to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
FROM ai_providers p
LEFT JOIN ai_providers fb ON p.fallback_provider_id = fb.id
ORDER BY p.id ASC;
"""
)
return [AIProviderProfile(**dict(r)) for r in rows]
async def get_provider_profile_by_id(self, profile_id: int) -> Optional[AIProviderProfile]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT p.id, p.name, p.provider_type, p.model, p.base_url, p.api_key, p.reasoning_effort, p.is_active,
p.fallback_provider_id, p.supports_vision,
fb.name as fallback_provider_name,
to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
FROM ai_providers p
LEFT JOIN ai_providers fb ON p.fallback_provider_id = fb.id
WHERE p.id = $1;
""",
profile_id
)
return AIProviderProfile(**dict(row)) if row else None
async def get_active_provider_profile(self) -> Optional[AIProviderProfile]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT p.id, p.name, p.provider_type, p.model, p.base_url, p.api_key, p.reasoning_effort, p.is_active,
p.fallback_provider_id, p.supports_vision,
fb.name as fallback_provider_name,
to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
FROM ai_providers p
LEFT JOIN ai_providers fb ON p.fallback_provider_id = fb.id
WHERE p.is_active = TRUE
ORDER BY p.id ASC
LIMIT 1;
"""
)
return AIProviderProfile(**dict(row)) if row else None
async def set_active_provider_profile(self, profile_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute("UPDATE ai_providers SET is_active = FALSE;")
await conn.execute("UPDATE ai_providers SET is_active = TRUE WHERE id = $1;", profile_id)
async def update_provider_fallback(self, profile_id: int, fallback_provider_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE ai_providers SET fallback_provider_id = $1 WHERE id = $2;",
fallback_provider_id, profile_id
)
async def update_provider_vision(self, profile_id: int, supports_vision: bool) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE ai_providers SET supports_vision = $1 WHERE id = $2;",
supports_vision, profile_id
)
async def update_provider_profile(
self,
profile_id: int,
name: Optional[str] = None,
model: Optional[str] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
reasoning_effort: Optional[str] = None,
) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE ai_providers
SET name = COALESCE($2, name),
model = COALESCE($3, model),
base_url = COALESCE($4, base_url),
api_key = COALESCE($5, api_key),
reasoning_effort = COALESCE($6, reasoning_effort)
WHERE id = $1;
""",
profile_id, name, model, base_url, api_key, reasoning_effort
)
async def update_provider_reasoning_effort(self, profile_id: int, reasoning_effort: str) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE ai_providers SET reasoning_effort = $1 WHERE id = $2;",
reasoning_effort, profile_id
)
async def delete_provider_profile(self, profile_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM ai_providers WHERE id = $1;", profile_id)
# --- Channel Categories ---
async def create_category(self, name: str, cat_type: str = "both", description: str = "") -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO channel_categories (name, type, description)
VALUES ($1, $2, $3)
RETURNING id;
""",
name, cat_type, description
)
return row["id"]
async def get_categories(self, cat_type: Optional[str] = None) -> List[ChannelCategory]:
pool = await self._get_pool()
async with pool.acquire() as conn:
if cat_type:
rows = await conn.fetch(
"SELECT * FROM channel_categories WHERE type = $1 OR type = 'both' ORDER BY id ASC;",
cat_type
)
else:
rows = await conn.fetch("SELECT * FROM channel_categories ORDER BY id ASC;")
return [ChannelCategory(**dict(r)) for r in rows]
async def get_category_by_id(self, cat_id: int) -> Optional[ChannelCategory]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM channel_categories WHERE id = $1;", cat_id)
return ChannelCategory(**dict(row)) if row else None
async def update_category(self, cat_id: int, name: Optional[str] = None, description: Optional[str] = None, cat_type: Optional[str] = None) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE channel_categories
SET name = COALESCE($2, name),
description = COALESCE($3, description),
type = COALESCE($4, type)
WHERE id = $1;
""",
cat_id, name, description, cat_type
)
async def delete_category(self, cat_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE sources SET category_id = NULL WHERE category_id = $1;", cat_id)
await conn.execute("UPDATE targets SET category_id = NULL WHERE category_id = $1;", cat_id)
await conn.execute("DELETE FROM channel_categories WHERE id = $1;", cat_id)
async def set_source_category(self, source_id: int, category_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE sources SET category_id = $1 WHERE id = $2;", category_id, source_id)
async def set_target_category(self, target_id: int, category_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE targets SET category_id = $1 WHERE id = $2;", category_id, target_id)
async def get_sources_by_category(self, category_id: Optional[int]) -> List[SourceChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
if category_id is None:
rows = await conn.fetch("SELECT * FROM sources WHERE is_active = TRUE AND category_id IS NULL ORDER BY id ASC;")
else:
rows = await conn.fetch("SELECT * FROM sources WHERE is_active = TRUE AND category_id = $1 ORDER BY id ASC;", category_id)
return [SourceChannel(**dict(r)) for r in rows]
async def get_targets_by_category(self, category_id: Optional[int]) -> List[TargetChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
if category_id is None:
rows = await conn.fetch("SELECT * FROM targets WHERE is_active = TRUE AND category_id IS NULL ORDER BY id ASC;")
else:
rows = await conn.fetch("SELECT * FROM targets WHERE is_active = TRUE AND category_id = $1 ORDER BY id ASC;", category_id)
return [TargetChannel(**dict(r)) for r in rows]
async def get_category_channel_counts(self, category_id: int) -> Dict[str, int]:
pool = await self._get_pool()
async with pool.acquire() as conn:
src_count = await conn.fetchval("SELECT COUNT(*) FROM sources WHERE is_active = TRUE AND category_id = $1;", category_id) or 0
trg_count = await conn.fetchval("SELECT COUNT(*) FROM targets WHERE is_active = TRUE AND category_id = $1;", category_id) or 0
return {"sources": src_count, "targets": trg_count}
# --- System Global Operational State ---
async def is_system_paused(self) -> bool:
val = await self.get_setting("system_is_paused", "false")
return str(val).strip().lower() in ("true", "1", "yes")
async def set_system_paused(self, paused: bool) -> None:
await self.set_setting(
"system_is_paused",
"true" if paused else "false",
description="Global emergency operational pause"
)
# --- Admin Review Channels ---
async def add_admin_channel(
self,
channel_id: int,
title: Optional[str] = None,
username: Optional[str] = None,
is_default: bool = False
) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
if is_default:
await conn.execute("UPDATE admin_channels SET is_default = FALSE;")
row = await conn.fetchrow(
"""
INSERT INTO admin_channels (channel_id, title, username, is_default, is_active)
VALUES ($1, $2, $3, $4, TRUE)
ON CONFLICT(channel_id) DO UPDATE SET
title = COALESCE(EXCLUDED.title, admin_channels.title),
username = COALESCE(EXCLUDED.username, admin_channels.username),
is_default = EXCLUDED.is_default,
is_active = TRUE
RETURNING id;
""",
channel_id, title, username, is_default
)
return row["id"]
async def get_admin_channels(self) -> List[AdminReviewChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM admin_channels WHERE is_active = TRUE ORDER BY is_default DESC, id ASC;")
return [_parse_admin_channel_row(r) for r in rows]
async def get_admin_channel_by_id(self, admin_id: int) -> Optional[AdminReviewChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM admin_channels WHERE id = $1;", admin_id)
return _parse_admin_channel_row(row) if row else None
async def get_admin_channel_by_telegram_id(self, channel_id: int) -> Optional[AdminReviewChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM admin_channels WHERE channel_id = $1;", channel_id)
return _parse_admin_channel_row(row) if row else None
async def get_default_admin_channel(self) -> Optional[AdminReviewChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM admin_channels WHERE is_active = TRUE AND is_default = TRUE LIMIT 1;")
if not row:
row = await conn.fetchrow("SELECT * FROM admin_channels WHERE is_active = TRUE ORDER BY id ASC LIMIT 1;")
return _parse_admin_channel_row(row) if row else None
async def set_default_admin_channel(self, admin_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute("UPDATE admin_channels SET is_default = FALSE;")
await conn.execute("UPDATE admin_channels SET is_default = TRUE WHERE id = $1;", admin_id)
async def delete_admin_channel(self, admin_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT channel_id FROM admin_channels WHERE id = $1;", admin_id)
if row:
ch_id = row["channel_id"]
await conn.execute("UPDATE sources SET admin_channel_id = NULL WHERE admin_channel_id = $1;", ch_id)
await conn.execute("UPDATE source_websites SET admin_channel_id = NULL WHERE admin_channel_id = $1;", ch_id)
await conn.execute("DELETE FROM admin_channels WHERE id = $1;", admin_id)
# --- Source & Website Admin Channel Setters ---
async def set_source_admin_channel(self, source_id: int, admin_channel_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE sources SET admin_channel_id = $1 WHERE id = $2;", admin_channel_id, source_id)
async def set_website_admin_channel(self, site_id: int, admin_channel_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE source_websites SET admin_channel_id = $1 WHERE id = $2;", admin_channel_id, site_id)
# --- Review Purge Helpers ---
async def get_review_posts_by_admin_channel(self, admin_channel_id: int) -> List[Post]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE review_channel_id = $1
AND review_message_id IS NOT NULL
AND is_deleted = FALSE
ORDER BY id ASC;
""",
admin_channel_id
)
return [_parse_post_row(r) for r in rows]
async def count_review_posts_by_admin_channel(self, admin_channel_id: int) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
val = await conn.fetchval(
"""
SELECT COUNT(*) FROM posts
WHERE review_channel_id = $1
AND review_message_id IS NOT NULL
AND is_deleted = FALSE;
""",
admin_channel_id
)
return val or 0
async def clear_review_messages_for_channel(self, admin_channel_id: int) -> List[Post]:
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE review_channel_id = $1
AND review_message_id IS NOT NULL
AND is_deleted = FALSE;
""",
admin_channel_id
)
posts = [_parse_post_row(r) for r in rows]
await conn.execute(
"""
UPDATE posts
SET review_message_id = NULL,
is_deleted = TRUE,
status = 'deleted'
WHERE review_channel_id = $1
AND review_message_id IS NOT NULL
AND is_deleted = FALSE;
""",
admin_channel_id
)
return posts
async def get_all_review_posts_with_message_id(self) -> List[Post]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE review_message_id IS NOT NULL
AND is_deleted = FALSE
ORDER BY id ASC;
"""
)
return [_parse_post_row(r) for r in rows]
async def clear_all_review_messages(self) -> List[Post]:
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE review_message_id IS NOT NULL
AND is_deleted = FALSE;
"""
)
posts = [_parse_post_row(r) for r in rows]
await conn.execute(
"""
UPDATE posts
SET review_message_id = NULL,
is_deleted = TRUE,
status = 'deleted'
WHERE review_message_id IS NOT NULL
AND is_deleted = FALSE;
"""
)
return posts