Files
wiseclaw/backend/app/automation/store.py

456 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import AuditLogORM, AutomationORM, AutomationWizardORM
from app.models import AutomationRecord
LOCAL_TZ = ZoneInfo("Europe/Istanbul")
WEEKDAY_MAP = {
"pzt": 0,
"pazartesi": 0,
"sal": 1,
"sali": 1,
"çar": 2,
"cars": 2,
"çarşamba": 2,
"carsamba": 2,
"per": 3,
"persembe": 3,
"perşembe": 3,
"cum": 4,
"cuma": 4,
"cts": 5,
"cumartesi": 5,
"paz": 6,
"pazar": 6,
}
WEEKDAY_NAMES = ["Pzt", "Sal", "Cars", "Per", "Cum", "Cts", "Paz"]
class AutomationService:
def __init__(self, session: Session) -> None:
self.session = session
def list_automations(self, telegram_user_id: int | None = None) -> list[AutomationRecord]:
stmt = select(AutomationORM).order_by(AutomationORM.created_at.desc(), AutomationORM.id.desc())
if telegram_user_id is not None:
stmt = stmt.where(AutomationORM.telegram_user_id == telegram_user_id)
return [self._to_record(item) for item in self.session.scalars(stmt)]
def start_wizard(self, telegram_user_id: int) -> str:
record = self._get_or_create_wizard(telegram_user_id)
record.step = 0
record.draft_json = "{}"
record.updated_at = datetime.utcnow()
self.session.add(AuditLogORM(category="automation", message=f"automation:wizard-start:{telegram_user_id}"))
self.session.flush()
return (
"Yeni otomasyon olusturalim. Istersen herhangi bir adimda /iptal yazabilirsin.\n\n"
"1/6 Otomasyon adi ne olsun?"
)
def is_wizard_active(self, telegram_user_id: int) -> bool:
wizard = self.session.get(AutomationWizardORM, telegram_user_id)
return wizard is not None and wizard.step < 6
def cancel_wizard(self, telegram_user_id: int) -> str:
wizard = self.session.get(AutomationWizardORM, telegram_user_id)
if wizard is not None:
self.session.delete(wizard)
self.session.add(AuditLogORM(category="automation", message=f"automation:wizard-cancel:{telegram_user_id}"))
self.session.flush()
return "Otomasyon olusturma akisini iptal ettim."
def answer_wizard(self, telegram_user_id: int, text: str) -> tuple[str, bool]:
wizard = self._get_or_create_wizard(telegram_user_id)
draft = self._load_draft(wizard)
cleaned = text.strip()
if wizard.step == 0:
draft["name"] = cleaned
wizard.step = 1
return self._persist_wizard(wizard, draft, "2/6 Bu otomasyon ne yapsin?")
if wizard.step == 1:
draft["prompt"] = cleaned
wizard.step = 2
return self._persist_wizard(
wizard,
draft,
"3/6 Hangi siklikla calissin? Su seceneklerden birini yaz: gunluk, haftaici, haftalik, saatlik",
)
if wizard.step == 2:
schedule_type = self._parse_schedule_type(cleaned)
if schedule_type is None:
return ("Gecerli bir secim gormedim. Lutfen gunluk, haftaici, haftalik veya saatlik yaz.", False)
draft["schedule_type"] = schedule_type
wizard.step = 3
if schedule_type == "hourly":
prompt = "4/6 Kac saatte bir calissin? Ornek: 1, 2, 4, 6"
elif schedule_type == "weekly":
prompt = "4/6 Hangi gunlerde calissin? Ornek: Pzt,Cars,Cum"
else:
prompt = "4/6 Saat kacta calissin? 24 saat formatinda yaz. Ornek: 09:00"
return self._persist_wizard(wizard, draft, prompt)
if wizard.step == 3:
schedule_type = str(draft.get("schedule_type", "daily"))
if schedule_type == "hourly":
interval_hours = self._parse_interval_hours(cleaned)
if interval_hours is None:
return ("Gecerli bir saat araligi gormedim. Lutfen 1 ile 24 arasinda bir sayi yaz.", False)
draft["interval_hours"] = interval_hours
wizard.step = 4
return self._persist_wizard(wizard, draft, "5/6 Aktif olarak kaydedeyim mi? evet/hayir")
if schedule_type == "weekly":
weekdays = self._parse_weekdays(cleaned)
if not weekdays:
return ("Gunleri anlayamadim. Ornek olarak Pzt,Cars,Cum yazabilirsin.", False)
draft["days_of_week"] = weekdays
wizard.step = 4
return self._persist_wizard(wizard, draft, "5/6 Saat kacta calissin? 24 saat formatinda yaz. Ornek: 09:00")
time_of_day = self._parse_time(cleaned)
if time_of_day is None:
return ("Saat formatini anlayamadim. Lutfen 24 saat formatinda HH:MM yaz.", False)
draft["time_of_day"] = time_of_day
wizard.step = 4
return self._persist_wizard(wizard, draft, "5/6 Aktif olarak kaydedeyim mi? evet/hayir")
if wizard.step == 4:
schedule_type = str(draft.get("schedule_type", "daily"))
if schedule_type == "weekly" and "time_of_day" not in draft:
time_of_day = self._parse_time(cleaned)
if time_of_day is None:
return ("Saat formatini anlayamadim. Lutfen 24 saat formatinda HH:MM yaz.", False)
draft["time_of_day"] = time_of_day
wizard.step = 5
summary = self._render_wizard_summary(draft)
return self._persist_wizard(wizard, draft, f"{summary}\n\n6/6 Aktif olarak kaydedeyim mi? evet/hayir")
active = self._parse_yes_no(cleaned)
if active is None:
return ("Lutfen evet veya hayir yaz.", False)
draft["status"] = "active" if active else "paused"
created = self._create_automation(telegram_user_id, draft)
self.session.delete(wizard)
self.session.add(AuditLogORM(category="automation", message=f"automation:created:{created.id}"))
self.session.flush()
return (self._render_created_message(created), True)
if wizard.step == 5:
active = self._parse_yes_no(cleaned)
if active is None:
return ("Lutfen evet veya hayir yaz.", False)
draft["status"] = "active" if active else "paused"
created = self._create_automation(telegram_user_id, draft)
self.session.delete(wizard)
self.session.add(AuditLogORM(category="automation", message=f"automation:created:{created.id}"))
self.session.flush()
return (self._render_created_message(created), True)
return ("Otomasyon wizard durumu gecersiz.", False)
def render_automation_list(self, telegram_user_id: int) -> str:
automations = self.list_automations(telegram_user_id)
if not automations:
return "Henuz otomasyonun yok. /otomasyon_ekle ile baslayabiliriz."
lines = ["Otomasyonlarin:"]
for item in automations:
next_run = self._format_display_time(item.next_run_at)
lines.append(f"- #{item.id} {item.name} [{item.status}] -> siradaki: {next_run}")
return "\n".join(lines)
def pause_automation(self, telegram_user_id: int, automation_id: int) -> str:
item = self._get_owned_automation(telegram_user_id, automation_id)
if item is None:
return "Bu ID ile bir otomasyon bulamadim."
item.status = "paused"
item.updated_at = datetime.utcnow()
self.session.add(AuditLogORM(category="automation", message=f"automation:paused:{item.id}"))
self.session.flush()
return f"Otomasyon durduruldu: #{item.id} {item.name}"
def resume_automation(self, telegram_user_id: int, automation_id: int) -> str:
item = self._get_owned_automation(telegram_user_id, automation_id)
if item is None:
return "Bu ID ile bir otomasyon bulamadim."
item.status = "active"
item.next_run_at = self._compute_next_run(item, from_time=datetime.utcnow())
item.updated_at = datetime.utcnow()
self.session.add(AuditLogORM(category="automation", message=f"automation:resumed:{item.id}"))
self.session.flush()
return f"Otomasyon tekrar aktif edildi: #{item.id} {item.name}"
def delete_automation(self, telegram_user_id: int, automation_id: int) -> str:
item = self._get_owned_automation(telegram_user_id, automation_id)
if item is None:
return "Bu ID ile bir otomasyon bulamadim."
name = item.name
self.session.delete(item)
self.session.add(AuditLogORM(category="automation", message=f"automation:deleted:{automation_id}"))
self.session.flush()
return f"Otomasyon silindi: #{automation_id} {name}"
def due_automations(self, now: datetime | None = None) -> list[AutomationORM]:
current = now or datetime.utcnow()
stmt = (
select(AutomationORM)
.where(AutomationORM.status == "active")
.where(AutomationORM.next_run_at.is_not(None))
.where(AutomationORM.next_run_at <= current)
.order_by(AutomationORM.next_run_at.asc(), AutomationORM.id.asc())
)
return list(self.session.scalars(stmt))
def mark_run_result(self, item: AutomationORM, result: str, ran_at: datetime | None = None) -> None:
run_time = ran_at or datetime.utcnow()
item.last_run_at = run_time
item.last_result = result[:2000]
item.next_run_at = self._compute_next_run(item, from_time=run_time + timedelta(seconds=1))
item.updated_at = datetime.utcnow()
self.session.add(AuditLogORM(category="automation", message=f"automation:ran:{item.id}"))
self.session.flush()
def mark_run_error(self, item: AutomationORM, error: str) -> None:
item.last_result = f"ERROR: {error[:1800]}"
item.next_run_at = self._compute_next_run(item, from_time=datetime.utcnow() + timedelta(minutes=5))
item.updated_at = datetime.utcnow()
self.session.add(AuditLogORM(category="automation", message=f"automation:error:{item.id}:{error[:120]}"))
self.session.flush()
def _persist_wizard(self, wizard: AutomationWizardORM, draft: dict[str, object], reply: str) -> tuple[str, bool]:
wizard.draft_json = json.dumps(draft, ensure_ascii=False)
wizard.updated_at = datetime.utcnow()
self.session.flush()
return reply, False
def _get_or_create_wizard(self, telegram_user_id: int) -> AutomationWizardORM:
wizard = self.session.get(AutomationWizardORM, telegram_user_id)
if wizard is None:
wizard = AutomationWizardORM(
telegram_user_id=telegram_user_id,
step=0,
draft_json="{}",
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
self.session.add(wizard)
self.session.flush()
return wizard
def _load_draft(self, wizard: AutomationWizardORM) -> dict[str, object]:
try:
payload = json.loads(wizard.draft_json)
except json.JSONDecodeError:
return {}
return payload if isinstance(payload, dict) else {}
def _parse_schedule_type(self, text: str) -> str | None:
lowered = text.strip().lower()
mapping = {
"gunluk": "daily",
"daily": "daily",
"her gun": "daily",
"haftaici": "weekdays",
"hafta içi": "weekdays",
"weekdays": "weekdays",
"haftalik": "weekly",
"haftalık": "weekly",
"weekly": "weekly",
"saatlik": "hourly",
"hourly": "hourly",
}
return mapping.get(lowered)
def _parse_interval_hours(self, text: str) -> int | None:
try:
value = int(text.strip())
except ValueError:
return None
if 1 <= value <= 24:
return value
return None
def _parse_time(self, text: str) -> str | None:
cleaned = text.strip()
if len(cleaned) != 5 or ":" not in cleaned:
return None
hour_text, minute_text = cleaned.split(":", 1)
try:
hour = int(hour_text)
minute = int(minute_text)
except ValueError:
return None
if not (0 <= hour <= 23 and 0 <= minute <= 59):
return None
return f"{hour:02d}:{minute:02d}"
def _parse_weekdays(self, text: str) -> list[str]:
parts = [part.strip().lower() for part in text.replace("\n", ",").split(",")]
seen: list[int] = []
for part in parts:
day = WEEKDAY_MAP.get(part)
if day is not None and day not in seen:
seen.append(day)
return [WEEKDAY_NAMES[day] for day in sorted(seen)]
def _parse_yes_no(self, text: str) -> bool | None:
lowered = text.strip().lower()
if lowered in {"evet", "e", "yes", "y"}:
return True
if lowered in {"hayir", "hayır", "h", "no", "n"}:
return False
return None
def _render_wizard_summary(self, draft: dict[str, object]) -> str:
schedule_type = str(draft.get("schedule_type", "daily"))
label = {
"daily": "gunluk",
"weekdays": "haftaici",
"weekly": "haftalik",
"hourly": "saatlik",
}.get(schedule_type, schedule_type)
lines = [
"Ozet:",
f"- Ad: {draft.get('name', '-')}",
f"- Gorev: {draft.get('prompt', '-')}",
f"- Siklik: {label}",
]
if schedule_type == "hourly":
lines.append(f"- Aralik: {draft.get('interval_hours', '-')} saat")
else:
lines.append(f"- Saat: {draft.get('time_of_day', '-')}")
if schedule_type == "weekly":
days = draft.get("days_of_week", [])
if isinstance(days, list):
lines.append(f"- Gunler: {', '.join(str(item) for item in days)}")
return "\n".join(lines)
def _render_created_message(self, item: AutomationORM) -> str:
next_run = self._format_display_time(item.next_run_at)
return (
f"Otomasyon kaydedildi: #{item.id} {item.name}\n"
f"- Durum: {item.status}\n"
f"- Siradaki calisma: {next_run}"
)
def _create_automation(self, telegram_user_id: int, draft: dict[str, object]) -> AutomationORM:
schedule_type = str(draft["schedule_type"])
item = AutomationORM(
telegram_user_id=telegram_user_id,
name=str(draft["name"]),
prompt=str(draft["prompt"]),
schedule_type=schedule_type,
interval_hours=int(draft["interval_hours"]) if draft.get("interval_hours") is not None else None,
time_of_day=str(draft["time_of_day"]) if draft.get("time_of_day") is not None else None,
days_of_week=json.dumps(draft.get("days_of_week", []), ensure_ascii=False),
status=str(draft.get("status", "active")),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
if item.status == "active":
item.next_run_at = self._compute_next_run(item, from_time=datetime.utcnow())
self.session.add(item)
self.session.flush()
return item
def _compute_next_run(self, item: AutomationORM, from_time: datetime) -> datetime:
if item.schedule_type == "hourly":
interval = max(item.interval_hours or 1, 1)
return from_time + timedelta(hours=interval)
local_now = from_time.replace(tzinfo=UTC).astimezone(LOCAL_TZ)
hour, minute = self._parse_hour_minute(item.time_of_day or "09:00")
if item.schedule_type == "daily":
return self._to_utc_naive(self._next_local_time(local_now, hour, minute))
if item.schedule_type == "weekdays":
candidate = self._next_local_time(local_now, hour, minute)
while candidate.weekday() >= 5:
candidate = candidate + timedelta(days=1)
candidate = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)
return self._to_utc_naive(candidate)
days = self._decode_days(item.days_of_week)
if not days:
days = [0]
candidate = self._next_local_time(local_now, hour, minute)
for _ in range(8):
if candidate.weekday() in days:
return self._to_utc_naive(candidate)
candidate = candidate + timedelta(days=1)
candidate = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)
return self._to_utc_naive(candidate)
def _next_local_time(self, local_now: datetime, hour: int, minute: int) -> datetime:
candidate = local_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if candidate <= local_now:
candidate = candidate + timedelta(days=1)
return candidate
def _parse_hour_minute(self, value: str) -> tuple[int, int]:
hour_text, minute_text = value.split(":", 1)
return int(hour_text), int(minute_text)
def _decode_days(self, value: str) -> list[int]:
try:
payload = json.loads(value)
except json.JSONDecodeError:
return []
result: list[int] = []
if not isinstance(payload, list):
return result
for item in payload:
label = str(item)
if label in WEEKDAY_NAMES:
result.append(WEEKDAY_NAMES.index(label))
return result
def _to_utc_naive(self, local_dt: datetime) -> datetime:
return local_dt.astimezone(UTC).replace(tzinfo=None)
def _format_display_time(self, value: datetime | None) -> str:
if value is None:
return "hesaplanmadi"
return value.replace(tzinfo=UTC).astimezone(LOCAL_TZ).strftime("%Y-%m-%d %H:%M")
def _to_record(self, item: AutomationORM) -> AutomationRecord:
days = []
try:
payload = json.loads(item.days_of_week)
if isinstance(payload, list):
days = [str(day) for day in payload]
except json.JSONDecodeError:
days = []
return AutomationRecord(
id=item.id,
telegram_user_id=item.telegram_user_id,
name=item.name,
prompt=item.prompt,
schedule_type=item.schedule_type, # type: ignore[arg-type]
interval_hours=item.interval_hours,
time_of_day=item.time_of_day,
days_of_week=days,
status=item.status, # type: ignore[arg-type]
last_run_at=item.last_run_at,
next_run_at=item.next_run_at,
last_result=item.last_result,
created_at=item.created_at,
updated_at=item.updated_at,
)
def _get_owned_automation(self, telegram_user_id: int, automation_id: int) -> AutomationORM | None:
item = self.session.get(AutomationORM, automation_id)
if item is None or item.telegram_user_id != telegram_user_id:
return None
return item