feat: opportunities UI improvements, MCP tools, session refresh, migrations 013-014

This commit is contained in:
pyr0ball 2026-04-27 07:49:34 -07:00
parent add5475d50
commit c7c57fe4e5
17 changed files with 486 additions and 41 deletions

View file

@ -57,6 +57,10 @@ class DismissBody(BaseModel):
note: str | None = None note: str | None = None
class MarkPostedBody(BaseModel):
url: str | None = None
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Routes # Routes
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@ -129,14 +133,29 @@ async def approve_opportunity(opportunity_id: int):
@router.post("/{opportunity_id}/mark-posted") @router.post("/{opportunity_id}/mark-posted")
async def mark_posted(opportunity_id: int, manual: bool = False): async def mark_posted(opportunity_id: int, body: MarkPostedBody = MarkPostedBody(), manual: bool = False):
"""Record that a post was successfully made (auto or manual).""" """Record that a post was successfully made (auto or manual).
When manual=True, also writes a row to the posts table for history tracking."""
opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
if opp is None:
raise HTTPException(404, "Opportunity not found")
status = "manual_posted" if manual else "posted" status = "manual_posted" if manual else "posted"
result = await asyncio.to_thread( result = await asyncio.to_thread(
_in_thread, lambda s: s.update_opportunity(opportunity_id, status=status) _in_thread, lambda s: s.update_opportunity(opportunity_id, status=status)
) )
if result is None:
raise HTTPException(404, "Opportunity not found") if manual:
await asyncio.to_thread(
_in_thread,
lambda s: s.log_manual_post(
opportunity_id=opportunity_id,
platform=opp["platform"],
target=opp["community"],
url=body.url,
),
)
return result return result

View file

@ -0,0 +1,93 @@
"""Reddit session management endpoints."""
from __future__ import annotations
import subprocess
import sys
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.services.reddit.session import ensure_valid_session, refresh_session, session_is_valid
router = APIRouter(tags=["reddit"])
BRIDGE_SESSION = Path("/Library/Development/CircuitForge/claude-bridge/reddit-poster/session.json")
BRIDGE_POST_SCRIPT = Path("/Library/Development/CircuitForge/claude-bridge/reddit-poster/post.py")
class SessionStatusResponse(BaseModel):
target: str
valid: bool
age_hours: float | None
session_file: str
class RefreshResponse(BaseModel):
target: str
ok: bool
message: str
def _session_age_hours(path: Path) -> float | None:
if not path.exists():
return None
return round((time.time() - path.stat().st_mtime) / 3600, 1)
@router.get("/reddit/session-status")
def session_status(target: str = "magpie") -> SessionStatusResponse:
"""Return session validity and age for magpie or bridge session."""
if target == "bridge":
valid = BRIDGE_SESSION.exists() and _session_age_hours(BRIDGE_SESSION) < 12
return SessionStatusResponse(
target="bridge",
valid=valid,
age_hours=_session_age_hours(BRIDGE_SESSION),
session_file=str(BRIDGE_SESSION),
)
# magpie session
from app.core.config import get_settings
session_file = Path(get_settings().reddit_session_file)
return SessionStatusResponse(
target="magpie",
valid=session_is_valid(session_file),
age_hours=_session_age_hours(session_file),
session_file=str(session_file),
)
@router.post("/reddit/refresh-session")
def refresh_reddit_session(target: str = "magpie") -> RefreshResponse:
"""
Re-establish the Playwright Reddit session.
target: "magpie" (default) refreshes Magpie's session.
"bridge" refreshes claude-bridge/reddit-poster/session.json.
"""
if target == "bridge":
if not BRIDGE_POST_SCRIPT.exists():
raise HTTPException(status_code=404, detail="claude-bridge post.py not found")
result = subprocess.run(
["xvfb-run", "--auto-servernum", sys.executable, str(BRIDGE_POST_SCRIPT), "--login"],
cwd=str(BRIDGE_POST_SCRIPT.parent),
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"Bridge session refresh failed: {result.stderr.strip()}",
)
return RefreshResponse(target="bridge", ok=True, message="Bridge session refreshed.")
# magpie session
try:
from app.core.config import get_settings
session_file = Path(get_settings().reddit_session_file)
refresh_session(session_file)
return RefreshResponse(target="magpie", ok=True, message="Magpie session refreshed.")
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))

View file

@ -25,6 +25,7 @@ class SubRulesUpsert(BaseModel):
promo_allowed: bool | None = None # None = unknown promo_allowed: bool | None = None # None = unknown
rule_warning: bool = False rule_warning: bool = False
notes: str | None = None notes: str | None = None
post_url: str | None = None # override link for Copy & Post (e.g. megathread)
@router.get("") @router.get("")

