From c7c57fe4e523d38e0b9a703e6184f579f824dcf9 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 27 Apr 2026 07:49:34 -0700 Subject: [PATCH] feat: opportunities UI improvements, MCP tools, session refresh, migrations 013-014 --- app/api/endpoints/opportunities.py | 27 ++++- app/api/endpoints/reddit.py | 93 +++++++++++++++ app/api/endpoints/subs.py | 1 + app/api/routes.py | 3 +- app/db/migrations/013_sub_rules_post_url.sql | 4 + .../migrations/014_posts_opportunity_link.sql | 4 + app/db/store.py | 15 ++- app/services/reddit/post.py | 55 ++++++--- frontend/src/components/CampaignDetail.vue | 103 ++++++++++++++++- frontend/src/components/CampaignList.vue | 5 +- frontend/src/components/OpportunitiesView.vue | 51 ++++++++- frontend/src/components/PostsView.vue | 3 +- frontend/src/components/SignalsView.vue | 8 +- frontend/src/components/SubRulesView.vue | 11 +- frontend/src/services/api.ts | 8 +- frontend/src/utils/cron.ts | 107 ++++++++++++++++++ mcp/server.js | 29 +++++ 17 files changed, 486 insertions(+), 41 deletions(-) create mode 100644 app/api/endpoints/reddit.py create mode 100644 app/db/migrations/013_sub_rules_post_url.sql create mode 100644 app/db/migrations/014_posts_opportunity_link.sql create mode 100644 frontend/src/utils/cron.ts diff --git a/app/api/endpoints/opportunities.py b/app/api/endpoints/opportunities.py index 2bb0f35..7ed055b 100644 --- a/app/api/endpoints/opportunities.py +++ b/app/api/endpoints/opportunities.py @@ -57,6 +57,10 @@ class DismissBody(BaseModel): note: str | None = None +class MarkPostedBody(BaseModel): + url: str | None = None + + # ------------------------------------------------------------------ # # Routes # ------------------------------------------------------------------ # @@ -129,14 +133,29 @@ async def approve_opportunity(opportunity_id: int): @router.post("/{opportunity_id}/mark-posted") -async def mark_posted(opportunity_id: int, manual: bool = False): - """Record that a post was successfully made (auto or manual).""" +async def mark_posted(opportunity_id: int, body: MarkPostedBody = MarkPostedBody(), manual: bool = False): + """Record that a post was successfully made (auto or manual). + When manual=True, also writes a row to the posts table for history tracking.""" + opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id)) + if opp is None: + raise HTTPException(404, "Opportunity not found") + status = "manual_posted" if manual else "posted" result = await asyncio.to_thread( _in_thread, lambda s: s.update_opportunity(opportunity_id, status=status) ) - if result is None: - raise HTTPException(404, "Opportunity not found") + + if manual: + await asyncio.to_thread( + _in_thread, + lambda s: s.log_manual_post( + opportunity_id=opportunity_id, + platform=opp["platform"], + target=opp["community"], + url=body.url, + ), + ) + return result diff --git a/app/api/endpoints/reddit.py b/app/api/endpoints/reddit.py new file mode 100644 index 0000000..48bdfdf --- /dev/null +++ b/app/api/endpoints/reddit.py @@ -0,0 +1,93 @@ +"""Reddit session management endpoints.""" +from __future__ import annotations + +import subprocess +import sys +import time +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.services.reddit.session import ensure_valid_session, refresh_session, session_is_valid + +router = APIRouter(tags=["reddit"]) + +BRIDGE_SESSION = Path("/Library/Development/CircuitForge/claude-bridge/reddit-poster/session.json") +BRIDGE_POST_SCRIPT = Path("/Library/Development/CircuitForge/claude-bridge/reddit-poster/post.py") + + +class SessionStatusResponse(BaseModel): + target: str + valid: bool + age_hours: float | None + session_file: str + + +class RefreshResponse(BaseModel): + target: str + ok: bool + message: str + + +def _session_age_hours(path: Path) -> float | None: + if not path.exists(): + return None + return round((time.time() - path.stat().st_mtime) / 3600, 1) + + +@router.get("/reddit/session-status") +def session_status(target: str = "magpie") -> SessionStatusResponse: + """Return session validity and age for magpie or bridge session.""" + if target == "bridge": + valid = BRIDGE_SESSION.exists() and _session_age_hours(BRIDGE_SESSION) < 12 + return SessionStatusResponse( + target="bridge", + valid=valid, + age_hours=_session_age_hours(BRIDGE_SESSION), + session_file=str(BRIDGE_SESSION), + ) + # magpie session + from app.core.config import get_settings + session_file = Path(get_settings().reddit_session_file) + return SessionStatusResponse( + target="magpie", + valid=session_is_valid(session_file), + age_hours=_session_age_hours(session_file), + session_file=str(session_file), + ) + + +@router.post("/reddit/refresh-session") +def refresh_reddit_session(target: str = "magpie") -> RefreshResponse: + """ + Re-establish the Playwright Reddit session. + + target: "magpie" (default) refreshes Magpie's session. + "bridge" refreshes claude-bridge/reddit-poster/session.json. + """ + if target == "bridge": + if not BRIDGE_POST_SCRIPT.exists(): + raise HTTPException(status_code=404, detail="claude-bridge post.py not found") + result = subprocess.run( + ["xvfb-run", "--auto-servernum", sys.executable, str(BRIDGE_POST_SCRIPT), "--login"], + cwd=str(BRIDGE_POST_SCRIPT.parent), + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + raise HTTPException( + status_code=500, + detail=f"Bridge session refresh failed: {result.stderr.strip()}", + ) + return RefreshResponse(target="bridge", ok=True, message="Bridge session refreshed.") + + # magpie session + try: + from app.core.config import get_settings + session_file = Path(get_settings().reddit_session_file) + refresh_session(session_file) + return RefreshResponse(target="magpie", ok=True, message="Magpie session refreshed.") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/app/api/endpoints/subs.py b/app/api/endpoints/subs.py index f4e2cff..d1dbbfb 100644 --- a/app/api/endpoints/subs.py +++ b/app/api/endpoints/subs.py @@ -25,6 +25,7 @@ class SubRulesUpsert(BaseModel): promo_allowed: bool | None = None # None = unknown rule_warning: bool = False notes: str | None = None + post_url: str | None = None # override link for Copy & Post (e.g. megathread) @router.get("") diff --git a/app/api/routes.py b/app/api/routes.py index 8c08b54..ed1ec4d 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,6 +1,6 @@ from fastapi import FastAPI -from app.api.endpoints import blog, campaigns, opportunities, posts, scheduler, signals, subs +from app.api.endpoints import blog, campaigns, opportunities, posts, reddit, scheduler, signals, subs def register_routes(app: FastAPI) -> None: @@ -11,3 +11,4 @@ def register_routes(app: FastAPI) -> None: app.include_router(opportunities.router, prefix="/api/v1") app.include_router(signals.router, prefix="/api/v1") app.include_router(blog.router, prefix="/api/v1") + app.include_router(reddit.router, prefix="/api/v1") diff --git a/app/db/migrations/013_sub_rules_post_url.sql b/app/db/migrations/013_sub_rules_post_url.sql new file mode 100644 index 0000000..00cc261 --- /dev/null +++ b/app/db/migrations/013_sub_rules_post_url.sql @@ -0,0 +1,4 @@ +-- Add optional post_url to sub_rules. +-- When set, the Copy & Post modal links here instead of /r/{sub}/submit. +-- Use for megathreads, weekly threads, or any pinned destination. +ALTER TABLE sub_rules ADD COLUMN post_url TEXT; diff --git a/app/db/migrations/014_posts_opportunity_link.sql b/app/db/migrations/014_posts_opportunity_link.sql new file mode 100644 index 0000000..d7f3322 --- /dev/null +++ b/app/db/migrations/014_posts_opportunity_link.sql @@ -0,0 +1,4 @@ +-- Link posts to opportunities for manual/signal-driven posts. +-- NULL = campaign-scheduled post (existing behaviour). +ALTER TABLE posts ADD COLUMN opportunity_id INTEGER REFERENCES opportunities(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_posts_opportunity ON posts(opportunity_id); diff --git a/app/db/store.py b/app/db/store.py index f5c0b1a..f7da52d 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -213,10 +213,19 @@ class Store: ) def create_post(self, campaign_id: int, target: str, variant_id: int | None = None, - platform: str = "reddit", triggered_by: str = "scheduler") -> dict: + platform: str = "reddit", triggered_by: str = "scheduler", + opportunity_id: int | None = None) -> dict: return self._insert_returning( - "INSERT INTO posts (campaign_id, variant_id, platform, target, status, triggered_by) VALUES (?,?,?,?,'pending',?) RETURNING *", - (campaign_id, variant_id, platform, target, triggered_by), + "INSERT INTO posts (campaign_id, variant_id, platform, target, status, triggered_by, opportunity_id) VALUES (?,?,?,?,'pending',?,?) RETURNING *", + (campaign_id, variant_id, platform, target, triggered_by, opportunity_id), + ) + + def log_manual_post(self, opportunity_id: int, platform: str, target: str, + url: str | None = None) -> dict: + """Create a success post record for a manually executed opportunity post.""" + return self._insert_returning( + "INSERT INTO posts (campaign_id, opportunity_id, platform, target, status, triggered_by, url) VALUES (NULL,?,?,?,'success','manual',?) RETURNING *", + (opportunity_id, platform, target, url), ) def update_post_status(self, post_id: int, status: str, url: str | None = None, diff --git a/app/services/reddit/post.py b/app/services/reddit/post.py index 72361bd..6c07c1b 100644 --- a/app/services/reddit/post.py +++ b/app/services/reddit/post.py @@ -24,9 +24,9 @@ from dotenv import load_dotenv from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout from playwright_stealth import Stealth -# Load .env from project root (two levels up from this file) +# Load .env from project root (magpie repo root, 3 levels up from this file) _HERE = Path(__file__).parent -_PROJECT_ROOT = _HERE.parents[3] +_PROJECT_ROOT = _HERE.parents[2] # reddit/ → services/ → app/ → magpie/ load_dotenv(_PROJECT_ROOT / ".env") REDDIT_USERNAME = os.getenv("REDDIT_USERNAME", "") @@ -34,7 +34,10 @@ REDDIT_PASSWORD = os.getenv("REDDIT_PASSWORD", "") CHROME_BIN = os.getenv("CHROME_BIN", "/usr/bin/google-chrome") # Session file path from env (so the service layer can pass it via env var) -SESSION_FILE = Path(os.getenv("REDDIT_SESSION_FILE", str(_HERE / "session.json"))) +SESSION_FILE = Path(os.getenv( + "REDDIT_SESSION_FILE", + str(Path.home() / ".local" / "share" / "magpie" / "session.json"), +)) LOGIN_URL = "https://www.reddit.com/login" SUBMIT_URL = "https://www.reddit.com/r/{sub}/submit?type=text" @@ -187,13 +190,24 @@ def post(sub: str, title: str, body: str, flair: str | None = None, yes: bool = time.sleep(0.5) - # Fill body (Lexical editor — click to focus, then type) + # Fill body — Reddit shows either a markdown textarea or a Lexical + # rich-text contenteditable depending on user/sub settings. + # Try markdown textarea first (visible in screenshot), fall back to Lexical. try: - body_el = page.locator('div[contenteditable="true"]').first - body_el.wait_for(state="visible", timeout=10_000) - body_el.click() - time.sleep(0.3) - page.keyboard.type(body, delay=2) + md_textarea = page.locator('textarea[placeholder="Body text*"]') + if md_textarea.count() > 0: + md_textarea.first.wait_for(state="visible", timeout=5_000) + md_textarea.first.click() + time.sleep(0.3) + md_textarea.first.fill(body) + print(" Body filled via markdown textarea") + else: + body_el = page.locator('div[contenteditable="true"]').first + body_el.wait_for(state="visible", timeout=10_000) + body_el.click() + time.sleep(0.3) + page.keyboard.type(body, delay=2) + print(" Body filled via rich text editor") except Exception as exc: print(f" Warning: body fill failed ({exc})") @@ -210,11 +224,26 @@ def post(sub: str, title: str, body: str, flair: str | None = None, yes: bool = except Exception as exc: print(f" Warning: flair selection failed ({exc})") - # Submit + # Submit — try multiple selector strategies; Reddit's form markup varies try: - submit_btn = page.locator('button[type="submit"]').filter(has_text="Post") - submit_btn.wait_for(state="visible", timeout=10_000) - submit_btn.click() + # Scroll to bottom so button is in viewport + page.keyboard.press("End") + time.sleep(0.3) + for selector in [ + 'button[type="submit"]', + 'button:has-text("Post")', + '[slot="submit-button"] button', + 'button.bg-interactive-onbackground', + ]: + btn = page.locator(selector).last + if btn.count() > 0: + btn.scroll_into_view_if_needed() + btn.wait_for(state="visible", timeout=5_000) + btn.click() + print(f" Clicked submit via {selector!r}") + break + else: + print(" Warning: no submit button found with any selector") except Exception as exc: print(f" Warning: submit button click failed ({exc})") diff --git a/frontend/src/components/CampaignDetail.vue b/frontend/src/components/CampaignDetail.vue index f6d4a88..5b985db 100644 --- a/frontend/src/components/CampaignDetail.vue +++ b/frontend/src/components/CampaignDetail.vue @@ -43,7 +43,13 @@
r/{{ s.sub }} - +
+ + + +
No subs configured.
@@ -109,6 +115,55 @@ + +