456 lines
19 KiB
Python
456 lines
19 KiB
Python
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
|