feat: official accounts management - AI post generation, RSS news import (NPR/AP/BMTN), scheduled auto-posting, admin UI
This commit is contained in:
parent
de5797ad41
commit
2dae622dea
611
admin/src/app/official-accounts/page.tsx
Normal file
611
admin/src/app/official-accounts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
622
go-backend/internal/services/official_accounts_service.go
Normal file
622
go-backend/internal/services/official_accounts_service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
Loading…
Reference in a new issue