release: publish Lumi 0.3.10 platform tenure

This commit is contained in:
Franz Rolfsvaag 2026-07-27 09:42:58 +02:00
parent 5c85ba400d
commit 0fb0089e1c
29 changed files with 1381 additions and 51 deletions

View File

@ -1,5 +1,13 @@
# Lumi changelog # Lumi changelog
## 0.3.10
- Added an extensible UTC interval-history engine for Twitch follows, subscriptions, moderators, editors, and VIPs; YouTube channel memberships and moderators; and Discord membership and server boosts.
- Added idempotent live-event handling, complete-snapshot reconciliation that never treats API failures as empty state, authoritative timestamp backfill for open intervals, and preserved history across restarts and account changes.
- Added contextual `current` and `total` age placeholders with deterministic years/months, selected-unit remainder flow, singular/plural output, and the shared embedded `{format.YY-MM-DD-HH-mm-ss}` grammar.
- Added Platform tenure summaries to Stats and scope-separated Platform tenure boards to Leaderboards, plus admin interval diagnostics with source, precision, and authoritative/first-observed provenance.
- Preserved all existing settings, identities, statistics, interval history, plugins, pairing records, databases, uploads, models, and secrets through additive migrations and the normal updater.
## 0.3.9 ## 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. - 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.

View File

@ -11,6 +11,10 @@ The frontend must not declare placeholder permissions, allowed plugins, or
sensitivity. Editable fields reference a trusted `field_id`, and the server sensitivity. Editable fields reference a trusted `field_id`, and the server
uses that field policy to decide which placeholders are available. uses that field policy to decide which placeholders are available.
Platform membership and role duration placeholders, including their embedded
`{format.YY-MM-DD}` grammar, are documented in
[platform-tenure.md](platform-tenure.md).
Core and plugins can register: Core and plugins can register:
- placeholder definitions with metadata and a resolver function - placeholder definitions with metadata and a resolver function

44
docs/platform-tenure.md Normal file
View File

@ -0,0 +1,44 @@
# Platform tenure
Lumi records platform membership and role periods as immutable UTC intervals. The same history powers custom-command placeholders, the **Platform tenure** cards on `/stats`, the **Platform tenure** section on `/leaderboards`, and the admin diagnostics page at `/admin/platform-tenure`.
## Supported statistics
| Platform | Statistics | Start timestamp |
| --- | --- | --- |
| Twitch | Follow, subscription, moderator, editor, VIP | Platform timestamp where Twitch supplies one; otherwise first reliable observation |
| YouTube | Paid channel membership and live-chat moderator | Membership event time where available; otherwise first reliable live-chat observation |
| Discord | Server membership and server boost | Discord `joinedTimestamp` and `premiumSinceTimestamp` where available |
YouTube does not expose a reliable timestamp for a viewer's ordinary public channel subscription through the existing live-chat integration. `youtube.user.subscriber_age` therefore represents paid channel membership (sponsor/member state), not a fabricated public-subscription age. YouTube roles are observed only when the live-chat API reports them.
Temporary API failures do not close intervals. Lumi closes missing states only after a complete successful platform snapshot or a reliable end event. Unlinking a platform identity does not delete interval history.
## Placeholders
The current uninterrupted interval and cumulative recorded history are exposed as `current` and `total`:
```text
{{twitch.user.follow_age.current}}
{{twitch.user.subscriber_age.total}}
{{twitch.user.mod_age.current}}
{{twitch.user.editor_age.total}}
{{twitch.user.vip_age.current}}
{{youtube.user.subscriber_age.current}}
{{youtube.user.mod_age.total}}
{{discord.user.member_age.current}}
{{discord.user.nitro_age.total}}
```
The current command context selects the platform user and channel or guild scope. Lumi never combines values across Twitch channels, YouTube channels, or Discord servers.
Append an embedded format to select duration units:
```text
{{twitch.user.follow_age.total}.{format.YY-MM-DD-HH-mm-ss}}
{{discord.user.member_age.current}.{format.DD-HH-mm}}
```
Units are case-sensitive: `Y`, `YY`, or `YYYY` means years; `M` or `MM` months; `D` or `DD` days; `H` or `HH` hours; `m` or `mm` minutes; and `s` or `ss` seconds. Repetition identifies the unit and does not add zero-padding. Units must be ordered largest to smallest and may appear once.
Lumi uses deterministic cumulative-duration units: one year is 365 days and one month is 30 days. Omitted time flows into the next smaller selected unit, so a format containing only `mm` returns total complete minutes. Time below the smallest selected unit is truncated. Zero units are omitted; an all-zero result is empty.

View File

@ -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.9 Version: 0.3.10
## Routes ## Routes
- POST /api/diagnostics/v1/run - POST /api/diagnostics/v1/run
- GET /api/events - GET /api/events

4
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.9", "version": "0.3.10",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
@ -26,7 +26,8 @@
"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: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",
"verify:user-age": "node scripts/verify-user-age-statistics.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"

View File

