From aad6675a2881fd153bfe5735de7577d94b22fe2a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 7 Jan 2026 16:07:52 -0700 Subject: [PATCH] Add chat dock --- app/assets/web/index.html | 35 ++ app/assets/web/widgets/chatdock/app.js | 554 ++++++++++++++++++ app/assets/web/widgets/chatdock/index.html | 54 ++ app/assets/web/widgets/chatdock/style.css | 621 +++++++++++++++++++++ app/auth.py | 17 +- app/chat_manager.py | 69 +++ app/chat_models.py | 2 + app/providers/twitch_chat.py | 187 ++++++- app/providers/youtube_chat.py | 43 ++ app/webserver.py | 134 ++++- 10 files changed, 1712 insertions(+), 4 deletions(-) create mode 100644 app/assets/web/widgets/chatdock/app.js create mode 100644 app/assets/web/widgets/chatdock/index.html create mode 100644 app/assets/web/widgets/chatdock/style.css diff --git a/app/assets/web/index.html b/app/assets/web/index.html index f6772db..bdfff0c 100644 --- a/app/assets/web/index.html +++ b/app/assets/web/index.html @@ -139,6 +139,12 @@ background: white; cursor: pointer; } + .widget-description { + margin: 8px 0 0 0; + font-size: 12px; + color: #64748b; + font-style: italic; + } .footer { border-top: 1px solid #e2e8f0; padding: 16px 32px; @@ -208,6 +214,34 @@ openLink.href = url; } + // Chat Dock URL generator + function updateChatDockUrl() { + const baseUrl = document.getElementById('chatdock-base-url'); + if (!baseUrl) return; + + const theme = document.getElementById('chatdock-theme').value; + const direction = document.getElementById('chatdock-direction').value; + const fontsize = document.getElementById('chatdock-fontsize').value; + const hidetime = document.getElementById('chatdock-hidetime').value; + const urlInput = document.getElementById('chatdock-url'); + const openLink = document.getElementById('chatdock-open'); + + let url = baseUrl.value; + const params = []; + + if (theme !== 'dark') params.push(`theme=${theme}`); + if (direction !== 'down') params.push(`direction=${direction}`); + if (fontsize !== 'medium') params.push(`fontsize=${fontsize}`); + if (hidetime === 'true') params.push(`hidetime=true`); + + if (params.length > 0) { + url += '?' + params.join('&'); + } + + urlInput.value = url; + openLink.href = url; + } + // Viewer Count URL generator function updateViewerCountUrl() { const baseUrl = document.getElementById('viewercount-base-url'); @@ -250,6 +284,7 @@ // Initialize on page load document.addEventListener('DOMContentLoaded', () => { updateLiveChatUrl(); + updateChatDockUrl(); updateViewerCountUrl(); }); diff --git a/app/assets/web/widgets/chatdock/app.js b/app/assets/web/widgets/chatdock/app.js new file mode 100644 index 0000000..0186ad6 --- /dev/null +++ b/app/assets/web/widgets/chatdock/app.js @@ -0,0 +1,554 @@ +class ChatDockWidget { + constructor() { + this.ws = null; + this.messagesContainer = document.getElementById('chat-messages'); + this.messageInput = document.getElementById('message-input'); + this.sendButton = document.getElementById('send-button'); + this.charCount = document.getElementById('char-count'); + this.statusDot = document.querySelector('.status-dot'); + this.statusText = document.querySelector('.status-text'); + this.maxMessages = 100; + this.autoScroll = true; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 10; + + // Current platform filter (all, twitch, youtube) + this.currentFilter = 'all'; + // Platform to send messages to (twitch or youtube) + this.sendPlatform = 'twitch'; + + this.platformIcons = { + twitch: ``, + youtube: ``, + }; + + // Apply settings from URL query params + const urlParams = new URLSearchParams(window.location.search); + + // Theme: dark (default), light + const theme = urlParams.get('theme'); + if (theme === 'light') { + document.body.classList.add('theme-light'); + } else { + document.body.classList.add('theme-dark'); + } + + // Direction: 'down' = newest at bottom (default), 'up' = newest at top + this.direction = urlParams.get('direction') || 'down'; + if (this.direction === 'up') { + document.body.classList.add('direction-up'); + } + + // Font size: small, medium (default), large, xlarge + this.fontSize = urlParams.get('fontsize') || 'medium'; + document.body.classList.add(`font-${this.fontSize}`); + + // Emote resolution based on font size + this.emoteScale = (this.fontSize === 'medium' || this.fontSize === 'large' || this.fontSize === 'xlarge') ? 2 : 1; + + // Hide timestamp option + const hideTime = urlParams.get('hidetime'); + if (hideTime === 'true' || hideTime === '1') { + document.body.classList.add('hide-time'); + } + + this.init(); + } + + init() { + this.setupEventListeners(); + this.connect(); + this.updateSendPlatformIndicator(); + this.checkAuthStatus(); + + // Handle scroll to detect manual scrolling + this.messagesContainer.addEventListener('scroll', () => { + const container = this.messagesContainer; + const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 50; + this.autoScroll = isAtBottom; + }); + } + + setupEventListeners() { + // Tab switching + document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => this.switchTab(tab.dataset.platform)); + }); + + // Message input + this.messageInput.addEventListener('input', () => { + this.charCount.textContent = this.messageInput.value.length; + }); + + this.messageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + }); + + // Send button + this.sendButton.addEventListener('click', () => this.sendMessage()); + + // Platform indicator click to switch send platform + document.getElementById('send-platform-indicator').addEventListener('click', () => { + this.toggleSendPlatform(); + }); + + // Reconnect button + document.getElementById('reconnect-btn').addEventListener('click', () => { + this.reconnectChat(); + }); + } + + switchTab(platform) { + this.currentFilter = platform; + + // Update tab UI + document.querySelectorAll('.tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.platform === platform); + }); + + // Filter messages + this.filterMessages(); + + // Update send target based on selected tab + this.sendPlatform = platform; // Can be 'all', 'twitch', or 'youtube' + this.updateSendPlatformIndicator(); + } + + filterMessages() { + const messages = this.messagesContainer.querySelectorAll('.chat-message'); + messages.forEach(msg => { + if (this.currentFilter === 'all') { + msg.style.display = ''; + } else { + const msgPlatform = msg.classList.contains('twitch') ? 'twitch' : 'youtube'; + msg.style.display = msgPlatform === this.currentFilter ? '' : 'none'; + } + }); + } + + toggleSendPlatform() { + // Cycle through: all -> twitch -> youtube -> all + if (this.sendPlatform === 'all') { + this.sendPlatform = 'twitch'; + } else if (this.sendPlatform === 'twitch') { + this.sendPlatform = 'youtube'; + } else { + this.sendPlatform = 'all'; + } + this.updateSendPlatformIndicator(); + } + + updateSendPlatformIndicator() { + const iconEl = document.getElementById('send-platform-icon'); + const nameEl = document.getElementById('send-platform-name'); + const indicator = document.getElementById('send-platform-indicator'); + + if (this.sendPlatform === 'all') { + // Show both icons for "all" + iconEl.innerHTML = ` + + ${this.platformIcons.twitch} + ${this.platformIcons.youtube} + + `; + nameEl.textContent = 'All'; + indicator.className = 'all'; + } else { + iconEl.innerHTML = this.platformIcons[this.sendPlatform]; + nameEl.textContent = this.sendPlatform === 'twitch' ? 'Twitch' : 'YouTube'; + indicator.className = this.sendPlatform; + } + } + + connect() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws`; + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('Chat WebSocket connected'); + this.reconnectAttempts = 0; + this.setStatus('connected', 'Connected'); + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + this.handleMessage(data); + } catch (err) { + console.error('Failed to parse message:', err); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + this.setStatus('error', 'Connection error'); + }; + + this.ws.onclose = () => { + console.log('Chat WebSocket disconnected'); + this.setStatus('disconnected', 'Disconnected'); + this.reconnect(); + }; + } + + reconnect() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.setStatus('error', 'Failed to connect'); + return; + } + + this.reconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); + this.setStatus('connecting', 'Reconnecting...'); + + setTimeout(() => { + console.log(`Reconnecting... (attempt ${this.reconnectAttempts})`); + this.connect(); + }, delay); + } + + setStatus(state, text) { + this.statusDot.className = 'status-dot ' + state; + this.statusText.textContent = text; + } + + handleMessage(data) { + switch (data.type) { + case 'chat_message': + this.addChatMessage(data.data); + break; + case 'chat_history': + if (data.data && Array.isArray(data.data)) { + // For both directions, add messages in order + data.data.forEach(msg => this.addChatMessage(msg, false)); + + if (this.direction === 'down') { + this.scrollToBottom(); + } + } + break; + case 'send_result': + // Handle send message result + if (data.success) { + this.messageInput.value = ''; + this.charCount.textContent = '0'; + } else { + this.showSendError(data.error || 'Failed to send message'); + } + break; + default: + break; + } + } + + addChatMessage(messageData, shouldAnimate = true) { + const msgElement = this.createMessageElement(messageData); + + if (!shouldAnimate) { + msgElement.style.animation = 'none'; + } + + // Apply filter + if (this.currentFilter !== 'all') { + const msgPlatform = messageData.platform; + if (msgPlatform !== this.currentFilter) { + msgElement.style.display = 'none'; + } + } + + if (this.direction === 'up') { + // Direction UP: newest at bottom (anchored), older messages bubble upward + this.messagesContainer.insertBefore(msgElement, this.messagesContainer.firstChild); + + // Limit total messages (remove oldest = last child) + while (this.messagesContainer.children.length > this.maxMessages) { + this.messagesContainer.removeChild(this.messagesContainer.lastChild); + } + } else { + // Direction DOWN (default): newest at bottom, scroll down + this.messagesContainer.appendChild(msgElement); + + // Limit total messages (remove oldest = first child) + while (this.messagesContainer.children.length > this.maxMessages) { + this.messagesContainer.removeChild(this.messagesContainer.firstChild); + } + + if (this.autoScroll) { + this.scrollToBottom(); + } + } + } + + createMessageElement(data) { + const msg = document.createElement('div'); + msg.className = `chat-message ${data.platform}`; + msg.dataset.messageId = data.id; + + if (data.is_action) { + msg.classList.add('action'); + } + + // Platform icon + if (data.platform) { + const iconDiv = document.createElement('div'); + iconDiv.className = 'platform-icon'; + iconDiv.innerHTML = this.platformIcons[data.platform] || ''; + msg.appendChild(iconDiv); + } + + // Message content + const content = document.createElement('div'); + content.className = 'message-content'; + + // User info line + const userInfo = document.createElement('div'); + userInfo.className = 'user-info'; + + // Badges + if (data.user.badges && data.user.badges.length > 0) { + const badgesContainer = document.createElement('div'); + badgesContainer.className = 'user-badges'; + + data.user.badges.forEach(badge => { + const badgeEl = document.createElement('span'); + badgeEl.className = 'badge'; + badgeEl.title = badge.name; + if (badge.icon_url) { + badgeEl.innerHTML = `${badge.name}`; + } else { + badgeEl.textContent = badge.name.charAt(0).toUpperCase(); + } + badgesContainer.appendChild(badgeEl); + }); + + userInfo.appendChild(badgesContainer); + } + + // Username + const username = document.createElement('span'); + username.className = 'username'; + username.textContent = data.user.display_name; + if (data.user.color) { + username.style.color = data.user.color; + } + userInfo.appendChild(username); + + // Timestamp + const timestamp = document.createElement('span'); + timestamp.className = 'timestamp'; + timestamp.textContent = this.formatTime(data.timestamp); + userInfo.appendChild(timestamp); + + content.appendChild(userInfo); + + // Message text with emotes + const messageText = document.createElement('div'); + messageText.className = 'message-text'; + messageText.innerHTML = this.parseMessageWithEmotes(data.message, data.emotes); + content.appendChild(messageText); + + msg.appendChild(content); + + return msg; + } + + getEmoteUrl(emote) { + if (!emote.emote_id) { + return emote.url; + } + + const scale = this.emoteScale; + const provider = emote.provider; + + switch (provider) { + case 'twitch': + return `https://static-cdn.jtvnw.net/emoticons/v2/${emote.emote_id}/default/dark/${scale}.0`; + case 'bttv': + return `https://cdn.betterttv.net/emote/${emote.emote_id}/${scale}x`; + case 'ffz': + const ffzScale = scale === 2 ? 2 : 1; + return emote.url.replace(/\/[124]$/, `/${ffzScale}`); + case '7tv': + return emote.url.replace(/\/[123]x\.webp$/, `/${scale}x.webp`); + default: + return emote.url; + } + } + + parseMessageWithEmotes(message, emotes) { + if (!emotes || emotes.length === 0) { + return this.escapeHtml(message); + } + + const emoteMap = {}; + emotes.forEach(emote => { + emoteMap[emote.code] = emote; + }); + + const words = message.split(' '); + const result = words.map(word => { + if (emoteMap[word]) { + const emote = emoteMap[word]; + const animatedClass = emote.is_animated ? 'animated' : ''; + const url = this.getEmoteUrl(emote); + return `${emote.code}`; + } + return this.escapeHtml(word); + }); + + return result.join(' '); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + formatTime(timestamp) { + const date = new Date(timestamp); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; + } + + scrollToBottom() { + this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight; + } + + async sendMessage() { + const message = this.messageInput.value.trim(); + if (!message) return; + + // Disable input while sending + this.messageInput.disabled = true; + this.sendButton.disabled = true; + + try { + const response = await fetch('/api/chat/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + platform: this.sendPlatform, + message: message, + }), + }); + + const result = await response.json(); + + if (result.success) { + this.messageInput.value = ''; + this.charCount.textContent = '0'; + } else { + this.showSendError(result.error || 'Failed to send message'); + } + } catch (error) { + console.error('Send error:', error); + this.showSendError('Network error'); + } finally { + this.messageInput.disabled = false; + this.sendButton.disabled = false; + this.messageInput.focus(); + } + } + + showSendError(error) { + // Create error toast + const toast = document.createElement('div'); + toast.className = 'error-toast'; + toast.textContent = error; + document.body.appendChild(toast); + + // Remove after 3 seconds + setTimeout(() => { + toast.classList.add('fade-out'); + setTimeout(() => toast.remove(), 300); + }, 3000); + } + + async checkAuthStatus() { + const statusEl = document.getElementById('auth-status-text'); + const statusContainer = document.getElementById('auth-status'); + + try { + const response = await fetch('/api/auth/status'); + const data = await response.json(); + + const twitchAuth = data.twitch_authenticated; + const youtubeAuth = data.youtube_authenticated; + + if (twitchAuth && youtubeAuth) { + statusEl.textContent = 'Both authenticated'; + statusContainer.className = 'authenticated'; + } else if (twitchAuth) { + statusEl.textContent = 'Twitch only'; + statusContainer.className = 'authenticated'; + } else if (youtubeAuth) { + statusEl.textContent = 'YouTube only'; + statusContainer.className = 'authenticated'; + } else { + statusEl.textContent = 'Not authenticated'; + statusContainer.className = 'not-authenticated'; + } + } catch (error) { + console.error('Failed to check auth status:', error); + statusEl.textContent = 'Unknown'; + statusContainer.className = ''; + } + } + + async reconnectChat() { + const btn = document.getElementById('reconnect-btn'); + const statusEl = document.getElementById('auth-status-text'); + + btn.disabled = true; + btn.classList.add('spinning'); + statusEl.textContent = 'Reconnecting...'; + + try { + const response = await fetch('/api/chat/reconnect', { + method: 'POST', + }); + const data = await response.json(); + + if (data.success) { + this.showToast('Chat reconnected!', 'success'); + // Re-check auth status + await this.checkAuthStatus(); + } else { + this.showToast(data.error || 'Failed to reconnect', 'error'); + } + } catch (error) { + console.error('Reconnect error:', error); + this.showToast('Network error', 'error'); + } finally { + btn.disabled = false; + btn.classList.remove('spinning'); + await this.checkAuthStatus(); + } + } + + showToast(message, type = 'info') { + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + document.body.appendChild(toast); + + setTimeout(() => { + toast.classList.add('fade-out'); + setTimeout(() => toast.remove(), 300); + }, 3000); + } +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + new ChatDockWidget(); +}); diff --git a/app/assets/web/widgets/chatdock/index.html b/app/assets/web/widgets/chatdock/index.html new file mode 100644 index 0000000..4c35513 --- /dev/null +++ b/app/assets/web/widgets/chatdock/index.html @@ -0,0 +1,54 @@ + + + + + + Chat Dock + + + +
+
+
+ + + +
+
+ + Connecting... +
+
+
+
+
+
+ + Twitch +
+
+ Checking... + +
+
+
+ + +
+
0/500
+
+
+ + + + diff --git a/app/assets/web/widgets/chatdock/style.css b/app/assets/web/widgets/chatdock/style.css new file mode 100644 index 0000000..4a6cc7d --- /dev/null +++ b/app/assets/web/widgets/chatdock/style.css @@ -0,0 +1,621 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #18181b; + --bg-secondary: #1f1f23; + --bg-tertiary: #26262c; + --bg-hover: #323239; + --text-primary: #efeff1; + --text-secondary: #adadb8; + --text-muted: #71717a; + --border-color: #3d3d42; + --accent-twitch: #9146ff; + --accent-youtube: #ff0000; + --success: #00c853; + --error: #ff4444; + --warning: #ffab00; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + height: 100vh; + overflow: hidden; +} + +/* Light Theme */ +body.theme-light { + --bg-primary: #f5f5f5; + --bg-secondary: #ffffff; + --bg-tertiary: #e8e8e8; + --bg-hover: #d4d4d4; + --text-primary: #1a1a1a; + --text-secondary: #4a4a4a; + --text-muted: #888888; + --border-color: #d0d0d0; +} + +body.theme-light .chat-message { + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +body.theme-light .username { + text-shadow: none; +} + +/* Font size options */ +body.font-small { + --chat-font-size: 12px; + --chat-username-size: 12px; + --chat-badge-size: 14px; + --chat-emote-size: 24px; +} + +body.font-medium { + --chat-font-size: 14px; + --chat-username-size: 14px; + --chat-badge-size: 16px; + --chat-emote-size: 32px; +} + +body.font-large { + --chat-font-size: 18px; + --chat-username-size: 18px; + --chat-badge-size: 20px; + --chat-emote-size: 44px; +} + +body.font-xlarge { + --chat-font-size: 24px; + --chat-username-size: 24px; + --chat-badge-size: 26px; + --chat-emote-size: 56px; +} + +/* Hide timestamp option */ +body.hide-time .timestamp { + display: none; +} + +#chat-dock { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Header with tabs */ +#chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.platform-tabs { + display: flex; + gap: 4px; +} + +.tab { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.tab:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.tab.active { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.tab svg { + width: 16px; + height: 16px; +} + +#tab-twitch.active { + color: var(--accent-twitch); +} + +#tab-youtube.active { + color: var(--accent-youtube); +} + +/* Connection status */ +#connection-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); +} + +.status-dot.connected { + background: var(--success); + box-shadow: 0 0 6px var(--success); +} + +.status-dot.connecting { + background: var(--warning); + animation: pulse 1s infinite; +} + +.status-dot.disconnected, +.status-dot.error { + background: var(--error); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Chat messages area */ +#chat-messages { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Direction UP: Messages anchor to bottom */ +body.direction-up #chat-messages { + flex-direction: column-reverse; + justify-content: flex-start; +} + +body.direction-up .chat-message { + animation: slideUp 0.2s ease-out; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Custom scrollbar */ +#chat-messages::-webkit-scrollbar { + width: 6px; +} + +#chat-messages::-webkit-scrollbar-track { + background: transparent; +} + +#chat-messages::-webkit-scrollbar-thumb { + background: var(--bg-hover); + border-radius: 3px; +} + +#chat-messages::-webkit-scrollbar-thumb:hover { + background: var(--border-color); +} + +/* Chat message */ +.chat-message { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 8px; + background: var(--bg-secondary); + border-radius: 6px; + border-left: 3px solid transparent; + animation: slideIn 0.2s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-message.twitch { + border-left-color: var(--accent-twitch); +} + +.chat-message.youtube { + border-left-color: var(--accent-youtube); +} + +.chat-message.action { + font-style: italic; + background: rgba(100, 100, 100, 0.2); +} + +/* Platform icon */ +.platform-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + margin-top: 2px; +} + +.platform-icon svg { + width: 100%; + height: 100%; +} + +/* Message content */ +.message-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.user-info { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.user-badges { + display: flex; + gap: 3px; + align-items: center; +} + +.badge { + width: var(--chat-badge-size, 18px); + height: var(--chat-badge-size, 18px); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: bold; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; +} + +.badge img { + width: 100%; + height: 100%; + border-radius: 3px; +} + +.username { + font-weight: 600; + font-size: var(--chat-username-size, 14px); + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); +} + +body.theme-light .username { + text-shadow: none; +} + +.timestamp { + font-size: calc(var(--chat-font-size, 14px) * 0.8); + color: var(--text-muted); +} + +.message-text { + font-size: var(--chat-font-size, 14px); + line-height: 1.4; + word-wrap: break-word; + overflow-wrap: break-word; + color: var(--text-primary); +} + +/* Emotes */ +.emote { + display: inline-block; + vertical-align: middle; + margin: 0 2px; + height: var(--chat-emote-size, 28px); + width: auto; + max-width: calc(var(--chat-emote-size, 28px) * 3); + object-fit: contain; +} + +.emote.animated { + image-rendering: auto; +} + +/* Input area */ +#chat-input-area { + padding: 8px 12px 12px; + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + flex-shrink: 0; +} + +#input-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +#auth-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-muted); + padding: 4px 8px; + background: var(--bg-tertiary); + border-radius: 4px; +} + +#auth-status.authenticated { + color: var(--success); +} + +#auth-status.not-authenticated { + color: var(--error); +} + +#auth-status-text { + white-space: nowrap; +} + +#reconnect-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +#reconnect-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +#reconnect-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +#reconnect-btn.spinning svg { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +#send-platform-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--bg-tertiary); + border-radius: 4px; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +#send-platform-indicator:hover { + background: var(--bg-hover); +} + +#send-platform-indicator.twitch { + border: 1px solid var(--accent-twitch); + color: var(--accent-twitch); +} + +#send-platform-indicator.youtube { + border: 1px solid var(--accent-youtube); + color: var(--accent-youtube); +} + +#send-platform-indicator.all { + border: 1px solid var(--text-secondary); + background: linear-gradient(135deg, rgba(145, 70, 255, 0.2), rgba(255, 0, 0, 0.2)); +} + +.dual-icons { + display: flex; + align-items: center; + gap: 2px; +} + +.dual-icons svg { + width: 12px; + height: 12px; +} + +#send-platform-icon { + display: flex; + align-items: center; +} + +#send-platform-icon svg { + width: 14px; + height: 14px; +} + +#input-row { + display: flex; + gap: 8px; +} + +#message-input { + flex: 1; + padding: 10px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + font-size: 14px; + outline: none; + transition: border-color 0.15s ease; +} + +#message-input::placeholder { + color: var(--text-muted); +} + +#message-input:focus { + border-color: var(--accent-twitch); +} + +#message-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +#send-button { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + background: var(--accent-twitch); + border: none; + border-radius: 6px; + color: white; + cursor: pointer; + transition: all 0.15s ease; +} + +#send-button:hover { + background: #7c3aed; + transform: scale(1.05); +} + +#send-button:active { + transform: scale(0.95); +} + +#send-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +#send-button svg { + width: 20px; + height: 20px; +} + +#char-counter { + margin-top: 4px; + font-size: 11px; + color: var(--text-muted); + text-align: right; +} + +/* Toasts */ +.error-toast, +.toast { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + padding: 10px 20px; + background: var(--bg-tertiary); + color: var(--text-primary); + border-radius: 6px; + font-size: 13px; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + animation: toastIn 0.3s ease; +} + +.error-toast, +.toast.error { + background: var(--error); + color: white; +} + +.toast.success { + background: var(--success); + color: white; +} + +.error-toast.fade-out, +.toast.fade-out { + animation: toastOut 0.3s ease forwards; +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@keyframes toastOut { + from { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + to { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } +} + +/* Empty state */ +.status-message { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + font-size: 14px; + text-align: center; + padding: 20px; +} diff --git a/app/auth.py b/app/auth.py index 22e022e..6949068 100644 --- a/app/auth.py +++ b/app/auth.py @@ -50,6 +50,7 @@ async def load_tokens(state: AppState) -> None: else None ), scope=twitch_data.get("scope", []), + username=twitch_data.get("username"), ) await state.set_auth_tokens(Platform.TWITCH, tokens) @@ -110,7 +111,7 @@ async def handle_twitch_login(request: web.Request) -> web.Response: "client_id": _app_config.twitch_oauth.client_id, "redirect_uri": _app_config.twitch_oauth.redirect_uri, "response_type": "code", - "scope": "chat:read", + "scope": "chat:read chat:edit", "state": state_token, } @@ -151,6 +152,19 @@ async def handle_twitch_callback(request: web.Request) -> web.Response: token_data = await resp.json() + # Get the user's username using the access token + user_login = None + headers = { + "Authorization": f"Bearer {token_data['access_token']}", + "Client-Id": _app_config.twitch_oauth.client_id, + } + async with session.get("https://api.twitch.tv/helix/users", headers=headers) as resp: + if resp.status == 200: + user_data = await resp.json() + if user_data.get("data"): + user_login = user_data["data"][0].get("login") + print(f"Twitch: Authenticated as {user_login}") + # Store tokens state: AppState = request.app["state"] expires_in = token_data.get("expires_in", 3600) @@ -159,6 +173,7 @@ async def handle_twitch_callback(request: web.Request) -> web.Response: refresh_token=token_data.get("refresh_token"), expires_at=datetime.now() + timedelta(seconds=expires_in), scope=token_data.get("scope", []), + username=user_login, ) await state.set_auth_tokens(Platform.TWITCH, tokens) diff --git a/app/chat_manager.py b/app/chat_manager.py index 95d441a..374642a 100644 --- a/app/chat_manager.py +++ b/app/chat_manager.py @@ -94,3 +94,72 @@ class ChatManager: """Restart all chat clients with current configuration.""" await self.stop() await self.start() + + async def send_message(self, platform: Platform | str, message: str) -> tuple[bool, str]: + """ + Send a chat message to the specified platform(s). + + Platform can be Platform.TWITCH, Platform.YOUTUBE, or "all" to send to both. + Returns a tuple of (success, error_message). + """ + # Handle sending to all platforms + if platform == "all": + return await self._send_to_all(message) + + if platform == Platform.TWITCH: + if not self.twitch_client: + return False, "Twitch chat not connected" + if not self.twitch_client.is_authenticated: + return False, "Not authenticated. Go to /config, login with Twitch, then restart the app." + success = await self.twitch_client.send_message(message) + if success: + return True, "" + return False, "Failed to send message" + + elif platform == Platform.YOUTUBE: + if not self.youtube_client: + return False, "YouTube chat not connected" + if not self.youtube_client.live_chat_id: + return False, "No active YouTube live chat found" + success = await self.youtube_client.send_message(message) + if success: + return True, "" + return False, "Failed to send message" + + return False, f"Unknown platform: {platform}" + + async def _send_to_all(self, message: str) -> tuple[bool, str]: + """Send a message to all connected platforms with a single echo.""" + errors = [] + any_success = False + echoed = False + + # Try Twitch + if self.twitch_client and self.twitch_client.is_authenticated: + # Send without echo first + success = await self.twitch_client.send_message_no_echo(message) + if success: + any_success = True + # Echo once from Twitch (since it has better user info) + if not echoed: + await self.twitch_client._echo_sent_message(message) + echoed = True + else: + errors.append("Twitch: failed to send") + + # Try YouTube + if self.youtube_client and self.youtube_client.live_chat_id: + success = await self.youtube_client.send_message(message) + if success: + any_success = True + else: + errors.append("YouTube: failed to send") + + if any_success: + if errors: + return True, f"Sent (some failed: {', '.join(errors)})" + return True, "" + + if errors: + return False, "; ".join(errors) + return False, "No platforms connected" \ No newline at end of file diff --git a/app/chat_models.py b/app/chat_models.py index 7c9eb99..a2a04ad 100644 --- a/app/chat_models.py +++ b/app/chat_models.py @@ -100,6 +100,7 @@ class AuthTokens: refresh_token: Optional[str] = None expires_at: Optional[datetime] = None scope: List[str] = field(default_factory=list) + username: Optional[str] = None # The authenticated user's username def is_expired(self) -> bool: if not self.expires_at: @@ -112,6 +113,7 @@ class AuthTokens: "refresh_token": self.refresh_token, "expires_at": self.expires_at.isoformat() if self.expires_at else None, "scope": self.scope, + "username": self.username, } diff --git a/app/providers/twitch_chat.py b/app/providers/twitch_chat.py index 14b58bc..46cf644 100644 --- a/app/providers/twitch_chat.py +++ b/app/providers/twitch_chat.py @@ -76,6 +76,14 @@ class TwitchChatClient: self.global_badges: dict[str, str] = {} self.channel_badges: dict[str, str] = {} self.channel_id: Optional[str] = None + + # Authentication state + self.is_authenticated: bool = False + + # Authenticated user info (populated from GLOBALUSERSTATE/USERSTATE) + self.user_color: Optional[str] = None + self.user_badges: list[ChatBadge] = [] + self.user_display_name: Optional[str] = None async def start(self) -> None: """Start the IRC connection.""" @@ -99,12 +107,18 @@ class TwitchChatClient: # Authenticate if tokens and tokens.access_token: + # Use the stored username, or fall back to channel name + nick = tokens.username or self.channel await self.ws.send_str(f"PASS oauth:{tokens.access_token}") - await self.ws.send_str(f"NICK {self.channel}") + await self.ws.send_str(f"NICK {nick}") + self.is_authenticated = True + print(f"Twitch: Connected with authentication as {nick}") else: - # Anonymous connection + # Anonymous connection (read-only) await self.ws.send_str("PASS SCHMOOPIIE") await self.ws.send_str(f"NICK justinfan{asyncio.get_event_loop().time():.0f}") + self.is_authenticated = False + print(f"Twitch: Connected anonymously (read-only, cannot send messages)") # Request capabilities for tags (emotes, badges, color, etc.) await self.ws.send_str("CAP REQ :twitch.tv/tags twitch.tv/commands") @@ -128,6 +142,100 @@ class TwitchChatClient: if self.session: await self.session.close() + async def send_message(self, message: str, echo: bool = True) -> bool: + """ + Send a chat message to the channel. + + Returns True if the message was sent successfully, False otherwise. + Requires authenticated connection (not anonymous). + + Args: + message: The message to send + echo: Whether to locally echo the message (default True) + """ + if not self.ws or not self.running: + print("Twitch: Cannot send message - not connected") + return False + + # Check if we're connected anonymously - can't send messages + if not self.is_authenticated: + print("Twitch: Cannot send message - connected anonymously. Please authenticate via /config and restart.") + return False + + try: + # Send PRIVMSG to channel + await self.ws.send_str(f"PRIVMSG #{self.channel} :{message}") + print(f"Twitch: Sent message to #{self.channel}") + + # Local echo - add our own message to the chat so we can see it + if echo: + await self._echo_sent_message(message) + + return True + except Exception as e: + print(f"Twitch: Error sending message: {e}") + return False + + async def send_message_no_echo(self, message: str) -> bool: + """Send a message without local echo (used for multi-platform sends).""" + return await self.send_message(message, echo=False) + + async def _echo_sent_message(self, message: str) -> None: + """Add our own sent message to the chat display (local echo).""" + tokens = await self.state.get_auth_tokens(Platform.TWITCH) + if not tokens: + return + + username = tokens.username or self.channel + display_name = self.user_display_name or username + + # Determine roles from badges + roles = [UserRole.VIEWER] + badge_names = [b.name for b in self.user_badges] + if "broadcaster" in badge_names: + roles.append(UserRole.BROADCASTER) + if "moderator" in badge_names: + roles.append(UserRole.MODERATOR) + if "vip" in badge_names: + roles.append(UserRole.VIP) + if "subscriber" in badge_names or "founder" in badge_names: + roles.append(UserRole.SUBSCRIBER) + + # Create a user object for ourselves using stored info + user = ChatUser( + id=username, + username=username, + display_name=display_name, + platform=Platform.TWITCH, + color=self.user_color, # Use our actual color from Twitch + roles=roles, + badges=self.user_badges.copy(), # Use our actual badges + ) + + # Check for /me action + is_action = message.startswith("/me ") + if is_action: + message = message[4:] + + # Create the message + msg_id = f"sent_{username}_{datetime.now().timestamp()}" + + # Parse emotes from our message + emotes = await self._parse_emotes(message, {}) + + chat_msg = ChatMessage( + id=msg_id, + platform=Platform.TWITCH, + user=user, + message=message, + timestamp=datetime.now(), + emotes=emotes, + is_action=is_action, + ) + + # Add to state (broadcasts to all connected clients) + await self.state.add_chat_message(chat_msg) + async def _message_loop(self) -> None: """Main loop to receive and process IRC messages.""" if not self.ws: @@ -149,10 +257,85 @@ class TwitchChatClient: await self.ws.send_str("PONG :tmi.twitch.tv") return + # Handle NOTICE messages (errors, warnings from Twitch) + if "NOTICE" in raw: + # Extract the notice message + notice_match = re.search(r"NOTICE [#\w]+ :(.+)", raw) + if notice_match: + notice_text = notice_match.group(1) + print(f"Twitch NOTICE: {notice_text}") + else: + print(f"Twitch NOTICE (raw): {raw}") + return + + # Handle USERSTATE (sent after successful message - contains our user info) + if "USERSTATE" in raw: + self._parse_user_state(raw) + return + + # Handle GLOBALUSERSTATE (sent on connect - contains our global user info) + if "GLOBALUSERSTATE" in raw: + self._parse_user_state(raw) + return + + # Log other important messages for debugging + if any(x in raw for x in ["JOIN", "PART"]): + # Normal connection messages, ignore + return + + # Log connection/room info + if "ROOMSTATE" in raw: + # Parse room state to check settings + if "followers-only=" in raw: + fo_match = re.search(r"followers-only=(-?\d+)", raw) + if fo_match: + fo_val = int(fo_match.group(1)) + if fo_val >= 0: + print(f"Twitch: Channel is in followers-only mode ({fo_val} minutes)") + else: + print(f"Twitch: Channel followers-only mode is OFF") + return + # Parse PRIVMSG (chat messages) if "PRIVMSG" in raw: await self._parse_privmsg(raw) + def _parse_user_state(self, raw: str) -> None: + """Parse USERSTATE or GLOBALUSERSTATE to get our own user info.""" + # Extract tags + if not raw.startswith("@"): + return + + tag_str = raw.split(" ", 1)[0] + tags = {} + for tag in tag_str[1:].split(";"): + if "=" in tag: + key, value = tag.split("=", 1) + tags[key] = value + + # Get display name + if tags.get("display-name"): + self.user_display_name = tags["display-name"] + + # Get color + if tags.get("color"): + self.user_color = tags["color"] + + # Get badges + badges_tag = tags.get("badges", "") + if badges_tag: + self.user_badges = [] + for badge_pair in badges_tag.split(","): + if "/" in badge_pair: + badge_name, badge_version = badge_pair.split("/", 1) + badge_key = f"{badge_name}/{badge_version}" + + # Look up badge image URL + icon_url = self.channel_badges.get(badge_key) or self.global_badges.get(badge_key) + self.user_badges.append(ChatBadge(name=badge_name, icon_url=icon_url)) + + print(f"Twitch: User info updated - {self.user_display_name}, color={self.user_color}, badges={len(self.user_badges)}") + async def _parse_privmsg(self, raw: str) -> None: """ Parse a PRIVMSG IRC line. diff --git a/app/providers/youtube_chat.py b/app/providers/youtube_chat.py index e5036eb..864b0f2 100644 --- a/app/providers/youtube_chat.py +++ b/app/providers/youtube_chat.py @@ -68,6 +68,49 @@ class YouTubeChatClient: if self.session: await self.session.close() + async def send_message(self, message: str) -> bool: + """ + Send a chat message to the live chat. + + Returns True if the message was sent successfully, False otherwise. + """ + if not self.session or not self.live_chat_id: + print("YouTube: Cannot send message - not connected to live chat") + return False + + tokens = await self.state.get_auth_tokens(Platform.YOUTUBE) + if not tokens or not tokens.access_token: + print("YouTube: Cannot send message - not authenticated") + return False + + url = f"{self.API_BASE}/liveChat/messages" + params = {"part": "snippet"} + headers = { + "Authorization": f"Bearer {tokens.access_token}", + "Content-Type": "application/json", + } + payload = { + "snippet": { + "liveChatId": self.live_chat_id, + "type": "textMessageEvent", + "textMessageDetails": { + "messageText": message + } + } + } + + try: + async with self.session.post(url, params=params, headers=headers, json=payload) as resp: + if resp.status in (200, 201): + return True + else: + error = await resp.text() + print(f"YouTube: Error sending message: {resp.status} - {error}") + return False + except Exception as e: + print(f"YouTube: Error sending message: {e}") + return False + async def _find_active_broadcast(self, access_token: str) -> None: """Find the user's active live broadcast automatically.""" if not self.session: diff --git a/app/webserver.py b/app/webserver.py index 33fff7f..61866e9 100644 --- a/app/webserver.py +++ b/app/webserver.py @@ -4,7 +4,7 @@ from pathlib import Path from aiohttp import WSMsgType, web -from app.chat_models import ChatConfig +from app.chat_models import ChatConfig, Platform from app.config import get_config_file, load_config, save_chat_settings from app.paths import get_art_dir, get_web_assets_dir from app.state import AppState @@ -13,6 +13,7 @@ from app.state import AppState WIDGETS = [ {"slug": "nowplaying", "label": "Now Playing"}, {"slug": "livechat", "label": "Live Chat"}, + {"slug": "chatdock", "label": "Chat Dock"}, {"slug": "viewercount", "label": "Viewer Count"}, ] @@ -77,6 +78,53 @@ async def handle_root(request: web.Request) -> web.Response: """ + elif slug == "chatdock": + # Chat Dock widget with options (same as livechat but for OBS dock) + item_html = f""" +
  • +
    + {label} +
    +
    + + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +

    Chat dock with send capability. Requires Twitch OAuth to send messages.

    +
  • + """ elif slug == "viewercount": # Viewer Count widget with options item_html = f""" @@ -238,6 +286,88 @@ async def handle_chat_config_post(request: web.Request) -> web.Response: return web.json_response({"status": "ok"}) +async def handle_chat_send(request: web.Request) -> web.Response: + """Send a chat message to a platform.""" + state: AppState = request.app["state"] + + try: + data = await request.json() + except Exception: + return web.json_response( + {"success": False, "error": "Invalid JSON"}, + status=400 + ) + + platform_str = data.get("platform", "").lower() + message = data.get("message", "").strip() + + if not message: + return web.json_response( + {"success": False, "error": "Message cannot be empty"}, + status=400 + ) + + if len(message) > 500: + return web.json_response( + {"success": False, "error": "Message too long (max 500 characters)"}, + status=400 + ) + + # Parse platform - can be "twitch", "youtube", or "all" + if platform_str == "all": + platform: Platform | str = "all" + else: + try: + platform = Platform(platform_str) + except ValueError: + return web.json_response( + {"success": False, "error": f"Invalid platform: {platform_str}"}, + status=400 + ) + + # Check if chat manager is available + if not state.chat_manager: + return web.json_response( + {"success": False, "error": "Chat not initialized"}, + status=503 + ) + + # Send the message + success, error = await state.chat_manager.send_message(platform, message) + + if success: + return web.json_response({"success": True}) + else: + return web.json_response( + {"success": False, "error": error}, + status=400 + ) + + +async def handle_chat_reconnect(request: web.Request) -> web.Response: + """Reconnect chat with current tokens (useful after re-authenticating).""" + from app.auth import load_tokens + + state: AppState = request.app["state"] + + # Reload tokens from disk + await load_tokens(state) + + # Restart chat connections + if state.chat_manager: + print("Reconnecting chat with updated tokens...") + await state.chat_manager.restart() + return web.json_response({ + "success": True, + "message": "Chat reconnected with updated tokens" + }) + else: + return web.json_response({ + "success": False, + "error": "Chat manager not initialized" + }, status=503) + + async def handle_config_page(request: web.Request) -> web.FileResponse: """Serve the configuration page.""" config_path = get_web_assets_dir() / "config.html" @@ -407,6 +537,8 @@ def make_app(state: AppState) -> web.Application: app.router.add_get("/api/chat/messages", handle_chat_messages) app.router.add_get("/api/chat/config", handle_chat_config_get) app.router.add_post("/api/chat/config", handle_chat_config_post) + app.router.add_post("/api/chat/send", handle_chat_send) + app.router.add_post("/api/chat/reconnect", handle_chat_reconnect) app.router.add_get("/api/oauth/status", handle_oauth_status) app.router.add_get("/api/auth/status", handle_auth_status) app.router.add_post("/api/config/open-directory", handle_open_config_dir)