View file

@ -1,6 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from app.api.endpoints import blog, campaigns, opportunities, posts, scheduler, signals, subs from app.api.endpoints import blog, campaigns, opportunities, posts, reddit, scheduler, signals, subs
def register_routes(app: FastAPI) -> None: def register_routes(app: FastAPI) -> None:
@ -11,3 +11,4 @@ def register_routes(app: FastAPI) -> None:
app.include_router(opportunities.router, prefix="/api/v1") app.include_router(opportunities.router, prefix="/api/v1")
app.include_router(signals.router, prefix="/api/v1") app.include_router(signals.router, prefix="/api/v1")
app.include_router(blog.router, prefix="/api/v1") app.include_router(blog.router, prefix="/api/v1")
app.include_router(reddit.router, prefix="/api/v1")

View file

@ -0,0 +1,4 @@
-- Add optional post_url to sub_rules.
-- When set, the Copy & Post modal links here instead of /r/{sub}/submit.
-- Use for megathreads, weekly threads, or any pinned destination.
ALTER TABLE sub_rules ADD COLUMN post_url TEXT;

View file

@ -0,0 +1,4 @@
-- Link posts to opportunities for manual/signal-driven posts.
-- NULL = campaign-scheduled post (existing behaviour).
ALTER TABLE posts ADD COLUMN opportunity_id INTEGER REFERENCES opportunities(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_posts_opportunity ON posts(opportunity_id);

View file

@ -213,10 +213,19 @@ class Store:
) )
def create_post(self, campaign_id: int, target: str, variant_id: int | None = None, def create_post(self, campaign_id: int, target: str, variant_id: int | None = None,
platform: str = "reddit", triggered_by: str = "scheduler") -> dict: platform: str = "reddit", triggered_by: str = "scheduler",
opportunity_id: int | None = None) -> dict:
return self._insert_returning( return self._insert_returning(
"INSERT INTO posts (campaign_id, variant_id, platform, target, status, triggered_by) VALUES (?,?,?,?,'pending',?) RETURNING *", "INSERT INTO posts (campaign_id, variant_id, platform, target, status, triggered_by, opportunity_id) VALUES (?,?,?,?,'pending',?,?) RETURNING *",
(campaign_id, variant_id, platform, target, triggered_by), (campaign_id, variant_id, platform, target, triggered_by, opportunity_id),
)
def log_manual_post(self, opportunity_id: int, platform: str, target: str,
url: str | None = None) -> dict:
"""Create a success post record for a manually executed opportunity post."""
return self._insert_returning(
"INSERT INTO posts (campaign_id, opportunity_id, platform, target, status, triggered_by, url) VALUES (NULL,?,?,?,'success','manual',?) RETURNING *",
(opportunity_id, platform, target, url),
) )
def update_post_status(self, post_id: int, status: str, url: str | None = None, def update_post_status(self, post_id: int, status: str, url: str | None = None,

View file

@ -24,9 +24,9 @@ from dotenv import load_dotenv
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
from playwright_stealth import Stealth from playwright_stealth import Stealth
# Load .env from project root (two levels up from this file) # Load .env from project root (magpie repo root, 3 levels up from this file)
_HERE = Path(__file__).parent _HERE = Path(__file__).parent
_PROJECT_ROOT = _HERE.parents[3] _PROJECT_ROOT = _HERE.parents[2] # reddit/ → services/ → app/ → magpie/
load_dotenv(_PROJECT_ROOT / ".env") load_dotenv(_PROJECT_ROOT / ".env")
REDDIT_USERNAME = os.getenv("REDDIT_USERNAME", "") REDDIT_USERNAME = os.getenv("REDDIT_USERNAME", "")
@ -34,7 +34,10 @@ REDDIT_PASSWORD = os.getenv("REDDIT_PASSWORD", "")
CHROME_BIN = os.getenv("CHROME_BIN", "/usr/bin/google-chrome") 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 from env (so the service layer can pass it via env var)
SESSION_FILE = Path(os.getenv("REDDIT_SESSION_FILE", str(_HERE / "session.json"))) SESSION_FILE = Path(os.getenv(
"REDDIT_SESSION_FILE",
str(Path.home() / ".local" / "share" / "magpie" / "session.json"),
))
LOGIN_URL = "https://www.reddit.com/login" LOGIN_URL = "https://www.reddit.com/login"
SUBMIT_URL = "https://www.reddit.com/r/{sub}/submit?type=text" SUBMIT_URL = "https://www.reddit.com/r/{sub}/submit?type=text"
@ -187,13 +190,24 @@ def post(sub: str, title: str, body: str, flair: str | None = None, yes: bool =
time.sleep(0.5) time.sleep(0.5)
# Fill body (Lexical editor — click to focus, then type) # Fill body — Reddit shows either a markdown textarea or a Lexical
# rich-text contenteditable depending on user/sub settings.
# Try markdown textarea first (visible in screenshot), fall back to Lexical.
try: try:
body_el = page.locator('div[contenteditable="true"]').first md_textarea = page.locator('textarea[placeholder="Body text*"]')
body_el.wait_for(state="visible", timeout=10_000) if md_textarea.count() > 0:
body_el.click() md_textarea.first.wait_for(state="visible", timeout=5_000)
time.sleep(0.3) md_textarea.first.click()
page.keyboard.type(body, delay=2) time.sleep(0.3)
md_textarea.first.fill(body)
print(" Body filled via markdown textarea")
else:
body_el = page.locator('div[contenteditable="true"]').first
body_el.wait_for(state="visible", timeout=10_000)
body_el.click()
time.sleep(0.3)
page.keyboard.type(body, delay=2)
print(" Body filled via rich text editor")
except Exception as exc: except Exception as exc:
print(f" Warning: body fill failed ({exc})") print(f" Warning: body fill failed ({exc})")
@ -210,11 +224,26 @@ def post(sub: str, title: str, body: str, flair: str | None = None, yes: bool =
except Exception as exc: except Exception as exc:
print(f" Warning: flair selection failed ({exc})") print(f" Warning: flair selection failed ({exc})")
# Submit # Submit — try multiple selector strategies; Reddit's form markup varies
try: try:
submit_btn = page.locator('button[type="submit"]').filter(has_text="Post") # Scroll to bottom so button is in viewport
submit_btn.wait_for(state="visible", timeout=10_000) page.keyboard.press("End")
submit_btn.click() time.sleep(0.3)
for selector in [
'button[type="submit"]',
'button:has-text("Post")',
'[slot="submit-button"] button',
'button.bg-interactive-onbackground',
]:
btn = page.locator(selector).last
if btn.count() > 0:
btn.scroll_into_view_if_needed()
btn.wait_for(state="visible", timeout=5_000)
btn.click()
print(f" Clicked submit via {selector!r}")
break
else:
print(" Warning: no submit button found with any selector")
except Exception as exc: except Exception as exc:
print(f" Warning: submit button click failed ({exc})") print(f" Warning: submit button click failed ({exc})")

View file

@ -43,7 +43,13 @@
</div> </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);"> <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> <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 style="margin-left: auto; display: flex; gap: 4px;">
<button class="btn btn-ghost btn-sm" @click="openCopyModal(s.sub)" title="Copy & open Reddit">Copy & Post</button>
<button class="btn btn-primary btn-sm" @click="triggerSub(s.sub)" :disabled="triggeringSub === s.sub" title="Auto-post via Playwright">
{{ triggeringSub === s.sub ? '...' : 'Run' }}
</button>
<button class="btn btn-ghost btn-sm" style="color: var(--color-danger);" @click="removeSub(s.sub)"></button>
</div>
</div> </div>
<div v-if="campaignSubs.length === 0" class="empty-state" style="padding: var(--spacing-md);">No subs configured.</div> <div v-if="campaignSubs.length === 0" class="empty-state" style="padding: var(--spacing-md);">No subs configured.</div>
</div> </div>
@ -109,6 +115,55 @@
</div> </div>
</div> </div>
<!-- Copy & Open modal -->
<div v-if="copyModal.sub" class="modal-backdrop" @click.self="copyModal.sub = ''">
<div class="modal card" style="width: 620px; max-height: 90vh; overflow-y: auto;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--spacing-md);">
<h2 style="font-size: 16px; margin: 0;">Post to r/{{ copyModal.sub }}</h2>
<a :href="copyModal.url" target="_blank" class="btn btn-primary btn-sm">Open Reddit </a>
</div>
<div class="form-group">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<label class="form-label" style="margin: 0;">Title</label>
<button class="btn btn-ghost btn-sm" @click="copy(copyModal.title, 'title')">{{ copied === 'title' ? '✓ Copied' : 'Copy' }}</button>
</div>
<input class="form-input" :value="copyModal.title" readonly @click="($event.target as HTMLInputElement).select()" />
</div>
<div class="form-group">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<label class="form-label" style="margin: 0;">Body</label>
<button class="btn btn-ghost btn-sm" @click="copy(copyModal.body, 'body')">{{ copied === 'body' ? '✓ Copied' : 'Copy' }}</button>
</div>
<textarea class="form-textarea" :value="copyModal.body" readonly rows="14"
style="font-family: var(--font-mono); font-size: 12px; resize: vertical;"
@click="($event.target as HTMLTextAreaElement).select()" />
</div>
<!-- Sub-specific notes (e.g. AI disclosure requirement) -->
<div v-if="copyModal.notes" class="form-group">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<label class="form-label" style="margin: 0;">Sub notes</label>
<button class="btn btn-ghost btn-sm" @click="copy(copyModal.notes, 'notes')">{{ copied === 'notes' ? '✓ Copied' : 'Copy' }}</button>
</div>
<textarea class="form-textarea" :value="copyModal.notes" readonly rows="3"
style="font-size: 12px; resize: vertical; color: var(--color-text-muted);"
@click="($event.target as HTMLTextAreaElement).select()" />
</div>
<div style="color: var(--color-text-muted); font-size: 12px; margin-bottom: var(--spacing-md);">
1. Copy title paste into Reddit title field<br>
2. Copy body paste into body<br>
3. Submit on Reddit
</div>
<div style="display: flex; justify-content: flex-end;">
<button class="btn btn-ghost" @click="copyModal.sub = ''">Close</button>
</div>
</div>
</div>
<!-- Add sub modal --> <!-- Add sub modal -->
<div v-if="showAddSub" class="modal-backdrop" @click.self="showAddSub = false"> <div v-if="showAddSub" class="modal-backdrop" @click.self="showAddSub = false">
<div class="modal card" style="width: 360px;"> <div class="modal card" style="width: 360px;">
@ -130,7 +185,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { api, type Campaign, type Variant, type CampaignSub, type Post } from '@/services/api' import { api, type Campaign, type Variant, type CampaignSub, type Post, type SubRules } from '@/services/api'
const route = useRoute() const route = useRoute()
const campaignId = Number(route.params.id) const campaignId = Number(route.params.id)
@ -139,26 +194,42 @@ const campaign = ref<Campaign | null>(null)
const variants = ref<Variant[]>([]) const variants = ref<Variant[]>([])
const campaignSubs = ref<CampaignSub[]>([]) const campaignSubs = ref<CampaignSub[]>([])
const recentPosts = ref<Post[]>([]) const recentPosts = ref<Post[]>([])
const subRulesMap = ref<Record<string, SubRules>>({})
const triggering = ref(false) const triggering = ref(false)
const triggeringSub = ref<string | null>(null)
const showAddVariant = ref(false) const showAddVariant = ref(false)
const showAddSub = ref(false) const showAddSub = ref(false)
const copyModal = reactive({ sub: '', title: '', body: '', url: '', notes: '' })
const copied = ref('')
const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '', notes: '' }) const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
const subForm = reactive({ sub: '' }) const subForm = reactive({ sub: '' })
onMounted(async () => { onMounted(async () => {
const [c, v, s, p] = await Promise.all([ const [c, v, s, p, allRules] = await Promise.all([
api.campaigns.get(campaignId), api.campaigns.get(campaignId),
api.variants.list(campaignId), api.variants.list(campaignId),
api.subs.listForCampaign(campaignId), api.subs.listForCampaign(campaignId),
api.posts.list(campaignId, undefined, 20), api.posts.list(campaignId, undefined, 20),
api.subs.listRules(),
]) ])
campaign.value = c campaign.value = c
variants.value = v variants.value = v
campaignSubs.value = s campaignSubs.value = s
recentPosts.value = p recentPosts.value = p
subRulesMap.value = Object.fromEntries(allRules.map(r => [r.sub, r]))
}) })
async function triggerSub(sub: string) {
triggeringSub.value = sub
try {
await api.posts.trigger(campaignId, sub)
recentPosts.value = await api.posts.list(campaignId, undefined, 20)
} finally {
triggeringSub.value = null
}
}
async function triggerAll() { async function triggerAll() {
triggering.value = true triggering.value = true
try { try {
@ -199,6 +270,32 @@ async function removeSub(sub: string) {
campaignSubs.value = campaignSubs.value.filter(s => s.sub !== sub) campaignSubs.value = campaignSubs.value.filter(s => s.sub !== sub)
} }
function resolveVariant(sub: string): Variant | null {
// Exact sub match first, then wildcard mirrors backend resolve_variant logic
return (
variants.value.find(v => v.sub_pattern === sub) ??
variants.value.find(v => v.sub_pattern === '*') ??
null
)
}
function openCopyModal(sub: string) {
const v = resolveVariant(sub)
const rules = subRulesMap.value[sub]
copyModal.sub = sub
copyModal.title = v?.title ?? ''
copyModal.body = v?.body ?? ''
copyModal.url = rules?.post_url ?? `https://www.reddit.com/r/${sub}/submit?type=TEXT`
copyModal.notes = rules?.notes ?? ''
copied.value = ''
}
async function copy(text: string, which: string) {
await navigator.clipboard.writeText(text)
copied.value = which
setTimeout(() => { copied.value = '' }, 2000)
}
function formatDate(iso: string) { function formatDate(iso: string) {
const d = new Date(iso + 'Z') const d = new Date(iso + 'Z')
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })

View file

@ -30,8 +30,8 @@
</router-link> </router-link>
</td> </td>
<td data-label="Product"><span class="badge badge-info">{{ c.product }}</span></td> <td data-label="Product"><span class="badge badge-info">{{ c.product }}</span></td>
<td data-label="Schedule" class="text-mono text-sm text-muted"> <td data-label="Schedule" class="text-sm text-muted" :title="c.cron_schedule ?? undefined">
{{ c.cron_schedule ?? '— manual' }} {{ humanizeCron(c.cron_schedule) }}
</td> </td>
<td data-label="Status"> <td data-label="Status">
<span :class="['badge', c.active ? 'badge-success' : 'badge-muted']"> <span :class="['badge', c.active ? 'badge-success' : 'badge-muted']">
@ -90,6 +90,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useCampaignStore } from '@/stores/campaigns' import { useCampaignStore } from '@/stores/campaigns'
import { humanizeCron } from '@/utils/cron'
const store = useCampaignStore() const store = useCampaignStore()
const showCreate = ref(false) const showCreate = ref(false)

View file

@ -115,20 +115,48 @@
<div class="handoff-actions"> <div class="handoff-actions">
<button class="btn btn-secondary" @click="copyDraft">📋 Copy draft</button> <button class="btn btn-secondary" @click="copyDraft">📋 Copy draft</button>
<a :href="selected.thread_url" target="_blank" class="btn btn-secondary">🔗 Open thread</a> <a :href="selected.thread_url" target="_blank" class="btn btn-secondary">🔗 Open thread</a>
<button class="btn btn-success" :disabled="saving" @click="markManualPosted"> <button v-if="!confirmingPosted" class="btn btn-success" @click="confirmingPosted = true">
Mark as posted Mark as posted
</button> </button>
</div> </div>
<div v-if="copied" class="copy-confirm">Copied to clipboard</div> <div v-if="copied" class="copy-confirm">Copied to clipboard</div>
<div v-if="confirmingPosted" class="post-url-confirm">
<input
v-model="postedUrl"
class="input"
placeholder="Post URL (optional — paste link to your comment/post)"
@keydown.enter="markManualPosted"
@keydown.escape="confirmingPosted = false"
/>
<div class="handoff-actions" style="margin-top: var(--spacing-xs);">
<button class="btn btn-success" :disabled="saving" @click="markManualPosted">Confirm</button>
<button class="btn btn-secondary" @click="confirmingPosted = false">Cancel</button>
</div>
</div>
</section> </section>
<!-- Auto-post panel for approved Reddit --> <!-- Auto-post panel for approved Reddit -->
<section v-if="selected.status === 'approved' && selected.platform === 'reddit'" class="handoff-panel"> <section v-if="selected.status === 'approved' && selected.platform === 'reddit'" class="handoff-panel">
<h3 class="section-label">Ready to post</h3> <h3 class="section-label">Ready to post</h3>
<p class="handoff-note">Use trigger_sub_post from the Campaigns view, or mark as posted manually if you handled it.</p> <p class="handoff-note">Use trigger_sub_post from the Campaigns view, or mark as posted manually if you handled it.</p>
<button class="btn btn-success" :disabled="saving" @click="markManualPosted"> <div v-if="!confirmingPosted">
Mark as posted <button class="btn btn-success" @click="confirmingPosted = true">
</button> Mark as posted
</button>
</div>
<div v-if="confirmingPosted" class="post-url-confirm">
<input
v-model="postedUrl"
class="input"
placeholder="Post URL (optional)"
@keydown.enter="markManualPosted"
@keydown.escape="confirmingPosted = false"
/>
<div class="handoff-actions" style="margin-top: var(--spacing-xs);">
<button class="btn btn-success" :disabled="saving" @click="markManualPosted">Confirm</button>
<button class="btn btn-secondary" @click="confirmingPosted = false">Cancel</button>
</div>
</div>
</section> </section>
</div> </div>
</div> </div>
@ -203,6 +231,8 @@ const showAddModal = ref(false)
const copied = ref(false) const copied = ref(false)
const filterStatus = ref<OpportunityStatus | ''>('') const filterStatus = ref<OpportunityStatus | ''>('')
const loadError = ref<string | null>(null) const loadError = ref<string | null>(null)
const confirmingPosted = ref(false)
const postedUrl = ref('')
const editBody = ref('') const editBody = ref('')
const editTitle = ref('') const editTitle = ref('')
@ -240,6 +270,8 @@ function select(opp: Opportunity) {
selected.value = opp selected.value = opp
editBody.value = opp.draft_body editBody.value = opp.draft_body
editTitle.value = opp.draft_title ?? '' editTitle.value = opp.draft_title ?? ''
confirmingPosted.value = false
postedUrl.value = ''
} }
watch(selected, opp => { watch(selected, opp => {
@ -293,8 +325,10 @@ async function markManualPosted() {
if (!selected.value) return if (!selected.value) return
saving.value = true saving.value = true
try { try {
const updated = await api.opportunities.markPosted(selected.value.id, true) const updated = await api.opportunities.markPosted(selected.value.id, true, postedUrl.value || null)
replace(updated) replace(updated)
confirmingPosted.value = false
postedUrl.value = ''
} finally { } finally {
saving.value = false saving.value = false
} }
@ -484,6 +518,13 @@ onMounted(load)
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);
} }
.post-url-confirm {
margin-top: var(--spacing-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
/* Add form */ /* Add form */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); }

View file

@ -55,7 +55,8 @@ onMounted(async () => {
]) ])
}) })
function campaignName(id: number) { function campaignName(id: number | null) {
if (id === null) return 'manual'
return campaignStore.campaigns.find(c => c.id === id)?.name ?? `#${id}` return campaignStore.campaigns.find(c => c.id === id)?.name ?? `#${id}`
} }

