release: publish Lumi 0.3.9 chat media and Auto VC controls

This commit is contained in:
Franz Rolfsvaag 2026-07-27 00:10:01 +02:00
parent 655547938a
commit 5c85ba400d
37 changed files with 861 additions and 88 deletions

View File

@ -1,5 +1,12 @@
# Lumi changelog
## 0.3.9
- Restored animated Twitch/BTTV and Discord emotes plus Discord GIF media across the existing normalized OBS and native Companion overlay paths, and rendered real Twitch badge artwork with safe fallbacks.
- Released Companion 0.2.7 with bounded multi-frame GIF/WebP decoding for the capture-excluded native overlay while retaining the existing OBS browser renderer and media workflow.
- Added configurable Auto VC room messages with the room-owner placeholder, persistent lock/unlock controls, timed ownership claiming, and short-lived action reports.
- Preserved all existing settings, lobbies, active rooms, ownership, permissions, pairing identities, Companion settings, overlays, databases, uploads, models, and secrets through additive migrations and the normal updater.
## 0.3.8
- Added the native Windows Lumi Overlay using the existing paired-device authentication, normalized provider streams, live-state tracking, and Companion plugin transport without exposing provider credentials.

View File

@ -1,5 +1,5 @@
#ifndef AppVersion
#define AppVersion "0.2.6"
#define AppVersion "0.2.7"
#endif
#ifndef SourceRoot
#error SourceRoot must point at the self-contained Companion publish directory.

View File

@ -11,5 +11,6 @@
<PackageReference Include="Avalonia" Version="12.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
<PackageReference Include="Microsoft.Win32.SystemEvents" Version="8.0.0" />
<PackageReference Include="SkiaSharp" Version="3.119.4" />
</ItemGroup>
</Project>

View File

@ -10,6 +10,7 @@ using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Threading;
using Microsoft.Win32;
using SkiaSharp;
namespace Lumi.Companion.Overlay;
@ -299,7 +300,7 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
var contentColumn = 0;
if (settings.ShowAvatar && message.Author.Avatar is not null)
{
var avatar = new Image { Width = 36, Height = 36, Stretch = Stretch.UniformToFill };
var avatar = new AnimatedOverlayImage { Width = 36, Height = 36, Stretch = Stretch.UniformToFill };
_ = LoadImageAsync(avatar, message.Author.Avatar);
grid.Children.Add(avatar);
contentColumn = 1;
@ -308,10 +309,26 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
var header = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 7 };
if (settings.ShowPlatform) header.Children.Add(Text(message.Platform.ToUpperInvariant(), settings, 11, FontWeight.Bold));
foreach (var badge in message.Author.Badges.Take(6))
header.Children.Add(Text(badge.Label, settings, 10, FontWeight.SemiBold));
{
if (badge.Image is null)
{
header.Children.Add(Text(badge.Label, settings, 10, FontWeight.SemiBold));
continue;
}
var token = new Grid();
ToolTip.SetTip(token, badge.Label);
var fallback = Text(badge.Label, settings, 10, FontWeight.SemiBold);
var image = new AnimatedOverlayImage { Width = 18, Height = 18, Stretch = Stretch.Uniform, Opacity = 0 };
token.Children.Add(fallback);
token.Children.Add(image);
header.Children.Add(token);
_ = LoadImageAsync(image, badge.Image, fallback);
}
if (settings.ShowUsername) header.Children.Add(Text(message.Author.Name, settings, settings.FontSize * .72, FontWeight.Bold));
if (header.Children.Count > 0) stack.Children.Add(header);
stack.Children.Add(CreateMessageBody(message, settings));
var media = CreateMessageMedia(message);
if (media is not null) stack.Children.Add(media);
Grid.SetColumn(stack, contentColumn);
grid.Children.Add(stack);
return grid;
@ -329,7 +346,7 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
{
var token = new Grid { Margin = new Thickness(2, 0) };
var fallback = Text(string.IsNullOrWhiteSpace(emote.Label) ? message.Text[emote.Start..Math.Min(message.Text.Length, emote.End + 1)] : emote.Label, settings, settings.FontSize);
var image = new Image { Width = settings.FontSize * 1.3, Height = settings.FontSize * 1.3, Opacity = 0 };
var image = new AnimatedOverlayImage { Width = settings.FontSize * 1.3, Height = settings.FontSize * 1.3, Opacity = 0 };
token.Children.Add(fallback);
token.Children.Add(image);
panel.Children.Add(token);
@ -343,6 +360,31 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
return panel;
}
private Control? CreateMessageMedia(OverlayFeedMessage message)
{
var panel = new WrapPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
ItemWidth = 220,
ItemHeight = 150
};
foreach (var media in message.Media.Take(4))
{
var source = media.Type == "video" ? media.Preview : media.Url;
if (string.IsNullOrWhiteSpace(source)) continue;
var image = new AnimatedOverlayImage
{
Width = 220,
Height = 150,
Stretch = Stretch.Uniform
};
if (!string.IsNullOrWhiteSpace(media.Alt)) ToolTip.SetTip(image, media.Alt);
panel.Children.Add(image);
_ = LoadImageAsync(image, source);
}
return panel.Children.Count == 0 ? null : panel;
}
private Control CreateEventContent(OverlayRenderedEvent value, OverlayContainerSettings settings)
{
var stack = new StackPanel { Spacing = 3 };
@ -378,14 +420,14 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
return block;
}
private async Task LoadImageAsync(Image target, string url, Control? fallback = null)
private async Task LoadImageAsync(AnimatedOverlayImage target, string url, Control? fallback = null)
{
var bitmap = await _images.GetAsync(url).ConfigureAwait(false);
var asset = await _images.GetAsync(url).ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (bitmap is not null)
if (asset is not null)
{
target.Source = bitmap;
target.SetAsset(asset);
target.Opacity = 1;
if (fallback is not null) fallback.IsVisible = false;
}
@ -475,14 +517,18 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
internal sealed class HttpsImageCache : IDisposable
{
private const int Limit = 128;
private const int MaxEncodedBytes = 2 * 1024 * 1024;
private const int MaxDimension = 384;
private const int MaxFrames = 60;
private const long MaxDecodedPixels = 12_000_000;
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) };
private readonly ConcurrentDictionary<string, Lazy<Task<Bitmap?>>> _entries = new();
private readonly ConcurrentDictionary<string, Lazy<Task<OverlayImageAsset?>>> _entries = new();
private readonly Queue<string> _order = 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)
{
if (!_entries.ContainsKey(uri.AbsoluteUri))
@ -495,19 +541,58 @@ internal sealed class HttpsImageCache : IDisposable
return _entries.GetOrAdd(uri.AbsoluteUri, key => new(() => DownloadAsync(key))).Value;
}
private async Task<Bitmap?> DownloadAsync(string url)
private async Task<OverlayImageAsset?> DownloadAsync(string url)
{
try
{
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > 2 * 1024 * 1024) return null;
if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > MaxEncodedBytes) return null;
var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
if (bytes.Length > 2 * 1024 * 1024) return null;
return new Bitmap(new MemoryStream(bytes));
if (bytes.Length > MaxEncodedBytes) return null;
return Decode(bytes);
}
catch { return null; }
}
private static OverlayImageAsset? Decode(byte[] bytes)
{
using var data = SKData.CreateCopy(bytes);
using var codec = SKCodec.Create(data);
if (codec is null || codec.Info.Width <= 0 || codec.Info.Height <= 0) return null;
var scale = Math.Min(1d, MaxDimension / (double)Math.Max(codec.Info.Width, codec.Info.Height));
var dimensions = codec.GetScaledDimensions((float)scale);
var width = Math.Max(1, dimensions.Width);
var height = Math.Max(1, dimensions.Height);
var frameCount = Math.Clamp(codec.FrameCount, 1, MaxFrames);
frameCount = (int)Math.Min(frameCount, Math.Max(1, MaxDecodedPixels / ((long)width * height)));
var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul);
using var working = new SKBitmap(info);
var frameInfo = codec.FrameInfo;
var frames = new List<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()
{
_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
{
public static Color ColorFromRgba(string value)

View File

@ -25,6 +25,14 @@ public sealed class OverlayFeedEmote
[JsonPropertyName("image")] public string? Image { get; set; }
}
public sealed class OverlayFeedMedia
{
[JsonPropertyName("url")] public string? Url { get; set; }
[JsonPropertyName("type")] public string Type { get; set; } = "image";
[JsonPropertyName("alt")] public string Alt { get; set; } = "";
[JsonPropertyName("preview")] public string? Preview { get; set; }
}
public sealed class OverlayFeedMessage
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
@ -32,6 +40,7 @@ public sealed class OverlayFeedMessage
[JsonPropertyName("text")] public string Text { get; set; } = "";
[JsonPropertyName("author")] public OverlayFeedAuthor Author { get; set; } = new();
[JsonPropertyName("emotes")] public List<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);

