diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d1dfb4..ef47655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Lumi changelog +## 0.3.9 + +- Restored animated Twitch/BTTV and Discord emotes plus Discord GIF media across the existing normalized OBS and native Companion overlay paths, and rendered real Twitch badge artwork with safe fallbacks. +- Released Companion 0.2.7 with bounded multi-frame GIF/WebP decoding for the capture-excluded native overlay while retaining the existing OBS browser renderer and media workflow. +- Added configurable Auto VC room messages with the room-owner placeholder, persistent lock/unlock controls, timed ownership claiming, and short-lived action reports. +- Preserved all existing settings, lobbies, active rooms, ownership, permissions, pairing identities, Companion settings, overlays, databases, uploads, models, and secrets through additive migrations and the normal updater. + ## 0.3.8 - Added the native Windows Lumi Overlay using the existing paired-device authentication, normalized provider streams, live-state tracking, and Companion plugin transport without exposing provider credentials. diff --git a/companion/installer/Lumi.Companion.iss b/companion/installer/Lumi.Companion.iss index 6f5ac21..8fe1e2d 100644 --- a/companion/installer/Lumi.Companion.iss +++ b/companion/installer/Lumi.Companion.iss @@ -1,5 +1,5 @@ #ifndef AppVersion - #define AppVersion "0.2.6" + #define AppVersion "0.2.7" #endif #ifndef SourceRoot #error SourceRoot must point at the self-contained Companion publish directory. diff --git a/companion/plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj b/companion/plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj index 4753994..ef70edd 100644 --- a/companion/plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj +++ b/companion/plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj @@ -11,5 +11,6 @@ + diff --git a/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs b/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs index 59e12d5..c670eca 100644 --- a/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs +++ b/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs @@ -10,6 +10,7 @@ using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Threading; using Microsoft.Win32; +using SkiaSharp; namespace Lumi.Companion.Overlay; @@ -299,7 +300,7 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable var contentColumn = 0; if (settings.ShowAvatar && message.Author.Avatar is not null) { - var avatar = new Image { Width = 36, Height = 36, Stretch = Stretch.UniformToFill }; + var avatar = new AnimatedOverlayImage { Width = 36, Height = 36, Stretch = Stretch.UniformToFill }; _ = LoadImageAsync(avatar, message.Author.Avatar); grid.Children.Add(avatar); contentColumn = 1; @@ -308,10 +309,26 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable var header = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 7 }; if (settings.ShowPlatform) header.Children.Add(Text(message.Platform.ToUpperInvariant(), settings, 11, FontWeight.Bold)); foreach (var badge in message.Author.Badges.Take(6)) - header.Children.Add(Text(badge.Label, settings, 10, FontWeight.SemiBold)); + { + if (badge.Image is null) + { + header.Children.Add(Text(badge.Label, settings, 10, FontWeight.SemiBold)); + continue; + } + var token = new Grid(); + ToolTip.SetTip(token, badge.Label); + var fallback = Text(badge.Label, settings, 10, FontWeight.SemiBold); + var image = new AnimatedOverlayImage { Width = 18, Height = 18, Stretch = Stretch.Uniform, Opacity = 0 }; + token.Children.Add(fallback); + token.Children.Add(image); + header.Children.Add(token); + _ = LoadImageAsync(image, badge.Image, fallback); + } if (settings.ShowUsername) header.Children.Add(Text(message.Author.Name, settings, settings.FontSize * .72, FontWeight.Bold)); if (header.Children.Count > 0) stack.Children.Add(header); stack.Children.Add(CreateMessageBody(message, settings)); + var media = CreateMessageMedia(message); + if (media is not null) stack.Children.Add(media); Grid.SetColumn(stack, contentColumn); grid.Children.Add(stack); return grid; @@ -329,7 +346,7 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable { var token = new Grid { Margin = new Thickness(2, 0) }; var fallback = Text(string.IsNullOrWhiteSpace(emote.Label) ? message.Text[emote.Start..Math.Min(message.Text.Length, emote.End + 1)] : emote.Label, settings, settings.FontSize); - var image = new Image { Width = settings.FontSize * 1.3, Height = settings.FontSize * 1.3, Opacity = 0 }; + var image = new AnimatedOverlayImage { Width = settings.FontSize * 1.3, Height = settings.FontSize * 1.3, Opacity = 0 }; token.Children.Add(fallback); token.Children.Add(image); panel.Children.Add(token); @@ -343,6 +360,31 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable return panel; } + private Control? CreateMessageMedia(OverlayFeedMessage message) + { + var panel = new WrapPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + ItemWidth = 220, + ItemHeight = 150 + }; + foreach (var media in message.Media.Take(4)) + { + var source = media.Type == "video" ? media.Preview : media.Url; + if (string.IsNullOrWhiteSpace(source)) continue; + var image = new AnimatedOverlayImage + { + Width = 220, + Height = 150, + Stretch = Stretch.Uniform + }; + if (!string.IsNullOrWhiteSpace(media.Alt)) ToolTip.SetTip(image, media.Alt); + panel.Children.Add(image); + _ = LoadImageAsync(image, source); + } + return panel.Children.Count == 0 ? null : panel; + } + private Control CreateEventContent(OverlayRenderedEvent value, OverlayContainerSettings settings) { var stack = new StackPanel { Spacing = 3 }; @@ -378,14 +420,14 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable return block; } - private async Task LoadImageAsync(Image target, string url, Control? fallback = null) + private async Task LoadImageAsync(AnimatedOverlayImage target, string url, Control? fallback = null) { - var bitmap = await _images.GetAsync(url).ConfigureAwait(false); + var asset = await _images.GetAsync(url).ConfigureAwait(false); await Dispatcher.UIThread.InvokeAsync(() => { - if (bitmap is not null) + if (asset is not null) { - target.Source = bitmap; + target.SetAsset(asset); target.Opacity = 1; if (fallback is not null) fallback.IsVisible = false; } @@ -475,14 +517,18 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable internal sealed class HttpsImageCache : IDisposable { private const int Limit = 128; + private const int MaxEncodedBytes = 2 * 1024 * 1024; + private const int MaxDimension = 384; + private const int MaxFrames = 60; + private const long MaxDecodedPixels = 12_000_000; private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) }; - private readonly ConcurrentDictionary>> _entries = new(); + private readonly ConcurrentDictionary>> _entries = new(); private readonly Queue _order = new(); private readonly object _gate = new(); - public Task GetAsync(string value) + public Task GetAsync(string value) { - if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult(null); + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult(null); lock (_gate) { if (!_entries.ContainsKey(uri.AbsoluteUri)) @@ -495,19 +541,58 @@ internal sealed class HttpsImageCache : IDisposable return _entries.GetOrAdd(uri.AbsoluteUri, key => new(() => DownloadAsync(key))).Value; } - private async Task DownloadAsync(string url) + private async Task DownloadAsync(string url) { try { using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); - if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > 2 * 1024 * 1024) return null; + if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > MaxEncodedBytes) return null; var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); - if (bytes.Length > 2 * 1024 * 1024) return null; - return new Bitmap(new MemoryStream(bytes)); + if (bytes.Length > MaxEncodedBytes) return null; + return Decode(bytes); } catch { return null; } } + private static OverlayImageAsset? Decode(byte[] bytes) + { + using var data = SKData.CreateCopy(bytes); + using var codec = SKCodec.Create(data); + if (codec is null || codec.Info.Width <= 0 || codec.Info.Height <= 0) return null; + var scale = Math.Min(1d, MaxDimension / (double)Math.Max(codec.Info.Width, codec.Info.Height)); + var dimensions = codec.GetScaledDimensions((float)scale); + var width = Math.Max(1, dimensions.Width); + var height = Math.Max(1, dimensions.Height); + var frameCount = Math.Clamp(codec.FrameCount, 1, MaxFrames); + frameCount = (int)Math.Min(frameCount, Math.Max(1, MaxDecodedPixels / ((long)width * height))); + var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul); + using var working = new SKBitmap(info); + var frameInfo = codec.FrameInfo; + var frames = new List(frameCount); + var delays = new List(frameCount); + try + { + for (var index = 0; index < frameCount; index++) + { + var requiredFrame = index < frameInfo.Length ? frameInfo[index].RequiredFrame : -1; + if (requiredFrame < 0) working.Erase(SKColors.Transparent); + var result = codec.GetPixels(info, working.GetPixels(), working.RowBytes, new SKCodecOptions(index, requiredFrame)); + if (result is not (SKCodecResult.Success or SKCodecResult.IncompleteInput)) break; + using var image = SKImage.FromBitmap(working); + using var encoded = image.Encode(SKEncodedImageFormat.Png, 100); + frames.Add(new Bitmap(new MemoryStream(encoded.ToArray()))); + var duration = index < frameInfo.Length ? frameInfo[index].Duration : 100; + delays.Add(TimeSpan.FromMilliseconds(Math.Clamp(duration <= 0 ? 100 : duration, 20, 10_000))); + } + return frames.Count == 0 ? null : new OverlayImageAsset(frames, delays); + } + catch + { + foreach (var frame in frames) frame.Dispose(); + return null; + } + } + public void Dispose() { _http.Dispose(); @@ -516,6 +601,65 @@ internal sealed class HttpsImageCache : IDisposable } } +internal sealed class OverlayImageAsset(IReadOnlyList frames, IReadOnlyList delays) : IDisposable +{ + public IReadOnlyList Frames { get; } = frames; + public IReadOnlyList Delays { get; } = delays; + public void Dispose() + { + foreach (var frame in Frames) frame.Dispose(); + } +} + +internal sealed class AnimatedOverlayImage : Image +{ + private DispatcherTimer? _timer; + private OverlayImageAsset? _asset; + private int _frame; + + public AnimatedOverlayImage() + { + DetachedFromVisualTree += (_, _) => Stop(); + AttachedToVisualTree += (_, _) => Start(); + } + + public void SetAsset(OverlayImageAsset asset) + { + Stop(); + _asset = asset; + _frame = 0; + Source = asset.Frames[0]; + Start(); + } + + private void Start() + { + if (_asset is null || _asset.Frames.Count < 2 || _timer is not null) return; + _timer = new DispatcherTimer { Interval = DelayFor(0) }; + _timer.Tick += Advance; + _timer.Start(); + } + + private void Advance(object? sender, EventArgs args) + { + if (_asset is null || _asset.Frames.Count < 2) return; + _frame = (_frame + 1) % _asset.Frames.Count; + Source = _asset.Frames[_frame]; + if (_timer is not null) _timer.Interval = DelayFor(_frame); + } + + private TimeSpan DelayFor(int frame) => + _asset is not null && frame < _asset.Delays.Count ? _asset.Delays[frame] : TimeSpan.FromMilliseconds(100); + + private void Stop() + { + if (_timer is null) return; + _timer.Stop(); + _timer.Tick -= Advance; + _timer = null; + } +} + internal static class OverlayColor { public static Color ColorFromRgba(string value) diff --git a/companion/plugins/Lumi.Companion.Overlay/OverlayFeedModels.cs b/companion/plugins/Lumi.Companion.Overlay/OverlayFeedModels.cs index 104a861..710aeec 100644 --- a/companion/plugins/Lumi.Companion.Overlay/OverlayFeedModels.cs +++ b/companion/plugins/Lumi.Companion.Overlay/OverlayFeedModels.cs @@ -25,6 +25,14 @@ public sealed class OverlayFeedEmote [JsonPropertyName("image")] public string? Image { get; set; } } +public sealed class OverlayFeedMedia +{ + [JsonPropertyName("url")] public string? Url { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } = "image"; + [JsonPropertyName("alt")] public string Alt { get; set; } = ""; + [JsonPropertyName("preview")] public string? Preview { get; set; } +} + public sealed class OverlayFeedMessage { [JsonPropertyName("id")] public string Id { get; set; } = ""; @@ -32,6 +40,7 @@ public sealed class OverlayFeedMessage [JsonPropertyName("text")] public string Text { get; set; } = ""; [JsonPropertyName("author")] public OverlayFeedAuthor Author { get; set; } = new(); [JsonPropertyName("emotes")] public List Emotes { get; set; } = []; + [JsonPropertyName("media")] public List Media { get; set; } = []; } public sealed record OverlayRenderedEvent(string Id, string Type, string Platform, string Summary, JsonElement Payload); diff --git a/companion/plugins/Lumi.Companion.Overlay/plugin.json b/companion/plugins/Lumi.Companion.Overlay/plugin.json index d7319b0..430ee99 100644 --- a/companion/plugins/Lumi.Companion.Overlay/plugin.json +++ b/companion/plugins/Lumi.Companion.Overlay/plugin.json @@ -1,7 +1,7 @@ { "id": "lumi_overlay", "name": "Lumi Overlay", - "version": "0.1.0", + "version": "0.1.1", "provider_api": 1, "capabilities": [ "network.lumi.overlay.read", diff --git a/companion/scripts/publish-companion.ps1 b/companion/scripts/publish-companion.ps1 index 4e894a9..579e884 100644 --- a/companion/scripts/publish-companion.ps1 +++ b/companion/scripts/publish-companion.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "0.2.6", + [string]$Version = "0.2.7", [string]$BridgeVersion = "0.2.5", [string]$ObsVersion = "31.1.1" ) diff --git a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj index b258bc6..f82c1ae 100644 --- a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj +++ b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj @@ -6,8 +6,8 @@ enable app.manifest Assets\Lumi.Companion.ico - 0.2.6 - 0.2.6.0 + 0.2.7 + 0.2.7.0 diff --git a/docs/companion-native-overlay.md b/docs/companion-native-overlay.md index 713f670..3660217 100644 --- a/docs/companion-native-overlay.md +++ b/docs/companion-native-overlay.md @@ -18,6 +18,12 @@ a connection receives live items onlyβ€”no history is replayed after startup or reconnect. Missing provider scopes disable only their affected event types and are reported in feed status. +The native renderer uses the same normalized badge, emote, and Discord media +payloads as Lumi's OBS browser overlay. HTTPS artwork is downloaded with strict +size, dimension, frame-count, and decoded-pixel limits. Animated GIF/WebP +emotes and images retain their bounded frame timing; unsupported video-only +embeds use their supplied preview image when available. + ## Window behavior The selected monitor, layout, styling, sources, event types, visibility mode, diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index 050134f..38b311b 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,7 +14,7 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.3.8 +Version: 0.3.9 ## Routes - POST /api/diagnostics/v1/run - GET /api/events diff --git a/knowledge/plugins/auto-vc.md b/knowledge/plugins/auto-vc.md index d3674a4..abc9982 100644 --- a/knowledge/plugins/auto-vc.md +++ b/knowledge/plugins/auto-vc.md @@ -14,7 +14,7 @@ editable: false Auto-create managed voice channels from lobby rooms. ## Metadata Plugin ID: auto-vc -Version: 0.1.6 +Version: 0.1.7 Default state: enabled ## Web Routes - /plugins/auto-vc diff --git a/knowledge/plugins/lumi-overlay.md b/knowledge/plugins/lumi-overlay.md index 629b6b8..a670d11 100644 --- a/knowledge/plugins/lumi-overlay.md +++ b/knowledge/plugins/lumi-overlay.md @@ -14,7 +14,7 @@ editable: false Authenticated live chat and event feed for the native Lumi Companion monitor overlay. ## Metadata Plugin ID: lumi_overlay -Version: 0.1.0 +Version: 0.1.1 Default state: enabled ## Web Routes - No plugin routes detected. diff --git a/knowledge/plugins/lumi-transcription.md b/knowledge/plugins/lumi-transcription.md index 31e89b9..23d9ccf 100644 --- a/knowledge/plugins/lumi-transcription.md +++ b/knowledge/plugins/lumi-transcription.md @@ -14,7 +14,7 @@ editable: false Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions. ## Metadata Plugin ID: lumi_transcription -Version: 0.2.6 +Version: 0.2.7 Default state: enabled ## Web Routes - /plugins/lumi_transcription diff --git a/package-lock.json b/package-lock.json index 6258972..0c8ffc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.3.8", + "version": "0.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.3.8", + "version": "0.3.9", "dependencies": { "adm-zip": "^0.6.0", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index f06517e..2263edb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.3.8", + "version": "0.3.9", "private": true, "type": "commonjs", "scripts": { @@ -25,6 +25,7 @@ "verify:content": "node scripts/verify-content-library.js", "verify:transcription": "node plugins/lumi_transcription/tests/verify.js", "verify:companion-overlay": "node plugins/lumi_overlay/tests/verify.js", + "verify:auto-vc": "node plugins/auto-vc/tests/verify.js", "verify:dev-updates": "node scripts/verify-local-development-updates.js" }, "engines": { diff --git a/plugins/auto-vc/CHANGELOG.md b/plugins/auto-vc/CHANGELOG.md index e1ddb63..2e1db6c 100644 --- a/plugins/auto-vc/CHANGELOG.md +++ b/plugins/auto-vc/CHANGELOG.md @@ -1,5 +1,17 @@ # Auto VC changelog +## 0.1.7 + +- Add an admin-defined message to each new room with the + `{{plugins.auto_vc.channel.owner_username}}` placeholder. +- Add persistent lock and unlock reactions that reuse the existing room + permission behavior. +- Add a claim reaction after the owner has been absent for the configured + cleanup duration, plus concise success or failure reports that delete after + 30 seconds. +- Preserve existing room and lobby data with additive control-message and + owner-absence columns. + ## 0.1.6 - Connected lobby deletion to the shared timed-confirmation flow. diff --git a/plugins/auto-vc/index.js b/plugins/auto-vc/index.js index 2b58290..699dae1 100644 --- a/plugins/auto-vc/index.js +++ b/plugins/auto-vc/index.js @@ -10,6 +10,12 @@ const logger = createLogger("plugin:auto-vc", { category: "plugin" }); const PLUGIN_ID = "auto-vc"; const DEFAULT_TEMPLATE = "[username]'s room"; const DEFAULT_TIMEOUT = 30; +const DEFAULT_ROOM_MESSAGE = "Welcome {{plugins.auto_vc.channel.owner_username}}! Use πŸ”’ to lock this room or πŸ”“ to unlock it. If the owner is away long enough, πŸ‘‘ appears so someone in the room can claim it."; +const ROOM_MESSAGE_FIELD_ID = "plugins.auto_vc.room_message"; +const LOCK_EMOJI = "πŸ”’"; +const UNLOCK_EMOJI = "πŸ”“"; +const CLAIM_EMOJI = "πŸ‘‘"; +const REPORT_LIFETIME_MS = 30 * 1000; const GAME_NAME_TOKEN = "[game_name]"; const NAME_REFRESH_INTERVAL_MS = 5 * 60 * 1000; const ALLOW_CONNECT_VIEW = @@ -19,12 +25,16 @@ const DEFAULT_ACTION_LIMIT = { max: 8, windowSeconds: 60 }; module.exports = { id: PLUGIN_ID, - init({ web, discordClient, db, settings }) { + init({ web, discordClient, db, settings, placeholders }) { ensureTables(db); + registerPlaceholderSupport(placeholders); const state = { rooms: new Map(), cleanupTimers: new Map(), + claimTimers: new Map(), emptySince: new Map(), + reactionActions: new Map(), + placeholders, sweepTimer: null, nameSweepTimer: null, rateLimits: { @@ -66,6 +76,7 @@ module.exports = { } const config = parseConfigFromForm(req.body); saveConfig(db, config); + for (const channelId of state.claimTimers.keys()) clearClaimTimer(state, channelId); state.rooms.clear(); req.session.flash = { type: "success", @@ -138,6 +149,9 @@ module.exports = { discordClient.on("messageCreate", (message) => { handleMessage(message, db, settings, state); }); + discordClient.on("messageReactionAdd", (reaction, user) => { + queueControlReaction(reaction, user, db, settings, state); + }); discordClient.on("channelDelete", (channel) => { if (channel && channel.id) { removeRoom(db, state, channel.id); @@ -218,7 +232,9 @@ async function buildLobbyPermissionChecks(discordClient, lobby) { "Connect to lobby", "Move members", "Target category visible", - "Manage rooms" + "Manage rooms", + "Post room controls", + "Manage room reactions" ]; if (!discordClient || !discordClient.user) { @@ -271,6 +287,22 @@ async function buildLobbyPermissionChecks(discordClient, lobby) { lobbyPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) || guildPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) ); + const canSendMessages = Boolean( + categoryPerms?.has(Permissions.FLAGS.SEND_MESSAGES) || + guildPerms?.has(Permissions.FLAGS.SEND_MESSAGES) + ); + const canAddReactions = Boolean( + categoryPerms?.has(Permissions.FLAGS.ADD_REACTIONS) || + guildPerms?.has(Permissions.FLAGS.ADD_REACTIONS) + ); + const canReadHistory = Boolean( + categoryPerms?.has(Permissions.FLAGS.READ_MESSAGE_HISTORY) || + guildPerms?.has(Permissions.FLAGS.READ_MESSAGE_HISTORY) + ); + const canManageMessages = Boolean( + categoryPerms?.has(Permissions.FLAGS.MANAGE_MESSAGES) || + guildPerms?.has(Permissions.FLAGS.MANAGE_MESSAGES) + ); return [ buildPermissionCheck("Bot in guild", true, ""), @@ -307,6 +339,16 @@ async function buildLobbyPermissionChecks(discordClient, lobby) { "Manage rooms", canManageChannels && (categoryChannel ? canViewCategory : true), "Allow Manage Channels (and View Channel) on the target category so the bot can create, rename, and delete rooms." + ), + buildPermissionCheck( + "Post room controls", + canSendMessages && canAddReactions && canReadHistory, + "Allow Send Messages, Add Reactions, and Read Message History on the target category." + ), + buildPermissionCheck( + "Manage room reactions", + canManageMessages, + "Allow Manage Messages on the target category so reaction controls remain reusable." ) ]; } @@ -361,6 +403,8 @@ function ensureTables(db) { locked INTEGER NOT NULL DEFAULT 0, allowed_user_ids TEXT NOT NULL DEFAULT '[]', base_overwrites TEXT NOT NULL, + control_message_id TEXT, + owner_absent_since INTEGER, created_at INTEGER NOT NULL ); @@ -376,6 +420,15 @@ function ensureTables(db) { created_at INTEGER NOT NULL ); `); + ensureColumn(db, "auto_vc_rooms", "control_message_id", "TEXT"); + ensureColumn(db, "auto_vc_rooms", "owner_absent_since", "INTEGER"); +} + +function ensureColumn(db, table, column, definition) { + const columns = db.prepare(`PRAGMA table_info(${table})`).all(); + if (!columns.some((entry) => entry.name === column)) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } } function getConfig(db) { @@ -408,6 +461,7 @@ function normalizeLobby(lobby) { lobbyChannelId: (lobby?.lobbyChannelId || "").toString().trim(), categoryId: (lobby?.categoryId || "").toString().trim(), nameTemplate: (lobby?.nameTemplate || DEFAULT_TEMPLATE).toString(), + roomMessage: (lobby?.roomMessage ?? DEFAULT_ROOM_MESSAGE).toString().slice(0, 2000), emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : Math.max(5, timeout) }; } @@ -451,6 +505,7 @@ function parseConfigFromForm(body) { const lobbyChannelIds = toArray(body.lobby_channel_id); const categoryIds = toArray(body.lobby_category_id); const templates = toArray(body.lobby_name_template); + const roomMessages = toArray(body.lobby_room_message); const timeouts = toArray(body.lobby_empty_timeout); const removeIds = new Set(toArray(body.lobby_remove)); @@ -461,6 +516,7 @@ function parseConfigFromForm(body) { lobbyChannelId: lobbyChannelIds[index] || "", categoryId: categoryIds[index] || "", nameTemplate: templates[index] || DEFAULT_TEMPLATE, + roomMessage: roomMessages[index] ?? DEFAULT_ROOM_MESSAGE, emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : timeout }); }); @@ -534,6 +590,7 @@ function isBanned(db, discordUserId) { } async function bootstrapRooms(discordClient, db, state, settings) { + const config = getConfig(db); const rooms = db .prepare("SELECT * FROM auto_vc_rooms") .all(); @@ -554,7 +611,9 @@ async function bootstrapRooms(discordClient, db, state, settings) { removeRoom(db, state, room.channel_id); continue; } - state.rooms.set(room.channel_id, normalizeRoom(room)); + const normalized = normalizeRoom(room); + state.rooms.set(room.channel_id, normalized); + await syncOwnerAbsence(channel, normalized, db, state, config); } } @@ -571,6 +630,8 @@ function normalizeRoom(room) { locked: Boolean(room.locked), allowed_user_ids: parseJsonArray(room.allowed_user_ids), base_overwrites: room.base_overwrites, + control_message_id: room.control_message_id || null, + owner_absent_since: Number(room.owner_absent_since) || null, created_at: room.created_at }; } @@ -593,6 +654,9 @@ function handleVoiceStateUpdate(oldState, newState, db, settings, state) { const channel = oldState.guild.channels.cache.get(oldState.channelId); if (channel) { scheduleCleanup(channel, room, db, state, config); + syncOwnerAbsence(channel, room, db, state, config).catch((error) => { + logger.warn("Auto VC owner absence update failed", error, { event: "owner_absence_sync_failed" }); + }); } } } @@ -602,6 +666,12 @@ function handleVoiceStateUpdate(oldState, newState, db, settings, state) { if (room) { clearCleanupTimer(state, newState.channelId); clearEmpty(state, newState.channelId); + const channel = newState.guild.channels.cache.get(newState.channelId); + if (channel) { + syncOwnerAbsence(channel, room, db, state, config).catch((error) => { + logger.warn("Auto VC owner absence update failed", error, { event: "owner_absence_sync_failed" }); + }); + } } } } @@ -660,11 +730,14 @@ async function createRoomFromLobby(newState, lobby, db, settings, state, config) locked: false, allowed_user_ids: [], base_overwrites: JSON.stringify(baseOverwrites), + control_message_id: null, + owner_absent_since: null, created_at: Date.now() }; saveRoom(db, room); - state.rooms.set(channel.id, normalizeRoom(room)); + const normalizedRoom = normalizeRoom(room); + state.rooms.set(channel.id, normalizedRoom); incrementUserStat(db, profile.id); const moved = await moveMemberToChannel(member, channel); @@ -674,6 +747,83 @@ async function createRoomFromLobby(newState, lobby, db, settings, state, config) "I couldn't move you to the new VC. Please make sure the bot has Move Members permission." ); } + await createRoomControlMessage(channel, normalizedRoom, lobby, member, db, state); +} + +function registerPlaceholderSupport(placeholders) { + if (!placeholders?.registerFieldPolicy || !placeholders?.registerPlaceholders) return; + placeholders.registerFieldPolicy({ + field_id: ROOM_MESSAGE_FIELD_ID, + label: "Auto VC room message", + field_type: "chat_message", + output_audience: "public", + min_editor_role: "admin", + allowed_namespaces: ["plugins.auto_vc.channel"], + max_sensitivity: "public_safe" + }); + placeholders.registerPlaceholders([{ + id: "plugins.auto_vc.channel.owner_username", + namespace: "plugins.auto_vc.channel", + label: "Room owner username", + description: "The display name of the member who created the Auto VC.", + value_type: "string", + sensitivity: "public_safe", + min_editor_role: "admin", + min_viewer_role: "public", + allowed_field_types: ["chat_message"], + plugin_id: PLUGIN_ID, + resolver: ({ runtimeContext }) => runtimeContext?.autoVc?.owner_username || "" + }]); +} + +async function renderRoomMessage(lobby, member, placeholders) { + const ownerUsername = member?.displayName || member?.user?.username || "Room owner"; + const template = String(lobby?.roomMessage || DEFAULT_ROOM_MESSAGE).slice(0, 2000); + if (!placeholders?.renderTemplate) { + return template.replace(/\{\{\s*plugins\.auto_vc\.channel\.owner_username\s*\}\}/gi, ownerUsername).slice(0, 2000); + } + const result = await placeholders.renderTemplate({ + fieldId: ROOM_MESSAGE_FIELD_ID, + template, + user: { isAdmin: true }, + outputAudience: "public", + runtimeContext: { runtime: true, autoVc: { owner_username: ownerUsername } }, + fallback: "[unavailable]" + }); + if (!result.ok) { + logger.warn("Auto VC room message contained unavailable placeholders", { + error_count: result.errors?.length || 0 + }, { event: "room_message_placeholder_failed" }); + } + return String(result.rendered || DEFAULT_ROOM_MESSAGE).trim().slice(0, 2000); +} + +async function createRoomControlMessage(channel, room, lobby, member, db, state) { + if (typeof channel?.send !== "function") { + logger.warn("Created Auto VC does not support room messages", { + channel_id: channel?.id || null + }, { event: "room_controls_unavailable" }); + return null; + } + let message = null; + try { + const content = await renderRoomMessage(lobby, member, state.placeholders); + message = await channel.send(content || DEFAULT_ROOM_MESSAGE); + await message.react(LOCK_EMOJI); + await message.react(UNLOCK_EMOJI); + room.control_message_id = message.id; + room.owner_absent_since = null; + updateRoom(db, room); + state.rooms.set(room.channel_id, room); + return message; + } catch (error) { + if (message) await message.delete().catch(() => null); + logger.warn("Failed to create Auto VC room controls", error, { + event: "room_controls_create_failed", + channel_id: channel?.id || null + }); + return null; + } } function buildRoomName(template, member, roomNumber, gameName) { @@ -762,9 +912,9 @@ function extractOverwrites(channel) { function saveRoom(db, room) { db.prepare( - "INSERT INTO auto_vc_rooms (channel_id, guild_id, lobby_id, category_id, owner_discord_id, owner_user_id, room_number, name_template, locked, allowed_user_ids, base_overwrites, created_at) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + - "ON CONFLICT(channel_id) DO UPDATE SET owner_discord_id = excluded.owner_discord_id, owner_user_id = excluded.owner_user_id, room_number = excluded.room_number, name_template = excluded.name_template, locked = excluded.locked, allowed_user_ids = excluded.allowed_user_ids, base_overwrites = excluded.base_overwrites" + "INSERT INTO auto_vc_rooms (channel_id, guild_id, lobby_id, category_id, owner_discord_id, owner_user_id, room_number, name_template, locked, allowed_user_ids, base_overwrites, control_message_id, owner_absent_since, created_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(channel_id) DO UPDATE SET owner_discord_id = excluded.owner_discord_id, owner_user_id = excluded.owner_user_id, room_number = excluded.room_number, name_template = excluded.name_template, locked = excluded.locked, allowed_user_ids = excluded.allowed_user_ids, base_overwrites = excluded.base_overwrites, control_message_id = excluded.control_message_id, owner_absent_since = excluded.owner_absent_since" ).run( room.channel_id, room.guild_id, @@ -777,13 +927,15 @@ function saveRoom(db, room) { room.locked ? 1 : 0, JSON.stringify(room.allowed_user_ids || []), room.base_overwrites, + room.control_message_id || null, + room.owner_absent_since || null, room.created_at ); } function updateRoom(db, room) { db.prepare( - "UPDATE auto_vc_rooms SET owner_discord_id = ?, owner_user_id = ?, room_number = ?, name_template = ?, locked = ?, allowed_user_ids = ? WHERE channel_id = ?" + "UPDATE auto_vc_rooms SET owner_discord_id = ?, owner_user_id = ?, room_number = ?, name_template = ?, locked = ?, allowed_user_ids = ?, control_message_id = ?, owner_absent_since = ? WHERE channel_id = ?" ).run( room.owner_discord_id, room.owner_user_id, @@ -791,6 +943,8 @@ function updateRoom(db, room) { room.name_template, room.locked ? 1 : 0, JSON.stringify(room.allowed_user_ids || []), + room.control_message_id || null, + room.owner_absent_since || null, room.channel_id ); } @@ -809,6 +963,7 @@ function getRoomById(db, channelId, state) { function removeRoom(db, state, channelId) { clearCleanupTimer(state, channelId); + clearClaimTimer(state, channelId); clearEmpty(state, channelId); state.rooms.delete(channelId); db.prepare("DELETE FROM auto_vc_rooms WHERE channel_id = ?").run(channelId); @@ -922,6 +1077,168 @@ function getLobbyTimeout(config, lobbyId) { return lobby ? lobby.emptyTimeoutSeconds : DEFAULT_TIMEOUT; } +function clearClaimTimer(state, channelId) { + const timer = state.claimTimers.get(channelId); + if (timer) { + clearTimeout(timer); + state.claimTimers.delete(channelId); + } +} + +function hasClaimCandidate(channel, room) { + return [...(channel?.members?.values?.() || [])].some((member) => + !member?.user?.bot && member.id !== room.owner_discord_id + ); +} + +async function fetchControlMessage(channel, room) { + if (!room.control_message_id || typeof channel?.messages?.fetch !== "function") return null; + return channel.messages.fetch(room.control_message_id).catch(() => null); +} + +async function setClaimReaction(channel, room, available) { + const message = await fetchControlMessage(channel, room); + if (!message) return; + const reaction = message.reactions?.cache?.find((entry) => entry.emoji?.name === CLAIM_EMOJI); + if (available) { + if (!reaction?.me) await message.react(CLAIM_EMOJI); + return; + } + if (reaction?.me && message.client?.user?.id) { + await reaction.users.remove(message.client.user.id).catch(() => null); + } +} + +async function syncOwnerAbsence(channel, room, db, state, config) { + clearClaimTimer(state, channel.id); + const ownerPresent = Boolean(channel.members?.has(room.owner_discord_id)); + const claimCandidatePresent = hasClaimCandidate(channel, room); + if (ownerPresent || !claimCandidatePresent) { + if (room.owner_absent_since) { + room.owner_absent_since = null; + updateRoom(db, room); + state.rooms.set(room.channel_id, room); + } + await setClaimReaction(channel, room, false); + return; + } + + if (!room.owner_absent_since) { + room.owner_absent_since = Date.now(); + updateRoom(db, room); + state.rooms.set(room.channel_id, room); + } + const timeoutMs = getLobbyTimeout(config, room.lobby_id) * 1000; + const remaining = timeoutMs - (Date.now() - room.owner_absent_since); + if (remaining <= 0) { + await setClaimReaction(channel, room, true); + return; + } + await setClaimReaction(channel, room, false); + const timer = setTimeout(() => { + state.claimTimers.delete(channel.id); + const refreshedRoom = state.rooms.get(channel.id) || getRoomById(db, channel.id, state); + const refreshedChannel = channel.guild?.channels?.cache?.get(channel.id); + if (!refreshedRoom || !refreshedChannel) return; + syncOwnerAbsence(refreshedChannel, refreshedRoom, db, state, getConfig(db)).catch((error) => { + logger.warn("Auto VC claim reaction update failed", error, { event: "claim_reaction_update_failed" }); + }); + }, remaining); + timer.unref?.(); + state.claimTimers.set(channel.id, timer); +} + +function queueControlReaction(reaction, user, db, settings, state) { + if (!reaction?.message?.id || user?.bot) return; + const key = reaction.message.id; + const previous = state.reactionActions.get(key) || Promise.resolve(); + const current = previous + .catch(() => null) + .then(() => handleControlReaction(reaction, user, db, settings, state)) + .catch((error) => { + logger.warn("Auto VC reaction action failed", error, { event: "room_reaction_failed" }); + }) + .finally(() => { + if (state.reactionActions.get(key) === current) state.reactionActions.delete(key); + }); + state.reactionActions.set(key, current); +} + +async function handleControlReaction(reaction, user, db, settings, state) { + if (reaction.partial && typeof reaction.fetch === "function") { + reaction = await reaction.fetch().catch(() => null); + } + if (!reaction?.message) return; + let message = reaction.message; + if (message.partial && typeof message.fetch === "function") { + message = await message.fetch().catch(() => null); + } + const emoji = reaction.emoji?.name; + if (!message || ![LOCK_EMOJI, UNLOCK_EMOJI, CLAIM_EMOJI].includes(emoji)) return; + const channel = message.channel; + const room = state.rooms.get(channel?.id) || getRoomById(db, channel?.id, state); + if (!room || room.control_message_id !== message.id) return; + + try { + const member = await message.guild?.members?.fetch(user.id).catch(() => null); + let result; + if (!member) { + result = { ok: false, message: "Could not verify that member." }; + } else { + const rateLimit = consumeRateLimit(state, getConfig(db), "action", member.id); + if (!rateLimit.ok) { + result = { ok: false, message: `Try again in ${formatCooldown(rateLimit.retryAfter)}.` }; + } else if (emoji === LOCK_EMOJI || emoji === UNLOCK_EMOJI) { + if (member.id !== room.owner_discord_id) { + result = { ok: false, message: "Only the room owner can use that control." }; + } else { + const locked = emoji === LOCK_EMOJI; + const applied = await setRoomLocked(channel, room, locked, db, settings, state); + result = applied + ? { ok: true, message: locked ? "Room locked." : "Room unlocked." } + : { ok: false, message: "Discord did not allow the room permission change." }; + } + } else { + const eligibility = claimEligibility(channel, room, member, getConfig(db), Date.now()); + if (!eligibility.ok) { + result = eligibility; + } else { + const claimed = await claimRoomOwnership(channel, room, member, db, settings, state); + if (claimed) { + await syncOwnerAbsence(channel, room, db, state, getConfig(db)); + result = { ok: true, message: `${member.displayName} now owns this room.` }; + } else { + result = { ok: false, message: "Discord did not allow the ownership change." }; + } + } + } + } + await sendTemporaryReport(channel, `${result.ok ? "βœ“" : "Couldn’t do that:"} ${result.message}`); + } finally { + await reaction.users?.remove(user.id).catch(() => null); + } +} + +function claimEligibility(channel, room, member, config, now = Date.now()) { + if (member.id === room.owner_discord_id) return { ok: false, message: "You already own this room." }; + if (!channel.members?.has(member.id)) return { ok: false, message: "Join this voice room before claiming it." }; + if (channel.members?.has(room.owner_discord_id)) return { ok: false, message: "The current owner is still here." }; + const absentSince = Number(room.owner_absent_since) || now; + const remaining = getLobbyTimeout(config, room.lobby_id) * 1000 - (now - absentSince); + if (remaining > 0) return { ok: false, message: `The room can be claimed in ${formatCooldown(Math.ceil(remaining / 1000))}.` }; + return { ok: true }; +} + +async function sendTemporaryReport(channel, content) { + if (typeof channel?.send !== "function") return; + const report = await channel.send(String(content).slice(0, 500)).catch(() => null); + if (!report) return; + const timer = setTimeout(() => { + report.delete().catch(() => null); + }, REPORT_LIFETIME_MS); + timer.unref?.(); +} + async function deleteChannel(channel) { try { await channel.delete("Auto VC cleanup"); @@ -1064,13 +1381,12 @@ async function handleLock(message, room, db, settings, state, config) { if (!enforceActionRateLimit(message, state, config)) { return; } - room.locked = !room.locked; - await applyRoomPermissions(channel, room, db, settings); - updateRoom(db, room); - state.rooms.set(room.channel_id, room); - message - .reply(room.locked ? "Room locked." : "Room unlocked.") - .catch(() => null); + if (room.locked) { + message.reply("Room is already locked.").catch(() => null); + return; + } + const applied = await setRoomLocked(channel, room, true, db, settings, state); + message.reply(applied ? "Room locked." : "I couldn't update this room's permissions.").catch(() => null); } async function handleUnlock(message, room, db, settings, state, config) { @@ -1086,11 +1402,20 @@ async function handleUnlock(message, room, db, settings, state, config) { message.reply("Room is already unlocked.").catch(() => null); return; } - room.locked = false; - await applyRoomPermissions(channel, room, db, settings); + const applied = await setRoomLocked(channel, room, false, db, settings, state); + message.reply(applied ? "Room unlocked." : "I couldn't update this room's permissions.").catch(() => null); +} + +async function setRoomLocked(channel, room, locked, db, settings, state) { + const previous = room.locked; + room.locked = Boolean(locked); + if (!await applyRoomPermissions(channel, room, db, settings)) { + room.locked = previous; + return false; + } updateRoom(db, room); state.rooms.set(room.channel_id, room); - message.reply("Room unlocked.").catch(() => null); + return true; } async function handleAllow(message, room, args, db, settings, state, config) { @@ -1168,12 +1493,15 @@ async function handleTransfer(message, room, args, db, settings, state, config) displayName: target.displayName }); room.owner_user_id = profile.id; + room.owner_absent_since = null; const channel = message.guild.channels.cache.get(room.channel_id); if (channel) { await applyRoomPermissions(channel, room, db, settings); } updateRoom(db, room); state.rooms.set(room.channel_id, room); + clearClaimTimer(state, room.channel_id); + if (channel) await setClaimReaction(channel, room, false); message.reply(`Ownership transferred to ${target.displayName}.`).catch(() => null); } @@ -1190,6 +1518,14 @@ async function handleClaim(message, room, member, db, settings, state, config) { if (!enforceActionRateLimit(message, state, config)) { return; } + const claimed = await claimRoomOwnership(channel, room, member, db, settings, state); + message.reply(claimed ? "You are now the owner of this room." : "I couldn't update this room's permissions.").catch(() => null); +} + +async function claimRoomOwnership(channel, room, member, db, settings, state) { + const previousOwnerDiscordId = room.owner_discord_id; + const previousOwnerUserId = room.owner_user_id; + const previousOwnerAbsentSince = room.owner_absent_since; room.owner_discord_id = member.id; const profile = ensureUserForIdentity({ provider: "discord", @@ -1197,10 +1533,18 @@ async function handleClaim(message, room, member, db, settings, state, config) { displayName: member.displayName }); room.owner_user_id = profile.id; - await applyRoomPermissions(channel, room, db, settings); + room.owner_absent_since = null; + if (!await applyRoomPermissions(channel, room, db, settings)) { + room.owner_discord_id = previousOwnerDiscordId; + room.owner_user_id = previousOwnerUserId; + room.owner_absent_since = previousOwnerAbsentSince; + return false; + } updateRoom(db, room); state.rooms.set(room.channel_id, room); - message.reply("You are now the owner of this room.").catch(() => null); + clearClaimTimer(state, room.channel_id); + await setClaimReaction(channel, room, false); + return true; } async function applyRoomPermissions(channel, room, db, settings) { @@ -1249,7 +1593,16 @@ async function applyRoomPermissions(channel, room, db, settings) { } } - await channel.permissionOverwrites.set(Array.from(overrides.values())).catch(() => null); + try { + await channel.permissionOverwrites.set(Array.from(overrides.values())); + return true; + } catch (error) { + logger.warn("Failed to update Auto VC permissions", error, { + event: "room_permissions_update_failed", + channel_id: channel?.id || null + }); + return false; + } } function checkIsModerator(member, settings) { @@ -1351,6 +1704,21 @@ function cryptoRandomId() { return require("crypto").randomUUID(); } +module.exports._test = { + CLAIM_EMOJI, + DEFAULT_ROOM_MESSAGE, + LOCK_EMOJI, + UNLOCK_EMOJI, + claimEligibility, + ensureTables, + normalizeConfig, + normalizeLobby, + parseConfigFromForm, + registerPlaceholderSupport, + renderRoomMessage, + setRoomLocked +}; + async function safeNotify(member, message) { try { await member.send(message); @@ -1446,6 +1814,7 @@ async function sweepRooms(discordClient, db, state) { removeRoom(db, state, room.channel_id); continue; } + await syncOwnerAbsence(channel, room, db, state, config); if (channel.members.size === 0) { markEmpty(state, channel.id); const timeout = getLobbyTimeout(config, room.lobby_id); diff --git a/plugins/auto-vc/plugin.json b/plugins/auto-vc/plugin.json index 27bf1fe..616393b 100644 --- a/plugins/auto-vc/plugin.json +++ b/plugins/auto-vc/plugin.json @@ -1,11 +1,11 @@ { "id": "auto-vc", "name": "Auto VC", - "version": "0.1.6", + "version": "0.1.7", "description": "Auto-create managed voice channels from lobby rooms.", "main": "index.js", "channel": "stable", "compatible_from": "0.1.5", - "migration_notes": "No manual migration is required.", + "migration_notes": "Adds persisted room-control messages and owner-absence timestamps with automatic additive database migration. Existing lobbies, rooms, owners, permissions, bans, and statistics remain preserved.", "rollback_safe": true } diff --git a/plugins/auto-vc/tests/verify.js b/plugins/auto-vc/tests/verify.js new file mode 100644 index 0000000..2e2ca33 --- /dev/null +++ b/plugins/auto-vc/tests/verify.js @@ -0,0 +1,105 @@ +"use strict"; + +const assert = require("assert"); +const Database = require("better-sqlite3"); +const plugin = require("../index"); + +async function run() { + const { + CLAIM_EMOJI, + DEFAULT_ROOM_MESSAGE, + LOCK_EMOJI, + UNLOCK_EMOJI, + claimEligibility, + ensureTables, + normalizeConfig, + parseConfigFromForm, + registerPlaceholderSupport, + renderRoomMessage, + setRoomLocked + } = plugin._test; + + const defaults = normalizeConfig({ lobbies: [{ id: "lobby-1" }] }); + assert.equal(defaults.lobbies[0].roomMessage, DEFAULT_ROOM_MESSAGE); + assert.equal(defaults.lobbies[0].emptyTimeoutSeconds, 30); + + const parsed = parseConfigFromForm({ + lobby_id: ["one", "two"], + lobby_channel_id: ["voice-1", "voice-2"], + lobby_category_id: ["category-1", "category-2"], + lobby_name_template: ["[username] one", "[username] two"], + lobby_room_message: ["Hello one", "Hello two"], + lobby_empty_timeout: ["45", "60"] + }); + assert.deepStrictEqual(parsed.lobbies.map((lobby) => lobby.roomMessage), ["Hello one", "Hello two"]); + assert.deepStrictEqual(parsed.lobbies.map((lobby) => lobby.emptyTimeoutSeconds), [45, 60]); + + const database = new Database(":memory:"); + database.exec("CREATE TABLE auto_vc_rooms (channel_id TEXT PRIMARY KEY)"); + ensureTables(database); + const columns = database.prepare("PRAGMA table_info(auto_vc_rooms)").all().map((column) => column.name); + assert(columns.includes("control_message_id"), "room controls must survive a restart"); + assert(columns.includes("owner_absent_since"), "owner absence must survive a restart"); + database.close(); + + let policy; + let definitions; + const placeholders = { + registerFieldPolicy(value) { policy = value; }, + registerPlaceholders(value) { definitions = value; }, + async renderTemplate({ template, runtimeContext }) { + return { + ok: true, + rendered: template.replace("{{plugins.auto_vc.channel.owner_username}}", runtimeContext.autoVc.owner_username) + }; + } + }; + registerPlaceholderSupport(placeholders); + assert.equal(policy.field_id, "plugins.auto_vc.room_message"); + assert(policy.allowed_namespaces.includes("plugins.auto_vc.channel")); + assert.equal(definitions[0].id, "plugins.auto_vc.channel.owner_username"); + const rendered = await renderRoomMessage( + { roomMessage: "Welcome {{plugins.auto_vc.channel.owner_username}}" }, + { displayName: "Lumi User" }, + placeholders + ); + assert.equal(rendered, "Welcome Lumi User"); + + const members = new Map([["claimant", { id: "claimant", user: { bot: false } }]]); + const channel = { members }; + const config = { lobbies: [{ id: "lobby-1", emptyTimeoutSeconds: 30 }] }; + const room = { + lobby_id: "lobby-1", + owner_discord_id: "owner", + owner_absent_since: 1_000 + }; + assert.equal(claimEligibility(channel, room, { id: "claimant" }, config, 30_999).ok, false); + assert.equal(claimEligibility(channel, room, { id: "claimant" }, config, 31_000).ok, true); + members.set("owner", { id: "owner", user: { bot: false } }); + assert.equal(claimEligibility(channel, room, { id: "claimant" }, config, 60_000).ok, false); + assert.deepStrictEqual([LOCK_EMOJI, UNLOCK_EMOJI, CLAIM_EMOJI], ["πŸ”’", "πŸ”“", "πŸ‘‘"]); + + const failedRoom = { + channel_id: "room", + owner_discord_id: "owner", + locked: false, + allowed_user_ids: [], + base_overwrites: "[]" + }; + const applied = await setRoomLocked({ + id: "room", + guild: { roles: { everyone: { id: "everyone" } } }, + permissionOverwrites: { async set() { throw new Error("missing permission"); } } + }, failedRoom, true, { + prepare() { throw new Error("a failed Discord change must not be persisted"); } + }, { getSetting() { return ""; } }, { rooms: new Map() }); + assert.equal(applied, false); + assert.equal(failedRoom.locked, false, "failed permission changes must not report or persist success"); + + console.log("Auto VC verification passed: welcome placeholders, persisted controls, and timed ownership claims."); +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/plugins/auto-vc/views/auto-vc.ejs b/plugins/auto-vc/views/auto-vc.ejs index cd50643..a7b8c52 100644 --- a/plugins/auto-vc/views/auto-vc.ejs +++ b/plugins/auto-vc/views/auto-vc.ejs @@ -275,6 +275,15 @@ +
+ + +

+ Sent in every newly created room with lock and unlock reactions. + Supports {{plugins.auto_vc.channel.owner_username}}. + The claim reaction appears after the owner has been away for the cleanup duration. +

+
<% if (lobby.permissions && lobby.permissions.length) { %> <% const totalPerms = lobby.permissions.length; %> @@ -428,6 +437,13 @@ +
+ + +

+ Sent with lock and unlock reactions. The claim reaction appears after the owner has been away for the cleanup duration. +

+
diff --git a/plugins/lumi_overlay/CHANGELOG.md b/plugins/lumi_overlay/CHANGELOG.md index 8f2baa7..3963c11 100644 --- a/plugins/lumi_overlay/CHANGELOG.md +++ b/plugins/lumi_overlay/CHANGELOG.md @@ -1,5 +1,11 @@ # Lumi Overlay Feed changelog +## 0.1.1 + +- Preserve Discord emotes and GIF media in the authenticated Companion feed. +- Preserve Twitch badge artwork and all normalized HTTPS emote sources while + retaining the existing bounded payload and server-side filtering rules. + ## 0.1.0 - Add the paired-device `overlay.read.v1` live feed for the native Companion diff --git a/plugins/lumi_overlay/backend/protocol.js b/plugins/lumi_overlay/backend/protocol.js index 3750a86..0a0322a 100644 --- a/plugins/lumi_overlay/backend/protocol.js +++ b/plugins/lumi_overlay/backend/protocol.js @@ -51,12 +51,18 @@ function sanitizeChat(message) { image: safeHttps(badge?.image) })) }, - emotes: (message.platform === "twitch" && Array.isArray(message.emotes) ? message.emotes : []).slice(0, 100).map((emote) => ({ + emotes: (Array.isArray(message.emotes) ? message.emotes : []).slice(0, 100).map((emote) => ({ start: Math.max(0, Number(emote?.start) || 0), end: Math.max(0, Number(emote?.end) || 0), label: cleanText(emote?.label, 100), image: safeHttps(emote?.image) - })).filter((emote) => emote.image) + })).filter((emote) => emote.image), + media: (Array.isArray(message.media) ? message.media : []).slice(0, 4).map((media) => ({ + url: safeHttps(media?.url), + type: media?.type === "video" ? "video" : "image", + alt: cleanText(media?.alt, 160), + preview: safeHttps(media?.preview) + })).filter((media) => media.url) }; } diff --git a/plugins/lumi_overlay/plugin.json b/plugins/lumi_overlay/plugin.json index 320f6f0..07a439b 100644 --- a/plugins/lumi_overlay/plugin.json +++ b/plugins/lumi_overlay/plugin.json @@ -1,7 +1,7 @@ { "id": "lumi_overlay", "name": "Lumi Overlay Feed", - "version": "0.1.0", + "version": "0.1.1", "description": "Authenticated live chat and event feed for the native Lumi Companion monitor overlay.", "main": "index.js", "channel": "stable", diff --git a/plugins/lumi_overlay/tests/verify.js b/plugins/lumi_overlay/tests/verify.js index 5d1f42a..d3eacdb 100644 --- a/plugins/lumi_overlay/tests/verify.js +++ b/plugins/lumi_overlay/tests/verify.js @@ -112,6 +112,14 @@ function verifySanitization() { assert.equal(chat.text, "", "content remains plain data and is not interpreted server-side"); assert.equal(chat.author.avatar, null); assert.equal(chat.emotes[0].image, "https://cdn.test/emote.png"); + const discord = sanitizeChat({ + id: "discord-id", platform: "discord", text: "", channel: {}, + author: { id: "discord-user", name: "Discord viewer", badges: [] }, + emotes: [{ start: 0, end: 9, label: ":wave:", image: "https://cdn.test/wave.gif" }], + media: [{ url: "https://cdn.test/reaction.gif", type: "image", alt: "Reaction" }] + }); + assert.equal(discord.emotes[0].image, "https://cdn.test/wave.gif", "Discord emotes must reach the Companion feed"); + assert.equal(discord.media[0].url, "https://cdn.test/reaction.gif", "Discord GIFs must reach the Companion feed"); const event = sanitizeEvent({ id: "event", type: "twitch.follow", payload: { user_name: "Viewer", access_token: "secret" } }); assert.equal(event.payload.access_token, undefined); assert.equal(event.payload.user_name, "Viewer"); diff --git a/plugins/lumi_transcription/CHANGELOG.md b/plugins/lumi_transcription/CHANGELOG.md index f6bfa21..0e95301 100644 --- a/plugins/lumi_transcription/CHANGELOG.md +++ b/plugins/lumi_transcription/CHANGELOG.md @@ -1,5 +1,12 @@ # Lumi Transcription changelog +## 0.2.7 + +- Release Companion 0.2.7 with animated native overlay emotes, Discord GIF + media, and Twitch badge artwork. +- Retain the existing OBS browser overlay renderer, transcription behavior, + OBS Bridge 0.2.5, paired identity, and automatic update compatibility. + ## 0.2.6 - Release Companion 0.2.6 with the native Lumi Overlay and shell-owned authenticated live feed. diff --git a/plugins/lumi_transcription/companion_manifest.json b/plugins/lumi_transcription/companion_manifest.json index 5f10c3e..709a776 100644 --- a/plugins/lumi_transcription/companion_manifest.json +++ b/plugins/lumi_transcription/companion_manifest.json @@ -1,17 +1,17 @@ { "schema_version": 1, - "version": "0.2.6", + "version": "0.2.7", "signed": false, - "release_notes": "Adds the native Lumi Overlay, draft-safe settings, clearer visibility and layout controls, and direct navigation for single-page plugins while retaining transcription, Stream Testing, Song Overlay, silent reconnect, and OBS Bridge 0.2.5.", + "release_notes": "Restores animated Twitch, BetterTTV, and Discord emotes, Discord GIF media, and Twitch badge artwork in the native Lumi Overlay while retaining the existing OBS browser renderer, transcription, Stream Testing, Song Overlay, silent reconnect, and OBS Bridge 0.2.5.", "installer": { "id": "windows-x64-installer", "platform": "win32", "architecture": "x64", "label": "Windows x64 per-user installer", "filename": "Lumi.Companion-Setup.exe", - "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.6/Lumi.Companion-Setup.exe", - "sha256": "0420cdee45d676ded1566fd8da2e25482eca258b1d7d200544e5425d22105bb8", - "bytes": 51922633 + "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.7/Lumi.Companion-Setup.exe", + "sha256": "b23686d0e8ecd0fbc89034f9dc6a37350bee17212d60f385eeaf2e4de634fab6", + "bytes": 51930364 }, "artifacts": [ { @@ -20,9 +20,9 @@ "architecture": "x64", "label": "Windows x64 self-contained", "filename": "Lumi.Companion-win-x64.zip", - "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.6/Lumi.Companion-win-x64.zip", - "sha256": "1e216f9653d0dc0513ca119f0a8c5cf43bf47328a83cbbf183e1d4b14c111e94", - "bytes": 66857759, + "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.7/Lumi.Companion-win-x64.zip", + "sha256": "c36983e1f83a9f278475db31819be0acaa6af72d5d03e1a1c5d3ba62b556e81c", + "bytes": 66860208, "entrypoint": "Lumi.Companion.App.exe" } ] diff --git a/plugins/lumi_transcription/plugin.json b/plugins/lumi_transcription/plugin.json index 359ab79..011a687 100644 --- a/plugins/lumi_transcription/plugin.json +++ b/plugins/lumi_transcription/plugin.json @@ -1,7 +1,7 @@ { "id": "lumi_transcription", "name": "Lumi Transcription", - "version": "0.2.6", + "version": "0.2.7", "description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.", "main": "index.js", "channel": "stable", diff --git a/release-index.json b/release-index.json index 50ff865..3661445 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,39 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.3.9", + "ref": "refs/tags/v0.3.9", + "released_at": "2026-07-26", + "installable": true, + "rollback_safe": true, + "replaces_versions": [ + "1.2.0" + ], + "data_policy": "preserve", + "dependency_policy": "sync_on_restart", + "migration_notes": "Restores animated emotes, Twitch badge artwork, and Discord GIF media through the existing OBS and native Companion overlay paths; adds persistent Auto VC welcome controls and timed ownership claiming; and releases Companion 0.2.7. Existing settings, lobbies, rooms, owners, permissions, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved.", + "plugins": { + "auto-vc": "0.1.7", + "birthday": "0.1.3", + "economy-framework": "0.2.10", + "economy-games": "0.1.7", + "expression-interaction": "0.2.1", + "lumi_ai": "0.8.5", + "lumi_overlay": "0.1.1", + "lumi_transcription": "0.2.7", + "moderation": "0.1.5", + "now_playing": "0.1.3", + "okf": "0.1.2", + "quotes": "0.1.2", + "sample-plugin": "0.1.0", + "throne_wishlist": "0.1.2", + "welcome_messages": "0.1.1" + }, + "tools": { + "lumi_ai_web_search": "0.1.1" + } + }, { "version": "0.3.8", "ref": "refs/tags/v0.3.8", diff --git a/scripts/verify-all.js b/scripts/verify-all.js index eca6826..c68ac23 100644 --- a/scripts/verify-all.js +++ b/scripts/verify-all.js @@ -24,6 +24,7 @@ const checks = [ "plugins/lumi_ai_web_search/tests/verify.js", "plugins/lumi_transcription/tests/verify.js", "plugins/lumi_overlay/tests/verify.js", + "plugins/auto-vc/tests/verify.js", "scripts/verify-assistant-panels.js", "scripts/verify-command-preview-confirmations.js", "scripts/verify-command-policies.js", diff --git a/scripts/verify-overlays.js b/scripts/verify-overlays.js index 4f21f84..064a8fa 100644 --- a/scripts/verify-overlays.js +++ b/scripts/verify-overlays.js @@ -273,6 +273,18 @@ try { assert.strictEqual(discordContent.text, "Hello "); assert.strictEqual(discordContent.emotes[0].image, "https://cdn.discordapp.com/emojis/123456.gif?size=96&quality=lossless"); assert.strictEqual(discordContent.media[0].url, "https://cdn.example/dance.gif"); + const gifvContent = discordOverlayContent({ + content: "https://tenor.example/view", + attachments: [], + stickers: [], + embeds: [{ + type: "gifv", + url: "https://tenor.example/view", + video: { url: "https://media.example/animation.mp4" }, + thumbnail: { url: "https://media.example/animation-preview.gif" } + }] + }); + assert.strictEqual(gifvContent.media[0].preview, "https://media.example/animation-preview.gif"); const twitchChatAssets = require(path.join(serviceDir, "twitch-chat-assets.js")); const betterTtvCatalog = twitchChatAssets.indexBetterTtvCatalog([ { id: "emote-kekw", code: "KEKW", imageType: "webp", animated: false }, diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index 14f4efe..741ecd1 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,14 +4,15 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.3.8"; -const previousStableVersion = "0.3.7"; -const priorStableVersion = "0.3.6"; +const releaseVersion = "0.3.9"; +const previousStableVersion = "0.3.8"; +const priorStableVersion = "0.3.7"; const earliestCompatibleCoreVersion = "0.1.9"; const introducedPlugins = { - lumi_overlay: { version: "0.1.0", knowledge: "lumi-overlay" }, - lumi_transcription: { version: "0.2.6", knowledge: "lumi-transcription" }, - now_playing: { version: "0.1.3", knowledge: "now-playing" } + "auto-vc": { version: "0.1.7", knowledge: "auto-vc", compatibleFrom: "0.1.5" }, + lumi_overlay: { version: "0.1.1", knowledge: "lumi-overlay", compatibleFrom: "0.1.0" }, + lumi_transcription: { version: "0.2.7", knowledge: "lumi-transcription", compatibleFrom: "0.1.0" }, + now_playing: { version: "0.1.3", knowledge: "now-playing", compatibleFrom: "0.1.0" } }; function readJson(relativePath) { @@ -62,7 +63,7 @@ assert.equal(safeCoreTarget.target?.version, releaseVersion); for (const [pluginId, expected] of Object.entries(introducedPlugins)) { const manifest = readJson(`plugins/${pluginId}/plugin.json`); assert.equal(manifest.version, expected.version, `${pluginId} manifest version`); - assert.equal(manifest.compatible_from, "0.1.0", `${pluginId} compatible_from`); + assert.equal(manifest.compatible_from, expected.compatibleFrom, `${pluginId} compatible_from`); assert.equal(manifest.channel, "stable", `${pluginId} release channel`); assert.equal(manifest.rollback_safe, true, `${pluginId} rollback metadata`); assert.equal( @@ -83,4 +84,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: stable core 0.3.8 after 0.3.7 with synchronized Companion plugin metadata."); +console.log("Release metadata verification passed: stable core 0.3.9 after 0.3.8 with synchronized Companion and plugin metadata."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 7dffa0c..011bdfd 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -24,7 +24,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.3.8", "0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); +assert.deepEqual(releaseVersions, ["0.3.9", "0.3.8", "0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); for (const release of releaseIndex.releases) { assert.equal(normalizeRepositoryRef(release.ref), release.ref); @@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version; const coreManifest = readJson("update-manifest.json"); assert.equal(packageVersion, coreManifest.version); assert.equal(coreManifest.channel, "stable"); -assert.equal(packageVersion, "0.3.8"); -assert.equal(currentRelease.version, "0.3.8"); +assert.equal(packageVersion, "0.3.9"); +assert.equal(currentRelease.version, "0.3.9"); assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]); for (const [pluginId, version] of Object.entries(currentRelease.plugins)) { assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`); @@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { const baseTarget = { current_version: "0.2.4", available_versions: [ + { version: "0.3.9", ref: "refs/tags/v0.3.9", rollback_safe: true }, { version: "0.3.8", ref: "refs/tags/v0.3.8", rollback_safe: true }, { version: "0.3.7", ref: "refs/tags/v0.3.7", rollback_safe: true }, { version: "0.3.6", ref: "refs/tags/v0.3.6", rollback_safe: true }, @@ -157,7 +158,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.3.8"); +assert.equal(corrected.safe_target_version, "0.3.9"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/discord-chat-content.js b/src/services/discord-chat-content.js index a05c85f..e2489b5 100644 --- a/src/services/discord-chat-content.js +++ b/src/services/discord-chat-content.js @@ -10,11 +10,16 @@ function discordOverlayContent(message) { const media = []; const mediaUrls = new Set(); const removedLinks = new Set(); - const addMedia = (url, type = "image", alt = "Animated image", sourceUrl = "") => { + const addMedia = (url, type = "image", alt = "Animated image", sourceUrl = "", preview = "") => { const clean = String(url || "").trim(); if (!/^https?:\/\//i.test(clean) || mediaUrls.has(clean) || media.length >= 4) return; mediaUrls.add(clean); - media.push({ url: clean, type: type === "video" ? "video" : "image", alt }); + media.push({ + url: clean, + type: type === "video" ? "video" : "image", + alt, + preview: /^https?:\/\//i.test(String(preview || "").trim()) ? String(preview).trim() : null + }); if (sourceUrl && text.includes(sourceUrl)) removedLinks.add(sourceUrl); }; @@ -31,7 +36,7 @@ function discordOverlayContent(message) { if (embedType !== "gifv" && !/\.(?:gif|gifv)(?:$|\?)/i.test(sourceUrl)) continue; const videoUrl = embed?.video?.url || embed?.video?.proxyURL || embed?.video?.proxy_url; const imageUrl = embed?.image?.url || embed?.image?.proxyURL || embed?.image?.proxy_url || embed?.thumbnail?.url || embed?.thumbnail?.proxyURL || embed?.thumbnail?.proxy_url; - if (videoUrl) addMedia(videoUrl, "video", embed?.title || "Animated image", sourceUrl); + if (videoUrl) addMedia(videoUrl, "video", embed?.title || "Animated image", sourceUrl, imageUrl); else addMedia(imageUrl || sourceUrl, "image", embed?.title || "Animated image", sourceUrl); } for (const sticker of collectionValues(message?.stickers)) { diff --git a/src/services/discord.js b/src/services/discord.js index 7f68869..f240d87 100644 --- a/src/services/discord.js +++ b/src/services/discord.js @@ -25,9 +25,10 @@ async function startBot({ commandRouter } = {}) { const intents = [ resolveIntent("Guilds", "GUILDS"), resolveIntent("GuildMessages", "GUILD_MESSAGES"), - resolveIntent("GuildMembers", "GUILD_MEMBERS"), - resolveIntent("MessageContent", "MESSAGE_CONTENT"), - resolveIntent("GuildVoiceStates", "GUILD_VOICE_STATES"), + resolveIntent("GuildMembers", "GUILD_MEMBERS"), + resolveIntent("MessageContent", "MESSAGE_CONTENT"), + resolveIntent("GuildMessageReactions", "GUILD_MESSAGE_REACTIONS"), + resolveIntent("GuildVoiceStates", "GUILD_VOICE_STATES"), resolveIntent("GuildPresences", "GUILD_PRESENCES") ].filter(Boolean); @@ -39,9 +40,12 @@ async function startBot({ commandRouter } = {}) { intents, guildMembers: Boolean(resolveIntent("GuildMembers", "GUILD_MEMBERS")) }, { event: "platform_starting" }); - if (Partials?.Channel) { - options.partials = [Partials.Channel]; - } + options.partials = [ + resolvePartial("Channel", "CHANNEL"), + resolvePartial("Message", "MESSAGE"), + resolvePartial("Reaction", "REACTION"), + resolvePartial("User", "USER") + ].filter((value, index, values) => value !== null && values.indexOf(value) === index); client = new Client(options); @@ -159,7 +163,7 @@ function getBotAvatarUrl(user) { return null; } -function resolveIntent(key, legacyKey) { +function resolveIntent(key, legacyKey) { if (GatewayIntentBits?.[key]) { return GatewayIntentBits[key]; } @@ -170,8 +174,13 @@ function resolveIntent(key, legacyKey) { return Intents.FLAGS[legacyKey]; } return null; -} - +} + +function resolvePartial(key, legacyKey) { + if (Partials?.[key] !== undefined) return Partials[key]; + return legacyKey; +} + async function stopBot() { if (client) { await client.destroy(); diff --git a/src/services/overlay-chat.js b/src/services/overlay-chat.js index dd99204..f20da65 100644 --- a/src/services/overlay-chat.js +++ b/src/services/overlay-chat.js @@ -53,7 +53,8 @@ function normalizeMedia(media) { return { url, type: media?.type === "video" ? "video" : "image", - alt: cleanText(media?.alt || "Animated image", 160) + alt: cleanText(media?.alt || "Animated image", 160), + preview: cleanUrl(media?.preview || media?.thumbnail) }; } diff --git a/src/web/public/overlay-renderer.js b/src/web/public/overlay-renderer.js index f38a3d4..a3287d0 100644 --- a/src/web/public/overlay-renderer.js +++ b/src/web/public/overlay-renderer.js @@ -279,6 +279,7 @@ media.loop = true; media.muted = true; media.playsInline = true; + if (mediaData.preview) media.poster = mediaData.preview; } else media.alt = mediaData.alt || "Animated image"; media.addEventListener("error", () => media.remove(), { once: true }); mediaRow.appendChild(media); diff --git a/update-manifest.json b/update-manifest.json index b247281..857f25e 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,6 +1,6 @@ { "name": "Lumi Core", - "version": "0.3.8", + "version": "0.3.9", "channel": "stable", "released_at": "2026-07-26", "compatible_from": "0.1.9", @@ -8,7 +8,7 @@ "replaces_versions": [ "1.2.0" ], - "migration_notes": "Adds the native Windows Lumi Overlay through the existing paired-device and provider pipelines, preserves active and revoked device semantics, and releases Companion 0.2.6 with draft-safe settings, direct single-page plugin navigation, and clearer overlay controls. Existing settings, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved.", + "migration_notes": "Restores animated emotes, Twitch badge artwork, and Discord GIF media through the existing OBS and native Companion overlay paths; adds persistent Auto VC welcome controls and timed ownership claiming; and releases Companion 0.2.7. Existing settings, lobbies, rooms, owners, permissions, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -445,6 +445,18 @@ ], "rollback_safe": true, "migration_notes": "Moves production Stream Testing TLS termination to Nginx Proxy Manager while preserving RTMPS Companion destinations, plain local MediaMTX ingest, localhost development, session credentials, and all existing operator data. Adds DB-backed public and local ports plus external TLS and RTMP route validation, and removes unused Lumi ACME and DNS automation without deleting stored data." + }, + { + "version": "0.3.8", + "channel": "stable", + "released_at": "2026-07-26", + "compatible_from": "0.1.9", + "migration_kind": "patch", + "replaces_versions": [ + "1.2.0" + ], + "rollback_safe": true, + "migration_notes": "Adds the native Windows Lumi Overlay through the existing paired-device and provider pipelines, preserves active and revoked device semantics, and releases Companion 0.2.6 with draft-safe settings, direct single-page plugin navigation, and clearer overlay controls. Existing settings, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved." } ] }