Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ include = ["src*"]
# Target Python version
target-version = "py311"

[tool.ruff.lint]
# Rule selections
select = [
"E", # pycodestyle errors
Expand All @@ -63,7 +64,10 @@ select = [
"UP", # pyupgrade
"T20", # flake8-print
]
ignore = []
# Ignored rules:
# E501: Line too long - long lines are JavaScript snippets or AI prompts
# T201: print statements - intentional CLI output
ignore = ["E501", "T201"]

# Exclude directories
exclude = [
Expand Down
4 changes: 3 additions & 1 deletion src/ai/banned_phrases.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,6 @@
" - - ", # double hyphen spaced
)

ALL_BANNED_PHRASES: tuple[str, ...] = AI_CLICHE_PHRASES + BOT_INDICATOR_PHRASES + AI_PUNCTUATION
ALL_BANNED_PHRASES: tuple[str, ...] = (
AI_CLICHE_PHRASES + BOT_INDICATOR_PHRASES + AI_PUNCTUATION
)
10 changes: 5 additions & 5 deletions src/ai/comment_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import logging
import re
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from src.ai.banned_phrases import ALL_BANNED_PHRASES
Expand Down Expand Up @@ -72,27 +72,27 @@


class CommentGenerator:
def __init__(self, client: "GeminiClient", config: "AIConfig") -> None:
def __init__(self, client: GeminiClient, config: AIConfig) -> None:
self._client = client
self._config = config

def generate(self, post: "LinkedInPost") -> GenerateResult:
def generate(self, post: LinkedInPost) -> GenerateResult:
try:
prompt = self._build_prompt(post)
raw_text = self._client.generate(prompt)
validated_text = self._validate(raw_text)
comment = GeneratedComment(
text=validated_text,
post_url=post.post_url,
generated_at=datetime.now(timezone.utc),
generated_at=datetime.now(UTC),
model_used=self._config.model_name,
)
return GenerateResult(comment=comment, error=None)
except Exception as exc:
logger.error("Comment generation failed for %s: %s", post.post_url, exc)
return GenerateResult(comment=None, error=str(exc))

def _build_prompt(self, post: "LinkedInPost") -> str:
def _build_prompt(self, post: LinkedInPost) -> str:
banned_list = "\n".join(f"- {phrase}" for phrase in ALL_BANNED_PHRASES)
author = post.author_name or "the author"

Expand Down
2 changes: 1 addition & 1 deletion src/ai/gemini_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class GeminiClient:
def __init__(self, model_name: str, temperature: float, max_output_tokens: int) -> None:
api_key = os.environ.get("GEMINI_API_KEY", "")
if not api_key:
raise EnvironmentError("GEMINI_API_KEY environment variable is not set.")
raise OSError("GEMINI_API_KEY environment variable is not set.")
self._client = genai.Client(api_key=api_key)
self._model_name = model_name
self._temperature = temperature
Expand Down
21 changes: 10 additions & 11 deletions src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
import argparse
import asyncio
import logging
import shutil
import subprocess
import sys
from pathlib import Path

from src.core import paths

Expand Down Expand Up @@ -107,7 +105,7 @@ async def _run_headless() -> None:
from src.ai.gemini_client import GeminiClient
from src.core.config import load_config
from src.core.orchestrator import Orchestrator
from src.core.rate_limiter import DailyLimitExceededError, RateLimiter
from src.core.rate_limiter import RateLimiter
from src.executor.comment_poster import CommentPoster
from src.executor.human_typer import HumanTyper
from src.scraper.browser_factory import create_persistent_context
Expand Down Expand Up @@ -221,11 +219,12 @@ async def _run_headless() -> None:

def _show_about() -> None:
"""Display the Ubuntu-style 'About Yappy' splash screen."""
import platform

from rich.columns import Columns
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
import platform

console = Console()

Expand All @@ -247,15 +246,15 @@ def _show_about() -> None:

# Project Info
info_text = Text.assemble(
(f"Yappy Assistant\n", f"bold {SAPPHIRE}"),
("Yappy Assistant\n", f"bold {SAPPHIRE}"),
(f"{'─' * 20}\n", f"{SKY}"),
(f"Version: ", f"bold {TEXT}"), (f"0.1.0\n", f"{TEXT}"),
(f"OS: ", f"bold {TEXT}"), (f"{platform.system()} {platform.release()}\n", f"{TEXT}"),
(f"Python: ", f"bold {TEXT}"), (f"{platform.python_version()}\n", f"{TEXT}"),
(f"Author: ", f"bold {TEXT}"), (f"Jien Weng\n", f"{TEXT}"),
(f"GitHub: ", f"bold {TEXT}"), (f"github.com/jienweng/yappy\n", f"{SKY} underline"),
("Version: ", f"bold {TEXT}"), ("0.1.0\n", f"{TEXT}"),
("OS: ", f"bold {TEXT}"), (f"{platform.system()} {platform.release()}\n", f"{TEXT}"),
("Python: ", f"bold {TEXT}"), (f"{platform.python_version()}\n", f"{TEXT}"),
("Author: ", f"bold {TEXT}"), ("Jien Weng\n", f"{TEXT}"),
("GitHub: ", f"bold {TEXT}"), ("github.com/jienweng/yappy\n", f"{SKY} underline"),
(f"{'─' * 20}\n", f"{SKY}"),
(f"Status: ", f"bold {TEXT}"), (f"Open Source (MIT)\n", f"{TEXT}"),
("Status: ", f"bold {TEXT}"), ("Open Source (MIT)\n", f"{TEXT}"),
)

# Layout using Columns
Expand Down
5 changes: 2 additions & 3 deletions src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import yaml
from pydantic import BaseModel, Field, model_validator
from pydantic_settings import BaseSettings

from src.core import paths

Expand Down Expand Up @@ -44,7 +43,7 @@ class LimitsConfig(BaseModel, frozen=True):
auto_like: bool = True # like posts before commenting

@model_validator(mode="after")
def _check_min_max(self) -> "LimitsConfig":
def _check_min_max(self) -> LimitsConfig:
if self.min_delay_seconds >= self.max_delay_seconds:
raise ValueError(
f"min_delay_seconds ({self.min_delay_seconds}) must be less than "
Expand All @@ -67,7 +66,7 @@ class AppConfig(BaseModel, frozen=True):
db_path: str = ""

@model_validator(mode="after")
def _check_targets(self) -> "AppConfig":
def _check_targets(self) -> AppConfig:
if not self.targets:
raise ValueError("At least one target must be configured")
return self
Expand Down
32 changes: 16 additions & 16 deletions src/core/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@
import asyncio
import logging
import random
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from src.core.callbacks import NullCallbacks

if TYPE_CHECKING:
from src.ai.comment_generator import CommentGenerator
from src.core.callbacks import OrchestratorCallbacks
from src.core.config import AppConfig
from src.core.rate_limiter import RateLimiter
from src.scraper.linkedin_scraper import LinkedInScraper
from src.ai.comment_generator import CommentGenerator
from src.executor.comment_poster import CommentPoster
from src.scraper.linkedin_scraper import LinkedInScraper
from src.storage.activity_log import ActivityLog

logger = logging.getLogger(__name__)
Expand All @@ -35,13 +35,13 @@ class PipelineResult:
class Orchestrator:
def __init__(
self,
config: "AppConfig",
rate_limiter: "RateLimiter",
scraper: "LinkedInScraper",
comment_generator: "CommentGenerator",
comment_poster: "CommentPoster",
activity_log: "ActivityLog",
callbacks: "OrchestratorCallbacks | None" = None,
config: AppConfig,
rate_limiter: RateLimiter,
scraper: LinkedInScraper,
comment_generator: CommentGenerator,
comment_poster: CommentPoster,
activity_log: ActivityLog,
callbacks: OrchestratorCallbacks | None = None,
) -> None:
self._config = config
self._rate_limiter = rate_limiter
Expand All @@ -50,12 +50,12 @@ def __init__(
self._poster = comment_poster
self._log = activity_log
self._callbacks: OrchestratorCallbacks = callbacks or NullCallbacks()

# Wire up callbacks to scraper
self._scraper._callbacks = self._callbacks

async def run(self) -> PipelineResult:
started_at = datetime.now(timezone.utc)
started_at = datetime.now(UTC)
posts_scraped = 0
comments_attempted = 0
comments_succeeded = 0
Expand Down Expand Up @@ -125,7 +125,7 @@ async def run(self) -> PipelineResult:
errors.append(str(exc))
return PipelineResult(
started_at=started_at,
finished_at=datetime.now(timezone.utc),
finished_at=datetime.now(UTC),
posts_scraped=posts_scraped,
comments_attempted=comments_attempted,
comments_succeeded=comments_succeeded,
Expand Down Expand Up @@ -181,7 +181,7 @@ async def run(self) -> PipelineResult:
post, final_comment,
auto_like=self._config.limits.auto_like,
)

# Record like if successful
if post_result.liked:
self._log.record_activity(
Expand Down Expand Up @@ -238,7 +238,7 @@ async def run(self) -> PipelineResult:

return PipelineResult(
started_at=started_at,
finished_at=datetime.now(timezone.utc),
finished_at=datetime.now(UTC),
posts_scraped=posts_scraped,
comments_attempted=comments_attempted,
comments_succeeded=comments_succeeded,
Expand Down
2 changes: 1 addition & 1 deletion src/core/rate_limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class RateLimitStatus:
limit_reached: bool

@classmethod
def from_counts(cls, comments_today: int, daily_limit: int) -> "RateLimitStatus":
def from_counts(cls, comments_today: int, daily_limit: int) -> RateLimitStatus:
remaining = max(0, daily_limit - comments_today)
return cls(
comments_today=comments_today,
Expand Down
11 changes: 5 additions & 6 deletions src/core/scheduler.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Module for OS-native scheduling of Yappy tasks."""
from __future__ import annotations

import os
import platform
import subprocess
import sys
Expand All @@ -11,10 +10,10 @@
def register_daily_run(time_str: str) -> str:
"""
Schedule a daily run of Yappy at the specified time.

Args:
time_str: Time in HH:MM format.

Returns:
Success message or error.
"""
Expand Down Expand Up @@ -72,7 +71,7 @@ def _schedule_mac(time_str: str) -> str:
# Load the agent
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
result = subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True)

if result.returncode == 0:
return f"Successfully scheduled daily run at {time_str} (macOS LaunchAgent)"
else:
Expand All @@ -88,7 +87,7 @@ def _schedule_linux(time_str: str) -> str:

executable = sys.argv[0]
cron_cmd = f"{minute} {hour} * * * {executable} --no-tui >> ~/.config/yappy/scheduler.log 2>&1"

try:
# Get current crontab
current_cron = subprocess.run(["crontab", "-l"], capture_output=True, text=True).stdout
Expand All @@ -99,7 +98,7 @@ def _schedule_linux(time_str: str) -> str:
new_cron = "\n".join(lines) + "\n"
else:
new_cron = current_cron + f"\n# yappy daily run\n{cron_cmd}\n"

subprocess.run(["crontab", "-"], input=new_cron, text=True, check=True)
return f"Successfully scheduled daily run at {time_str} (Linux crontab)"
except Exception as e:
Expand Down
11 changes: 6 additions & 5 deletions src/core/updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import logging

import httpx
from packaging.version import parse as parse_version

Expand All @@ -12,10 +13,10 @@
async def check_for_updates(current_version: str) -> str | None:
"""
Check if a newer version of Yappy is available on PyPI.

Args:
current_version: The current version of the application.

Returns:
The latest version string if a newer version exists, else None.
"""
Expand All @@ -25,16 +26,16 @@ async def check_for_updates(current_version: str) -> str | None:
response = await client.get(url)
if response.status_code != 200:
return None

data = response.json()
latest_version = data.get("info", {}).get("version")
if not latest_version:
return None

if parse_version(latest_version) > parse_version(current_version):
return latest_version
except Exception as exc:
logger.debug("Update check failed: %s", exc)
return None

return None
Loading
Loading