feat: official accounts management - AI post generation, RSS news import (NPR/AP/BMTN), scheduled auto-posting, admin UI

This commit is contained in:
Patrick Britton 2026-02-08 11:30:44 -06:00
parent de5797ad41
commit 2dae622dea
8 changed files with 1717 additions and 27 deletions

View file

@ -0,0 +1,611 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { useEffect, useState } from 'react';
import {
Plus, Play, Eye, Trash2, Power, PowerOff, RefreshCw, Newspaper,
ChevronDown, ChevronUp, Bot, Clock, AlertCircle, CheckCircle, ExternalLink,
} from 'lucide-react';
const DEFAULT_NEWS_SOURCES = [
{ name: 'NPR', rss_url: 'https://feeds.npr.org/1001/rss.xml', enabled: true },
{ name: 'AP News', rss_url: 'https://rsshub.app/apnews/topics/apf-topnews', enabled: true },
{ name: 'Bring Me The News', rss_url: 'https://bringmethenews.com/feed', enabled: true },
];
const DEFAULT_NEWS_PROMPT = `You are a news curator for Sojorn, a social media platform. Your job is to write brief, engaging social media posts about news articles.
Rules:
- Keep posts under 280 characters (excluding the link)
- Be factual and neutral no editorializing or opinion
- Include the article link at the end
- Do NOT use hashtags
- Do NOT use emojis
- Write in a professional but conversational tone
- Start with the most important fact or detail
- Do NOT include "Source:" or "via" attribution the link speaks for itself`;
const DEFAULT_GENERAL_PROMPT = `You are a social media content creator for an official Sojorn account. Generate engaging, original posts that spark conversation.
Rules:
- Keep posts under 500 characters
- Be authentic and conversational
- Ask questions to encourage engagement
- Vary your tone and topics
- Do NOT use hashtags excessively (1-2 max if any)
- Do NOT be overly promotional`;
interface NewsSource {
name: string;
rss_url: string;
enabled: boolean;
}
interface Config {
id: string;
profile_id: string;
account_type: string;
enabled: boolean;
model_id: string;
system_prompt: string;
temperature: number;
max_tokens: number;
post_interval_minutes: number;
max_posts_per_day: number;
posts_today: number;
last_posted_at: string | null;
news_sources: NewsSource[] | string;
last_fetched_at: string | null;
handle: string;
display_name: string;
avatar_url: string;
}
export default function OfficialAccountsPage() {
const [configs, setConfigs] = useState<Config[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message: string }>>({});
const fetchConfigs = async () => {
setLoading(true);
try {
const data = await api.listOfficialAccounts();
setConfigs(data.configs || []);
} catch {
setConfigs([]);
}
setLoading(false);
};
useEffect(() => { fetchConfigs(); }, []);
const setAction = (id: string, loading: boolean, result?: { ok: boolean; message: string }) => {
setActionLoading((p) => ({ ...p, [id]: loading }));
if (result) setActionResult((p) => ({ ...p, [id]: result }));
};
const handleToggle = async (cfg: Config) => {
setAction(cfg.id, true);
try {
await api.toggleOfficialAccount(cfg.id, !cfg.enabled);
setAction(cfg.id, false, { ok: true, message: cfg.enabled ? 'Disabled' : 'Enabled' });
fetchConfigs();
} catch (e: any) {
setAction(cfg.id, false, { ok: false, message: e.message });
}
};
const handleDelete = async (cfg: Config) => {
if (!confirm(`Delete official account config for @${cfg.handle}?`)) return;
try {
await api.deleteOfficialAccount(cfg.id);
fetchConfigs();
} catch (e: any) {
alert(e.message);
}
};
const handleTrigger = async (cfg: Config) => {
setAction(cfg.id, true);
try {
const resp = await api.triggerOfficialPost(cfg.id);
setAction(cfg.id, false, { ok: true, message: resp.body ? `Posted: ${resp.body.slice(0, 100)}...` : resp.message });
fetchConfigs();
} catch (e: any) {
setAction(cfg.id, false, { ok: false, message: e.message });
}
};
const handlePreview = async (cfg: Config) => {
setAction(cfg.id, true);
try {
const resp = await api.previewOfficialPost(cfg.id);
setAction(cfg.id, false, {
ok: true,
message: resp.preview
? `Preview: ${resp.preview}${resp.article_title ? `\n\nArticle: ${resp.article_title}` : ''}`
: resp.message || 'No content to preview',
});
} catch (e: any) {
setAction(cfg.id, false, { ok: false, message: e.message });
}
};
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Official Accounts</h1>
<p className="text-gray-500 mt-1">Manage AI-powered official accounts and news automation</p>
</div>
<div className="flex gap-2">
<button onClick={fetchConfigs} className="flex items-center gap-2 px-3 py-2 text-sm border border-warm-300 rounded-lg hover:bg-warm-200 transition-colors">
<RefreshCw className="w-4 h-4" /> Refresh
</button>
<button onClick={() => setShowForm(!showForm)} className="flex items-center gap-2 px-4 py-2 text-sm bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors">
<Plus className="w-4 h-4" /> Add Account
</button>
</div>
</div>
{showForm && <CreateAccountForm onDone={() => { setShowForm(false); fetchConfigs(); }} />}
{loading ? (
<div className="text-center py-12 text-gray-500">Loading...</div>
) : configs.length === 0 ? (
<div className="text-center py-12 text-gray-500">No official accounts configured yet.</div>
) : (
<div className="space-y-4">
{configs.map((cfg) => {
const expanded = expandedId === cfg.id;
const sources = typeof cfg.news_sources === 'string' ? JSON.parse(cfg.news_sources) : (cfg.news_sources || []);
return (
<div key={cfg.id} className="bg-white rounded-xl border border-warm-300 overflow-hidden">
{/* Header */}
<div className="p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-brand-100 rounded-full flex items-center justify-center flex-shrink-0">
{cfg.account_type === 'news' ? <Newspaper className="w-5 h-5 text-brand-600" /> : <Bot className="w-5 h-5 text-brand-600" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">@{cfg.handle}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-warm-200 text-gray-600">{cfg.account_type}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${cfg.enabled ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{cfg.enabled ? 'Active' : 'Disabled'}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 mt-1">
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> Every {cfg.post_interval_minutes}m</span>
<span>{cfg.posts_today}/{cfg.max_posts_per_day} today</span>
{cfg.last_posted_at && <span>Last: {new Date(cfg.last_posted_at).toLocaleString()}</span>}
<span className="text-gray-400 font-mono text-[10px]">{cfg.model_id}</span>
</div>
</div>
<div className="flex items-center gap-1">
<button onClick={() => handlePreview(cfg)} disabled={actionLoading[cfg.id]} title="Preview AI output" className="p-2 rounded-lg hover:bg-warm-200 transition-colors text-gray-500 disabled:opacity-50">
<Eye className="w-4 h-4" />
</button>
<button onClick={() => handleTrigger(cfg)} disabled={actionLoading[cfg.id]} title="Trigger post now" className="p-2 rounded-lg hover:bg-green-50 transition-colors text-green-600 disabled:opacity-50">
<Play className="w-4 h-4" />
</button>
<button onClick={() => handleToggle(cfg)} disabled={actionLoading[cfg.id]} title={cfg.enabled ? 'Disable' : 'Enable'} className="p-2 rounded-lg hover:bg-warm-200 transition-colors disabled:opacity-50">
{cfg.enabled ? <PowerOff className="w-4 h-4 text-red-500" /> : <Power className="w-4 h-4 text-green-500" />}
</button>
<button onClick={() => handleDelete(cfg)} title="Delete" className="p-2 rounded-lg hover:bg-red-50 transition-colors text-red-500">
<Trash2 className="w-4 h-4" />
</button>
<button onClick={() => setExpandedId(expanded ? null : cfg.id)} className="p-2 rounded-lg hover:bg-warm-200 transition-colors text-gray-500">
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
</div>
{/* Action result */}
{actionResult[cfg.id] && (
<div className={`mx-4 mb-3 p-3 rounded-lg text-sm flex items-start gap-2 ${actionResult[cfg.id].ok ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'}`}>
{actionResult[cfg.id].ok ? <CheckCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> : <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />}
<pre className="whitespace-pre-wrap break-all text-xs">{actionResult[cfg.id].message}</pre>
<button onClick={() => setActionResult((p) => { const n = { ...p }; delete n[cfg.id]; return n; })} className="ml-auto text-gray-400 hover:text-gray-600 text-xs"></button>
</div>
)}
{/* Expanded details */}
{expanded && (
<div className="px-4 pb-4 border-t border-warm-200 pt-3 space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-600">Profile ID:</span>
<span className="ml-2 font-mono text-xs text-gray-500">{cfg.profile_id}</span>
</div>
<div>
<span className="font-medium text-gray-600">Model:</span>
<span className="ml-2 font-mono text-xs text-gray-500">{cfg.model_id}</span>
</div>
<div>
<span className="font-medium text-gray-600">Temperature:</span>
<span className="ml-2">{cfg.temperature}</span>
</div>
<div>
<span className="font-medium text-gray-600">Max Tokens:</span>
<span className="ml-2">{cfg.max_tokens}</span>
</div>
</div>
{cfg.system_prompt && (
<div>
<span className="text-sm font-medium text-gray-600">System Prompt:</span>
<pre className="mt-1 p-2 bg-warm-100 rounded text-xs text-gray-700 whitespace-pre-wrap max-h-40 overflow-y-auto">{cfg.system_prompt}</pre>
</div>
)}
{cfg.account_type === 'news' && sources.length > 0 && (
<div>
<span className="text-sm font-medium text-gray-600">News Sources:</span>
<div className="mt-1 space-y-1">
{sources.map((src: NewsSource, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className={`w-2 h-2 rounded-full ${src.enabled ? 'bg-green-500' : 'bg-gray-300'}`} />
<span className="font-medium">{src.name}</span>
<a href={src.rss_url} target="_blank" className="text-brand-500 hover:underline flex items-center gap-1">
RSS <ExternalLink className="w-3 h-3" />
</a>
</div>
))}
</div>
{cfg.last_fetched_at && (
<p className="text-xs text-gray-500 mt-1">Last fetched: {new Date(cfg.last_fetched_at).toLocaleString()}</p>
)}
<ArticlesPanel configId={cfg.id} />
</div>
)}
<EditAccountForm config={cfg} onDone={fetchConfigs} />
</div>
)}
</div>
);
})}
</div>
)}
</AdminShell>
);
}
// ─── Create Account Form ──────────────────────────────
function CreateAccountForm({ onDone }: { onDone: () => void }) {
const [handle, setHandle] = useState('');
const [accountType, setAccountType] = useState('general');
const [modelId, setModelId] = useState('google/gemini-2.0-flash-001');
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_GENERAL_PROMPT);
const [temperature, setTemperature] = useState(0.7);
const [maxTokens, setMaxTokens] = useState(500);
const [intervalMin, setIntervalMin] = useState(60);
const [maxPerDay, setMaxPerDay] = useState(24);
const [newsSources, setNewsSources] = useState<NewsSource[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (accountType === 'news') {
setNewsSources(DEFAULT_NEWS_SOURCES);
setSystemPrompt(DEFAULT_NEWS_PROMPT);
} else {
setNewsSources([]);
setSystemPrompt(DEFAULT_GENERAL_PROMPT);
}
}, [accountType]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await api.upsertOfficialAccount({
handle: handle.trim().toLowerCase(),
account_type: accountType,
enabled: false,
model_id: modelId,
system_prompt: systemPrompt,
temperature,
max_tokens: maxTokens,
post_interval_minutes: intervalMin,
max_posts_per_day: maxPerDay,
news_sources: newsSources,
});
onDone();
} catch (e: any) {
setError(e.message);
}
setLoading(false);
};
return (
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-warm-300 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Add Official Account</h2>
<div className="grid grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Handle *</label>
<input type="text" required value={handle} onChange={(e) => setHandle(e.target.value)}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" placeholder="newsbot" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Account Type</label>
<select value={accountType} onChange={(e) => setAccountType(e.target.value)}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm">
<option value="general">General</option>
<option value="news">News</option>
<option value="community">Community</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Model</label>
<input type="text" value={modelId} onChange={(e) => setModelId(e.target.value)}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm font-mono" />
</div>
</div>
<div className="grid grid-cols-4 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Temperature</label>
<input type="number" step="0.1" min="0" max="2" value={temperature} onChange={(e) => setTemperature(Number(e.target.value))}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max Tokens</label>
<input type="number" min="50" max="4000" value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Interval (min)</label>
<input type="number" min="5" value={intervalMin} onChange={(e) => setIntervalMin(Number(e.target.value))}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max/Day</label>
<input type="number" min="1" value={maxPerDay} onChange={(e) => setMaxPerDay(Number(e.target.value))}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" />
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">System Prompt</label>
<textarea value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} rows={6}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm font-mono" />
</div>
{accountType === 'news' && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">News Sources (RSS Feeds)</label>
{newsSources.map((src, i) => (
<div key={i} className="flex items-center gap-2 mb-2">
<input type="checkbox" checked={src.enabled}
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], enabled: e.target.checked }; setNewsSources(n); }}
className="w-4 h-4 rounded" />
<input type="text" value={src.name} placeholder="Name"
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], name: e.target.value }; setNewsSources(n); }}
className="w-32 px-2 py-1 border border-warm-300 rounded text-sm" />
<input type="text" value={src.rss_url} placeholder="RSS URL"
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], rss_url: e.target.value }; setNewsSources(n); }}
className="flex-1 px-2 py-1 border border-warm-300 rounded text-sm font-mono" />
<button type="button" onClick={() => setNewsSources(newsSources.filter((_, j) => j !== i))}
className="text-red-500 hover:text-red-700 text-sm"></button>
</div>
))}
<button type="button" onClick={() => setNewsSources([...newsSources, { name: '', rss_url: '', enabled: true }])}
className="text-sm text-brand-500 hover:text-brand-600 flex items-center gap-1 mt-1">
<Plus className="w-3 h-3" /> Add Source
</button>
</div>
)}
{error && (
<div className="mb-4 p-3 rounded-lg text-sm bg-red-50 text-red-800 border border-red-200 flex items-center gap-2">
<AlertCircle className="w-4 h-4" /> {error}
</div>
)}
<div className="flex gap-2">
<button type="submit" disabled={loading}
className="px-4 py-2 bg-brand-500 text-white rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-50">
{loading ? 'Creating...' : 'Create Account Config'}
</button>
<button type="button" onClick={() => { /* parent handles close via onDone */ }}
className="px-4 py-2 border border-warm-300 rounded-lg text-sm hover:bg-warm-200">
Cancel
</button>
</div>
</form>
);
}
// ─── Edit Account Form (inline) ───────────────────────
function EditAccountForm({ config, onDone }: { config: Config; onDone: () => void }) {
const sources = typeof config.news_sources === 'string' ? JSON.parse(config.news_sources) : (config.news_sources || []);
const [modelId, setModelId] = useState(config.model_id);
const [systemPrompt, setSystemPrompt] = useState(config.system_prompt);
const [temperature, setTemperature] = useState(config.temperature);
const [maxTokens, setMaxTokens] = useState(config.max_tokens);
const [intervalMin, setIntervalMin] = useState(config.post_interval_minutes);
const [maxPerDay, setMaxPerDay] = useState(config.max_posts_per_day);
const [newsSources, setNewsSources] = useState<NewsSource[]>(sources);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
const handleSave = async () => {
setLoading(true);
setResult(null);
try {
await api.upsertOfficialAccount({
profile_id: config.profile_id,
account_type: config.account_type,
enabled: config.enabled,
model_id: modelId,
system_prompt: systemPrompt,
temperature,
max_tokens: maxTokens,
post_interval_minutes: intervalMin,
max_posts_per_day: maxPerDay,
news_sources: newsSources,
});
setResult({ ok: true, msg: 'Saved' });
onDone();
} catch (e: any) {
setResult({ ok: false, msg: e.message });
}
setLoading(false);
};
return (
<div className="border-t border-warm-200 pt-3 mt-3">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Edit Configuration</h3>
<div className="grid grid-cols-4 gap-3 mb-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Model</label>
<input type="text" value={modelId} onChange={(e) => setModelId(e.target.value)}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-xs font-mono" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Temperature</label>
<input type="number" step="0.1" min="0" max="2" value={temperature} onChange={(e) => setTemperature(Number(e.target.value))}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-xs" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Interval (min)</label>
<input type="number" min="5" value={intervalMin} onChange={(e) => setIntervalMin(Number(e.target.value))}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-xs" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Max/Day</label>
<input type="number" min="1" value={maxPerDay} onChange={(e) => setMaxPerDay(Number(e.target.value))}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-xs" />
</div>
</div>
<div className="mb-3">
<label className="block text-xs font-medium text-gray-600 mb-1">System Prompt</label>
<textarea value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} rows={4}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-xs font-mono" />
</div>
{config.account_type === 'news' && (
<div className="mb-3">
<label className="block text-xs font-medium text-gray-600 mb-1">News Sources</label>
{newsSources.map((src, i) => (
<div key={i} className="flex items-center gap-2 mb-1">
<input type="checkbox" checked={src.enabled}
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], enabled: e.target.checked }; setNewsSources(n); }}
className="w-3 h-3 rounded" />
<input type="text" value={src.name}
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], name: e.target.value }; setNewsSources(n); }}
className="w-28 px-2 py-1 border border-warm-300 rounded text-xs" />
<input type="text" value={src.rss_url}
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], rss_url: e.target.value }; setNewsSources(n); }}
className="flex-1 px-2 py-1 border border-warm-300 rounded text-xs font-mono" />
<button type="button" onClick={() => setNewsSources(newsSources.filter((_, j) => j !== i))}
className="text-red-500 text-xs"></button>
</div>
))}
<button type="button" onClick={() => setNewsSources([...newsSources, { name: '', rss_url: '', enabled: true }])}
className="text-xs text-brand-500 hover:text-brand-600 flex items-center gap-1 mt-1">
<Plus className="w-3 h-3" /> Add
</button>
</div>
)}
<div className="flex items-center gap-2">
<button onClick={handleSave} disabled={loading}
className="px-3 py-1.5 bg-brand-500 text-white rounded text-xs font-medium hover:bg-brand-600 disabled:opacity-50">
{loading ? 'Saving...' : 'Save Changes'}
</button>
{result && (
<span className={`text-xs ${result.ok ? 'text-green-600' : 'text-red-600'}`}>{result.msg}</span>
)}
</div>
</div>
);
}
// ─── Articles Panel (news accounts) ──────────────────
function ArticlesPanel({ configId }: { configId: string }) {
const [articles, setArticles] = useState<any[]>([]);
const [posted, setPosted] = useState<any[]>([]);
const [tab, setTab] = useState<'pending' | 'posted'>('pending');
const [loading, setLoading] = useState(false);
const fetchPending = async () => {
setLoading(true);
try {
const data = await api.fetchNewsArticles(configId);
setArticles(data.articles || []);
} catch { setArticles([]); }
setLoading(false);
};
const fetchPosted = async () => {
setLoading(true);
try {
const data = await api.getPostedArticles(configId);
setPosted(data.articles || []);
} catch { setPosted([]); }
setLoading(false);
};
useEffect(() => {
if (tab === 'pending') fetchPending();
else fetchPosted();
}, [tab]);
return (
<div className="mt-3 border border-warm-200 rounded-lg overflow-hidden">
<div className="flex bg-warm-100">
<button onClick={() => setTab('pending')}
className={`px-3 py-1.5 text-xs font-medium ${tab === 'pending' ? 'bg-white text-gray-900 border-b-2 border-brand-500' : 'text-gray-500'}`}>
Pending Articles
</button>
<button onClick={() => setTab('posted')}
className={`px-3 py-1.5 text-xs font-medium ${tab === 'posted' ? 'bg-white text-gray-900 border-b-2 border-brand-500' : 'text-gray-500'}`}>
Posted History
</button>
</div>
<div className="max-h-48 overflow-y-auto">
{loading ? (
<p className="p-3 text-xs text-gray-500">Loading...</p>
) : tab === 'pending' ? (
articles.length === 0 ? (
<p className="p-3 text-xs text-gray-500">No pending articles</p>
) : (
articles.map((a, i) => (
<div key={i} className="p-2 border-b border-warm-100 last:border-0">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600">{a.source}</span>
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1">
{a.title} <ExternalLink className="w-3 h-3" />
</a>
</div>
{a.description && <p className="text-[10px] text-gray-500 mt-0.5">{a.description}</p>}
</div>
))
)
) : (
posted.length === 0 ? (
<p className="p-3 text-xs text-gray-500">No posted articles yet</p>
) : (
posted.map((a) => (
<div key={a.id} className="p-2 border-b border-warm-100 last:border-0">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600">{a.source_name}</span>
<span className="text-xs font-medium text-gray-700">{a.article_title}</span>
<span className="text-[10px] text-gray-400 ml-auto">{new Date(a.posted_at).toLocaleString()}</span>
</div>
</div>
))
)
)}
</div>
</div>
);
}