View file

@ -86,10 +86,10 @@
<span style="font-weight: 500; font-size: 13px;">{{ r.name }}</span> <span style="font-weight: 500; font-size: 13px;">{{ r.name }}</span>
<span v-if="r.sub" class="chip chip-sub" style="font-size: 10px;">{{ r.sub }}</span> <span v-if="r.sub" class="chip chip-sub" style="font-size: 10px;">{{ r.sub }}</span>
<span v-if="r.label" class="chip" :class="`chip-label-${r.label}`" style="font-size: 10px;">{{ r.label }}</span> <span v-if="r.label" class="chip" :class="`chip-label-${r.label}`" style="font-size: 10px;">{{ r.label }}</span>
<span v-if="!r.active" class="badge badge-muted" style="margin-left: auto; font-size: 10px;">paused</span> <div style="margin-left: auto; display: flex; align-items: center; gap: 4px;">
<div v-else style="margin-left: auto; display: flex; gap: 4px;"> <span v-if="!r.active" class="badge badge-muted" style="font-size: 10px;">paused</span>
<button class="btn btn-ghost btn-xs" @click="toggleRule(r)"></button> <button class="btn btn-ghost btn-xs" :title="r.active ? 'Pause' : 'Resume'" @click="toggleRule(r)">{{ r.active ? '' : '' }}</button>
<button class="btn btn-ghost btn-xs" style="color: var(--color-danger);" @click="deleteRule(r.id)"></button> <button class="btn btn-ghost btn-xs" style="color: var(--color-danger);" title="Delete" @click="deleteRule(r.id)"></button>
</div> </div>
</div> </div>
<div class="rule-keywords"> <div class="rule-keywords">

