From 96b7ce11c0dca8e4ecfe364a88ce2e6f7fdac72a Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 3 Jul 2026 21:18:12 +0700 Subject: [PATCH] feat: add tiktok polling system with docker cron --- app/api/cron/tiktok/route.ts | 68 +++++++++++++ app/api/webhooks/tiktok/route.ts | 161 ------------------------------- docker-compose.yml | 8 ++ lib/discord.ts | 35 +++++++ lib/followers/tiktok.ts | 44 +++++++++ package.json | 1 + 6 files changed, 156 insertions(+), 161 deletions(-) create mode 100644 app/api/cron/tiktok/route.ts delete mode 100644 app/api/webhooks/tiktok/route.ts create mode 100644 lib/discord.ts diff --git a/app/api/cron/tiktok/route.ts b/app/api/cron/tiktok/route.ts new file mode 100644 index 0000000..f81b725 --- /dev/null +++ b/app/api/cron/tiktok/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { getLatestTiktokVideo, getTiktokProfile } from "@/lib/followers/tiktok"; +import { getRedisClient } from "@/lib/redis"; +import { sendDiscordNotification } from "@/lib/discord"; + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + + // 1. Verify cron secret + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret || searchParams.get("secret") !== cronSecret) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // 2. Fetch the latest video from TikTok API + const latestVideo = await getLatestTiktokVideo(); + if (!latestVideo) { + return NextResponse.json({ message: "No videos found or failed to fetch." }, { status: 200 }); + } + + // 3. Check Redis to see if we've already notified about this video + const redis = await getRedisClient(); + const redisKey = "erika:tiktok_last_video_id"; + const lastVideoId = await redis.get(redisKey); + + if (lastVideoId === latestVideo.id) { + // Already notified + return NextResponse.json({ message: "No new videos.", id: latestVideo.id }, { status: 200 }); + } + + // 4. We have a new video! Prepare the notification + let officialAuthorName = "TikTok"; + let officialAvatarUrl: string | undefined = undefined; + try { + const profile = await getTiktokProfile(); + if (profile.displayName) officialAuthorName = profile.displayName; + if (profile.avatarUrl) officialAvatarUrl = profile.avatarUrl; + } catch (e) { + console.error("[TikTok Cron] Failed to fetch profile:", e); + } + + const message = `ลงคลิปใหม่แล้วววสสสสส ${latestVideo.shareUrl}`; + const embed = { + title: latestVideo.title || "คลิปใหม่จาก TikTok", + url: latestVideo.shareUrl || undefined, + color: 0xff0050, + author: { + name: officialAuthorName, + url: latestVideo.shareUrl || "https://www.tiktok.com/", + icon_url: officialAvatarUrl || undefined + }, + image: latestVideo.coverImageUrl ? { url: latestVideo.coverImageUrl } : undefined + }; + + // 5. Send Discord notification + await sendDiscordNotification(message, embed); + + // 6. Update Redis with the new video ID + // We don't set an expiry because we want to remember it forever (until the next video) + await redis.set(redisKey, latestVideo.id); + + return NextResponse.json({ success: true, message: "New video notified!", id: latestVideo.id }, { status: 200 }); + } catch (error) { + console.error("[TikTok Cron] Error processing request:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/app/api/webhooks/tiktok/route.ts b/app/api/webhooks/tiktok/route.ts deleted file mode 100644 index 4741eea..0000000 --- a/app/api/webhooks/tiktok/route.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { NextResponse } from "next/server"; -import { getTiktokProfile } from "@/lib/followers/tiktok"; - -export async function POST(req: Request) { - try { - if (process.env.TIKTOK_WEBHOOK_SECRET) { - const { searchParams } = new URL(req.url); - if (searchParams.get("secret") !== process.env.TIKTOK_WEBHOOK_SECRET) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - } - - // Read the body as text first in case it's not JSON or for signature verification if needed - const rawBody = await req.text(); - let body: any = {}; - - if (rawBody) { - try { - body = JSON.parse(rawBody); - } catch (e) { - console.error("[TikTok Webhook] Failed to parse JSON:", rawBody); - } - } - - console.log("[TikTok Webhook] Received payload:", JSON.stringify(body, null, 2)); - - const event = body.event || body.type || body.Event; - const data = body.data || body.Data || body; - - let message = ""; - let embed: any = null; - - // Fetch the user's official TikTok profile to use for author name & avatar - let officialAuthorName = "TikTok"; - let officialAvatarUrl: string | undefined = undefined; - try { - const profile = await getTiktokProfile(); - if (profile.displayName) officialAuthorName = profile.displayName; - if (profile.avatarUrl) officialAvatarUrl = profile.avatarUrl; - } catch (e) { - console.error("[TikTok Webhook] Failed to fetch profile:", e); - } - - // Check if it's a video upload or live event - if (typeof event === "string") { - const eventLower = event.toLowerCase(); - if (eventLower.includes("video") && (eventLower.includes("upload") || eventLower.includes("publish"))) { - const link = data.share_url || data.url || data.video_url || data.link || ""; - - let title = data.title || data.video_title || data.desc || "คลิปใหม่จาก TikTok"; - let thumbnailUrl = data.cover_url || data.thumbnail_url || ""; - - // Default to official profile info - let authorName = officialAuthorName; - let authorAvatar = officialAvatarUrl || ""; - - if (link && link.includes("tiktok.com")) { - try { - // Using tikwm API for video thumbnail and title - const oembedRes = await fetch(`https://www.tikwm.com/api/?url=${encodeURIComponent(link)}`); - if (oembedRes.ok) { - const oembedData = await oembedRes.json(); - if (oembedData.data) { - if (oembedData.data.title) title = oembedData.data.title; - if (oembedData.data.cover) thumbnailUrl = oembedData.data.cover; - // If the official API failed for some reason, fallback to tikwm author info - if (officialAuthorName === "TikTok" && oembedData.data.author?.nickname) authorName = oembedData.data.author.nickname; - if (!officialAvatarUrl && oembedData.data.author?.avatar) authorAvatar = oembedData.data.author.avatar; - } - } - } catch (e) { - console.error("[TikTok Webhook] Failed to fetch video info:", e); - } - } - - message = `ลงคลิปใหม่แล้วววสสสสส ${link}`; - embed = { - title: title, - url: link || undefined, - color: 0xff0050, - author: { - name: authorName, - url: link || "https://www.tiktok.com/", - icon_url: authorAvatar || undefined - }, - image: thumbnailUrl ? { url: thumbnailUrl } : undefined - }; - } else if (eventLower.includes("live")) { - const link = data.share_url || data.url || data.room_url || data.link || ""; - const title = data.title || data.live_title || data.desc || ""; - - message = `${link}`; - embed = { - title: title, - url: link || undefined, - color: 0xff0050, - author: { - name: officialAuthorName, - url: "https://www.tiktok.com/", - icon_url: officialAvatarUrl || undefined - }, - thumbnail: officialAvatarUrl ? { url: officialAvatarUrl } : undefined - }; - } - } else { - // Fallback: search payload keys for indicators - const payloadStr = JSON.stringify(body).toLowerCase(); - if (payloadStr.includes("video") && (payloadStr.includes("upload") || payloadStr.includes("publish"))) { - message = `คลิปใหม่!`; - } else if (payloadStr.includes("live")) { - message = `Live!`; - } - } - - if (message) { - await sendDiscordNotification(message, embed); - } - - return NextResponse.json({ success: true }, { status: 200 }); - } catch (error) { - console.error("[TikTok Webhook] Error processing request:", error); - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); - } -} - -async function sendDiscordNotification(content: string, embed?: any) { - const token = process.env.DISCORD_BOT_TOKEN; - const channelId = process.env.DISCORD_CHANNEL_ID; - - if (!token) { - console.error("[TikTok Webhook] Missing DISCORD_BOT_TOKEN"); - return; - } - - if (!channelId) { - console.error("[TikTok Webhook] Missing DISCORD_CHANNEL_ID environment variable. Please add it to your .env file."); - return; - } - - try { - const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { - method: "POST", - headers: { - "Authorization": `Bot ${token}`, - "Content-Type": "application/json" - }, - body: JSON.stringify({ - content, - embeds: embed ? [embed] : undefined - }) - }); - - if (!res.ok) { - console.error("[TikTok Webhook] Failed to send Discord message:", await res.text()); - } else { - console.log("[TikTok Webhook] Successfully sent Discord message"); - } - } catch (error) { - console.error("[TikTok Webhook] Error sending Discord message:", error); - } -} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0642b28..3763195 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,3 +13,11 @@ services: depends_on: - erika restart: unless-stopped + + cron: + image: curlimages/curl + network_mode: host + restart: unless-stopped + env_file: + - .env + command: /bin/sh -c "while true; do curl -s 'http://localhost:3000/api/cron/tiktok?secret=$${CRON_SECRET}' > /dev/null; sleep 300; done" diff --git a/lib/discord.ts b/lib/discord.ts new file mode 100644 index 0000000..835e5b9 --- /dev/null +++ b/lib/discord.ts @@ -0,0 +1,35 @@ +export async function sendDiscordNotification(content: string, embed?: any) { + const token = process.env.DISCORD_BOT_TOKEN; + const channelId = process.env.DISCORD_CHANNEL_ID; + + if (!token) { + console.error("[Discord Notification] Missing DISCORD_BOT_TOKEN"); + return; + } + + if (!channelId) { + console.error("[Discord Notification] Missing DISCORD_CHANNEL_ID environment variable. Please add it to your .env file."); + return; + } + + try { + const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { + method: "POST", + headers: { + "Authorization": `Bot ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + content, + embeds: embed ? [embed] : undefined + }) + }); + + if (!res.ok) { + console.error("[Discord Notification] Failed to send message:", await res.text()); + } else { + } + } catch (error) { + console.error("[Discord Notification] Error sending message:", error); + } +} diff --git a/lib/followers/tiktok.ts b/lib/followers/tiktok.ts index ef6ba94..855448b 100644 --- a/lib/followers/tiktok.ts +++ b/lib/followers/tiktok.ts @@ -100,3 +100,47 @@ export async function getTiktokFollowerCount(): Promise { const profile = await getTiktokProfile(); return profile.followerCount; } + +export interface TiktokVideo { + id: string; + title: string; + coverImageUrl: string; + shareUrl: string; + createTime: number; +} + +export async function getLatestTiktokVideo(): Promise { + const profile = await getTiktokProfile(); + if (!profile.username) { + console.error("[TikTok API] Could not get username from profile to fetch TikWM."); + return null; + } + + try { + const res = await fetch(`https://www.tikwm.com/api/user/posts?unique_id=@${profile.username}&count=1`, { + cache: "no-store", + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) return null; + + const data = await res.json(); + const videos = data.data?.videos; + + if (!videos || videos.length === 0) return null; + + const v = videos[0]; + const shareUrl = `https://www.tiktok.com/@${profile.username}/video/${v.video_id}`; + + return { + id: v.video_id, + title: v.title ?? "", + coverImageUrl: v.cover ?? "", + shareUrl: shareUrl, + createTime: v.create_time ?? 0, + }; + } catch (e) { + console.error("[TikTok API] Error fetching latest video via TikWM:", e); + return null; + } +} diff --git a/package.json b/package.json index 93f30cc..bfe6dfe 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint", "up": "docker compose up -d --build", "logs": "docker compose logs -f erika", + "log": "docker compose logs -f", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db": "drizzle-kit"