View file

@ -6,7 +6,7 @@ import { useAuth } from '@/lib/auth';
import { cn } from '@/lib/utils';
import {
LayoutDashboard, Users, FileText, Shield, Scale, Flag,
Settings, Activity, LogOut, ChevronLeft, ChevronRight, Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench,
Settings, Activity, LogOut, ChevronLeft, ChevronRight, Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
} from 'lucide-react';
import { useState } from 'react';
@ -23,6 +23,7 @@ const navItems = [
{ href: '/ai-moderation', label: 'AI Moderation', icon: Brain },
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
{ href: '/content-tools', label: 'Content Tools', icon: Wrench },
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
{ href: '/storage', label: 'Storage', icon: HardDrive },
{ href: '/system', label: 'System Health', icon: Activity },
{ href: '/settings', label: 'Settings', icon: Settings },

View file

@ -394,6 +394,49 @@ class ApiClient {
});
}
// Official Accounts
async listOfficialAccounts() {
return this.request<any>('/api/v1/admin/official-accounts');
}
async getOfficialAccount(id: string) {
return this.request<any>(`/api/v1/admin/official-accounts/${id}`);
}
async upsertOfficialAccount(data: any) {
return this.request<any>('/api/v1/admin/official-accounts', {
method: 'POST',
body: JSON.stringify(data),
});
}
async deleteOfficialAccount(id: string) {
return this.request<any>(`/api/v1/admin/official-accounts/${id}`, { method: 'DELETE' });
}
async toggleOfficialAccount(id: string, enabled: boolean) {
return this.request<any>(`/api/v1/admin/official-accounts/${id}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ enabled }),
});
}
async triggerOfficialPost(id: string) {
return this.request<any>(`/api/v1/admin/official-accounts/${id}/trigger`, { method: 'POST' });
}
async previewOfficialPost(id: string) {
return this.request<any>(`/api/v1/admin/official-accounts/${id}/preview`, { method: 'POST' });
}
async fetchNewsArticles(id: string) {
return this.request<any>(`/api/v1/admin/official-accounts/${id}/articles`);
}
async getPostedArticles(id: string, limit = 50) {
return this.request<any>(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}`);
}
async adminImportContent(data: {
author_id: string;
content_type: string;

View file

@ -158,7 +158,12 @@ func main() {
}
}
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
// Initialize official accounts service
officialAccountsService := services.NewOfficialAccountsService(dbPool, openRouterService)
officialAccountsService.StartScheduler()
defer officialAccountsService.StopScheduler()
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, officialAccountsService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
@ -473,6 +478,17 @@ func main() {
// Admin Content Creation & Import
admin.POST("/users/create", adminHandler.AdminCreateUser)
admin.POST("/content/import", adminHandler.AdminImportContent)
// Official Accounts Management
admin.GET("/official-accounts", adminHandler.ListOfficialAccounts)
admin.GET("/official-accounts/:id", adminHandler.GetOfficialAccount)
admin.POST("/official-accounts", adminHandler.UpsertOfficialAccount)
admin.DELETE("/official-accounts/:id", adminHandler.DeleteOfficialAccount)
admin.PATCH("/official-accounts/:id/toggle", adminHandler.ToggleOfficialAccount)
admin.POST("/official-accounts/:id/trigger", adminHandler.TriggerOfficialPost)
admin.POST("/official-accounts/:id/preview", adminHandler.PreviewOfficialPost)
admin.GET("/official-accounts/:id/articles", adminHandler.FetchNewsArticles)
admin.GET("/official-accounts/:id/posted", adminHandler.GetPostedArticles)
}
// Public claim request endpoint (no auth)

View file

@ -0,0 +1,48 @@
-- Official account AI posting configurations
CREATE TABLE IF NOT EXISTS official_account_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
account_type TEXT NOT NULL DEFAULT 'general', -- 'general', 'news', 'community', etc.
enabled BOOLEAN NOT NULL DEFAULT false,
-- AI config
model_id TEXT NOT NULL DEFAULT 'google/gemini-2.0-flash-001',
system_prompt TEXT NOT NULL DEFAULT '',
temperature DOUBLE PRECISION NOT NULL DEFAULT 0.7,
max_tokens INTEGER NOT NULL DEFAULT 500,
-- Posting config
post_interval_minutes INTEGER NOT NULL DEFAULT 60,
max_posts_per_day INTEGER NOT NULL DEFAULT 24,
posts_today INTEGER NOT NULL DEFAULT 0,
posts_today_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_posted_at TIMESTAMPTZ,
-- News-specific config (only for account_type = 'news')
news_sources JSONB NOT NULL DEFAULT '[]'::jsonb, -- array of {name, rss_url, enabled}
last_fetched_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_profile_config UNIQUE (profile_id)
);
CREATE INDEX IF NOT EXISTS idx_oac_profile ON official_account_configs(profile_id);
CREATE INDEX IF NOT EXISTS idx_oac_enabled ON official_account_configs(enabled) WHERE enabled = true;
-- Track which news articles have already been posted to avoid duplicates
CREATE TABLE IF NOT EXISTS official_account_posted_articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
config_id UUID NOT NULL REFERENCES official_account_configs(id) ON DELETE CASCADE,
article_url TEXT NOT NULL,
article_title TEXT NOT NULL DEFAULT '',
source_name TEXT NOT NULL DEFAULT '',
posted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
post_id UUID REFERENCES public.posts(id) ON DELETE SET NULL,
CONSTRAINT unique_article_per_config UNIQUE (config_id, article_url)
);
CREATE INDEX IF NOT EXISTS idx_oapa_config ON official_account_posted_articles(config_id);
CREATE INDEX IF NOT EXISTS idx_oapa_url ON official_account_posted_articles(article_url);