View File

@ -1,7 +1,7 @@
{
"id": "lumi_overlay",
"name": "Lumi Overlay",
"version": "0.1.0",
"version": "0.1.1",
"provider_api": 1,
"capabilities": [
"network.lumi.overlay.read",

View File

@ -1,5 +1,5 @@
param(
[string]$Version = "0.2.6",
[string]$Version = "0.2.7",
[string]$BridgeVersion = "0.2.5",
[string]$ObsVersion = "31.1.1"
)

View File

@ -6,8 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
<Version>0.2.6</Version>
<AssemblyVersion>0.2.6.0</AssemblyVersion>
<Version>0.2.7</Version>
<AssemblyVersion>0.2.7.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />

View File

@ -18,6 +18,12 @@ a connection receives live items only—no history is replayed after startup or
reconnect. Missing provider scopes disable only their affected event types and
are reported in feed status.
The native renderer uses the same normalized badge, emote, and Discord media
payloads as Lumi's OBS browser overlay. HTTPS artwork is downloaded with strict
size, dimension, frame-count, and decoded-pixel limits. Animated GIF/WebP
emotes and images retain their bounded frame timing; unsupported video-only
embeds use their supplied preview image when available.
## Window behavior
The selected monitor, layout, styling, sources, event types, visibility mode,

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
Version: 0.3.8
Version: 0.3.9
## Routes
- POST /api/diagnostics/v1/run
- GET /api/events

View File

@ -14,7 +14,7 @@ editable: false
Auto-create managed voice channels from lobby rooms.
## Metadata
Plugin ID: auto-vc
Version: 0.1.6
Version: 0.1.7
Default state: enabled
## Web Routes
- /plugins/auto-vc

View File

@ -14,7 +14,7 @@ editable: false
Authenticated live chat and event feed for the native Lumi Companion monitor overlay.
## Metadata
Plugin ID: lumi_overlay
Version: 0.1.0
Version: 0.1.1
Default state: enabled
## Web Routes
- No plugin routes detected.

View File

@ -14,7 +14,7 @@ editable: false
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
## Metadata
Plugin ID: lumi_transcription
Version: 0.2.6
Version: 0.2.7
Default state: enabled
## Web Routes
- /plugins/lumi_transcription

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.3.8",
"version": "0.3.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.3.8",
"version": "0.3.9",
"dependencies": {
"adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.3.8",
"version": "0.3.9",
"private": true,
"type": "commonjs",
"scripts": {
@ -25,6 +25,7 @@
"verify:content": "node scripts/verify-content-library.js",
"verify:transcription": "node plugins/lumi_transcription/tests/verify.js",
"verify:companion-overlay": "node plugins/lumi_overlay/tests/verify.js",
"verify:auto-vc": "node plugins/auto-vc/tests/verify.js",
"verify:dev-updates": "node scripts/verify-local-development-updates.js"
},
"engines": {

View File

@ -1,5 +1,17 @@
# Auto VC changelog
## 0.1.7
- Add an admin-defined message to each new room with the
`{{plugins.auto_vc.channel.owner_username}}` placeholder.
- Add persistent lock and unlock reactions that reuse the existing room
permission behavior.
- Add a claim reaction after the owner has been absent for the configured
cleanup duration, plus concise success or failure reports that delete after
30 seconds.
- Preserve existing room and lobby data with additive control-message and
owner-absence columns.
## 0.1.6
- Connected lobby deletion to the shared timed-confirmation flow.

View File

@ -10,6 +10,12 @@ const logger = createLogger("plugin:auto-vc", { category: "plugin" });
const PLUGIN_ID = "auto-vc";
const DEFAULT_TEMPLATE = "[username]'s room";
const DEFAULT_TIMEOUT = 30;
const DEFAULT_ROOM_MESSAGE = "Welcome {{plugins.auto_vc.channel.owner_username}}! Use 🔒 to lock this room or 🔓 to unlock it. If the owner is away long enough, 👑 appears so someone in the room can claim it.";
const ROOM_MESSAGE_FIELD_ID = "plugins.auto_vc.room_message";
const LOCK_EMOJI = "🔒";
const UNLOCK_EMOJI = "🔓";
const CLAIM_EMOJI = "👑";
const REPORT_LIFETIME_MS = 30 * 1000;
const GAME_NAME_TOKEN = "[game_name]";
const NAME_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
const ALLOW_CONNECT_VIEW =
@ -19,12 +25,16 @@ const DEFAULT_ACTION_LIMIT = { max: 8, windowSeconds: 60 };
module.exports = {
id: PLUGIN_ID,
init({ web, discordClient, db, settings }) {
init({ web, discordClient, db, settings, placeholders }) {
ensureTables(db);
registerPlaceholderSupport(placeholders);
const state = {
rooms: new Map(),
cleanupTimers: new Map(),
claimTimers: new Map(),
emptySince: new Map(),
reactionActions: new Map(),
placeholders,
sweepTimer: null,
nameSweepTimer: null,
rateLimits: {
@ -66,6 +76,7 @@ module.exports = {
}
const config = parseConfigFromForm(req.body);
saveConfig(db, config);
for (const channelId of state.claimTimers.keys()) clearClaimTimer(state, channelId);
state.rooms.clear();
req.session.flash = {
type: "success",
@ -138,6 +149,9 @@ module.exports = {
discordClient.on("messageCreate", (message) => {
handleMessage(message, db, settings, state);
});
discordClient.on("messageReactionAdd", (reaction, user) => {
queueControlReaction(reaction, user, db, settings, state);
});
discordClient.on("channelDelete", (channel) => {
if (channel && channel.id) {
removeRoom(db, state, channel.id);
@ -218,7 +232,9 @@ async function buildLobbyPermissionChecks(discordClient, lobby) {
"Connect to lobby",
"Move members",
"Target category visible",
"Manage rooms"
"Manage rooms",
"Post room controls",
"Manage room reactions"
];
if (!discordClient || !discordClient.user) {
@ -271,6 +287,22 @@ async function buildLobbyPermissionChecks(discordClient, lobby) {
lobbyPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) ||
guildPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS)
);
const canSendMessages = Boolean(
categoryPerms?.has(Permissions.FLAGS.SEND_MESSAGES) ||
guildPerms?.has(Permissions.FLAGS.SEND_MESSAGES)
);
const canAddReactions = Boolean(
categoryPerms?.has(Permissions.FLAGS.ADD_REACTIONS) ||
guildPerms?.has(Permissions.FLAGS.ADD_REACTIONS)
);
const canReadHistory = Boolean(
categoryPerms?.has(Permissions.FLAGS.READ_MESSAGE_HISTORY) ||
guildPerms?.has(Permissions.FLAGS.READ_MESSAGE_HISTORY)
);
const canManageMessages = Boolean(
categoryPerms?.has(Permissions.FLAGS.MANAGE_MESSAGES) ||
guildPerms?.has(Permissions.FLAGS.MANAGE_MESSAGES)
);
return [
buildPermissionCheck("Bot in guild", true, ""),
@ -307,6 +339,16 @@ async function buildLobbyPermissionChecks(discordClient, lobby) {
"Manage rooms",
canManageChannels && (categoryChannel ? canViewCategory : true),
"Allow Manage Channels (and View Channel) on the target category so the bot can create, rename, and delete rooms."
),
buildPermissionCheck(
"Post room controls",
canSendMessages && canAddReactions && canReadHistory,
"Allow Send Messages, Add Reactions, and Read Message History on the target category."
),
buildPermissionCheck(
"Manage room reactions",
canManageMessages,
"Allow Manage Messages on the target category so reaction controls remain reusable."
)
];
}
@ -361,6 +403,8 @@ function ensureTables(db) {
locked INTEGER NOT NULL DEFAULT 0,
allowed_user_ids TEXT NOT NULL DEFAULT '[]',
base_overwrites TEXT NOT NULL,
control_message_id TEXT,
owner_absent_since INTEGER,
created_at INTEGER NOT NULL
);
@ -376,6 +420,15 @@ function ensureTables(db) {
created_at INTEGER NOT NULL
);
`);
ensureColumn(db, "auto_vc_rooms", "control_message_id", "TEXT");
ensureColumn(db, "auto_vc_rooms", "owner_absent_since", "INTEGER");
}
function ensureColumn(db, table, column, definition) {
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
if (!columns.some((entry) => entry.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}
function getConfig(db) {
@ -408,6 +461,7 @@ function normalizeLobby(lobby) {
lobbyChannelId: (lobby?.lobbyChannelId || "").toString().trim(),
categoryId: (lobby?.categoryId || "").toString().trim(),
nameTemplate: (lobby?.nameTemplate || DEFAULT_TEMPLATE).toString(),
roomMessage: (lobby?.roomMessage ?? DEFAULT_ROOM_MESSAGE).toString().slice(0, 2000),
emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : Math.max(5, timeout)
};
}
@ -451,6 +505,7 @@ function parseConfigFromForm(body) {
const lobbyChannelIds = toArray(body.lobby_channel_id);
const categoryIds = toArray(body.lobby_category_id);
const templates = toArray(body.lobby_name_template);
const roomMessages = toArray(body.lobby_room_message);
const timeouts = toArray(body.lobby_empty_timeout);
const removeIds = new Set(toArray(body.lobby_remove));
@ -461,6 +516,7 @@ function parseConfigFromForm(body) {
lobbyChannelId: lobbyChannelIds[index] || "",
categoryId: categoryIds[index] || "",
nameTemplate: templates[index] || DEFAULT_TEMPLATE,
roomMessage: roomMessages[index] ?? DEFAULT_ROOM_MESSAGE,
emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : timeout
});
});
@ -534,6 +590,7 @@ function isBanned(db, discordUserId) {
}
async function bootstrapRooms(discordClient, db, state, settings) {
const config = getConfig(db);
const rooms = db
.prepare("SELECT * FROM auto_vc_rooms")
.all();
@ -554,7 +611,9 @@ async function bootstrapRooms(discordClient, db, state, settings) {
removeRoom(db, state, room.channel_id);
continue;
}
state.rooms.set(room.channel_id, normalizeRoom(room));
const normalized = normalizeRoom(room);
state.rooms.set(room.channel_id, normalized);
await syncOwnerAbsence(channel, normalized, db, state, config);
}
}
@ -571,6 +630,8 @@ function normalizeRoom(room) {
locked: Boolean(room.locked),
allowed_user_ids: parseJsonArray(room.allowed_user_ids),
base_overwrites: room.base_overwrites,
control_message_id: room.control_message_id || null,
owner_absent_since: Number(room.owner_absent_since) || null,
created_at: room.created_at
};
}
@ -593,6 +654,9 @@ function handleVoiceStateUpdate(oldState, newState, db, settings, state) {
const channel = oldState.guild.channels.cache.get(oldState.channelId);
if (channel) {
scheduleCleanup(channel, room, db, state, config);
syncOwnerAbsence(channel, room, db, state, config).catch((error) => {
logger.warn("Auto VC owner absence update failed", error, { event: "owner_absence_sync_failed" });
});
}
}
}
@ -602,6 +666,12 @@ function handleVoiceStateUpdate(oldState, newState, db, settings, state) {
if (room) {
clearCleanupTimer(state, newState.channelId);
clearEmpty(state, newState.channelId);
const channel = newState.guild.channels.cache.get(newState.channelId);
if (channel) {
syncOwnerAbsence(channel, room, db, state, config).catch((error) => {
logger.warn("Auto VC owner absence update failed", error, { event: "owner_absence_sync_failed" });
});
}
}
}
}
@ -660,11 +730,14 @@ async function createRoomFromLobby(newState, lobby, db, settings, state, config)
locked: false,
allowed_user_ids: [],
base_overwrites: JSON.stringify(baseOverwrites),
control_message_id: null,
owner_absent_since: null,
created_at: Date.now()
};
saveRoom(db, room);
state.rooms.set(channel.id, normalizeRoom(room));
const normalizedRoom = normalizeRoom(room);
state.rooms.set(channel.id, normalizedRoom);
incrementUserStat(db, profile.id);
const moved = await moveMemberToChannel(member, channel);
@ -674,6 +747,83 @@ async function createRoomFromLobby(newState, lobby, db, settings, state, config)
"I couldn't move you to the new VC. Please make sure the bot has Move Members permission."
);
}
await createRoomControlMessage(channel, normalizedRoom, lobby, member, db, state);
}
function registerPlaceholderSupport(placeholders) {
if (!placeholders?.registerFieldPolicy || !placeholders?.registerPlaceholders) return;
placeholders.registerFieldPolicy({
field_id: ROOM_MESSAGE_FIELD_ID,
label: "Auto VC room message",
field_type: "chat_message",
output_audience: "public",
min_editor_role: "admin",
allowed_namespaces: ["plugins.auto_vc.channel"],
max_sensitivity: "public_safe"
});
placeholders.registerPlaceholders([{
id: "plugins.auto_vc.channel.owner_username",
namespace: "plugins.auto_vc.channel",
label: "Room owner username",
description: "The display name of the member who created the Auto VC.",
value_type: "string",
sensitivity: "public_safe",
min_editor_role: "admin",
min_viewer_role: "public",
allowed_field_types: ["chat_message"],
plugin_id: PLUGIN_ID,
resolver: ({ runtimeContext }) => runtimeContext?.autoVc?.owner_username || ""
}]);
}
async function renderRoomMessage(lobby, member, placeholders) {
const ownerUsername = member?.displayName || member?.user?.username || "Room owner";
const template = String(lobby?.roomMessage || DEFAULT_ROOM_MESSAGE).slice(0, 2000);
if (!placeholders?.renderTemplate) {
return template.replace(/\{\{\s*plugins\.auto_vc\.channel\.owner_username\s*\}\}/gi, ownerUsername).slice(0, 2000);
}
const result = await placeholders.renderTemplate({
fieldId: ROOM_MESSAGE_FIELD_ID,
template,
user: { isAdmin: true },
outputAudience: "public",
runtimeContext: { runtime: true, autoVc: { owner_username: ownerUsername } },
fallback: "[unavailable]"
});
if (!result.ok) {
logger.warn("Auto VC room message contained unavailable placeholders", {
error_count: result.errors?.length || 0
}, { event: "room_message_placeholder_failed" });
}
return String(result.rendered || DEFAULT_ROOM_MESSAGE).trim().slice(0, 2000);
}
async function createRoomControlMessage(channel, room, lobby, member, db, state) {
if (typeof channel?.send !== "function") {
logger.warn("Created Auto VC does not support room messages", {
channel_id: channel?.id || null
}, { event: "room_controls_unavailable" });
return null;
}
let message = null;
try {
const content = await renderRoomMessage(lobby, member, state.placeholders);
message = await channel.send(content || DEFAULT_ROOM_MESSAGE);
await message.react(LOCK_EMOJI);
await message.react(UNLOCK_EMOJI);
room.control_message_id = message.id;
room.owner_absent_since = null;
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
return message;
} catch (error) {
if (message) await message.delete().catch(() => null);
logger.warn("Failed to create Auto VC room controls", error, {
event: "room_controls_create_failed",
channel_id: channel?.id || null
});
return null;
}
}
function buildRoomName(template, member, roomNumber, gameName) {
@ -762,9 +912,9 @@ function extractOverwrites(channel) {
function saveRoom(db, room) {
db.prepare(
"INSERT INTO auto_vc_rooms (channel_id, guild_id, lobby_id, category_id, owner_discord_id, owner_user_id, room_number, name_template, locked, allowed_user_ids, base_overwrites, created_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(channel_id) DO UPDATE SET owner_discord_id = excluded.owner_discord_id, owner_user_id = excluded.owner_user_id, room_number = excluded.room_number, name_template = excluded.name_template, locked = excluded.locked, allowed_user_ids = excluded.allowed_user_ids, base_overwrites = excluded.base_overwrites"
"INSERT INTO auto_vc_rooms (channel_id, guild_id, lobby_id, category_id, owner_discord_id, owner_user_id, room_number, name_template, locked, allowed_user_ids, base_overwrites, control_message_id, owner_absent_since, created_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(channel_id) DO UPDATE SET owner_discord_id = excluded.owner_discord_id, owner_user_id = excluded.owner_user_id, room_number = excluded.room_number, name_template = excluded.name_template, locked = excluded.locked, allowed_user_ids = excluded.allowed_user_ids, base_overwrites = excluded.base_overwrites, control_message_id = excluded.control_message_id, owner_absent_since = excluded.owner_absent_since"
).run(
room.channel_id,
room.guild_id,
@ -777,13 +927,15 @@ function saveRoom(db, room) {
room.locked ? 1 : 0,
JSON.stringify(room.allowed_user_ids || []),
room.base_overwrites,
room.control_message_id || null,
room.owner_absent_since || null,
room.created_at
);
}
function updateRoom(db, room) {
db.prepare(
"UPDATE auto_vc_rooms SET owner_discord_id = ?, owner_user_id = ?, room_number = ?, name_template = ?, locked = ?, allowed_user_ids = ? WHERE channel_id = ?"
"UPDATE auto_vc_rooms SET owner_discord_id = ?, owner_user_id = ?, room_number = ?, name_template = ?, locked = ?, allowed_user_ids = ?, control_message_id = ?, owner_absent_since = ? WHERE channel_id = ?"
).run(
room.owner_discord_id,
room.owner_user_id,
@ -791,6 +943,8 @@ function updateRoom(db, room) {
room.name_template,
room.locked ? 1 : 0,
JSON.stringify(room.allowed_user_ids || []),
room.control_message_id || null,
room.owner_absent_since || null,
room.channel_id
);
}
@ -809,6 +963,7 @@ function getRoomById(db, channelId, state) {
function removeRoom(db, state, channelId) {
clearCleanupTimer(state, channelId);
clearClaimTimer(state, channelId);
clearEmpty(state, channelId);
state.rooms.delete(channelId);
db.prepare("DELETE FROM auto_vc_rooms WHERE channel_id = ?").run(channelId);
@ -922,6 +1077,168 @@ function getLobbyTimeout(config, lobbyId) {
return lobby ? lobby.emptyTimeoutSeconds : DEFAULT_TIMEOUT;
}
function clearClaimTimer(state, channelId) {
const timer = state.claimTimers.get(channelId);
if (timer) {
clearTimeout(timer);
state.claimTimers.delete(channelId);
}
}
function hasClaimCandidate(channel, room) {
return [...(channel?.members?.values?.() || [])].some((member) =>
!member?.user?.bot && member.id !== room.owner_discord_id
);
}
async function fetchControlMessage(channel, room) {
if (!room.control_message_id || typeof channel?.messages?.fetch !== "function") return null;
return channel.messages.fetch(room.control_message_id).catch(() => null);
}
async function setClaimReaction(channel, room, available) {
const message = await fetchControlMessage(channel, room);
if (!message) return;
const reaction = message.reactions?.cache?.find((entry) => entry.emoji?.name === CLAIM_EMOJI);
if (available) {
if (!reaction?.me) await message.react(CLAIM_EMOJI);
return;
}
if (reaction?.me && message.client?.user?.id) {
await reaction.users.remove(message.client.user.id).catch(() => null);
}
}
async function syncOwnerAbsence(channel, room, db, state, config) {
clearClaimTimer(state, channel.id);
const ownerPresent = Boolean(channel.members?.has(room.owner_discord_id));
const claimCandidatePresent = hasClaimCandidate(channel, room);
if (ownerPresent || !claimCandidatePresent) {
if (room.owner_absent_since) {
room.owner_absent_since = null;
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
}
await setClaimReaction(channel, room, false);
return;
}
if (!room.owner_absent_since) {
room.owner_absent_since = Date.now();
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
}
const timeoutMs = getLobbyTimeout(config, room.lobby_id) * 1000;
const remaining = timeoutMs - (Date.now() - room.owner_absent_since);
if (remaining <= 0) {
await setClaimReaction(channel, room, true);
return;
}
await setClaimReaction(channel, room, false);
const timer = setTimeout(() => {
state.claimTimers.delete(channel.id);
const refreshedRoom = state.rooms.get(channel.id) || getRoomById(db, channel.id, state);
const refreshedChannel = channel.guild?.channels?.cache?.get(channel.id);
if (!refreshedRoom || !refreshedChannel) return;
syncOwnerAbsence(refreshedChannel, refreshedRoom, db, state, getConfig(db)).catch((error) => {
logger.warn("Auto VC claim reaction update failed", error, { event: "claim_reaction_update_failed" });
});
}, remaining);
timer.unref?.();
state.claimTimers.set(channel.id, timer);
}
function queueControlReaction(reaction, user, db, settings, state) {
if (!reaction?.message?.id || user?.bot) return;
const key = reaction.message.id;
const previous = state.reactionActions.get(key) || Promise.resolve();
const current = previous
.catch(() => null)
.then(() => handleControlReaction(reaction, user, db, settings, state))
.catch((error) => {
logger.warn("Auto VC reaction action failed", error, { event: "room_reaction_failed" });
})
.finally(() => {
if (state.reactionActions.get(key) === current) state.reactionActions.delete(key);
});
state.reactionActions.set(key, current);
}
async function handleControlReaction(reaction, user, db, settings, state) {
if (reaction.partial && typeof reaction.fetch === "function") {
reaction = await reaction.fetch().catch(() => null);
}
if (!reaction?.message) return;
let message = reaction.message;
if (message.partial && typeof message.fetch === "function") {
message = await message.fetch().catch(() => null);
}
const emoji = reaction.emoji?.name;
if (!message || ![LOCK_EMOJI, UNLOCK_EMOJI, CLAIM_EMOJI].includes(emoji)) return;
const channel = message.channel;
const room = state.rooms.get(channel?.id) || getRoomById(db, channel?.id, state);
if (!room || room.control_message_id !== message.id) return;
try {
const member = await message.guild?.members?.fetch(user.id).catch(() => null);
let result;
if (!member) {
result = { ok: false, message: "Could not verify that member." };
} else {
const rateLimit = consumeRateLimit(state, getConfig(db), "action", member.id);
if (!rateLimit.ok) {
result = { ok: false, message: `Try again in ${formatCooldown(rateLimit.retryAfter)}.` };
} else if (emoji === LOCK_EMOJI || emoji === UNLOCK_EMOJI) {
if (member.id !== room.owner_discord_id) {
result = { ok: false, message: "Only the room owner can use that control." };
} else {
const locked = emoji === LOCK_EMOJI;
const applied = await setRoomLocked(channel, room, locked, db, settings, state);
result = applied
? { ok: true, message: locked ? "Room locked." : "Room unlocked." }
: { ok: false, message: "Discord did not allow the room permission change." };
}
} else {
const eligibility = claimEligibility(channel, room, member, getConfig(db), Date.now());
if (!eligibility.ok) {
result = eligibility;
} else {
const claimed = await claimRoomOwnership(channel, room, member, db, settings, state);
if (claimed) {
await syncOwnerAbsence(channel, room, db, state, getConfig(db));
result = { ok: true, message: `${member.displayName} now owns this room.` };
} else {
result = { ok: false, message: "Discord did not allow the ownership change." };
}
}
}
}
await sendTemporaryReport(channel, `${result.ok ? "✓" : "Couldnt do that:"} ${result.message}`);
} finally {
await reaction.users?.remove(user.id).catch(() => null);
}
}
function claimEligibility(channel, room, member, config, now = Date.now()) {
if (member.id === room.owner_discord_id) return { ok: false, message: "You already own this room." };
if (!channel.members?.has(member.id)) return { ok: false, message: "Join this voice room before claiming it." };
if (channel.members?.has(room.owner_discord_id)) return { ok: false, message: "The current owner is still here." };
const absentSince = Number(room.owner_absent_since) || now;
const remaining = getLobbyTimeout(config, room.lobby_id) * 1000 - (now - absentSince);
if (remaining > 0) return { ok: false, message: `The room can be claimed in ${formatCooldown(Math.ceil(remaining / 1000))}.` };
return { ok: true };
}
async function sendTemporaryReport(channel, content) {
if (typeof channel?.send !== "function") return;
const report = await channel.send(String(content).slice(0, 500)).catch(() => null);
if (!report) return;
const timer = setTimeout(() => {
report.delete().catch(() => null);
}, REPORT_LIFETIME_MS);
timer.unref?.();
}
async function deleteChannel(channel) {
try {
await channel.delete("Auto VC cleanup");
@ -1064,13 +1381,12 @@ async function handleLock(message, room, db, settings, state, config) {
if (!enforceActionRateLimit(message, state, config)) {
return;
}
room.locked = !room.locked;
await applyRoomPermissions(channel, room, db, settings);
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
message
.reply(room.locked ? "Room locked." : "Room unlocked.")
.catch(() => null);
if (room.locked) {
message.reply("Room is already locked.").catch(() => null);
return;
}
const applied = await setRoomLocked(channel, room, true, db, settings, state);
message.reply(applied ? "Room locked." : "I couldn't update this room's permissions.").catch(() => null);
}
async function handleUnlock(message, room, db, settings, state, config) {
@ -1086,11 +1402,20 @@ async function handleUnlock(message, room, db, settings, state, config) {
message.reply("Room is already unlocked.").catch(() => null);
return;
}
room.locked = false;
await applyRoomPermissions(channel, room, db, settings);
const applied = await setRoomLocked(channel, room, false, db, settings, state);
message.reply(applied ? "Room unlocked." : "I couldn't update this room's permissions.").catch(() => null);
}
async function setRoomLocked(channel, room, locked, db, settings, state) {
const previous = room.locked;
room.locked = Boolean(locked);
if (!await applyRoomPermissions(channel, room, db, settings)) {
room.locked = previous;
return false;
}
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
message.reply("Room unlocked.").catch(() => null);
return true;
}
async function handleAllow(message, room, args, db, settings, state, config) {
@ -1168,12 +1493,15 @@ async function handleTransfer(message, room, args, db, settings, state, config)
displayName: target.displayName
});
room.owner_user_id = profile.id;
room.owner_absent_since = null;
const channel = message.guild.channels.cache.get(room.channel_id);
if (channel) {
await applyRoomPermissions(channel, room, db, settings);
}
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
clearClaimTimer(state, room.channel_id);
if (channel) await setClaimReaction(channel, room, false);
message.reply(`Ownership transferred to ${target.displayName}.`).catch(() => null);
}
@ -1190,6 +1518,14 @@ async function handleClaim(message, room, member, db, settings, state, config) {
if (!enforceActionRateLimit(message, state, config)) {
return;
}
const claimed = await claimRoomOwnership(channel, room, member, db, settings, state);
message.reply(claimed ? "You are now the owner of this room." : "I couldn't update this room's permissions.").catch(() => null);
}
async function claimRoomOwnership(channel, room, member, db, settings, state) {
const previousOwnerDiscordId = room.owner_discord_id;
const previousOwnerUserId = room.owner_user_id;
const previousOwnerAbsentSince = room.owner_absent_since;
room.owner_discord_id = member.id;
const profile = ensureUserForIdentity({
provider: "discord",
@ -1197,10 +1533,18 @@ async function handleClaim(message, room, member, db, settings, state, config) {
displayName: member.displayName
});
room.owner_user_id = profile.id;
await applyRoomPermissions(channel, room, db, settings);
room.owner_absent_since = null;
if (!await applyRoomPermissions(channel, room, db, settings)) {
room.owner_discord_id = previousOwnerDiscordId;
room.owner_user_id = previousOwnerUserId;
room.owner_absent_since = previousOwnerAbsentSince;
return false;
}
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
message.reply("You are now the owner of this room.").catch(() => null);
clearClaimTimer(state, room.channel_id);
await setClaimReaction(channel, room, false);
return true;
}
async function applyRoomPermissions(channel, room, db, settings) {
@ -1249,7 +1593,16 @@ async function applyRoomPermissions(channel, room, db, settings) {
}
}
await channel.permissionOverwrites.set(Array.from(overrides.values())).catch(() => null);
try {
await channel.permissionOverwrites.set(Array.from(overrides.values()));
return true;
} catch (error) {
logger.warn("Failed to update Auto VC permissions", error, {
event: "room_permissions_update_failed",
channel_id: channel?.id || null
});
return false;
}
}
function checkIsModerator(member, settings) {
@ -1351,6 +1704,21 @@ function cryptoRandomId() {
return require("crypto").randomUUID();
}
module.exports._test = {
CLAIM_EMOJI,
DEFAULT_ROOM_MESSAGE,
LOCK_EMOJI,
UNLOCK_EMOJI,
claimEligibility,
ensureTables,
normalizeConfig,
normalizeLobby,
parseConfigFromForm,
registerPlaceholderSupport,
renderRoomMessage,
setRoomLocked
};
async function safeNotify(member, message) {
try {
await member.send(message);
@ -1446,6 +1814,7 @@ async function sweepRooms(discordClient, db, state) {
removeRoom(db, state, room.channel_id);
continue;
}
await syncOwnerAbsence(channel, room, db, state, config);
if (channel.members.size === 0) {
markEmpty(state, channel.id);
const timeout = getLobbyTimeout(config, room.lobby_id);

View File

@ -1,11 +1,11 @@
{
"id": "auto-vc",
"name": "Auto VC",
"version": "0.1.6",
"version": "0.1.7",
"description": "Auto-create managed voice channels from lobby rooms.",
"main": "index.js",
"channel": "stable",
"compatible_from": "0.1.5",
"migration_notes": "No manual migration is required.",
"migration_notes": "Adds persisted room-control messages and owner-absence timestamps with automatic additive database migration. Existing lobbies, rooms, owners, permissions, bans, and statistics remain preserved.",
"rollback_safe": true
}

View 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;
});

View File

@ -275,6 +275,15 @@
<label>Empty room cleanup (seconds)</label>
<input name="lobby_empty_timeout" value="<%= lobby.emptyTimeoutSeconds %>" type="number" min="5" />
</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>
<% if (lobby.permissions && lobby.permissions.length) { %>
<% const totalPerms = lobby.permissions.length; %>
@ -428,6 +437,13 @@
<label>Empty room cleanup (seconds)</label>
<input name="lobby_empty_timeout" value="30" type="number" min="5" />
</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>
</template>

View File

@ -1,5 +1,11 @@
# Lumi Overlay Feed changelog
## 0.1.1
- Preserve Discord emotes and GIF media in the authenticated Companion feed.
- Preserve Twitch badge artwork and all normalized HTTPS emote sources while
retaining the existing bounded payload and server-side filtering rules.
## 0.1.0
- Add the paired-device `overlay.read.v1` live feed for the native Companion

View File

@ -51,12 +51,18 @@ function sanitizeChat(message) {
image: safeHttps(badge?.image)
}))
},
emotes: (message.platform === "twitch" && Array.isArray(message.emotes) ? message.emotes : []).slice(0, 100).map((emote) => ({
emotes: (Array.isArray(message.emotes) ? message.emotes : []).slice(0, 100).map((emote) => ({
start: Math.max(0, Number(emote?.start) || 0),
end: Math.max(0, Number(emote?.end) || 0),
label: cleanText(emote?.label, 100),
image: safeHttps(emote?.image)
})).filter((emote) => emote.image)
})).filter((emote) => emote.image),
media: (Array.isArray(message.media) ? message.media : []).slice(0, 4).map((media) => ({
url: safeHttps(media?.url),
type: media?.type === "video" ? "video" : "image",
alt: cleanText(media?.alt, 160),
preview: safeHttps(media?.preview)
})).filter((media) => media.url)
};
}

View File

@ -1,7 +1,7 @@
{
"id": "lumi_overlay",
"name": "Lumi Overlay Feed",
"version": "0.1.0",
"version": "0.1.1",
"description": "Authenticated live chat and event feed for the native Lumi Companion monitor overlay.",
"main": "index.js",
"channel": "stable",

View File

@ -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.author.avatar, null);
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" } });
assert.equal(event.payload.access_token, undefined);
assert.equal(event.payload.user_name, "Viewer");

View File

@ -1,5 +1,12 @@
# Lumi Transcription changelog
## 0.2.7
- Release Companion 0.2.7 with animated native overlay emotes, Discord GIF
media, and Twitch badge artwork.
- Retain the existing OBS browser overlay renderer, transcription behavior,
OBS Bridge 0.2.5, paired identity, and automatic update compatibility.
## 0.2.6
- Release Companion 0.2.6 with the native Lumi Overlay and shell-owned authenticated live feed.

View File

@ -1,17 +1,17 @@
{
"schema_version": 1,
"version": "0.2.6",
"version": "0.2.7",
"signed": false,
"release_notes": "Adds the native Lumi Overlay, draft-safe settings, clearer visibility and layout controls, and direct navigation for single-page plugins while retaining transcription, Stream Testing, Song Overlay, silent reconnect, and OBS Bridge 0.2.5.",
"release_notes": "Restores animated Twitch, BetterTTV, and Discord emotes, Discord GIF media, and Twitch badge artwork in the native Lumi Overlay while retaining the existing OBS browser renderer, transcription, Stream Testing, Song Overlay, silent reconnect, and OBS Bridge 0.2.5.",
"installer": {
"id": "windows-x64-installer",
"platform": "win32",
"architecture": "x64",
"label": "Windows x64 per-user installer",
"filename": "Lumi.Companion-Setup.exe",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.6/Lumi.Companion-Setup.exe",
"sha256": "0420cdee45d676ded1566fd8da2e25482eca258b1d7d200544e5425d22105bb8",
"bytes": 51922633
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.7/Lumi.Companion-Setup.exe",
"sha256": "b23686d0e8ecd0fbc89034f9dc6a37350bee17212d60f385eeaf2e4de634fab6",
"bytes": 51930364
},
"artifacts": [
{
@ -20,9 +20,9 @@
"architecture": "x64",
"label": "Windows x64 self-contained",
"filename": "Lumi.Companion-win-x64.zip",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.6/Lumi.Companion-win-x64.zip",
"sha256": "1e216f9653d0dc0513ca119f0a8c5cf43bf47328a83cbbf183e1d4b14c111e94",
"bytes": 66857759,
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.7/Lumi.Companion-win-x64.zip",
"sha256": "c36983e1f83a9f278475db31819be0acaa6af72d5d03e1a1c5d3ba62b556e81c",
"bytes": 66860208,
"entrypoint": "Lumi.Companion.App.exe"
}
]

View File

@ -1,7 +1,7 @@
{
"id": "lumi_transcription",
"name": "Lumi Transcription",
"version": "0.2.6",
"version": "0.2.7",
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
"main": "index.js",
"channel": "stable",

View File

@ -2,6 +2,39 @@
"schema_version": 1,
"channel": "stable",
"releases": [
{
"version": "0.3.9",
"ref": "refs/tags/v0.3.9",
"released_at": "2026-07-26",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Restores animated emotes, Twitch badge artwork, and Discord GIF media through the existing OBS and native Companion overlay paths; adds persistent Auto VC welcome controls and timed ownership claiming; and releases Companion 0.2.7. Existing settings, lobbies, rooms, owners, permissions, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved.",
"plugins": {
"auto-vc": "0.1.7",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.5",
"lumi_overlay": "0.1.1",
"lumi_transcription": "0.2.7",
"moderation": "0.1.5",
"now_playing": "0.1.3",
"okf": "0.1.2",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{
"version": "0.3.8",
"ref": "refs/tags/v0.3.8",

View File

@ -24,6 +24,7 @@ const checks = [
"plugins/lumi_ai_web_search/tests/verify.js",
"plugins/lumi_transcription/tests/verify.js",
"plugins/lumi_overlay/tests/verify.js",
"plugins/auto-vc/tests/verify.js",
"scripts/verify-assistant-panels.js",
"scripts/verify-command-preview-confirmations.js",
"scripts/verify-command-policies.js",

View File

@ -273,6 +273,18 @@ try {
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.media[0].url, "https://cdn.example/dance.gif");
const gifvContent = discordOverlayContent({
content: "https://tenor.example/view",
attachments: [],
stickers: [],
embeds: [{
type: "gifv",
url: "https://tenor.example/view",
video: { url: "https://media.example/animation.mp4" },
thumbnail: { url: "https://media.example/animation-preview.gif" }
}]
});
assert.strictEqual(gifvContent.media[0].preview, "https://media.example/animation-preview.gif");
const twitchChatAssets = require(path.join(serviceDir, "twitch-chat-assets.js"));
const betterTtvCatalog = twitchChatAssets.indexBetterTtvCatalog([
{ id: "emote-kekw", code: "KEKW", imageType: "webp", animated: false },

View File

@ -4,14 +4,15 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.3.8";
const previousStableVersion = "0.3.7";
const priorStableVersion = "0.3.6";
const releaseVersion = "0.3.9";
const previousStableVersion = "0.3.8";
const priorStableVersion = "0.3.7";
const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = {
lumi_overlay: { version: "0.1.0", knowledge: "lumi-overlay" },
lumi_transcription: { version: "0.2.6", knowledge: "lumi-transcription" },
now_playing: { version: "0.1.3", knowledge: "now-playing" }
"auto-vc": { version: "0.1.7", knowledge: "auto-vc", compatibleFrom: "0.1.5" },
lumi_overlay: { version: "0.1.1", knowledge: "lumi-overlay", compatibleFrom: "0.1.0" },
lumi_transcription: { version: "0.2.7", knowledge: "lumi-transcription", compatibleFrom: "0.1.0" },
now_playing: { version: "0.1.3", knowledge: "now-playing", compatibleFrom: "0.1.0" }
};
function readJson(relativePath) {
@ -62,7 +63,7 @@ assert.equal(safeCoreTarget.target?.version, releaseVersion);
for (const [pluginId, expected] of Object.entries(introducedPlugins)) {
const manifest = readJson(`plugins/${pluginId}/plugin.json`);
assert.equal(manifest.version, expected.version, `${pluginId} manifest version`);
assert.equal(manifest.compatible_from, "0.1.0", `${pluginId} compatible_from`);
assert.equal(manifest.compatible_from, expected.compatibleFrom, `${pluginId} compatible_from`);
assert.equal(manifest.channel, "stable", `${pluginId} release channel`);
assert.equal(manifest.rollback_safe, true, `${pluginId} rollback metadata`);
assert.equal(
@ -83,4 +84,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: stable core 0.3.8 after 0.3.7 with synchronized Companion plugin metadata.");
console.log("Release metadata verification passed: stable core 0.3.9 after 0.3.8 with synchronized Companion and plugin metadata.");

View File

@ -24,7 +24,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.3.8", "0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.deepEqual(releaseVersions, ["0.3.9", "0.3.8", "0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
const coreManifest = readJson("update-manifest.json");
assert.equal(packageVersion, coreManifest.version);
assert.equal(coreManifest.channel, "stable");
assert.equal(packageVersion, "0.3.8");
assert.equal(currentRelease.version, "0.3.8");
assert.equal(packageVersion, "0.3.9");
assert.equal(currentRelease.version, "0.3.9");
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
available_versions: [
{ version: "0.3.9", ref: "refs/tags/v0.3.9", rollback_safe: true },
{ version: "0.3.8", ref: "refs/tags/v0.3.8", rollback_safe: true },
{ version: "0.3.7", ref: "refs/tags/v0.3.7", rollback_safe: true },
{ version: "0.3.6", ref: "refs/tags/v0.3.6", rollback_safe: true },
@ -157,7 +158,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.3.8");
assert.equal(corrected.safe_target_version, "0.3.9");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);

View File

@ -10,11 +10,16 @@ function discordOverlayContent(message) {
const media = [];
const mediaUrls = new Set();
const removedLinks = new Set();
const addMedia = (url, type = "image", alt = "Animated image", sourceUrl = "") => {
const addMedia = (url, type = "image", alt = "Animated image", sourceUrl = "", preview = "") => {
const clean = String(url || "").trim();
if (!/^https?:\/\//i.test(clean) || mediaUrls.has(clean) || media.length >= 4) return;
mediaUrls.add(clean);
media.push({ url: clean, type: type === "video" ? "video" : "image", alt });
media.push({
url: clean,
type: type === "video" ? "video" : "image",
alt,
preview: /^https?:\/\//i.test(String(preview || "").trim()) ? String(preview).trim() : null
});
if (sourceUrl && text.includes(sourceUrl)) removedLinks.add(sourceUrl);
};
@ -31,7 +36,7 @@ function discordOverlayContent(message) {
if (embedType !== "gifv" && !/\.(?:gif|gifv)(?:$|\?)/i.test(sourceUrl)) continue;
const videoUrl = embed?.video?.url || embed?.video?.proxyURL || embed?.video?.proxy_url;
const imageUrl = embed?.image?.url || embed?.image?.proxyURL || embed?.image?.proxy_url || embed?.thumbnail?.url || embed?.thumbnail?.proxyURL || embed?.thumbnail?.proxy_url;
if (videoUrl) addMedia(videoUrl, "video", embed?.title || "Animated image", sourceUrl);
if (videoUrl) addMedia(videoUrl, "video", embed?.title || "Animated image", sourceUrl, imageUrl);
else addMedia(imageUrl || sourceUrl, "image", embed?.title || "Animated image", sourceUrl);
}
for (const sticker of collectionValues(message?.stickers)) {

View File

@ -27,6 +27,7 @@ async function startBot({ commandRouter } = {}) {
resolveIntent("GuildMessages", "GUILD_MESSAGES"),
resolveIntent("GuildMembers", "GUILD_MEMBERS"),
resolveIntent("MessageContent", "MESSAGE_CONTENT"),
resolveIntent("GuildMessageReactions", "GUILD_MESSAGE_REACTIONS"),
resolveIntent("GuildVoiceStates", "GUILD_VOICE_STATES"),
resolveIntent("GuildPresences", "GUILD_PRESENCES")
].filter(Boolean);
@ -39,9 +40,12 @@ async function startBot({ commandRouter } = {}) {
intents,
guildMembers: Boolean(resolveIntent("GuildMembers", "GUILD_MEMBERS"))
}, { event: "platform_starting" });
if (Partials?.Channel) {
options.partials = [Partials.Channel];
}
options.partials = [
resolvePartial("Channel", "CHANNEL"),
resolvePartial("Message", "MESSAGE"),
resolvePartial("Reaction", "REACTION"),
resolvePartial("User", "USER")
].filter((value, index, values) => value !== null && values.indexOf(value) === index);
client = new Client(options);
@ -172,6 +176,11 @@ function resolveIntent(key, legacyKey) {
return null;
}
function resolvePartial(key, legacyKey) {
if (Partials?.[key] !== undefined) return Partials[key];
return legacyKey;
}
async function stopBot() {
if (client) {
await client.destroy();

View File

@ -53,7 +53,8 @@ function normalizeMedia(media) {
return {
url,
type: media?.type === "video" ? "video" : "image",
alt: cleanText(media?.alt || "Animated image", 160)
alt: cleanText(media?.alt || "Animated image", 160),
preview: cleanUrl(media?.preview || media?.thumbnail)
};
}

View File

@ -279,6 +279,7 @@
media.loop = true;
media.muted = true;
media.playsInline = true;
if (mediaData.preview) media.poster = mediaData.preview;
} else media.alt = mediaData.alt || "Animated image";
media.addEventListener("error", () => media.remove(), { once: true });
mediaRow.appendChild(media);

View File

@ -1,6 +1,6 @@
{
"name": "Lumi Core",
"version": "0.3.8",
"version": "0.3.9",
"channel": "stable",
"released_at": "2026-07-26",
"compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [
"1.2.0"
],
"migration_notes": "Adds the native Windows Lumi Overlay through the existing paired-device and provider pipelines, preserves active and revoked device semantics, and releases Companion 0.2.6 with draft-safe settings, direct single-page plugin navigation, and clearer overlay controls. Existing settings, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved.",
"migration_notes": "Restores animated emotes, Twitch badge artwork, and Discord GIF media through the existing OBS and native Companion overlay paths; adds persistent Auto VC welcome controls and timed ownership claiming; and releases Companion 0.2.7. Existing settings, lobbies, rooms, owners, permissions, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved.",
"rollback_safe": true,
"requirements": [
"Node.js 18 or newer"
@ -445,6 +445,18 @@
],
"rollback_safe": true,
"migration_notes": "Moves production Stream Testing TLS termination to Nginx Proxy Manager while preserving RTMPS Companion destinations, plain local MediaMTX ingest, localhost development, session credentials, and all existing operator data. Adds DB-backed public and local ports plus external TLS and RTMP route validation, and removes unused Lumi ACME and DNS automation without deleting stored data."
},
{
"version": "0.3.8",
"channel": "stable",
"released_at": "2026-07-26",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Adds the native Windows Lumi Overlay through the existing paired-device and provider pipelines, preserves active and revoked device semantics, and releases Companion 0.2.6 with draft-safe settings, direct single-page plugin navigation, and clearer overlay controls. Existing settings, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved."
}
]
}