View file

@ -35,7 +35,7 @@
<span v-if="r.rule_warning" class="badge badge-warning">yes</span> <span v-if="r.rule_warning" class="badge badge-warning">yes</span>
<span v-else style="color: var(--color-text-muted);"></span> <span v-else style="color: var(--color-text-muted);"></span>
</td> </td>
<td data-label="Notes" style="color: var(--color-text-muted); max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"> <td data-label="Notes" style="color: var(--color-text-muted); max-width: 260px; white-space: normal; word-break: break-word;" :title="r.notes ?? ''">
{{ r.notes ?? '—' }} {{ r.notes ?? '—' }}
</td> </td>
<td data-label=""> <td data-label="">
@ -94,6 +94,10 @@
<option :value="true">Yes</option> <option :value="true">Yes</option>
</select> </select>
</div> </div>
<div class="form-group">
<label class="form-label">Post URL <span style="color: var(--color-text-muted)">(optional overrides /submit, e.g. megathread link)</span></label>
<input class="form-input" v-model="form.post_url" placeholder="https://www.reddit.com/r/selfhosted/comments/..." />
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Notes</label> <label class="form-label">Notes</label>
<textarea class="form-textarea" v-model="form.notes" rows="2" placeholder="Any posting quirks..." /> <textarea class="form-textarea" v-model="form.notes" rows="2" placeholder="Any posting quirks..." />
@ -122,6 +126,7 @@ const form = reactive({
flair_to_use: '', flair_to_use: '',
promo_allowed: null as boolean | null, promo_allowed: null as boolean | null,
rule_warning: false, rule_warning: false,
post_url: '',
notes: '', notes: '',
}) })
@ -138,6 +143,7 @@ function startEdit(r: SubRules) {
flair_to_use: r.flair_to_use ?? '', flair_to_use: r.flair_to_use ?? '',
promo_allowed: r.promo_allowed === null ? null : !!r.promo_allowed, promo_allowed: r.promo_allowed === null ? null : !!r.promo_allowed,
rule_warning: !!r.rule_warning, rule_warning: !!r.rule_warning,
post_url: r.post_url ?? '',
notes: r.notes ?? '', notes: r.notes ?? '',
}) })
} }
@ -145,7 +151,7 @@ function startEdit(r: SubRules) {
function closeModal() { function closeModal() {
showAdd.value = false showAdd.value = false
editing.value = null editing.value = null
Object.assign(form, { sub: '', platform: 'reddit', flair_required: false, flair_to_use: '', promo_allowed: null, rule_warning: false, notes: '' }) Object.assign(form, { sub: '', platform: 'reddit', flair_required: false, flair_to_use: '', promo_allowed: null, rule_warning: false, post_url: '', notes: '' })
} }
async function save() { async function save() {
@ -156,6 +162,7 @@ async function save() {
flair_to_use: form.flair_to_use || null, flair_to_use: form.flair_to_use || null,
promo_allowed: form.promo_allowed, promo_allowed: form.promo_allowed,
rule_warning: form.rule_warning, rule_warning: form.rule_warning,
post_url: form.post_url || null,
notes: form.notes || null, notes: form.notes || null,
}, platform) }, platform)
const idx = rules.value.findIndex(r => r.sub === sub && r.platform === platform) const idx = rules.value.findIndex(r => r.sub === sub && r.platform === platform)

