diff --git a/src/main/index.js b/src/main/index.js index 760f26c..c268edd 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -133,6 +133,33 @@ try { console.warn('[Telemetry]: Failed to set service.version from package/app version:', e?.message || e); } +// Telemetry debug configuration for main process +const getTelemetryDebug = () => { + try { + // Check consolidated VITE_ vars first (available to all processes), then fall back to old vars + return import.meta.env.VITE_TELEMETRY_DEBUG === 'true' || + import.meta.env.MAIN_VITE_TELEMETRY_DEBUG === 'true'; + } catch { + return false; + } +}; + +const getTelemetryLevel = () => { + try { + // Check consolidated VITE_ vars first (available to all processes), then fall back to old vars + const level = import.meta.env.VITE_TELEMETRY_LEVEL || + import.meta.env.MAIN_VITE_TELEMETRY_LEVEL || + 'NORMAL'; + return level.toUpperCase(); + } catch { + return 'NORMAL'; + } +}; + +const shouldLogDebug = () => { + return getTelemetryDebug() || getTelemetryLevel() === 'VERBOSE'; +}; + // Load metrics with fallback let metrics = null; try { @@ -686,8 +713,10 @@ ipcMain.handle("otel:trace-export-json", async (_e, exportJson) => { const startedAt = Date.now(); try { - console.log(`[OTEL IPC Relay][${requestId}] Received trace export from renderer`); - console.log(`[OTEL IPC Relay][${requestId}] Payload size: ${JSON.stringify(exportJson || {}).length} chars`); + if (shouldLogDebug()) { + console.log(`[OTEL IPC Relay][${requestId}] Received trace export from renderer`); + console.log(`[OTEL IPC Relay][${requestId}] Payload size: ${JSON.stringify(exportJson || {}).length} chars`); + } const base = import.meta.env.MAIN_VITE_OTEL_EXPORTER_OTLP_ENDPOINT || process.env.OTEL_EXPORTER_OTLP_ENDPOINT || ""; const endpoint = import.meta.env.MAIN_VITE_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || @@ -732,7 +761,9 @@ ipcMain.handle("otel:trace-export-json", async (_e, exportJson) => { timeout: 15000, }; - console.log(`[OTEL IPC Relay][${requestId}] → POST ${url.hostname}${options.path}`); + if (shouldLogDebug()) { + console.log(`[OTEL IPC Relay][${requestId}] → POST ${url.hostname}${options.path}`); + } const result = await new Promise((resolve, reject) => { const req = https.request(options, (res) => { @@ -742,7 +773,9 @@ ipcMain.handle("otel:trace-export-json", async (_e, exportJson) => { const ms = Date.now() - startedAt; const responseBody = Buffer.concat(chunks).toString("utf8"); - console.log(`[OTEL IPC Relay][${requestId}] ← ${res.statusCode} (${ms}ms)`); + if (shouldLogDebug()) { + console.log(`[OTEL IPC Relay][${requestId}] ← ${res.statusCode} (${ms}ms)`); + } resolve({ statusCode: res.statusCode || 0, responseBody }); }); }); @@ -762,7 +795,9 @@ ipcMain.handle("otel:trace-export-json", async (_e, exportJson) => { }); const success = result.statusCode >= 200 && result.statusCode < 300; - console.log(`[OTEL IPC Relay][${requestId}] Result: ${success ? 'success' : 'failed'}`); + if (shouldLogDebug()) { + console.log(`[OTEL IPC Relay][${requestId}] Result: ${success ? 'success' : 'failed'}`); + } return { ok: success, status: result.statusCode, requestId }; } catch (e) { diff --git a/src/preload/index.js b/src/preload/index.js index a7e23b5..f1900b1 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -35,7 +35,7 @@ import { getUpdateTitle, getClearChatroom, } from "../../utils/services/kick/kickAPI"; -import { getUserStvProfile, getChannelEmotes } from "../../utils/services/seventv/stvAPI"; +import { getUserStvProfile, getChannelEmotes, getChannelCosmetics } from "../../utils/services/seventv/stvAPI"; import Store from "electron-store"; @@ -498,6 +498,7 @@ if (process.contextIsolated) { // 7TV API stv: { getChannelEmotes, + getChannelCosmetics, }, // Utility functions diff --git a/src/renderer/src/components/Messages/Message.jsx b/src/renderer/src/components/Messages/Message.jsx index b7d551a..ed20035 100644 --- a/src/renderer/src/components/Messages/Message.jsx +++ b/src/renderer/src/components/Messages/Message.jsx @@ -4,7 +4,7 @@ import ModActionMessage from "./ModActionMessage"; import RegularMessage from "./RegularMessage"; import EmoteUpdateMessage from "./EmoteUpdateMessage"; import clsx from "clsx"; -import { useShallow } from "zustand/shallow"; +import { useShallow } from "zustand/react/shallow"; import useCosmeticsStore from "../../providers/CosmeticsProvider"; import useChatStore from "../../providers/ChatProvider"; import ReplyMessage from "./ReplyMessage"; @@ -41,15 +41,17 @@ const Message = ({ const getDeleteMessage = useChatStore(useShallow((state) => state.getDeleteMessage)); const [rightClickedEmote, setRightClickedEmote] = useState(null); - let userStyle; + const subscribedUserStyle = useCosmeticsStore( + useShallow((state) => { + if (!message?.sender || type === "replyThread" || type === "dialog") { + return null; + } - if (message?.sender && type !== "replyThread") { - if (type === "dialog") { - userStyle = dialogUserStyle; - } else { - userStyle = useCosmeticsStore(useShallow((state) => state.getUserStyle(message?.sender?.username))); - } - } + return state.getUserStyle(message.sender.username); + }), + ); + + const userStyle = type === "dialog" ? dialogUserStyle : subscribedUserStyle; // CheckIcon if user can moderate const canModerate = useMemo( diff --git a/src/renderer/src/components/Messages/ModActionMessage.jsx b/src/renderer/src/components/Messages/ModActionMessage.jsx index fc41b25..6d7af53 100644 --- a/src/renderer/src/components/Messages/ModActionMessage.jsx +++ b/src/renderer/src/components/Messages/ModActionMessage.jsx @@ -1,11 +1,9 @@ import { useCallback } from "react"; import { convertMinutesToHumanReadable } from "../../utils/ChatUtils"; import useCosmeticsStore from "../../providers/CosmeticsProvider"; -import { useShallow } from "zustand/react/shallow"; const ModActionMessage = ({ message, chatroomId, allStvEmotes, subscriberBadges, chatroomName, userChatroomInfo }) => { const { modAction, modActionDetails } = message; - const getUserStyle = useCosmeticsStore(useShallow((state) => state.getUserStyle)); const actionTaker = modActionDetails?.banned_by?.username || modActionDetails?.unbanned_by?.username; const moderator = actionTaker !== "moderated" ? actionTaker : "Bot"; @@ -20,7 +18,7 @@ const ModActionMessage = ({ message, chatroomId, allStvEmotes, subscriberBadges, const user = await window.app.kick.getUserChatroomInfo(chatroomName, usernameDialog); if (!user?.data?.id) return; - const userStyle = getUserStyle(usernameDialog); + const userStyle = useCosmeticsStore.getState().getUserStyle(usernameDialog); const userDialogInfo = { id: user.data.id, diff --git a/src/renderer/src/providers/ChatProvider.jsx b/src/renderer/src/providers/ChatProvider.jsx index 5b48457..f7b86ef 100644 --- a/src/renderer/src/providers/ChatProvider.jsx +++ b/src/renderer/src/providers/ChatProvider.jsx @@ -551,7 +551,9 @@ const useChatStore = create((set, get) => ({ if (stvPresenceUpdates.has(userId)) { const lastUpdateTime = stvPresenceUpdates.get(userId); - console.log("[7tv Presence]: Last update time for chatroom:", userId, lastUpdateTime, stvPresenceUpdates); + if (window.__KT_TELEMETRY_UTILS__?.shouldLogDebug?.()) { + console.log("[7tv Presence]: Last update time for chatroom:", userId, lastUpdateTime, stvPresenceUpdates); + } if (currentTime - lastUpdateTime < PRESENCE_UPDATE_INTERVAL) { return; } @@ -1225,6 +1227,24 @@ const useChatStore = create((set, get) => ({ if (pusher.chat.OPEN) { const channel7TVEmotes = await window.app.stv.getChannelEmotes(chatroom.streamerData.user_id); + // Load initial cosmetics (badges and paints) for the channel + try { + const channelCosmetics = await window.app.stv.getChannelCosmetics(chatroom.streamerData.user_id); + console.log('[7TV Cosmetics] Initial cosmetics loaded:', { + badges: channelCosmetics?.badges?.length || 0, + paints: channelCosmetics?.paints?.length || 0 + }); + + // Add cosmetics to the store + const addCosmetics = useCosmeticsStore?.getState()?.addCosmetics; + if (addCosmetics && channelCosmetics && (channelCosmetics.badges?.length > 0 || channelCosmetics.paints?.length > 0)) { + addCosmetics(channelCosmetics); + console.log('[7TV Cosmetics] Added initial cosmetics to store'); + } + } catch (error) { + console.error('[7TV Cosmetics] Failed to load initial cosmetics:', error); + } + if (channel7TVEmotes) { const seenEmoteNames = new Set(); @@ -1509,14 +1529,21 @@ const useChatStore = create((set, get) => ({ // 7TV event handlers onStvMessage: (event) => { try { - const { chatroomId } = event.detail; + const { chatroomId, type, body } = event.detail; if (chatroomId) { get().handleStvMessage(chatroomId, event.detail); } else { - // Broadcast to all chatrooms if no specific chatroom - chatrooms.forEach(chatroom => { - get().handleStvMessage(chatroom.id, event.detail); - }); + // Handle global cosmetic events once instead of broadcasting to all chatrooms + if (type === 'cosmetic.create' || type === 'entitlement.create' || type === 'entitlement.delete') { + console.log(`[ChatProvider] Processing global ${type} event for ${body?.object?.user?.username || 'unknown'}`); + // Handle once with a null chatroomId to indicate global event + get().handleStvMessage(null, event.detail); + } else { + // Broadcast to all chatrooms if no specific chatroom (for non-cosmetic events) + chatrooms.forEach(chatroom => { + get().handleStvMessage(chatroom.id, event.detail); + }); + } } } catch (error) { console.error("[ChatProvider] Error handling 7TV message:", error); @@ -1625,7 +1652,9 @@ const useChatStore = create((set, get) => ({ get().connectToChatroom(chatroom); // Connect to 7TV WebSocket - get().connectToStvWebSocket(chatroom); + // DISABLED: Using shared connection system via connectionManager.initializeConnections + console.log(`[ChatProvider] Skipping individual 7TV connection for chatroom ${chatroom.id} - using shared connection system`); + // get().connectToStvWebSocket(chatroom); } }); }, @@ -1893,19 +1922,79 @@ const useChatStore = create((set, get) => ({ handleStvMessage: (chatroomId, eventDetail) => { const { type, body } = eventDetail; + console.log( + `[ChatProvider] Received 7TV event ${type} for ${chatroomId ?? 'broadcast'}`, + { + badgeCount: body?.badges?.length, + paintCount: body?.paints?.length, + entitlementUser: body?.object?.user?.username, + }, + ); + switch (type) { case "connection_established": break; case "emote_set.update": get().handleEmoteSetUpdate(chatroomId, body); break; - case "cosmetic.create": - useCosmeticsStore?.getState()?.addCosmetics(body); + case "cosmetic.create": { + console.log( + `[ChatProvider] Applying cosmetic catalog update for ${chatroomId ?? 'all chatrooms'}`, + { + badges: body?.badges?.length, + paints: body?.paints?.length, + }, + ); + const addCosmetics = useCosmeticsStore?.getState()?.addCosmetics; + if (addCosmetics) { + if (window.__KT_TELEMETRY_UTILS__?.shouldLogDebug?.()) console.log(`[ChatProvider] Calling CosmeticsStore.addCosmetics with body:`, { + badges: body?.badges?.length, + paints: body?.paints?.length + }); + addCosmetics(body); + } else { + console.error(`[ChatProvider] CosmeticsStore.addCosmetics method not available!`); + } break; + } case "entitlement.create": { const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); - useCosmeticsStore?.getState()?.addUserStyle(transformedUsername, body); + console.log( + `[ChatProvider] Processing entitlement for ${transformedUsername || 'unknown user'}`, + { + badgeId: body?.object?.user?.style?.badge_id, + paintId: body?.object?.user?.style?.paint_id, + chatroomId, + }, + ); + const addUserStyle = useCosmeticsStore?.getState()?.addUserStyle; + if (addUserStyle) { + console.log(`[ChatProvider] Calling CosmeticsStore.addUserStyle for ${transformedUsername}`); + addUserStyle(transformedUsername, body); + } else { + console.error(`[ChatProvider] CosmeticsStore.addUserStyle method not available!`); + } + break; + } + case "entitlement.delete": { + const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; + const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); + console.log( + `[ChatProvider] Processing entitlement deletion for ${transformedUsername || 'unknown user'}`, + { + refId: body?.object?.ref_id, + kind: body?.object?.kind, + chatroomId, + }, + ); + const removeUserStyle = useCosmeticsStore?.getState()?.removeUserStyle; + if (removeUserStyle) { + console.log(`[ChatProvider] Calling CosmeticsStore.removeUserStyle for ${transformedUsername}`); + removeUserStyle(transformedUsername, body); + } else { + console.error(`[ChatProvider] CosmeticsStore.removeUserStyle method not available!`); + } break; } default: @@ -2144,8 +2233,18 @@ const useChatStore = create((set, get) => ({ // Connect to chatroom get().connectToChatroom(newChatroom); - // Connect to 7TV WebSocket - get().connectToStvWebSocket(newChatroom); + // Connect to 7TV WebSocket via connectionManager + if (connectionManager) { + console.log(`[ChatProvider] Adding new chatroom ${newChatroom.id} to connectionManager for 7TV subscriptions`); + try { + await connectionManager.addChatroom(newChatroom); + console.log(`[ChatProvider] Successfully added chatroom ${newChatroom.id} to connectionManager`); + } catch (error) { + console.error(`[ChatProvider] Error adding chatroom ${newChatroom.id} to connectionManager:`, error); + } + } else { + console.warn(`[ChatProvider] ConnectionManager not available for new chatroom ${newChatroom.id}`); + } // Save to local storage localStorage.setItem("chatrooms", JSON.stringify([...savedChatrooms, newChatroom])); @@ -3180,9 +3279,6 @@ const useChatStore = create((set, get) => ({ // Clear emote cache to ensure new emotes are loaded from updated store clearChatroomEmoteCache(chatroomId); - // Refresh emote data to get the updated emote set - get().refresh7TVEmotes(chatroomId); - try { const processingDuration = performance.now() - startTime; // Record emote update metrics via IPC @@ -4031,7 +4127,9 @@ if (window.location.pathname === "/" || window.location.pathname.endsWith("index if (chatrooms?.length === 0) return; chatrooms.forEach((chatroom) => { - console.log("[7tv Presence]: Sending presence check for chatroom:", chatroom.streamerData.user_id); + if (window.__KT_TELEMETRY_UTILS__?.shouldLogDebug?.()) { + console.log("[7tv Presence]: Sending presence check for chatroom:", chatroom.streamerData.user_id); + } useChatStore.getState().sendPresenceUpdate(storeStvId, chatroom.streamerData.user_id); }); }, diff --git a/src/renderer/src/providers/CosmeticsProvider.jsx b/src/renderer/src/providers/CosmeticsProvider.jsx index 9f064fa..4cdc3c2 100644 --- a/src/renderer/src/providers/CosmeticsProvider.jsx +++ b/src/renderer/src/providers/CosmeticsProvider.jsx @@ -8,14 +8,49 @@ const useCosmeticsStore = create((set, get) => ({ }, addUserStyle: async (username, body) => { - if (!body?.object?.user?.style) return; + console.log(`[CosmeticsStore DIAGNOSTIC] addUserStyle called`, { + username, + hasBody: !!body, + hasUserStyle: !!body?.object?.user?.style, + objectKind: body?.object?.kind, + bodyKeys: Object.keys(body || {}), + objectKeys: Object.keys(body?.object || {}), + userKeys: Object.keys(body?.object?.user || {}), + fullBody: body + }); + + if (!body?.object?.user?.style) { + console.log(`[CosmeticsStore DIAGNOSTIC] No user style found in body, returning early`, { + hasObject: !!body?.object, + hasUser: !!body?.object?.user, + hasStyle: !!body?.object?.user?.style, + userObject: body?.object?.user + }); + return; + } + const transformedUsername = username.toLowerCase(); const userStyle = body.object.user; + console.log( + `[CosmeticsStore DIAGNOSTIC] Upserting style for ${transformedUsername}`, + { + badgeId: userStyle?.style?.badge_id, + paintId: userStyle?.style?.paint_id, + entitlementId: body?.object?.id, + userStyleObject: userStyle?.style, + connections: userStyle?.connections + }, + ); + set((state) => { const currentStyle = state.userStyles[transformedUsername] || {}; - if (currentStyle.badgeId === body.object.user.style.badge_id && currentStyle.paintId === body.object.user.style.paint_id) - return state; + const newBadgeId = body.object.user.style.badge_id; + const newPaintId = body.object.user.style.paint_id; + + if (currentStyle.badgeId === newBadgeId && currentStyle.paintId === newPaintId) { + return state; // Skip duplicate style update + } return { userStyles: { @@ -35,15 +70,129 @@ const useCosmeticsStore = create((set, get) => ({ }); }, + removeUserStyle: async (username, body) => { + console.log(`[CosmeticsStore DIAGNOSTIC] removeUserStyle called`, { + username, + hasBody: !!body, + refId: body?.object?.ref_id, + kind: body?.object?.kind, + fullBody: body + }); + + const transformedUsername = username.toLowerCase(); + const refId = body?.object?.ref_id; + const kind = body?.object?.kind; + + if (!refId || !kind) { + console.log(`[CosmeticsStore DIAGNOSTIC] Missing ref_id or kind in entitlement.delete`, { + refId, + kind + }); + return; + } + + console.log( + `[CosmeticsStore DIAGNOSTIC] Removing ${kind} entitlement for ${transformedUsername}`, + { + refId, + kind + } + ); + + set((state) => { + const currentStyle = state.userStyles[transformedUsername]; + if (!currentStyle) { + console.log(`[CosmeticsStore DIAGNOSTIC] No existing style for ${transformedUsername}, nothing to remove`); + return state; + } + + // Based on Chatterino spec: remove by kind and ref_id + let updatedStyle = { ...currentStyle }; + let hasChanges = false; + + if (kind === "BADGE" && currentStyle.badgeId === refId) { + updatedStyle.badgeId = null; + hasChanges = true; + console.log(`[CosmeticsStore DIAGNOSTIC] Removed badge ${refId} from ${transformedUsername}`); + } else if (kind === "PAINT" && currentStyle.paintId === refId) { + updatedStyle.paintId = null; + hasChanges = true; + console.log(`[CosmeticsStore DIAGNOSTIC] Removed paint ${refId} from ${transformedUsername}`); + } + + if (!hasChanges) { + console.log(`[CosmeticsStore DIAGNOSTIC] No matching ${kind} with ref_id ${refId} found for ${transformedUsername}`); + return state; + } + + // If both badge and paint are removed, remove the entire user style entry + if (!updatedStyle.badgeId && !updatedStyle.paintId) { + const { [transformedUsername]: removed, ...restUserStyles } = state.userStyles; + console.log(`[CosmeticsStore DIAGNOSTIC] Removed entire user style for ${transformedUsername} (no remaining cosmetics)`); + return { + userStyles: restUserStyles, + }; + } + + return { + userStyles: { + ...state.userStyles, + [transformedUsername]: { + ...updatedStyle, + updatedAt: new Date().toISOString(), + }, + }, + }; + }); + }, + getUserStyle: (username) => { - if (!username) return null; + if (!username) { + console.log(`[CosmeticsStore DIAGNOSTIC] getUserStyle called with empty username`); + return null; + } + const transformedUsername = username.toLowerCase(); + + // Track call frequency for debugging + if (!window.__getUserStyleCalls) window.__getUserStyleCalls = new Map(); + const now = Date.now(); + const lastCall = window.__getUserStyleCalls.get(transformedUsername) || 0; + const timeSinceLastCall = now - lastCall; + window.__getUserStyleCalls.set(transformedUsername, now); + + if (timeSinceLastCall < 100) { // Less than 100ms since last call + console.warn(`[CosmeticsStore WARNING] getUserStyle called for ${transformedUsername} only ${timeSinceLastCall}ms ago - possible render loop!`); + } const userStyle = get().userStyles[transformedUsername]; + const globalCosmetics = get().globalCosmetics; + + console.log(`[CosmeticsStore DIAGNOSTIC] getUserStyle for ${transformedUsername}`, { + hasUserStyle: !!userStyle, + userStyleBadgeId: userStyle?.badgeId, + userStylePaintId: userStyle?.paintId, + totalBadges: globalCosmetics?.badges?.length, + totalPaints: globalCosmetics?.paints?.length, + userStyleObject: userStyle, + callStack: new Error().stack?.split('\n').slice(1, 6).join('\n') // Show top 5 stack frames + }); - if (!userStyle?.badgeId && !userStyle?.paintId) return null; + if (!userStyle?.badgeId && !userStyle?.paintId) { + console.log(`[CosmeticsStore DIAGNOSTIC] No badge or paint for ${transformedUsername}`); + return null; + } - const badge = get().globalCosmetics?.badges?.find((b) => b.id === userStyle.badgeId); - const paint = get().globalCosmetics?.paints?.find((p) => p.id === userStyle.paintId); + const badge = globalCosmetics?.badges?.find((b) => b.id === userStyle.badgeId); + const paint = globalCosmetics?.paints?.find((p) => p.id === userStyle.paintId); + + console.log(`[CosmeticsStore DIAGNOSTIC] Found cosmetics for ${transformedUsername}`, { + foundBadge: !!badge, + foundPaint: !!paint, + badgeId: userStyle.badgeId, + paintId: userStyle.paintId, + badge: badge, + paint: paint + }); return { badge, @@ -54,13 +203,35 @@ const useCosmeticsStore = create((set, get) => ({ }, addCosmetics: (body) => { - set(() => { + console.log(`[CosmeticsStore DIAGNOSTIC] addCosmetics called`, { + hasBody: !!body, + badges: body?.badges?.length, + paints: body?.paints?.length, + bodyKeys: Object.keys(body || {}), + firstBadge: body?.badges?.[0], + firstPaint: body?.paints?.[0] + }); + + const currentState = get(); + console.log(`[CosmeticsStore DIAGNOSTIC] Current state before update`, { + currentBadges: currentState.globalCosmetics?.badges?.length, + currentPaints: currentState.globalCosmetics?.paints?.length + }); + + set((state) => { const newState = { globalCosmetics: { ...body, }, }; + console.log(`[CosmeticsStore DIAGNOSTIC] State updated`, { + newBadges: newState.globalCosmetics?.badges?.length, + newPaints: newState.globalCosmetics?.paints?.length, + previousBadges: state.globalCosmetics?.badges?.length, + previousPaints: state.globalCosmetics?.paints?.length + }); + return newState; }); }, @@ -82,6 +253,79 @@ const useCosmeticsStore = create((set, get) => ({ return get().globalCosmetics[userStyle.paintId]; }, + + // TEST FUNCTION: Simulate receiving cosmetics to verify the store works + testCosmetics: () => { + console.log(`[CosmeticsStore TEST] Simulating cosmetic events...`); + + // Simulate cosmetic.create event + const testCosmetics = { + badges: [ + { + id: "test-badge-1", + title: "Test Badge", + url: "https://cdn.7tv.app/badge/test/1x.webp" + } + ], + paints: [ + { + id: "test-paint-1", + name: "Test Paint", + backgroundImage: "linear-gradient(45deg, #ff0000, #00ff00)", + KIND: "non-animated" + } + ] + }; + + get().addCosmetics(testCosmetics); + + // Simulate entitlement.create event + const testEntitlement = { + object: { + kind: "ENTITLEMENT", + id: "test-entitlement-1", + user: { + id: "test-user-id", + username: "testuser", + style: { + badge_id: "test-badge-1", + paint_id: "test-paint-1", + color: -1 + }, + connections: [ + { + platform: "KICK", + username: "testuser" + } + ] + } + } + }; + + get().addUserStyle("testuser", testEntitlement); + + console.log(`[CosmeticsStore TEST] Test complete. Try getUserStyle('testuser')`); + }, })); +// Expose test function globally for debugging +if (typeof window !== 'undefined') { + window.testCosmeticsStore = () => { + console.log(`[TEST] Starting cosmetics test...`); + const store = useCosmeticsStore.getState(); + console.log(`[TEST] Got store:`, !!store); + store.testCosmetics(); + + // Also test retrieval + setTimeout(() => { + const result = store.getUserStyle('testuser'); + console.log(`[CosmeticsStore TEST] getUserStyle result:`, result); + }, 100); + }; + + // Also expose the store itself for direct testing + window.cosmeticsStore = useCosmeticsStore; + console.log(`[CosmeticsProvider] Exposed window.testCosmeticsStore() and window.cosmeticsStore`); +} + export default useCosmeticsStore; diff --git a/src/renderer/src/telemetry/webTracing.js b/src/renderer/src/telemetry/webTracing.js index 770ecdf..c81276f 100644 --- a/src/renderer/src/telemetry/webTracing.js +++ b/src/renderer/src/telemetry/webTracing.js @@ -198,13 +198,30 @@ if (!telemetryEnabled && typeof window !== 'undefined') { // Telemetry Level Configuration and Sampling Utilities const getTelemetryLevel = () => { try { - const level = import.meta.env.RENDERER_VITE_TELEMETRY_LEVEL || 'NORMAL'; + // Check consolidated VITE_ vars first (available to all processes), then fall back to old vars + const level = import.meta.env.VITE_TELEMETRY_LEVEL || + import.meta.env.RENDERER_VITE_TELEMETRY_LEVEL || + 'NORMAL'; return level.toUpperCase(); } catch { return 'NORMAL'; } }; +const getTelemetryDebug = () => { + try { + // Check consolidated VITE_ vars first (available to all processes), then fall back to old vars + return import.meta.env.VITE_TELEMETRY_DEBUG === 'true' || + import.meta.env.RENDERER_VITE_TELEMETRY_DEBUG === 'true'; + } catch { + return false; + } +}; + +const shouldLogDebug = () => { + return getTelemetryDebug() || getTelemetryLevel() === 'VERBOSE'; +}; + const TELEMETRY_LEVELS = { MINIMAL: { priority: 1, @@ -291,6 +308,8 @@ const checkStartupPhase = () => { // Export utilities for use in components window.__KT_TELEMETRY_UTILS__ = { getTelemetryLevel, + getTelemetryDebug, + shouldLogDebug, shouldEmitTelemetry, shouldSampleMessageParser, shouldEmitLexicalUpdate, @@ -654,45 +673,51 @@ if (!window.__KT_RENDERER_OTEL_INITIALIZED__ && telemetryEnabled) { } catch {} } - console.log(`[Renderer OTEL][${exportId}] IPCSpanExporter.export() called:`, { - exportId, - exportCount: this.exportCount, - spanCount: spanArray.length, - serviceName: this.serviceName, - deploymentEnv: this.deploymentEnv, - traceIds: traceInfo.traceIds, - spanIds: traceInfo.spanIds.slice(0, 3), // First 3 span IDs - spanNames: traceInfo.spanNames, - parentSpanIds: traceInfo.parentSpanIds.slice(0, 3) - }); + if (shouldLogDebug()) { + console.log(`[Renderer OTEL][${exportId}] IPCSpanExporter.export() called:`, { + exportId, + exportCount: this.exportCount, + spanCount: spanArray.length, + serviceName: this.serviceName, + deploymentEnv: this.deploymentEnv, + traceIds: traceInfo.traceIds, + spanIds: traceInfo.spanIds.slice(0, 3), // First 3 span IDs + spanNames: traceInfo.spanNames, + parentSpanIds: traceInfo.parentSpanIds.slice(0, 3) + }); + } const req = this._toOtlpJson(spans); const reqSize = JSON.stringify(req).length; - console.log(`[Renderer OTEL][${exportId}] Converted to OTLP JSON:`, { - exportId, - requestSize: reqSize, - resourceSpansCount: req.resourceSpans?.length || 0, - traceIds: traceInfo.traceIds, - actualTraceIds: traceInfo.traceIds, - traceIdLengths: traceInfo.traceIds.map(id => id?.length || 0) - }); + if (shouldLogDebug()) { + console.log(`[Renderer OTEL][${exportId}] Converted to OTLP JSON:`, { + exportId, + requestSize: reqSize, + resourceSpansCount: req.resourceSpans?.length || 0, + traceIds: traceInfo.traceIds, + actualTraceIds: traceInfo.traceIds, + traceIdLengths: traceInfo.traceIds.map(id => id?.length || 0) + }); + } const res = await window.telemetry.exportTracesJson(req); const duration = performance.now() - startTime; const ok = !!res?.ok && (!res.status || (res.status >= 200 && res.status < 300)); - console.log(`[Renderer OTEL][${exportId}] IPC export result:`, { - exportId, - success: ok, - duration: `${Math.round(duration)}ms`, - responseStatus: res?.status, - responseOk: res?.ok, - requestId: res?.requestId, - traceIds: traceInfo.traceIds, - returnedTraceIds: res?.traceIds - }); + if (shouldLogDebug()) { + console.log(`[Renderer OTEL][${exportId}] IPC export result:`, { + exportId, + success: ok, + duration: `${Math.round(duration)}ms`, + responseStatus: res?.status, + responseOk: res?.ok, + requestId: res?.requestId, + traceIds: traceInfo.traceIds, + returnedTraceIds: res?.traceIds + }); + } resultCallback({ code: ok ? 0 : 1 }); } catch (e) { @@ -883,50 +908,52 @@ if (!window.__KT_RENDERER_OTEL_INITIALIZED__ && telemetryEnabled) { const directConversion = rawSec * 1000000000n + rawNs; const directDate = new Date(Number(directConversion) / 1e6); - console.log(`[Renderer OTEL] Span validation debug:`, { - traceId, - traceIdHexLength: traceId.length, - spanId, - spanIdHexLength: spanId.length, - parentSpanId, - parentSpanIdHexLength: parentSpanId.length, - // For visibility when debugging, also show base64 versions - traceIdBase64: hexToBase64(traceIdHex), - spanIdBase64: hexToBase64(spanIdHex), - parentSpanIdBase64: parentSpanIdHex ? hexToBase64(parentSpanIdHex) : '', - startTimeUnixNano: startNs.toString(), - endTimeUnixNano: endNs.toString(), - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), - startYear: startDate.getFullYear(), - endYear: endDate.getFullYear(), - isStartTimeReasonable: startDate.getFullYear() >= 2020 && startDate.getFullYear() <= 2030, - isEndTimeReasonable: endDate.getFullYear() >= 2020 && endDate.getFullYear() <= 2030, - duration: (endNs - startNs).toString() + ' nanos', - durationMs: Number(endNs - startNs) / 1e6, - kind: Number(s.kind) || 0, - name: s.name || 'span', - nowMs, - nowNs: nowNs.toString(), - timeDiffFromNow: Number(nowNs - startNs), - statusCode: s.status?.code, - statusMessage: s.status?.message, - statusWillBeIncluded: !!(s.status?.code && s.status.code > 0), - statusExists: !!s.status, - statusCodeType: typeof s.status?.code, - // Raw hrtime debugging - rawStartTime: s.startTime, - rawEndTime: s.endTime, - performanceTimeOrigin: performance?.timeOrigin, - performanceNow: performance?.now?.(), - // Step-by-step conversion debugging - rawSecBigInt: rawSec.toString(), - rawNsBigInt: rawNs.toString(), - directConversion: directConversion.toString(), - directDate: directDate.toISOString(), - directYear: directDate.getFullYear(), - directVsCalculated: directConversion.toString() === startNs.toString() - }); + if (shouldLogDebug()) { + console.log(`[Renderer OTEL] Span validation debug:`, { + traceId, + traceIdHexLength: traceId.length, + spanId, + spanIdHexLength: spanId.length, + parentSpanId, + parentSpanIdHexLength: parentSpanId.length, + // For visibility when debugging, also show base64 versions + traceIdBase64: hexToBase64(traceIdHex), + spanIdBase64: hexToBase64(spanIdHex), + parentSpanIdBase64: parentSpanIdHex ? hexToBase64(parentSpanIdHex) : '', + startTimeUnixNano: startNs.toString(), + endTimeUnixNano: endNs.toString(), + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + startYear: startDate.getFullYear(), + endYear: endDate.getFullYear(), + isStartTimeReasonable: startDate.getFullYear() >= 2020 && startDate.getFullYear() <= 2030, + isEndTimeReasonable: endDate.getFullYear() >= 2020 && endDate.getFullYear() <= 2030, + duration: (endNs - startNs).toString() + ' nanos', + durationMs: Number(endNs - startNs) / 1e6, + kind: Number(s.kind) || 0, + name: s.name || 'span', + nowMs, + nowNs: nowNs.toString(), + timeDiffFromNow: Number(nowNs - startNs), + statusCode: s.status?.code, + statusMessage: s.status?.message, + statusWillBeIncluded: !!(s.status?.code && s.status.code > 0), + statusExists: !!s.status, + statusCodeType: typeof s.status?.code, + // Raw hrtime debugging + rawStartTime: s.startTime, + rawEndTime: s.endTime, + performanceTimeOrigin: performance?.timeOrigin, + performanceNow: performance?.now?.(), + // Step-by-step conversion debugging + rawSecBigInt: rawSec.toString(), + rawNsBigInt: rawNs.toString(), + directConversion: directConversion.toString(), + directDate: directDate.toISOString(), + directYear: directDate.getFullYear(), + directVsCalculated: directConversion.toString() === startNs.toString() + }); + } // Warn if timestamps are too far in the future (>5m) or in the past (>24h) try { @@ -969,12 +996,14 @@ if (!window.__KT_RENDERER_OTEL_INITIALIZED__ && telemetryEnabled) { }; } - console.log(`[Renderer OTEL] Status filtering debug:`, { - originalStatusCode: statusCode, - originalStatusMessage: s.status?.message, - isError, - willAddStatus: isError - }); + if (shouldLogDebug()) { + console.log(`[Renderer OTEL] Status filtering debug:`, { + originalStatusCode: statusCode, + originalStatusMessage: s.status?.message, + isError, + willAddStatus: isError + }); + } const spanName = (s.name || 'span'); const spanOut = { diff --git a/src/telemetry/user-analytics.js b/src/telemetry/user-analytics.js index b5c3de4..0ea24b6 100644 --- a/src/telemetry/user-analytics.js +++ b/src/telemetry/user-analytics.js @@ -2,6 +2,34 @@ const { metrics, trace, context } = require('@opentelemetry/api'); const { ErrorMonitor } = require('./error-monitoring'); +// Debug configuration for user analytics +const getTelemetryDebug = () => { + try { + // Check consolidated VITE_ vars first (available to all processes), then fall back to old vars + // Note: In main process, we access these via process.env since they're embedded at build time + return process.env.VITE_TELEMETRY_DEBUG === 'true' || + process.env.MAIN_VITE_TELEMETRY_DEBUG === 'true'; + } catch { + return false; + } +}; + +const getTelemetryLevel = () => { + try { + // Check consolidated VITE_ vars first (available to all processes), then fall back to old vars + const level = process.env.VITE_TELEMETRY_LEVEL || + process.env.MAIN_VITE_TELEMETRY_LEVEL || + 'NORMAL'; + return level.toUpperCase(); + } catch { + return 'NORMAL'; + } +}; + +const shouldLogDebug = () => { + return getTelemetryDebug() || getTelemetryLevel() === 'VERBOSE'; +}; + const pkg = require('../../package.json'); const meter = metrics.getMeter('kicktalk-user-analytics', pkg.version); const tracer = trace.getTracer('kicktalk-user-analytics', pkg.version); @@ -279,7 +307,9 @@ class UserSession { const finalScore = this.calculateFinalSatisfactionScore(); this.satisfactionScore = finalScore; - console.log(`[User Analytics] Session ${this.sessionId} ended: ${this.getSessionDuration()}s, satisfaction: ${finalScore.toFixed(1)}/10`); + if (shouldLogDebug()) { + console.log(`[User Analytics] Session ${this.sessionId} ended: ${this.getSessionDuration()}s, satisfaction: ${finalScore.toFixed(1)}/10`); + } } } @@ -293,7 +323,9 @@ const UserAnalytics = { const session = new UserSession(sessionId, userId); activeSessions.set(sessionId, session); - console.log(`[User Analytics] Started session ${sessionId} for user ${userId || 'anonymous'}`); + if (shouldLogDebug()) { + console.log(`[User Analytics] Started session ${sessionId} for user ${userId || 'anonymous'}`); + } return session; }, @@ -319,7 +351,9 @@ const UserAnalytics = { // Record final satisfaction score const finalScore = session.satisfactionScore; - console.log(`[User Analytics] Session satisfaction: ${finalScore.toFixed(2)}/10`); + if (shouldLogDebug()) { + console.log(`[User Analytics] Session satisfaction: ${finalScore.toFixed(2)}/10`); + } // Store session data for correlation analysis userBehaviorData.set(sessionId, { @@ -417,7 +451,9 @@ const UserAnalytics = { } featureAdoptionData.get(userKey).add(featureName); - console.log(`[User Analytics] Feature usage: ${featureName}.${action} by ${session.userId}`); + if (shouldLogDebug()) { + console.log(`[User Analytics] Feature usage: ${featureName}.${action} by ${session.userId}`); + } }, /** @@ -453,7 +489,9 @@ const UserAnalytics = { timestamp: Date.now() }); - console.log(`[User Analytics] Connection quality: ${quality}/10 (${eventType}) for session ${sessionId}`); + if (shouldLogDebug()) { + console.log(`[User Analytics] Connection quality: ${quality}/10 (${eventType}) for session ${sessionId}`); + } }, /** @@ -586,16 +624,18 @@ const UserAnalytics = { const cleanupDuration = Date.now() - cleanupStartTime; - console.log(`[User Analytics] Cleanup completed in ${cleanupDuration}ms:`, { - active_sessions_cleaned: cleanedSessions, - historical_data_cleaned: cleanedHistoricalData, + if (shouldLogDebug()) { + console.log(`[User Analytics] Cleanup completed in ${cleanupDuration}ms:`, { + active_sessions_cleaned: cleanedSessions, + historical_data_cleaned: cleanedHistoricalData, adoption_data_cleaned: cleanedAdoptionData, performance_data_cleaned: cleanedPerfData, active_sessions_remaining: activeSessions.size, - historical_data_remaining: userBehaviorData.size, - feature_adoption_users: featureAdoptionData.size, - error_impact_sessions: performanceCorrelationData.error_impact_sessions.size - }); + historical_data_remaining: userBehaviorData.size, + feature_adoption_users: featureAdoptionData.size, + error_impact_sessions: performanceCorrelationData.error_impact_sessions.size + }); + } return { cleaned: cleanedSessions + cleanedHistoricalData + cleanedAdoptionData + cleanedPerfData, @@ -633,15 +673,17 @@ const UserAnalytics = { error_impact_sessions: new Set() }; - console.log(`[User Analytics] Force cleanup completed:`, { - before: beforeCounts, - after: { - activeSessions: activeSessions.size, - historicalData: userBehaviorData.size, - featureAdoption: featureAdoptionData.size, - errorImpactSessions: performanceCorrelationData.error_impact_sessions.size - } - }); + if (shouldLogDebug()) { + console.log(`[User Analytics] Force cleanup completed:`, { + before: beforeCounts, + after: { + activeSessions: activeSessions.size, + historicalData: userBehaviorData.size, + featureAdoption: featureAdoptionData.size, + errorImpactSessions: performanceCorrelationData.error_impact_sessions.size + } + }); + } return beforeCounts; }, diff --git a/utils/services/connectionManager.js b/utils/services/connectionManager.js index ff70288..3e68be4 100644 --- a/utils/services/connectionManager.js +++ b/utils/services/connectionManager.js @@ -120,12 +120,17 @@ class ConnectionManager { // Set up 7TV event handlers if (handlers.onStvMessage) { + console.log(`[ConnectionManager] Registering onStvMessage handler`); this.stvWebSocket.addEventListener("message", handlers.onStvMessage); + } else { + console.warn(`[ConnectionManager] No onStvMessage handler provided!`); } if (handlers.onStvOpen) { + console.log(`[ConnectionManager] Registering onStvOpen handler`); this.stvWebSocket.addEventListener("open", handlers.onStvOpen); } if (handlers.onStvConnection) { + console.log(`[ConnectionManager] Registering onStvConnection handler`); this.stvWebSocket.addEventListener("connection", handlers.onStvConnection); } } @@ -304,12 +309,21 @@ class ConnectionManager { }); span.addEvent('7tv_websocket_add_start'); + console.log(`[ConnectionManager DIAGNOSTIC]: About to call stvWebSocket.addChatroom`, { + chatroomId: chatroom.id, + userIdForSubscription: chatroom.streamerData.user_id, + stvId: stvId, + stvEmoteSetId: stvEmoteSetId, + hasWebSocket: !!this.stvWebSocket + }); this.stvWebSocket.addChatroom( chatroom.id, - chatroom.streamerData.id, // Use the Kick channel ID for cosmetic/entitlement subscriptions + chatroom.streamerData.user_id, // Use the correct Kick user ID for cosmetic/entitlement subscriptions stvId, - stvEmoteSetId + stvEmoteSetId, + chatroom // Pass the full chatroom data for ID variants ); + console.log(`[ConnectionManager DIAGNOSTIC]: stvWebSocket.addChatroom completed for ${chatroom.id}`); span.addEvent('7tv_websocket_add_complete'); // Fetch initial messages for this chatroom @@ -551,6 +565,8 @@ class ConnectionManager { const channel7TVEmotes = await window.app.stv.getChannelEmotes(chatroom.streamerData.user_id); span.addEvent('api_fetch_complete'); + // Note: Cosmetics (badges/paints) are loaded dynamically via WebSocket events, not API calls + if (channel7TVEmotes) { this.channelStvEmoteCache.set(cacheKey, channel7TVEmotes); span.addEvent('cache_stored'); @@ -589,6 +605,7 @@ class ConnectionManager { chatroom.streamerData?.id, stvId, stvEmoteSetId, + chatroom // Pass the full chatroom data for ID variants ); } diff --git a/utils/services/seventv/sharedStvWebSocket.js b/utils/services/seventv/sharedStvWebSocket.js index 387ba23..75ab263 100644 --- a/utils/services/seventv/sharedStvWebSocket.js +++ b/utils/services/seventv/sharedStvWebSocket.js @@ -28,11 +28,13 @@ const updateCosmetics = async (body) => { return; } - cosmetics.badges.push({ + const newBadge = { id: data.id === "00000000000000000000000000" ? data.ref_id || "default_id" : data.id, title: data.tooltip, url: `https:${data.host.url}/${data.host.files[data.host.files.length - 1].name}`, - }); + }; + cosmetics.badges.push(newBadge); + console.log(`[Shared7TV] Added badge: ${newBadge.title}`); } } else if (object?.kind === "PAINT") { if (!object.user) { @@ -117,6 +119,7 @@ const updateCosmetics = async (body) => { } cosmetics.paints.push(push); + console.log(`[Shared7TV] Added paint: ${push.name}`); } } else if ( object?.name === "Personal Emotes" || @@ -167,16 +170,51 @@ class SharedStvWebSocket extends EventTarget { this.connectionSpan = null; // Track current connection span } - addChatroom(chatroomId, channelKickID, stvId = "0", stvEmoteSetId = "0") { - this.chatrooms.set(chatroomId, { + addChatroom(chatroomId, channelKickID, stvId = "0", stvEmoteSetId = "0", chatroomData = null) { + // Store all available ID variants for diagnostic subscriptions + const storedData = { channelKickID: String(channelKickID), stvId, stvEmoteSetId, - }); + }; + + // Add additional ID variants if chatroomData is provided + if (chatroomData) { + storedData.idVariants = { + chatroom_id: String(chatroomId), + streamer_id: String(channelKickID), // This is chatroomData.streamerData.id + streamer_user_id: chatroomData.streamerData?.user_id ? String(chatroomData.streamerData.user_id) : null, + username: chatroomData.streamerData?.username || null, + user_username: chatroomData.streamerData?.user?.username || null, + slug: chatroomData.slug || null, + }; + } else { + // Fallback for legacy calls - just store the basic IDs + storedData.idVariants = { + chatroom_id: String(chatroomId), + streamer_id: String(channelKickID), + }; + } + + this.chatrooms.set(chatroomId, storedData); + + console.log( + `[Shared7TV]: Registered chatroom ${chatroomId} (kick=${channelKickID}, stvUser=${stvId}, stvSet=${stvEmoteSetId})`, + ); // If we're already connected, subscribe to this chatroom's events + console.log(`[Shared7TV DIAGNOSTIC]: addChatroom called for ${chatroomId}`, { + connectionState: this.connectionState, + willSubscribe: this.connectionState === 'connected', + totalChatrooms: this.chatrooms.size, + channelKickID: channelKickID + }); + if (this.connectionState === 'connected') { + console.log(`[Shared7TV DIAGNOSTIC]: Subscribing to events for new chatroom ${chatroomId}`); this.subscribeToChatroomEvents(chatroomId); + } else { + console.log(`[Shared7TV DIAGNOSTIC]: WebSocket not connected (${this.connectionState}), will subscribe when connected`); } } @@ -340,10 +378,25 @@ class SharedStvWebSocket extends EventTarget { await this.delay(1000); // Subscribe to events for all chatrooms + console.log(`[Shared7TV]: Starting subscription to all events for ${this.chatrooms.size} chatrooms`); + try { + window.app?.telemetry?.recordWebSocketEvent?.('7tv_shared', 'subscription_start', { + chatroom_count: this.chatrooms.size + }); + } catch (_) {} await this.subscribeToAllEvents(); + console.log(`[Shared7TV]: Finished subscribing to all events`); + try { + window.app?.telemetry?.recordWebSocketEvent?.('7tv_shared', 'subscription_complete', { + chatroom_count: this.chatrooms.size, + subscribed_events: this.subscribedEvents.size + }); + } catch (_) {} // Setup message handler + console.log(`[Shared7TV]: Setting up message handler`); this.setupMessageHandler(); + console.log(`[Shared7TV]: Message handler setup complete`); // Dispatch connection event this.dispatchEvent( @@ -361,11 +414,22 @@ class SharedStvWebSocket extends EventTarget { handleConnectionError() { this.reconnectAttempts++; console.log(`[Shared7TV]: Connection error. Attempt ${this.reconnectAttempts}`); + try { + window.app?.telemetry?.recordWebSocketError?.('7tv_shared', 'connection_error', { + attempt: this.reconnectAttempts, + chatroom_count: this.chatrooms.size + }); + } catch (_) {} } handleReconnection() { if (!this.shouldReconnect) { console.log(`[Shared7TV]: Reconnection disabled`); + try { + window.app?.telemetry?.recordWebSocketEvent?.('7tv_shared', 'reconnection_disabled', { + attempt: this.reconnectAttempts + }); + } catch (_) {} return; } @@ -375,6 +439,13 @@ class SharedStvWebSocket extends EventTarget { const delay = this.startDelay * Math.pow(2, step - 1); console.log(`[Shared7TV]: Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`); + try { + window.app?.telemetry?.recordWebSocketEvent?.('7tv_shared', 'reconnection_scheduled', { + attempt: this.reconnectAttempts + 1, + delay_ms: delay, + step: step + }); + } catch (_) {} setTimeout(() => { this.connect(); @@ -392,12 +463,18 @@ class SharedStvWebSocket extends EventTarget { } async subscribeToChatroomEvents(chatroomId) { + console.log(`[Shared7TV DIAGNOSTIC]: subscribeToChatroomEvents called for ${chatroomId}`); + const chatroomData = this.chatrooms.get(chatroomId); if (!chatroomData) { console.log(`[Shared7TV]: Chatroom ${chatroomId} not found`); return; } + console.log( + `[Shared7TV]: Preparing subscriptions for chatroom ${chatroomId} (kick=${chatroomData.channelKickID}, emoteSet=${chatroomData.stvEmoteSetId})`, + ); + const { channelKickID, stvEmoteSetId } = chatroomData; // Validate IDs for specific subscriptions @@ -450,7 +527,6 @@ class SharedStvWebSocket extends EventTarget { const subscribeUserMessage = { op: 35, - t: Date.now(), d: { type: "user.*", condition: { object_id: chatroomWithStvId.stvId }, @@ -464,69 +540,61 @@ class SharedStvWebSocket extends EventTarget { } /** - * Subscribe to cosmetic events for a specific chatroom + * Subscribe to cosmetic events for a specific chatroom (using wildcard like Firefox extension) */ async subscribeToCosmeticEvents(chatroomId, channelKickID) { if (!this.chat || this.chat.readyState !== WebSocket.OPEN) { - console.log(`[Shared7TV]: Cannot subscribe to cosmetic events - WebSocket not ready`); + console.log(`[Shared7TV]: Cannot subscribe to cosmetic events - WebSocket not ready. State: ${this.chat?.readyState}`); return; } const eventKey = `cosmetic.*:${channelKickID}`; if (this.subscribedEvents.has(eventKey)) { + console.log(`[Shared7TV]: Cosmetic subscription already exists for channel ${channelKickID}`); return; } - const subscribeAllCosmetics = { + const subscribeCosmeticAll = { op: 35, - t: Date.now(), d: { type: "cosmetic.*", - condition: { platform: "KICK", ctx: "channel", id: channelKickID }, + condition: { ctx: "channel", platform: "KICK", id: channelKickID }, }, }; - this.chat.send(JSON.stringify(subscribeAllCosmetics)); + console.log(`[Shared7TV]: Subscribing to cosmetic.* events for channel ${channelKickID}`); + this.chat.send(JSON.stringify(subscribeCosmeticAll)); this.subscribedEvents.add(eventKey); - console.log(`[Shared7TV]: Subscribed to cosmetic.* events for chatroom ${chatroomId}`); } /** - * Subscribe to entitlement events for a specific chatroom + * Subscribe to entitlement events for a specific chatroom (using wildcard like Firefox extension) */ async subscribeToEntitlementEvents(chatroomId, channelKickID) { if (!this.chat || this.chat.readyState !== WebSocket.OPEN) { - console.log(`[Shared7TV]: Cannot subscribe to entitlement events - WebSocket not ready`); + console.log(`[Shared7TV]: Cannot subscribe to entitlement events - WebSocket not ready. State: ${this.chat?.readyState}`); return; } const eventKey = `entitlement.*:${channelKickID}`; if (this.subscribedEvents.has(eventKey)) { + console.log(`[Shared7TV]: Entitlement subscription already exists for channel ${channelKickID}`); return; } - const subscribeAllEntitlements = { + const subscribeEntitlementAll = { op: 35, - t: Date.now(), d: { type: "entitlement.*", - condition: { platform: "KICK", ctx: "channel", id: channelKickID }, + condition: { ctx: "channel", platform: "KICK", id: channelKickID }, }, }; - this.chat.send(JSON.stringify(subscribeAllEntitlements)); + console.log(`[Shared7TV]: Subscribing to entitlement.* events for channel ${channelKickID}`); + this.chat.send(JSON.stringify(subscribeEntitlementAll)); this.subscribedEvents.add(eventKey); - console.log(`[Shared7TV]: Subscribed to entitlement.* events for chatroom ${chatroomId}`); - - this.dispatchEvent( - new CustomEvent("open", { - detail: { - body: "SUBSCRIBED", - type: "entitlement.*", - chatroomId, - }, - }), - ); + + this.dispatchEvent(new CustomEvent("open", { detail: { body: "SUBSCRIBED", type: "entitlement.*" } })); } /** @@ -551,7 +619,6 @@ class SharedStvWebSocket extends EventTarget { const subscribeAllEmoteSets = { op: 35, - t: Date.now(), d: { type: "emote_set.*", condition: { object_id: stvEmoteSetId }, @@ -567,16 +634,155 @@ class SharedStvWebSocket extends EventTarget { this.chat.onmessage = (event) => { try { const msg = JSON.parse(event.data); + // Only log non-heartbeat messages + if (msg?.op !== 2) { + console.log(`[Shared7TV]: Message received:`, { + op: msg?.op, + type: msg?.d?.type, + hasBody: !!msg?.d?.body + }); + } - if (!msg?.d?.body) return; + // Handle different 7TV opcodes + switch (msg?.op) { + case 0: // Dispatch (actual events) + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'dispatch', msg?.d?.type); + } catch (_) {} + break; // Break here to continue to event processing logic below the switch statement + case 1: // Hello (connection established) + console.log(`[Shared7TV]: Hello received`, { + heartbeat_interval: msg?.d?.heartbeat_interval, + session_id: msg?.d?.session_id + }); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'hello', null, { + heartbeat_interval: msg?.d?.heartbeat_interval, + has_session_id: !!msg?.d?.session_id + }); + } catch (_) {} + return; + case 2: // Heartbeat + console.log(`[Shared7TV]: Heartbeat received`, { count: msg?.d?.count }); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'heartbeat', null, { + count: msg?.d?.count + }); + } catch (_) {} + return; // Don't process heartbeats further + case 4: // Reconnect (server requests reconnection) + console.log(`[Shared7TV]: Reconnect request received`, msg?.d); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'reconnect', null, { + reason: msg?.d?.reason + }); + } catch (_) {} + // Handle reconnection logic if needed + return; + case 5: // Ack (acknowledgment) + console.log(`[Shared7TV]: ACK received`, { + command: msg?.d?.command, + type: msg?.d?.data?.type, + id: msg?.d?.data?.id + }); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'ack', msg?.d?.data?.type, { + command: msg?.d?.command + }); + } catch (_) {} + return; + case 6: // Error + console.error(`[Shared7TV]: Error received`, { + message: msg?.d?.message, + code: msg?.d?.code, + data: msg?.d?.data + }); + try { + window.app?.telemetry?.recordWebSocketError?.('7tv_shared', msg?.d?.message, { + code: msg?.d?.code, + data: msg?.d?.data + }); + } catch (_) {} + return; + case 7: // EndOfStream + console.log(`[Shared7TV]: End of stream received`, msg?.d); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'end_of_stream', null, msg?.d); + } catch (_) {} + return; + case 33: // Identify (client authentication) + console.log(`[Shared7TV]: Identify opcode (should not be received by client)`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'identify_unexpected', null); + } catch (_) {} + return; + case 34: // Resume (session resumption) + console.log(`[Shared7TV]: Resume opcode (should not be received by client)`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'resume_unexpected', null); + } catch (_) {} + return; + case 35: // Subscribe (subscription request) + console.log(`[Shared7TV]: Subscribe opcode (should not be received by client)`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'subscribe_unexpected', null); + } catch (_) {} + return; + case 36: // Unsubscribe (unsubscription request) + console.log(`[Shared7TV]: Unsubscribe opcode (should not be received by client)`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'unsubscribe_unexpected', null); + } catch (_) {} + return; + case 37: // Signal + console.log(`[Shared7TV]: Signal received`, msg?.d); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'signal', null, msg?.d); + } catch (_) {} + return; + case 38: // Bridge (deprecated) + console.log(`[Shared7TV]: Bridge opcode received (deprecated)`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'bridge_deprecated', null); + } catch (_) {} + return; + default: + console.log(`[Shared7TV]: Unknown opcode ${msg?.op}:`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.('7tv_shared', 'unknown_opcode', null, { + opcode: msg?.op + }); + } catch (_) {} + return; + } + + // Only process dispatch events (op: 0) that have a body + if (!msg?.d?.body) { + console.log(`[Shared7TV]: Dispatch event has no body, skipping:`, msg); + return; + } const { body, type } = msg.d; + // Log cosmetic-related events (condensed) + if (type?.includes('cosmetic') || type?.includes('entitlement')) { + console.log(`[Shared7TV]: ${type} event for ${body?.object?.user?.username || 'unknown'}`); + } + // Find which chatroom this event belongs to const chatroomId = this.findChatroomForEvent(body, type); + console.log( + `[Shared7TV]: Received ${type} for ${chatroomId === null ? 'broadcast' : `chatroom ${chatroomId}`}`, + { + channelContext: body?.context || body?.condition || null, + entitlementUser: body?.object?.user?.username, + }, + ); + switch (type) { case "user.update": + console.log(`[Shared7TV]: Dispatching user.update event for chatroomId: ${chatroomId}`); this.dispatchEvent( new CustomEvent("message", { detail: { @@ -589,6 +795,7 @@ class SharedStvWebSocket extends EventTarget { break; case "emote_set.update": + console.log(`[Shared7TV]: Dispatching emote_set.update event for chatroomId: ${chatroomId}`); this.dispatchEvent( new CustomEvent("message", { detail: { @@ -615,7 +822,8 @@ class SharedStvWebSocket extends EventTarget { break; case "entitlement.create": - if (body.kind === 10) { + // Process entitlements for both cosmetics (kind 10) and emote sets (kind 5) + if (body.kind === 10 || body.kind === 5) { this.dispatchEvent( new CustomEvent("message", { detail: { @@ -627,6 +835,18 @@ class SharedStvWebSocket extends EventTarget { ); } break; + + case "entitlement.delete": + this.dispatchEvent( + new CustomEvent("message", { + detail: { + body, + type: "entitlement.delete", + chatroomId, + }, + }), + ); + break; } } catch (error) { console.log("[Shared7TV] Error parsing message:", error); @@ -636,8 +856,7 @@ class SharedStvWebSocket extends EventTarget { findChatroomForEvent(body, type) { // Try to identify which chatroom this event belongs to - // This is a best-effort approach since 7TV events don't always include channel context - + // For user events, broadcast to all chatrooms if (type.startsWith("user.")) { return null; // null means broadcast to all chatrooms @@ -652,8 +871,24 @@ class SharedStvWebSocket extends EventTarget { } } - // For cosmetic and entitlement events, they should include channel context - // but if not, we'll broadcast to all chatrooms + // For cosmetic and entitlement events, check all ID variants to see which subscription received this + if (type.startsWith("cosmetic.") || type.startsWith("entitlement.")) { + const contextId = body?.context?.id || body?.condition?.id || null; + + if (contextId) { + // Check each chatroom's ID variants to see which one matches + for (const [chatroomId, data] of this.chatrooms) { + const idVariants = data.idVariants || {}; + + for (const [idType, idValue] of Object.entries(idVariants)) { + if (idValue && String(idValue) === String(contextId)) { + return chatroomId; + } + } + } + } + } + return null; } diff --git a/utils/services/seventv/stvAPI.js b/utils/services/seventv/stvAPI.js index 6169381..3a02cf8 100644 --- a/utils/services/seventv/stvAPI.js +++ b/utils/services/seventv/stvAPI.js @@ -251,4 +251,120 @@ const getUserStvProfile = async (platformId) => { } }; -export { getChannelEmotes, sendUserPresence, getUserStvProfile }; +const getChannelCosmetics = async (channelId) => { + try { + console.log("[7TV Cosmetics] Fetching global cosmetics catalog"); + + // Fetch the global cosmetics catalog using v2 API (v3 removed cosmetics endpoints) + // This provides the base catalog of badges and paints that WebSocket events reference + const response = await axios.get(`https://api.7tv.app/v2/cosmetics`); + + if (response.status !== 200 || !response.data) { + console.warn("[7TV Cosmetics] Failed to fetch cosmetics:", response.status); + return { badges: [], paints: [] }; + } + + const cosmeticsData = response.data; + const cosmetics = { badges: [], paints: [] }; + + // Process badges from v2 API response + if (cosmeticsData.badges?.length) { + cosmetics.badges = cosmeticsData.badges.map(badge => ({ + id: badge.id, + title: badge.tooltip || badge.name, + url: `https:${badge.urls?.[badge.urls.length - 1] || badge.urls?.[0]}`, + })); + console.log(`[7TV Cosmetics] Loaded ${cosmetics.badges.length} badges`); + } + + // Process paints from v2 API response + if (cosmeticsData.paints?.length) { + cosmetics.paints = cosmeticsData.paints.map(paint => { + const randomColor = "#00f742"; + let paintObject = {}; + + if (paint.data?.stops?.length) { + const normalizedColors = paint.data.stops.map((stop) => ({ + at: stop.at * 100, + color: stop.color, + })); + + const gradient = normalizedColors.map((stop) => `${argbToRgba(stop.color)} ${stop.at}%`).join(", "); + + let paintFunction = paint.data.function?.toLowerCase().replace("_", "-"); + if (paint.data.repeat) { + paintFunction = `repeating-${paintFunction}`; + } + + let isDeg_or_Shape = `${paint.data.angle}deg`; + if (paintFunction !== "linear-gradient" && paintFunction !== "repeating-linear-gradient") { + isDeg_or_Shape = paint.data.shape; + } + + paintObject = { + id: paint.id, + name: paint.data.name, + style: paint.data.function, + shape: paint.data.shape, + backgroundImage: `${paintFunction || "linear-gradient"}(${isDeg_or_Shape}, ${gradient})` || + `${paint.data.style || "linear-gradient"}(${paint.data.shape || ""} 0deg, ${randomColor}, ${randomColor})`, + shadows: null, + KIND: "non-animated", + url: paint.data.image_url, + }; + } else { + paintObject = { + id: paint.id, + name: paint.data.name, + style: paint.data.function, + shape: paint.data.shape, + backgroundImage: `url('${paint.data.image_url}')` || + `${paint.data.style || "linear-gradient"}(${paint.data.shape || ""} 0deg, ${randomColor}, ${randomColor})`, + shadows: null, + KIND: "animated", + url: paint.data.image_url, + }; + } + + // Process shadows if present + if (paint.data.shadows?.length) { + const shadow = paint.data.shadows + .map((shadow) => { + let rgbaColor = argbToRgba(shadow.color); + rgbaColor = rgbaColor.replace(/rgba\((\d+), (\d+), (\d+), (\d+(\.\d+)?)\)/, `rgba($1, $2, $3)`); + return `drop-shadow(${rgbaColor} ${shadow.x_offset}px ${shadow.y_offset}px ${shadow.radius}px)`; + }) + .join(" "); + paintObject.shadows = shadow; + } + + return paintObject; + }); + console.log(`[7TV Cosmetics] Loaded ${cosmetics.paints.length} paints`); + } + + console.log("[7TV Cosmetics] Total cosmetics loaded:", { + badges: cosmetics.badges.length, + paints: cosmetics.paints.length + }); + + return cosmetics; + } catch (error) { + console.error("[7TV Cosmetics] Error fetching cosmetics:", error.message); + return { badges: [], paints: [] }; + } +}; + +// Helper function to convert ARGB to RGBA +const argbToRgba = (color) => { + if (color < 0) { + color = color >>> 0; + } + + const red = (color >> 24) & 0xff; + const green = (color >> 16) & 0xff; + const blue = (color >> 8) & 0xff; + return `rgba(${red}, ${green}, ${blue}, 1)`; +}; + +export { getChannelEmotes, sendUserPresence, getUserStvProfile, getChannelCosmetics }; diff --git a/utils/services/seventv/stvWebsocket.js b/utils/services/seventv/stvWebsocket.js index d3b2779..a0aa455 100644 --- a/utils/services/seventv/stvWebsocket.js +++ b/utils/services/seventv/stvWebsocket.js @@ -218,11 +218,23 @@ class StvWebSocket extends EventTarget { handleConnectionError() { this.reconnectAttempts++; console.log(`[7TV]: Connection error. Attempt ${this.reconnectAttempts}`); + try { + window.app?.telemetry?.recordWebSocketError?.(`7tv_${this.channelKickID}`, 'connection_error', { + attempt: this.reconnectAttempts, + channelKickID: this.channelKickID + }); + } catch (_) {} } handleReconnection() { if (!this.shouldReconnect) { console.log(`[7TV]: Reconnection disabled for chatroom ${this.channelKickID}`); + try { + window.app?.telemetry?.recordWebSocketEvent?.(`7tv_${this.channelKickID}`, 'reconnection_disabled', { + attempt: this.reconnectAttempts, + channelKickID: this.channelKickID + }); + } catch (_) {} return; } @@ -232,6 +244,14 @@ class StvWebSocket extends EventTarget { const delay = this.startDelay * Math.pow(2, step - 1); console.log(`[7TV]: Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); + try { + window.app?.telemetry?.recordWebSocketEvent?.(`7tv_${this.channelKickID}`, 'reconnection_scheduled', { + attempt: this.reconnectAttempts, + delay_ms: delay, + step: step, + channelKickID: this.channelKickID + }); + } catch (_) {} setTimeout(() => { this.connect(); @@ -332,8 +352,120 @@ class StvWebSocket extends EventTarget { try { const msg = JSON.parse(event.data); - // Log ALL messages to see if we're getting any at all + // Handle different 7TV opcodes + switch (msg?.op) { + case 0: // Dispatch (actual events) + console.log(`[7TV]: Dispatch event received for channel ${this.channelKickID}`, { type: msg?.d?.type }); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'dispatch', msg?.d?.type); + } catch (_) {} + break; + case 1: // Hello (connection established) + console.log(`[7TV]: Hello received for channel ${this.channelKickID}`, { + heartbeat_interval: msg?.d?.heartbeat_interval, + session_id: msg?.d?.session_id + }); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'hello', null, { + heartbeat_interval: msg?.d?.heartbeat_interval, + has_session_id: !!msg?.d?.session_id + }); + } catch (_) {} + return; + case 2: // Heartbeat + console.log(`[7TV]: Heartbeat received for channel ${this.channelKickID}`, { count: msg?.d?.count }); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'heartbeat', null, { + count: msg?.d?.count + }); + } catch (_) {} + return; // Don't process heartbeats further + case 4: // Reconnect (server requests reconnection) + console.log(`[7TV]: Reconnect request received for channel ${this.channelKickID}`, msg?.d); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'reconnect', null, { + reason: msg?.d?.reason + }); + } catch (_) {} + return; + case 5: // Ack (acknowledgment) + console.log(`[7TV]: ACK received for channel ${this.channelKickID}`, { + command: msg?.d?.command, + type: msg?.d?.data?.type, + id: msg?.d?.data?.id + }); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'ack', msg?.d?.data?.type, { + command: msg?.d?.command + }); + } catch (_) {} + return; + case 6: // Error + console.error(`[7TV]: Error received for channel ${this.channelKickID}`, { + message: msg?.d?.message, + code: msg?.d?.code, + data: msg?.d?.data + }); + try { + window.app?.telemetry?.recordWebSocketError?.(`7tv_${this.channelKickID}`, msg?.d?.message, { + code: msg?.d?.code, + data: msg?.d?.data + }); + } catch (_) {} + return; + case 7: // EndOfStream + console.log(`[7TV]: End of stream received for channel ${this.channelKickID}`, msg?.d); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'end_of_stream', null, msg?.d); + } catch (_) {} + return; + case 33: // Identify (client authentication) + console.log(`[7TV]: Identify opcode (should not be received by client) for channel ${this.channelKickID}`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'identify_unexpected', null); + } catch (_) {} + return; + case 34: // Resume (session resumption) + console.log(`[7TV]: Resume opcode (should not be received by client) for channel ${this.channelKickID}`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'resume_unexpected', null); + } catch (_) {} + return; + case 35: // Subscribe (subscription request) + console.log(`[7TV]: Subscribe opcode (should not be received by client) for channel ${this.channelKickID}`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'subscribe_unexpected', null); + } catch (_) {} + return; + case 36: // Unsubscribe (unsubscription request) + console.log(`[7TV]: Unsubscribe opcode (should not be received by client) for channel ${this.channelKickID}`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'unsubscribe_unexpected', null); + } catch (_) {} + return; + case 37: // Signal + console.log(`[7TV]: Signal received for channel ${this.channelKickID}`, msg?.d); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'signal', null, msg?.d); + } catch (_) {} + return; + case 38: // Bridge (deprecated) + console.log(`[7TV]: Bridge opcode received (deprecated) for channel ${this.channelKickID}`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'bridge_deprecated', null); + } catch (_) {} + return; + default: + console.log(`[7TV]: Unknown opcode ${msg?.op} for channel ${this.channelKickID}:`, msg); + try { + window.app?.telemetry?.recordWebSocketMessage?.(`7tv_${this.channelKickID}`, 'unknown_opcode', null, { + opcode: msg?.op + }); + } catch (_) {} + return; + } + // Only process dispatch events (op: 0) that have a body if (!msg?.d?.body) { return; } @@ -369,7 +501,8 @@ class StvWebSocket extends EventTarget { break; case "entitlement.create": - if (body.kind === 10) { + // Process entitlements for both cosmetics (kind 10) and emote sets (kind 5) + if (body.kind === 10 || body.kind === 5) { this.dispatchEvent( new CustomEvent("message", { detail: { body, type: "entitlement.create" },