View file

@ -21,34 +21,36 @@ import (
)
type AdminHandler struct {
pool *pgxpool.Pool
moderationService *services.ModerationService
appealService *services.AppealService
emailService *services.EmailService
openRouterService *services.OpenRouterService
jwtSecret string
turnstileSecret string
s3Client *s3.Client
mediaBucket string
videoBucket string
imgDomain string
vidDomain string
pool *pgxpool.Pool
moderationService *services.ModerationService
appealService *services.AppealService
emailService *services.EmailService
openRouterService *services.OpenRouterService
officialAccountsService *services.OfficialAccountsService
jwtSecret string
turnstileSecret string
s3Client *s3.Client
mediaBucket string
videoBucket string
imgDomain string
vidDomain string
}
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler {
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, officialAccountsService *services.OfficialAccountsService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler {
return &AdminHandler{
pool: pool,
moderationService: moderationService,
appealService: appealService,
emailService: emailService,
openRouterService: openRouterService,
jwtSecret: jwtSecret,
turnstileSecret: turnstileSecret,
s3Client: s3Client,
mediaBucket: mediaBucket,
videoBucket: videoBucket,
imgDomain: imgDomain,
vidDomain: vidDomain,
pool: pool,
moderationService: moderationService,
appealService: appealService,
emailService: emailService,
openRouterService: openRouterService,
officialAccountsService: officialAccountsService,
jwtSecret: jwtSecret,
turnstileSecret: turnstileSecret,
s3Client: s3Client,
mediaBucket: mediaBucket,
videoBucket: videoBucket,
imgDomain: imgDomain,
vidDomain: vidDomain,
}
}
@ -2814,3 +2816,294 @@ func (h *AdminHandler) AdminImportContent(c *gin.Context) {
"failures": len(errors),
})
}
// ──────────────────────────────────────────────────────
// Official Accounts Management
// ──────────────────────────────────────────────────────
func (h *AdminHandler) ListOfficialAccounts(c *gin.Context) {
configs, err := h.officialAccountsService.ListConfigs(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if configs == nil {
configs = []services.OfficialAccountConfig{}
}
c.JSON(http.StatusOK, gin.H{"configs": configs})
}
func (h *AdminHandler) GetOfficialAccount(c *gin.Context) {
id := c.Param("id")
cfg, err := h.officialAccountsService.GetConfig(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
c.JSON(http.StatusOK, cfg)
}
func (h *AdminHandler) UpsertOfficialAccount(c *gin.Context) {
var req struct {
ProfileID string `json:"profile_id"`
Handle string `json:"handle"`
AccountType string `json:"account_type"`
Enabled bool `json:"enabled"`
ModelID string `json:"model_id"`
SystemPrompt string `json:"system_prompt"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
PostIntervalMinutes int `json:"post_interval_minutes"`
MaxPostsPerDay int `json:"max_posts_per_day"`
NewsSources []services.NewsSource `json:"news_sources"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
// Resolve handle to profile_id if provided
profileID := req.ProfileID
if profileID == "" && req.Handle != "" {
pid, err := h.officialAccountsService.LookupProfileID(ctx, req.Handle)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
profileID = pid
}
if profileID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "profile_id or handle required"})
return
}
// Defaults
if req.ModelID == "" {
req.ModelID = "google/gemini-2.0-flash-001"
}
if req.MaxTokens == 0 {
req.MaxTokens = 500
}
if req.PostIntervalMinutes == 0 {
req.PostIntervalMinutes = 60
}
if req.MaxPostsPerDay == 0 {
req.MaxPostsPerDay = 24
}
if req.AccountType == "" {
req.AccountType = "general"
}
newsJSON, _ := json.Marshal(req.NewsSources)
cfg := services.OfficialAccountConfig{
ProfileID: profileID,
AccountType: req.AccountType,
Enabled: req.Enabled,
ModelID: req.ModelID,
SystemPrompt: req.SystemPrompt,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
PostIntervalMinutes: req.PostIntervalMinutes,
MaxPostsPerDay: req.MaxPostsPerDay,
NewsSources: newsJSON,
}
result, err := h.officialAccountsService.UpsertConfig(ctx, cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
adminID, _ := c.Get("user_id")
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_upsert_official_account', 'official_account', $2, $3)`,
adminID, result.ID, fmt.Sprintf(`{"profile_id":"%s","type":"%s"}`, profileID, req.AccountType))
c.JSON(http.StatusOK, result)
}
func (h *AdminHandler) DeleteOfficialAccount(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
if err := h.officialAccountsService.DeleteConfig(ctx, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
adminID, _ := c.Get("user_id")
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id) VALUES ($1, 'admin_delete_official_account', 'official_account', $2)`, adminID, id)
c.JSON(http.StatusOK, gin.H{"message": "Deleted"})
}
func (h *AdminHandler) ToggleOfficialAccount(c *gin.Context) {
id := c.Param("id")
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.officialAccountsService.ToggleEnabled(c.Request.Context(), id, req.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enabled": req.Enabled})
}
// Manually trigger a post for an official account
func (h *AdminHandler) TriggerOfficialPost(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
cfg, err := h.officialAccountsService.GetConfig(ctx, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
if cfg.AccountType == "news" {
// Fetch new articles and post the first one
items, sourceNames, err := h.officialAccountsService.FetchNewArticles(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch news: " + err.Error()})
return
}
if len(items) == 0 {
c.JSON(http.StatusOK, gin.H{"message": "No new articles found", "post_id": nil})
return
}
postID, body, err := h.officialAccountsService.GenerateAndPost(ctx, id, &items[0], sourceNames[0])
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "News post created",
"post_id": postID,
"body": body,
"source": sourceNames[0],
"title": items[0].Title,
"remaining": len(items) - 1,
})
} else {
postID, body, err := h.officialAccountsService.GenerateAndPost(ctx, id, nil, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Post created",
"post_id": postID,
"body": body,
})
}
}
// Preview what AI would generate without actually posting
func (h *AdminHandler) PreviewOfficialPost(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
cfg, err := h.officialAccountsService.GetConfig(ctx, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
if cfg.AccountType == "news" {
items, sourceNames, err := h.officialAccountsService.FetchNewArticles(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(items) == 0 {
c.JSON(http.StatusOK, gin.H{"message": "No new articles", "preview": nil})
return
}
body, err := h.officialAccountsService.GeneratePost(ctx, id, &items[0], sourceNames[0])
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"preview": body,
"source": sourceNames[0],
"article_title": items[0].Title,
"article_link": items[0].Link,
"pending_count": len(items),
})
} else {
body, err := h.officialAccountsService.GeneratePost(ctx, id, nil, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"preview": body})
}
}
// Fetch available news articles (without posting)
func (h *AdminHandler) FetchNewsArticles(c *gin.Context) {
id := c.Param("id")
items, sourceNames, err := h.officialAccountsService.FetchNewArticles(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
type articlePreview struct {
Title string `json:"title"`
Link string `json:"link"`
Description string `json:"description"`
Source string `json:"source"`
PubDate string `json:"pub_date"`
}
var previews []articlePreview
for i, item := range items {
desc := services.StripHTMLTagsPublic(item.Description)
if len(desc) > 200 {
desc = desc[:200] + "..."
}
previews = append(previews, articlePreview{
Title: item.Title,
Link: item.Link,
Description: desc,
Source: sourceNames[i],
PubDate: item.PubDate,
})
}
if previews == nil {
previews = []articlePreview{}
}
c.JSON(http.StatusOK, gin.H{"articles": previews, "count": len(previews)})
}
// Get posted articles history for an account
func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
id := c.Param("id")
limit := 50
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
articles, err := h.officialAccountsService.GetRecentArticles(c.Request.Context(), id, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if articles == nil {
articles = []services.PostedArticle{}
}
c.JSON(http.StatusOK, gin.H{"articles": articles})
}

View file

@ -0,0 +1,622 @@
package services
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
// OfficialAccountConfig represents a row in official_account_configs
type OfficialAccountConfig struct {
ID string `json:"id"`
ProfileID string `json:"profile_id"`
AccountType string `json:"account_type"`
Enabled bool `json:"enabled"`
ModelID string `json:"model_id"`
SystemPrompt string `json:"system_prompt"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
PostIntervalMinutes int `json:"post_interval_minutes"`
MaxPostsPerDay int `json:"max_posts_per_day"`
PostsToday int `json:"posts_today"`
PostsTodayResetAt time.Time `json:"posts_today_reset_at"`
LastPostedAt *time.Time `json:"last_posted_at"`
NewsSources json.RawMessage `json:"news_sources"`
LastFetchedAt *time.Time `json:"last_fetched_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Joined fields
Handle string `json:"handle,omitempty"`
DisplayName string `json:"display_name,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// NewsSource represents a single RSS feed configuration
type NewsSource struct {
Name string `json:"name"`
RSSURL string `json:"rss_url"`
Enabled bool `json:"enabled"`
}
// RSSFeed represents a parsed RSS feed
type RSSFeed struct {
Channel struct {
Title string `xml:"title"`
Items []RSSItem `xml:"item"`
} `xml:"channel"`
}
// RSSItem represents a single RSS item
type RSSItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
GUID string `xml:"guid"`
}
// PostedArticle represents a previously posted article
type PostedArticle struct {
ID string `json:"id"`
ConfigID string `json:"config_id"`
ArticleURL string `json:"article_url"`
ArticleTitle string `json:"article_title"`
SourceName string `json:"source_name"`
PostedAt time.Time `json:"posted_at"`
PostID *string `json:"post_id"`
}
// OfficialAccountsService manages official account automation
type OfficialAccountsService struct {
pool *pgxpool.Pool
openRouterService *OpenRouterService
httpClient *http.Client
stopCh chan struct{}
wg sync.WaitGroup
}
func NewOfficialAccountsService(pool *pgxpool.Pool, openRouterService *OpenRouterService) *OfficialAccountsService {
return &OfficialAccountsService{
pool: pool,
openRouterService: openRouterService,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
stopCh: make(chan struct{}),
}
}
// ── CRUD ─────────────────────────────────────────────
func (s *OfficialAccountsService) ListConfigs(ctx context.Context) ([]OfficialAccountConfig, error) {
rows, err := s.pool.Query(ctx, `
SELECT c.id, c.profile_id, c.account_type, c.enabled,
c.model_id, c.system_prompt, c.temperature, c.max_tokens,
c.post_interval_minutes, c.max_posts_per_day, c.posts_today, c.posts_today_reset_at,
c.last_posted_at, c.news_sources, c.last_fetched_at,
c.created_at, c.updated_at,
p.handle, p.display_name, COALESCE(p.avatar_url, '')
FROM official_account_configs c
JOIN public.profiles p ON p.id = c.profile_id
ORDER BY c.created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var configs []OfficialAccountConfig
for rows.Next() {
var c OfficialAccountConfig
if err := rows.Scan(
&c.ID, &c.ProfileID, &c.AccountType, &c.Enabled,
&c.ModelID, &c.SystemPrompt, &c.Temperature, &c.MaxTokens,
&c.PostIntervalMinutes, &c.MaxPostsPerDay, &c.PostsToday, &c.PostsTodayResetAt,
&c.LastPostedAt, &c.NewsSources, &c.LastFetchedAt,
&c.CreatedAt, &c.UpdatedAt,
&c.Handle, &c.DisplayName, &c.AvatarURL,
); err != nil {
return nil, err
}
configs = append(configs, c)
}
return configs, nil
}
func (s *OfficialAccountsService) GetConfig(ctx context.Context, id string) (*OfficialAccountConfig, error) {
var c OfficialAccountConfig
err := s.pool.QueryRow(ctx, `
SELECT c.id, c.profile_id, c.account_type, c.enabled,
c.model_id, c.system_prompt, c.temperature, c.max_tokens,
c.post_interval_minutes, c.max_posts_per_day, c.posts_today, c.posts_today_reset_at,
c.last_posted_at, c.news_sources, c.last_fetched_at,
c.created_at, c.updated_at,
p.handle, p.display_name, COALESCE(p.avatar_url, '')
FROM official_account_configs c
JOIN public.profiles p ON p.id = c.profile_id
WHERE c.id = $1
`, id).Scan(
&c.ID, &c.ProfileID, &c.AccountType, &c.Enabled,
&c.ModelID, &c.SystemPrompt, &c.Temperature, &c.MaxTokens,
&c.PostIntervalMinutes, &c.MaxPostsPerDay, &c.PostsToday, &c.PostsTodayResetAt,
&c.LastPostedAt, &c.NewsSources, &c.LastFetchedAt,
&c.CreatedAt, &c.UpdatedAt,
&c.Handle, &c.DisplayName, &c.AvatarURL,
)
if err != nil {
return nil, err
}
return &c, nil
}
func (s *OfficialAccountsService) UpsertConfig(ctx context.Context, cfg OfficialAccountConfig) (*OfficialAccountConfig, error) {
newsJSON, err := json.Marshal(cfg.NewsSources)
if err != nil {
newsJSON = []byte("[]")
}
var id string
err = s.pool.QueryRow(ctx, `
INSERT INTO official_account_configs
(profile_id, account_type, enabled, model_id, system_prompt, temperature, max_tokens,
post_interval_minutes, max_posts_per_day, news_sources, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
ON CONFLICT (profile_id)
DO UPDATE SET
account_type = EXCLUDED.account_type,
enabled = EXCLUDED.enabled,
model_id = EXCLUDED.model_id,
system_prompt = EXCLUDED.system_prompt,
temperature = EXCLUDED.temperature,
max_tokens = EXCLUDED.max_tokens,
post_interval_minutes = EXCLUDED.post_interval_minutes,
max_posts_per_day = EXCLUDED.max_posts_per_day,
news_sources = EXCLUDED.news_sources,
updated_at = NOW()
RETURNING id
`, cfg.ProfileID, cfg.AccountType, cfg.Enabled, cfg.ModelID, cfg.SystemPrompt,
cfg.Temperature, cfg.MaxTokens, cfg.PostIntervalMinutes, cfg.MaxPostsPerDay, newsJSON,
).Scan(&id)
if err != nil {
return nil, err
}
return s.GetConfig(ctx, id)
}
func (s *OfficialAccountsService) DeleteConfig(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM official_account_configs WHERE id = $1`, id)
return err
}
func (s *OfficialAccountsService) ToggleEnabled(ctx context.Context, id string, enabled bool) error {
_, err := s.pool.Exec(ctx, `UPDATE official_account_configs SET enabled = $2, updated_at = NOW() WHERE id = $1`, id, enabled)
return err
}
// ── RSS News Fetching ────────────────────────────────
func (s *OfficialAccountsService) FetchRSS(ctx context.Context, rssURL string) ([]RSSItem, error) {
req, err := http.NewRequestWithContext(ctx, "GET", rssURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Sojorn/1.0 (News Aggregator)")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch RSS %s: %w", rssURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("RSS feed %s returned status %d", rssURL, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var feed RSSFeed
if err := xml.Unmarshal(body, &feed); err != nil {
return nil, fmt.Errorf("failed to parse RSS from %s: %w", rssURL, err)
}
return feed.Channel.Items, nil
}
// FetchNewArticles fetches new articles from all enabled news sources for a config,
// filtering out already-posted articles.
func (s *OfficialAccountsService) FetchNewArticles(ctx context.Context, configID string) ([]RSSItem, []string, error) {
cfg, err := s.GetConfig(ctx, configID)
if err != nil {
return nil, nil, err
}
var sources []NewsSource
if err := json.Unmarshal(cfg.NewsSources, &sources); err != nil {
return nil, nil, fmt.Errorf("failed to parse news sources: %w", err)
}
var allItems []RSSItem
var sourceNames []string
for _, src := range sources {
if !src.Enabled || src.RSSURL == "" {
continue
}
items, err := s.FetchRSS(ctx, src.RSSURL)
if err != nil {
log.Warn().Err(err).Str("source", src.Name).Msg("Failed to fetch RSS feed")
continue
}
for _, item := range items {
allItems = append(allItems, item)
sourceNames = append(sourceNames, src.Name)
}
}
// Filter out already-posted articles
var newItems []RSSItem
var newSourceNames []string
for i, item := range allItems {
link := item.Link
if link == "" {
link = item.GUID
}
if link == "" {
continue
}
var exists bool
_ = s.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM official_account_posted_articles WHERE config_id = $1 AND article_url = $2)`,
configID, link,
).Scan(&exists)
if !exists {
newItems = append(newItems, item)
newSourceNames = append(newSourceNames, sourceNames[i])
}
}
// Update last_fetched_at
_, _ = s.pool.Exec(ctx, `UPDATE official_account_configs SET last_fetched_at = NOW() WHERE id = $1`, configID)
return newItems, newSourceNames, nil
}
// ── AI Post Generation ───────────────────────────────
// GeneratePost creates a post using AI for a given official account config.
// For news accounts, it takes an article and generates a commentary/summary.
// For general accounts, it generates an original post.
func (s *OfficialAccountsService) GeneratePost(ctx context.Context, configID string, article *RSSItem, sourceName string) (string, error) {
cfg, err := s.GetConfig(ctx, configID)
if err != nil {
return "", err
}
var userPrompt string
if article != nil {
// News mode: generate a post about this article
desc := article.Description
// Strip HTML tags from description
desc = stripHTMLTags(desc)
if len(desc) > 500 {
desc = desc[:500] + "..."
}
userPrompt = fmt.Sprintf(
"Write a social media post about this news article. Include the link.\n\nSource: %s\nTitle: %s\nDescription: %s\nLink: %s",
sourceName, article.Title, desc, article.Link,
)
} else {
// General mode: generate an original post
userPrompt = "Generate a new social media post. Be creative and engaging."
}
generated, err := s.openRouterService.GenerateText(
ctx, cfg.ModelID, cfg.SystemPrompt, userPrompt, cfg.Temperature, cfg.MaxTokens,
)
if err != nil {
return "", fmt.Errorf("AI generation failed: %w", err)
}
return generated, nil
}
// CreatePostForAccount creates a post in the database for the official account
func (s *OfficialAccountsService) CreatePostForAccount(ctx context.Context, configID string, body string, article *RSSItem, sourceName string) (string, error) {
cfg, err := s.GetConfig(ctx, configID)
if err != nil {
return "", err
}
// Check daily limit
if cfg.PostsToday >= cfg.MaxPostsPerDay {
// Reset if it's a new day
if time.Since(cfg.PostsTodayResetAt) > 24*time.Hour {
_, _ = s.pool.Exec(ctx, `UPDATE official_account_configs SET posts_today = 0, posts_today_reset_at = NOW() WHERE id = $1`, configID)
} else {
return "", fmt.Errorf("daily post limit reached (%d/%d)", cfg.PostsToday, cfg.MaxPostsPerDay)
}
}
// Get user_id from profile_id
var authorID string
err = s.pool.QueryRow(ctx, `SELECT user_id FROM public.profiles WHERE id = $1`, cfg.ProfileID).Scan(&authorID)
if err != nil {
return "", fmt.Errorf("failed to get user_id for profile: %w", err)
}
authorUUID, _ := uuid.Parse(authorID)
postID := uuid.New()
tx, err := s.pool.Begin(ctx)
if err != nil {
return "", err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO public.posts (id, author_id, body, status, body_format, is_beacon, allow_chain, visibility, is_nsfw, confidence_score, created_at)
VALUES ($1, $2, $3, 'active', 'plain', false, true, 'public', false, 1.0, $4)
`, postID, authorUUID, body, time.Now())
if err != nil {
return "", fmt.Errorf("failed to insert post: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO public.post_metrics (post_id, like_count, save_count, view_count, comment_count, updated_at)
VALUES ($1, 0, 0, 0, 0, $2) ON CONFLICT DO NOTHING
`, postID, time.Now())
if err != nil {
return "", fmt.Errorf("failed to insert post_metrics: %w", err)
}
// Track article if this was a news post
if article != nil {
link := article.Link
if link == "" {
link = article.GUID
}
postIDStr := postID.String()
_, _ = tx.Exec(ctx, `
INSERT INTO official_account_posted_articles (config_id, article_url, article_title, source_name, post_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (config_id, article_url) DO NOTHING
`, configID, link, article.Title, sourceName, postIDStr)
}
// Update counters
_, _ = tx.Exec(ctx, `
UPDATE official_account_configs
SET posts_today = posts_today + 1, last_posted_at = NOW(), updated_at = NOW()
WHERE id = $1
`, configID)
if err := tx.Commit(ctx); err != nil {
return "", err
}
return postID.String(), nil
}
// GenerateAndPost generates an AI post and creates it in the database
func (s *OfficialAccountsService) GenerateAndPost(ctx context.Context, configID string, article *RSSItem, sourceName string) (string, string, error) {
body, err := s.GeneratePost(ctx, configID, article, sourceName)
if err != nil {
return "", "", err
}
postID, err := s.CreatePostForAccount(ctx, configID, body, article, sourceName)
if err != nil {
return "", "", err
}
return postID, body, nil
}
// ── Scheduled Auto-Posting ───────────────────────────
func (s *OfficialAccountsService) StartScheduler() {
s.wg.Add(1)
go func() {
defer s.wg.Done()
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
log.Info().Msg("[OfficialAccounts] Scheduler started (5-min tick)")
for {
select {
case <-s.stopCh:
log.Info().Msg("[OfficialAccounts] Scheduler stopped")
return
case <-ticker.C:
s.runScheduledPosts()
}
}
}()
}
func (s *OfficialAccountsService) StopScheduler() {
close(s.stopCh)
s.wg.Wait()
}
func (s *OfficialAccountsService) runScheduledPosts() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// Find enabled configs that are due for a post
rows, err := s.pool.Query(ctx, `
SELECT id, account_type, post_interval_minutes, last_posted_at, posts_today, max_posts_per_day, posts_today_reset_at
FROM official_account_configs
WHERE enabled = true
`)
if err != nil {
log.Error().Err(err).Msg("[OfficialAccounts] Failed to query configs")
return
}
defer rows.Close()
type candidate struct {
ID string
AccountType string
PostIntervalMinutes int
LastPostedAt *time.Time
PostsToday int
MaxPostsPerDay int
PostsTodayResetAt time.Time
}
var candidates []candidate
for rows.Next() {
var c candidate
if err := rows.Scan(&c.ID, &c.AccountType, &c.PostIntervalMinutes, &c.LastPostedAt, &c.PostsToday, &c.MaxPostsPerDay, &c.PostsTodayResetAt); err != nil {
continue
}
candidates = append(candidates, c)
}
for _, c := range candidates {
// Reset daily counter if needed
if time.Since(c.PostsTodayResetAt) > 24*time.Hour {
_, _ = s.pool.Exec(ctx, `UPDATE official_account_configs SET posts_today = 0, posts_today_reset_at = NOW() WHERE id = $1`, c.ID)
c.PostsToday = 0
}
// Check daily limit
if c.PostsToday >= c.MaxPostsPerDay {
continue
}
// Check interval
if c.LastPostedAt != nil && time.Since(*c.LastPostedAt) < time.Duration(c.PostIntervalMinutes)*time.Minute {
continue
}
// Time to post!
if c.AccountType == "news" {
s.scheduleNewsPost(ctx, c.ID)
} else {
s.scheduleGeneralPost(ctx, c.ID)
}
}
}
func (s *OfficialAccountsService) scheduleNewsPost(ctx context.Context, configID string) {
items, sourceNames, err := s.FetchNewArticles(ctx, configID)
if err != nil {
log.Error().Err(err).Str("config", configID).Msg("[OfficialAccounts] Failed to fetch news")
return
}
if len(items) == 0 {
log.Debug().Str("config", configID).Msg("[OfficialAccounts] No new articles to post")
return
}
// Post the first new article
article := items[0]
sourceName := sourceNames[0]
postID, body, err := s.GenerateAndPost(ctx, configID, &article, sourceName)
if err != nil {
log.Error().Err(err).Str("config", configID).Msg("[OfficialAccounts] Failed to generate news post")
return
}
log.Info().Str("config", configID).Str("post_id", postID).Str("source", sourceName).Str("title", article.Title).Msg("[OfficialAccounts] News post created")
_ = body // logged implicitly via post
}
func (s *OfficialAccountsService) scheduleGeneralPost(ctx context.Context, configID string) {
postID, body, err := s.GenerateAndPost(ctx, configID, nil, "")
if err != nil {
log.Error().Err(err).Str("config", configID).Msg("[OfficialAccounts] Failed to generate post")
return
}
log.Info().Str("config", configID).Str("post_id", postID).Msg("[OfficialAccounts] General post created")
_ = body
}
// ── Recent Articles ──────────────────────────────────
func (s *OfficialAccountsService) GetRecentArticles(ctx context.Context, configID string, limit int) ([]PostedArticle, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT id, config_id, article_url, article_title, source_name, posted_at, post_id
FROM official_account_posted_articles
WHERE config_id = $1
ORDER BY posted_at DESC
LIMIT $2
`, configID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var articles []PostedArticle
for rows.Next() {
var a PostedArticle
if err := rows.Scan(&a.ID, &a.ConfigID, &a.ArticleURL, &a.ArticleTitle, &a.SourceName, &a.PostedAt, &a.PostID); err != nil {
continue
}
articles = append(articles, a)
}
return articles, nil
}
// ── Helpers ──────────────────────────────────────────
// StripHTMLTagsPublic is the exported version for use by handlers
func StripHTMLTagsPublic(s string) string {
return stripHTMLTags(s)
}
func stripHTMLTags(s string) string {
var result strings.Builder
inTag := false
for _, r := range s {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
result.WriteRune(r)
}
}
return strings.TrimSpace(result.String())
}
// LookupProfileID finds a profile ID by handle
func (s *OfficialAccountsService) LookupProfileID(ctx context.Context, handle string) (string, error) {
var id string
err := s.pool.QueryRow(ctx, `SELECT id FROM public.profiles WHERE handle = $1`, strings.ToLower(handle)).Scan(&id)
if err != nil {
if err == pgx.ErrNoRows {
return "", fmt.Errorf("profile not found: @%s", handle)
}
return "", err
}
return id, nil
}

View file

@ -235,6 +235,62 @@ type ModerationResult struct {
RawContent string `json:"raw_content"`
}
// GenerateText sends a general-purpose chat completion request and returns the raw text response.
// Used for AI content generation (not moderation).
func (s *OpenRouterService) GenerateText(ctx context.Context, modelID, systemPrompt, userPrompt string, temperature float64, maxTokens int) (string, error) {
if s.apiKey == "" {
return "", fmt.Errorf("OpenRouter API key not configured")
}
messages := []OpenRouterChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
}
reqBody := OpenRouterChatRequest{
Model: modelID,
Messages: messages,
Temperature: floatPtr(temperature),
MaxTokens: intPtr(maxTokens),
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(jsonBody))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.apiKey)
req.Header.Set("HTTP-Referer", "https://sojorn.net")
req.Header.Set("X-Title", "Sojorn Content Generation")
resp, err := s.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("OpenRouter request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("OpenRouter API error %d: %s", resp.StatusCode, string(body))
}
var chatResp OpenRouterChatResponse
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("no response from model")
}
return strings.TrimSpace(chatResp.Choices[0].Message.Content), nil
}
// callModel sends a chat completion request to OpenRouter
func (s *OpenRouterService) callModel(ctx context.Context, modelID, systemPrompt, textContent string, imageURLs []string) (*ModerationResult, error) {
if s.apiKey == "" {