|
1 | 1 | import express from "express"; |
2 | | -import fetch from "node-fetch"; |
3 | | -import tmi from "tmi.js"; |
4 | 2 | import { WebSocketServer } from "ws"; |
5 | | -import path from "path"; |
| 3 | +import WebSocket from "ws"; |
6 | 4 |
|
7 | 5 | const app = express(); |
8 | 6 | const PORT = 3000; |
9 | | -const __dirname = process.cwd(); |
10 | | - |
11 | | -const activeChannels = {}; // { channel: { emotes:[], users:{}, emoteUsage:{} } } |
12 | 7 |
|
| 8 | +// Serve static HTML (index.html, CSS, JS) |
13 | 9 | app.use(express.static("public")); |
14 | 10 |
|
15 | | -// 🔹 Get 7TV emotes for a channel |
16 | | -async function get7TVEmotes(channel) { |
17 | | - try { |
18 | | - const res = await fetch(`https://7tv.io/v3/users/twitch/${channel}`); |
19 | | - const data = await res.json(); |
20 | | - return data.emote_set?.emotes?.map(e => ({ |
21 | | - name: e.name, |
22 | | - id: e.id, |
23 | | - url: `https://cdn.7tv.app/emote/${e.id}/4x.webp` |
24 | | - })) || []; |
25 | | - } catch (e) { |
26 | | - console.log("7TV fetch failed:", e); |
27 | | - return []; |
28 | | - } |
29 | | -} |
30 | | - |
31 | | -// 🔹 Get Twitch user info |
32 | | -async function getTwitchUser(username) { |
33 | | - try { |
34 | | - const res = await fetch(`https://7tv.io/v3/users/twitch/${username}`); |
35 | | - if (!res.ok) return null; |
36 | | - const data = await res.json(); |
37 | | - return { |
38 | | - id: data.id, |
39 | | - avatar: data.avatar_url || "https://static-cdn.jtvnw.net/jtv_user_pictures/xarth/404_user_70x70.png", |
40 | | - paint: data.style?.paint_id || null |
41 | | - }; |
42 | | - } catch { |
43 | | - return null; |
44 | | - } |
| 11 | +// Local memory |
| 12 | +let clients = []; |
| 13 | +let emoteUsage = {}; // { emoteName: count } |
| 14 | +let userUsage = {}; // { username: count } |
| 15 | + |
| 16 | +// Create HTTP + WebSocket server |
| 17 | +const server = app.listen(PORT, () => |
| 18 | + console.log(`💜 Server running at http://localhost:${PORT}`) |
| 19 | +); |
| 20 | +const wss = new WebSocketServer({ server }); |
| 21 | + |
| 22 | +// Broadcast data to all connected clients |
| 23 | +function broadcast(data) { |
| 24 | + const message = JSON.stringify(data); |
| 25 | + clients.forEach((ws) => { |
| 26 | + if (ws.readyState === ws.OPEN) ws.send(message); |
| 27 | + }); |
45 | 28 | } |
46 | 29 |
|
47 | | -// 🔹 Connect and track emotes in a channel |
48 | | -async function startTracking(channel) { |
49 | | - if (activeChannels[channel]) return activeChannels[channel]; |
50 | | - console.log(`Starting tracking for ${channel}`); |
51 | | - |
52 | | - const emotes = await get7TVEmotes(channel); |
53 | | - const users = {}; |
54 | | - const emoteUsage = {}; |
| 30 | +// Handle client dashboard connections |
| 31 | +wss.on("connection", (ws) => { |
| 32 | + clients.push(ws); |
| 33 | + console.log("🟣 New dashboard connected"); |
| 34 | + ws.send(JSON.stringify({ type: "init", emoteUsage, userUsage })); |
55 | 35 |
|
56 | | - const client = new tmi.Client({ |
57 | | - connection: { reconnect: true }, |
58 | | - channels: [channel] |
| 36 | + ws.on("close", () => { |
| 37 | + clients = clients.filter((c) => c !== ws); |
59 | 38 | }); |
| 39 | +}); |
60 | 40 |
|
61 | | - client.connect(); |
62 | | - |
63 | | - client.on("message", async (ch, tags, msg, self) => { |
64 | | - if (self) return; |
65 | | - const username = tags["display-name"] || tags.username; |
66 | | - |
67 | | - if (!users[username]) { |
68 | | - const info = await getTwitchUser(username); |
69 | | - users[username] = { |
70 | | - username, |
71 | | - id: info?.id || username, |
72 | | - avatar: info?.avatar, |
73 | | - paint: info?.paint, |
74 | | - count: 0 |
75 | | - }; |
76 | | - } |
| 41 | +// 🔹 Connect to StreamElements WebSocket |
| 42 | +const seSocket = new WebSocket("wss://realtime.streamelements.com/socket"); |
77 | 43 |
|
78 | | - const words = msg.split(/\s+/); |
79 | | - const used = emotes.filter(e => words.includes(e.name)); |
| 44 | +// When StreamElements connects |
| 45 | +seSocket.on("open", () => { |
| 46 | + console.log("💫 Connected to StreamElements Realtime API"); |
| 47 | +}); |
80 | 48 |
|
81 | | - if (used.length > 0) { |
82 | | - for (const e of used) { |
83 | | - users[username].count++; |
84 | | - emoteUsage[e.name] = (emoteUsage[e.name] || 0) + 1; |
85 | | - } |
86 | | - broadcast(channel, { type: "update", user: users[username], emoteUsage }); |
87 | | - } |
88 | | - }); |
| 49 | +// Handle StreamElements messages |
| 50 | +seSocket.on("message", (msg) => { |
| 51 | + try { |
| 52 | + const data = JSON.parse(msg); |
89 | 53 |
|
90 | | - activeChannels[channel] = { emotes, users, emoteUsage, client }; |
91 | | - return activeChannels[channel]; |
92 | | -} |
| 54 | + // Welcome event |
| 55 | + if (data.type === "welcome") { |
| 56 | + console.log("✅ Connected to StreamElements Realtime API (Session:", data.payload?.id, ")"); |
| 57 | + return; |
| 58 | + } |
93 | 59 |
|
94 | | -// 🔹 WebSocket setup |
95 | | -const wss = new WebSocketServer({ noServer: true }); |
96 | | -const sockets = new Set(); |
| 60 | + // Ping-Pong keepalive |
| 61 | + if (data.type === "ping") { |
| 62 | + seSocket.send(JSON.stringify({ type: "pong" })); |
| 63 | + return; |
| 64 | + } |
97 | 65 |
|
98 | | -function broadcast(channel, data) { |
99 | | - const msg = JSON.stringify({ channel, ...data }); |
100 | | - for (const ws of sockets) ws.send(msg); |
101 | | -} |
| 66 | + // Handle chat messages |
| 67 | + if (data.type === "event" && data.event?.type === "message") { |
| 68 | + const message = data.event.data; |
| 69 | + const username = message.nick || "UnknownUser"; |
| 70 | + const emotes = message.emotes || []; |
| 71 | + |
| 72 | + // Count message as 1 for user |
| 73 | + userUsage[username] = (userUsage[username] || 0) + 1; |
| 74 | + |
| 75 | + // Count emote usage |
| 76 | + emotes.forEach((em) => { |
| 77 | + const name = em.text || em.name || "unknown"; |
| 78 | + emoteUsage[name] = (emoteUsage[name] || 0) + 1; |
| 79 | + }); |
| 80 | + |
| 81 | + // Broadcast updates |
| 82 | + broadcast({ |
| 83 | + type: "update", |
| 84 | + userUsage, |
| 85 | + emoteUsage, |
| 86 | + }); |
| 87 | + } |
| 88 | + } catch (err) { |
| 89 | + console.error("Error handling StreamElements message:", err); |
| 90 | + } |
| 91 | +}); |
102 | 92 |
|
103 | | -const server = app.listen(PORT, () => { |
104 | | - console.log(`✅ Running at http://localhost:${PORT}`); |
| 93 | +seSocket.on("close", () => { |
| 94 | + console.log("⚠️ Disconnected from StreamElements Realtime API. Reconnecting in 5s..."); |
| 95 | + setTimeout(() => reconnectSE(), 5000); |
105 | 96 | }); |
106 | 97 |
|
107 | | -server.on("upgrade", (req, socket, head) => { |
108 | | - wss.handleUpgrade(req, socket, head, ws => { |
109 | | - sockets.add(ws); |
110 | | - ws.on("close", () => sockets.delete(ws)); |
111 | | - }); |
| 98 | +seSocket.on("error", (err) => { |
| 99 | + console.error("❌ StreamElements socket error:", err.message); |
112 | 100 | }); |
113 | 101 |
|
114 | | -// 🔹 API Routes |
115 | | -app.get("/api/channel/:channel", async (req, res) => { |
116 | | - const channel = req.params.channel.toLowerCase(); |
117 | | - const tracker = await startTracking(channel); |
118 | | - res.json({ |
119 | | - emotes: tracker.emotes, |
120 | | - users: Object.values(tracker.users), |
121 | | - emoteUsage: tracker.emoteUsage |
| 102 | +// Auto-reconnect function |
| 103 | +function reconnectSE() { |
| 104 | + const newSocket = new WebSocket("wss://realtime.streamelements.com/socket"); |
| 105 | + newSocket.on("open", () => { |
| 106 | + console.log("🔄 Reconnected to StreamElements API"); |
| 107 | + seSocket = newSocket; |
122 | 108 | }); |
123 | | -}); |
| 109 | +} |
124 | 110 |
|
125 | | -app.use(express.static(path.join(__dirname, "public"))); |
| 111 | +// Express API for debugging |
| 112 | +app.get("/api/data", (req, res) => { |
| 113 | + res.json({ users: userUsage, emotes: emoteUsage }); |
| 114 | +}); |
0 commit comments