@ -2,6 +2,39 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "releases": [
{
"version": "0.3.10",
"ref": "refs/tags/v0.3.10",
"released_at": "2026-07-27",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Adds reusable platform-tenure interval history for Twitch, YouTube, and Discord; contextual current and total duration placeholders with deterministic formatting; safe platform reconciliation; admin diagnostics; and Platform tenure sections on Stats and Leaderboards. Existing settings, identities, statistics, intervals, plugins, pairing records, 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.9", "version": "0.3.9",
"ref": "refs/tags/v0.3.9", "ref": "refs/tags/v0.3.9",

View File

@ -11,6 +11,7 @@ const checks = [
"scripts/verify-feedback-system.js", "scripts/verify-feedback-system.js",
"scripts/verify-logging.js", "scripts/verify-logging.js",
"scripts/verify-placeholders.js", "scripts/verify-placeholders.js",
"scripts/verify-user-age-statistics.js",
"scripts/verify-release-metadata.js", "scripts/verify-release-metadata.js",
"scripts/verify-update-system.js", "scripts/verify-update-system.js",
"scripts/verify-local-development-updates.js", "scripts/verify-local-development-updates.js",

View File

@ -4,9 +4,9 @@ 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.9"; const releaseVersion = "0.3.10";
const previousStableVersion = "0.3.8"; const previousStableVersion = "0.3.9";
const priorStableVersion = "0.3.7"; const priorStableVersion = "0.3.8";
const earliestCompatibleCoreVersion = "0.1.9"; const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = { const introducedPlugins = {
"auto-vc": { version: "0.1.7", knowledge: "auto-vc", compatibleFrom: "0.1.5" }, "auto-vc": { version: "0.1.7", knowledge: "auto-vc", compatibleFrom: "0.1.5" },
@ -84,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.9 after 0.3.8 with synchronized Companion and plugin metadata."); console.log("Release metadata verification passed: stable core 0.3.10 after 0.3.9 with synchronized Companion and plugin metadata.");

View File

@ -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.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.deepEqual(releaseVersions, ["0.3.10", "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.9"); assert.equal(packageVersion, "0.3.10");
assert.equal(currentRelease.version, "0.3.9"); assert.equal(currentRelease.version, "0.3.10");
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.10", ref: "refs/tags/v0.3.10", rollback_safe: true },
{ version: "0.3.9", ref: "refs/tags/v0.3.9", rollback_safe: true }, { 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 },
@ -158,7 +159,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.9"); assert.equal(corrected.safe_target_version, "0.3.10");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -0,0 +1,175 @@
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const Database = require("better-sqlite3");
const isolatedDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-user-age-"));
process.env.LUMI_DATA_DIR = isolatedDataDir;
const { formatDuration, parseDurationFormat } = require("../src/services/duration-format");
const { UserAgeStatistics } = require("../src/services/user-age-statistics");
const placeholders = require("../src/services/placeholders");
const coreDatabase = require("../src/services/db");
coreDatabase.migrate();
const hour = 60 * 60 * 1000;
const day = 24 * hour;
let now = 100 * day;
const database = new Database(":memory:");
database.exec(`
CREATE TABLE user_profiles (
id TEXT PRIMARY KEY,
internal_username TEXT NOT NULL
);
CREATE TABLE user_identities (
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL
);
`);
const service = new UserAgeStatistics(database, { now: () => now });
database.prepare("INSERT INTO user_profiles (id, internal_username) VALUES (?, ?)").run("profile-1", "ViewerOne");
database.prepare("INSERT INTO user_identities (user_id, provider, provider_user_id) VALUES (?, ?, ?)").run("profile-1", "twitch", "viewer-1");
const base = {
platform: "twitch",
userId: "viewer-1",
scopeId: "channel-1",
type: "follow",
source: "test-event",
authoritative: true
};
service.setState({ ...base, active: true, startAt: now - 10 * day, occurredAt: now, eventId: "follow-1" });
let rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
assert.strictEqual(rows.length, 1, "new follow should create one interval");
assert.strictEqual(rows[0].end_at, null, "new follow interval should remain open");
now += 2 * day;
service.setState({ ...base, active: false, occurredAt: now, eventId: "unfollow-1" });
rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
assert.strictEqual(rows[0].end_at, now, "unfollow should close the open interval");
const firstCompleted = { ...rows[0] };
now += day;
service.setState({ ...base, active: true, startAt: now, occurredAt: now, eventId: "follow-2" });
service.setState({ ...base, active: true, startAt: now, occurredAt: now, eventId: "follow-2" });
rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
assert.strictEqual(rows.length, 2, "refollow should create exactly one second interval");
assert.deepStrictEqual(
Object.fromEntries(Object.entries(rows.find((row) => row.id === firstCompleted.id)).filter(([key]) => !["updated_at"].includes(key))),
Object.fromEntries(Object.entries(firstCompleted).filter(([key]) => !["updated_at"].includes(key))),
"completed intervals must not be modified"
);
now += 3 * day;
let durations = service.getDurations(base);
assert.strictEqual(durations.current, 3 * day, "current should include only the open interval");
assert.strictEqual(durations.total, 15 * day, "total should combine completed and active intervals");
const leaders = service.getLeaderboard("follow", { platform: "twitch", limit: 10 });
assert.strictEqual(leaders[0].username, "ViewerOne", "leaderboards should resolve linked Lumi profiles");
assert.ok(leaders.some((entry) => entry.label.includes("channel-1")), "leaderboards should keep channel scopes visible and separate");
service.setState({ ...base, active: false, occurredAt: now, eventId: "unfollow-2" });
durations = service.getDurations(base);
assert.strictEqual(durations.current, null, "inactive state should have no current duration");
service.setState({ ...base, scopeId: "channel-2", active: true, occurredAt: now, eventId: "channel-2-follow" });
assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-1" }).current, null, "channels must remain isolated");
assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-2" }).current, 0, "second channel should have its own interval");
service.setState({
platform: "discord",
userId: "viewer-1",
scopeId: "guild-1",
type: "member",
active: true,
occurredAt: now,
eventId: "guild-1-member",
source: "test"
});
assert.strictEqual(service.getDurations({
platform: "discord",
userId: "viewer-1",
scopeId: "guild-2",
type: "member"
}).total, null, "Discord guilds must remain isolated");
const combined = 2 * 30 * day + 3 * day + 4 * hour + 47 * 60 * 1000;
assert.strictEqual(formatDuration(combined, "MM-DD-mm"), "2 months, 3 days, 287 minutes", "omitted hours should flow into minutes");
assert.strictEqual(formatDuration(3 * day + 2 * hour, "HH-mm"), "74 hours", "larger omitted units should flow into the largest selected unit");
assert.strictEqual(formatDuration(287 * 60 * 1000, "mm"), "287 minutes", "minutes-only format should contain total minutes");
assert.strictEqual(formatDuration(day + hour + 60 * 1000, "DD-HH-mm"), "1 day, 1 hour, 1 minute", "singular labels should be correct");
assert.strictEqual(formatDuration(2 * day + 2 * hour + 2 * 60 * 1000, "D-H-m"), "2 days, 2 hours, 2 minutes", "plural labels should be correct");
assert.strictEqual(formatDuration(500, "s"), "", "all-zero selected units should return an empty string");
assert.throws(() => parseDurationFormat("DD-YY"), /largest to smallest/, "out-of-order units should fail deterministically");
const restarted = new UserAgeStatistics(database, { now: () => now });
const beforeRestart = restarted.listDiagnostics({ platform: "twitch", scopeId: "channel-2", type: "follow" });
restarted.reconcileSnapshot({
platform: "twitch",
scopeId: "channel-2",
type: "follow",
activeRecords: [{ userId: "viewer-1" }],
observedAt: now,
source: "restart-test",
complete: true
});
const afterRestart = restarted.listDiagnostics({ platform: "twitch", scopeId: "channel-2", type: "follow" });
assert.strictEqual(afterRestart.length, beforeRestart.length, "restart reconciliation must not duplicate valid intervals");
assert.strictEqual(afterRestart[0].end_at, null, "restart reconciliation must not close valid intervals");
restarted.setState({
platform: "youtube",
userId: "viewer-2",
scopeId: "youtube-channel",
type: "subscriber",
active: true,
occurredAt: now,
eventId: "youtube-observed",
source: "youtube-first-observed",
authoritative: false
});
const observed = restarted.listDiagnostics({ platform: "youtube", userId: "viewer-2" })[0];
assert.strictEqual(observed.start_at, now, "unavailable historical starts must begin at first observation");
assert.strictEqual(observed.start_authoritative, 0, "first-observed timestamps must not be marked authoritative");
placeholders.registerCorePlaceholders({ userAgeStatistics: restarted });
const invalidFormat = placeholders.validateTemplate({
fieldId: "core.custom_commands.static_response",
template: "{{twitch.user.follow_age.current}.{format.YYY-DD}}",
outputAudience: "user",
runtimeContext: { runtime: true }
});
assert.strictEqual(invalidFormat.ok, false, "unsupported duration tokens should fail shared placeholder validation");
const existing = placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template: "Hello {{user.public.display_name}}",
outputAudience: "user",
runtimeContext: { runtime: true, user: { displayName: "Lumi Friend" } }
});
const formatted = placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template: "{{twitch.user.follow_age.current}.{format.DD-HH-mm}}",
outputAudience: "user",
runtimeContext: {
runtime: true,
platform: "twitch",
user: { platformId: "viewer-1" },
ctx: { meta: { tags: { "room-id": "channel-2" } } }
}
});
Promise.all([existing, formatted]).then(([existingResult, formattedResult]) => {
assert.strictEqual(existingResult.rendered, "Hello Lumi Friend", "existing placeholders must remain unchanged");
assert.strictEqual(formattedResult.rendered, "", "a zero-duration current interval should render empty");
database.close();
coreDatabase.db.close();
fs.rmSync(isolatedDataDir, { recursive: true, force: true });
console.log("User-age statistics verification passed.");
}).catch((error) => {
database.close();
coreDatabase.db.close();
fs.rmSync(isolatedDataDir, { recursive: true, force: true });
console.error(error);
process.exit(1);
});

View File

@ -28,6 +28,7 @@ const { overlayConnectorManager } = require("./services/overlay-connectors");
const { cleanupSnapshots } = require("./services/update-manager"); const { cleanupSnapshots } = require("./services/update-manager");
const { twitchEventSubManager } = require("./services/twitch-eventsub"); const { twitchEventSubManager } = require("./services/twitch-eventsub");
const { streamTestingService } = require("./services/stream-testing"); const { streamTestingService } = require("./services/stream-testing");
const { userAgeStatistics } = require("./services/user-age-statistics");
const { const {
isSafeModeRequested, isSafeModeRequested,
markStartupVerification markStartupVerification
@ -56,7 +57,8 @@ async function main() {
plugin_count: completedPluginSync.plugin_ids.length plugin_count: completedPluginSync.plugin_ids.length
}, { event: "bundled_plugin_sync_completed" }); }, { event: "bundled_plugin_sync_completed" });
} }
registerCorePlaceholders(); userAgeStatistics.start();
registerCorePlaceholders({ userAgeStatistics });
const logCleanup = logger.cleanupLogs({ const logCleanup = logger.cleanupLogs({
maxAgeDays: getSetting("log_retention_days", logger.DEFAULT_MAX_AGE_DAYS), maxAgeDays: getSetting("log_retention_days", logger.DEFAULT_MAX_AGE_DAYS),
maxEntries: getSetting("log_retention_max_entries", logger.DEFAULT_MAX_ENTRIES) maxEntries: getSetting("log_retention_max_entries", logger.DEFAULT_MAX_ENTRIES)
@ -205,6 +207,7 @@ async function main() {
const closeWebServer = new Promise((resolve) => webServer.close(resolve)); const closeWebServer = new Promise((resolve) => webServer.close(resolve));
for (const service of [ for (const service of [
{ name: "stream_testing", stop: () => streamTestingService.close() }, { name: "stream_testing", stop: () => streamTestingService.close() },
{ name: "user_age_statistics", stop: () => userAgeStatistics.stop() },
{ name: "overlay_connectors", stop: () => overlayConnectorManager.stop() }, { name: "overlay_connectors", stop: () => overlayConnectorManager.stop() },
{ name: "twitch_eventsub", stop: () => twitchEventSubManager.stop() }, { name: "twitch_eventsub", stop: () => twitchEventSubManager.stop() },
{ name: "plugins", stop: () => stopPlugins() }, { name: "plugins", stop: () => stopPlugins() },

View File

@ -92,7 +92,7 @@ function buildTwitchEventAuthUrl(state, redirectOverride) {
client_id: clientId || "", client_id: clientId || "",
redirect_uri: redirectUri || "", redirect_uri: redirectUri || "",
response_type: "code", response_type: "code",
scope: "moderator:read:followers channel:read:subscriptions bits:read channel:read:redemptions", scope: "moderator:read:followers moderation:read channel:read:subscriptions channel:read:editors channel:read:vips bits:read channel:read:redemptions",
state, state,
force_verify: "true" force_verify: "true"
}); });

View File

@ -103,6 +103,39 @@ function migrate() {
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS user_age_intervals (
id TEXT PRIMARY KEY,
platform TEXT NOT NULL,
platform_user_id TEXT NOT NULL,
scope_id TEXT NOT NULL,
statistic_type TEXT NOT NULL,
start_at INTEGER NOT NULL,
end_at INTEGER,
start_source TEXT NOT NULL,
start_precision TEXT NOT NULL DEFAULT 'millisecond',
start_authoritative INTEGER NOT NULL DEFAULT 0,
end_source TEXT,
end_precision TEXT,
end_authoritative INTEGER,
observed_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS user_age_intervals_one_open_idx
ON user_age_intervals (platform, platform_user_id, scope_id, statistic_type)
WHERE end_at IS NULL;
CREATE INDEX IF NOT EXISTS user_age_intervals_lookup_idx
ON user_age_intervals (platform, scope_id, statistic_type, platform_user_id, start_at);
CREATE TABLE IF NOT EXISTS user_age_events (
platform TEXT NOT NULL,
event_id TEXT NOT NULL,
processed_at INTEGER NOT NULL,
PRIMARY KEY (platform, event_id)
);
CREATE TABLE IF NOT EXISTS custom_pages ( CREATE TABLE IF NOT EXISTS custom_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE,

View File

@ -11,6 +11,7 @@ const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat"); const { publishOverlayChatMessage } = require("./overlay-chat");
const { discordOverlayContent } = require("./discord-chat-content"); const { discordOverlayContent } = require("./discord-chat-content");
const { emitLumiEvent } = require("./lumi-events"); const { emitLumiEvent } = require("./lumi-events");
const { userAgeStatistics } = require("./user-age-statistics");
const discordLog = createLogger("platform:discord", { category: "integration" }); const discordLog = createLogger("platform:discord", { category: "integration" });
@ -55,6 +56,11 @@ async function startBot({ commandRouter } = {}) {
if (avatarUrl) { if (avatarUrl) {
setSetting("bot_avatar_url", avatarUrl); setSetting("bot_avatar_url", avatarUrl);
} }
reconcileDiscordTenure(client).catch((error) => {
discordLog.warn("Discord platform tenure could not be reconciled", {
error
}, { event: "user_age_reconciliation_failed" });
});
}); });
client.on("messageCreate", async (message) => { client.on("messageCreate", async (message) => {
@ -76,6 +82,9 @@ async function startBot({ commandRouter } = {}) {
displayName, displayName,
avatar: avatarUrl avatar: avatarUrl
}); });
if (!message.author.bot && message.member) {
observeDiscordMember(message.member, message.createdTimestamp || Date.now(), `discord-message:${message.id}`);
}
publishOverlayChatMessage({ publishOverlayChatMessage({
id: message.id, id: message.id,
platform: "discord", platform: "discord",
@ -140,6 +149,29 @@ async function startBot({ commandRouter } = {}) {
source: "discord-gateway", source: "discord-gateway",
occurredAt: member.joinedTimestamp || Date.now() occurredAt: member.joinedTimestamp || Date.now()
}); });
if (member.premiumSinceTimestamp) {
emitDiscordBoostEvent(member, true, member.premiumSinceTimestamp);
}
});
client.on("guildMemberRemove", (member) => {
if (!member?.user || member.user.bot || !isConfiguredGuild(member.guild?.id)) return;
const now = Date.now();
emitLumiEvent("discord.member_leave", discordMemberPayload(member), {
id: `${member.guild?.id || "guild"}:${member.user.id}:leave:${now}`,
source: "discord-gateway",
occurredAt: now
});
if (member.premiumSinceTimestamp) emitDiscordBoostEvent(member, false, now);
});
client.on("guildMemberUpdate", (previous, current) => {
if (!current?.user || current.user.bot || !isConfiguredGuild(current.guild?.id)) return;
const wasBoosting = Boolean(previous?.premiumSinceTimestamp);
const isBoosting = Boolean(current.premiumSinceTimestamp);
if (wasBoosting !== isBoosting) {
emitDiscordBoostEvent(current, isBoosting, isBoosting ? current.premiumSinceTimestamp : Date.now());
}
}); });
await client.login(token); await client.login(token);
@ -176,6 +208,98 @@ function resolveIntent(key, legacyKey) {
return null; return null;
} }
function isConfiguredGuild(guildId) {
const configuredGuildId = String(getSetting("discord_guild_id", "") || "");
return !configuredGuildId || configuredGuildId === String(guildId || "");
}
function discordMemberPayload(member) {
return {
guild_id: member.guild?.id || null,
guild_name: member.guild?.name || null,
user_id: member.user?.id || member.id || null,
user_name: member.user?.globalName || member.user?.username || member.user?.tag || null,
joined_at: member.joinedTimestamp || null,
boosted_at: member.premiumSinceTimestamp || null
};
}
function emitDiscordBoostEvent(member, active, occurredAt) {
emitLumiEvent(active ? "discord.boost_start" : "discord.boost_end", discordMemberPayload(member), {
id: `${member.guild?.id || "guild"}:${member.user?.id || member.id}:boost:${active ? "start" : "end"}:${occurredAt}`,
source: "discord-gateway",
occurredAt
});
}
function observeDiscordMember(member, observedAt, eventPrefix) {
if (!member?.user || member.user.bot || !isConfiguredGuild(member.guild?.id)) return;
userAgeStatistics.setState({
platform: "discord",
userId: member.user.id,
scopeId: member.guild.id,
type: "member",
active: true,
startAt: member.joinedTimestamp || undefined,
occurredAt: observedAt,
eventId: `${eventPrefix}:member`,
source: member.joinedTimestamp ? "discord-guild-member" : "discord-first-observed",
authoritative: Boolean(member.joinedTimestamp)
});
userAgeStatistics.setState({
platform: "discord",
userId: member.user.id,
scopeId: member.guild.id,
type: "nitro",
active: Boolean(member.premiumSinceTimestamp),
startAt: member.premiumSinceTimestamp || undefined,
occurredAt: observedAt,
eventId: `${eventPrefix}:nitro`,
source: member.premiumSinceTimestamp ? "discord-premium-since" : "discord-guild-member",
authoritative: Boolean(member.premiumSinceTimestamp)
});
}
async function reconcileDiscordTenure(discordClient) {
const configuredGuildId = String(getSetting("discord_guild_id", "") || "");
const guilds = configuredGuildId
? [discordClient.guilds.cache.get(configuredGuildId)].filter(Boolean)
: Array.from(discordClient.guilds.cache.values());
for (const guild of guilds) {
const members = await guild.members.fetch();
const humans = Array.from(members.values()).filter((member) => !member.user?.bot);
const observedAt = Date.now();
userAgeStatistics.reconcileSnapshot({
platform: "discord",
scopeId: guild.id,
type: "member",
activeRecords: humans.map((member) => ({
userId: member.user.id,
startAt: member.joinedTimestamp,
source: member.joinedTimestamp ? "discord-guild-member" : "discord-first-observed",
authoritative: Boolean(member.joinedTimestamp)
})),
observedAt,
source: "discord-guild-reconciliation",
complete: true
});
userAgeStatistics.reconcileSnapshot({
platform: "discord",
scopeId: guild.id,
type: "nitro",
activeRecords: humans.filter((member) => member.premiumSinceTimestamp).map((member) => ({
userId: member.user.id,
startAt: member.premiumSinceTimestamp,
source: "discord-premium-since",
authoritative: true
})),
observedAt,
source: "discord-guild-reconciliation",
complete: true
});
}
}
function resolvePartial(key, legacyKey) { function resolvePartial(key, legacyKey) {
if (Partials?.[key] !== undefined) return Partials[key]; if (Partials?.[key] !== undefined) return Partials[key];
return legacyKey; return legacyKey;

View File

@ -0,0 +1,54 @@
const UNIT_DEFINITIONS = Object.freeze({
Y: { key: "years", milliseconds: 365 * 24 * 60 * 60 * 1000, singular: "year", plural: "years" },
M: { key: "months", milliseconds: 30 * 24 * 60 * 60 * 1000, singular: "month", plural: "months" },
D: { key: "days", milliseconds: 24 * 60 * 60 * 1000, singular: "day", plural: "days" },
H: { key: "hours", milliseconds: 60 * 60 * 1000, singular: "hour", plural: "hours" },
m: { key: "minutes", milliseconds: 60 * 1000, singular: "minute", plural: "minutes" },
s: { key: "seconds", milliseconds: 1000, singular: "second", plural: "seconds" }
});
const DEFAULT_DURATION_FORMAT = "Y-M-D-H-m-s";
const TOKEN_PATTERN = /^(?:Y|YY|YYYY|M|MM|D|DD|H|HH|m|mm|s|ss)$/;
const UNIT_ORDER = ["Y", "M", "D", "H", "m", "s"];
function parseDurationFormat(value = DEFAULT_DURATION_FORMAT) {
const source = String(value || DEFAULT_DURATION_FORMAT).trim();
const tokens = source.split("-").filter(Boolean);
if (!tokens.length || tokens.some((token) => !TOKEN_PATTERN.test(token))) {
throw new Error("Invalid duration format.");
}
const units = tokens.map((token) => token[0]);
if (new Set(units).size !== units.length) {
throw new Error("A duration format may include each unit only once.");
}
for (let index = 1; index < units.length; index += 1) {
if (UNIT_ORDER.indexOf(units[index]) <= UNIT_ORDER.indexOf(units[index - 1])) {
throw new Error("Duration units must be ordered from largest to smallest.");
}
}
return units.map((unit, index) => ({
...UNIT_DEFINITIONS[unit],
unit,
token: tokens[index]
}));
}
function formatDuration(milliseconds, format = DEFAULT_DURATION_FORMAT) {
let remaining = Math.max(0, Math.floor(Number(milliseconds) || 0));
if (!remaining) return "";
const units = parseDurationFormat(format);
const parts = [];
for (const unit of units) {
const value = Math.floor(remaining / unit.milliseconds);
remaining -= value * unit.milliseconds;
if (value) parts.push(`${value} ${value === 1 ? unit.singular : unit.plural}`);
}
return parts.join(", ");
}
module.exports = {
DEFAULT_DURATION_FORMAT,
UNIT_DEFINITIONS,
formatDuration,
parseDurationFormat
};

View File

@ -62,8 +62,13 @@ function emitLumiEvent(type, payload = {}, metadata = {}) {
for (const definition of [ for (const definition of [
{ id: "twitch.follow", label: "Twitch follow", description: "A viewer follows a configured Twitch channel.", platform: "twitch" }, { id: "twitch.follow", label: "Twitch follow", description: "A viewer follows a configured Twitch channel.", platform: "twitch" },
{ id: "twitch.unsubscribe", label: "Twitch subscription ended", description: "A viewer's subscription ends in a configured Twitch channel.", platform: "twitch" },
{ id: "twitch.raid", label: "Twitch raid received", description: "A configured Twitch channel receives a raid that appears in chat.", platform: "twitch" }, { id: "twitch.raid", label: "Twitch raid received", description: "A configured Twitch channel receives a raid that appears in chat.", platform: "twitch" },
{ id: "twitch.subscribe", label: "Twitch subscription", description: "A configured Twitch channel receives a new subscription.", platform: "twitch", supportsTier: true }, { id: "twitch.subscribe", label: "Twitch subscription", description: "A configured Twitch channel receives a new subscription.", platform: "twitch", supportsTier: true },
{ id: "twitch.moderator_add", label: "Twitch moderator added", description: "A viewer becomes a channel moderator.", platform: "twitch" },
{ id: "twitch.moderator_remove", label: "Twitch moderator removed", description: "A viewer stops being a channel moderator.", platform: "twitch" },
{ id: "twitch.vip_add", label: "Twitch VIP added", description: "A viewer becomes a channel VIP.", platform: "twitch" },
{ id: "twitch.vip_remove", label: "Twitch VIP removed", description: "A viewer stops being a channel VIP.", platform: "twitch" },
{ id: "twitch.subscription_gift", label: "Twitch gifted subscriptions", description: "A viewer gifts one or more subscriptions in a configured Twitch channel.", platform: "twitch", supportsTier: true }, { id: "twitch.subscription_gift", label: "Twitch gifted subscriptions", description: "A viewer gifts one or more subscriptions in a configured Twitch channel.", platform: "twitch", supportsTier: true },
{ id: "twitch.cheer", label: "Twitch cheer", description: "A viewer cheers Bits in a configured Twitch channel.", platform: "twitch" }, { id: "twitch.cheer", label: "Twitch cheer", description: "A viewer cheers Bits in a configured Twitch channel.", platform: "twitch" },
{ id: "twitch.channel_points", label: "Twitch channel-points redemption", description: "A viewer redeems a channel-points reward.", platform: "twitch" }, { id: "twitch.channel_points", label: "Twitch channel-points redemption", description: "A viewer redeems a channel-points reward.", platform: "twitch" },
@ -71,7 +76,10 @@ for (const definition of [
{ id: "youtube.membership_gift", label: "YouTube gifted membership", description: "A YouTube viewer gifts channel memberships.", platform: "youtube" }, { id: "youtube.membership_gift", label: "YouTube gifted membership", description: "A YouTube viewer gifts channel memberships.", platform: "youtube" },
{ id: "youtube.super_chat", label: "YouTube Super Chat", description: "A viewer sends a Super Chat.", platform: "youtube" }, { id: "youtube.super_chat", label: "YouTube Super Chat", description: "A viewer sends a Super Chat.", platform: "youtube" },
{ id: "youtube.super_sticker", label: "YouTube Super Sticker", description: "A viewer sends a Super Sticker.", platform: "youtube" }, { id: "youtube.super_sticker", label: "YouTube Super Sticker", description: "A viewer sends a Super Sticker.", platform: "youtube" },
{ id: "discord.member_join", label: "Discord member joined", description: "A member joins the configured Discord server.", platform: "discord" } { id: "discord.member_join", label: "Discord member joined", description: "A member joins the configured Discord server.", platform: "discord" },
{ id: "discord.member_leave", label: "Discord member left", description: "A member leaves the configured Discord server.", platform: "discord" },
{ id: "discord.boost_start", label: "Discord boost started", description: "A member starts boosting the configured Discord server.", platform: "discord" },
{ id: "discord.boost_end", label: "Discord boost ended", description: "A member stops boosting the configured Discord server.", platform: "discord" }
]) registerEventType(definition); ]) registerEventType(definition);
module.exports = { emitLumiEvent, listEventTypes, onLumiEvent, registerEventType }; module.exports = { emitLumiEvent, listEventTypes, onLumiEvent, registerEventType };

View File

@ -1,6 +1,7 @@
const { db } = require("./db"); const { db } = require("./db");
const { getSetting } = require("./settings"); const { getSetting } = require("./settings");
const { hasAccess } = require("./rbac"); const { hasAccess } = require("./rbac");
const { parseDurationFormat } = require("./duration-format");
const ROLE_LEVELS = Object.freeze({ const ROLE_LEVELS = Object.freeze({
public: 0, public: 0,
@ -23,6 +24,7 @@ const SENSITIVITY_LEVELS = Object.freeze({
const VALUE_TYPES = new Set(["string", "number", "boolean", "url", "json", "date"]); const VALUE_TYPES = new Set(["string", "number", "boolean", "url", "json", "date"]);
const placeholders = new Map(); const placeholders = new Map();
const fieldPolicies = new Map(); const fieldPolicies = new Map();
const PLACEHOLDER_PATTERN = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\.\{\s*format\.([A-Za-z-]+)\s*\}\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
function normalizeRole(value, fallback = "user") { function normalizeRole(value, fallback = "user") {
const role = String(value || fallback).trim().toLowerCase(); const role = String(value || fallback).trim().toLowerCase();
@ -100,7 +102,8 @@ function normalizeDefinition(definition = {}) {
example: definition.example === undefined ? null : String(definition.example), example: definition.example === undefined ? null : String(definition.example),
plugin_id: definition.plugin_id ? String(definition.plugin_id).trim() : null, plugin_id: definition.plugin_id ? String(definition.plugin_id).trim() : null,
resolver: typeof definition.resolver === "function" ? definition.resolver : () => "", resolver: typeof definition.resolver === "function" ? definition.resolver : () => "",
available: typeof definition.available === "function" ? definition.available : null available: typeof definition.available === "function" ? definition.available : null,
supports_duration_format: Boolean(definition.supports_duration_format)
}; };
} }
@ -246,13 +249,14 @@ function findDefinition(token) {
function parsePlaceholders(template) { function parsePlaceholders(template) {
const found = []; const found = [];
const matcher = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g; const matcher = new RegExp(PLACEHOLDER_PATTERN.source, PLACEHOLDER_PATTERN.flags);
let match = null; let match = null;
while ((match = matcher.exec(String(template || "")))) { while ((match = matcher.exec(String(template || "")))) {
found.push({ found.push({
token: match[0], token: match[0],
id: normalizeId(match[1]), id: normalizeId(match[1] || match[3]),
index: match.index index: match.index,
format: match[2] || null
}); });
} }
return found; return found;
@ -277,6 +281,7 @@ function catalog({ fieldId, user, outputAudience, runtimeContext } = {}) {
value_type: definition.value_type, value_type: definition.value_type,
sensitivity: definition.sensitivity, sensitivity: definition.sensitivity,
group: definition.group, group: definition.group,
supports_duration_format: definition.supports_duration_format,
example: definition.sensitivity === "public_safe" ? definition.example : null example: definition.sensitivity === "public_safe" ? definition.example : null
})) }))
.sort((a, b) => a.token.localeCompare(b.token)); .sort((a, b) => a.token.localeCompare(b.token));
@ -302,6 +307,19 @@ function validateTemplate({ fieldId, template, user, outputAudience, runtimeCont
} }
for (const token of parsePlaceholders(template)) { for (const token of parsePlaceholders(template)) {
const definition = findDefinition(token.id); const definition = findDefinition(token.id);
if (token.format) {
try {
if (!definition?.supports_duration_format) throw new Error("unsupported");
parseDurationFormat(token.format);
} catch {
errors.push({
token: token.token,
id: token.id,
reason: definition?.supports_duration_format ? "invalid_duration_format" : "duration_format_unsupported"
});
continue;
}
}
const access = checkPlaceholderAccess(definition, policy, { const access = checkPlaceholderAccess(definition, policy, {
user, user,
outputAudience, outputAudience,
@ -328,8 +346,8 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
}; };
} }
const errors = []; const errors = [];
const rendered = await replaceAsync(String(template || ""), /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, async (match, rawId) => { const rendered = await replaceAsync(String(template || ""), PLACEHOLDER_PATTERN, async (match, formattedId, format, regularId) => {
const id = normalizeId(rawId); const id = normalizeId(formattedId || regularId);
const definition = findDefinition(id); const definition = findDefinition(id);
const access = checkPlaceholderAccess(definition, policy, { const access = checkPlaceholderAccess(definition, policy, {
user, user,
@ -340,6 +358,16 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
errors.push({ token: match, id, reason: access.reason }); errors.push({ token: match, id, reason: access.reason });
return fallback; return fallback;
} }
if (format) {
try {
if (!definition.supports_duration_format) throw new Error("unsupported");
parseDurationFormat(format);
} catch {
const reason = definition.supports_duration_format ? "invalid_duration_format" : "duration_format_unsupported";
errors.push({ token: match, id, reason });
return fallback;
}
}
try { try {
const value = await withTimeout(Promise.resolve(definition.resolver({ const value = await withTimeout(Promise.resolve(definition.resolver({
user, user,
@ -347,7 +375,8 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience), outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience),
runtimeContext, runtimeContext,
token: match, token: match,
id id,
format: format || null
})), runtimeContext?.placeholder_timeout_ms); })), runtimeContext?.placeholder_timeout_ms);
return stringifyResolvedValue(value); return stringifyResolvedValue(value);
} catch (error) { } catch (error) {
@ -393,14 +422,22 @@ function withTimeout(promise, requestedMs) {
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
} }
function registerCorePlaceholders() { function registerCorePlaceholders({ userAgeStatistics } = {}) {
registerFieldPolicy({ registerFieldPolicy({
field_id: "core.custom_commands.static_response", field_id: "core.custom_commands.static_response",
label: "Custom command response", label: "Custom command response",
field_type: "command_response", field_type: "command_response",
output_audience: "user", output_audience: "user",
min_editor_role: "mod", min_editor_role: "mod",
allowed_namespaces: ["core.main", "core.command", "custom", "user.public"], allowed_namespaces: [
"core.main",
"core.command",
"custom",
"user.public",
"twitch.user",
"youtube.user",
"discord.user"
],
max_sensitivity: "public_safe" max_sensitivity: "public_safe"
}); });
registerPlaceholders([ registerPlaceholders([
@ -461,6 +498,9 @@ function registerCorePlaceholders() {
"" ""
} }
]); ]);
if (userAgeStatistics?.placeholderDefinitions) {
registerPlaceholders(userAgeStatistics.placeholderDefinitions());
}
registerCustomPlaceholders(getSetting("custom_placeholders", [])); registerCustomPlaceholders(getSetting("custom_placeholders", []));
} }

View File

@ -5,6 +5,7 @@ const { getSetting } = require("./settings");
const { getEnabledPlatformIds } = require("./platforms"); const { getEnabledPlatformIds } = require("./platforms");
const { getPluginLeaderboards } = require("./plugin-stats"); const { getPluginLeaderboards } = require("./plugin-stats");
const { getPlugins, pluginsDir } = require("./plugins"); const { getPlugins, pluginsDir } = require("./plugins");
const { userAgeStatistics } = require("./user-age-statistics");
const coreProviders = new Map(); const coreProviders = new Map();
const coreOrder = []; const coreOrder = [];
@ -97,17 +98,31 @@ function ensureCoreProviders() {
description: "Most popular commands across platforms.", description: "Most popular commands across platforms.",
getRows: ({ limit }) => buildCommandUsageRows(limit) getRows: ({ limit }) => buildCommandUsageRows(limit)
}); });
for (const definition of [
{ id: "followage", platform: "twitch", type: "follow", label: "Longest Twitch follows" },
{ id: "twitch_subscriber_age", platform: "twitch", type: "subscriber", label: "Longest Twitch subscriptions" },
{ id: "twitch_mod_age", platform: "twitch", type: "moderator", label: "Longest Twitch moderator tenure" },
{ id: "twitch_editor_age", platform: "twitch", type: "editor", label: "Longest Twitch editor tenure" },
{ id: "twitch_vip_age", platform: "twitch", type: "vip", label: "Longest Twitch VIP tenure" },
{ id: "youtube_member_age", platform: "youtube", type: "subscriber", label: "Longest YouTube memberships" },
{ id: "youtube_mod_age", platform: "youtube", type: "moderator", label: "Longest YouTube moderator tenure" },
{ id: "discord_member_age", platform: "discord", type: "member", label: "Longest Discord membership" },
{ id: "discord_nitro_age", platform: "discord", type: "nitro", label: "Longest Discord boosts" }
]) {
registerTopProvider({ registerTopProvider({
id: "followage", ...definition,
label: "Top followage", section: "Platform tenure",
section: "Platforms", valueLabel: "Recorded tenure",
valueLabel: "Days", description: `${definition.label} across linked Lumi profiles.`,
description: "Longest follower durations.", getRows: ({ limit }) => ({
getRows: () => ({ rows: userAgeStatistics.getLeaderboard(definition.type, {
rows: [], platform: definition.platform,
emptyMessage: "Followage tracking is not configured yet." limit
}),
emptyMessage: "No recorded tenure is available yet."
}) })
}); });
}
registerTopProvider({ registerTopProvider({
id: "watchtime", id: "watchtime",
label: "Top watchtime", label: "Top watchtime",

View File

@ -3,6 +3,7 @@ const { getSetting, setSetting } = require("./settings");
const { emitLumiEvent } = require("./lumi-events"); const { emitLumiEvent } = require("./lumi-events");
const { createLogger } = require("./logger"); const { createLogger } = require("./logger");
const { setPlatformLiveState } = require("./platform-live-state"); const { setPlatformLiveState } = require("./platform-live-state");
const { reconcileTwitchUserAges } = require("./twitch-user-age");
const EVENTSUB_URL = "wss://eventsub.wss.twitch.tv/ws?keepalive_timeout_seconds=30"; const EVENTSUB_URL = "wss://eventsub.wss.twitch.tv/ws?keepalive_timeout_seconds=30";
const eventLog = createLogger("platform:twitch:eventsub", { category: "integration" }); const eventLog = createLogger("platform:twitch:eventsub", { category: "integration" });
@ -70,6 +71,8 @@ class TwitchEventSubManager {
this.running = false; this.running = false;
this.reconnectTimer = null; this.reconnectTimer = null;
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
this.reconcileTimer = null;
this.reconcileContext = null;
this.generation = 0; this.generation = 0;
this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 }; this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 };
} }
@ -98,7 +101,10 @@ class TwitchEventSubManager {
this.running = false; this.running = false;
this.generation += 1; this.generation += 1;
clearTimeout(this.reconnectTimer); clearTimeout(this.reconnectTimer);
clearInterval(this.reconcileTimer);
this.reconnectTimer = null; this.reconnectTimer = null;
this.reconcileTimer = null;
this.reconcileContext = null;
const socket = this.socket; const socket = this.socket;
this.socket = null; this.socket = null;
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Lumi shutdown"); if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Lumi shutdown");
@ -204,8 +210,17 @@ class TwitchEventSubManager {
} }
if (scopes.has("channel:read:subscriptions") && broadcaster.id === credentials.validation.user_id) { if (scopes.has("channel:read:subscriptions") && broadcaster.id === credentials.validation.user_id) {
definitions.push({ type: "channel.subscribe", version: "1", condition: { broadcaster_user_id: broadcaster.id } }); definitions.push({ type: "channel.subscribe", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
definitions.push({ type: "channel.subscription.end", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
definitions.push({ type: "channel.subscription.gift", version: "1", condition: { broadcaster_user_id: broadcaster.id } }); definitions.push({ type: "channel.subscription.gift", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
} }
if (scopes.has("moderation:read") && broadcaster.id === credentials.validation.user_id) {
definitions.push({ type: "channel.moderator.add", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
definitions.push({ type: "channel.moderator.remove", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
}
if (broadcaster.id === credentials.validation.user_id && (scopes.has("channel:read:vips") || scopes.has("channel:manage:vips"))) {
definitions.push({ type: "channel.vip.add", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
definitions.push({ type: "channel.vip.remove", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
}
if (scopes.has("bits:read") && broadcaster.id === credentials.validation.user_id) if (scopes.has("bits:read") && broadcaster.id === credentials.validation.user_id)
definitions.push({ type: "channel.cheer", version: "1", condition: { broadcaster_user_id: broadcaster.id } }); definitions.push({ type: "channel.cheer", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
if ((scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions")) && broadcaster.id === credentials.validation.user_id) if ((scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions")) && broadcaster.id === credentials.validation.user_id)
@ -235,6 +250,15 @@ class TwitchEventSubManager {
} }
} }
if (generation !== this.generation) return; if (generation !== this.generation) return;
this.reconcileContext = { ...credentials, broadcasters: users.data || [] };
await reconcileTwitchUserAges(this.reconcileContext);
clearInterval(this.reconcileTimer);
this.reconcileTimer = setInterval(() => {
if (!this.running || generation !== this.generation || !this.reconcileContext) return;
reconcileTwitchUserAges(this.reconcileContext).catch((error) => {
eventLog.warn("Scheduled Twitch tenure reconciliation failed", { error }, { event: "user_age_reconciliation_failed" });
});
}, 10 * 60 * 1000);
this.status = { this.status = {
state: "connected", state: "connected",
detail: active ? `Receiving ${active} Twitch event feed${active === 1 ? "" : "s"}.` : "Connected, but no configured channel events could be subscribed.", detail: active ? `Receiving ${active} Twitch event feed${active === 1 ? "" : "s"}.` : "Connected, but no configured channel events could be subscribed.",
@ -243,7 +267,10 @@ class TwitchEventSubManager {
cheers: scopes.has("bits:read") ? "available" : "missing bits:read", cheers: scopes.has("bits:read") ? "available" : "missing bits:read",
channel_points: scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions") ? "available" : "missing channel:read:redemptions", channel_points: scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions") ? "available" : "missing channel:read:redemptions",
subscriptions: scopes.has("channel:read:subscriptions") ? "available" : "missing channel:read:subscriptions", subscriptions: scopes.has("channel:read:subscriptions") ? "available" : "missing channel:read:subscriptions",
follows: scopes.has("moderator:read:followers") ? "available" : "missing moderator:read:followers" follows: scopes.has("moderator:read:followers") ? "available" : "missing moderator:read:followers",
moderators: scopes.has("moderation:read") ? "available" : "missing moderation:read",
vips: scopes.has("channel:read:vips") || scopes.has("channel:manage:vips") ? "available" : "missing channel:read:vips",
editors: scopes.has("channel:read:editors") ? "available" : "missing channel:read:editors"
} }
}; };
eventLog.info("Twitch EventSub connected", { channels: users.data?.length || 0, subscriptions: active }, { event: "eventsub_ready" }); eventLog.info("Twitch EventSub connected", { channels: users.data?.length || 0, subscriptions: active }, { event: "eventsub_ready" });
@ -265,6 +292,11 @@ class TwitchEventSubManager {
if (subscription.type === "channel.follow") emitLumiEvent("twitch.follow", { ...common, followed_at: event.followed_at }, metadata); if (subscription.type === "channel.follow") emitLumiEvent("twitch.follow", { ...common, followed_at: event.followed_at }, metadata);
if (subscription.type === "channel.raid") emitLumiEvent("twitch.raid", { ...common, viewers: Number(event.viewers || 0) }, metadata); if (subscription.type === "channel.raid") emitLumiEvent("twitch.raid", { ...common, viewers: Number(event.viewers || 0) }, metadata);
if (subscription.type === "channel.subscribe") emitLumiEvent("twitch.subscribe", { ...common, tier: event.tier, gifted: Boolean(event.is_gift) }, metadata); if (subscription.type === "channel.subscribe") emitLumiEvent("twitch.subscribe", { ...common, tier: event.tier, gifted: Boolean(event.is_gift) }, metadata);
if (subscription.type === "channel.subscription.end") emitLumiEvent("twitch.unsubscribe", { ...common, tier: event.tier }, metadata);
if (subscription.type === "channel.moderator.add") emitLumiEvent("twitch.moderator_add", common, metadata);
if (subscription.type === "channel.moderator.remove") emitLumiEvent("twitch.moderator_remove", common, metadata);
if (subscription.type === "channel.vip.add") emitLumiEvent("twitch.vip_add", common, metadata);
if (subscription.type === "channel.vip.remove") emitLumiEvent("twitch.vip_remove", common, metadata);
if (subscription.type === "channel.subscription.gift") emitLumiEvent("twitch.subscription_gift", { ...common, tier: event.tier, total: Number(event.total || 0), anonymous: Boolean(event.is_anonymous) }, metadata); if (subscription.type === "channel.subscription.gift") emitLumiEvent("twitch.subscription_gift", { ...common, tier: event.tier, total: Number(event.total || 0), anonymous: Boolean(event.is_anonymous) }, metadata);
if (subscription.type === "channel.cheer") emitLumiEvent("twitch.cheer", { ...common, bits: Number(event.bits || 0), message: event.message || "", anonymous: Boolean(event.is_anonymous) }, metadata); if (subscription.type === "channel.cheer") emitLumiEvent("twitch.cheer", { ...common, bits: Number(event.bits || 0), message: event.message || "", anonymous: Boolean(event.is_anonymous) }, metadata);
if (subscription.type === "channel.channel_points_custom_reward_redemption.add") emitLumiEvent("twitch.channel_points", { if (subscription.type === "channel.channel_points_custom_reward_redemption.add") emitLumiEvent("twitch.channel_points", {

View File

@ -0,0 +1,122 @@
const { userAgeStatistics } = require("./user-age-statistics");
const { createLogger } = require("./logger");
const log = createLogger("platform:twitch:user-age", { category: "integration" });
async function fetchAll(url, headers) {
const records = [];
let cursor = "";
do {
const target = new URL(url);
target.searchParams.set("first", "100");
if (cursor) target.searchParams.set("after", cursor);
const response = await fetch(target, { headers });
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`Twitch reconciliation failed (${response.status})${detail ? `: ${detail.slice(0, 160)}` : ""}`);
}
const body = await response.json();
records.push(...(Array.isArray(body.data) ? body.data : []));
cursor = String(body.pagination?.cursor || "");
} while (cursor);
return records;
}
async function reconcileTwitchUserAges({ accessToken, clientId, validation, broadcasters = [] } = {}) {
if (!accessToken || !clientId) return;
const headers = {
"Client-Id": clientId,
Authorization: `Bearer ${accessToken}`
};
const scopes = new Set(validation?.scopes || []);
const moderatorId = validation?.user_id;
for (const broadcaster of broadcasters) {
const jobs = [];
if (scopes.has("moderator:read:followers")) {
jobs.push(reconcileList({
platform: "twitch",
scopeId: broadcaster.id,
type: "follow",
url: `https://api.twitch.tv/helix/channels/followers?broadcaster_id=${encodeURIComponent(broadcaster.id)}&moderator_id=${encodeURIComponent(moderatorId)}`,
headers,
startField: "followed_at",
source: "twitch-followers-api"
}));
}
if (broadcaster.id === moderatorId && scopes.has("channel:read:subscriptions")) {
jobs.push(reconcileList({
platform: "twitch",
scopeId: broadcaster.id,
type: "subscriber",
url: `https://api.twitch.tv/helix/subscriptions?broadcaster_id=${encodeURIComponent(broadcaster.id)}`,
headers,
source: "twitch-subscriptions-api"
}));
}
if (broadcaster.id === moderatorId && scopes.has("moderation:read")) {
jobs.push(reconcileList({
platform: "twitch",
scopeId: broadcaster.id,
type: "moderator",
url: `https://api.twitch.tv/helix/moderation/moderators?broadcaster_id=${encodeURIComponent(broadcaster.id)}`,
headers,
source: "twitch-moderators-api"
}));
}
if (broadcaster.id === moderatorId && (scopes.has("channel:read:vips") || scopes.has("channel:manage:vips"))) {
jobs.push(reconcileList({
platform: "twitch",
scopeId: broadcaster.id,
type: "vip",
url: `https://api.twitch.tv/helix/channels/vips?broadcaster_id=${encodeURIComponent(broadcaster.id)}`,
headers,
source: "twitch-vips-api"
}));
}
if (broadcaster.id === moderatorId && scopes.has("channel:read:editors")) {
jobs.push(reconcileList({
platform: "twitch",
scopeId: broadcaster.id,
type: "editor",
url: `https://api.twitch.tv/helix/channels/editors?broadcaster_id=${encodeURIComponent(broadcaster.id)}`,
headers,
startField: "created_at",
source: "twitch-editors-api"
}));
}
await Promise.allSettled(jobs);
}
}
async function reconcileList({ platform, scopeId, type, url, headers, startField = null, source }) {
try {
const rows = await fetchAll(url, headers);
const observedAt = Date.now();
userAgeStatistics.reconcileSnapshot({
platform,
scopeId,
type,
activeRecords: rows.map((row) => ({
userId: row.user_id,
startAt: startField ? row[startField] : undefined,
source: startField && row[startField] ? source : `${source}:first-observed`,
authoritative: Boolean(startField && row[startField])
})),
observedAt,
source,
complete: true
});
} catch (error) {
// A failed or partial API read must never be interpreted as an empty state.
log.warn("Twitch platform tenure reconciliation was skipped", {
statistic_type: type,
scope_id: scopeId,
error
}, { event: "user_age_reconciliation_failed" });
}
}
module.exports = {
fetchAll,
reconcileTwitchUserAges
};

View File

@ -5,6 +5,7 @@ const { ensureUserForIdentity } = require("./users");
const { createLogger } = require("./logger"); const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat"); const { publishOverlayChatMessage } = require("./overlay-chat");
const { resolveBetterTtvEmotes, resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets"); const { resolveBetterTtvEmotes, resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets");
const { userAgeStatistics } = require("./user-age-statistics");
const twitchLog = createLogger("platform:twitch", { category: "integration" }); const twitchLog = createLogger("platform:twitch", { category: "integration" });
@ -57,6 +58,28 @@ async function startTwitchBot({ commandRouter } = {}) {
displayName, displayName,
avatar avatar
}); });
if (!self && tags["room-id"]) {
const observedAt = Number(tags["tmi-sent-ts"]) || Date.now();
const badgeNames = new Set(Object.keys(tags.badges || {}));
for (const [type, active] of [
["subscriber", badgeNames.has("subscriber") || badgeNames.has("founder") || Boolean(tags.subscriber)],
["moderator", badgeNames.has("moderator") || tags.mod === true || tags.mod === "1"],
["vip", badgeNames.has("vip")]
]) {
userAgeStatistics.setState({
platform: "twitch",
userId,
scopeId: tags["room-id"],
type,
active,
occurredAt: observedAt,
eventId: `twitch-chat:${tags.id || `${tags["room-id"]}:${userId}:${observedAt}`}:${type}`,
source: "twitch-chat-tags",
precision: "millisecond",
authoritative: false
});
}
}
publishOverlayChatMessage({ publishOverlayChatMessage({
id: tags.id, id: tags.id,
platform: "twitch", platform: "twitch",

View File

@ -0,0 +1,423 @@
const crypto = require("crypto");
const { db } = require("./db");
const { formatDuration } = require("./duration-format");
const { createLogger } = require("./logger");
const log = createLogger("core:user-age-statistics", { category: "integration" });
const DEFAULT_STATISTICS = Object.freeze([
{ platform: "twitch", type: "follow", placeholder: "follow_age", label: "Follow age" },
{ platform: "twitch", type: "subscriber", placeholder: "subscriber_age", label: "Subscriber age" },
{ platform: "twitch", type: "moderator", placeholder: "mod_age", label: "Moderator age" },
{ platform: "twitch", type: "editor", placeholder: "editor_age", label: "Editor age" },
{ platform: "twitch", type: "vip", placeholder: "vip_age", label: "VIP age" },
{ platform: "youtube", type: "subscriber", placeholder: "subscriber_age", label: "Channel membership age" },
{ platform: "youtube", type: "moderator", placeholder: "mod_age", label: "Moderator age" },
{ platform: "discord", type: "member", placeholder: "member_age", label: "Server member age" },
{ platform: "discord", type: "nitro", placeholder: "nitro_age", label: "Server boost age" }
]);
function normalizeText(value) {
return String(value ?? "").trim();
}
function timestamp(value, fallback = null) {
if (value instanceof Date) return value.getTime();
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) return Math.floor(numeric);
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
class UserAgeStatistics {
constructor(database = db, { now = () => Date.now(), registerDefaults = true } = {}) {
this.db = database;
this.now = now;
this.statistics = new Map();
this.stopListening = null;
this.ensureTables();
if (registerDefaults) DEFAULT_STATISTICS.forEach((definition) => this.registerStatistic(definition));
}
ensureTables() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS user_age_intervals (
id TEXT PRIMARY KEY,
platform TEXT NOT NULL,
platform_user_id TEXT NOT NULL,
scope_id TEXT NOT NULL,
statistic_type TEXT NOT NULL,
start_at INTEGER NOT NULL,
end_at INTEGER,
start_source TEXT NOT NULL,
start_precision TEXT NOT NULL DEFAULT 'millisecond',
start_authoritative INTEGER NOT NULL DEFAULT 0,
end_source TEXT,
end_precision TEXT,
end_authoritative INTEGER,
observed_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS user_age_intervals_one_open_idx
ON user_age_intervals (platform, platform_user_id, scope_id, statistic_type)
WHERE end_at IS NULL;
CREATE INDEX IF NOT EXISTS user_age_intervals_lookup_idx
ON user_age_intervals (platform, scope_id, statistic_type, platform_user_id, start_at);
CREATE TABLE IF NOT EXISTS user_age_events (
platform TEXT NOT NULL,
event_id TEXT NOT NULL,
processed_at INTEGER NOT NULL,
PRIMARY KEY (platform, event_id)
);
`);
}
registerStatistic(definition = {}) {
const platform = normalizeText(definition.platform).toLowerCase();
const type = normalizeText(definition.type).toLowerCase();
const placeholder = normalizeText(definition.placeholder).toLowerCase();
if (!platform || !type || !placeholder) throw new Error("Platform, type, and placeholder are required.");
const normalized = Object.freeze({
platform,
type,
placeholder,
label: normalizeText(definition.label) || placeholder
});
this.statistics.set(`${platform}:${type}`, normalized);
return normalized;
}
listStatistics() {
return Array.from(this.statistics.values());
}
start() {
if (this.stopListening) return;
const { onLumiEvent } = require("./lumi-events");
const mappings = {
"twitch.follow": ["follow", true, "followed_at"],
"twitch.subscribe": ["subscriber", true, null],
"twitch.unsubscribe": ["subscriber", false, null],
"twitch.moderator_add": ["moderator", true, null],
"twitch.moderator_remove": ["moderator", false, null],
"twitch.vip_add": ["vip", true, null],
"twitch.vip_remove": ["vip", false, null],
"discord.member_join": ["member", true, "joined_at"],
"discord.member_leave": ["member", false, null],
"discord.boost_start": ["nitro", true, "boosted_at"],
"discord.boost_end": ["nitro", false, null]
};
this.stopListening = onLumiEvent((event) => {
const mapping = mappings[event.type];
if (!mapping) return;
const [type, active, startField] = mapping;
const payload = event.payload || {};
const platform = event.type.split(".")[0];
const userId = payload.user_id;
const scopeId = platform === "discord" ? payload.guild_id : payload.broadcaster_id;
if (!userId || !scopeId) return;
try {
this.setState({
platform,
type,
userId,
scopeId,
active,
startAt: startField ? payload[startField] : event.occurredAt,
occurredAt: event.occurredAt,
eventId: event.id,
source: event.source,
precision: "millisecond",
authoritative: Boolean(startField && payload[startField]) || active === false
});
} catch (error) {
log.error("Platform tenure event could not be recorded", {
event_type: event.type,
event_id: event.id,
error
}, { event: "user_age_event_failed" });
}
});
}
stop() {
this.stopListening?.();
this.stopListening = null;
}
setState(input = {}) {
return this.db.transaction(() => this._setState(input, true))();
}
_setState(input, recordEvent) {
const platform = normalizeText(input.platform).toLowerCase();
const userId = normalizeText(input.userId || input.platformUserId);
const scopeId = normalizeText(input.scopeId);
const type = normalizeText(input.type).toLowerCase();
const definition = this.statistics.get(`${platform}:${type}`);
if (!definition || !userId || !scopeId) throw new Error("Unknown or incomplete user-age statistic.");
const now = this.now();
const occurredAt = timestamp(input.occurredAt, now);
const eventId = normalizeText(input.eventId);
if (recordEvent && eventId) {
const result = this.db.prepare(
"INSERT OR IGNORE INTO user_age_events (platform, event_id, processed_at) VALUES (?, ?, ?)"
).run(platform, eventId, now);
if (!result.changes) return { changed: false, duplicate: true };
}
const open = this.db.prepare(
`SELECT * FROM user_age_intervals
WHERE platform = ? AND platform_user_id = ? AND scope_id = ? AND statistic_type = ? AND end_at IS NULL`
).get(platform, userId, scopeId, type);
const active = Boolean(input.active);
const authoritative = Boolean(input.authoritative);
const source = normalizeText(input.source) || "first-observed";
const precision = normalizeText(input.precision) || "millisecond";
if (active) {
const proposedStart = Math.min(timestamp(input.startAt, occurredAt), occurredAt);
if (open) {
if (authoritative && !open.start_authoritative && proposedStart <= open.start_at) {
this.db.prepare(
`UPDATE user_age_intervals
SET start_at = ?, start_source = ?, start_precision = ?, start_authoritative = 1,
observed_at = ?, updated_at = ?
WHERE id = ? AND end_at IS NULL`
).run(proposedStart, source, precision, occurredAt, now, open.id);
return { changed: true, intervalId: open.id, backfilled: true };
}
return { changed: false, intervalId: open.id };
}
const id = crypto.randomUUID();
this.db.prepare(
`INSERT INTO user_age_intervals
(id, platform, platform_user_id, scope_id, statistic_type, start_at, end_at,
start_source, start_precision, start_authoritative, observed_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)`
).run(id, platform, userId, scopeId, type, proposedStart, source, precision, authoritative ? 1 : 0, occurredAt, now, now);
return { changed: true, intervalId: id, opened: true };
}
if (!open) return { changed: false };
const endAt = Math.max(open.start_at, occurredAt);
this.db.prepare(
`UPDATE user_age_intervals
SET end_at = ?, end_source = ?, end_precision = ?, end_authoritative = ?,
observed_at = ?, updated_at = ?
WHERE id = ? AND end_at IS NULL`
).run(endAt, source, precision, authoritative ? 1 : 0, occurredAt, now, open.id);
return { changed: true, intervalId: open.id, closed: true };
}
reconcileSnapshot(input = {}) {
const platform = normalizeText(input.platform).toLowerCase();
const scopeId = normalizeText(input.scopeId);
const type = normalizeText(input.type).toLowerCase();
if (!input.complete) return { changed: false, skipped: true };
const records = Array.isArray(input.activeRecords) ? input.activeRecords : [];
const observedAt = timestamp(input.observedAt, this.now());
return this.db.transaction(() => {
const activeUsers = new Set();
let changes = 0;
for (const record of records) {
const userId = normalizeText(record.userId || record.platformUserId);
if (!userId) continue;
activeUsers.add(userId);
const result = this._setState({
platform,
scopeId,
type,
userId,
active: true,
startAt: record.startAt,
occurredAt: observedAt,
source: record.source || input.source || "platform-reconciliation",
precision: record.precision || input.precision || "millisecond",
authoritative: record.authoritative ?? input.authoritative
}, false);
if (result.changed) changes += 1;
}
const openRows = this.db.prepare(
`SELECT platform_user_id FROM user_age_intervals
WHERE platform = ? AND scope_id = ? AND statistic_type = ? AND end_at IS NULL`
).all(platform, scopeId, type);
for (const row of openRows) {
if (activeUsers.has(row.platform_user_id)) continue;
const result = this._setState({
platform,
scopeId,
type,
userId: row.platform_user_id,
active: false,
occurredAt: observedAt,
source: input.source || "platform-reconciliation",
precision: input.precision || "millisecond",
authoritative: Boolean(input.authoritative)
}, false);
if (result.changed) changes += 1;
}
return { changed: Boolean(changes), changes };
})();
}
getDurations({ platform, userId, scopeId, type, now = this.now() } = {}) {
const rows = this.db.prepare(
`SELECT start_at, end_at FROM user_age_intervals
WHERE platform = ? AND platform_user_id = ? AND scope_id = ? AND statistic_type = ?
ORDER BY start_at ASC`
).all(normalizeText(platform).toLowerCase(), normalizeText(userId), normalizeText(scopeId), normalizeText(type).toLowerCase());
if (!rows.length) return { current: null, total: null, intervals: 0 };
let current = null;
let total = 0;
for (const row of rows) {
const end = row.end_at === null ? now : row.end_at;
const duration = Math.max(0, end - row.start_at);
total += duration;
if (row.end_at === null) current = duration;
}
return { current, total, intervals: rows.length };
}
getProfileStatistics(userId, { now = this.now() } = {}) {
const identities = this.db.prepare(
"SELECT provider, provider_user_id FROM user_identities WHERE user_id = ?"
).all(userId);
const output = [];
for (const identity of identities) {
const definitions = this.listStatistics().filter((entry) => entry.platform === identity.provider);
for (const definition of definitions) {
const rows = this.db.prepare(
`SELECT scope_id, start_at, end_at FROM user_age_intervals
WHERE platform = ? AND platform_user_id = ? AND statistic_type = ?`
).all(identity.provider, identity.provider_user_id, definition.type);
if (!rows.length) continue;
const byScope = new Map();
for (const row of rows) {
const scoped = byScope.get(row.scope_id) || [];
scoped.push(row);
byScope.set(row.scope_id, scoped);
}
for (const [scopeId, scopedRows] of byScope.entries()) {
const total = scopedRows.reduce((sum, row) => sum + Math.max(0, (row.end_at ?? now) - row.start_at), 0);
const open = scopedRows.find((row) => row.end_at === null);
output.push({
platform: definition.platform,
type: definition.type,
scopeId,
label: `${capitalize(definition.platform)} ${definition.label} · ${scopeId}`,
current: open ? formatDuration(now - open.start_at) : "",
total: formatDuration(total),
value: formatDuration(total)
});
}
}
}
return output;
}
getLeaderboard(type, { platform = null, limit = 25, now = this.now() } = {}) {
const conditions = ["i.statistic_type = ?"];
const params = [now, normalizeText(type).toLowerCase()];
if (platform) {
conditions.push("i.platform = ?");
params.push(normalizeText(platform).toLowerCase());
}
params.push(Math.max(1, Math.min(Number(limit) || 25, 100)));
return this.db.prepare(
`SELECT p.internal_username AS username, i.scope_id,
SUM(MAX(0, COALESCE(i.end_at, ?) - i.start_at)) AS duration_ms
FROM user_age_intervals i
JOIN user_identities identity
ON identity.provider = i.platform AND identity.provider_user_id = i.platform_user_id
JOIN user_profiles p ON p.id = identity.user_id
WHERE ${conditions.join(" AND ")}
GROUP BY p.id, p.internal_username, i.scope_id
ORDER BY duration_ms DESC, p.internal_username ASC
LIMIT ?`
).all(...params).map((row) => ({
username: row.username,
label: `${row.username} · ${row.scope_id}`,
value: formatDuration(row.duration_ms, "D-H-m"),
numericValue: row.duration_ms
}));
}
listDiagnostics({ platform = "", scopeId = "", userId = "", type = "", limit = 500 } = {}) {
const conditions = [];
const params = [];
if (platform) { conditions.push("platform = ?"); params.push(normalizeText(platform).toLowerCase()); }
if (scopeId) { conditions.push("scope_id = ?"); params.push(normalizeText(scopeId)); }
if (userId) { conditions.push("platform_user_id = ?"); params.push(normalizeText(userId)); }
if (type) { conditions.push("statistic_type = ?"); params.push(normalizeText(type).toLowerCase()); }
params.push(Math.max(1, Math.min(Number(limit) || 500, 1000)));
return this.db.prepare(
`SELECT * FROM user_age_intervals
${conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""}
ORDER BY end_at IS NULL DESC, start_at DESC LIMIT ?`
).all(...params);
}
placeholderDefinitions() {
return this.listStatistics().flatMap((definition) => ["current", "total"].map((mode) => ({
id: `${definition.platform}.user.${definition.placeholder}.${mode}`,
namespace: `${definition.platform}.user`,
group: "Platform tenure",
label: `${definition.label} (${mode})`,
description: mode === "current"
? `Current uninterrupted ${definition.label.toLowerCase()} in this platform context.`
: `Combined recorded ${definition.label.toLowerCase()} in this platform context.`,
value_type: "string",
sensitivity: "public_safe",
min_editor_role: "user",
min_viewer_role: "user",
allowed_field_types: ["command_response", "chat_message", "admin_template", "okf_markdown"],
example: "2 years, 3 months, 4 days",
supports_duration_format: true,
resolver: ({ runtimeContext, format }) => {
const identity = contextIdentity(definition.platform, runtimeContext);
if (!identity) return "";
const durations = this.getDurations({
platform: definition.platform,
userId: identity.userId,
scopeId: identity.scopeId,
type: definition.type
});
const value = durations[mode];
return value === null ? "" : formatDuration(value, format);
}
})));
}
}
function contextIdentity(platform, runtimeContext = {}) {
const ctx = runtimeContext.ctx || {};
const activePlatform = normalizeText(runtimeContext.platform || ctx.platform).toLowerCase();
if (activePlatform !== platform) return null;
const userId = normalizeText(
runtimeContext.platformUser?.id ||
runtimeContext.user?.platformId ||
ctx.platformUser?.id ||
ctx.user?.platformId
);
const meta = runtimeContext.meta || ctx.meta || {};
let scopeId = "";
if (platform === "twitch") scopeId = normalizeText(meta.tags?.["room-id"] || meta.broadcasterId);
if (platform === "youtube") scopeId = normalizeText(meta.broadcasterChannelId);
if (platform === "discord") scopeId = normalizeText(meta.message?.guildId || meta.message?.guild?.id || meta.guildId);
return userId && scopeId ? { userId, scopeId } : null;
}
function capitalize(value) {
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : "";
}
const userAgeStatistics = new UserAgeStatistics();
module.exports = {
DEFAULT_STATISTICS,
UserAgeStatistics,
contextIdentity,
userAgeStatistics
};

View File

@ -5,6 +5,7 @@ const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat"); const { publishOverlayChatMessage } = require("./overlay-chat");
const { emitLumiEvent } = require("./lumi-events"); const { emitLumiEvent } = require("./lumi-events");
const { setPlatformLiveState } = require("./platform-live-state"); const { setPlatformLiveState } = require("./platform-live-state");
const { userAgeStatistics } = require("./user-age-statistics");
const youtubeLog = createLogger("platform:youtube", { category: "integration" }); const youtubeLog = createLogger("platform:youtube", { category: "integration" });
@ -119,14 +120,41 @@ async function handleChatItem(state, liveChatId, item) {
if (!snippet || !author) { if (!snippet || !author) {
return; return;
} }
const messageText = snippet.displayMessage; const messageText = snippet.displayMessage || "";
if (!messageText) {
return;
}
const displayName = author.displayName || "YouTube User"; const displayName = author.displayName || "YouTube User";
const avatar = author.profileImageUrl || null; const avatar = author.profileImageUrl || null;
const isSelf = Boolean(state.channelId && author.channelId === state.channelId); const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
emitYouTubeEvent(item, state, liveChatId, displayName); emitYouTubeEvent(item, state, liveChatId, displayName);
if (!isSelf && state.channelId && author.channelId) {
const observedAt = Date.parse(snippet.publishedAt) || Date.now();
const membershipStart = ["newSponsorEvent", "giftMembershipReceivedEvent"].includes(snippet.type);
userAgeStatistics.setState({
platform: "youtube",
userId: author.channelId,
scopeId: state.channelId,
type: "subscriber",
active: Boolean(author.isChatSponsor || membershipStart),
startAt: membershipStart ? observedAt : undefined,
occurredAt: observedAt,
eventId: `youtube-chat:${item.id}:subscriber`,
source: membershipStart ? "youtube-membership-event" : "youtube-live-chat-author",
precision: "millisecond",
authoritative: membershipStart
});
userAgeStatistics.setState({
platform: "youtube",
userId: author.channelId,
scopeId: state.channelId,
type: "moderator",
active: Boolean(author.isChatModerator),
occurredAt: observedAt,
eventId: `youtube-chat:${item.id}:moderator`,
source: "youtube-live-chat-author",
precision: "millisecond",
authoritative: false
});
}
if (!messageText) return;
const profile = isSelf ? null : ensureUserForIdentity({ const profile = isSelf ? null : ensureUserForIdentity({
provider: "youtube", provider: "youtube",
providerUserId: author.channelId, providerUserId: author.channelId,

View File

@ -80,6 +80,7 @@ const { getClient: getTwitchClient } = require("../services/twitch");
const { twitchEventSubManager } = require("../services/twitch-eventsub"); const { twitchEventSubManager } = require("../services/twitch-eventsub");
const { eventHooksApi } = require("../services/overlay-event-hooks"); const { eventHooksApi } = require("../services/overlay-event-hooks");
const { streamTestingService } = require("../services/stream-testing"); const { streamTestingService } = require("../services/stream-testing");
const { userAgeStatistics } = require("../services/user-age-statistics");
const { const {
getReverseProxyIngestSettings, getReverseProxyIngestSettings,
saveReverseProxyIngestSettings saveReverseProxyIngestSettings
@ -379,7 +380,7 @@ function getExpressionUserSummary(userId) {
function buildUserStatsPayload(userId) { function buildUserStatsPayload(userId) {
if (!userId) { if (!userId) {
return { stats: null, expression: null, pluginStats: [] }; return { stats: null, expression: null, tenureStats: [], pluginStats: [] };
} }
const stats = db const stats = db
.prepare("SELECT * FROM stats WHERE user_id = ?") .prepare("SELECT * FROM stats WHERE user_id = ?")
@ -387,6 +388,7 @@ function buildUserStatsPayload(userId) {
return { return {
stats, stats,
expression: getExpressionUserSummary(userId), expression: getExpressionUserSummary(userId),
tenureStats: userAgeStatistics.getProfileStatistics(userId),
pluginStats: getPluginProfileStats(userId) pluginStats: getPluginProfileStats(userId)
}; };
} }
@ -425,6 +427,14 @@ function buildCompareRows(leftStats, rightStats) {
]; ];
pushSection("Community Interaction", leftCommunity, rightCommunity); pushSection("Community Interaction", leftCommunity, rightCommunity);
if (leftStats.tenureStats?.length || rightStats.tenureStats?.length) {
pushSection(
"Platform tenure",
(leftStats.tenureStats || []).map((entry) => ({ label: entry.label, value: entry.total })),
(rightStats.tenureStats || []).map((entry) => ({ label: entry.label, value: entry.total }))
);
}
if (leftStats.expression || rightStats.expression) { if (leftStats.expression || rightStats.expression) {
const leftExpression = leftStats.expression const leftExpression = leftStats.expression
? [ ? [
@ -3113,7 +3123,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
// network. Public clients remain unable to forge forwarding headers. // network. Public clients remain unable to forge forwarding headers.
app.set("trust proxy", isTrustedProxyAddress); app.set("trust proxy", isTrustedProxyAddress);
const webhooks = createWebhookService(); const webhooks = createWebhookService();
placeholders.registerCorePlaceholders(); placeholders.registerCorePlaceholders({ userAgeStatistics });
placeholders.registerPlatformPlaceholders({ placeholders.registerPlatformPlaceholders({
discordClient, discordClient,
getTwitchClient, getTwitchClient,
@ -5280,6 +5290,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
title: "Your stats", title: "Your stats",
stats: payload.stats, stats: payload.stats,
expression: payload.expression, expression: payload.expression,
tenureStats: payload.tenureStats,
pluginStats: payload.pluginStats, pluginStats: payload.pluginStats,
statsOwner: { statsOwner: {
username: req.session.user.username, username: req.session.user.username,
@ -5324,6 +5335,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
title: `${profile.internal_username}'s stats`, title: `${profile.internal_username}'s stats`,
stats: payload.stats, stats: payload.stats,
expression: payload.expression, expression: payload.expression,
tenureStats: payload.tenureStats,
pluginStats: payload.pluginStats, pluginStats: payload.pluginStats,
statsOwner: { statsOwner: {
username: profile.internal_username, username: profile.internal_username,
@ -6212,6 +6224,21 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
renderDiagnosticsAdmin(req, res); renderDiagnosticsAdmin(req, res);
}); });
app.get("/admin/platform-tenure", requireRole("admin"), (req, res) => {
const filters = {
platform: String(req.query.platform || "").trim().toLowerCase(),
scopeId: String(req.query.scope || "").trim(),
userId: String(req.query.user || "").trim(),
type: String(req.query.type || "").trim().toLowerCase()
};
res.render("admin-platform-tenure", {
title: "Platform tenure diagnostics",
intervals: userAgeStatistics.listDiagnostics(filters),
statistics: userAgeStatistics.listStatistics(),
filters
});
});
app.get("/admin/stream-testing", requireRole("admin"), (req, res) => { app.get("/admin/stream-testing", requireRole("admin"), (req, res) => {
res.set("Cache-Control", "no-store"); res.set("Cache-Control", "no-store");
res.render("admin-stream-testing", { res.render("admin-stream-testing", {
@ -7969,6 +7996,7 @@ function collectNavItems(user, pluginNav, currentPath) {
}, },
{ label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" }, { label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" },
{ label: "Stream testing", path: "/admin/stream-testing", role: "admin", section: "admin" }, { label: "Stream testing", path: "/admin/stream-testing", role: "admin", section: "admin" },
{ label: "Platform tenure", path: "/admin/platform-tenure", role: "admin", section: "admin" },
{ label: "Diagnostics", path: "/admin/diagnostics", role: "admin", section: "admin" }, { label: "Diagnostics", path: "/admin/diagnostics", role: "admin", section: "admin" },
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" }, { label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" }, { label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
@ -8237,6 +8265,7 @@ function getDefaultNavIcon(item) {
if (pathName === "/admin/theming") return "theming"; if (pathName === "/admin/theming") return "theming";
if (pathName === "/admin/privileges") return "privileges"; if (pathName === "/admin/privileges") return "privileges";
if (pathName === "/admin/diagnostics") return "admin"; if (pathName === "/admin/diagnostics") return "admin";
if (pathName === "/admin/platform-tenure") return "admin";
if (pathName === "/admin/logs") return "logs"; if (pathName === "/admin/logs") return "logs";
if (pathName === "/admin/updates") return "updates"; if (pathName === "/admin/updates") return "updates";
if (pathName === "/admin/commands") return "commands"; if (pathName === "/admin/commands") return "commands";

View File

@ -0,0 +1,98 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<%- include("partials/page-header", {
eyebrow: "Diagnostics",
pageTitle: "Platform tenure",
description: "Inspect the authoritative and first-observed intervals behind age placeholders, profile stats, and leaderboards."
}) %>
</section>
<section class="card">
<details>
<summary>Filter intervals</summary>
<form method="get" action="/admin/platform-tenure" class="form-grid">
<label>
Platform
<select name="platform">
<option value="">All platforms</option>
<% ["twitch", "youtube", "discord"].forEach((platform) => { %>
<option value="<%= platform %>" <%= filters.platform === platform ? "selected" : "" %>><%= platform %></option>
<% }) %>
</select>
</label>
<label>
Statistic
<select name="type">
<option value="">All statistics</option>
<% [...new Set(statistics.map((entry) => entry.type))].forEach((type) => { %>
<option value="<%= type %>" <%= filters.type === type ? "selected" : "" %>><%= type %></option>
<% }) %>
</select>
</label>
<label>
Scope ID
<input name="scope" value="<%= filters.scopeId %>" autocomplete="off" />
</label>
<label>
Platform user ID
<input name="user" value="<%= filters.userId %>" autocomplete="off" />
</label>
<div class="form-actions">
<button class="button" type="submit">Apply filters</button>
<a class="button subtle" href="/admin/platform-tenure">Clear</a>
</div>
</form>
</details>
</section>
<section class="card">
<div class="stats-header">
<h2>Recorded intervals</h2>
<span class="pill"><%= intervals.length %> shown</span>
</div>
<% if (!intervals.length) { %>
<p>No matching intervals have been recorded.</p>
<% } else { %>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>State</th>
<th>Platform / statistic</th>
<th>User / scope</th>
<th>Start</th>
<th>End</th>
<th>Timestamp provenance</th>
</tr>
</thead>
<tbody>
<% intervals.forEach((interval) => { %>
<tr>
<td><span class="pill <%= interval.end_at === null ? "allowed" : "" %>"><%= interval.end_at === null ? "Current" : "Previous" %></span></td>
<td><strong><%= interval.platform %></strong><br /><span class="help"><%= interval.statistic_type %></span></td>
<td><code><%= interval.platform_user_id %></code><br /><span class="help">Scope <code><%= interval.scope_id %></code></span></td>
<td><time datetime="<%= new Date(interval.start_at).toISOString() %>"><%= new Date(interval.start_at).toLocaleString() %></time></td>
<td>
<% if (interval.end_at === null) { %>
Active
<% } else { %>
<time datetime="<%= new Date(interval.end_at).toISOString() %>"><%= new Date(interval.end_at).toLocaleString() %></time>
<% } %>
</td>
<td>
<%= interval.start_source %>
<br />
<span class="help"><%= interval.start_authoritative ? "Authoritative" : "First observed" %> · <%= interval.start_precision %></span>
<% if (interval.end_at !== null) { %>
<br />
<span class="help">Ended by <%= interval.end_source || "unknown source" %> · <%= interval.end_authoritative ? "authoritative" : "observed" %></span>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>
<%- include("partials/layout-bottom") %>

View File

@ -40,7 +40,7 @@
<tr> <tr>
<td> <td>
<% if (rowType === "user" && entry.username) { %> <% if (rowType === "user" && entry.username) { %>
<a class="link" href="/stats/<%= encodeURIComponent(entry.username) %>"><%= entry.username %></a> <a class="link" href="/stats/<%= encodeURIComponent(entry.username) %>"><%= entryLabel %></a>
<% } else if (rowType === "command") { %> <% } else if (rowType === "command") { %>
<% if (entry.href) { %> <% if (entry.href) { %>
<a class="link" href="<%= entry.href %>"><code><%= entryLabel %></code></a> <a class="link" href="<%= entry.href %>"><code><%= entryLabel %></code></a>

View File

@ -35,6 +35,25 @@
<% } %> <% } %>
</section> </section>
<section class="card">
<h2>Platform tenure</h2>
<% if (!tenureStats || !tenureStats.length) { %>
<p>Follow, membership, role, and server tenure will appear here as Lumi observes it.</p>
<% } else { %>
<div class="stat-grid">
<% tenureStats.forEach((stat) => { %>
<div class="stat">
<span class="stat-label"><%= stat.label %></span>
<span class="stat-value"><%= stat.total %></span>
<% if (stat.current) { %>
<span class="help">Current period: <%= stat.current %></span>
<% } %>
</div>
<% }) %>
</div>
<% } %>
</section>
<section class="card"> <section class="card">
<h2>Expression Interaction</h2> <h2>Expression Interaction</h2>
<% if (!expression) { %> <% if (!expression) { %>

View File

@ -1,14 +1,14 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.3.9", "version": "0.3.10",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-26", "released_at": "2026-07-27",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
"migration_kind": "patch", "migration_kind": "patch",
"replaces_versions": [ "replaces_versions": [
"1.2.0" "1.2.0"
], ],
"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.", "migration_notes": "Adds reusable platform-tenure interval history for Twitch, YouTube, and Discord; contextual current and total duration placeholders with deterministic formatting; safe platform reconciliation; admin diagnostics; and Platform tenure sections on Stats and Leaderboards. Existing settings, identities, statistics, intervals, plugins, pairing records, 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"
@ -457,6 +457,18 @@
], ],
"rollback_safe": true, "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." "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."
},
{
"version": "0.3.9",
"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": "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."
} }
] ]
} }