addd TG integration

This commit is contained in:
xCyanGrizzly
2026-03-02 11:57:17 +01:00
parent b427193d17
commit 4d0df6b1a4
35 changed files with 4436 additions and 242 deletions

View File

@@ -9,6 +9,7 @@ import {
Link2,
Play,
KeyRound,
Download,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -36,6 +37,7 @@ interface AccountColumnsProps {
onViewLinks: (id: string) => void;
onTriggerSync: (id: string) => void;
onEnterCode: (account: AccountRow) => void;
onFetchChannels: (id: string) => void;
}
export function getAccountColumns({
@@ -45,6 +47,7 @@ export function getAccountColumns({
onViewLinks,
onTriggerSync,
onEnterCode,
onFetchChannels,
}: AccountColumnsProps): ColumnDef<AccountRow, unknown>[] {
return [
{
@@ -157,6 +160,13 @@ export function getAccountColumns({
<Link2 className="mr-2 h-3.5 w-3.5" />
Manage Channels
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onFetchChannels(row.original.id)}
disabled={row.original.authState !== "AUTHENTICATED"}
>
<Download className="mr-2 h-3.5 w-3.5" />
Fetch Channels
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onTriggerSync(row.original.id)}>
<Play className="mr-2 h-3.5 w-3.5" />
Sync Now

View File

@@ -1,12 +1,14 @@
"use client";
import { useState, useTransition } from "react";
import { useState, useEffect, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Plus, Play } from "lucide-react";
import { toast } from "sonner";
import { getAccountColumns } from "./account-columns";
import { AccountModal } from "./account-modal";
import { AccountLinksDrawer } from "./account-links-drawer";
import { AuthCodeDialog } from "./auth-code-dialog";
import { ChannelPickerDialog } from "./channel-picker-dialog";
import { deleteAccount, toggleAccountActive, triggerIngestion } from "../actions";
import { DataTable } from "@/components/shared/data-table";
import { DeleteDialog } from "@/components/shared/delete-dialog";
@@ -19,12 +21,27 @@ interface AccountsTabProps {
}
export function AccountsTab({ accounts }: AccountsTabProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [modalOpen, setModalOpen] = useState(false);
const [editAccount, setEditAccount] = useState<AccountRow | undefined>();
const [deleteId, setDeleteId] = useState<string | null>(null);
const [linksAccountId, setLinksAccountId] = useState<string | null>(null);
const [authCodeAccount, setAuthCodeAccount] = useState<AccountRow | null>(null);
const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null);
// Auto-refresh when accounts are in transitional states (PENDING, AWAITING_CODE, AWAITING_PASSWORD)
const hasTransitional = accounts.some(
(a) => a.authState === "PENDING" || a.authState === "AWAITING_CODE" || a.authState === "AWAITING_PASSWORD"
);
useEffect(() => {
if (!hasTransitional) return;
const interval = setInterval(() => {
router.refresh();
}, 3_000);
return () => clearInterval(interval);
}, [hasTransitional, router]);
const columns = getAccountColumns({
onEdit: (account) => {
@@ -48,6 +65,7 @@ export function AccountsTab({ accounts }: AccountsTabProps) {
else toast.error(result.error);
});
},
onFetchChannels: (id) => setFetchChannelsAccountId(id),
});
const { table } = useDataTable({
@@ -135,6 +153,14 @@ export function AccountsTab({ accounts }: AccountsTabProps) {
if (!open) setAuthCodeAccount(null);
}}
/>
<ChannelPickerDialog
accountId={fetchChannelsAccountId}
open={!!fetchChannelsAccountId}
onOpenChange={(open) => {
if (!open) setFetchChannelsAccountId(null);
}}
/>
</div>
);
}

View File

@@ -3,9 +3,10 @@
import { type ColumnDef } from "@tanstack/react-table";
import {
MoreHorizontal,
Pencil,
Trash2,
Power,
ArrowDownToLine,
ArrowUpFromLine,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -19,15 +20,15 @@ import {
import type { ChannelRow } from "@/lib/telegram/admin-queries";
interface ChannelColumnsProps {
onEdit: (channel: ChannelRow) => void;
onToggleActive: (id: string) => void;
onDelete: (id: string) => void;
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
}
export function getChannelColumns({
onEdit,
onToggleActive,
onDelete,
onSetType,
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
return [
{
@@ -105,10 +106,21 @@ export function getChannelColumns({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
{row.original.type === "SOURCE" ? (
<DropdownMenuItem
onClick={() => onSetType(row.original.id, "DESTINATION")}
>
<ArrowDownToLine className="mr-2 h-3.5 w-3.5" />
Set as Destination
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => onSetType(row.original.id, "SOURCE")}
>
<ArrowUpFromLine className="mr-2 h-3.5 w-3.5" />
Set as Source
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => onToggleActive(row.original.id)}
>

View File

@@ -0,0 +1,337 @@
"use client";
import { useState, useEffect, useCallback, useTransition } from "react";
import { Loader2, Search, CheckSquare, Square, Radio } from "lucide-react";
import { toast } from "sonner";
import { saveChannelSelections } from "../actions";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
interface FetchedChannel {
chatId: string;
title: string;
type: "channel" | "supergroup";
isForum: boolean;
memberCount: number | null;
alreadyLinked: boolean;
existingChannelId: string | null;
}
interface ChannelPickerDialogProps {
accountId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
type FetchState =
| { phase: "idle" }
| { phase: "fetching"; requestId?: string }
| { phase: "loaded"; channels: FetchedChannel[] }
| { phase: "error"; message: string };
export function ChannelPickerDialog({
accountId,
open,
onOpenChange,
}: ChannelPickerDialogProps) {
const [isPending, startTransition] = useTransition();
const [fetchState, setFetchState] = useState<FetchState>({ phase: "idle" });
const [selected, setSelected] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
// Start fetching when dialog opens
useEffect(() => {
if (!open || !accountId) {
setFetchState({ phase: "idle" });
setSelected(new Set());
setSearch("");
return;
}
let mounted = true;
const startFetch = async () => {
setFetchState({ phase: "fetching" });
try {
// POST to create a fetch request
const postRes = await fetch(
`/api/telegram/accounts/${accountId}/fetch-channels`,
{ method: "POST" }
);
if (!postRes.ok) {
let message = `Server error (${postRes.status})`;
try {
const err = await postRes.json();
message = err.error || message;
} catch {
// response wasn't JSON
}
if (mounted) setFetchState({ phase: "error", message });
return;
}
const { requestId } = await postRes.json();
if (mounted) setFetchState({ phase: "fetching", requestId });
// Poll for result
const poll = async () => {
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 2000));
if (!mounted) return;
const getRes = await fetch(
`/api/telegram/accounts/${accountId}/fetch-channels?requestId=${requestId}`
);
if (!getRes.ok) continue;
const data = await getRes.json();
if (data.status === "COMPLETED") {
if (mounted) {
// Filter out already-linked channels
const available = (data.channels as FetchedChannel[]).filter(
(ch) => !ch.alreadyLinked
);
setFetchState({ phase: "loaded", channels: available });
}
return;
} else if (data.status === "FAILED") {
if (mounted) {
setFetchState({
phase: "error",
message: data.error || "Fetch failed",
});
}
return;
}
}
if (mounted) {
setFetchState({ phase: "error", message: "Fetch timed out" });
}
};
await poll();
} catch (err) {
if (mounted) {
const message = err instanceof Error ? err.message : "Network error";
setFetchState({ phase: "error", message: `Network error: ${message}` });
}
}
};
startFetch();
return () => { mounted = false; };
}, [open, accountId]);
const channels =
fetchState.phase === "loaded" ? fetchState.channels : [];
const filteredChannels = channels.filter((ch) =>
ch.title.toLowerCase().includes(search.toLowerCase())
);
const toggleChannel = (chatId: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(chatId)) {
next.delete(chatId);
} else {
next.add(chatId);
}
return next;
});
};
const selectAll = () => {
setSelected(new Set(filteredChannels.map((ch) => ch.chatId)));
};
const deselectAll = () => {
setSelected(new Set());
};
const handleSave = () => {
if (!accountId || selected.size === 0) return;
const selectedChannels = channels
.filter((ch) => selected.has(ch.chatId))
.map((ch) => ({
telegramId: ch.chatId,
title: ch.title,
isForum: ch.isForum,
}));
startTransition(async () => {
const result = await saveChannelSelections(accountId, selectedChannels);
if (result.success) {
toast.success(`${selectedChannels.length} channel(s) linked as source`);
onOpenChange(false);
} else {
toast.error(result.error);
}
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Select Source Channels</DialogTitle>
<DialogDescription>
Choose which channels to scan for archives. Already-linked channels
are hidden.
</DialogDescription>
</DialogHeader>
{fetchState.phase === "fetching" && (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
Fetching channels from Telegram...
</p>
<p className="text-xs text-muted-foreground">
This may take a few seconds
</p>
</div>
)}
{fetchState.phase === "error" && (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<p className="text-sm text-destructive">{fetchState.message}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
// Reopen to re-trigger fetch
onOpenChange(false);
setTimeout(() => onOpenChange(true), 100);
}}
>
Retry
</Button>
</div>
)}
{fetchState.phase === "loaded" && (
<>
{channels.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12">
<p className="text-sm text-muted-foreground">
All channels are already linked to this account.
</p>
</div>
) : (
<>
{/* Search + bulk actions */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Filter channels..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" size="sm" onClick={selectAll}>
All
</Button>
<Button variant="outline" size="sm" onClick={deselectAll}>
None
</Button>
</div>
<p className="text-xs text-muted-foreground">
{filteredChannels.length} channel(s) available
{selected.size > 0 && ` \u2014 ${selected.size} selected`}
</p>
{/* Channel list */}
<ScrollArea className="flex-1 max-h-[400px] -mx-2 px-2">
<div className="space-y-1">
{filteredChannels.map((ch) => (
<label
key={ch.chatId}
className="flex items-center gap-3 rounded-md border p-3 cursor-pointer hover:bg-accent/50 transition-colors"
>
<Checkbox
checked={selected.has(ch.chatId)}
onCheckedChange={() => toggleChannel(ch.chatId)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{ch.title}
</span>
<Badge
variant="outline"
className="text-[10px] shrink-0"
>
{ch.type}
</Badge>
{ch.isForum && (
<Badge
variant="secondary"
className="text-[10px] shrink-0"
>
forum
</Badge>
)}
{!ch.existingChannelId && (
<Badge
variant="secondary"
className="text-[10px] bg-emerald-500/10 text-emerald-600 border-emerald-500/20 shrink-0"
>
new
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
ID: {ch.chatId}
{ch.memberCount ? ` \u2022 ${ch.memberCount} members` : ""}
</span>
</div>
</label>
))}
</div>
</ScrollArea>
</>
)}
</>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={
isPending ||
selected.size === 0 ||
fetchState.phase !== "loaded"
}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Link {selected.size} Channel{selected.size !== 1 ? "s" : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,32 +1,29 @@
"use client";
import { useState, useTransition } from "react";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { getChannelColumns } from "./channel-columns";
import { ChannelModal } from "./channel-modal";
import { deleteChannel, toggleChannelActive } from "../actions";
import { DestinationCard } from "./destination-card";
import {
deleteChannel,
toggleChannelActive,
setChannelType,
} from "../actions";
import { DataTable } from "@/components/shared/data-table";
import { DeleteDialog } from "@/components/shared/delete-dialog";
import { Button } from "@/components/ui/button";
import type { ChannelRow } from "@/lib/telegram/admin-queries";
import type { ChannelRow, GlobalDestination } from "@/lib/telegram/admin-queries";
import { useDataTable } from "@/hooks/use-data-table";
interface ChannelsTabProps {
channels: ChannelRow[];
globalDestination: GlobalDestination;
}
export function ChannelsTab({ channels }: ChannelsTabProps) {
export function ChannelsTab({ channels, globalDestination }: ChannelsTabProps) {
const [isPending, startTransition] = useTransition();
const [modalOpen, setModalOpen] = useState(false);
const [editChannel, setEditChannel] = useState<ChannelRow | undefined>();
const [deleteId, setDeleteId] = useState<string | null>(null);
const columns = getChannelColumns({
onEdit: (channel) => {
setEditChannel(channel);
setModalOpen(true);
},
onToggleActive: (id) => {
startTransition(async () => {
const result = await toggleChannelActive(id);
@@ -35,6 +32,13 @@ export function ChannelsTab({ channels }: ChannelsTabProps) {
});
},
onDelete: (id) => setDeleteId(id),
onSetType: (id, type) => {
startTransition(async () => {
const result = await setChannelType(id, type);
if (result.success) toast.success(`Channel set as ${type.toLowerCase()}`);
else toast.error(result.error);
});
},
});
const { table } = useDataTable({
@@ -58,30 +62,17 @@ export function ChannelsTab({ channels }: ChannelsTabProps) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button
onClick={() => {
setEditChannel(undefined);
setModalOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add Channel
</Button>
</div>
<DestinationCard destination={globalDestination} />
{channels.length > 0 && (
<p className="text-xs text-muted-foreground">
Source channels are added per-account via the &quot;Fetch Channels&quot; button on the Accounts tab.
</p>
)}
<DataTable
table={table}
emptyMessage="No channels configured. Add a Telegram channel to start ingesting."
/>
<ChannelModal
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) setEditChannel(undefined);
}}
channel={editChannel}
emptyMessage="No channels yet. Use &quot;Fetch Channels&quot; on an account to discover and add source channels."
/>
<DeleteDialog

View File

@@ -0,0 +1,287 @@
"use client";
import { useState, useEffect, useTransition } from "react";
import { Database, AlertTriangle, Link2, Plus, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { createDestinationViaWorker } from "../actions";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import type { GlobalDestination } from "@/lib/telegram/admin-queries";
interface DestinationCardProps {
destination: GlobalDestination;
}
type CreateState =
| { phase: "idle" }
| { phase: "creating"; requestId?: string }
| { phase: "done"; title: string; telegramId: string }
| { phase: "error"; message: string };
export function DestinationCard({ destination }: DestinationCardProps) {
const [isPending, startTransition] = useTransition();
const [createOpen, setCreateOpen] = useState(false);
const [title, setTitle] = useState("dragonsstash db");
const [createState, setCreateState] = useState<CreateState>({ phase: "idle" });
// Poll for worker result when creating
useEffect(() => {
if (createState.phase !== "creating" || !createState.requestId) return;
let mounted = true;
const requestId = createState.requestId;
const poll = async () => {
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 2000));
if (!mounted) return;
try {
const res = await fetch(
`/api/telegram/worker-request?requestId=${requestId}`
);
if (!res.ok) continue;
const data = await res.json();
if (data.status === "COMPLETED" && data.result) {
if (mounted) {
setCreateState({
phase: "done",
title: data.result.title,
telegramId: data.result.telegramId,
});
toast.success(`Telegram group "${data.result.title}" created and set as destination!`);
setCreateOpen(false);
// Refresh the page to show the new destination
window.location.reload();
}
return;
} else if (data.status === "FAILED") {
if (mounted) {
setCreateState({
phase: "error",
message: data.error || "Worker failed to create the group",
});
}
return;
}
} catch {
// Network blip — keep polling
}
}
if (mounted) {
setCreateState({ phase: "error", message: "Timed out waiting for the worker" });
}
};
poll();
return () => { mounted = false; };
}, [createState]);
const handleCreate = () => {
if (!title.trim()) return;
startTransition(async () => {
const result = await createDestinationViaWorker(title.trim());
if (result.success) {
setCreateState({ phase: "creating", requestId: result.data.requestId });
} else {
setCreateState({ phase: "error", message: result.error ?? "Unknown error" });
}
});
};
const handleOpenChange = (open: boolean) => {
setCreateOpen(open);
if (!open) {
// Reset state when closing (unless actively creating)
if (createState.phase !== "creating") {
setCreateState({ phase: "idle" });
}
}
};
if (!destination) {
return (
<>
<Card className="border-dashed border-yellow-500/40">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-500 shrink-0" />
<div>
<p className="text-sm font-medium">
No destination channel configured
</p>
<p className="text-xs text-muted-foreground">
Create a private Telegram group that all accounts will write
archives to. Requires at least one authenticated account.
</p>
</div>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Create Destination
</Button>
</CardContent>
</Card>
<CreateDestinationDialog
open={createOpen}
onOpenChange={handleOpenChange}
title={title}
setTitle={setTitle}
onSubmit={handleCreate}
createState={createState}
isPending={isPending}
/>
</>
);
}
return (
<>
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<Database className="h-5 w-5 text-purple-500 shrink-0" />
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{destination.title}</p>
<Badge
variant="outline"
className="bg-purple-500/10 text-purple-600 border-purple-500/20 text-[10px]"
>
DESTINATION
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>ID: {destination.telegramId}</span>
{destination.inviteLink && (
<span className="flex items-center gap-1">
<Link2 className="h-3 w-3" />
Invite link active
</span>
)}
</div>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCreateOpen(true)}
>
Change
</Button>
</CardContent>
</Card>
<CreateDestinationDialog
open={createOpen}
onOpenChange={handleOpenChange}
title={title}
setTitle={setTitle}
onSubmit={handleCreate}
createState={createState}
isPending={isPending}
/>
</>
);
}
function CreateDestinationDialog({
open,
onOpenChange,
title,
setTitle,
onSubmit,
createState,
isPending,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
setTitle: (v: string) => void;
onSubmit: () => void;
createState: CreateState;
isPending: boolean;
}) {
const isCreating = createState.phase === "creating";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create Destination Channel</DialogTitle>
<DialogDescription>
A private Telegram group will be created automatically using one of
your authenticated accounts. All accounts will write archives here.
</DialogDescription>
</DialogHeader>
{isCreating ? (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
Creating Telegram group...
</p>
<p className="text-xs text-muted-foreground">
This may take a few seconds
</p>
</div>
) : (
<div className="space-y-4">
{createState.phase === "error" && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="text-sm text-destructive">{createState.message}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="dest-title">Group Name</Label>
<Input
id="dest-title"
placeholder="e.g. dragonsstash db"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
This will be the name of the Telegram group. You can rename it later in Telegram.
</p>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isCreating}
>
Cancel
</Button>
<Button
onClick={onSubmit}
disabled={isPending || isCreating || !title.trim()}
>
{(isPending || isCreating) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Group
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,14 +4,23 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageHeader } from "@/components/shared/page-header";
import { AccountsTab } from "./accounts-tab";
import { ChannelsTab } from "./channels-tab";
import type { AccountRow, ChannelRow } from "@/lib/telegram/admin-queries";
import { WorkerStatusPanel } from "./worker-status-panel";
import type { AccountRow, ChannelRow, GlobalDestination } from "@/lib/telegram/admin-queries";
import type { IngestionAccountStatus } from "@/lib/telegram/types";
interface TelegramAdminProps {
accounts: AccountRow[];
channels: ChannelRow[];
ingestionStatus: IngestionAccountStatus[];
globalDestination: GlobalDestination;
}
export function TelegramAdmin({ accounts, channels }: TelegramAdminProps) {
export function TelegramAdmin({
accounts,
channels,
ingestionStatus,
globalDestination,
}: TelegramAdminProps) {
return (
<div className="space-y-4">
<PageHeader
@@ -19,6 +28,8 @@ export function TelegramAdmin({ accounts, channels }: TelegramAdminProps) {
description="Manage Telegram accounts, channels, and ingestion"
/>
<WorkerStatusPanel initialStatus={ingestionStatus} />
<Tabs defaultValue="accounts" className="space-y-4">
<TabsList>
<TabsTrigger value="accounts">
@@ -33,7 +44,7 @@ export function TelegramAdmin({ accounts, channels }: TelegramAdminProps) {
<AccountsTab accounts={accounts} />
</TabsContent>
<TabsContent value="channels">
<ChannelsTab channels={channels} />
<ChannelsTab channels={channels} globalDestination={globalDestination} />
</TabsContent>
</Tabs>
</div>

View File

@@ -0,0 +1,340 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
Loader2,
CheckCircle2,
XCircle,
Clock,
Radio,
AlertTriangle,
RefreshCw,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { IngestionAccountStatus } from "@/lib/telegram/types";
interface WorkerStatusPanelProps {
initialStatus: IngestionAccountStatus[];
}
const AUTH_STATE_CONFIG: Record<
string,
{ label: string; color: string; icon: string }
> = {
PENDING: { label: "Pending", color: "text-yellow-500", icon: "clock" },
AWAITING_CODE: {
label: "Awaiting Code",
color: "text-orange-500",
icon: "alert",
},
AWAITING_PASSWORD: {
label: "Awaiting Password",
color: "text-orange-500",
icon: "alert",
},
AUTHENTICATED: { label: "Connected", color: "text-emerald-500", icon: "check" },
EXPIRED: { label: "Expired", color: "text-red-500", icon: "x" },
};
export function WorkerStatusPanel({ initialStatus }: WorkerStatusPanelProps) {
const [accounts, setAccounts] = useState(initialStatus);
const [error, setError] = useState(false);
const [nextRunCountdown, setNextRunCountdown] = useState<string | null>(null);
// Find active run
const activeRun = accounts.find((a) => a.currentRun);
const isRunning = !!activeRun;
// Poll for status
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
let mounted = true;
const poll = async () => {
try {
const res = await fetch("/api/ingestion/status");
if (!res.ok) throw new Error("fetch failed");
const data = await res.json();
if (mounted) {
setAccounts(data.accounts ?? []);
setError(false);
}
} catch {
if (mounted) setError(true);
}
if (mounted) {
const interval = accounts.some((a) => a.currentRun) ? 2_000 : 10_000;
timer = setTimeout(poll, interval);
}
};
timer = setTimeout(poll, 2_000);
return () => {
mounted = false;
clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRunning]);
// Countdown timer to next run
useEffect(() => {
if (isRunning) {
setNextRunCountdown(null);
return;
}
// Estimate next run based on last run finish time + interval (5 min + up to 5 min jitter)
const lastFinished = accounts
.filter((a) => a.lastRun?.finishedAt)
.map((a) => new Date(a.lastRun!.finishedAt!).getTime())
.sort((a, b) => b - a)[0];
if (!lastFinished) {
setNextRunCountdown(null);
return;
}
const intervalMs = 5 * 60 * 1000; // 5 min base
const estimatedNext = lastFinished + intervalMs;
const tick = () => {
const remaining = estimatedNext - Date.now();
if (remaining <= 0) {
setNextRunCountdown("any moment...");
} else {
const mins = Math.floor(remaining / 60_000);
const secs = Math.floor((remaining % 60_000) / 1_000);
setNextRunCountdown(
mins > 0 ? `~${mins}m ${secs}s` : `~${secs}s`
);
}
};
tick();
const interval = setInterval(tick, 1_000);
return () => clearInterval(interval);
}, [isRunning, accounts]);
if (accounts.length === 0 && !error) {
return (
<Card>
<CardContent className="flex items-center gap-3 py-4">
<AlertTriangle className="h-5 w-5 text-yellow-500 shrink-0" />
<div>
<p className="text-sm font-medium">No accounts configured</p>
<p className="text-xs text-muted-foreground">
Add a Telegram account below to get started. You&apos;ll need your
phone number and the API credentials in your .env.local file.
</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="py-4 space-y-3">
{/* Account status row */}
<div className="flex items-center gap-4 flex-wrap">
{accounts.map((account) => {
const config = AUTH_STATE_CONFIG[account.authState] ?? AUTH_STATE_CONFIG.PENDING;
return (
<div key={account.id} className="flex items-center gap-2">
{config.icon === "check" && (
<CheckCircle2 className={cn("h-4 w-4", config.color)} />
)}
{config.icon === "clock" && (
<Clock className={cn("h-4 w-4", config.color)} />
)}
{config.icon === "alert" && (
<AlertTriangle className={cn("h-4 w-4", config.color)} />
)}
{config.icon === "x" && (
<XCircle className={cn("h-4 w-4", config.color)} />
)}
<span className="text-sm font-medium">
{account.displayName || account.phone}
</span>
<Badge
variant="outline"
className={cn("text-[10px]", config.color)}
>
{config.label}
</Badge>
</div>
);
})}
</div>
{/* Divider */}
<div className="border-t" />
{/* Worker activity */}
{error ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<XCircle className="h-3.5 w-3.5" />
<span>Could not reach worker status</span>
</div>
) : isRunning && activeRun?.currentRun ? (
<RunningStatus run={activeRun.currentRun} />
) : (
<IdleStatus accounts={accounts} nextRunCountdown={nextRunCountdown} />
)}
</CardContent>
</Card>
);
}
function RunningStatus({
run,
}: {
run: NonNullable<IngestionAccountStatus["currentRun"]>;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
<span className="text-sm font-medium text-primary truncate">
{run.currentActivity ?? "Working..."}
</span>
</div>
{/* Progress bar for downloads */}
{run.downloadPercent != null && run.downloadPercent > 0 && (
<div className="flex items-center gap-3 pl-6">
<div className="h-1.5 flex-1 max-w-[200px] rounded-full bg-primary/20">
<div
className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${Math.min(100, run.downloadPercent)}%` }}
/>
</div>
<span className="text-xs text-primary/70 tabular-nums">
{run.downloadPercent}%
</span>
</div>
)}
{/* Stats line */}
<div className="flex items-center gap-4 pl-6 text-xs text-muted-foreground">
{run.currentChannel && (
<span>
Channel: <span className="text-foreground">{run.currentChannel}</span>
</span>
)}
{run.totalFiles != null && run.currentFileNum != null && (
<span>
Archive{" "}
<span className="text-foreground tabular-nums">
{run.currentFileNum}/{run.totalFiles}
</span>
</span>
)}
{run.zipsIngested > 0 && (
<span>
<span className="text-foreground tabular-nums">{run.zipsIngested}</span> ingested
</span>
)}
{run.zipsDuplicate > 0 && (
<span>
<span className="text-foreground tabular-nums">{run.zipsDuplicate}</span> skipped
</span>
)}
</div>
</div>
);
}
function IdleStatus({
accounts,
nextRunCountdown,
}: {
accounts: IngestionAccountStatus[];
nextRunCountdown: string | null;
}) {
const lastRun = accounts
.filter((a) => a.lastRun)
.sort(
(a, b) =>
new Date(b.lastRun!.finishedAt ?? b.lastRun!.startedAt).getTime() -
new Date(a.lastRun!.finishedAt ?? a.lastRun!.startedAt).getTime()
)[0]?.lastRun;
const hasAuthenticated = accounts.some(
(a) => a.authState === "AUTHENTICATED"
);
return (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
{lastRun ? (
<>
{lastRun.status === "FAILED" ? (
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
)}
<span className="text-xs text-muted-foreground truncate">
{lastRun.status === "FAILED"
? `Last sync failed ${getTimeAgo(lastRun.finishedAt ?? lastRun.startedAt)}`
: `Last sync ${getTimeAgo(lastRun.finishedAt ?? lastRun.startedAt)}${lastRun.zipsIngested} new, ${lastRun.zipsDuplicate} skipped, ${lastRun.messagesScanned} messages`}
</span>
</>
) : hasAuthenticated ? (
<>
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground">
Waiting for first sync...
</span>
</>
) : accounts.some((a) => a.authState === "PENDING") ? (
<>
<Clock className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
<span className="text-xs text-muted-foreground">
Pending account detected worker will send an SMS code on the next cycle. Please wait...
</span>
</>
) : accounts.some(
(a) => a.authState === "AWAITING_CODE" || a.authState === "AWAITING_PASSWORD"
) ? (
<>
<AlertTriangle className="h-3.5 w-3.5 text-orange-500 shrink-0" />
<span className="text-xs text-muted-foreground">
Waiting for you to enter the auth code check the Accounts table below
</span>
</>
) : (
<>
<Radio className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground">
Worker idle authenticate an account to start syncing
</span>
</>
)}
</div>
{nextRunCountdown && hasAuthenticated && (
<div className="flex items-center gap-1.5 shrink-0">
<RefreshCw className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground tabular-nums">
Next: {nextRunCountdown}
</span>
</div>
)}
</div>
);
}
function getTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}

View File

@@ -258,6 +258,44 @@ export async function deleteChannel(id: string): Promise<ActionResult> {
}
}
export async function setChannelType(
id: string,
type: "SOURCE" | "DESTINATION"
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const existing = await prisma.telegramChannel.findUnique({ where: { id } });
if (!existing) return { success: false, error: "Channel not found" };
try {
await prisma.telegramChannel.update({
where: { id },
data: { type },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update channel type" };
}
}
export async function triggerChannelSync(): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
try {
// Signal the worker to do a channel sync via pg_notify
await prisma.$queryRawUnsafe(
`SELECT pg_notify('channel_sync', 'requested')`
);
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to trigger channel sync" };
}
}
// ── Account-Channel link actions ──
export async function linkChannel(
@@ -317,24 +355,42 @@ export async function triggerIngestion(
if (!admin.success) return admin;
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/ingestion/trigger`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.INGESTION_API_KEY || "",
},
body: JSON.stringify({ accountId }),
}
);
// Find eligible accounts
const where: { isActive: boolean; authState: "AUTHENTICATED"; id?: string } = {
isActive: true,
authState: "AUTHENTICATED",
};
if (accountId) where.id = accountId;
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return {
success: false,
error: (data as { error?: string }).error || "Failed to trigger ingestion",
};
const accounts = await prisma.telegramAccount.findMany({
where,
select: { id: true },
});
if (accounts.length === 0) {
return { success: false, error: "No eligible accounts found" };
}
// Create ingestion runs — the worker picks these up
for (const account of accounts) {
const existing = await prisma.ingestionRun.findFirst({
where: { accountId: account.id, status: "RUNNING" },
});
if (!existing) {
await prisma.ingestionRun.create({
data: { accountId: account.id, status: "RUNNING" },
});
}
}
// pg_notify for immediate worker pickup
try {
await prisma.$queryRawUnsafe(
`SELECT pg_notify('ingestion_trigger', $1)`,
accounts.map((a) => a.id).join(",")
);
} catch {
// Best-effort
}
revalidatePath(REVALIDATE_PATH);
@@ -343,3 +399,227 @@ export async function triggerIngestion(
return { success: false, error: "Failed to trigger ingestion" };
}
}
// ── Channel selection (from fetch results) ──
export async function saveChannelSelections(
accountId: string,
channels: { telegramId: string; title: string; isForum: boolean }[]
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const existing = await prisma.telegramAccount.findUnique({
where: { id: accountId },
});
if (!existing) return { success: false, error: "Account not found" };
try {
let linked = 0;
for (const ch of channels) {
// Upsert the channel record
const channel = await prisma.telegramChannel.upsert({
where: { telegramId: BigInt(ch.telegramId) },
create: {
telegramId: BigInt(ch.telegramId),
title: ch.title,
type: "SOURCE",
isForum: ch.isForum,
},
update: {
title: ch.title,
isForum: ch.isForum,
},
});
// Create READER link (idempotent)
try {
await prisma.accountChannelMap.create({
data: { accountId, channelId: channel.id, role: "READER" },
});
linked++;
} catch (err: unknown) {
// Unique constraint = already linked, that's fine
if (!(err instanceof Error && err.message.includes("Unique constraint"))) {
throw err;
}
}
}
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to save channel selections" };
}
}
// ── Global destination channel ──
export async function setGlobalDestination(
channelId: string
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const channel = await prisma.telegramChannel.findUnique({
where: { id: channelId },
});
if (!channel) return { success: false, error: "Channel not found" };
try {
// Set the channel type to DESTINATION
await prisma.telegramChannel.update({
where: { id: channelId },
data: { type: "DESTINATION" },
});
// Save as global destination
await prisma.globalSetting.upsert({
where: { key: "destination_channel_id" },
create: { key: "destination_channel_id", value: channelId },
update: { value: channelId },
});
// Auto-create WRITER links for all active authenticated accounts
const accounts = await prisma.telegramAccount.findMany({
where: { isActive: true, authState: "AUTHENTICATED" },
select: { id: true },
});
for (const account of accounts) {
try {
await prisma.accountChannelMap.create({
data: { accountId: account.id, channelId, role: "WRITER" },
});
} catch {
// Already linked — ignore
}
}
// Signal worker to generate invite link
try {
await prisma.$queryRawUnsafe(
`SELECT pg_notify('generate_invite', $1)`,
channelId
);
} catch {
// Best-effort
}
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to set global destination" };
}
}
export async function createDestinationChannel(
telegramId: string,
title: string
): Promise<ActionResult<{ id: string }>> {
const admin = await requireAdmin();
if (!admin.success) return admin;
try {
// Create the channel as DESTINATION
const channel = await prisma.telegramChannel.upsert({
where: { telegramId: BigInt(telegramId) },
create: {
telegramId: BigInt(telegramId),
title,
type: "DESTINATION",
},
update: {
title,
type: "DESTINATION",
},
});
// Set as global destination
await prisma.globalSetting.upsert({
where: { key: "destination_channel_id" },
create: { key: "destination_channel_id", value: channel.id },
update: { value: channel.id },
});
// Auto-create WRITER links for all active authenticated accounts
const accounts = await prisma.telegramAccount.findMany({
where: { isActive: true, authState: "AUTHENTICATED" },
select: { id: true },
});
for (const account of accounts) {
try {
await prisma.accountChannelMap.create({
data: { accountId: account.id, channelId: channel.id, role: "WRITER" },
});
} catch {
// Already linked
}
}
// Signal worker to generate invite link
try {
await prisma.$queryRawUnsafe(
`SELECT pg_notify('generate_invite', $1)`,
channel.id
);
} catch {
// Best-effort
}
revalidatePath(REVALIDATE_PATH);
return { success: true, data: { id: channel.id } };
} catch (err: unknown) {
if (
err instanceof Error &&
err.message.includes("Unique constraint failed")
) {
return { success: false, error: "A channel with this Telegram ID already exists" };
}
return { success: false, error: "Failed to create destination channel" };
}
}
/**
* Request the worker to create a new Telegram supergroup as the destination.
* Uses ChannelFetchRequest as a generic DB-mediated request with pg_notify.
* Returns the requestId so the UI can poll for completion.
*/
export async function createDestinationViaWorker(
title: string
): Promise<ActionResult<{ requestId: string }>> {
const admin = await requireAdmin();
if (!admin.success) return admin;
if (!title.trim()) return { success: false, error: "Title is required" };
try {
// Need at least one authenticated account for TDLib
const hasAccount = await prisma.telegramAccount.findFirst({
where: { isActive: true, authState: "AUTHENTICATED" },
select: { id: true },
});
if (!hasAccount) {
return { success: false, error: "At least one authenticated account is needed to create a Telegram group" };
}
// Create a fetch request to track progress (reusing the model as a generic worker request)
const fetchRequest = await prisma.channelFetchRequest.create({
data: {
accountId: hasAccount.id,
status: "PENDING",
},
});
// Signal worker via pg_notify
await prisma.$queryRawUnsafe(
`SELECT pg_notify('create_destination', $1)`,
JSON.stringify({ requestId: fetchRequest.id, title: title.trim() })
);
return { success: true, data: { requestId: fetchRequest.id } };
} catch {
return { success: false, error: "Failed to request destination creation" };
}
}

View File

@@ -1,6 +1,7 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { listAccounts, listChannels } from "@/lib/telegram/admin-queries";
import { listAccounts, listChannels, getGlobalDestination } from "@/lib/telegram/admin-queries";
import { getIngestionStatus } from "@/lib/telegram/queries";
import { TelegramAdmin } from "./_components/telegram-admin";
export default async function TelegramPage() {
@@ -8,10 +9,19 @@ export default async function TelegramPage() {
if (!session?.user?.id) redirect("/login");
if (session.user.role !== "ADMIN") redirect("/dashboard");
const [accounts, channels] = await Promise.all([
const [accounts, channels, ingestionStatus, globalDestination] = await Promise.all([
listAccounts(),
listChannels(),
getIngestionStatus(),
getGlobalDestination(),
]);
return <TelegramAdmin accounts={accounts} channels={channels} />;
return (
<TelegramAdmin
accounts={accounts}
channels={channels}
ingestionStatus={ingestionStatus}
globalDestination={globalDestination}
/>
);
}