feat: opportunities UI improvements, MCP tools, session refresh, migrations 013-014
This commit is contained in:
parent
add5475d50
commit
c7c57fe4e5
17 changed files with 486 additions and 41 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
93
app/api/endpoints/reddit.py
Normal file
93
app/api/endpoints/reddit.py
Normal 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))
|
||||||
|
|
@ -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("")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
4
app/db/migrations/013_sub_rules_post_url.sql
Normal file
4
app/db/migrations/013_sub_rules_post_url.sql
Normal 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;
|
||||||
4
app/db/migrations/014_posts_opportunity_link.sql
Normal file
4
app/db/migrations/014_posts_opportunity_link.sql
Normal 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);
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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})")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
107
frontend/src/utils/cron.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue