release: publish Lumi 0.3.9 chat media and Auto VC controls
This commit is contained in:
parent
655547938a
commit
5c85ba400d
@ -1,5 +1,12 @@
|
|||||||
# Lumi changelog
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#ifndef AppVersion
|
#ifndef AppVersion
|
||||||
#define AppVersion "0.2.6"
|
#define AppVersion "0.2.7"
|
||||||
#endif
|
#endif
|
||||||
#ifndef SourceRoot
|
#ifndef SourceRoot
|
||||||
#error SourceRoot must point at the self-contained Companion publish directory.
|
#error SourceRoot must point at the self-contained Companion publish directory.
|
||||||
|
|||||||
@ -11,5 +11,6 @@
|
|||||||
<PackageReference Include="Avalonia" Version="12.1.0" />
|
<PackageReference Include="Avalonia" Version="12.1.0" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
|
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
|
||||||
<PackageReference Include="Microsoft.Win32.SystemEvents" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Win32.SystemEvents" Version="8.0.0" />
|
||||||
|
<PackageReference Include="SkiaSharp" Version="3.119.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ using Avalonia.Media.Imaging;
|
|||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
namespace Lumi.Companion.Overlay;
|
namespace Lumi.Companion.Overlay;
|
||||||
|
|
||||||
@ -299,7 +300,7 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
var contentColumn = 0;
|
var contentColumn = 0;
|
||||||
if (settings.ShowAvatar && message.Author.Avatar is not null)
|
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);
|
_ = LoadImageAsync(avatar, message.Author.Avatar);
|
||||||
grid.Children.Add(avatar);
|
grid.Children.Add(avatar);
|
||||||
contentColumn = 1;
|
contentColumn = 1;
|
||||||
@ -308,10 +309,26 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
var header = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 7 };
|
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));
|
if (settings.ShowPlatform) header.Children.Add(Text(message.Platform.ToUpperInvariant(), settings, 11, FontWeight.Bold));
|
||||||
foreach (var badge in message.Author.Badges.Take(6))
|
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 (settings.ShowUsername) header.Children.Add(Text(message.Author.Name, settings, settings.FontSize * .72, FontWeight.Bold));
|
||||||
if (header.Children.Count > 0) stack.Children.Add(header);
|
if (header.Children.Count > 0) stack.Children.Add(header);
|
||||||
stack.Children.Add(CreateMessageBody(message, settings));
|
stack.Children.Add(CreateMessageBody(message, settings));
|
||||||
|
var media = CreateMessageMedia(message);
|
||||||
|
if (media is not null) stack.Children.Add(media);
|
||||||
Grid.SetColumn(stack, contentColumn);
|
Grid.SetColumn(stack, contentColumn);
|
||||||
grid.Children.Add(stack);
|
grid.Children.Add(stack);
|
||||||
return grid;
|
return grid;
|
||||||
@ -329,7 +346,7 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
var token = new Grid { Margin = new Thickness(2, 0) };
|
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 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(fallback);
|
||||||
token.Children.Add(image);
|
token.Children.Add(image);
|
||||||
panel.Children.Add(token);
|
panel.Children.Add(token);
|
||||||
@ -343,6 +360,31 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
return panel;
|
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)
|
private Control CreateEventContent(OverlayRenderedEvent value, OverlayContainerSettings settings)
|
||||||
{
|
{
|
||||||
var stack = new StackPanel { Spacing = 3 };
|
var stack = new StackPanel { Spacing = 3 };
|
||||||
@ -378,14 +420,14 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
return block;
|
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(() =>
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
if (bitmap is not null)
|
if (asset is not null)
|
||||||
{
|
{
|
||||||
target.Source = bitmap;
|
target.SetAsset(asset);
|
||||||
target.Opacity = 1;
|
target.Opacity = 1;
|
||||||
if (fallback is not null) fallback.IsVisible = false;
|
if (fallback is not null) fallback.IsVisible = false;
|
||||||
}
|
}
|
||||||
@ -475,14 +517,18 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
internal sealed class HttpsImageCache : IDisposable
|
internal sealed class HttpsImageCache : IDisposable
|
||||||
{
|
{
|
||||||
private const int Limit = 128;
|
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 HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) };
|
||||||
private readonly ConcurrentDictionary<string, Lazy<Task<Bitmap?>>> _entries = new();
|
private readonly ConcurrentDictionary<string, Lazy<Task<OverlayImageAsset?>>> _entries = new();
|
||||||
private readonly Queue<string> _order = new();
|
private readonly Queue<string> _order = new();
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
|
|
||||||
public Task<Bitmap?> GetAsync(string value)
|
public Task<OverlayImageAsset?> GetAsync(string value)
|
||||||
{
|
{
|
||||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult<Bitmap?>(null);
|
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult<OverlayImageAsset?>(null);
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_entries.ContainsKey(uri.AbsoluteUri))
|
if (!_entries.ContainsKey(uri.AbsoluteUri))
|
||||||
@ -495,19 +541,58 @@ internal sealed class HttpsImageCache : IDisposable
|
|||||||
return _entries.GetOrAdd(uri.AbsoluteUri, key => new(() => DownloadAsync(key))).Value;
|
return _entries.GetOrAdd(uri.AbsoluteUri, key => new(() => DownloadAsync(key))).Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Bitmap?> DownloadAsync(string url)
|
private async Task<OverlayImageAsset?> DownloadAsync(string url)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
|
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);
|
var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
||||||
if (bytes.Length > 2 * 1024 * 1024) return null;
|
if (bytes.Length > MaxEncodedBytes) return null;
|
||||||
return new Bitmap(new MemoryStream(bytes));
|
return Decode(bytes);
|
||||||
}
|
}
|
||||||
catch { return null; }
|
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<Bitmap>(frameCount);
|
||||||
|
var delays = new List<TimeSpan>(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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_http.Dispose();
|
_http.Dispose();
|
||||||
@ -516,6 +601,65 @@ internal sealed class HttpsImageCache : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed class OverlayImageAsset(IReadOnlyList<Bitmap> frames, IReadOnlyList<TimeSpan> delays) : IDisposable
|
||||||
|
{
|
||||||
|
public IReadOnlyList<Bitmap> Frames { get; } = frames;
|
||||||
|
public IReadOnlyList<TimeSpan> 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
|
internal static class OverlayColor
|
||||||
{
|
{
|
||||||
public static Color ColorFromRgba(string value)
|
public static Color ColorFromRgba(string value)
|
||||||
|
|||||||
@ -25,6 +25,14 @@ public sealed class OverlayFeedEmote
|
|||||||
[JsonPropertyName("image")] public string? Image { get; set; }
|
[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
|
public sealed class OverlayFeedMessage
|
||||||
{
|
{
|
||||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||||
@ -32,6 +40,7 @@ public sealed class OverlayFeedMessage
|
|||||||
[JsonPropertyName("text")] public string Text { get; set; } = "";
|
[JsonPropertyName("text")] public string Text { get; set; } = "";
|
||||||
[JsonPropertyName("author")] public OverlayFeedAuthor Author { get; set; } = new();
|
[JsonPropertyName("author")] public OverlayFeedAuthor Author { get; set; } = new();
|
||||||
[JsonPropertyName("emotes")] public List<OverlayFeedEmote> Emotes { get; set; } = [];
|
[JsonPropertyName("emotes")] public List<OverlayFeedEmote> Emotes { get; set; } = [];
|
||||||
|
[JsonPropertyName("media")] public List<OverlayFeedMedia> Media { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record OverlayRenderedEvent(string Id, string Type, string Platform, string Summary, JsonElement Payload);
|
public sealed record OverlayRenderedEvent(string Id, string Type, string Platform, string Summary, JsonElement Payload);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_overlay",
|
"id": "lumi_overlay",
|
||||||
"name": "Lumi Overlay",
|
"name": "Lumi Overlay",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"provider_api": 1,
|
"provider_api": 1,
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"network.lumi.overlay.read",
|
"network.lumi.overlay.read",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Version = "0.2.6",
|
[string]$Version = "0.2.7",
|
||||||
[string]$BridgeVersion = "0.2.5",
|
[string]$BridgeVersion = "0.2.5",
|
||||||
[string]$ObsVersion = "31.1.1"
|
[string]$ObsVersion = "31.1.1"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
||||||
<Version>0.2.6</Version>
|
<Version>0.2.7</Version>
|
||||||
<AssemblyVersion>0.2.6.0</AssemblyVersion>
|
<AssemblyVersion>0.2.7.0</AssemblyVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
||||||
|
|||||||
@ -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
|
reconnect. Missing provider scopes disable only their affected event types and
|
||||||
are reported in feed status.
|
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
|
## Window behavior
|
||||||
|
|
||||||
The selected monitor, layout, styling, sources, event types, visibility mode,
|
The selected monitor, layout, styling, sources, event types, visibility mode,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Lumi is the core web UI and bot runtime.
|
Lumi is the core web UI and bot runtime.
|
||||||
## Runtime
|
## Runtime
|
||||||
Package: lumi-bot
|
Package: lumi-bot
|
||||||
Version: 0.3.8
|
Version: 0.3.9
|
||||||
## Routes
|
## Routes
|
||||||
- POST /api/diagnostics/v1/run
|
- POST /api/diagnostics/v1/run
|
||||||
- GET /api/events
|
- GET /api/events
|
||||||
|
|||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Auto-create managed voice channels from lobby rooms.
|
Auto-create managed voice channels from lobby rooms.
|
||||||
## Metadata
|
## Metadata
|
||||||
Plugin ID: auto-vc
|
Plugin ID: auto-vc
|
||||||
Version: 0.1.6
|
Version: 0.1.7
|
||||||
Default state: enabled
|
Default state: enabled
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- /plugins/auto-vc
|
- /plugins/auto-vc
|
||||||
|
|||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Authenticated live chat and event feed for the native Lumi Companion monitor overlay.
|
Authenticated live chat and event feed for the native Lumi Companion monitor overlay.
|
||||||
## Metadata
|
## Metadata
|
||||||
Plugin ID: lumi_overlay
|
Plugin ID: lumi_overlay
|
||||||
Version: 0.1.0
|
Version: 0.1.1
|
||||||
Default state: enabled
|
Default state: enabled
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- No plugin routes detected.
|
- No plugin routes detected.
|
||||||
|
|||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
||||||
## Metadata
|
## Metadata
|
||||||
Plugin ID: lumi_transcription
|
Plugin ID: lumi_transcription
|
||||||
Version: 0.2.6
|
Version: 0.2.7
|
||||||
Default state: enabled
|
Default state: enabled
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- /plugins/lumi_transcription
|
- /plugins/lumi_transcription
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.8",
|
"version": "0.3.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.8",
|
"version": "0.3.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.6.0",
|
"adm-zip": "^0.6.0",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.8",
|
"version": "0.3.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -25,6 +25,7 @@
|
|||||||
"verify:content": "node scripts/verify-content-library.js",
|
"verify:content": "node scripts/verify-content-library.js",
|
||||||
"verify:transcription": "node plugins/lumi_transcription/tests/verify.js",
|
"verify:transcription": "node plugins/lumi_transcription/tests/verify.js",
|
||||||
"verify:companion-overlay": "node plugins/lumi_overlay/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"
|
"verify:dev-updates": "node scripts/verify-local-development-updates.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@ -1,5 +1,17 @@
|
|||||||
# Auto VC changelog
|
# 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
|
## 0.1.6
|
||||||
|
|
||||||
- Connected lobby deletion to the shared timed-confirmation flow.
|
- Connected lobby deletion to the shared timed-confirmation flow.
|
||||||
|
|||||||
@ -10,6 +10,12 @@ const logger = createLogger("plugin:auto-vc", { category: "plugin" });
|
|||||||
const PLUGIN_ID = "auto-vc";
|
const PLUGIN_ID = "auto-vc";
|
||||||
const DEFAULT_TEMPLATE = "[username]'s room";
|
const DEFAULT_TEMPLATE = "[username]'s room";
|
||||||
const DEFAULT_TIMEOUT = 30;
|
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 GAME_NAME_TOKEN = "[game_name]";
|
||||||
const NAME_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
const NAME_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
const ALLOW_CONNECT_VIEW =
|
const ALLOW_CONNECT_VIEW =
|
||||||
@ -19,12 +25,16 @@ const DEFAULT_ACTION_LIMIT = { max: 8, windowSeconds: 60 };
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
id: PLUGIN_ID,
|
id: PLUGIN_ID,
|
||||||
init({ web, discordClient, db, settings }) {
|
init({ web, discordClient, db, settings, placeholders }) {
|
||||||
ensureTables(db);
|
ensureTables(db);
|
||||||
|
registerPlaceholderSupport(placeholders);
|
||||||
const state = {
|
const state = {
|
||||||
rooms: new Map(),
|
rooms: new Map(),
|
||||||
cleanupTimers: new Map(),
|
cleanupTimers: new Map(),
|
||||||
|
claimTimers: new Map(),
|
||||||
emptySince: new Map(),
|
emptySince: new Map(),
|
||||||
|
reactionActions: new Map(),
|
||||||
|
placeholders,
|
||||||
sweepTimer: null,
|
sweepTimer: null,
|
||||||
nameSweepTimer: null,
|
nameSweepTimer: null,
|
||||||
rateLimits: {
|
rateLimits: {
|
||||||
@ -66,6 +76,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
const config = parseConfigFromForm(req.body);
|
const config = parseConfigFromForm(req.body);
|
||||||
saveConfig(db, config);
|
saveConfig(db, config);
|
||||||
|
for (const channelId of state.claimTimers.keys()) clearClaimTimer(state, channelId);
|
||||||
state.rooms.clear();
|
state.rooms.clear();
|
||||||
req.session.flash = {
|
req.session.flash = {
|
||||||
type: "success",
|
type: "success",
|
||||||
@ -138,6 +149,9 @@ module.exports = {
|
|||||||
discordClient.on("messageCreate", (message) => {
|
discordClient.on("messageCreate", (message) => {
|
||||||
handleMessage(message, db, settings, state);
|
handleMessage(message, db, settings, state);
|
||||||
});
|
});
|
||||||
|
discordClient.on("messageReactionAdd", (reaction, user) => {
|
||||||
|
queueControlReaction(reaction, user, db, settings, state);
|
||||||
|
});
|
||||||
discordClient.on("channelDelete", (channel) => {
|
discordClient.on("channelDelete", (channel) => {
|
||||||
if (channel && channel.id) {
|
if (channel && channel.id) {
|
||||||
removeRoom(db, state, channel.id);
|
removeRoom(db, state, channel.id);
|
||||||
@ -218,7 +232,9 @@ async function buildLobbyPermissionChecks(discordClient, lobby) {
|
|||||||
"Connect to lobby",
|
"Connect to lobby",
|
||||||
"Move members",
|
"Move members",
|
||||||
"Target category visible",
|
"Target category visible",
|
||||||
"Manage rooms"
|
"Manage rooms",
|
||||||
|
"Post room controls",
|
||||||
|
"Manage room reactions"
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!discordClient || !discordClient.user) {
|
if (!discordClient || !discordClient.user) {
|
||||||
@ -271,6 +287,22 @@ async function buildLobbyPermissionChecks(discordClient, lobby) {
|
|||||||
lobbyPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) ||
|
lobbyPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) ||
|
||||||
guildPerms?.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 [
|
return [
|
||||||
buildPermissionCheck("Bot in guild", true, ""),
|
buildPermissionCheck("Bot in guild", true, ""),
|
||||||
@ -307,6 +339,16 @@ async function buildLobbyPermissionChecks(discordClient, lobby) {
|
|||||||
"Manage rooms",
|
"Manage rooms",
|
||||||
canManageChannels && (categoryChannel ? canViewCategory : true),
|
canManageChannels && (categoryChannel ? canViewCategory : true),
|
||||||
"Allow Manage Channels (and View Channel) on the target category so the bot can create, rename, and delete rooms."
|
"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,
|
locked INTEGER NOT NULL DEFAULT 0,
|
||||||
allowed_user_ids TEXT NOT NULL DEFAULT '[]',
|
allowed_user_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
base_overwrites TEXT NOT NULL,
|
base_overwrites TEXT NOT NULL,
|
||||||
|
control_message_id TEXT,
|
||||||
|
owner_absent_since INTEGER,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -376,6 +420,15 @@ function ensureTables(db) {
|
|||||||
created_at INTEGER NOT NULL
|
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) {
|
function getConfig(db) {
|
||||||
@ -408,6 +461,7 @@ function normalizeLobby(lobby) {
|
|||||||
lobbyChannelId: (lobby?.lobbyChannelId || "").toString().trim(),
|
lobbyChannelId: (lobby?.lobbyChannelId || "").toString().trim(),
|
||||||
categoryId: (lobby?.categoryId || "").toString().trim(),
|
categoryId: (lobby?.categoryId || "").toString().trim(),
|
||||||
nameTemplate: (lobby?.nameTemplate || DEFAULT_TEMPLATE).toString(),
|
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)
|
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 lobbyChannelIds = toArray(body.lobby_channel_id);
|
||||||
const categoryIds = toArray(body.lobby_category_id);
|
const categoryIds = toArray(body.lobby_category_id);
|
||||||
const templates = toArray(body.lobby_name_template);
|
const templates = toArray(body.lobby_name_template);
|
||||||
|
const roomMessages = toArray(body.lobby_room_message);
|
||||||
const timeouts = toArray(body.lobby_empty_timeout);
|
const timeouts = toArray(body.lobby_empty_timeout);
|
||||||
const removeIds = new Set(toArray(body.lobby_remove));
|
const removeIds = new Set(toArray(body.lobby_remove));
|
||||||
|
|
||||||
@ -461,6 +516,7 @@ function parseConfigFromForm(body) {
|
|||||||
lobbyChannelId: lobbyChannelIds[index] || "",
|
lobbyChannelId: lobbyChannelIds[index] || "",
|
||||||
categoryId: categoryIds[index] || "",
|
categoryId: categoryIds[index] || "",
|
||||||
nameTemplate: templates[index] || DEFAULT_TEMPLATE,
|
nameTemplate: templates[index] || DEFAULT_TEMPLATE,
|
||||||
|
roomMessage: roomMessages[index] ?? DEFAULT_ROOM_MESSAGE,
|
||||||
emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : timeout
|
emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : timeout
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -534,6 +590,7 @@ function isBanned(db, discordUserId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrapRooms(discordClient, db, state, settings) {
|
async function bootstrapRooms(discordClient, db, state, settings) {
|
||||||
|
const config = getConfig(db);
|
||||||
const rooms = db
|
const rooms = db
|
||||||
.prepare("SELECT * FROM auto_vc_rooms")
|
.prepare("SELECT * FROM auto_vc_rooms")
|
||||||
.all();
|
.all();
|
||||||
@ -554,7 +611,9 @@ async function bootstrapRooms(discordClient, db, state, settings) {
|
|||||||
removeRoom(db, state, room.channel_id);
|
removeRoom(db, state, room.channel_id);
|
||||||
continue;
|
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),
|
locked: Boolean(room.locked),
|
||||||
allowed_user_ids: parseJsonArray(room.allowed_user_ids),
|
allowed_user_ids: parseJsonArray(room.allowed_user_ids),
|
||||||
base_overwrites: room.base_overwrites,
|
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
|
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);
|
const channel = oldState.guild.channels.cache.get(oldState.channelId);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
scheduleCleanup(channel, room, db, state, config);
|
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) {
|
if (room) {
|
||||||
clearCleanupTimer(state, newState.channelId);
|
clearCleanupTimer(state, newState.channelId);
|
||||||
clearEmpty(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,
|
locked: false,
|
||||||
allowed_user_ids: [],
|
allowed_user_ids: [],
|
||||||
base_overwrites: JSON.stringify(baseOverwrites),
|
base_overwrites: JSON.stringify(baseOverwrites),
|
||||||
|
control_message_id: null,
|
||||||
|
owner_absent_since: null,
|
||||||
created_at: Date.now()
|
created_at: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
saveRoom(db, room);
|
saveRoom(db, room);
|
||||||
state.rooms.set(channel.id, normalizeRoom(room));
|
const normalizedRoom = normalizeRoom(room);
|
||||||
|
state.rooms.set(channel.id, normalizedRoom);
|
||||||
incrementUserStat(db, profile.id);
|
incrementUserStat(db, profile.id);
|
||||||
|
|
||||||
const moved = await moveMemberToChannel(member, channel);
|
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."
|
"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) {
|
function buildRoomName(template, member, roomNumber, gameName) {
|
||||||
@ -762,9 +912,9 @@ function extractOverwrites(channel) {
|
|||||||
|
|
||||||
function saveRoom(db, room) {
|
function saveRoom(db, room) {
|
||||||
db.prepare(
|
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) " +
|
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
"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"
|
"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(
|
).run(
|
||||||
room.channel_id,
|
room.channel_id,
|
||||||
room.guild_id,
|
room.guild_id,
|
||||||
@ -777,13 +927,15 @@ function saveRoom(db, room) {
|
|||||||
room.locked ? 1 : 0,
|
room.locked ? 1 : 0,
|
||||||
JSON.stringify(room.allowed_user_ids || []),
|
JSON.stringify(room.allowed_user_ids || []),
|
||||||
room.base_overwrites,
|
room.base_overwrites,
|
||||||
|
room.control_message_id || null,
|
||||||
|
room.owner_absent_since || null,
|
||||||
room.created_at
|
room.created_at
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRoom(db, room) {
|
function updateRoom(db, room) {
|
||||||
db.prepare(
|
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(
|
).run(
|
||||||
room.owner_discord_id,
|
room.owner_discord_id,
|
||||||
room.owner_user_id,
|
room.owner_user_id,
|
||||||
@ -791,6 +943,8 @@ function updateRoom(db, room) {
|
|||||||
room.name_template,
|
room.name_template,
|
||||||
room.locked ? 1 : 0,
|
room.locked ? 1 : 0,
|
||||||
JSON.stringify(room.allowed_user_ids || []),
|
JSON.stringify(room.allowed_user_ids || []),
|
||||||
|
room.control_message_id || null,
|
||||||
|
room.owner_absent_since || null,
|
||||||
room.channel_id
|
room.channel_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -809,6 +963,7 @@ function getRoomById(db, channelId, state) {
|
|||||||
|
|
||||||
function removeRoom(db, state, channelId) {
|
function removeRoom(db, state, channelId) {
|
||||||
clearCleanupTimer(state, channelId);
|
clearCleanupTimer(state, channelId);
|
||||||
|
clearClaimTimer(state, channelId);
|
||||||
clearEmpty(state, channelId);
|
clearEmpty(state, channelId);
|
||||||
state.rooms.delete(channelId);
|
state.rooms.delete(channelId);
|
||||||
db.prepare("DELETE FROM auto_vc_rooms WHERE channel_id = ?").run(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;
|
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) {
|
async function deleteChannel(channel) {
|
||||||
try {
|
try {
|
||||||
await channel.delete("Auto VC cleanup");
|
await channel.delete("Auto VC cleanup");
|
||||||
@ -1064,13 +1381,12 @@ async function handleLock(message, room, db, settings, state, config) {
|
|||||||
if (!enforceActionRateLimit(message, state, config)) {
|
if (!enforceActionRateLimit(message, state, config)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
room.locked = !room.locked;
|
if (room.locked) {
|
||||||
await applyRoomPermissions(channel, room, db, settings);
|
message.reply("Room is already locked.").catch(() => null);
|
||||||
updateRoom(db, room);
|
return;
|
||||||
state.rooms.set(room.channel_id, room);
|
}
|
||||||
message
|
const applied = await setRoomLocked(channel, room, true, db, settings, state);
|
||||||
.reply(room.locked ? "Room locked." : "Room unlocked.")
|
message.reply(applied ? "Room locked." : "I couldn't update this room's permissions.").catch(() => null);
|
||||||
.catch(() => null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUnlock(message, room, db, settings, state, config) {
|
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);
|
message.reply("Room is already unlocked.").catch(() => null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
room.locked = false;
|
const applied = await setRoomLocked(channel, room, false, db, settings, state);
|
||||||
await applyRoomPermissions(channel, room, db, settings);
|
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);
|
updateRoom(db, room);
|
||||||
state.rooms.set(room.channel_id, 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) {
|
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
|
displayName: target.displayName
|
||||||
});
|
});
|
||||||
room.owner_user_id = profile.id;
|
room.owner_user_id = profile.id;
|
||||||
|
room.owner_absent_since = null;
|
||||||
const channel = message.guild.channels.cache.get(room.channel_id);
|
const channel = message.guild.channels.cache.get(room.channel_id);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
await applyRoomPermissions(channel, room, db, settings);
|
await applyRoomPermissions(channel, room, db, settings);
|
||||||
}
|
}
|
||||||
updateRoom(db, room);
|
updateRoom(db, room);
|
||||||
state.rooms.set(room.channel_id, 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);
|
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)) {
|
if (!enforceActionRateLimit(message, state, config)) {
|
||||||
return;
|
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;
|
room.owner_discord_id = member.id;
|
||||||
const profile = ensureUserForIdentity({
|
const profile = ensureUserForIdentity({
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
@ -1197,10 +1533,18 @@ async function handleClaim(message, room, member, db, settings, state, config) {
|
|||||||
displayName: member.displayName
|
displayName: member.displayName
|
||||||
});
|
});
|
||||||
room.owner_user_id = profile.id;
|
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);
|
updateRoom(db, room);
|
||||||
state.rooms.set(room.channel_id, 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) {
|
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) {
|
function checkIsModerator(member, settings) {
|
||||||
@ -1351,6 +1704,21 @@ function cryptoRandomId() {
|
|||||||
return require("crypto").randomUUID();
|
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) {
|
async function safeNotify(member, message) {
|
||||||
try {
|
try {
|
||||||
await member.send(message);
|
await member.send(message);
|
||||||
@ -1446,6 +1814,7 @@ async function sweepRooms(discordClient, db, state) {
|
|||||||
removeRoom(db, state, room.channel_id);
|
removeRoom(db, state, room.channel_id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
await syncOwnerAbsence(channel, room, db, state, config);
|
||||||
if (channel.members.size === 0) {
|
if (channel.members.size === 0) {
|
||||||
markEmpty(state, channel.id);
|
markEmpty(state, channel.id);
|
||||||
const timeout = getLobbyTimeout(config, room.lobby_id);
|
const timeout = getLobbyTimeout(config, room.lobby_id);
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"id": "auto-vc",
|
"id": "auto-vc",
|
||||||
"name": "Auto VC",
|
"name": "Auto VC",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"description": "Auto-create managed voice channels from lobby rooms.",
|
"description": "Auto-create managed voice channels from lobby rooms.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"compatible_from": "0.1.5",
|
"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
|
"rollback_safe": true
|
||||||
}
|
}
|
||||||
|
|||||||
105
plugins/auto-vc/tests/verify.js
Normal file
105
plugins/auto-vc/tests/verify.js
Normal file
@ -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;
|
||||||
|
});
|
||||||
@ -275,6 +275,15 @@
|
|||||||
<label>Empty room cleanup (seconds)</label>
|
<label>Empty room cleanup (seconds)</label>
|
||||||
<input name="lobby_empty_timeout" value="<%= lobby.emptyTimeoutSeconds %>" type="number" min="5" />
|
<input name="lobby_empty_timeout" value="<%= lobby.emptyTimeoutSeconds %>" type="number" min="5" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>New room message</label>
|
||||||
|
<textarea name="lobby_room_message" rows="4" maxlength="2000"><%= lobby.roomMessage %></textarea>
|
||||||
|
<p class="hint">
|
||||||
|
Sent in every newly created room with lock and unlock reactions.
|
||||||
|
Supports <code>{{plugins.auto_vc.channel.owner_username}}</code>.
|
||||||
|
The claim reaction appears after the owner has been away for the cleanup duration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% if (lobby.permissions && lobby.permissions.length) { %>
|
<% if (lobby.permissions && lobby.permissions.length) { %>
|
||||||
<% const totalPerms = lobby.permissions.length; %>
|
<% const totalPerms = lobby.permissions.length; %>
|
||||||
@ -428,6 +437,13 @@
|
|||||||
<label>Empty room cleanup (seconds)</label>
|
<label>Empty room cleanup (seconds)</label>
|
||||||
<input name="lobby_empty_timeout" value="30" type="number" min="5" />
|
<input name="lobby_empty_timeout" value="30" type="number" min="5" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>New room message</label>
|
||||||
|
<textarea name="lobby_room_message" rows="4" maxlength="2000">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.</textarea>
|
||||||
|
<p class="hint">
|
||||||
|
Sent with lock and unlock reactions. The claim reaction appears after the owner has been away for the cleanup duration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
# Lumi Overlay Feed changelog
|
# 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
|
## 0.1.0
|
||||||
|
|
||||||
- Add the paired-device `overlay.read.v1` live feed for the native Companion
|
- Add the paired-device `overlay.read.v1` live feed for the native Companion
|
||||||
|
|||||||
@ -51,12 +51,18 @@ function sanitizeChat(message) {
|
|||||||
image: safeHttps(badge?.image)
|
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),
|
start: Math.max(0, Number(emote?.start) || 0),
|
||||||
end: Math.max(0, Number(emote?.end) || 0),
|
end: Math.max(0, Number(emote?.end) || 0),
|
||||||
label: cleanText(emote?.label, 100),
|
label: cleanText(emote?.label, 100),
|
||||||
image: safeHttps(emote?.image)
|
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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_overlay",
|
"id": "lumi_overlay",
|
||||||
"name": "Lumi Overlay Feed",
|
"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.",
|
"description": "Authenticated live chat and event feed for the native Lumi Companion monitor overlay.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
|
|||||||
@ -112,6 +112,14 @@ function verifySanitization() {
|
|||||||
assert.equal(chat.text, "<script>plain text</script>", "content remains plain data and is not interpreted server-side");
|
assert.equal(chat.text, "<script>plain text</script>", "content remains plain data and is not interpreted server-side");
|
||||||
assert.equal(chat.author.avatar, null);
|
assert.equal(chat.author.avatar, null);
|
||||||
assert.equal(chat.emotes[0].image, "https://cdn.test/emote.png");
|
assert.equal(chat.emotes[0].image, "https://cdn.test/emote.png");
|
||||||
|
const discord = sanitizeChat({
|
||||||
|
id: "discord-id", platform: "discord", text: "<a:wave:1>", 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" } });
|
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.access_token, undefined);
|
||||||
assert.equal(event.payload.user_name, "Viewer");
|
assert.equal(event.payload.user_name, "Viewer");
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
# Lumi Transcription changelog
|
# 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
|
## 0.2.6
|
||||||
|
|
||||||
- Release Companion 0.2.6 with the native Lumi Overlay and shell-owned authenticated live feed.
|
- Release Companion 0.2.6 with the native Lumi Overlay and shell-owned authenticated live feed.
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"signed": false,
|
"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": {
|
"installer": {
|
||||||
"id": "windows-x64-installer",
|
"id": "windows-x64-installer",
|
||||||
"platform": "win32",
|
"platform": "win32",
|
||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"label": "Windows x64 per-user installer",
|
"label": "Windows x64 per-user installer",
|
||||||
"filename": "Lumi.Companion-Setup.exe",
|
"filename": "Lumi.Companion-Setup.exe",
|
||||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.6/Lumi.Companion-Setup.exe",
|
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.7/Lumi.Companion-Setup.exe",
|
||||||
"sha256": "0420cdee45d676ded1566fd8da2e25482eca258b1d7d200544e5425d22105bb8",
|
"sha256": "b23686d0e8ecd0fbc89034f9dc6a37350bee17212d60f385eeaf2e4de634fab6",
|
||||||
"bytes": 51922633
|
"bytes": 51930364
|
||||||
},
|
},
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
@ -20,9 +20,9 @@
|
|||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"label": "Windows x64 self-contained",
|
"label": "Windows x64 self-contained",
|
||||||
"filename": "Lumi.Companion-win-x64.zip",
|
"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",
|
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.7/Lumi.Companion-win-x64.zip",
|
||||||
"sha256": "1e216f9653d0dc0513ca119f0a8c5cf43bf47328a83cbbf183e1d4b14c111e94",
|
"sha256": "c36983e1f83a9f278475db31819be0acaa6af72d5d03e1a1c5d3ba62b556e81c",
|
||||||
"bytes": 66857759,
|
"bytes": 66860208,
|
||||||
"entrypoint": "Lumi.Companion.App.exe"
|
"entrypoint": "Lumi.Companion.App.exe"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_transcription",
|
"id": "lumi_transcription",
|
||||||
"name": "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.",
|
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
|
|||||||
@ -2,6 +2,39 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"releases": [
|
"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",
|
"version": "0.3.8",
|
||||||
"ref": "refs/tags/v0.3.8",
|
"ref": "refs/tags/v0.3.8",
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const checks = [
|
|||||||
"plugins/lumi_ai_web_search/tests/verify.js",
|
"plugins/lumi_ai_web_search/tests/verify.js",
|
||||||
"plugins/lumi_transcription/tests/verify.js",
|
"plugins/lumi_transcription/tests/verify.js",
|
||||||
"plugins/lumi_overlay/tests/verify.js",
|
"plugins/lumi_overlay/tests/verify.js",
|
||||||
|
"plugins/auto-vc/tests/verify.js",
|
||||||
"scripts/verify-assistant-panels.js",
|
"scripts/verify-assistant-panels.js",
|
||||||
"scripts/verify-command-preview-confirmations.js",
|
"scripts/verify-command-preview-confirmations.js",
|
||||||
"scripts/verify-command-policies.js",
|
"scripts/verify-command-policies.js",
|
||||||
|
|||||||
@ -273,6 +273,18 @@ try {
|
|||||||
assert.strictEqual(discordContent.text, "Hello <a:party:123456>");
|
assert.strictEqual(discordContent.text, "Hello <a:party:123456>");
|
||||||
assert.strictEqual(discordContent.emotes[0].image, "https://cdn.discordapp.com/emojis/123456.gif?size=96&quality=lossless");
|
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");
|
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 twitchChatAssets = require(path.join(serviceDir, "twitch-chat-assets.js"));
|
||||||
const betterTtvCatalog = twitchChatAssets.indexBetterTtvCatalog([
|
const betterTtvCatalog = twitchChatAssets.indexBetterTtvCatalog([
|
||||||
{ id: "emote-kekw", code: "KEKW", imageType: "webp", animated: false },
|
{ id: "emote-kekw", code: "KEKW", imageType: "webp", animated: false },
|
||||||
|
|||||||
@ -4,14 +4,15 @@ const path = require("path");
|
|||||||
const { findSafeTarget } = require("../src/services/versioning");
|
const { findSafeTarget } = require("../src/services/versioning");
|
||||||
|
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const releaseVersion = "0.3.8";
|
const releaseVersion = "0.3.9";
|
||||||
const previousStableVersion = "0.3.7";
|
const previousStableVersion = "0.3.8";
|
||||||
const priorStableVersion = "0.3.6";
|
const priorStableVersion = "0.3.7";
|
||||||
const earliestCompatibleCoreVersion = "0.1.9";
|
const earliestCompatibleCoreVersion = "0.1.9";
|
||||||
const introducedPlugins = {
|
const introducedPlugins = {
|
||||||
lumi_overlay: { version: "0.1.0", knowledge: "lumi-overlay" },
|
"auto-vc": { version: "0.1.7", knowledge: "auto-vc", compatibleFrom: "0.1.5" },
|
||||||
lumi_transcription: { version: "0.2.6", knowledge: "lumi-transcription" },
|
lumi_overlay: { version: "0.1.1", knowledge: "lumi-overlay", compatibleFrom: "0.1.0" },
|
||||||
now_playing: { version: "0.1.3", knowledge: "now-playing" }
|
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) {
|
function readJson(relativePath) {
|
||||||
@ -62,7 +63,7 @@ assert.equal(safeCoreTarget.target?.version, releaseVersion);
|
|||||||
for (const [pluginId, expected] of Object.entries(introducedPlugins)) {
|
for (const [pluginId, expected] of Object.entries(introducedPlugins)) {
|
||||||
const manifest = readJson(`plugins/${pluginId}/plugin.json`);
|
const manifest = readJson(`plugins/${pluginId}/plugin.json`);
|
||||||
assert.equal(manifest.version, expected.version, `${pluginId} manifest version`);
|
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.channel, "stable", `${pluginId} release channel`);
|
||||||
assert.equal(manifest.rollback_safe, true, `${pluginId} rollback metadata`);
|
assert.equal(manifest.rollback_safe, true, `${pluginId} rollback metadata`);
|
||||||
assert.equal(
|
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(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
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.");
|
||||||
|
|||||||
@ -24,7 +24,7 @@ function readJson(relativePath) {
|
|||||||
|
|
||||||
const releaseIndex = readJson("release-index.json");
|
const releaseIndex = readJson("release-index.json");
|
||||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
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");
|
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||||
for (const release of releaseIndex.releases) {
|
for (const release of releaseIndex.releases) {
|
||||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||||
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
|
|||||||
const coreManifest = readJson("update-manifest.json");
|
const coreManifest = readJson("update-manifest.json");
|
||||||
assert.equal(packageVersion, coreManifest.version);
|
assert.equal(packageVersion, coreManifest.version);
|
||||||
assert.equal(coreManifest.channel, "stable");
|
assert.equal(coreManifest.channel, "stable");
|
||||||
assert.equal(packageVersion, "0.3.8");
|
assert.equal(packageVersion, "0.3.9");
|
||||||
assert.equal(currentRelease.version, "0.3.8");
|
assert.equal(currentRelease.version, "0.3.9");
|
||||||
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
||||||
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
||||||
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
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 = {
|
const baseTarget = {
|
||||||
current_version: "0.2.4",
|
current_version: "0.2.4",
|
||||||
available_versions: [
|
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.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.7", ref: "refs/tags/v0.3.7", rollback_safe: true },
|
||||||
{ version: "0.3.6", ref: "refs/tags/v0.3.6", 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"
|
channel: "stable"
|
||||||
});
|
});
|
||||||
assert.equal(corrected.version_correction, true);
|
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.update_available, true);
|
||||||
assert.equal(corrected.blocked, false);
|
assert.equal(corrected.blocked, false);
|
||||||
|
|
||||||
|
|||||||
@ -10,11 +10,16 @@ function discordOverlayContent(message) {
|
|||||||
const media = [];
|
const media = [];
|
||||||
const mediaUrls = new Set();
|
const mediaUrls = new Set();
|
||||||
const removedLinks = 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();
|
const clean = String(url || "").trim();
|
||||||
if (!/^https?:\/\//i.test(clean) || mediaUrls.has(clean) || media.length >= 4) return;
|
if (!/^https?:\/\//i.test(clean) || mediaUrls.has(clean) || media.length >= 4) return;
|
||||||
mediaUrls.add(clean);
|
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);
|
if (sourceUrl && text.includes(sourceUrl)) removedLinks.add(sourceUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -31,7 +36,7 @@ function discordOverlayContent(message) {
|
|||||||
if (embedType !== "gifv" && !/\.(?:gif|gifv)(?:$|\?)/i.test(sourceUrl)) continue;
|
if (embedType !== "gifv" && !/\.(?:gif|gifv)(?:$|\?)/i.test(sourceUrl)) continue;
|
||||||
const videoUrl = embed?.video?.url || embed?.video?.proxyURL || embed?.video?.proxy_url;
|
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;
|
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);
|
else addMedia(imageUrl || sourceUrl, "image", embed?.title || "Animated image", sourceUrl);
|
||||||
}
|
}
|
||||||
for (const sticker of collectionValues(message?.stickers)) {
|
for (const sticker of collectionValues(message?.stickers)) {
|
||||||
|
|||||||
@ -25,9 +25,10 @@ async function startBot({ commandRouter } = {}) {
|
|||||||
const intents = [
|
const intents = [
|
||||||
resolveIntent("Guilds", "GUILDS"),
|
resolveIntent("Guilds", "GUILDS"),
|
||||||
resolveIntent("GuildMessages", "GUILD_MESSAGES"),
|
resolveIntent("GuildMessages", "GUILD_MESSAGES"),
|
||||||
resolveIntent("GuildMembers", "GUILD_MEMBERS"),
|
resolveIntent("GuildMembers", "GUILD_MEMBERS"),
|
||||||
resolveIntent("MessageContent", "MESSAGE_CONTENT"),
|
resolveIntent("MessageContent", "MESSAGE_CONTENT"),
|
||||||
resolveIntent("GuildVoiceStates", "GUILD_VOICE_STATES"),
|
resolveIntent("GuildMessageReactions", "GUILD_MESSAGE_REACTIONS"),
|
||||||
|
resolveIntent("GuildVoiceStates", "GUILD_VOICE_STATES"),
|
||||||
resolveIntent("GuildPresences", "GUILD_PRESENCES")
|
resolveIntent("GuildPresences", "GUILD_PRESENCES")
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
@ -39,9 +40,12 @@ async function startBot({ commandRouter } = {}) {
|
|||||||
intents,
|
intents,
|
||||||
guildMembers: Boolean(resolveIntent("GuildMembers", "GUILD_MEMBERS"))
|
guildMembers: Boolean(resolveIntent("GuildMembers", "GUILD_MEMBERS"))
|
||||||
}, { event: "platform_starting" });
|
}, { event: "platform_starting" });
|
||||||
if (Partials?.Channel) {
|
options.partials = [
|
||||||
options.partials = [Partials.Channel];
|
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);
|
client = new Client(options);
|
||||||
|
|
||||||
@ -159,7 +163,7 @@ function getBotAvatarUrl(user) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveIntent(key, legacyKey) {
|
function resolveIntent(key, legacyKey) {
|
||||||
if (GatewayIntentBits?.[key]) {
|
if (GatewayIntentBits?.[key]) {
|
||||||
return GatewayIntentBits[key];
|
return GatewayIntentBits[key];
|
||||||
}
|
}
|
||||||
@ -170,8 +174,13 @@ function resolveIntent(key, legacyKey) {
|
|||||||
return Intents.FLAGS[legacyKey];
|
return Intents.FLAGS[legacyKey];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePartial(key, legacyKey) {
|
||||||
|
if (Partials?.[key] !== undefined) return Partials[key];
|
||||||
|
return legacyKey;
|
||||||
|
}
|
||||||
|
|
||||||
async function stopBot() {
|
async function stopBot() {
|
||||||
if (client) {
|
if (client) {
|
||||||
await client.destroy();
|
await client.destroy();
|
||||||
|
|||||||
@ -53,7 +53,8 @@ function normalizeMedia(media) {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
type: media?.type === "video" ? "video" : "image",
|
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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -279,6 +279,7 @@
|
|||||||
media.loop = true;
|
media.loop = true;
|
||||||
media.muted = true;
|
media.muted = true;
|
||||||
media.playsInline = true;
|
media.playsInline = true;
|
||||||
|
if (mediaData.preview) media.poster = mediaData.preview;
|
||||||
} else media.alt = mediaData.alt || "Animated image";
|
} else media.alt = mediaData.alt || "Animated image";
|
||||||
media.addEventListener("error", () => media.remove(), { once: true });
|
media.addEventListener("error", () => media.remove(), { once: true });
|
||||||
mediaRow.appendChild(media);
|
mediaRow.appendChild(media);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Lumi Core",
|
"name": "Lumi Core",
|
||||||
"version": "0.3.8",
|
"version": "0.3.9",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"released_at": "2026-07-26",
|
"released_at": "2026-07-26",
|
||||||
"compatible_from": "0.1.9",
|
"compatible_from": "0.1.9",
|
||||||
@ -8,7 +8,7 @@
|
|||||||
"replaces_versions": [
|
"replaces_versions": [
|
||||||
"1.2.0"
|
"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,
|
"rollback_safe": true,
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"Node.js 18 or newer"
|
"Node.js 18 or newer"
|
||||||
@ -445,6 +445,18 @@
|
|||||||
],
|
],
|
||||||
"rollback_safe": true,
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user