feat: scaffold Magpie — campaign scheduler + social posting platform
FastAPI backend (SQLite + APScheduler), Vue 3 frontend, MCP server for Claude integration, and Docker Compose stack. Includes campaign data model (campaigns → variants → subs), post history, sub rules, and Playwright-based Reddit posting layer migrated from claude-bridge/reddit-poster. Also seeds legacy campaigns (6) and sub rules (14) from reddit-poster history. Closes #1 (scaffold), resolves migration from claude-bridge.
This commit is contained in:
parent
8777eff5a4
commit
2cc85d8fc5
49 changed files with 5737 additions and 0 deletions
23
.env.example
Normal file
23
.env.example
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Magpie — environment config
|
||||
# Copy to .env and fill in values.
|
||||
|
||||
# Reddit credentials (for Playwright-based posting)
|
||||
REDDIT_USERNAME=
|
||||
REDDIT_PASSWORD=
|
||||
|
||||
# Path to system Chrome binary (xvfb posting)
|
||||
CHROME_BIN=/usr/bin/google-chrome
|
||||
|
||||
# API server
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8532
|
||||
DEBUG=false
|
||||
|
||||
# Database location (default: ~/.local/share/magpie/magpie.db)
|
||||
# DB_PATH=/path/to/magpie.db
|
||||
|
||||
# Session file location (default: ~/.local/share/magpie/session.json)
|
||||
# REDDIT_SESSION_FILE=/path/to/session.json
|
||||
|
||||
# APScheduler
|
||||
SCHEDULER_ENABLED=true
|
||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Secrets / config
|
||||
.env
|
||||
session.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
.venv/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Debug screenshots
|
||||
debug_*.png
|
||||
|
||||
# Data
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# CLAUDE.md — gitignored per BSL 1.1 + docs-location policy
|
||||
CLAUDE.md
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
# System deps for Playwright + Chrome + Xvfb
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
xvfb google-chrome-stable wget gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python deps
|
||||
COPY pyproject.toml .
|
||||
COPY ../circuitforge-core /cf-core
|
||||
RUN pip install --no-cache-dir -e /cf-core && \
|
||||
pip install --no-cache-dir -e .
|
||||
|
||||
# Install Playwright browsers
|
||||
RUN playwright install chromium
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8532
|
||||
|
||||
CMD ["python", "-m", "app.main"]
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/endpoints/__init__.py
Normal file
0
app/api/endpoints/__init__.py
Normal file
184
app/api/endpoints/campaigns.py
Normal file
184
app/api/endpoints/campaigns.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.store import Store
|
||||
from app.services.poster import run_campaign
|
||||
from app.services.scheduler import remove_campaign as scheduler_remove, sync_campaign
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
def _get_store() -> Store:
|
||||
return Store(get_settings().db_path)
|
||||
|
||||
|
||||
def _in_thread(fn):
|
||||
store = _get_store()
|
||||
try:
|
||||
return fn(store)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Schemas
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class CampaignCreate(BaseModel):
|
||||
name: str
|
||||
product: str
|
||||
platform: str = "reddit"
|
||||
cron_schedule: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class CampaignUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
product: str | None = None
|
||||
cron_schedule: str | None = None
|
||||
active: bool | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class VariantCreate(BaseModel):
|
||||
sub_pattern: str = "*"
|
||||
title: str
|
||||
body: str
|
||||
flair: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class VariantUpdate(BaseModel):
|
||||
sub_pattern: str | None = None
|
||||
title: str | None = None
|
||||
body: str | None = None
|
||||
flair: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class SubEntry(BaseModel):
|
||||
sub: str
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Campaign CRUD
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@router.get("")
|
||||
async def list_campaigns(active_only: bool = False):
|
||||
return await asyncio.to_thread(_in_thread, lambda s: s.list_campaigns(active_only))
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_campaign(body: CampaignCreate):
|
||||
campaign = await asyncio.to_thread(
|
||||
_in_thread,
|
||||
lambda s: s.create_campaign(**body.model_dump()),
|
||||
)
|
||||
sync_campaign(campaign)
|
||||
return campaign
|
||||
|
||||
|
||||
@router.get("/{campaign_id}")
|
||||
async def get_campaign(campaign_id: int):
|
||||
result = await asyncio.to_thread(_in_thread, lambda s: s.get_campaign(campaign_id))
|
||||
if result is None:
|
||||
raise HTTPException(404, "Campaign not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/{campaign_id}")
|
||||
async def update_campaign(campaign_id: int, body: CampaignUpdate):
|
||||
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
if "active" in updates:
|
||||
updates["active"] = 1 if updates["active"] else 0
|
||||
result = await asyncio.to_thread(_in_thread, lambda s: s.update_campaign(campaign_id, **updates))
|
||||
if result is None:
|
||||
raise HTTPException(404, "Campaign not found")
|
||||
sync_campaign(result)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{campaign_id}", status_code=204)
|
||||
async def delete_campaign(campaign_id: int):
|
||||
ok = await asyncio.to_thread(_in_thread, lambda s: s.delete_campaign(campaign_id))
|
||||
if not ok:
|
||||
raise HTTPException(404, "Campaign not found")
|
||||
scheduler_remove(campaign_id)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Manual trigger
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@router.post("/{campaign_id}/trigger")
|
||||
async def trigger_campaign(campaign_id: int):
|
||||
"""Manually fire a campaign to all its configured subs."""
|
||||
campaign = await asyncio.to_thread(_in_thread, lambda s: s.get_campaign(campaign_id))
|
||||
if campaign is None:
|
||||
raise HTTPException(404, "Campaign not found")
|
||||
results = await run_campaign(campaign_id, triggered_by="manual")
|
||||
return {"campaign_id": campaign_id, "results": results}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Variants
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@router.get("/{campaign_id}/variants")
|
||||
async def list_variants(campaign_id: int):
|
||||
return await asyncio.to_thread(_in_thread, lambda s: s.list_variants(campaign_id))
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/variants", status_code=201)
|
||||
async def create_variant(campaign_id: int, body: VariantCreate):
|
||||
return await asyncio.to_thread(
|
||||
_in_thread,
|
||||
lambda s: s.create_variant(campaign_id=campaign_id, **body.model_dump()),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{campaign_id}/variants/{variant_id}")
|
||||
async def update_variant(campaign_id: int, variant_id: int, body: VariantUpdate):
|
||||
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
result = await asyncio.to_thread(_in_thread, lambda s: s.update_variant(variant_id, **updates))
|
||||
if result is None:
|
||||
raise HTTPException(404, "Variant not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{campaign_id}/variants/{variant_id}", status_code=204)
|
||||
async def delete_variant(campaign_id: int, variant_id: int):
|
||||
ok = await asyncio.to_thread(_in_thread, lambda s: s.delete_variant(variant_id))
|
||||
if not ok:
|
||||
raise HTTPException(404, "Variant not found")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Subs
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@router.get("/{campaign_id}/subs")
|
||||
async def list_campaign_subs(campaign_id: int):
|
||||
return await asyncio.to_thread(_in_thread, lambda s: s.list_campaign_subs(campaign_id))
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/subs", status_code=201)
|
||||
async def add_campaign_sub(campaign_id: int, body: SubEntry):
|
||||
return await asyncio.to_thread(
|
||||
_in_thread,
|
||||
lambda s: s.add_campaign_sub(campaign_id, body.sub, body.sort_order),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{campaign_id}/subs/{sub}", status_code=204)
|
||||
async def remove_campaign_sub(campaign_id: int, sub: str):
|
||||
ok = await asyncio.to_thread(_in_thread, lambda s: s.remove_campaign_sub(campaign_id, sub))
|
||||
if not ok:
|
||||
raise HTTPException(404, "Sub not found in campaign")
|
||||
49
app/api/endpoints/posts.py
Normal file
49
app/api/endpoints/posts.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.store import Store
|
||||
from app.services.poster import post_campaign_to_sub
|
||||
|
||||
router = APIRouter(prefix="/posts", tags=["posts"])
|
||||
|
||||
|
||||
def _in_thread(fn):
|
||||
store = Store(get_settings().db_path)
|
||||
try:
|
||||
return fn(store)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class PostToSub(BaseModel):
|
||||
campaign_id: int
|
||||
sub: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_posts(campaign_id: int | None = None, target: str | None = None, limit: int = 50):
|
||||
return await asyncio.to_thread(
|
||||
_in_thread, lambda s: s.list_posts(campaign_id, target, limit)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/trigger")
|
||||
async def trigger_single_post(body: PostToSub):
|
||||
"""Manually trigger a single post to one sub."""
|
||||
campaign = await asyncio.to_thread(_in_thread, lambda s: s.get_campaign(body.campaign_id))
|
||||
if campaign is None:
|
||||
raise HTTPException(404, "Campaign not found")
|
||||
return await post_campaign_to_sub(body.campaign_id, body.sub, triggered_by="manual")
|
||||
|
||||
|
||||
@router.get("/{post_id}/engagement")
|
||||
async def get_engagement(post_id: int):
|
||||
result = await asyncio.to_thread(_in_thread, lambda s: s.get_latest_engagement(post_id))
|
||||
if result is None:
|
||||
raise HTTPException(404, "No engagement data for this post")
|
||||
return result
|
||||
22
app/api/endpoints/scheduler.py
Normal file
22
app/api/endpoints/scheduler.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Scheduler status endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.services.scheduler import get_scheduler
|
||||
|
||||
router = APIRouter(prefix="/scheduler", tags=["scheduler"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def scheduler_status():
|
||||
"""Return running state and next-fire-time for all scheduled jobs."""
|
||||
sched = get_scheduler()
|
||||
jobs = []
|
||||
for job in sched.get_jobs():
|
||||
jobs.append({
|
||||
"job_id": job.id,
|
||||
"name": job.name,
|
||||
"next_run": job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
})
|
||||
return {"running": sched.running, "jobs": jobs}
|
||||
53
app/api/endpoints/subs.py
Normal file
53
app/api/endpoints/subs.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.store import Store
|
||||
|
||||
router = APIRouter(prefix="/subs", tags=["subs"])
|
||||
|
||||
|
||||
def _in_thread(fn):
|
||||
store = Store(get_settings().db_path)
|
||||
try:
|
||||
return fn(store)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class SubRulesUpsert(BaseModel):
|
||||
flair_required: bool = False
|
||||
flair_to_use: str | None = None
|
||||
promo_allowed: bool | None = None # None = unknown
|
||||
rule_warning: bool = False
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_sub_rules(platform: str = "reddit"):
|
||||
return await asyncio.to_thread(_in_thread, lambda s: s.list_sub_rules(platform))
|
||||
|
||||
|
||||
@router.get("/{sub}")
|
||||
async def get_sub_rules(sub: str, platform: str = "reddit"):
|
||||
result = await asyncio.to_thread(_in_thread, lambda s: s.get_sub_rules(sub, platform))
|
||||
if result is None:
|
||||
raise HTTPException(404, f"No rules on record for r/{sub}")
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/{sub}")
|
||||
async def upsert_sub_rules(sub: str, body: SubRulesUpsert, platform: str = "reddit"):
|
||||
fields = body.model_dump()
|
||||
# Convert bool | None to int | None for SQLite
|
||||
if fields.get("promo_allowed") is not None:
|
||||
fields["promo_allowed"] = 1 if fields["promo_allowed"] else 0
|
||||
fields["flair_required"] = 1 if fields["flair_required"] else 0
|
||||
fields["rule_warning"] = 1 if fields["rule_warning"] else 0
|
||||
return await asyncio.to_thread(
|
||||
_in_thread, lambda s: s.upsert_sub_rules(sub, platform, **fields)
|
||||
)
|
||||
11
app/api/routes.py
Normal file
11
app/api/routes.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from fastapi import FastAPI
|
||||
|
||||
from app.api.endpoints import campaigns, opportunities, posts, scheduler, subs
|
||||
|
||||
|
||||
def register_routes(app: FastAPI) -> None:
|
||||
app.include_router(campaigns.router, prefix="/api/v1")
|
||||
app.include_router(posts.router, prefix="/api/v1")
|
||||
app.include_router(subs.router, prefix="/api/v1")
|
||||
app.include_router(scheduler.router, prefix="/api/v1")
|
||||
app.include_router(opportunities.router, prefix="/api/v1")
|
||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
26
app/core/config.py
Normal file
26
app/core/config.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# Server
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8532
|
||||
debug: bool = False
|
||||
|
||||
# Database
|
||||
db_path: str = str(Path.home() / ".local" / "share" / "magpie" / "magpie.db")
|
||||
|
||||
# Reddit session
|
||||
reddit_session_file: str = str(Path.home() / ".local" / "share" / "magpie" / "session.json")
|
||||
|
||||
# Scheduler
|
||||
scheduler_enabled: bool = True
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
0
app/db/__init__.py
Normal file
0
app/db/__init__.py
Normal file
16
app/db/migrations/001_campaigns.sql
Normal file
16
app/db/migrations/001_campaigns.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- Campaigns: a named posting campaign for a product on a platform.
|
||||
-- cron_schedule uses standard cron syntax (e.g. "0 9 * * 2" = Tuesday 9 AM)
|
||||
CREATE TABLE IF NOT EXISTS campaigns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
product TEXT NOT NULL, -- peregrine, kiwi, snipe, circuitforge
|
||||
platform TEXT NOT NULL DEFAULT 'reddit',
|
||||
cron_schedule TEXT, -- NULL = manual trigger only
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_campaigns_product ON campaigns(product);
|
||||
CREATE INDEX IF NOT EXISTS idx_campaigns_active ON campaigns(active);
|
||||
16
app/db/migrations/002_campaign_variants.sql
Normal file
16
app/db/migrations/002_campaign_variants.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- Campaign variants: per-sub (or wildcard) content framing.
|
||||
-- sub_pattern: exact sub name ("selfhosted"), prefix wildcard ("nd_*"), or "*" for default.
|
||||
-- Priority: exact match > prefix > "*" default.
|
||||
CREATE TABLE IF NOT EXISTS campaign_variants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
|
||||
sub_pattern TEXT NOT NULL DEFAULT '*', -- exact sub, glob prefix, or "*" default
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
flair TEXT, -- override flair for this variant
|
||||
notes TEXT, -- internal framing notes
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_variants_campaign ON campaign_variants(campaign_id);
|
||||
12
app/db/migrations/003_campaign_subs.sql
Normal file
12
app/db/migrations/003_campaign_subs.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- Campaign subs: which subreddits a campaign posts to, and in what order.
|
||||
CREATE TABLE IF NOT EXISTS campaign_subs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
|
||||
sub TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
UNIQUE(campaign_id, sub)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_campaign_subs_campaign ON campaign_subs(campaign_id);
|
||||
20
app/db/migrations/004_posts.sql
Normal file
20
app/db/migrations/004_posts.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- Posts: individual post attempts, one row per sub per campaign run.
|
||||
-- status: pending | running | success | failed | skipped (dupe guard)
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
|
||||
variant_id INTEGER REFERENCES campaign_variants(id),
|
||||
platform TEXT NOT NULL DEFAULT 'reddit',
|
||||
target TEXT NOT NULL, -- subreddit name
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
url TEXT, -- permalink if succeeded
|
||||
error_msg TEXT,
|
||||
screenshot_path TEXT,
|
||||
triggered_by TEXT DEFAULT 'scheduler', -- scheduler | manual
|
||||
posted_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_campaign ON posts(campaign_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_target ON posts(target);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_posted_at ON posts(posted_at);
|
||||
16
app/db/migrations/005_sub_rules.sql
Normal file
16
app/db/migrations/005_sub_rules.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- Sub rules: per-subreddit posting rules and capabilities.
|
||||
-- promo_allowed: NULL = unknown, 1 = allowed, 0 = banned
|
||||
CREATE TABLE IF NOT EXISTS sub_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL DEFAULT 'reddit',
|
||||
sub TEXT NOT NULL,
|
||||
flair_required INTEGER NOT NULL DEFAULT 0,
|
||||
flair_to_use TEXT,
|
||||
promo_allowed INTEGER, -- NULL=unknown, 1=yes, 0=hard-banned
|
||||
rule_warning INTEGER NOT NULL DEFAULT 0, -- shows "Your post may break rules" modal
|
||||
notes TEXT,
|
||||
last_checked TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
UNIQUE(platform, sub)
|
||||
);
|
||||
13
app/db/migrations/006_engagement.sql
Normal file
13
app/db/migrations/006_engagement.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- Engagement snapshots: periodic metric pulls after posting.
|
||||
CREATE TABLE IF NOT EXISTS engagement (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
score INTEGER,
|
||||
upvotes INTEGER,
|
||||
comments INTEGER,
|
||||
awards INTEGER DEFAULT 0,
|
||||
checked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_engagement_post ON engagement(post_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_engagement_checked_at ON engagement(checked_at);
|
||||
355
app/db/store.py
Normal file
355
app/db/store.py
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||
|
||||
# Columns whose values are stored as JSON and should be auto-decoded on read.
|
||||
_JSON_COLUMNS = frozenset()
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
for col in _JSON_COLUMNS:
|
||||
if col in d and isinstance(d[col], str):
|
||||
try:
|
||||
d[col] = json.loads(d[col])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
path = Path(db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.conn = sqlite3.connect(str(path), check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||
self.conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
def close(self) -> None:
|
||||
self.conn.close()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Migrations
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def run_migrations(self) -> None:
|
||||
self.conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
||||
)
|
||||
self.conn.commit()
|
||||
applied = {
|
||||
row[0]
|
||||
for row in self.conn.execute("SELECT name FROM _migrations").fetchall()
|
||||
}
|
||||
for sql_file in sorted(MIGRATIONS_DIR.glob("*.sql")):
|
||||
if sql_file.name not in applied:
|
||||
self.conn.executescript(sql_file.read_text())
|
||||
self.conn.execute(
|
||||
"INSERT INTO _migrations (name) VALUES (?)", (sql_file.name,)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _insert_returning(self, sql: str, params: tuple = ()) -> dict[str, Any]:
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
cur = self.conn.execute(sql, params)
|
||||
row = _row_to_dict(cur.fetchone())
|
||||
self.conn.commit()
|
||||
return row
|
||||
|
||||
def _fetchall(self, sql: str, params: tuple = ()) -> list[dict[str, Any]]:
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
cur = self.conn.execute(sql, params)
|
||||
return [_row_to_dict(r) for r in cur.fetchall()]
|
||||
|
||||
def _fetchone(self, sql: str, params: tuple = ()) -> dict[str, Any] | None:
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
cur = self.conn.execute(sql, params)
|
||||
return _row_to_dict(cur.fetchone())
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Campaigns
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def list_campaigns(self, active_only: bool = False) -> list[dict]:
|
||||
where = "WHERE active = 1" if active_only else ""
|
||||
return self._fetchall(f"SELECT * FROM campaigns {where} ORDER BY product, name")
|
||||
|
||||
def get_campaign(self, campaign_id: int) -> dict | None:
|
||||
return self._fetchone("SELECT * FROM campaigns WHERE id = ?", (campaign_id,))
|
||||
|
||||
def create_campaign(self, name: str, product: str, platform: str = "reddit",
|
||||
cron_schedule: str | None = None, notes: str | None = None) -> dict:
|
||||
return self._insert_returning(
|
||||
"INSERT INTO campaigns (name, product, platform, cron_schedule, notes) VALUES (?,?,?,?,?) RETURNING *",
|
||||
(name, product, platform, cron_schedule, notes),
|
||||
)
|
||||
|
||||
def update_campaign(self, campaign_id: int, **fields) -> dict | None:
|
||||
allowed = {"name", "product", "cron_schedule", "active", "notes"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return self.get_campaign(campaign_id)
|
||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||
set_clause += ", updated_at = datetime('now')"
|
||||
params = tuple(updates.values()) + (campaign_id,)
|
||||
self.conn.execute(
|
||||
f"UPDATE campaigns SET {set_clause} WHERE id = ?", params
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_campaign(campaign_id)
|
||||
|
||||
def delete_campaign(self, campaign_id: int) -> bool:
|
||||
cur = self.conn.execute("DELETE FROM campaigns WHERE id = ?", (campaign_id,))
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Campaign variants
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def list_variants(self, campaign_id: int) -> list[dict]:
|
||||
return self._fetchall(
|
||||
"SELECT * FROM campaign_variants WHERE campaign_id = ? ORDER BY sub_pattern",
|
||||
(campaign_id,),
|
||||
)
|
||||
|
||||
def get_variant(self, variant_id: int) -> dict | None:
|
||||
return self._fetchone("SELECT * FROM campaign_variants WHERE id = ?", (variant_id,))
|
||||
|
||||
def resolve_variant(self, campaign_id: int, sub: str) -> dict | None:
|
||||
"""Pick the best-matching variant for a given sub (exact > prefix > default)."""
|
||||
variants = self.list_variants(campaign_id)
|
||||
exact = next((v for v in variants if v["sub_pattern"] == sub), None)
|
||||
if exact:
|
||||
return exact
|
||||
prefix = next(
|
||||
(v for v in variants
|
||||
if v["sub_pattern"].endswith("*") and sub.startswith(v["sub_pattern"][:-1])),
|
||||
None,
|
||||
)
|
||||
if prefix:
|
||||
return prefix
|
||||
return next((v for v in variants if v["sub_pattern"] == "*"), None)
|
||||
|
||||
def create_variant(self, campaign_id: int, title: str, body: str,
|
||||
sub_pattern: str = "*", flair: str | None = None,
|
||||
notes: str | None = None) -> dict:
|
||||
return self._insert_returning(
|
||||
"INSERT INTO campaign_variants (campaign_id, sub_pattern, title, body, flair, notes) VALUES (?,?,?,?,?,?) RETURNING *",
|
||||
(campaign_id, sub_pattern, title, body, flair, notes),
|
||||
)
|
||||
|
||||
def update_variant(self, variant_id: int, **fields) -> dict | None:
|
||||
allowed = {"sub_pattern", "title", "body", "flair", "notes"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return self.get_variant(variant_id)
|
||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||
set_clause += ", updated_at = datetime('now')"
|
||||
params = tuple(updates.values()) + (variant_id,)
|
||||
self.conn.execute(
|
||||
f"UPDATE campaign_variants SET {set_clause} WHERE id = ?", params
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_variant(variant_id)
|
||||
|
||||
def delete_variant(self, variant_id: int) -> bool:
|
||||
cur = self.conn.execute("DELETE FROM campaign_variants WHERE id = ?", (variant_id,))
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Campaign subs
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def list_campaign_subs(self, campaign_id: int) -> list[dict]:
|
||||
return self._fetchall(
|
||||
"SELECT * FROM campaign_subs WHERE campaign_id = ? ORDER BY sort_order, sub",
|
||||
(campaign_id,),
|
||||
)
|
||||
|
||||
def add_campaign_sub(self, campaign_id: int, sub: str, sort_order: int = 0) -> dict:
|
||||
return self._insert_returning(
|
||||
"INSERT OR REPLACE INTO campaign_subs (campaign_id, sub, sort_order) VALUES (?,?,?) RETURNING *",
|
||||
(campaign_id, sub, sort_order),
|
||||
)
|
||||
|
||||
def remove_campaign_sub(self, campaign_id: int, sub: str) -> bool:
|
||||
cur = self.conn.execute(
|
||||
"DELETE FROM campaign_subs WHERE campaign_id = ? AND sub = ?", (campaign_id, sub)
|
||||
)
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Posts
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def list_posts(self, campaign_id: int | None = None, target: str | None = None,
|
||||
limit: int = 50) -> list[dict]:
|
||||
clauses, params = [], []
|
||||
if campaign_id is not None:
|
||||
clauses.append("campaign_id = ?")
|
||||
params.append(campaign_id)
|
||||
if target is not None:
|
||||
clauses.append("target = ?")
|
||||
params.append(target)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
params.append(limit)
|
||||
return self._fetchall(
|
||||
f"SELECT * FROM posts {where} ORDER BY posted_at DESC LIMIT ?", tuple(params)
|
||||
)
|
||||
|
||||
def create_post(self, campaign_id: int, target: str, variant_id: int | None = None,
|
||||
platform: str = "reddit", triggered_by: str = "scheduler") -> 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),
|
||||
)
|
||||
|
||||
def update_post_status(self, post_id: int, status: str, url: str | None = None,
|
||||
error_msg: str | None = None, screenshot_path: str | None = None) -> dict | None:
|
||||
self.conn.execute(
|
||||
"UPDATE posts SET status = ?, url = ?, error_msg = ?, screenshot_path = ? WHERE id = ?",
|
||||
(status, url, error_msg, screenshot_path, post_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self._fetchone("SELECT * FROM posts WHERE id = ?", (post_id,))
|
||||
|
||||
def already_posted_this_week(self, campaign_id: int, target: str) -> bool:
|
||||
"""Return True if a successful post to this sub exists in the past 7 days."""
|
||||
row = self._fetchone(
|
||||
"SELECT id FROM posts WHERE campaign_id = ? AND target = ? AND status = 'success'"
|
||||
" AND posted_at >= datetime('now', '-7 days')",
|
||||
(campaign_id, target),
|
||||
)
|
||||
return row is not None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Sub rules
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def list_sub_rules(self, platform: str = "reddit") -> list[dict]:
|
||||
return self._fetchall(
|
||||
"SELECT * FROM sub_rules WHERE platform = ? ORDER BY sub", (platform,)
|
||||
)
|
||||
|
||||
def get_sub_rules(self, sub: str, platform: str = "reddit") -> dict | None:
|
||||
return self._fetchone(
|
||||
"SELECT * FROM sub_rules WHERE platform = ? AND sub = ?", (platform, sub)
|
||||
)
|
||||
|
||||
def upsert_sub_rules(self, sub: str, platform: str = "reddit", **fields) -> dict:
|
||||
existing = self.get_sub_rules(sub, platform)
|
||||
if existing:
|
||||
allowed = {"flair_required", "flair_to_use", "promo_allowed", "rule_warning", "notes", "last_checked"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if updates:
|
||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||
set_clause += ", updated_at = datetime('now')"
|
||||
params = tuple(updates.values()) + (platform, sub)
|
||||
self.conn.execute(
|
||||
f"UPDATE sub_rules SET {set_clause} WHERE platform = ? AND sub = ?", params
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_sub_rules(sub, platform)
|
||||
return self._insert_returning(
|
||||
"INSERT INTO sub_rules (platform, sub, flair_required, flair_to_use, promo_allowed, rule_warning, notes, last_checked) VALUES (?,?,?,?,?,?,?,?) RETURNING *",
|
||||
(platform, sub,
|
||||
fields.get("flair_required", 0), fields.get("flair_to_use"),
|
||||
fields.get("promo_allowed"), fields.get("rule_warning", 0),
|
||||
fields.get("notes"), fields.get("last_checked")),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Engagement
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def record_engagement(self, post_id: int, score: int | None = None,
|
||||
upvotes: int | None = None, comments: int | None = None,
|
||||
awards: int = 0) -> dict:
|
||||
return self._insert_returning(
|
||||
"INSERT INTO engagement (post_id, score, upvotes, comments, awards) VALUES (?,?,?,?,?) RETURNING *",
|
||||
(post_id, score, upvotes, comments, awards),
|
||||
)
|
||||
|
||||
def get_latest_engagement(self, post_id: int) -> dict | None:
|
||||
return self._fetchone(
|
||||
"SELECT * FROM engagement WHERE post_id = ? ORDER BY checked_at DESC LIMIT 1",
|
||||
(post_id,),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Opportunities
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def list_opportunities(self, status: str | None = None) -> list[dict]:
|
||||
if status:
|
||||
return self._fetchall(
|
||||
"SELECT * FROM opportunities WHERE status = ? ORDER BY created_at DESC",
|
||||
(status,),
|
||||
)
|
||||
return self._fetchall(
|
||||
"SELECT * FROM opportunities ORDER BY created_at DESC"
|
||||
)
|
||||
|
||||
def get_opportunity(self, opportunity_id: int) -> dict | None:
|
||||
return self._fetchone(
|
||||
"SELECT * FROM opportunities WHERE id = ?", (opportunity_id,)
|
||||
)
|
||||
|
||||
def create_opportunity(
|
||||
self,
|
||||
community: str,
|
||||
thread_url: str,
|
||||
draft_body: str,
|
||||
platform: str = "reddit",
|
||||
thread_title: str | None = None,
|
||||
thread_body: str | None = None,
|
||||
signal_reason: str | None = None,
|
||||
product: str | None = None,
|
||||
draft_title: str | None = None,
|
||||
post_type: str = "reply_to_thread",
|
||||
campaign_id: int | None = None,
|
||||
) -> dict:
|
||||
return self._insert_returning(
|
||||
"""INSERT INTO opportunities
|
||||
(platform, community, thread_url, thread_title, thread_body,
|
||||
signal_reason, product, draft_title, draft_body, post_type, campaign_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?) RETURNING *""",
|
||||
(platform, community, thread_url, thread_title, thread_body,
|
||||
signal_reason, product, draft_title, draft_body, post_type, campaign_id),
|
||||
)
|
||||
|
||||
def update_opportunity(self, opportunity_id: int, **fields) -> dict | None:
|
||||
allowed = {"draft_title", "draft_body", "signal_reason", "product",
|
||||
"status", "dismiss_note", "campaign_id"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return self.get_opportunity(opportunity_id)
|
||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||
set_clause += ", updated_at = datetime('now')"
|
||||
params = tuple(updates.values()) + (opportunity_id,)
|
||||
self.conn.execute(
|
||||
f"UPDATE opportunities SET {set_clause} WHERE id = ?", params
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_opportunity(opportunity_id)
|
||||
|
||||
def approve_opportunity(self, opportunity_id: int) -> dict | None:
|
||||
return self.update_opportunity(opportunity_id, status="approved")
|
||||
|
||||
def dismiss_opportunity(self, opportunity_id: int, note: str | None = None) -> dict | None:
|
||||
return self.update_opportunity(opportunity_id, status="dismissed", dismiss_note=note)
|
||||
68
app/main.py
Normal file
68
app/main.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import register_routes
|
||||
from app.core.config import get_settings
|
||||
from app.db.store import Store
|
||||
from app.services.scheduler import start_scheduler, stop_scheduler, sync_all_campaigns
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
|
||||
# Run DB migrations
|
||||
store = Store(settings.db_path)
|
||||
store.run_migrations()
|
||||
|
||||
# Boot scheduler and register all active campaigns
|
||||
if settings.scheduler_enabled:
|
||||
sched = start_scheduler()
|
||||
app.state.scheduler = sched
|
||||
campaigns = store.list_campaigns(active_only=True)
|
||||
sync_all_campaigns(campaigns)
|
||||
logger.info("Magpie started — %d campaign(s) scheduled", len(campaigns))
|
||||
else:
|
||||
app.state.scheduler = None
|
||||
logger.info("Magpie started — scheduler disabled")
|
||||
|
||||
store.close()
|
||||
yield
|
||||
|
||||
# Graceful shutdown
|
||||
stop_scheduler()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title="Magpie",
|
||||
description="CircuitForge cross-product social media management",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:8531", "http://0.0.0.0:8531"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
register_routes(app)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
settings = get_settings()
|
||||
uvicorn.run("app.main:app", host=settings.api_host, port=settings.api_port, reload=settings.debug)
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
37
app/services/platforms.py
Normal file
37
app/services/platforms.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""
|
||||
Platform registry: maps platform names to their poster implementations.
|
||||
|
||||
Adding a new platform:
|
||||
1. Create app/services/<platform>/client.py implementing PlatformClient
|
||||
2. Register it here in REGISTRY
|
||||
|
||||
This keeps poster.py platform-agnostic — it looks up the right client by
|
||||
the campaign's `platform` field rather than branching on strings everywhere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class PlatformClient(Protocol):
|
||||
def post(self, target: str, title: str, body: str, flair: str | None = None) -> str:
|
||||
"""Post content to a target (sub, group, channel). Returns a URL."""
|
||||
...
|
||||
|
||||
|
||||
def get_client(platform: str) -> PlatformClient:
|
||||
"""Return an initialized client for the given platform name."""
|
||||
if platform == "reddit":
|
||||
from app.services.reddit.client import RedditClient
|
||||
return RedditClient()
|
||||
raise NotImplementedError(
|
||||
f"Platform '{platform}' is not yet implemented. "
|
||||
f"Add a client in app/services/{platform}/ and register it here."
|
||||
)
|
||||
|
||||
|
||||
# Platforms with posting support implemented
|
||||
SUPPORTED_PLATFORMS = {"reddit"}
|
||||
|
||||
# Platforms planned but not yet implemented
|
||||
PLANNED_PLATFORMS = {"facebook", "discord", "lemmy", "mastodon"}
|
||||
87
app/services/poster.py
Normal file
87
app/services/poster.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""
|
||||
Posting service: orchestrates variant resolution, dupe guard, and post execution.
|
||||
Called by the scheduler and by the manual-trigger API endpoint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.store import Store
|
||||
from app.services.platforms import get_client, SUPPORTED_PLATFORMS
|
||||
|
||||
|
||||
def _run_post(db_path: str, campaign_id: int, sub: str, triggered_by: str = "scheduler") -> dict:
|
||||
"""Execute a single post attempt (blocking, runs in a thread)."""
|
||||
store = Store(db_path)
|
||||
try:
|
||||
# Dupe guard: skip if already posted to this sub this week
|
||||
if store.already_posted_this_week(campaign_id, sub):
|
||||
return {"skipped": True, "reason": f"already posted to r/{sub} this week"}
|
||||
|
||||
# Resolve the best variant for this sub
|
||||
variant = store.resolve_variant(campaign_id, sub)
|
||||
if variant is None:
|
||||
return {"skipped": True, "reason": "no variant found for campaign"}
|
||||
|
||||
# Check sub rules
|
||||
rules = store.get_sub_rules(sub)
|
||||
if rules and rules.get("promo_allowed") == 0:
|
||||
return {"skipped": True, "reason": f"r/{sub} hard-bans promotion"}
|
||||
|
||||
# Check platform support
|
||||
campaign = store.get_campaign(campaign_id)
|
||||
platform = campaign["platform"] if campaign else "reddit"
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
return {"skipped": True, "reason": f"platform '{platform}' not yet implemented"}
|
||||
|
||||
# Create the pending post record
|
||||
post = store.create_post(
|
||||
campaign_id=campaign_id,
|
||||
target=sub,
|
||||
variant_id=variant["id"],
|
||||
platform=platform,
|
||||
triggered_by=triggered_by,
|
||||
)
|
||||
post_id = post["id"]
|
||||
|
||||
# Execute
|
||||
try:
|
||||
client = get_client(platform)
|
||||
flair = variant.get("flair") or (rules.get("flair_to_use") if rules else None)
|
||||
url = client.post(
|
||||
sub=sub,
|
||||
title=variant["title"],
|
||||
body=variant["body"],
|
||||
flair=flair,
|
||||
)
|
||||
return store.update_post_status(post_id, "success", url=url)
|
||||
except Exception as exc:
|
||||
return store.update_post_status(post_id, "failed", error_msg=str(exc))
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
async def post_campaign_to_sub(campaign_id: int, sub: str,
|
||||
triggered_by: str = "scheduler") -> dict:
|
||||
"""Async wrapper for API and scheduler use."""
|
||||
db_path = get_settings().db_path
|
||||
return await asyncio.to_thread(_run_post, db_path, campaign_id, sub, triggered_by)
|
||||
|
||||
|
||||
async def run_campaign(campaign_id: int, triggered_by: str = "scheduler") -> list[dict]:
|
||||
"""Post a campaign to all of its configured subs, sequentially."""
|
||||
db_path = get_settings().db_path
|
||||
store = Store(db_path)
|
||||
try:
|
||||
subs = store.list_campaign_subs(campaign_id)
|
||||
active_subs = [s["sub"] for s in subs if s.get("active", 1)]
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
results = []
|
||||
for sub in active_subs:
|
||||
result = await post_campaign_to_sub(campaign_id, sub, triggered_by)
|
||||
results.append(result)
|
||||
return results
|
||||
3
app/services/reddit/__init__.py
Normal file
3
app/services/reddit/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.services.reddit.client import RedditClient
|
||||
|
||||
__all__ = ["RedditClient"]
|
||||
122
app/services/reddit/client.py
Normal file
122
app/services/reddit/client.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""
|
||||
RedditClient: thin wrapper around the Playwright post.py script.
|
||||
|
||||
Migrated from claude-bridge/reddit-poster/reddit.py.
|
||||
Posting goes via xvfb-run + Playwright (avoids Reddit API bot detection).
|
||||
Comment/delete go via httpx with saved session cookies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.services.reddit.session import ensure_valid_session, load_cookies
|
||||
|
||||
_POST_SCRIPT = Path(__file__).parent / "post.py"
|
||||
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) Chrome/124.0.0.0"
|
||||
|
||||
|
||||
class RedditClient:
|
||||
def __init__(self, session_file: Path | None = None) -> None:
|
||||
settings = get_settings()
|
||||
self._session_file = session_file or Path(settings.reddit_session_file)
|
||||
ensure_valid_session(self._session_file)
|
||||
self.cookies = load_cookies(self._session_file)
|
||||
self.headers = {"User-Agent": USER_AGENT}
|
||||
self._modhash: str | None = None
|
||||
|
||||
@property
|
||||
def modhash(self) -> str:
|
||||
if self._modhash is None:
|
||||
resp = httpx.get(
|
||||
"https://www.reddit.com/api/me.json",
|
||||
cookies=self.cookies,
|
||||
headers=self.headers,
|
||||
timeout=15,
|
||||
)
|
||||
self._modhash = resp.json().get("data", {}).get("modhash", "")
|
||||
return self._modhash
|
||||
|
||||
def post(self, sub: str, title: str, body: str, flair: str | None = None) -> str:
|
||||
"""Submit a text post via Playwright (xvfb-run). Returns the permalink."""
|
||||
settings = get_settings()
|
||||
cmd = [
|
||||
"xvfb-run", "--auto-servernum",
|
||||
sys.executable, str(_POST_SCRIPT),
|
||||
"--sub", sub,
|
||||
"--title", title,
|
||||
"--body", body,
|
||||
"--yes",
|
||||
]
|
||||
if flair:
|
||||
cmd += ["--flair", flair]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
env={
|
||||
**__import__("os").environ,
|
||||
"REDDIT_SESSION_FILE": str(self._session_file),
|
||||
},
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Post to r/{sub} failed (exit {result.returncode}):\n"
|
||||
f"STDOUT:\n{result.stdout}\n"
|
||||
f"STDERR:\n{result.stderr}"
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("Posted:"):
|
||||
url = line.split("Posted:", 1)[-1].strip()
|
||||
return url
|
||||
raise RuntimeError(
|
||||
f"Post to r/{sub} may have failed — no 'Posted:' line in output.\n"
|
||||
f"Full stdout:\n{result.stdout}"
|
||||
)
|
||||
|
||||
def comment(self, thread_id: str, body: str) -> str:
|
||||
"""Post a top-level comment to a thread. Returns the permalink."""
|
||||
resp = httpx.post(
|
||||
"https://www.reddit.com/api/comment",
|
||||
cookies=self.cookies,
|
||||
headers={**self.headers, "X-Modhash": self.modhash},
|
||||
data={
|
||||
"api_type": "json",
|
||||
"thing_id": f"t3_{thread_id}",
|
||||
"text": body,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
result = resp.json()
|
||||
errors = result.get("json", {}).get("errors", [])
|
||||
if errors:
|
||||
raise RuntimeError(f"Comment failed: {errors}")
|
||||
things = result.get("json", {}).get("data", {}).get("things", [])
|
||||
permalink = (
|
||||
"https://reddit.com" + things[0]["data"].get("permalink", "") if things else ""
|
||||
)
|
||||
return permalink
|
||||
|
||||
def delete(self, post_url: str) -> None:
|
||||
"""Delete a post by URL."""
|
||||
import re
|
||||
match = re.search(r"/comments/([a-z0-9]+)/", post_url)
|
||||
if not match:
|
||||
raise ValueError(f"Cannot extract post ID from: {post_url}")
|
||||
post_id = match.group(1)
|
||||
resp = httpx.post(
|
||||
"https://www.reddit.com/api/del",
|
||||
cookies=self.cookies,
|
||||
headers={**self.headers, "X-Modhash": self.modhash},
|
||||
data={"id": f"t3_{post_id}"},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Delete failed ({resp.status_code}): {resp.text[:200]}")
|
||||
322
app/services/reddit/post.py
Normal file
322
app/services/reddit/post.py
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reddit posting script using Playwright + stealth.
|
||||
|
||||
Migrated from claude-bridge/reddit-poster/post.py.
|
||||
Uses system Google Chrome (non-headless via Xvfb) with anti-detection flags
|
||||
to avoid Reddit's bot detection. Saves a cookie session after first login.
|
||||
|
||||
Usage:
|
||||
python -m app.services.reddit.post --login
|
||||
python -m app.services.reddit.post --sub selfhosted --title "..." --body "..."
|
||||
python -m app.services.reddit.post --sub selfhosted --title "..." --body-file draft.txt
|
||||
python -m app.services.reddit.post --delete <post_url>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
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)
|
||||
_HERE = Path(__file__).parent
|
||||
_PROJECT_ROOT = _HERE.parents[3]
|
||||
load_dotenv(_PROJECT_ROOT / ".env")
|
||||
|
||||
REDDIT_USERNAME = os.getenv("REDDIT_USERNAME", "")
|
||||
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")))
|
||||
|
||||
LOGIN_URL = "https://www.reddit.com/login"
|
||||
SUBMIT_URL = "https://www.reddit.com/r/{sub}/submit?type=text"
|
||||
|
||||
|
||||
def _make_browser(p):
|
||||
return p.chromium.launch(
|
||||
executable_path=CHROME_BIN,
|
||||
headless=False,
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--window-size=1280,900",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _make_context(browser):
|
||||
return browser.new_context(
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
),
|
||||
viewport={"width": 1280, "height": 900},
|
||||
locale="en-US",
|
||||
storage_state=str(SESSION_FILE) if SESSION_FILE.exists() else None,
|
||||
)
|
||||
|
||||
|
||||
def _apply_stealth(page) -> None:
|
||||
Stealth().apply_stealth_sync(page)
|
||||
|
||||
|
||||
def _login(page) -> None:
|
||||
print("Navigating to Reddit login...")
|
||||
page.goto(LOGIN_URL, wait_until="domcontentloaded")
|
||||
time.sleep(2)
|
||||
|
||||
selectors_user = [
|
||||
"#login-username",
|
||||
'input[name="username"]',
|
||||
'input[id="loginUsername"]',
|
||||
'input[placeholder*="Username"]',
|
||||
'input[autocomplete="username"]',
|
||||
]
|
||||
selectors_pass = [
|
||||
"#login-password",
|
||||
'input[name="password"]',
|
||||
'input[id="loginPassword"]',
|
||||
'input[placeholder*="Password"]',
|
||||
'input[autocomplete="current-password"]',
|
||||
]
|
||||
|
||||
def fill_first(selectors, value):
|
||||
for sel in selectors:
|
||||
try:
|
||||
el = page.locator(sel).first
|
||||
if el.count() and el.is_visible():
|
||||
el.fill(value)
|
||||
return sel
|
||||
except Exception:
|
||||
continue
|
||||
raise RuntimeError(f"Could not find input. Tried: {selectors}")
|
||||
|
||||
usel = fill_first(selectors_user, REDDIT_USERNAME)
|
||||
print(f" Filled username via {usel}")
|
||||
time.sleep(0.3)
|
||||
psel = fill_first(selectors_pass, REDDIT_PASSWORD)
|
||||
print(f" Filled password via {psel}")
|
||||
time.sleep(0.3)
|
||||
page.keyboard.press("Enter")
|
||||
|
||||
try:
|
||||
page.wait_for_url(lambda url: "login" not in url, timeout=20_000)
|
||||
except PlaywrightTimeout:
|
||||
raise RuntimeError("Login did not redirect — check credentials or CAPTCHA.")
|
||||
|
||||
print(f"Logged in as u/{REDDIT_USERNAME}")
|
||||
|
||||
|
||||
def do_login() -> None:
|
||||
if not REDDIT_USERNAME or not REDDIT_PASSWORD:
|
||||
sys.exit("Set REDDIT_USERNAME and REDDIT_PASSWORD in .env")
|
||||
SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
browser = _make_browser(p)
|
||||
ctx = browser.new_context(
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
),
|
||||
viewport={"width": 1280, "height": 900},
|
||||
locale="en-US",
|
||||
)
|
||||
page = ctx.new_page()
|
||||
_apply_stealth(page)
|
||||
_login(page)
|
||||
ctx.storage_state(path=str(SESSION_FILE))
|
||||
print(f"Session saved to {SESSION_FILE}")
|
||||
browser.close()
|
||||
|
||||
|
||||
def _ensure_logged_in(page) -> None:
|
||||
page.goto("https://www.reddit.com", wait_until="domcontentloaded")
|
||||
time.sleep(2)
|
||||
if REDDIT_USERNAME.lower() not in page.content().lower():
|
||||
print("Session expired — re-logging in...")
|
||||
_login(page)
|
||||
|
||||
|
||||
def post(sub: str, title: str, body: str, flair: str | None = None, yes: bool = False) -> str:
|
||||
"""Submit a text post. Returns the posted URL."""
|
||||
if not REDDIT_USERNAME or not REDDIT_PASSWORD:
|
||||
sys.exit("Set REDDIT_USERNAME and REDDIT_PASSWORD in .env")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Subreddit : r/{sub}")
|
||||
print(f" Title : {title}")
|
||||
print(f" Body :\n")
|
||||
for line in body.splitlines():
|
||||
print(f" {line}")
|
||||
print(f"\n{'='*60}\n")
|
||||
|
||||
if not yes:
|
||||
confirm = input("Post this? [y/N] ").strip().lower()
|
||||
if confirm != "y":
|
||||
print("Aborted.")
|
||||
return ""
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = _make_browser(p)
|
||||
ctx = _make_context(browser)
|
||||
page = ctx.new_page()
|
||||
_apply_stealth(page)
|
||||
|
||||
_ensure_logged_in(page)
|
||||
|
||||
page.goto(SUBMIT_URL.format(sub=sub), wait_until="domcontentloaded")
|
||||
time.sleep(2)
|
||||
|
||||
# Fill title
|
||||
try:
|
||||
title_el = page.locator('textarea[name="title"]').first
|
||||
title_el.wait_for(state="visible", timeout=10_000)
|
||||
title_el.fill(title)
|
||||
except Exception as exc:
|
||||
print(f" Warning: title fill failed ({exc})")
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
# Fill body (Lexical editor — click to focus, then type)
|
||||
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)
|
||||
except Exception as exc:
|
||||
print(f" Warning: body fill failed ({exc})")
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
# Flair selection (faceplate-radio-input custom web component)
|
||||
if flair:
|
||||
try:
|
||||
page.locator('faceplate-radio-input').filter(has_text=flair).click()
|
||||
time.sleep(0.5)
|
||||
# "Add" button in flair dialog — coordinate-based (1280x900 viewport)
|
||||
page.mouse.click(877, 765)
|
||||
time.sleep(0.5)
|
||||
except Exception as exc:
|
||||
print(f" Warning: flair selection failed ({exc})")
|
||||
|
||||
# Submit
|
||||
try:
|
||||
submit_btn = page.locator('button[type="submit"]').filter(has_text="Post")
|
||||
submit_btn.wait_for(state="visible", timeout=10_000)
|
||||
submit_btn.click()
|
||||
except Exception as exc:
|
||||
print(f" Warning: submit button click failed ({exc})")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# Rule-warning dialog — must use wait_for(state=), not is_visible()
|
||||
try:
|
||||
warning_btn = page.locator('button:has-text("Submit without editing")')
|
||||
warning_btn.wait_for(state="visible", timeout=3_000)
|
||||
warning_btn.click()
|
||||
print(" Acknowledged rule warning — submitted without editing")
|
||||
time.sleep(1)
|
||||
except PlaywrightTimeout:
|
||||
pass
|
||||
|
||||
try:
|
||||
page.wait_for_url(lambda url: "/comments/" in url, timeout=20_000)
|
||||
except PlaywrightTimeout:
|
||||
pass
|
||||
|
||||
final_url = page.url
|
||||
if "/submit" in final_url:
|
||||
screenshot_path = SESSION_FILE.parent / f"debug_{sub}_{int(time.time())}.png"
|
||||
page.screenshot(path=str(screenshot_path))
|
||||
raise RuntimeError(
|
||||
f"Post may have failed — still on submit URL: {final_url}\n"
|
||||
f"Screenshot saved to {screenshot_path}"
|
||||
)
|
||||
|
||||
print(f"\nPosted: {final_url}")
|
||||
ctx.storage_state(path=str(SESSION_FILE))
|
||||
browser.close()
|
||||
return final_url
|
||||
|
||||
|
||||
def delete_post(post_url: str) -> None:
|
||||
import json
|
||||
import re
|
||||
import httpx
|
||||
|
||||
state = json.loads(SESSION_FILE.read_text())
|
||||
cookies = {c["name"]: c["value"] for c in state.get("cookies", [])}
|
||||
headers = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/124.0.0.0"}
|
||||
|
||||
match = re.search(r"/comments/([a-z0-9]+)/", post_url)
|
||||
if not match:
|
||||
print(f"Could not extract post ID from URL: {post_url}")
|
||||
return
|
||||
post_id = match.group(1)
|
||||
|
||||
me = httpx.get("https://www.reddit.com/api/me.json", cookies=cookies, headers=headers)
|
||||
modhash = me.json().get("data", {}).get("modhash", "")
|
||||
if not modhash:
|
||||
print("Could not get modhash — session may be expired. Run --login first.")
|
||||
return
|
||||
|
||||
resp = httpx.post(
|
||||
"https://www.reddit.com/api/del",
|
||||
cookies=cookies,
|
||||
headers={**headers, "X-Modhash": modhash},
|
||||
data={"id": f"t3_{post_id}"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
print(f"Deleted: {post_url}")
|
||||
else:
|
||||
print(f"Delete failed ({resp.status_code}): {resp.text[:200]}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Post to Reddit via Playwright")
|
||||
parser.add_argument("--sub")
|
||||
parser.add_argument("--title")
|
||||
parser.add_argument("--body")
|
||||
parser.add_argument("--body-file")
|
||||
parser.add_argument("--flair")
|
||||
parser.add_argument("--login", action="store_true")
|
||||
parser.add_argument("--delete", metavar="POST_URL")
|
||||
parser.add_argument("--yes", "-y", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.login:
|
||||
do_login()
|
||||
return
|
||||
|
||||
if args.delete:
|
||||
delete_post(args.delete)
|
||||
return
|
||||
|
||||
if not args.sub or not args.title:
|
||||
parser.error("--sub and --title are required")
|
||||
|
||||
body = ""
|
||||
if args.body_file:
|
||||
body = Path(args.body_file).read_text()
|
||||
elif args.body:
|
||||
body = args.body
|
||||
else:
|
||||
print("Enter post body (Ctrl+D when done):")
|
||||
body = sys.stdin.read()
|
||||
|
||||
post(args.sub, args.title, body.strip(), flair=args.flair, yes=args.yes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
app/services/reddit/session.py
Normal file
64
app/services/reddit/session.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
Reddit session management via Playwright + xvfb-run.
|
||||
|
||||
Migrated from claude-bridge/reddit-poster/reddit.py.
|
||||
Session cookies are stored in a JSON file and refreshed automatically when stale.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
SESSION_MAX_AGE_HOURS = 12
|
||||
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) Chrome/124.0.0.0"
|
||||
|
||||
_POST_SCRIPT = Path(__file__).parent / "post.py"
|
||||
|
||||
|
||||
def _session_age_hours(session_file: Path) -> float:
|
||||
if not session_file.exists():
|
||||
return float("inf")
|
||||
return (time.time() - session_file.stat().st_mtime) / 3600
|
||||
|
||||
|
||||
def session_is_valid(session_file: Path | None = None) -> bool:
|
||||
if session_file is None:
|
||||
session_file = Path(get_settings().reddit_session_file)
|
||||
return _session_age_hours(session_file) < SESSION_MAX_AGE_HOURS
|
||||
|
||||
|
||||
def refresh_session(session_file: Path | None = None) -> None:
|
||||
"""Re-login via Playwright (xvfb-run) and overwrite session.json."""
|
||||
if session_file is None:
|
||||
session_file = Path(get_settings().reddit_session_file)
|
||||
session_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
print("Session expired or missing — re-establishing via Playwright login...")
|
||||
result = subprocess.run(
|
||||
["xvfb-run", "--auto-servernum", sys.executable, str(_POST_SCRIPT), "--login"],
|
||||
cwd=str(_POST_SCRIPT.parent),
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("Playwright re-login failed. Check credentials in .env.")
|
||||
print("Session refreshed.")
|
||||
|
||||
|
||||
def load_cookies(session_file: Path | None = None) -> dict[str, str]:
|
||||
if session_file is None:
|
||||
session_file = Path(get_settings().reddit_session_file)
|
||||
if not session_file.exists():
|
||||
refresh_session(session_file)
|
||||
state = json.loads(session_file.read_text())
|
||||
return {c["name"]: c["value"] for c in state.get("cookies", [])}
|
||||
|
||||
|
||||
def ensure_valid_session(session_file: Path | None = None) -> None:
|
||||
if session_file is None:
|
||||
session_file = Path(get_settings().reddit_session_file)
|
||||
if not session_is_valid(session_file):
|
||||
refresh_session(session_file)
|
||||
121
app/services/scheduler.py
Normal file
121
app/services/scheduler.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""
|
||||
Campaign scheduler: wraps APScheduler's AsyncIOScheduler.
|
||||
|
||||
One cron job per active campaign with a cron_schedule.
|
||||
Jobs are re-synced at startup and updated dynamically via sync_campaign().
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.services.poster import run_campaign
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level singleton — attached to app.state in main.py so it is accessible
|
||||
# from endpoints without importing it directly.
|
||||
_scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
|
||||
def _job_id(campaign_id: int) -> str:
|
||||
return f"campaign_{campaign_id}"
|
||||
|
||||
|
||||
async def _run_campaign_job(campaign_id: int) -> None:
|
||||
"""APScheduler coroutine target: run a campaign and log the outcome."""
|
||||
logger.info("Scheduler firing campaign %d", campaign_id)
|
||||
try:
|
||||
results = await run_campaign(campaign_id, triggered_by="scheduler")
|
||||
success = sum(1 for r in results if not r.get("skipped") and r.get("status") == "success")
|
||||
skipped = sum(1 for r in results if r.get("skipped"))
|
||||
failed = sum(1 for r in results if not r.get("skipped") and r.get("status") == "failed")
|
||||
logger.info(
|
||||
"Campaign %d done: %d success, %d skipped, %d failed",
|
||||
campaign_id, success, skipped, failed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Unhandled error running campaign %d", campaign_id)
|
||||
|
||||
|
||||
def get_scheduler() -> AsyncIOScheduler:
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
_scheduler = AsyncIOScheduler(timezone="UTC")
|
||||
return _scheduler
|
||||
|
||||
|
||||
def start_scheduler() -> AsyncIOScheduler:
|
||||
sched = get_scheduler()
|
||||
if not sched.running:
|
||||
sched.start()
|
||||
logger.info("Scheduler started")
|
||||
return sched
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None and _scheduler.running:
|
||||
_scheduler.shutdown(wait=False)
|
||||
logger.info("Scheduler stopped")
|
||||
_scheduler = None
|
||||
|
||||
|
||||
def sync_campaign(campaign: dict) -> None:
|
||||
"""
|
||||
Add, update, or remove the cron job for a campaign.
|
||||
|
||||
Call this after any create / update / delete / toggle.
|
||||
"""
|
||||
sched = get_scheduler()
|
||||
job_id = _job_id(campaign["id"])
|
||||
cron_expr = campaign.get("cron_schedule")
|
||||
is_active = bool(campaign.get("active", 1))
|
||||
|
||||
# Remove existing job (if any) before re-adding so schedule changes take effect
|
||||
existing = sched.get_job(job_id)
|
||||
if existing:
|
||||
existing.remove()
|
||||
|
||||
if cron_expr and is_active:
|
||||
try:
|
||||
trigger = CronTrigger.from_crontab(cron_expr, timezone="UTC")
|
||||
sched.add_job(
|
||||
_run_campaign_job,
|
||||
trigger=trigger,
|
||||
id=job_id,
|
||||
args=[campaign["id"]],
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600, # fire up to 1 hour late (e.g. after a restart)
|
||||
)
|
||||
logger.info(
|
||||
"Scheduled campaign %d (%s) with cron: %s",
|
||||
campaign["id"], campaign.get("name", ""), cron_expr,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Invalid cron expression for campaign %d: %r",
|
||||
campaign["id"], cron_expr,
|
||||
)
|
||||
|
||||
|
||||
def remove_campaign(campaign_id: int) -> None:
|
||||
"""Remove the cron job for a deleted campaign."""
|
||||
sched = get_scheduler()
|
||||
existing = sched.get_job(_job_id(campaign_id))
|
||||
if existing:
|
||||
existing.remove()
|
||||
logger.info("Removed scheduler job for campaign %d", campaign_id)
|
||||
|
||||
|
||||
def sync_all_campaigns(campaigns: list[dict]) -> None:
|
||||
"""Sync scheduler state from a full campaign list (called at startup)."""
|
||||
for campaign in campaigns:
|
||||
sync_campaign(campaign)
|
||||
logger.info("Synced %d campaign(s) to scheduler", len(campaigns))
|
||||
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
services:
|
||||
magpie-api:
|
||||
build: .
|
||||
container_name: magpie-api
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
volumes:
|
||||
- magpie-data:/data
|
||||
- /dev/shm:/dev/shm # needed for Chrome in Docker
|
||||
environment:
|
||||
- DB_PATH=/data/magpie.db
|
||||
- REDDIT_SESSION_FILE=/data/session.json
|
||||
|
||||
magpie-web:
|
||||
image: node:20-slim
|
||||
container_name: magpie-web
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
command: sh -c "npm install && npm run dev"
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
|
||||
volumes:
|
||||
magpie-data:
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Magpie — Campaign Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1919
frontend/package-lock.json
generated
Normal file
1919
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "magpie-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 8531",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.1",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.1.7",
|
||||
"vue-tsc": "^3.1.0"
|
||||
}
|
||||
}
|
||||
14
frontend/src/App.vue
Normal file
14
frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<div class="app-shell">
|
||||
<nav class="sidebar">
|
||||
<div class="nav-brand">🪶 Magpie</div>
|
||||
<router-link class="nav-link" to="/opportunities" active-class="active">Opportunities</router-link>
|
||||
<router-link class="nav-link" to="/campaigns" active-class="active">Campaigns</router-link>
|
||||
<router-link class="nav-link" to="/posts" active-class="active">Post History</router-link>
|
||||
<router-link class="nav-link" to="/subs" active-class="active">Sub Rules</router-link>
|
||||
</nav>
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
213
frontend/src/components/CampaignDetail.vue
Normal file
213
frontend/src/components/CampaignDetail.vue
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<template>
|
||||
<div v-if="campaign">
|
||||
<div class="page-header">
|
||||
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
||||
<router-link to="/campaigns" style="color: var(--color-text-muted); text-decoration: none;">← Campaigns</router-link>
|
||||
<span style="color: var(--color-border);">/</span>
|
||||
<h1 class="page-title">{{ campaign.name }}</h1>
|
||||
<span class="badge badge-info">{{ campaign.product }}</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="triggerAll" :disabled="triggering">
|
||||
{{ triggering ? 'Running...' : '▶ Run All Subs' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-lg);">
|
||||
<!-- Left: variants + subs -->
|
||||
<div>
|
||||
<!-- Variants -->
|
||||
<div class="card" style="margin-bottom: var(--spacing-lg);">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--spacing-md);">
|
||||
<div class="card-title" style="margin: 0;">Content Variants</div>
|
||||
<button class="btn btn-ghost btn-sm" @click="showAddVariant = true">+ Variant</button>
|
||||
</div>
|
||||
<div v-if="variants.length === 0" class="empty-state" style="padding: var(--spacing-md);">
|
||||
No variants. Add a default (*) variant to start posting.
|
||||
</div>
|
||||
<div v-for="v in variants" :key="v.id" class="variant-row">
|
||||
<div style="display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: 4px;">
|
||||
<code style="font-size: 11px; color: var(--color-primary); background: var(--color-primary-dim); padding: 1px 6px; border-radius: 4px;">{{ v.sub_pattern }}</code>
|
||||
<span v-if="v.flair" style="font-size: 11px; color: var(--color-text-muted);">flair: {{ v.flair }}</span>
|
||||
<button class="btn btn-ghost btn-sm" style="margin-left: auto;" @click="deleteVariant(v.id)">✕</button>
|
||||
</div>
|
||||
<div style="font-weight: 500; font-size: 13px; margin-bottom: 2px;">{{ v.title }}</div>
|
||||
<div style="color: var(--color-text-muted); font-size: 12px; white-space: pre-wrap; max-height: 60px; overflow: hidden;">{{ v.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subs -->
|
||||
<div class="card">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--spacing-md);">
|
||||
<div class="card-title" style="margin: 0;">Target Subreddits</div>
|
||||
<button class="btn btn-ghost btn-sm" @click="showAddSub = true">+ Sub</button>
|
||||
</div>
|
||||
<div v-for="s in campaignSubs" :key="s.id" style="display: flex; align-items: center; gap: var(--spacing-sm); padding: 6px 0; border-bottom: 1px solid var(--color-border);">
|
||||
<span>r/{{ s.sub }}</span>
|
||||
<button class="btn btn-ghost btn-sm" style="margin-left: auto; color: var(--color-danger);" @click="removeSub(s.sub)">✕</button>
|
||||
</div>
|
||||
<div v-if="campaignSubs.length === 0" class="empty-state" style="padding: var(--spacing-md);">No subs configured.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: recent posts -->
|
||||
<div class="card" style="padding: 0; overflow: hidden; align-self: start;">
|
||||
<div class="card-title" style="padding: var(--spacing-md) var(--spacing-md) 0;">Recent Posts</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sub</th>
|
||||
<th>Status</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in recentPosts" :key="p.id">
|
||||
<td>r/{{ p.target }}</td>
|
||||
<td>
|
||||
<span :class="['status-dot', p.status]"></span>
|
||||
<a v-if="p.url" :href="p.url" target="_blank" style="color: var(--color-primary); text-decoration: none;">{{ p.status }}</a>
|
||||
<span v-else>{{ p.status }}</span>
|
||||
</td>
|
||||
<td style="color: var(--color-text-muted); font-size: 12px;">{{ formatDate(p.posted_at) }}</td>
|
||||
</tr>
|
||||
<tr v-if="recentPosts.length === 0">
|
||||
<td colspan="3" style="color: var(--color-text-muted); text-align: center; padding: var(--spacing-lg);">No posts yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add variant modal -->
|
||||
<div v-if="showAddVariant" class="modal-backdrop" @click.self="showAddVariant = false">
|
||||
<div class="modal card" style="width: 600px; max-height: 90vh; overflow-y: auto;">
|
||||
<h2 style="margin-bottom: var(--spacing-md); font-size: 16px;">Add Variant</h2>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sub pattern <span style="color: var(--color-text-muted)">(* = default, exact sub name, or prefix*)</span></label>
|
||||
<input class="form-input" v-model="variantForm.sub_pattern" placeholder="*" style="font-family: var(--font-mono);" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Flair (optional)</label>
|
||||
<input class="form-input" v-model="variantForm.flair" placeholder="Action / DIY / Activism" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Title</label>
|
||||
<input class="form-input" v-model="variantForm.title" placeholder="Post title..." />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Body</label>
|
||||
<textarea class="form-textarea" v-model="variantForm.body" rows="8" placeholder="Post body (markdown)..." style="min-height: 200px;" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Internal notes</label>
|
||||
<input class="form-input" v-model="variantForm.notes" placeholder="Framing angle, tone notes..." />
|
||||
</div>
|
||||
<div style="display: flex; gap: var(--spacing-sm); justify-content: flex-end;">
|
||||
<button class="btn btn-ghost" @click="showAddVariant = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="addVariant" :disabled="!variantForm.title || !variantForm.body">Add Variant</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add sub modal -->
|
||||
<div v-if="showAddSub" class="modal-backdrop" @click.self="showAddSub = false">
|
||||
<div class="modal card" style="width: 360px;">
|
||||
<h2 style="margin-bottom: var(--spacing-md); font-size: 16px;">Add Subreddit</h2>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Subreddit name (without r/)</label>
|
||||
<input class="form-input" v-model="subForm.sub" placeholder="selfhosted" />
|
||||
</div>
|
||||
<div style="display: flex; gap: var(--spacing-sm); justify-content: flex-end;">
|
||||
<button class="btn btn-ghost" @click="showAddSub = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="addSub" :disabled="!subForm.sub">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">Loading campaign...</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api, type Campaign, type Variant, type CampaignSub, type Post } from '@/services/api'
|
||||
|
||||
const route = useRoute()
|
||||
const campaignId = Number(route.params.id)
|
||||
|
||||
const campaign = ref<Campaign | null>(null)
|
||||
const variants = ref<Variant[]>([])
|
||||
const campaignSubs = ref<CampaignSub[]>([])
|
||||
const recentPosts = ref<Post[]>([])
|
||||
const triggering = ref(false)
|
||||
const showAddVariant = ref(false)
|
||||
const showAddSub = ref(false)
|
||||
|
||||
const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
|
||||
const subForm = reactive({ sub: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
const [c, v, s, p] = await Promise.all([
|
||||
api.campaigns.get(campaignId),
|
||||
api.variants.list(campaignId),
|
||||
api.subs.listForCampaign(campaignId),
|
||||
api.posts.list(campaignId, undefined, 20),
|
||||
])
|
||||
campaign.value = c
|
||||
variants.value = v
|
||||
campaignSubs.value = s
|
||||
recentPosts.value = p
|
||||
})
|
||||
|
||||
async function triggerAll() {
|
||||
triggering.value = true
|
||||
try {
|
||||
await api.campaigns.trigger(campaignId)
|
||||
recentPosts.value = await api.posts.list(campaignId, undefined, 20)
|
||||
} finally {
|
||||
triggering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addVariant() {
|
||||
const v = await api.variants.create(campaignId, {
|
||||
sub_pattern: variantForm.sub_pattern || '*',
|
||||
title: variantForm.title,
|
||||
body: variantForm.body,
|
||||
flair: variantForm.flair || null,
|
||||
notes: variantForm.notes || null,
|
||||
})
|
||||
variants.value = [...variants.value, v]
|
||||
showAddVariant.value = false
|
||||
Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
|
||||
}
|
||||
|
||||
async function deleteVariant(id: number) {
|
||||
await api.variants.delete(campaignId, id)
|
||||
variants.value = variants.value.filter(v => v.id !== id)
|
||||
}
|
||||
|
||||
async function addSub() {
|
||||
const s = await api.subs.add(campaignId, subForm.sub)
|
||||
campaignSubs.value = [...campaignSubs.value, s]
|
||||
showAddSub.value = false
|
||||
subForm.sub = ''
|
||||
}
|
||||
|
||||
async function removeSub(sub: string) {
|
||||
await api.subs.remove(campaignId, sub)
|
||||
campaignSubs.value = campaignSubs.value.filter(s => s.sub !== sub)
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
const d = new Date(iso + 'Z')
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { padding: var(--spacing-lg); }
|
||||
.variant-row { padding: var(--spacing-sm); border-bottom: 1px solid var(--color-border); }
|
||||
.variant-row:last-child { border-bottom: none; }
|
||||
</style>
|
||||
133
frontend/src/components/CampaignList.vue
Normal file
133
frontend/src/components/CampaignList.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Campaigns</h1>
|
||||
<button class="btn btn-primary" @click="showCreate = true">+ New Campaign</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="empty-state">Loading...</div>
|
||||
<div v-else-if="store.error" class="empty-state" style="color: var(--color-danger)">{{ store.error }}</div>
|
||||
<div v-else-if="store.campaigns.length === 0" class="empty-state">
|
||||
No campaigns yet. Create one to get started.
|
||||
</div>
|
||||
|
||||
<div v-else class="card" style="padding: 0; overflow: hidden;">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Product</th>
|
||||
<th>Schedule</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in store.campaigns" :key="c.id">
|
||||
<td>
|
||||
<router-link :to="`/campaigns/${c.id}`" style="color: var(--color-primary); text-decoration: none; font-weight: 500;">
|
||||
{{ c.name }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td><span class="badge badge-info">{{ c.product }}</span></td>
|
||||
<td style="font-family: var(--font-mono); font-size: 12px; color: var(--color-text-muted);">
|
||||
{{ c.cron_schedule ?? '— manual' }}
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['badge', c.active ? 'badge-success' : 'badge-muted']">
|
||||
{{ c.active ? 'active' : 'paused' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 6px; justify-content: flex-end;">
|
||||
<button class="btn btn-ghost btn-sm" @click="toggle(c)" :title="c.active ? 'Pause' : 'Resume'">
|
||||
{{ c.active ? '⏸' : '▶' }}
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" @click="trigger(c)" :disabled="triggering === c.id">
|
||||
{{ triggering === c.id ? '...' : 'Run' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create modal -->
|
||||
<div v-if="showCreate" class="modal-backdrop" @click.self="showCreate = false">
|
||||
<div class="modal card" style="width: 480px;">
|
||||
<h2 style="margin-bottom: var(--spacing-md); font-size: 16px;">New Campaign</h2>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-input" v-model="form.name" placeholder="Kiwi food waste launch" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Product</label>
|
||||
<select class="form-select" v-model="form.product">
|
||||
<option value="kiwi">kiwi</option>
|
||||
<option value="peregrine">peregrine</option>
|
||||
<option value="snipe">snipe</option>
|
||||
<option value="circuitforge">circuitforge</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Cron schedule <span style="color: var(--color-text-muted)">(optional — blank = manual)</span></label>
|
||||
<input class="form-input" v-model="form.cron_schedule" placeholder="0 9 * * 2 (Tues 9 AM)" style="font-family: var(--font-mono);" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea class="form-textarea" v-model="form.notes" rows="2" placeholder="Internal notes..." />
|
||||
</div>
|
||||
<div style="display: flex; gap: var(--spacing-sm); justify-content: flex-end;">
|
||||
<button class="btn btn-ghost" @click="showCreate = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="create" :disabled="!form.name || !form.product">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useCampaignStore } from '@/stores/campaigns'
|
||||
|
||||
const store = useCampaignStore()
|
||||
const showCreate = ref(false)
|
||||
const triggering = ref<number | null>(null)
|
||||
|
||||
const form = reactive({ name: '', product: 'kiwi', cron_schedule: '', notes: '' })
|
||||
|
||||
onMounted(() => store.fetchCampaigns())
|
||||
|
||||
async function create() {
|
||||
await store.createCampaign({
|
||||
name: form.name,
|
||||
product: form.product,
|
||||
cron_schedule: form.cron_schedule || null,
|
||||
notes: form.notes || null,
|
||||
})
|
||||
showCreate.value = false
|
||||
Object.assign(form, { name: '', product: 'kiwi', cron_schedule: '', notes: '' })
|
||||
}
|
||||
|
||||
async function toggle(c: { id: number; active: number }) {
|
||||
await store.updateCampaign(c.id, { active: !c.active })
|
||||
}
|
||||
|
||||
async function trigger(c: { id: number }) {
|
||||
triggering.value = c.id
|
||||
try {
|
||||
await store.triggerCampaign(c.id)
|
||||
} finally {
|
||||
triggering.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.modal { padding: var(--spacing-lg); }
|
||||
</style>
|
||||
66
frontend/src/components/PostsView.vue
Normal file
66
frontend/src/components/PostsView.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Post History</h1>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Campaign</th>
|
||||
<th>Target</th>
|
||||
<th>Status</th>
|
||||
<th>Triggered by</th>
|
||||
<th>When</th>
|
||||
<th>Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in posts" :key="p.id">
|
||||
<td>{{ campaignName(p.campaign_id) }}</td>
|
||||
<td>{{ p.target }}</td>
|
||||
<td>
|
||||
<span :class="['status-dot', p.status]"></span>{{ p.status }}
|
||||
<span v-if="p.error_msg" :title="p.error_msg" style="color: var(--color-danger); cursor: help;"> ⚠</span>
|
||||
</td>
|
||||
<td><span class="badge badge-muted">{{ p.triggered_by }}</span></td>
|
||||
<td style="color: var(--color-text-muted); font-size: 12px;">{{ formatDate(p.posted_at) }}</td>
|
||||
<td>
|
||||
<a v-if="p.url" :href="p.url" target="_blank" style="color: var(--color-primary); font-size: 12px;">view →</a>
|
||||
<span v-else style="color: var(--color-text-muted);">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="posts.length === 0">
|
||||
<td colspan="6" class="empty-state">No posts yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { usePostStore, useCampaignStore } from '@/stores/campaigns'
|
||||
|
||||
const postStore = usePostStore()
|
||||
const campaignStore = useCampaignStore()
|
||||
const posts = postStore.posts
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
postStore.fetchPosts(undefined, undefined, 100),
|
||||
campaignStore.fetchCampaigns(),
|
||||
])
|
||||
})
|
||||
|
||||
function campaignName(id: number) {
|
||||
return campaignStore.campaigns.find(c => c.id === id)?.name ?? `#${id}`
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
const d = new Date(iso + 'Z')
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
174
frontend/src/components/SubRulesView.vue
Normal file
174
frontend/src/components/SubRulesView.vue
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Sub / Channel Rules</h1>
|
||||
<button class="btn btn-primary" @click="showAdd = true">+ Add Sub</button>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sub / Channel</th>
|
||||
<th>Platform</th>
|
||||
<th>Flair</th>
|
||||
<th>Promo</th>
|
||||
<th>Rule Warning</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in rules" :key="r.id">
|
||||
<td style="font-weight: 500;">{{ r.sub }}</td>
|
||||
<td><span class="badge badge-muted">{{ r.platform }}</span></td>
|
||||
<td>
|
||||
<span v-if="r.flair_required">{{ r.flair_to_use ?? '(required, unknown)' }}</span>
|
||||
<span v-else style="color: var(--color-text-muted);">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="r.promo_allowed === null" class="badge badge-muted">unknown</span>
|
||||
<span v-else-if="r.promo_allowed" class="badge badge-success">allowed</span>
|
||||
<span v-else class="badge badge-danger">banned</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="r.rule_warning" class="badge badge-warning">yes</span>
|
||||
<span v-else style="color: var(--color-text-muted);">—</span>
|
||||
</td>
|
||||
<td style="color: var(--color-text-muted); max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
{{ r.notes ?? '—' }}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" @click="startEdit(r)">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="rules.length === 0">
|
||||
<td colspan="7" class="empty-state">No rules on record.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit modal -->
|
||||
<div v-if="showAdd || editing" class="modal-backdrop" @click.self="closeModal">
|
||||
<div class="modal card" style="width: 480px;">
|
||||
<h2 style="margin-bottom: var(--spacing-md); font-size: 16px;">
|
||||
{{ editing ? `Edit r/${editing.sub}` : 'Add Sub / Channel' }}
|
||||
</h2>
|
||||
<div v-if="!editing" class="form-group">
|
||||
<label class="form-label">Subreddit / channel name</label>
|
||||
<input class="form-input" v-model="form.sub" placeholder="selfhosted" />
|
||||
</div>
|
||||
<div v-if="!editing" class="form-group">
|
||||
<label class="form-label">Platform</label>
|
||||
<select class="form-select" v-model="form.platform">
|
||||
<option value="reddit">Reddit</option>
|
||||
<option value="facebook">Facebook</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="lemmy">Lemmy</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Flair required?</label>
|
||||
<select class="form-select" v-model="form.flair_required">
|
||||
<option :value="false">No</option>
|
||||
<option :value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="form.flair_required" class="form-group">
|
||||
<label class="form-label">Flair to use</label>
|
||||
<input class="form-input" v-model="form.flair_to_use" placeholder="Action / DIY / Activism" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Promo allowed?</label>
|
||||
<select class="form-select" v-model="form.promo_allowed">
|
||||
<option :value="null">Unknown</option>
|
||||
<option :value="true">Yes</option>
|
||||
<option :value="false">Hard ban — skip</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Shows rule-warning dialog?</label>
|
||||
<select class="form-select" v-model="form.rule_warning">
|
||||
<option :value="false">No</option>
|
||||
<option :value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea class="form-textarea" v-model="form.notes" rows="2" placeholder="Any posting quirks..." />
|
||||
</div>
|
||||
<div style="display: flex; gap: var(--spacing-sm); justify-content: flex-end;">
|
||||
<button class="btn btn-ghost" @click="closeModal">Cancel</button>
|
||||
<button class="btn btn-primary" @click="save" :disabled="!editing && !form.sub">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { api, type SubRules } from '@/services/api'
|
||||
|
||||
const rules = ref<SubRules[]>([])
|
||||
const showAdd = ref(false)
|
||||
const editing = ref<SubRules | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
sub: '',
|
||||
platform: 'reddit',
|
||||
flair_required: false,
|
||||
flair_to_use: '',
|
||||
promo_allowed: null as boolean | null,
|
||||
rule_warning: false,
|
||||
notes: '',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
rules.value = await api.subs.listRules()
|
||||
})
|
||||
|
||||
function startEdit(r: SubRules) {
|
||||
editing.value = r
|
||||
Object.assign(form, {
|
||||
sub: r.sub,
|
||||
platform: r.platform,
|
||||
flair_required: !!r.flair_required,
|
||||
flair_to_use: r.flair_to_use ?? '',
|
||||
promo_allowed: r.promo_allowed === null ? null : !!r.promo_allowed,
|
||||
rule_warning: !!r.rule_warning,
|
||||
notes: r.notes ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showAdd.value = false
|
||||
editing.value = null
|
||||
Object.assign(form, { sub: '', platform: 'reddit', flair_required: false, flair_to_use: '', promo_allowed: null, rule_warning: false, notes: '' })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const sub = editing.value ? editing.value.sub : form.sub
|
||||
const platform = editing.value ? editing.value.platform : form.platform
|
||||
const updated = await api.subs.upsertRules(sub, {
|
||||
flair_required: form.flair_required,
|
||||
flair_to_use: form.flair_to_use || null,
|
||||
promo_allowed: form.promo_allowed,
|
||||
rule_warning: form.rule_warning,
|
||||
notes: form.notes || null,
|
||||
}, platform)
|
||||
const idx = rules.value.findIndex(r => r.sub === sub && r.platform === platform)
|
||||
if (idx !== -1) {
|
||||
rules.value = [...rules.value.slice(0, idx), updated, ...rules.value.slice(idx + 1)]
|
||||
} else {
|
||||
rules.value = [...rules.value, updated]
|
||||
}
|
||||
closeModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { padding: var(--spacing-lg); }
|
||||
</style>
|
||||
25
frontend/src/main.ts
Normal file
25
frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import './theme.css'
|
||||
|
||||
import CampaignList from './components/CampaignList.vue'
|
||||
import CampaignDetail from './components/CampaignDetail.vue'
|
||||
import SubRulesView from './components/SubRulesView.vue'
|
||||
import PostsView from './components/PostsView.vue'
|
||||
import OpportunitiesView from './components/OpportunitiesView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/opportunities' },
|
||||
{ path: '/opportunities', component: OpportunitiesView },
|
||||
{ path: '/campaigns', component: CampaignList },
|
||||
{ path: '/campaigns/:id', component: CampaignDetail },
|
||||
{ path: '/subs', component: SubRulesView },
|
||||
{ path: '/posts', component: PostsView },
|
||||
],
|
||||
})
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
236
frontend/src/services/api.ts
Normal file
236
frontend/src/services/api.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import axios from 'axios'
|
||||
|
||||
const http = axios.create({ baseURL: '/api/v1' })
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Types
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
export interface Campaign {
|
||||
id: number
|
||||
name: string
|
||||
product: string
|
||||
platform: string
|
||||
cron_schedule: string | null
|
||||
active: number
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CampaignCreate {
|
||||
name: string
|
||||
product: string
|
||||
platform?: string
|
||||
cron_schedule?: string | null
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export interface CampaignUpdate {
|
||||
name?: string
|
||||
product?: string
|
||||
cron_schedule?: string | null
|
||||
active?: boolean
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export interface Variant {
|
||||
id: number
|
||||
campaign_id: number
|
||||
sub_pattern: string
|
||||
title: string
|
||||
body: string
|
||||
flair: string | null
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface VariantCreate {
|
||||
sub_pattern?: string
|
||||
title: string
|
||||
body: string
|
||||
flair?: string | null
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export interface CampaignSub {
|
||||
id: number
|
||||
campaign_id: number
|
||||
sub: string
|
||||
sort_order: number
|
||||
active: number
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: number
|
||||
campaign_id: number
|
||||
variant_id: number | null
|
||||
platform: string
|
||||
target: string
|
||||
status: string
|
||||
url: string | null
|
||||
error_msg: string | null
|
||||
screenshot_path: string | null
|
||||
triggered_by: string
|
||||
posted_at: string
|
||||
}
|
||||
|
||||
export interface SubRules {
|
||||
id: number
|
||||
platform: string
|
||||
sub: string
|
||||
flair_required: number
|
||||
flair_to_use: string | null
|
||||
promo_allowed: number | null
|
||||
rule_warning: number
|
||||
notes: string | null
|
||||
last_checked: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SubRulesUpsert {
|
||||
flair_required?: boolean
|
||||
flair_to_use?: string | null
|
||||
promo_allowed?: boolean | null
|
||||
rule_warning?: boolean
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export type OpportunityStatus =
|
||||
| 'pending_review'
|
||||
| 'approved'
|
||||
| 'posted'
|
||||
| 'manual_posted'
|
||||
| 'dismissed'
|
||||
|
||||
export type PostType = 'reply_to_thread' | 'new_post'
|
||||
|
||||
export interface Opportunity {
|
||||
id: number
|
||||
platform: string
|
||||
community: string
|
||||
thread_url: string
|
||||
thread_title: string | null
|
||||
thread_body: string | null
|
||||
signal_reason: string | null
|
||||
product: string | null
|
||||
draft_title: string | null
|
||||
draft_body: string
|
||||
post_type: PostType
|
||||
status: OpportunityStatus
|
||||
campaign_id: number | null
|
||||
dismiss_note: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface OpportunityCreate {
|
||||
platform?: string
|
||||
community: string
|
||||
thread_url: string
|
||||
thread_title?: string | null
|
||||
thread_body?: string | null
|
||||
signal_reason?: string | null
|
||||
product?: string | null
|
||||
draft_title?: string | null
|
||||
draft_body?: string
|
||||
post_type?: PostType
|
||||
campaign_id?: number | null
|
||||
}
|
||||
|
||||
export interface ApproveResult {
|
||||
type: 'auto_post_ready' | 'manual_handoff'
|
||||
opportunity: Opportunity
|
||||
draft_body?: string
|
||||
thread_url?: string
|
||||
instructions: string
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Campaigns
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
export const api = {
|
||||
campaigns: {
|
||||
list: (activeOnly = false) =>
|
||||
http.get<Campaign[]>('/campaigns', { params: { active_only: activeOnly } }).then(r => r.data),
|
||||
|
||||
create: (data: CampaignCreate) =>
|
||||
http.post<Campaign>('/campaigns', data).then(r => r.data),
|
||||
|
||||
get: (id: number) =>
|
||||
http.get<Campaign>(`/campaigns/${id}`).then(r => r.data),
|
||||
|
||||
update: (id: number, data: CampaignUpdate) =>
|
||||
http.patch<Campaign>(`/campaigns/${id}`, data).then(r => r.data),
|
||||
|
||||
delete: (id: number) =>
|
||||
http.delete(`/campaigns/${id}`),
|
||||
|
||||
trigger: (id: number) =>
|
||||
http.post<{ campaign_id: number; results: unknown[] }>(`/campaigns/${id}/trigger`).then(r => r.data),
|
||||
},
|
||||
|
||||
variants: {
|
||||
list: (campaignId: number) =>
|
||||
http.get<Variant[]>(`/campaigns/${campaignId}/variants`).then(r => r.data),
|
||||
|
||||
create: (campaignId: number, data: VariantCreate) =>
|
||||
http.post<Variant>(`/campaigns/${campaignId}/variants`, data).then(r => r.data),
|
||||
|
||||
update: (campaignId: number, variantId: number, data: Partial<VariantCreate>) =>
|
||||
http.patch<Variant>(`/campaigns/${campaignId}/variants/${variantId}`, data).then(r => r.data),
|
||||
|
||||
delete: (campaignId: number, variantId: number) =>
|
||||
http.delete(`/campaigns/${campaignId}/variants/${variantId}`),
|
||||
},
|
||||
|
||||
subs: {
|
||||
listForCampaign: (campaignId: number) =>
|
||||
http.get<CampaignSub[]>(`/campaigns/${campaignId}/subs`).then(r => r.data),
|
||||
|
||||
add: (campaignId: number, sub: string, sortOrder = 0) =>
|
||||
http.post<CampaignSub>(`/campaigns/${campaignId}/subs`, { sub, sort_order: sortOrder }).then(r => r.data),
|
||||
|
||||
remove: (campaignId: number, sub: string) =>
|
||||
http.delete(`/campaigns/${campaignId}/subs/${sub}`),
|
||||
|
||||
listRules: (platform = 'reddit') =>
|
||||
http.get<SubRules[]>('/subs', { params: { platform } }).then(r => r.data),
|
||||
|
||||
upsertRules: (sub: string, data: SubRulesUpsert, platform = 'reddit') =>
|
||||
http.put<SubRules>(`/subs/${sub}`, data, { params: { platform } }).then(r => r.data),
|
||||
},
|
||||
|
||||
posts: {
|
||||
list: (campaignId?: number, target?: string, limit = 50) =>
|
||||
http.get<Post[]>('/posts', { params: { campaign_id: campaignId, target, limit } }).then(r => r.data),
|
||||
|
||||
triggerSingle: (campaignId: number, sub: string) =>
|
||||
http.post<Post>('/posts/trigger', { campaign_id: campaignId, sub }).then(r => r.data),
|
||||
},
|
||||
|
||||
opportunities: {
|
||||
list: (status?: OpportunityStatus) =>
|
||||
http.get<Opportunity[]>('/opportunities', { params: status ? { status } : {} }).then(r => r.data),
|
||||
|
||||
create: (data: OpportunityCreate) =>
|
||||
http.post<Opportunity>('/opportunities', data).then(r => r.data),
|
||||
|
||||
get: (id: number) =>
|
||||
http.get<Opportunity>(`/opportunities/${id}`).then(r => r.data),
|
||||
|
||||
update: (id: number, data: Partial<Pick<Opportunity, 'draft_title' | 'draft_body' | 'signal_reason' | 'product' | 'status' | 'campaign_id'>>) =>
|
||||
http.patch<Opportunity>(`/opportunities/${id}`, data).then(r => r.data),
|
||||
|
||||
approve: (id: number) =>
|
||||
http.post<ApproveResult>(`/opportunities/${id}/approve`).then(r => r.data),
|
||||
|
||||
markPosted: (id: number, manual = false) =>
|
||||
http.post<Opportunity>(`/opportunities/${id}/mark-posted`, null, { params: { manual } }).then(r => r.data),
|
||||
|
||||
dismiss: (id: number, note?: string) =>
|
||||
http.post<Opportunity>(`/opportunities/${id}/dismiss`, { note: note ?? null }).then(r => r.data),
|
||||
},
|
||||
}
|
||||
61
frontend/src/stores/campaigns.ts
Normal file
61
frontend/src/stores/campaigns.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api, type Campaign, type Variant, type CampaignSub, type Post } from '@/services/api'
|
||||
|
||||
export const useCampaignStore = defineStore('campaigns', () => {
|
||||
const campaigns = ref<Campaign[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchCampaigns(activeOnly = false) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
campaigns.value = await api.campaigns.list(activeOnly)
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load campaigns'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createCampaign(data: Parameters<typeof api.campaigns.create>[0]) {
|
||||
const created = await api.campaigns.create(data)
|
||||
campaigns.value.push(created)
|
||||
return created
|
||||
}
|
||||
|
||||
async function updateCampaign(id: number, data: Parameters<typeof api.campaigns.update>[1]) {
|
||||
const updated = await api.campaigns.update(id, data)
|
||||
const idx = campaigns.value.findIndex(c => c.id === id)
|
||||
if (idx !== -1) campaigns.value = [...campaigns.value.slice(0, idx), updated, ...campaigns.value.slice(idx + 1)]
|
||||
return updated
|
||||
}
|
||||
|
||||
async function deleteCampaign(id: number) {
|
||||
await api.campaigns.delete(id)
|
||||
campaigns.value = campaigns.value.filter(c => c.id !== id)
|
||||
}
|
||||
|
||||
async function triggerCampaign(id: number) {
|
||||
return api.campaigns.trigger(id)
|
||||
}
|
||||
|
||||
return { campaigns, loading, error, fetchCampaigns, createCampaign, updateCampaign, deleteCampaign, triggerCampaign }
|
||||
})
|
||||
|
||||
export const usePostStore = defineStore('posts', () => {
|
||||
const posts = ref<Post[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchPosts(campaignId?: number, target?: string, limit = 50) {
|
||||
loading.value = true
|
||||
try {
|
||||
posts.value = await api.posts.list(campaignId, target, limit)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { posts, loading, fetchPosts }
|
||||
})
|
||||
130
frontend/src/theme.css
Normal file
130
frontend/src/theme.css
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Magpie — Central Theme
|
||||
* Theme-aware, responsive CSS classes. All components use these.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--color-bg: #0f1117;
|
||||
--color-bg-secondary: #1a1d27;
|
||||
--color-bg-card: #1e2130;
|
||||
--color-border: #2d3148;
|
||||
--color-text: #e2e8f0;
|
||||
--color-text-muted: #8892a4;
|
||||
--color-primary: #7c6af7;
|
||||
--color-primary-dim: #3d3578;
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-danger: #f87171;
|
||||
--color-info: #60a5fa;
|
||||
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 40px;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--color-bg: #f8f9fc;
|
||||
--color-bg-secondary: #ffffff;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-border: #dde1ec;
|
||||
--color-text: #1a1d27;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-primary: #5b4fcf;
|
||||
--color-primary-dim: #ede9ff;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- Layout ---- */
|
||||
.app-shell { display: flex; min-height: 100vh; }
|
||||
.sidebar { width: 220px; background: var(--color-bg-secondary); border-right: 1px solid var(--color-border); padding: var(--spacing-lg) var(--spacing-md); flex-shrink: 0; }
|
||||
.main-content { flex: 1; padding: var(--spacing-lg); overflow-y: auto; }
|
||||
|
||||
/* ---- Nav ---- */
|
||||
.nav-brand { font-size: 18px; font-weight: 700; color: var(--color-primary); margin-bottom: var(--spacing-lg); display: flex; align-items: center; gap: var(--spacing-sm); }
|
||||
.nav-link { display: block; padding: var(--spacing-sm) var(--spacing-md); border-radius: var(--radius-md); color: var(--color-text-muted); text-decoration: none; margin-bottom: 2px; transition: background 0.15s, color 0.15s; }
|
||||
.nav-link:hover, .nav-link.active { background: var(--color-primary-dim); color: var(--color-primary); }
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.card { background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--spacing-md); }
|
||||
.card-title { font-size: 13px; font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: var(--spacing-md); }
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn { display: inline-flex; align-items: center; gap: var(--spacing-xs); padding: 6px var(--spacing-md); border-radius: var(--radius-md); border: none; cursor: pointer; font-size: 13px; font-weight: 500; transition: opacity 0.15s; }
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--color-primary); color: #fff; }
|
||||
.btn-ghost { background: transparent; color: var(--color-text-muted); border: 1px solid var(--color-border); }
|
||||
.btn-danger { background: var(--color-danger); color: #fff; }
|
||||
.btn-success { background: var(--color-success); color: #111; }
|
||||
.btn-sm { padding: 4px var(--spacing-sm); font-size: 12px; }
|
||||
|
||||
/* ---- Badges ---- */
|
||||
.badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
|
||||
.badge-success { background: color-mix(in srgb, var(--color-success) 20%, transparent); color: var(--color-success); }
|
||||
.badge-warning { background: color-mix(in srgb, var(--color-warning) 20%, transparent); color: var(--color-warning); }
|
||||
.badge-danger { background: color-mix(in srgb, var(--color-danger) 20%, transparent); color: var(--color-danger); }
|
||||
.badge-info { background: color-mix(in srgb, var(--color-info) 20%, transparent); color: var(--color-info); }
|
||||
.badge-muted { background: color-mix(in srgb, var(--color-text-muted) 15%, transparent); color: var(--color-text-muted); }
|
||||
|
||||
/* ---- Forms ---- */
|
||||
.form-label { display: block; font-size: 12px; font-weight: 500; color: var(--color-text-muted); margin-bottom: var(--spacing-xs); }
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%; padding: 8px var(--spacing-sm); background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border); border-radius: var(--radius-md);
|
||||
color: var(--color-text); font-size: 13px; font-family: inherit;
|
||||
}
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
outline: none; border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
.form-textarea { resize: vertical; min-height: 100px; font-family: var(--font-mono); font-size: 12px; }
|
||||
.form-group { margin-bottom: var(--spacing-md); }
|
||||
|
||||
/* ---- Table ---- */
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.table th { text-align: left; padding: var(--spacing-sm) var(--spacing-md); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); border-bottom: 1px solid var(--color-border); }
|
||||
.table td { padding: var(--spacing-sm) var(--spacing-md); border-bottom: 1px solid var(--color-border); }
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table tr:hover td { background: color-mix(in srgb, var(--color-primary) 5%, transparent); }
|
||||
|
||||
/* ---- Status dots ---- */
|
||||
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||
.status-dot.success { background: var(--color-success); }
|
||||
.status-dot.failed { background: var(--color-danger); }
|
||||
.status-dot.pending { background: var(--color-warning); }
|
||||
.status-dot.skipped { background: var(--color-text-muted); }
|
||||
.status-dot.running { background: var(--color-info); animation: pulse 1s infinite; }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
/* ---- Page header ---- */
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--spacing-lg); }
|
||||
.page-title { font-size: 20px; font-weight: 700; }
|
||||
|
||||
/* ---- Empty state ---- */
|
||||
.empty-state { text-align: center; padding: var(--spacing-xl); color: var(--color-text-muted); }
|
||||
|
||||
/* ---- SR only ---- */
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; }
|
||||
|
||||
:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; border-radius: var(--radius-sm); }
|
||||
9
frontend/tsconfig.json
Normal file
9
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"]
|
||||
}
|
||||
23
frontend/vite.config.ts
Normal file
23
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: process.env.VITE_BASE_URL ?? '/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 8531,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8532',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
74
manage.sh
Executable file
74
manage.sh
Executable file
|
|
@ -0,0 +1,74 @@
|
|||
#!/usr/bin/env bash
|
||||
# Magpie management script
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$REPO_DIR"
|
||||
|
||||
API_PORT=8532
|
||||
WEB_PORT=8531
|
||||
COMPOSE="docker compose"
|
||||
|
||||
cmd="${1:-help}"
|
||||
|
||||
case "$cmd" in
|
||||
start)
|
||||
echo "Starting Magpie (API :$API_PORT, Web :$WEB_PORT)..."
|
||||
$COMPOSE up -d
|
||||
;;
|
||||
stop)
|
||||
$COMPOSE stop
|
||||
;;
|
||||
restart)
|
||||
$COMPOSE restart
|
||||
;;
|
||||
status)
|
||||
$COMPOSE ps
|
||||
;;
|
||||
logs)
|
||||
service="${2:-}"
|
||||
if [[ -n "$service" ]]; then
|
||||
$COMPOSE logs -f "$service"
|
||||
else
|
||||
$COMPOSE logs -f
|
||||
fi
|
||||
;;
|
||||
build)
|
||||
$COMPOSE build
|
||||
;;
|
||||
open)
|
||||
xdg-open "http://localhost:$WEB_PORT" 2>/dev/null || open "http://localhost:$WEB_PORT"
|
||||
;;
|
||||
dev-api)
|
||||
echo "Starting API in dev mode (host network, port $API_PORT)..."
|
||||
conda run -n cf uvicorn app.main:app --host 0.0.0.0 --port $API_PORT --reload
|
||||
;;
|
||||
dev-web)
|
||||
echo "Starting frontend dev server (port $WEB_PORT)..."
|
||||
cd frontend && npm run dev
|
||||
;;
|
||||
login)
|
||||
echo "Refreshing Reddit session..."
|
||||
conda run -n cf xvfb-run --auto-servernum python -m app.services.reddit.post --login
|
||||
;;
|
||||
migrate)
|
||||
echo "Running DB migrations..."
|
||||
conda run -n cf python -c "from app.db.store import Store; from app.core.config import get_settings; s=Store(get_settings().db_path); s.run_migrations(); s.close(); print('Done.')"
|
||||
;;
|
||||
help|*)
|
||||
echo "Usage: ./manage.sh <command>"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " start Start all services via Docker Compose"
|
||||
echo " stop Stop all services"
|
||||
echo " restart Restart all services"
|
||||
echo " status Show service status"
|
||||
echo " logs [svc] Tail logs (optionally for one service)"
|
||||
echo " build Build Docker images"
|
||||
echo " open Open browser to dashboard"
|
||||
echo " dev-api Run API in dev mode (conda, hot-reload)"
|
||||
echo " dev-web Run frontend dev server"
|
||||
echo " login Refresh Reddit Playwright session"
|
||||
echo " migrate Run DB migrations standalone"
|
||||
;;
|
||||
esac
|
||||
13
mcp/package.json
Normal file
13
mcp/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "magpie-mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP stdio server for Magpie campaign management",
|
||||
"main": "server.js",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
499
mcp/server.js
Normal file
499
mcp/server.js
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Magpie MCP Server — JSON-RPC 2.0 over stdio
|
||||
*
|
||||
* Thin bridge between Claude and the Magpie FastAPI backend.
|
||||
* All heavy logic lives in the API; this server just translates
|
||||
* tool calls to HTTP requests.
|
||||
*
|
||||
* Env:
|
||||
* MAGPIE_API_URL - Magpie API base (default: http://localhost:8532)
|
||||
*/
|
||||
|
||||
const MAGPIE_URL = (process.env.MAGPIE_API_URL || 'http://localhost:8532').replace(/\/$/, '');
|
||||
const BASE = `${MAGPIE_URL}/api/v1`;
|
||||
|
||||
process.stderr.write(`[magpie-mcp] starting — API: ${MAGPIE_URL}\n`);
|
||||
|
||||
// ─── HTTP helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function api(method, path, body) {
|
||||
const url = `${BASE}${path}`;
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(url, opts);
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${path}: ${text}`);
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
// ─── Tool definitions ─────────────────────────────────────────────────────────
|
||||
|
||||
const TOOLS = [
|
||||
// Campaigns
|
||||
{
|
||||
name: 'list_campaigns',
|
||||
description: 'List all Magpie campaigns. Optionally filter to active only.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
active_only: { type: 'boolean', description: 'If true, only return active campaigns', default: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_campaign',
|
||||
description: 'Get a single campaign by ID, including its variants and subs.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID' },
|
||||
},
|
||||
required: ['campaign_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_campaign',
|
||||
description: 'Create a new campaign. Returns the created campaign record.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Human-readable campaign name' },
|
||||
product: { type: 'string', description: 'Product code (e.g. kiwi, peregrine, snipe, circuitforge)' },
|
||||
platform: { type: 'string', description: 'Platform (default: reddit)', default: 'reddit' },
|
||||
cron_schedule: { type: 'string', description: 'Cron expression for auto-scheduling (e.g. "0 9 * * 2"). Leave blank for manual-only.' },
|
||||
notes: { type: 'string', description: 'Internal notes about this campaign' },
|
||||
},
|
||||
required: ['name', 'product'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_campaign',
|
||||
description: 'Update a campaign (name, cron_schedule, active state, notes). Only provided fields are updated.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID' },
|
||||
name: { type: 'string' },
|
||||
cron_schedule: { type: 'string', description: 'New cron expression, or null to clear' },
|
||||
active: { type: 'boolean', description: 'true = active, false = paused' },
|
||||
notes: { type: 'string' },
|
||||
},
|
||||
required: ['campaign_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'trigger_campaign',
|
||||
description: 'Manually trigger a campaign to post to all its configured subreddits immediately. Returns per-sub results.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID to fire' },
|
||||
},
|
||||
required: ['campaign_id'],
|
||||
},
|
||||
},
|
||||
|
||||
// Variants
|
||||
{
|
||||
name: 'list_variants',
|
||||
description: 'List content variants for a campaign. Each variant has a sub_pattern (exact sub, prefix*, or * for default).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID' },
|
||||
},
|
||||
required: ['campaign_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_variant',
|
||||
description: 'Add a content variant to a campaign. Use sub_pattern="*" for the default, an exact sub name for a sub-specific variant, or "prefix*" for a prefix match (e.g. "nd_*" matches nd_audhd, nd_adhd, etc.).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID' },
|
||||
sub_pattern: { type: 'string', description: 'Sub pattern: "*" = default, exact name, or "prefix*"', default: '*' },
|
||||
title: { type: 'string', description: 'Post title' },
|
||||
body: { type: 'string', description: 'Post body (Reddit markdown)' },
|
||||
flair: { type: 'string', description: 'Flair label required by the subreddit (optional)' },
|
||||
notes: { type: 'string', description: 'Internal framing notes' },
|
||||
},
|
||||
required: ['campaign_id', 'title', 'body'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete_variant',
|
||||
description: 'Delete a content variant by ID.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID the variant belongs to' },
|
||||
variant_id: { type: 'integer', description: 'Variant ID to delete' },
|
||||
},
|
||||
required: ['campaign_id', 'variant_id'],
|
||||
},
|
||||
},
|
||||
|
||||
// Subs
|
||||
{
|
||||
name: 'list_campaign_subs',
|
||||
description: 'List the subreddits configured for a campaign.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID' },
|
||||
},
|
||||
required: ['campaign_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add_campaign_sub',
|
||||
description: 'Add a subreddit to a campaign target list.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID' },
|
||||
sub: { type: 'string', description: 'Subreddit name without r/ prefix' },
|
||||
sort_order: { type: 'integer', description: 'Posting order (lower = first, default 0)', default: 0 },
|
||||
},
|
||||
required: ['campaign_id', 'sub'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'remove_campaign_sub',
|
||||
description: 'Remove a subreddit from a campaign target list.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID' },
|
||||
sub: { type: 'string', description: 'Subreddit name to remove' },
|
||||
},
|
||||
required: ['campaign_id', 'sub'],
|
||||
},
|
||||
},
|
||||
|
||||
// Posts
|
||||
{
|
||||
name: 'list_posts',
|
||||
description: 'List post history. Filter by campaign or subreddit.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
campaign_id: { type: 'integer', description: 'Filter by campaign ID (optional)' },
|
||||
target: { type: 'string', description: 'Filter by subreddit name (optional)' },
|
||||
limit: { type: 'integer', description: 'Max results (default 50)', default: 50 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'trigger_sub_post',
|
||||
description: 'Manually trigger a post to a single subreddit for a specific campaign.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID' },
|
||||
sub: { type: 'string', description: 'Subreddit to post to (without r/)' },
|
||||
},
|
||||
required: ['campaign_id', 'sub'],
|
||||
},
|
||||
},
|
||||
|
||||
// Sub rules
|
||||
{
|
||||
name: 'get_sub_rules',
|
||||
description: 'Get the stored rules and posting metadata for a specific subreddit.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sub: { type: 'string', description: 'Subreddit name without r/ prefix' },
|
||||
},
|
||||
required: ['sub'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'upsert_sub_rules',
|
||||
description: 'Create or update posting rules for a subreddit (flair_required, promo_allowed, rule_warning).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sub: { type: 'string', description: 'Subreddit name without r/ prefix' },
|
||||
flair_required: { type: 'boolean', description: 'Does this sub require flair to post?' },
|
||||
flair_to_use: { type: 'string', description: 'Default flair label for this sub' },
|
||||
promo_allowed: { type: 'boolean', description: 'true = allowed, false = banned, omit = unknown' },
|
||||
rule_warning: { type: 'boolean', description: 'Does the sub show a rule-warning dialog on post?' },
|
||||
notes: { type: 'string', description: 'Posting notes for this sub' },
|
||||
},
|
||||
required: ['sub'],
|
||||
},
|
||||
},
|
||||
|
||||
// Opportunities
|
||||
{
|
||||
name: 'list_opportunities',
|
||||
description: 'List signal-detected opportunities for manual review. Filter by status: pending_review, approved, posted, manual_posted, dismissed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Filter by status (omit for all)',
|
||||
enum: ['pending_review', 'approved', 'posted', 'manual_posted', 'dismissed'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_opportunity',
|
||||
description: 'Record a new posting opportunity for review. Use this when you spot a thread that would benefit from a Magpie campaign reply.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
community: { type: 'string', description: 'Subreddit, Lemmy community, or other community handle (e.g. "nd_adhd", "lemmy.world/c/memes")' },
|
||||
thread_url: { type: 'string', description: 'Full URL to the thread' },
|
||||
thread_title: { type: 'string', description: 'Thread title for context' },
|
||||
thread_body: { type: 'string', description: 'Thread body text for context (optional)' },
|
||||
platform: { type: 'string', description: 'Platform: reddit, lemmy, linkedin, etc. (default: reddit)', default: 'reddit' },
|
||||
signal_reason: { type: 'string', description: 'Why this thread is a good opportunity (1-2 sentences)' },
|
||||
product: { type: 'string', description: 'Product this is relevant to (e.g. peregrine, kiwi, snipe)' },
|
||||
draft_title: { type: 'string', description: 'Draft post title (for new_post type)' },
|
||||
draft_body: { type: 'string', description: 'Draft reply or post body (Reddit/Lemmy markdown)' },
|
||||
post_type: { type: 'string', description: 'reply_to_thread or new_post', enum: ['reply_to_thread', 'new_post'], default: 'reply_to_thread' },
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID to associate (optional)' },
|
||||
},
|
||||
required: ['community', 'thread_url'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'approve_opportunity',
|
||||
description: 'Approve an opportunity for posting. Returns auto_post_ready (Reddit) or manual_handoff (other platforms) with instructions.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
opportunity_id: { type: 'integer', description: 'Opportunity ID to approve' },
|
||||
},
|
||||
required: ['opportunity_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dismiss_opportunity',
|
||||
description: 'Dismiss an opportunity (not worth posting). Optionally provide a reason.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
opportunity_id: { type: 'integer', description: 'Opportunity ID to dismiss' },
|
||||
note: { type: 'string', description: 'Reason for dismissal (optional)' },
|
||||
},
|
||||
required: ['opportunity_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_opportunity',
|
||||
description: 'Edit the draft body, draft title, signal reason, product, or campaign link on an opportunity.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
opportunity_id: { type: 'integer', description: 'Opportunity ID' },
|
||||
draft_title: { type: 'string', description: 'Updated draft title' },
|
||||
draft_body: { type: 'string', description: 'Updated draft body' },
|
||||
signal_reason: { type: 'string', description: 'Updated signal reason' },
|
||||
product: { type: 'string', description: 'Updated product association' },
|
||||
campaign_id: { type: 'integer', description: 'Campaign ID to associate' },
|
||||
},
|
||||
required: ['opportunity_id'],
|
||||
},
|
||||
},
|
||||
|
||||
// Scheduler
|
||||
{
|
||||
name: 'scheduler_status',
|
||||
description: 'Check the scheduler status and see next scheduled run times for all campaigns.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Dispatch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function callTool(name, args) {
|
||||
switch (name) {
|
||||
case 'list_campaigns': {
|
||||
const qs = args.active_only ? '?active_only=true' : '';
|
||||
return await api('GET', `/campaigns${qs}`);
|
||||
}
|
||||
case 'get_campaign': {
|
||||
const [campaign, variants, subs] = await Promise.all([
|
||||
api('GET', `/campaigns/${args.campaign_id}`),
|
||||
api('GET', `/campaigns/${args.campaign_id}/variants`),
|
||||
api('GET', `/campaigns/${args.campaign_id}/subs`),
|
||||
]);
|
||||
return { ...campaign, variants, subs };
|
||||
}
|
||||
case 'create_campaign': {
|
||||
const body = { name: args.name, product: args.product, platform: args.platform || 'reddit' };
|
||||
if (args.cron_schedule) body.cron_schedule = args.cron_schedule;
|
||||
if (args.notes) body.notes = args.notes;
|
||||
return await api('POST', '/campaigns', body);
|
||||
}
|
||||
case 'update_campaign': {
|
||||
const { campaign_id, ...fields } = args;
|
||||
return await api('PATCH', `/campaigns/${campaign_id}`, fields);
|
||||
}
|
||||
case 'trigger_campaign':
|
||||
return await api('POST', `/campaigns/${args.campaign_id}/trigger`);
|
||||
|
||||
case 'list_variants':
|
||||
return await api('GET', `/campaigns/${args.campaign_id}/variants`);
|
||||
case 'create_variant': {
|
||||
const { campaign_id, ...body } = args;
|
||||
return await api('POST', `/campaigns/${campaign_id}/variants`, body);
|
||||
}
|
||||
case 'delete_variant':
|
||||
return await api('DELETE', `/campaigns/${args.campaign_id}/variants/${args.variant_id}`);
|
||||
|
||||
case 'list_campaign_subs':
|
||||
return await api('GET', `/campaigns/${args.campaign_id}/subs`);
|
||||
case 'add_campaign_sub':
|
||||
return await api('POST', `/campaigns/${args.campaign_id}/subs`, {
|
||||
sub: args.sub,
|
||||
sort_order: args.sort_order || 0,
|
||||
});
|
||||
case 'remove_campaign_sub':
|
||||
return await api('DELETE', `/campaigns/${args.campaign_id}/subs/${args.sub}`);
|
||||
|
||||
case 'list_posts': {
|
||||
const params = new URLSearchParams();
|
||||
if (args.campaign_id) params.set('campaign_id', args.campaign_id);
|
||||
if (args.target) params.set('target', args.target);
|
||||
if (args.limit) params.set('limit', args.limit);
|
||||
const qs = params.toString() ? `?${params}` : '';
|
||||
return await api('GET', `/posts${qs}`);
|
||||
}
|
||||
case 'trigger_sub_post':
|
||||
return await api('POST', '/posts/trigger', { campaign_id: args.campaign_id, sub: args.sub });
|
||||
|
||||
case 'get_sub_rules':
|
||||
return await api('GET', `/subs/${args.sub}`);
|
||||
case 'upsert_sub_rules': {
|
||||
const { sub, ...body } = args;
|
||||
return await api('PUT', `/subs/${sub}`, body);
|
||||
}
|
||||
|
||||
case 'scheduler_status':
|
||||
return await api('GET', '/scheduler/status');
|
||||
|
||||
case 'list_opportunities': {
|
||||
const qs = args.status ? `?status=${encodeURIComponent(args.status)}` : '';
|
||||
return await api('GET', `/opportunities${qs}`);
|
||||
}
|
||||
case 'create_opportunity': {
|
||||
const body = {
|
||||
community: args.community,
|
||||
thread_url: args.thread_url,
|
||||
platform: args.platform || 'reddit',
|
||||
draft_body: args.draft_body || '',
|
||||
post_type: args.post_type || 'reply_to_thread',
|
||||
};
|
||||
if (args.thread_title) body.thread_title = args.thread_title;
|
||||
if (args.thread_body) body.thread_body = args.thread_body;
|
||||
if (args.signal_reason) body.signal_reason = args.signal_reason;
|
||||
if (args.product) body.product = args.product;
|
||||
if (args.draft_title) body.draft_title = args.draft_title;
|
||||
if (args.campaign_id) body.campaign_id = args.campaign_id;
|
||||
return await api('POST', '/opportunities', body);
|
||||
}
|
||||
case 'approve_opportunity':
|
||||
return await api('POST', `/opportunities/${args.opportunity_id}/approve`);
|
||||
case 'dismiss_opportunity':
|
||||
return await api('POST', `/opportunities/${args.opportunity_id}/dismiss`, { note: args.note || null });
|
||||
case 'update_opportunity': {
|
||||
const { opportunity_id, ...fields } = args;
|
||||
return await api('PATCH', `/opportunities/${opportunity_id}`, fields);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JSON-RPC 2.0 protocol ────────────────────────────────────────────────────
|
||||
|
||||
function send(obj) {
|
||||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
|
||||
function sendResult(id, result) {
|
||||
send({ jsonrpc: '2.0', id, result });
|
||||
}
|
||||
|
||||
function sendError(id, code, message) {
|
||||
send({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
}
|
||||
|
||||
async function handleMessage(msg) {
|
||||
const { id, method, params } = msg;
|
||||
|
||||
if (method === 'initialize') {
|
||||
sendResult(id, {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'magpie-mcp', version: '0.1.0' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'notifications/initialized') return;
|
||||
|
||||
if (method === 'tools/list') {
|
||||
sendResult(id, { tools: TOOLS });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'tools/call') {
|
||||
const { name, arguments: args = {} } = params || {};
|
||||
try {
|
||||
const result = await callTool(name, args);
|
||||
sendResult(id, {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`[magpie-mcp] tool error: ${err.message}\n`);
|
||||
sendResult(id, {
|
||||
content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (id !== undefined) sendError(id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
|
||||
// ─── Stdin line reader ────────────────────────────────────────────────────────
|
||||
|
||||
let buffer = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let msg;
|
||||
try { msg = JSON.parse(trimmed); }
|
||||
catch (e) { process.stderr.write(`[magpie-mcp] parse error: ${e.message}\n`); continue; }
|
||||
handleMessage(msg).catch(err => {
|
||||
process.stderr.write(`[magpie-mcp] unhandled error: ${err.message}\n`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
process.stderr.write('[magpie-mcp] stdin closed, exiting\n');
|
||||
process.exit(0);
|
||||
});
|
||||
26
pyproject.toml
Normal file
26
pyproject.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "magpie"
|
||||
version = "0.1.0"
|
||||
description = "CircuitForge cross-product social media management and data gathering"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.111.0",
|
||||
"uvicorn[standard]>=0.29.0",
|
||||
"httpx>=0.27.0",
|
||||
"pydantic>=2.7.0",
|
||||
"pydantic-settings>=2.2.0",
|
||||
"apscheduler>=3.10.0",
|
||||
"playwright>=1.44.0",
|
||||
"circuitforge-core",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx"]
|
||||
367
scripts/seed_campaigns.py
Normal file
367
scripts/seed_campaigns.py
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed Magpie DB with campaigns migrated from the legacy claude-bridge/reddit-poster scripts.
|
||||
|
||||
Idempotent: checks by name before inserting, so safe to re-run.
|
||||
|
||||
Usage:
|
||||
conda run -n cf python scripts/seed_campaigns.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.store import Store
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Campaign data — migrated from claude-bridge/reddit-poster/campaigns/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CAMPAIGNS = [
|
||||
{
|
||||
"campaign": {
|
||||
"name": "ND/AuDHD — CircuitForge intro",
|
||||
"product": "circuitforge",
|
||||
"platform": "reddit",
|
||||
"cron_schedule": "0 16 * * 1", # Monday 16:00 UTC
|
||||
"notes": "Executive-function framing. Covers Peregrine + Kiwi. r/ADHD and r/autism hard-banned; AuDHD OK.",
|
||||
},
|
||||
"subs": ["AuDHD"],
|
||||
"variants": [
|
||||
{
|
||||
"sub_pattern": "*",
|
||||
"title": "I built two tools specifically for the 'I know I need to do this but I cannot make myself start' problem",
|
||||
"body": """\
|
||||
I'm AuDHD and I kept running into the same wall: tasks that are not hard, they are just opaque and unstructured enough that my brain refuses to start them.
|
||||
|
||||
Job applications are the worst example. There is no clear next step, every posting is different, the feedback loop is invisible, and the rejection is silent. I would spend three hours staring at a job listing and close the tab.
|
||||
|
||||
I built **[Peregrine](https://demo.circuitforge.tech/peregrine)** to handle the mechanical parts. It finds listings, scores them against your resume, rewrites your resume bullets to pass ATS filters for that specific posting, and drafts a cover letter. You review everything before it goes anywhere. You still make the decisions, it just removes the blank-page paralysis.
|
||||
|
||||
The same problem shows up in the kitchen. I have ingredients, I know I should cook something, and I open the fridge and close it twelve times. **[Kiwi](https://menagerie.circuitforge.tech/kiwi)** is a pantry tracker that suggests recipes from what you actually have, tracks shelf life so you do not have to remember, and includes a meal planner with prep day scheduling. No guilt-trip UX, no "you should be eating better" energy, just a calm list of options.
|
||||
|
||||
Both are self-hostable via Docker. Both are free tier first. Neither sends your data anywhere without explicit opt-in.
|
||||
|
||||
Peregrine: [Demo](https://demo.circuitforge.tech/peregrine) | [Repo](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine)
|
||||
|
||||
Kiwi: [Demo](https://menagerie.circuitforge.tech/kiwi) | [Repo](https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi)
|
||||
|
||||
Happy to answer questions about either. I know these communities get a lot of "I built an app!" posts so I will keep it brief and let the tools speak for themselves.""",
|
||||
"flair": None,
|
||||
"notes": "Works for r/AuDHD. r/ADHD rejected this framing (Rule 8). Do not reuse on r/ADHD.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"campaign": {
|
||||
"name": "Mission — Solarpunk / Opensource",
|
||||
"product": "circuitforge",
|
||||
"platform": "reddit",
|
||||
"cron_schedule": "0 16 * * 1", # Monday 16:00 UTC (same window, different subs)
|
||||
"notes": "Privacy-first, anti-VC, open-core framing. Both subs need flair.",
|
||||
},
|
||||
"subs": ["solarpunk", "opensource"],
|
||||
"variants": [
|
||||
{
|
||||
"sub_pattern": "solarpunk",
|
||||
"title": "CircuitForge: open source pipelines for the tasks systems made hard on purpose",
|
||||
"body": """\
|
||||
I have been building tools under the CircuitForge name for the past year and wanted to introduce what we are doing here.
|
||||
|
||||
The premise: there is a category of task that is not actually hard, but that systems have made deliberately opaque, time-consuming, and exhausting. Job applications designed to filter by endurance. Government forms written to confuse. Auction platforms that reward automation over buyers. Pantry management that requires a subscription to your own grocery data.
|
||||
|
||||
These systems disproportionately harm people who are already under-resourced: neurodivergent folks, people without lawyers, people who do not have three hours to spend on a benefits form.
|
||||
|
||||
CircuitForge builds deterministic automation pipelines for those tasks. An LLM might draft a cover letter or flag a sketchy listing. The pipeline handles the structured work. You review and approve everything. Nothing acts without you in the loop.
|
||||
|
||||
**Privacy first, self-hostable, open core.**
|
||||
|
||||
No VC money. No growth KPIs. No plan to sell user data. The free tier is real.
|
||||
|
||||
Open-core licensing: the shared infrastructure library and all discovery/scraping pipelines are MIT. The AI assist layers (cover letter generation, recipe engine) and the VRAM orchestration coordinator are BSL 1.1 — free for personal non-commercial self-hosting, commercial SaaS re-hosting requires a license, converts to MIT after four years. Everything is on Forgejo.
|
||||
|
||||
**What is live now:**
|
||||
|
||||
- **Peregrine** — job search pipeline, ATS resume rewriting, cover letter drafting ([demo](https://demo.circuitforge.tech/peregrine))
|
||||
- **Kiwi** — pantry tracker, meal planning, leftover recipe suggestions ([demo](https://menagerie.circuitforge.tech/kiwi))
|
||||
- **Snipe** — eBay listing trust scoring before you bid ([demo](https://menagerie.circuitforge.tech/snipe))
|
||||
|
||||
More in the pipeline for government forms, insurance disputes, and accommodation requests.
|
||||
|
||||
[circuitforge.tech](https://circuitforge.tech) | [Forgejo org](https://git.opensourcesolarpunk.com/Circuit-Forge)""",
|
||||
"flair": "Action / DIY / Activism",
|
||||
"notes": "r/solarpunk flair required. Coordinate-click the flair Add button (shadow DOM).",
|
||||
},
|
||||
{
|
||||
"sub_pattern": "opensource",
|
||||
"title": "CircuitForge: open source pipelines for the tasks systems made hard on purpose",
|
||||
"body": """\
|
||||
I have been building tools under the CircuitForge name for the past year and wanted to introduce what we are doing here.
|
||||
|
||||
The premise: there is a category of task that is not actually hard, but that systems have made deliberately opaque, time-consuming, and exhausting. Job applications designed to filter by endurance. Government forms written to confuse. Auction platforms that reward automation over buyers. Pantry management that requires a subscription to your own grocery data.
|
||||
|
||||
These systems disproportionately harm people who are already under-resourced: neurodivergent folks, people without lawyers, people who do not have three hours to spend on a benefits form.
|
||||
|
||||
CircuitForge builds deterministic automation pipelines for those tasks. An LLM might draft a cover letter or flag a sketchy listing. The pipeline handles the structured work. You review and approve everything. Nothing acts without you in the loop.
|
||||
|
||||
**Privacy first, self-hostable, open core.**
|
||||
|
||||
No VC money. No growth KPIs. No plan to sell user data. The free tier is real.
|
||||
|
||||
Open-core licensing: the shared infrastructure library and all discovery/scraping pipelines are MIT. The AI assist layers (cover letter generation, recipe engine) and the VRAM orchestration coordinator are BSL 1.1 — free for personal non-commercial self-hosting, commercial SaaS re-hosting requires a license, converts to MIT after four years. Everything is on Forgejo.
|
||||
|
||||
**What is live now:**
|
||||
|
||||
- **Peregrine** — job search pipeline, ATS resume rewriting, cover letter drafting ([demo](https://demo.circuitforge.tech/peregrine))
|
||||
- **Kiwi** — pantry tracker, meal planning, leftover recipe suggestions ([demo](https://menagerie.circuitforge.tech/kiwi))
|
||||
- **Snipe** — eBay listing trust scoring before you bid ([demo](https://menagerie.circuitforge.tech/snipe))
|
||||
|
||||
More in the pipeline for government forms, insurance disputes, and accommodation requests.
|
||||
|
||||
[circuitforge.tech](https://circuitforge.tech) | [Forgejo org](https://git.opensourcesolarpunk.com/Circuit-Forge)""",
|
||||
"flair": "Promotional",
|
||||
"notes": "r/opensource shows rule-warning dialog after Post click. Use wait_for(visible) + submit without editing.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"campaign": {
|
||||
"name": "Privacy Stack — privacytoolsIO",
|
||||
"product": "circuitforge",
|
||||
"platform": "reddit",
|
||||
"cron_schedule": "0 16 * * 3", # Wednesday 16:00 UTC
|
||||
"notes": "Data-ownership framing. Resume data, pantry data, eBay behaviour — all local.",
|
||||
},
|
||||
"subs": ["privacytoolsIO"],
|
||||
"variants": [
|
||||
{
|
||||
"sub_pattern": "*",
|
||||
"title": "Self-hosted tools for the tasks that leak the most data: job search, pantry tracking, eBay buying",
|
||||
"body": """\
|
||||
Job applications go to LinkedIn, Indeed, and a dozen ATS platforms. Every one of them builds a profile on you. Your resume, your salary expectations, your rejection rate.
|
||||
|
||||
Grocery and pantry apps want to know what you eat, how often, and what you run out of. That is valuable data. They charge you a subscription for the privilege of giving it to them.
|
||||
|
||||
eBay and similar platforms know every item you searched, every bid you considered, every seller you trusted.
|
||||
|
||||
I built three tools that handle these tasks locally, with no account required for self-hosting:
|
||||
|
||||
**Peregrine** | job search pipeline. Your resume, application history, cover letters, and career profile in one place you control, not scattered across LinkedIn, Indeed, Greenhouse, and every other ATS you have applied through. Runs on Ollama for fully local inference.
|
||||
|
||||
**Kiwi** | pantry tracker and meal planner. Barcode and receipt scanning, shelf life tracking, recipe suggestions from what you have. No subscription, no cloud sync, no food behaviour profile.
|
||||
|
||||
**Snipe** | eBay trust scorer. Scores listings for red flags before you bid. Runs fully local. Has an MCP server if you want to wire it into an AI assistant.
|
||||
|
||||
All three run via Docker. All three are free tier first. Local LLM or BYOK (bring your own API key).
|
||||
|
||||
[circuitforge.tech](https://circuitforge.tech) | [Forgejo org](https://git.opensourcesolarpunk.com/Circuit-Forge)""",
|
||||
"flair": None,
|
||||
"notes": "r/privacytoolsIO allows self-promotion with context. No flair required.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"campaign": {
|
||||
"name": "Linux/Self-hosted Stack — linuxmasterrace",
|
||||
"product": "circuitforge",
|
||||
"platform": "reddit",
|
||||
"cron_schedule": "0 16 * * 4", # Thursday 16:00 UTC
|
||||
"notes": "Docker + Ollama + open-core framing. Technical audience — include stack details.",
|
||||
},
|
||||
"subs": ["linuxmasterrace"],
|
||||
"variants": [
|
||||
{
|
||||
"sub_pattern": "*",
|
||||
"title": "Three self-hosted Docker tools for job searching, pantry tracking, and eBay trust scoring — all run on Ollama",
|
||||
"body": """\
|
||||
I've been building a suite of tools that run locally via Docker and use Ollama for inference. No cloud required, no subscription, no data leaving your machine unless you choose it.
|
||||
|
||||
**Peregrine** | job search pipeline. Discovers listings, scores them against your resume, rewrites resume bullets for ATS filters, drafts cover letters. Your resume and application history stay in a local SQLite DB. Ollama-first, BYOK fallback.
|
||||
|
||||
**Kiwi** | pantry tracker and meal planner. Barcode and receipt scanning, shelf life tracking, recipe suggestions from what you actually have. Meal planner with prep day scheduling. Local by default, no food behaviour profile being built on you.
|
||||
|
||||
**Snipe** | eBay trust scorer. Scores listings for red flags before you bid. Fully local. Has an MCP server for wiring into AI assistants or automation pipelines.
|
||||
|
||||
Stack: FastAPI + Vue 3 + SQLite + Docker Compose. Ollama for local LLM. Playwright for scraping where APIs don't exist.
|
||||
|
||||
All three are open core: discovery and pipeline layers are MIT, AI assist features are BSL 1.1 (free for personal self-hosting).
|
||||
|
||||
[circuitforge.tech](https://circuitforge.tech) | [Forgejo](https://git.opensourcesolarpunk.com/Circuit-Forge)
|
||||
|
||||
Happy to answer questions about the stack or self-hosting setup.""",
|
||||
"flair": None,
|
||||
"notes": "r/linuxmasterrace allows self-promotion. No flair. Technical framing expected.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"campaign": {
|
||||
"name": "Weekly Sprint Review — selfhosted",
|
||||
"product": "circuitforge",
|
||||
"platform": "reddit",
|
||||
"cron_schedule": None, # Manual — content changes every week
|
||||
"notes": "Regenerated weekly. Use the sprint_review skill to draft content. Post manually or via trigger.",
|
||||
},
|
||||
"subs": ["selfhosted"],
|
||||
"variants": [], # No static variant — content is generated fresh each sprint
|
||||
},
|
||||
{
|
||||
"campaign": {
|
||||
"name": "Peregrine Launch — Apr 21 2026 (one-shot)",
|
||||
"product": "peregrine",
|
||||
"platform": "reddit",
|
||||
"cron_schedule": None, # One-shot, already fired
|
||||
"notes": "Archived one-shot campaign. selfhosted + audhd variants fired. adhd variant killed (Rule 8).",
|
||||
},
|
||||
"subs": ["selfhosted", "AuDHD"],
|
||||
"variants": [
|
||||
{
|
||||
"sub_pattern": "selfhosted",
|
||||
"title": "What shipped this week across my self-hosted AI stack: job search, pantry tracking, eBay sniping, and shared local inference infra",
|
||||
"body": """\
|
||||
Weekly update on CircuitForge — a collection of self-hosted tools for tasks the system made hard on purpose. Everything runs locally by default, local LLM or BYOK (bring your own key).
|
||||
|
||||
---
|
||||
|
||||
**Peregrine (job search)**
|
||||
- Resume library to profile sync: pick a resume, review a before/after diff, push to active profile
|
||||
- References tracker with recommendation letter draft generation
|
||||
- CI added via GitHub Actions
|
||||
|
||||
**Kiwi (pantry + recipes)**
|
||||
- Secondary-use hints: the recipe engine now suggests dishes for near-expired ingredients.
|
||||
- Hierarchical subcategory navigation
|
||||
- "Can make now" pantry match toggle, complexity badges, Surprise Me picker
|
||||
- Barcode miss fallback: tries Open Beauty Facts and Open Products Facts
|
||||
- Meal planner slot editor, meal type picker, current-week auto-select
|
||||
|
||||
**Snipe (eBay trust scoring)**
|
||||
- Search with AI: describe what you want, Snipe builds the eBay query using a local LLM
|
||||
- Community blocklist opt-in for reported sellers
|
||||
- Listing detail page: trust score ring, signal breakdown, seller history panel
|
||||
|
||||
**circuitforge-core (MIT)**
|
||||
- Community signal module shipped
|
||||
|
||||
**Website**
|
||||
- Self-hosted Plausible analytics
|
||||
- PayPal added alongside Stripe
|
||||
|
||||
All repos: https://git.opensourcesolarpunk.com/Circuit-Forge
|
||||
Live demos: https://menagerie.circuitforge.tech
|
||||
|
||||
What does your self-hosted productivity stack look like? Always curious what people are running.""",
|
||||
"flair": None,
|
||||
"notes": "Filed 2026-04-21. Sprint review format — regenerate each week.",
|
||||
},
|
||||
{
|
||||
"sub_pattern": "AuDHD",
|
||||
"title": "Job searching with AuDHD is a particular kind of nightmare. Built something for it.",
|
||||
"body": """\
|
||||
The specific AuDHD job search loop, as I understand it from building this and watching people I care about go through it:
|
||||
|
||||
You need structure to function. But the ADHD part means the structure you build collapses the moment life gets noisy. So you're perpetually rebuilding from scratch. The job search version of this is brutal because the stakes are real, the timeline feels urgent, and every interaction costs masking energy you may not have to spare.
|
||||
|
||||
Meanwhile you're trying to track 30 applications, write responses that sound "appropriately enthusiastic but professional," remember what you told each recruiter two weeks ago, and somehow prepare for an interview with 48 hours notice — while also holding down your current job or managing everything else.
|
||||
|
||||
I built Peregrine as a pipeline that holds the structure so you don't have to maintain it yourself. You review and approve rather than building from zero every day.
|
||||
|
||||
**What it does:**
|
||||
- Scrapes job boards automatically — everything in one place, no tab archaeology
|
||||
- Scores listings against your resume so the list is already filtered
|
||||
- Writes cover letters as a starting draft. One edit instead of starting from a blank page.
|
||||
- Tracks every application: applied > phone screen > interview > offer > hired.
|
||||
- Recruiter emails attach to the right job automatically
|
||||
- When you move to phone screen, a company research brief generates automatically.
|
||||
- References tracker: log your references and generate draft request emails when needed
|
||||
- Recommendation letter drafts built in
|
||||
|
||||
Everything runs locally via Docker. Your resume and application data stay on your machine. Free tier with a local AI model or bring your own API key.
|
||||
|
||||
Demo: https://menagerie.circuitforge.tech/peregrine
|
||||
|
||||
What part of the process drains you most? I'm building specifically for this community and want to know where to focus.""",
|
||||
"flair": None,
|
||||
"notes": "Soft-launch post Apr 23 2026. Follow up with engagement if thread is active.",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub rules — verified manually
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SUB_RULES = [
|
||||
{"sub": "selfhosted", "promo_allowed": True, "flair_required": False, "rule_warning": False, "notes": "Promo OK with context. Engages well with self-hosted + local inference framing."},
|
||||
{"sub": "solarpunk", "promo_allowed": True, "flair_required": True, "flair_to_use": "Action / DIY / Activism", "rule_warning": False, "notes": "Flair required. Use coordinate-click for Add button (shadow DOM)."},
|
||||
{"sub": "opensource", "promo_allowed": True, "flair_required": True, "flair_to_use": "Promotional", "rule_warning": True, "notes": "Rule-warning dialog appears after Post click. wait_for(visible) + Submit without editing."},
|
||||
{"sub": "AuDHD", "promo_allowed": True, "flair_required": False, "rule_warning": False, "notes": "No hard promo ban. Personally-relevant content qualifies."},
|
||||
{"sub": "privacytoolsIO", "promo_allowed": True, "flair_required": False, "rule_warning": False, "notes": "Self-promo OK with privacy context. No flair needed."},
|
||||
{"sub": "linuxmasterrace", "promo_allowed": True, "flair_required": False, "rule_warning": False, "notes": "Technical framing expected. Docker/Ollama angle works well."},
|
||||
{"sub": "ADHD", "promo_allowed": False, "flair_required": False, "rule_warning": False, "notes": "HARD BAN. Rule 8 explicitly prohibits 'I made this' posts. Do not post here."},
|
||||
{"sub": "autism", "promo_allowed": False, "flair_required": False, "rule_warning": False, "notes": "HARD BAN. Rule 9 bans promotion/self-promotion. Do not post here."},
|
||||
{"sub": "mealprep", "promo_allowed": False, "flair_required": False, "rule_warning": False, "notes": "HARD BAN. No promotional content allowed."},
|
||||
{"sub": "ZeroWaste", "promo_allowed": False, "flair_required": False, "rule_warning": False, "notes": "HARD BAN. No promotional posts."},
|
||||
{"sub": "jobs", "promo_allowed": False, "flair_required": False, "rule_warning": False, "notes": "HARD BAN. Job postings only, no tools/products."},
|
||||
{"sub": "eBay", "promo_allowed": False, "flair_required": False, "rule_warning": False, "notes": "HARD BAN. No tool promotion."},
|
||||
{"sub": "Flipping", "promo_allowed": False, "flair_required": False, "rule_warning": False, "notes": "Sunday-only community thread for sharing. Direct posts not allowed on other days."},
|
||||
{"sub": "cscareerquestions","promo_allowed": False, "flair_required": False, "rule_warning": False, "notes": "First-Sunday-of-month megathread only. Direct posts not allowed."},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seeder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def seed(store: Store) -> None:
|
||||
existing_names = {c["name"] for c in store.list_campaigns()}
|
||||
|
||||
for entry in CAMPAIGNS:
|
||||
camp_data = entry["campaign"]
|
||||
name = camp_data["name"]
|
||||
|
||||
if name in existing_names:
|
||||
print(f" [skip] campaign already exists: {name!r}")
|
||||
continue
|
||||
|
||||
camp = store.create_campaign(**camp_data)
|
||||
cid = camp["id"]
|
||||
print(f" [+] campaign {cid}: {name!r}")
|
||||
|
||||
for i, sub in enumerate(entry["subs"]):
|
||||
store.add_campaign_sub(cid, sub, sort_order=i)
|
||||
print(f" sub: r/{sub}")
|
||||
|
||||
for v in entry["variants"]:
|
||||
store.create_variant(campaign_id=cid, **v)
|
||||
print(f" variant: {v['sub_pattern']!r}")
|
||||
|
||||
print()
|
||||
print("Seeding sub rules...")
|
||||
for rule in SUB_RULES:
|
||||
sub = rule.pop("sub")
|
||||
store.upsert_sub_rules(sub=sub, last_checked="2026-04-21", **rule)
|
||||
allowed = "OK" if rule.get("promo_allowed") else "BANNED"
|
||||
print(f" r/{sub}: {allowed}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
settings = get_settings()
|
||||
store = Store(settings.db_path)
|
||||
store.run_migrations()
|
||||
print(f"DB: {settings.db_path}")
|
||||
print()
|
||||
seed(store)
|
||||
store.close()
|
||||
print()
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue