feat: add Telegram integration with forum topic support and creator tracking

Adds full Telegram ZIP ingestion pipeline: TDLib worker service scans source
channels for archive files, deduplicates by content hash, extracts metadata,
uploads to archive channel, and indexes in Postgres. Forum supergroups are
scanned per-topic with topic names used as creator. Filename-based creator
extraction (e.g. "Mammoth Factory - 2026-01.zip") serves as fallback.

Includes admin UI for managing accounts/channels, simplified account setup
(API credentials via env vars), auth code/password submission dialog,
package browser with creator column, and live ingestion activity tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
xCyanGrizzly
2026-02-24 16:02:06 +01:00
parent beb9cfb312
commit b427193d17
70 changed files with 8627 additions and 2 deletions

View File

@@ -0,0 +1,184 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import {
MoreHorizontal,
Pencil,
Trash2,
Power,
Link2,
Play,
KeyRound,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { AccountRow } from "@/lib/telegram/admin-queries";
const authStateColors: Record<string, string> = {
PENDING: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20",
AWAITING_CODE: "bg-orange-500/10 text-orange-600 border-orange-500/20",
AWAITING_PASSWORD: "bg-orange-500/10 text-orange-600 border-orange-500/20",
AUTHENTICATED: "bg-green-500/10 text-green-600 border-green-500/20",
EXPIRED: "bg-red-500/10 text-red-600 border-red-500/20",
};
interface AccountColumnsProps {
onEdit: (account: AccountRow) => void;
onToggleActive: (id: string) => void;
onDelete: (id: string) => void;
onViewLinks: (id: string) => void;
onTriggerSync: (id: string) => void;
onEnterCode: (account: AccountRow) => void;
}
export function getAccountColumns({
onEdit,
onToggleActive,
onDelete,
onViewLinks,
onTriggerSync,
onEnterCode,
}: AccountColumnsProps): ColumnDef<AccountRow, unknown>[] {
return [
{
accessorKey: "displayName",
header: "Account",
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium">
{row.original.displayName || row.original.phone}
</span>
{row.original.displayName && (
<span className="text-xs text-muted-foreground">
{row.original.phone}
</span>
)}
</div>
),
enableHiding: false,
},
{
accessorKey: "authState",
header: "Auth State",
cell: ({ row }) => {
const needsCode =
row.original.authState === "AWAITING_CODE" ||
row.original.authState === "AWAITING_PASSWORD";
return (
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={authStateColors[row.original.authState] ?? ""}
>
{row.original.authState.replace(/_/g, " ")}
</Badge>
{needsCode && (
<Button
variant="outline"
size="sm"
className="h-6 gap-1 px-2 text-xs"
onClick={() => onEnterCode(row.original)}
>
<KeyRound className="h-3 w-3" />
Enter Code
</Button>
)}
</div>
);
},
},
{
accessorKey: "isActive",
header: "Status",
cell: ({ row }) => (
<Badge variant={row.original.isActive ? "default" : "secondary"}>
{row.original.isActive ? "Active" : "Disabled"}
</Badge>
),
},
{
id: "channels",
header: "Channels",
cell: ({ row }) => (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={() => onViewLinks(row.original.id)}
>
<Link2 className="h-3 w-3" />
{row.original.channelCount}
</Button>
),
},
{
id: "runs",
header: "Runs",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.runCount}
</span>
),
},
{
accessorKey: "lastSeenAt",
header: "Last Seen",
cell: ({ row }) =>
row.original.lastSeenAt ? (
<span className="text-sm text-muted-foreground">
{new Date(row.original.lastSeenAt).toLocaleDateString()}
</span>
) : (
<span className="text-sm text-muted-foreground">Never</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onViewLinks(row.original.id)}>
<Link2 className="mr-2 h-3.5 w-3.5" />
Manage Channels
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onTriggerSync(row.original.id)}>
<Play className="mr-2 h-3.5 w-3.5" />
Sync Now
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onToggleActive(row.original.id)}
>
<Power className="mr-2 h-3.5 w-3.5" />
{row.original.isActive ? "Disable" : "Enable"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(row.original.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableHiding: false,
},
];
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import {
telegramAccountSchema,
type TelegramAccountInput,
} from "@/schemas/telegram";
import { createAccount, updateAccount } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import type { AccountRow } from "@/lib/telegram/admin-queries";
interface AccountFormProps {
account?: AccountRow;
onSuccess: () => void;
}
export function AccountForm({ account, onSuccess }: AccountFormProps) {
const [isPending, startTransition] = useTransition();
const isEditing = !!account;
const form = useForm<TelegramAccountInput>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(telegramAccountSchema) as any,
defaultValues: {
phone: account?.phone ?? "",
displayName: account?.displayName ?? "",
},
});
function onSubmit(values: TelegramAccountInput) {
startTransition(async () => {
const result = isEditing
? await updateAccount(account!.id, values)
: await createAccount(values);
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(isEditing ? "Account updated" : "Account created");
form.reset();
onSuccess();
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone Number</FormLabel>
<FormControl>
<Input placeholder="+31612345678" {...field} />
</FormControl>
<FormDescription>
International format with country code
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input placeholder="My Bot Account" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,233 @@
"use client";
import { useState, useEffect, useTransition, useCallback } from "react";
import { Link2Off, Plus } from "lucide-react";
import { toast } from "sonner";
import { linkChannel, unlinkChannel } from "../actions";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
interface ChannelLink {
id: string;
channelId: string;
role: string;
lastProcessedMessageId: string | null;
channel: {
id: string;
title: string;
type: string;
telegramId: string;
};
}
interface UnlinkedChannel {
id: string;
title: string;
type: string;
telegramId: string;
}
interface AccountLinksDrawerProps {
accountId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AccountLinksDrawer({
accountId,
open,
onOpenChange,
}: AccountLinksDrawerProps) {
const [isPending, startTransition] = useTransition();
const [links, setLinks] = useState<ChannelLink[]>([]);
const [unlinked, setUnlinked] = useState<UnlinkedChannel[]>([]);
const [selectedChannelId, setSelectedChannelId] = useState("");
const [selectedRole, setSelectedRole] = useState<"READER" | "WRITER">("READER");
const [loading, setLoading] = useState(false);
const fetchLinks = useCallback(async () => {
if (!accountId) return;
setLoading(true);
try {
const [linksRes, unlinkedRes] = await Promise.all([
fetch(`/api/telegram/accounts/${accountId}/links`),
fetch(`/api/telegram/accounts/${accountId}/unlinked-channels`),
]);
if (linksRes.ok) setLinks(await linksRes.json());
if (unlinkedRes.ok) setUnlinked(await unlinkedRes.json());
} catch {
toast.error("Failed to load channel links");
}
setLoading(false);
}, [accountId]);
useEffect(() => {
if (open && accountId) {
fetchLinks();
}
}, [open, accountId, fetchLinks]);
const handleLink = () => {
if (!accountId || !selectedChannelId) return;
startTransition(async () => {
const result = await linkChannel({
accountId,
channelId: selectedChannelId,
role: selectedRole,
});
if (result.success) {
toast.success("Channel linked");
setSelectedChannelId("");
await fetchLinks();
} else {
toast.error(result.error);
}
});
};
const handleUnlink = (linkId: string) => {
startTransition(async () => {
const result = await unlinkChannel(linkId);
if (result.success) {
toast.success("Channel unlinked");
await fetchLinks();
} else {
toast.error(result.error);
}
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Manage Channel Links</DialogTitle>
<DialogDescription>
Link channels to this account. The account will read from Source
channels and write to Destination channels.
</DialogDescription>
</DialogHeader>
{/* Add new link */}
{unlinked.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-medium">Link a Channel</h4>
<div className="flex items-end gap-2">
<div className="flex-1">
<Select
value={selectedChannelId}
onValueChange={setSelectedChannelId}
>
<SelectTrigger>
<SelectValue placeholder="Select channel" />
</SelectTrigger>
<SelectContent>
{unlinked.map((ch) => (
<SelectItem key={ch.id} value={ch.id}>
{ch.title} ({ch.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select
value={selectedRole}
onValueChange={(v) => setSelectedRole(v as "READER" | "WRITER")}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="READER">Reader</SelectItem>
<SelectItem value="WRITER">Writer</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
disabled={!selectedChannelId || isPending}
onClick={handleLink}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Link
</Button>
</div>
<Separator />
</div>
)}
{/* Existing links */}
<div className="space-y-2">
<h4 className="text-sm font-medium">
Linked Channels ({links.length})
</h4>
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : links.length === 0 ? (
<p className="text-sm text-muted-foreground">
No channels linked to this account.
</p>
) : (
<div className="space-y-2">
{links.map((link) => (
<div
key={link.id}
className="flex items-center justify-between rounded-md border p-3"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{link.channel.title}
</span>
<Badge
variant="outline"
className={
link.channel.type === "SOURCE"
? "bg-blue-500/10 text-blue-600 border-blue-500/20"
: "bg-purple-500/10 text-purple-600 border-purple-500/20"
}
>
{link.channel.type}
</Badge>
<Badge variant="secondary" className="text-[10px]">
{link.role}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
ID: {link.channel.telegramId}
{link.lastProcessedMessageId &&
` | Last msg: ${link.lastProcessedMessageId}`}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
disabled={isPending}
onClick={() => handleUnlink(link.id)}
>
<Link2Off className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { AccountForm } from "./account-form";
import type { AccountRow } from "@/lib/telegram/admin-queries";
interface AccountModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
account?: AccountRow;
}
export function AccountModal({
open,
onOpenChange,
account,
}: AccountModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{account ? "Edit Account" : "Add Telegram Account"}
</DialogTitle>
<DialogDescription>
{account
? "Update the account details below."
: "Configure a new Telegram account for ingestion. You'll need an API ID and hash from my.telegram.org."}
</DialogDescription>
</DialogHeader>
<AccountForm
account={account}
onSuccess={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,140 @@
"use client";
import { useState, useTransition } from "react";
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 { deleteAccount, toggleAccountActive, triggerIngestion } from "../actions";
import { DataTable } from "@/components/shared/data-table";
import { DeleteDialog } from "@/components/shared/delete-dialog";
import { Button } from "@/components/ui/button";
import type { AccountRow } from "@/lib/telegram/admin-queries";
import { useDataTable } from "@/hooks/use-data-table";
interface AccountsTabProps {
accounts: AccountRow[];
}
export function AccountsTab({ accounts }: AccountsTabProps) {
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 columns = getAccountColumns({
onEdit: (account) => {
setEditAccount(account);
setModalOpen(true);
},
onToggleActive: (id) => {
startTransition(async () => {
const result = await toggleAccountActive(id);
if (result.success) toast.success("Account toggled");
else toast.error(result.error);
});
},
onDelete: (id) => setDeleteId(id),
onViewLinks: (id) => setLinksAccountId(id),
onEnterCode: (account) => setAuthCodeAccount(account),
onTriggerSync: (id) => {
startTransition(async () => {
const result = await triggerIngestion(id);
if (result.success) toast.success("Ingestion triggered");
else toast.error(result.error);
});
},
});
const { table } = useDataTable({
data: accounts,
columns,
pageCount: 1,
});
const handleDelete = () => {
if (!deleteId) return;
startTransition(async () => {
const result = await deleteAccount(deleteId);
if (result.success) {
toast.success("Account deleted");
setDeleteId(null);
} else {
toast.error(result.error);
}
});
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button
onClick={() => {
setEditAccount(undefined);
setModalOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add Account
</Button>
<Button
variant="outline"
disabled={isPending}
onClick={() => {
startTransition(async () => {
const result = await triggerIngestion();
if (result.success) toast.success("Ingestion triggered for all accounts");
else toast.error(result.error);
});
}}
>
<Play className="mr-2 h-4 w-4" />
Sync All
</Button>
</div>
<DataTable
table={table}
emptyMessage="No accounts configured. Add your first Telegram account."
/>
<AccountModal
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) setEditAccount(undefined);
}}
account={editAccount}
/>
<DeleteDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
title="Delete Account"
description="This will permanently delete this Telegram account and all its channel links. Existing packages will NOT be deleted."
onConfirm={handleDelete}
isLoading={isPending}
/>
<AccountLinksDrawer
accountId={linksAccountId}
open={!!linksAccountId}
onOpenChange={(open) => {
if (!open) setLinksAccountId(null);
}}
/>
<AuthCodeDialog
account={authCodeAccount}
open={!!authCodeAccount}
onOpenChange={(open) => {
if (!open) setAuthCodeAccount(null);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { submitAuthCode } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import type { AccountRow } from "@/lib/telegram/admin-queries";
interface AuthCodeDialogProps {
account: AccountRow | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AuthCodeDialog({
account,
open,
onOpenChange,
}: AuthCodeDialogProps) {
const [code, setCode] = useState("");
const [isPending, startTransition] = useTransition();
const isPassword = account?.authState === "AWAITING_PASSWORD";
const title = isPassword ? "Enter 2FA Password" : "Enter Auth Code";
const description = isPassword
? "Your Telegram account requires a two-factor authentication password."
: "Enter the code sent to your Telegram app or SMS.";
const placeholder = isPassword ? "Password" : "12345";
function handleSubmit() {
if (!account || !code.trim()) return;
startTransition(async () => {
const result = await submitAuthCode(account.id, { code: code.trim() });
if (result.success) {
toast.success(isPassword ? "Password submitted" : "Code submitted");
setCode("");
onOpenChange(false);
} else {
toast.error(result.error);
}
});
}
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) setCode("");
onOpenChange(v);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="auth-code">
{isPassword ? "Password" : "Code"}
</Label>
<Input
id="auth-code"
type={isPassword ? "password" : "text"}
placeholder={placeholder}
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
autoFocus
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isPending || !code.trim()}
>
{isPending ? "Submitting..." : "Submit"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,132 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import {
MoreHorizontal,
Pencil,
Trash2,
Power,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { ChannelRow } from "@/lib/telegram/admin-queries";
interface ChannelColumnsProps {
onEdit: (channel: ChannelRow) => void;
onToggleActive: (id: string) => void;
onDelete: (id: string) => void;
}
export function getChannelColumns({
onEdit,
onToggleActive,
onDelete,
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
return [
{
accessorKey: "title",
header: "Channel",
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium">{row.original.title}</span>
<span className="text-xs text-muted-foreground">
ID: {row.original.telegramId}
</span>
</div>
),
enableHiding: false,
},
{
accessorKey: "type",
header: "Type",
cell: ({ row }) => (
<Badge
variant="outline"
className={
row.original.type === "SOURCE"
? "bg-blue-500/10 text-blue-600 border-blue-500/20"
: "bg-purple-500/10 text-purple-600 border-purple-500/20"
}
>
{row.original.type}
</Badge>
),
},
{
accessorKey: "isActive",
header: "Status",
cell: ({ row }) => (
<Badge variant={row.original.isActive ? "default" : "secondary"}>
{row.original.isActive ? "Active" : "Disabled"}
</Badge>
),
},
{
id: "accounts",
header: "Accounts",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.accountCount}
</span>
),
},
{
id: "packages",
header: "Packages",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.packageCount}
</span>
),
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{new Date(row.original.createdAt).toLocaleDateString()}
</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onToggleActive(row.original.id)}
>
<Power className="mr-2 h-3.5 w-3.5" />
{row.original.isActive ? "Disable" : "Enable"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(row.original.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableHiding: false,
},
];
}

View File

@@ -0,0 +1,142 @@
"use client";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import {
telegramChannelSchema,
type TelegramChannelInput,
} from "@/schemas/telegram";
import { createChannel, updateChannel } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import type { ChannelRow } from "@/lib/telegram/admin-queries";
interface ChannelFormProps {
channel?: ChannelRow;
onSuccess: () => void;
}
export function ChannelForm({ channel, onSuccess }: ChannelFormProps) {
const [isPending, startTransition] = useTransition();
const isEditing = !!channel;
const form = useForm<TelegramChannelInput>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(telegramChannelSchema) as any,
defaultValues: {
telegramId: channel ? Number(channel.telegramId) : (0 as unknown as number),
title: channel?.title ?? "",
type: channel?.type ?? "SOURCE",
},
});
function onSubmit(values: TelegramChannelInput) {
startTransition(async () => {
const result = isEditing
? await updateChannel(channel!.id, values)
: await createChannel(values);
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(isEditing ? "Channel updated" : "Channel created");
form.reset();
onSuccess();
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Channel name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="telegramId"
render={({ field }) => (
<FormItem>
<FormLabel>Telegram ID</FormLabel>
<FormControl>
<Input
type="number"
placeholder="1234567890"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormDescription>
Numeric ID of the Telegram channel or group
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="SOURCE">Source (read archives)</SelectItem>
<SelectItem value="DESTINATION">
Destination (forward indexed)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ChannelForm } from "./channel-form";
import type { ChannelRow } from "@/lib/telegram/admin-queries";
interface ChannelModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
channel?: ChannelRow;
}
export function ChannelModal({
open,
onOpenChange,
channel,
}: ChannelModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{channel ? "Edit Channel" : "Add Channel"}
</DialogTitle>
<DialogDescription>
{channel
? "Update the channel details below."
: "Add a Telegram channel. Source channels are scanned for archives, destination channels receive indexed files."}
</DialogDescription>
</DialogHeader>
<ChannelForm
channel={channel}
onSuccess={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,97 @@
"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 { 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 { useDataTable } from "@/hooks/use-data-table";
interface ChannelsTabProps {
channels: ChannelRow[];
}
export function ChannelsTab({ channels }: 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);
if (result.success) toast.success("Channel toggled");
else toast.error(result.error);
});
},
onDelete: (id) => setDeleteId(id),
});
const { table } = useDataTable({
data: channels,
columns,
pageCount: 1,
});
const handleDelete = () => {
if (!deleteId) return;
startTransition(async () => {
const result = await deleteChannel(deleteId);
if (result.success) {
toast.success("Channel deleted");
setDeleteId(null);
} else {
toast.error(result.error);
}
});
};
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>
<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}
/>
<DeleteDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
title="Delete Channel"
description="This will permanently delete this channel and unlink it from all accounts. Existing packages will NOT be deleted."
onConfirm={handleDelete}
isLoading={isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
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";
interface TelegramAdminProps {
accounts: AccountRow[];
channels: ChannelRow[];
}
export function TelegramAdmin({ accounts, channels }: TelegramAdminProps) {
return (
<div className="space-y-4">
<PageHeader
title="Telegram"
description="Manage Telegram accounts, channels, and ingestion"
/>
<Tabs defaultValue="accounts" className="space-y-4">
<TabsList>
<TabsTrigger value="accounts">
Accounts ({accounts.length})
</TabsTrigger>
<TabsTrigger value="channels">
Channels ({channels.length})
</TabsTrigger>
</TabsList>
<TabsContent value="accounts">
<AccountsTab accounts={accounts} />
</TabsContent>
<TabsContent value="channels">
<ChannelsTab channels={channels} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,345 @@
"use server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import type { ActionResult } from "@/types/api.types";
import {
telegramAccountSchema,
telegramChannelSchema,
linkChannelSchema,
submitAuthCodeSchema,
} from "@/schemas/telegram";
const REVALIDATE_PATH = "/telegram";
async function requireAdmin(): Promise<
{ success: true; userId: string } | { success: false; error: string }
> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
if (session.user.role !== "ADMIN")
return { success: false, error: "Admin access required" };
return { success: true, userId: session.user.id };
}
// ── Account actions ──
export async function createAccount(
input: unknown
): Promise<ActionResult<{ id: string }>> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const parsed = telegramAccountSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
try {
const account = await prisma.telegramAccount.create({
data: {
phone: parsed.data.phone.replace(/[\s\-]/g, ""),
displayName: parsed.data.displayName || null,
},
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: { id: account.id } };
} catch (err: unknown) {
if (
err instanceof Error &&
err.message.includes("Unique constraint failed")
) {
return { success: false, error: "Phone number already registered" };
}
return { success: false, error: "Failed to create account" };
}
}
export async function updateAccount(
id: string,
input: unknown
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const parsed = telegramAccountSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
const existing = await prisma.telegramAccount.findUnique({ where: { id } });
if (!existing) return { success: false, error: "Account not found" };
try {
await prisma.telegramAccount.update({
where: { id },
data: {
phone: parsed.data.phone.replace(/[\s\-]/g, ""),
displayName: parsed.data.displayName || null,
},
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch (err: unknown) {
if (
err instanceof Error &&
err.message.includes("Unique constraint failed")
) {
return { success: false, error: "Phone number already registered" };
}
return { success: false, error: "Failed to update account" };
}
}
export async function toggleAccountActive(id: string): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const existing = await prisma.telegramAccount.findUnique({ where: { id } });
if (!existing) return { success: false, error: "Account not found" };
try {
await prisma.telegramAccount.update({
where: { id },
data: { isActive: !existing.isActive },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to toggle account" };
}
}
export async function deleteAccount(id: string): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const existing = await prisma.telegramAccount.findUnique({ where: { id } });
if (!existing) return { success: false, error: "Account not found" };
try {
await prisma.telegramAccount.delete({ where: { id } });
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to delete account" };
}
}
export async function submitAuthCode(
accountId: string,
input: unknown
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const parsed = submitAuthCodeSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
const existing = await prisma.telegramAccount.findUnique({
where: { id: accountId },
});
if (!existing) return { success: false, error: "Account not found" };
if (
existing.authState !== "AWAITING_CODE" &&
existing.authState !== "AWAITING_PASSWORD"
) {
return { success: false, error: "Account is not waiting for a code" };
}
try {
await prisma.telegramAccount.update({
where: { id: accountId },
data: { authCode: parsed.data.code },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to submit code" };
}
}
// ── Channel actions ──
export async function createChannel(
input: unknown
): Promise<ActionResult<{ id: string }>> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const parsed = telegramChannelSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
try {
const channel = await prisma.telegramChannel.create({
data: {
telegramId: BigInt(parsed.data.telegramId),
title: parsed.data.title,
type: parsed.data.type,
},
});
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: "Channel with this Telegram ID already exists" };
}
return { success: false, error: "Failed to create channel" };
}
}
export async function updateChannel(
id: string,
input: unknown
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const parsed = telegramChannelSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
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: {
telegramId: BigInt(parsed.data.telegramId),
title: parsed.data.title,
type: parsed.data.type,
},
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch (err: unknown) {
if (
err instanceof Error &&
err.message.includes("Unique constraint failed")
) {
return { success: false, error: "Channel with this Telegram ID already exists" };
}
return { success: false, error: "Failed to update channel" };
}
}
export async function toggleChannelActive(id: string): 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: { isActive: !existing.isActive },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to toggle channel" };
}
}
export async function deleteChannel(id: string): 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.delete({ where: { id } });
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to delete channel" };
}
}
// ── Account-Channel link actions ──
export async function linkChannel(
input: unknown
): Promise<ActionResult<{ id: string }>> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const parsed = linkChannelSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
try {
const link = await prisma.accountChannelMap.create({
data: {
accountId: parsed.data.accountId,
channelId: parsed.data.channelId,
role: parsed.data.role,
},
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: { id: link.id } };
} catch (err: unknown) {
if (
err instanceof Error &&
err.message.includes("Unique constraint failed")
) {
return { success: false, error: "This channel is already linked to this account" };
}
return { success: false, error: "Failed to link channel" };
}
}
export async function unlinkChannel(id: string): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const existing = await prisma.accountChannelMap.findUnique({
where: { id },
});
if (!existing) return { success: false, error: "Link not found" };
try {
await prisma.accountChannelMap.delete({ where: { id } });
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to unlink channel" };
}
}
// ── Ingestion trigger ──
export async function triggerIngestion(
accountId?: string
): Promise<ActionResult> {
const admin = await requireAdmin();
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 }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return {
success: false,
error: (data as { error?: string }).error || "Failed to trigger ingestion",
};
}
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to trigger ingestion" };
}
}

View File

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