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