View file

@ -64,7 +64,7 @@ export interface CampaignSub {
export interface Post { export interface Post {
id: number id: number
campaign_id: number campaign_id: number | null
variant_id: number | null variant_id: number | null
platform: string platform: string
target: string target: string
@ -85,6 +85,7 @@ export interface SubRules {
promo_allowed: number | null promo_allowed: number | null
rule_warning: number rule_warning: number
notes: string | null notes: string | null
post_url: string | null
last_checked: string | null last_checked: string | null
updated_at: string updated_at: string
} }
@ -95,6 +96,7 @@ export interface SubRulesUpsert {
promo_allowed?: boolean | null promo_allowed?: boolean | null
rule_warning?: boolean rule_warning?: boolean
notes?: string | null notes?: string | null
post_url?: string | null
} }
export type OpportunityStatus = export type OpportunityStatus =
@ -275,8 +277,8 @@ export const api = {
approve: (id: number) => approve: (id: number) =>
http.post<ApproveResult>(`/opportunities/${id}/approve`).then(r => r.data), http.post<ApproveResult>(`/opportunities/${id}/approve`).then(r => r.data),
markPosted: (id: number, manual = false) => markPosted: (id: number, manual = false, url?: string | null) =>
http.post<Opportunity>(`/opportunities/${id}/mark-posted`, null, { params: { manual } }).then(r => r.data), http.post<Opportunity>(`/opportunities/${id}/mark-posted`, { url: url ?? null }, { params: { manual } }).then(r => r.data),
dismiss: (id: number, note?: string) => dismiss: (id: number, note?: string) =>
http.post<Opportunity>(`/opportunities/${id}/dismiss`, { note: note ?? null }).then(r => r.data), http.post<Opportunity>(`/opportunities/${id}/dismiss`, { note: note ?? null }).then(r => r.data),

107
frontend/src/utils/cron.ts Normal file
View file

@ -0,0 +1,107 @@
/**
* Humanize a 5-field cron expression into plain English.
* Covers the patterns realistically used for social posting schedules.
* Returns the original string for anything it can't parse cleanly.
*
* Field order: minute hour dom month dow
*/
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
function formatTime(minute: string, hour: string): string {
const h = parseInt(hour)
const m = parseInt(minute)
const suffix = h < 12 ? 'AM' : 'PM'
const h12 = h % 12 === 0 ? 12 : h % 12
return m === 0 ? `${h12} ${suffix}` : `${h12}:${m.toString().padStart(2, '0')} ${suffix}`
}
function parseDow(dow: string): string {
// Single digit
if (/^\d$/.test(dow)) return DAYS[parseInt(dow)] ?? dow
// Comma list: 1,3,5
if (dow.includes(',')) {
return dow.split(',').map(d => DAYS[parseInt(d)] ?? d).join(', ')
}
// Range: 1-5
if (dow.includes('-')) {
const [start, end] = dow.split('-').map(Number)
if (start === 1 && end === 5) return 'Weekdays'
if (start === 0 && end === 6) return 'Every day'
return `${DAYS[start]}-${DAYS[end]}`
}
return dow
}
function parseMonth(month: string): string {
if (/^\d+$/.test(month)) return MONTHS[parseInt(month) - 1] ?? month
return month
}
export function humanizeCron(expr: string | null | undefined): string {
if (!expr) return 'Manual'
const parts = expr.trim().split(/\s+/)
if (parts.length !== 5) return expr
const [minute, hour, dom, month, dow] = parts
const everyMinute = minute === '*'
const everyHour = hour === '*'
const everyDom = dom === '*'
const everyMonth = month === '*'
const everyDow = dow === '*'
// Reject non-trivial step/range combos in time fields — fall back to raw
if ((minute.includes('/') || minute.includes('-')) && minute !== '*') return expr
if ((hour.includes('/') || hour.includes('-')) && hour !== '*') return expr
// Every minute
if (everyMinute && everyHour && everyDom && everyMonth && everyDow) return 'Every minute'
// Every N minutes: */N * * * *
if (minute.startsWith('*/') && everyHour && everyDom && everyMonth && everyDow) {
const n = minute.slice(2)
return `Every ${n} min`
}
// Hourly at :MM — 30 * * * *
if (!everyMinute && everyHour && everyDom && everyMonth && everyDow) {
return `Hourly at :${minute.padStart(2, '0')}`
}
// Every N hours: 0 */N * * *
if (minute === '0' && hour.startsWith('*/') && everyDom && everyMonth && everyDow) {
const n = hour.slice(2)
return `Every ${n}h`
}
// Fixed time, specific dow(s), every month
if (!everyMinute && !everyHour && everyDom && everyMonth && !everyDow) {
const time = formatTime(minute, hour)
const day = parseDow(dow)
// Multiple days (comma)
if (dow.includes(',')) return `${day} at ${time}`
if (dow === '1-5') return `Weekdays at ${time}`
return `${day}s at ${time}`
}
// Fixed time, every day
if (!everyMinute && !everyHour && everyDom && everyMonth && everyDow) {
return `Daily at ${formatTime(minute, hour)}`
}
// Fixed time, specific dom, every month, every dow
if (!everyMinute && !everyHour && !everyDom && everyMonth && everyDow) {
return `Monthly on the ${dom} at ${formatTime(minute, hour)}`
}
// Fixed time, specific month + dom
if (!everyMinute && !everyHour && !everyDom && !everyMonth && everyDow) {
return `${parseMonth(month)} ${dom} at ${formatTime(minute, hour)}`
}
// Fallback: return raw but trimmed
return expr
}

View file

@ -344,6 +344,26 @@ const TOOLS = [
description: 'Check the scheduler status and see next scheduled run times for all campaigns.', description: 'Check the scheduler status and see next scheduled run times for all campaigns.',
inputSchema: { type: 'object', properties: {} }, inputSchema: { type: 'object', properties: {} },
}, },
{
name: 'refresh_reddit_session',
description: 'Re-establish the Playwright Reddit session. Use target="bridge" to refresh the claude-bridge poster session, or "magpie" (default) for Magpie\'s own session. Takes ~30s.',
inputSchema: {
type: 'object',
properties: {
target: { type: 'string', enum: ['magpie', 'bridge'], default: 'magpie', description: 'Which session to refresh: "magpie" or "bridge" (claude-bridge/reddit-poster)' },
},
},
},
{
name: 'reddit_session_status',
description: 'Check whether the Reddit Playwright session is valid and how old it is.',
inputSchema: {
type: 'object',
properties: {
target: { type: 'string', enum: ['magpie', 'bridge'], default: 'magpie', description: 'Which session to check' },
},
},
},
]; ];
// ─── Dispatch ───────────────────────────────────────────────────────────────── // ─── Dispatch ─────────────────────────────────────────────────────────────────
@ -451,6 +471,15 @@ async function callTool(name, args) {
return await api('PATCH', `/opportunities/${opportunity_id}`, fields); return await api('PATCH', `/opportunities/${opportunity_id}`, fields);
} }
case 'refresh_reddit_session': {
const target = args.target || 'magpie';
return await api('POST', `/reddit/refresh-session?target=${target}`);
}
case 'reddit_session_status': {
const target = args.target || 'magpie';
return await api('GET', `/reddit/session-status?target=${target}`);
}
default: default:
throw new Error(`Unknown tool: ${name}`); throw new Error(`Unknown tool: ${name}`);
} }