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 { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, FileText, Shield, Scale, Flag,
|
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';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@ const navItems = [
|
||||||
{ href: '/ai-moderation', label: 'AI Moderation', icon: Brain },
|
{ href: '/ai-moderation', label: 'AI Moderation', icon: Brain },
|
||||||
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
|
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
|
||||||
{ href: '/content-tools', label: 'Content Tools', icon: Wrench },
|
{ href: '/content-tools', label: 'Content Tools', icon: Wrench },
|
||||||
|
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
||||||
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
||||||
{ href: '/system', label: 'System Health', icon: Activity },
|
{ href: '/system', label: 'System Health', icon: Activity },
|
||||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
{ 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: {
|
async adminImportContent(data: {
|
||||||
author_id: string;
|
author_id: string;
|
||||||
content_type: 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)
|
accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
|
||||||
|
|
||||||
|
|
@ -473,6 +478,17 @@ func main() {
|
||||||
// Admin Content Creation & Import
|
// Admin Content Creation & Import
|
||||||
admin.POST("/users/create", adminHandler.AdminCreateUser)
|
admin.POST("/users/create", adminHandler.AdminCreateUser)
|
||||||
admin.POST("/content/import", adminHandler.AdminImportContent)
|
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)
|
// 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);
|
||||||
|
|
@ -26,6 +26,7 @@ type AdminHandler struct {
|
||||||
appealService *services.AppealService
|
appealService *services.AppealService
|
||||||
emailService *services.EmailService
|
emailService *services.EmailService
|
||||||
openRouterService *services.OpenRouterService
|
openRouterService *services.OpenRouterService
|
||||||
|
officialAccountsService *services.OfficialAccountsService
|
||||||
jwtSecret string
|
jwtSecret string
|
||||||
turnstileSecret string
|
turnstileSecret string
|
||||||
s3Client *s3.Client
|
s3Client *s3.Client
|
||||||
|
|
@ -35,13 +36,14 @@ type AdminHandler struct {
|
||||||
vidDomain 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{
|
return &AdminHandler{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
moderationService: moderationService,
|
moderationService: moderationService,
|
||||||
appealService: appealService,
|
appealService: appealService,
|
||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
openRouterService: openRouterService,
|
openRouterService: openRouterService,
|
||||||
|
officialAccountsService: officialAccountsService,
|
||||||
jwtSecret: jwtSecret,
|
jwtSecret: jwtSecret,
|
||||||
turnstileSecret: turnstileSecret,
|
turnstileSecret: turnstileSecret,
|
||||||
s3Client: s3Client,
|
s3Client: s3Client,
|
||||||
|
|
@ -2814,3 +2816,294 @@ func (h *AdminHandler) AdminImportContent(c *gin.Context) {
|
||||||
"failures": len(errors),
|
"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"`
|
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
|
// callModel sends a chat completion request to OpenRouter
|
||||||
func (s *OpenRouterService) callModel(ctx context.Context, modelID, systemPrompt, textContent string, imageURLs []string) (*ModerationResult, error) {
|
func (s *OpenRouterService) callModel(ctx context.Context, modelID, systemPrompt, textContent string, imageURLs []string) (*ModerationResult, error) {
|
||||||
if s.apiKey == "" {
|
if s.apiKey == "" {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue