diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef47655..72e3d39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# 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
- 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.
diff --git a/docs/placeholder-system.md b/docs/placeholder-system.md
index f280e1a..37b6cae 100644
--- a/docs/placeholder-system.md
+++ b/docs/placeholder-system.md
@@ -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
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:
- placeholder definitions with metadata and a resolver function
diff --git a/docs/platform-tenure.md b/docs/platform-tenure.md
new file mode 100644
index 0000000..514b9b1
--- /dev/null
+++ b/docs/platform-tenure.md
@@ -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.
diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md
index 38b311b..87eec8f 100644
--- a/knowledge/core/lumi-core.md
+++ b/knowledge/core/lumi-core.md
@@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
-Version: 0.3.9
+Version: 0.3.10
## Routes
- POST /api/diagnostics/v1/run
- GET /api/events
diff --git a/package-lock.json b/package-lock.json
index 0c8ffc7..3c43410 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "lumi-bot",
- "version": "0.3.9",
+ "version": "0.3.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
- "version": "0.3.9",
+ "version": "0.3.10",
"dependencies": {
"adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0",
diff --git a/package.json b/package.json
index 2263edb..9070194 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lumi-bot",
- "version": "0.3.9",
+ "version": "0.3.10",
"private": true,
"type": "commonjs",
"scripts": {
@@ -26,7 +26,8 @@
"verify:transcription": "node plugins/lumi_transcription/tests/verify.js",
"verify:companion-overlay": "node plugins/lumi_overlay/tests/verify.js",
"verify:auto-vc": "node plugins/auto-vc/tests/verify.js",
- "verify:dev-updates": "node scripts/verify-local-development-updates.js"
+ "verify:dev-updates": "node scripts/verify-local-development-updates.js",
+ "verify:user-age": "node scripts/verify-user-age-statistics.js"
},
"engines": {
"node": ">=18"
diff --git a/release-index.json b/release-index.json
index 3661445..719e54f 100644
--- a/release-index.json
+++ b/release-index.json
@@ -2,6 +2,39 @@
"schema_version": 1,
"channel": "stable",
"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",
"ref": "refs/tags/v0.3.9",
diff --git a/scripts/verify-all.js b/scripts/verify-all.js
index c68ac23..552e706 100644
--- a/scripts/verify-all.js
+++ b/scripts/verify-all.js
@@ -11,6 +11,7 @@ const checks = [
"scripts/verify-feedback-system.js",
"scripts/verify-logging.js",
"scripts/verify-placeholders.js",
+ "scripts/verify-user-age-statistics.js",
"scripts/verify-release-metadata.js",
"scripts/verify-update-system.js",
"scripts/verify-local-development-updates.js",
diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js
index 741ecd1..01da638 100644
--- a/scripts/verify-release-metadata.js
+++ b/scripts/verify-release-metadata.js
@@ -4,9 +4,9 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
-const releaseVersion = "0.3.9";
-const previousStableVersion = "0.3.8";
-const priorStableVersion = "0.3.7";
+const releaseVersion = "0.3.10";
+const previousStableVersion = "0.3.9";
+const priorStableVersion = "0.3.8";
const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = {
"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(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.");
diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js
index 011bdfd..0bf69c2 100644
--- a/scripts/verify-update-system.js
+++ b/scripts/verify-update-system.js
@@ -24,7 +24,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
-assert.deepEqual(releaseVersions, ["0.3.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");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
const coreManifest = readJson("update-manifest.json");
assert.equal(packageVersion, coreManifest.version);
assert.equal(coreManifest.channel, "stable");
-assert.equal(packageVersion, "0.3.9");
-assert.equal(currentRelease.version, "0.3.9");
+assert.equal(packageVersion, "0.3.10");
+assert.equal(currentRelease.version, "0.3.10");
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
@@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
available_versions: [
+ { version: "0.3.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.8", ref: "refs/tags/v0.3.8", 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"
});
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.blocked, false);
diff --git a/scripts/verify-user-age-statistics.js b/scripts/verify-user-age-statistics.js
new file mode 100644
index 0000000..9756aab
--- /dev/null
+++ b/scripts/verify-user-age-statistics.js
@@ -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);
+});
diff --git a/src/main.js b/src/main.js
index f5bce69..3463af4 100644
--- a/src/main.js
+++ b/src/main.js
@@ -28,6 +28,7 @@ const { overlayConnectorManager } = require("./services/overlay-connectors");
const { cleanupSnapshots } = require("./services/update-manager");
const { twitchEventSubManager } = require("./services/twitch-eventsub");
const { streamTestingService } = require("./services/stream-testing");
+const { userAgeStatistics } = require("./services/user-age-statistics");
const {
isSafeModeRequested,
markStartupVerification
@@ -56,7 +57,8 @@ async function main() {
plugin_count: completedPluginSync.plugin_ids.length
}, { event: "bundled_plugin_sync_completed" });
}
- registerCorePlaceholders();
+ userAgeStatistics.start();
+ registerCorePlaceholders({ userAgeStatistics });
const logCleanup = logger.cleanupLogs({
maxAgeDays: getSetting("log_retention_days", logger.DEFAULT_MAX_AGE_DAYS),
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));
for (const service of [
{ name: "stream_testing", stop: () => streamTestingService.close() },
+ { name: "user_age_statistics", stop: () => userAgeStatistics.stop() },
{ name: "overlay_connectors", stop: () => overlayConnectorManager.stop() },
{ name: "twitch_eventsub", stop: () => twitchEventSubManager.stop() },
{ name: "plugins", stop: () => stopPlugins() },
diff --git a/src/services/auth.js b/src/services/auth.js
index 6817265..363db23 100644
--- a/src/services/auth.js
+++ b/src/services/auth.js
@@ -92,7 +92,7 @@ function buildTwitchEventAuthUrl(state, redirectOverride) {
client_id: clientId || "",
redirect_uri: redirectUri || "",
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,
force_verify: "true"
});
diff --git a/src/services/db.js b/src/services/db.js
index f0eb353..556bb87 100644
--- a/src/services/db.js
+++ b/src/services/db.js
@@ -103,6 +103,39 @@ function migrate() {
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
diff --git a/src/services/discord.js b/src/services/discord.js
index f240d87..a901073 100644
--- a/src/services/discord.js
+++ b/src/services/discord.js
@@ -11,6 +11,7 @@ const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat");
const { discordOverlayContent } = require("./discord-chat-content");
const { emitLumiEvent } = require("./lumi-events");
+const { userAgeStatistics } = require("./user-age-statistics");
const discordLog = createLogger("platform:discord", { category: "integration" });
@@ -49,12 +50,17 @@ async function startBot({ commandRouter } = {}) {
client = new Client(options);
- client.on("ready", () => {
+ client.on("ready", () => {
discordLog.info("Discord bot ready", { account: client.user?.tag || null }, { event: "platform_ready" });
const avatarUrl = getBotAvatarUrl(client.user);
- if (avatarUrl) {
- setSetting("bot_avatar_url", avatarUrl);
- }
+ if (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) => {
@@ -76,6 +82,9 @@ async function startBot({ commandRouter } = {}) {
displayName,
avatar: avatarUrl
});
+ if (!message.author.bot && message.member) {
+ observeDiscordMember(message.member, message.createdTimestamp || Date.now(), `discord-message:${message.id}`);
+ }
publishOverlayChatMessage({
id: message.id,
platform: "discord",
@@ -140,6 +149,29 @@ async function startBot({ commandRouter } = {}) {
source: "discord-gateway",
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);
@@ -176,6 +208,98 @@ function resolveIntent(key, legacyKey) {
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) {
if (Partials?.[key] !== undefined) return Partials[key];
return legacyKey;
diff --git a/src/services/duration-format.js b/src/services/duration-format.js
new file mode 100644
index 0000000..b147ccd
--- /dev/null
+++ b/src/services/duration-format.js
@@ -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
+};
diff --git a/src/services/lumi-events.js b/src/services/lumi-events.js
index 3dac1ad..c8c1df2 100644
--- a/src/services/lumi-events.js
+++ b/src/services/lumi-events.js
@@ -62,8 +62,13 @@ function emitLumiEvent(type, payload = {}, metadata = {}) {
for (const definition of [
{ 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.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.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" },
@@ -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.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: "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);
module.exports = { emitLumiEvent, listEventTypes, onLumiEvent, registerEventType };
diff --git a/src/services/placeholders.js b/src/services/placeholders.js
index 6543ca8..ab33762 100644
--- a/src/services/placeholders.js
+++ b/src/services/placeholders.js
@@ -1,6 +1,7 @@
const { db } = require("./db");
const { getSetting } = require("./settings");
const { hasAccess } = require("./rbac");
+const { parseDurationFormat } = require("./duration-format");
const ROLE_LEVELS = Object.freeze({
public: 0,
@@ -23,6 +24,7 @@ const SENSITIVITY_LEVELS = Object.freeze({
const VALUE_TYPES = new Set(["string", "number", "boolean", "url", "json", "date"]);
const placeholders = 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") {
const role = String(value || fallback).trim().toLowerCase();
@@ -100,7 +102,8 @@ function normalizeDefinition(definition = {}) {
example: definition.example === undefined ? null : String(definition.example),
plugin_id: definition.plugin_id ? String(definition.plugin_id).trim() : null,
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) {
const found = [];
- const matcher = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
+ const matcher = new RegExp(PLACEHOLDER_PATTERN.source, PLACEHOLDER_PATTERN.flags);
let match = null;
while ((match = matcher.exec(String(template || "")))) {
found.push({
token: match[0],
- id: normalizeId(match[1]),
- index: match.index
+ id: normalizeId(match[1] || match[3]),
+ index: match.index,
+ format: match[2] || null
});
}
return found;
@@ -277,6 +281,7 @@ function catalog({ fieldId, user, outputAudience, runtimeContext } = {}) {
value_type: definition.value_type,
sensitivity: definition.sensitivity,
group: definition.group,
+ supports_duration_format: definition.supports_duration_format,
example: definition.sensitivity === "public_safe" ? definition.example : null
}))
.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)) {
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, {
user,
outputAudience,
@@ -328,8 +346,8 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
};
}
const errors = [];
- const rendered = await replaceAsync(String(template || ""), /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, async (match, rawId) => {
- const id = normalizeId(rawId);
+ const rendered = await replaceAsync(String(template || ""), PLACEHOLDER_PATTERN, async (match, formattedId, format, regularId) => {
+ const id = normalizeId(formattedId || regularId);
const definition = findDefinition(id);
const access = checkPlaceholderAccess(definition, policy, {
user,
@@ -340,6 +358,16 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
errors.push({ token: match, id, reason: access.reason });
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 {
const value = await withTimeout(Promise.resolve(definition.resolver({
user,
@@ -347,7 +375,8 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience),
runtimeContext,
token: match,
- id
+ id,
+ format: format || null
})), runtimeContext?.placeholder_timeout_ms);
return stringifyResolvedValue(value);
} catch (error) {
@@ -393,14 +422,22 @@ function withTimeout(promise, requestedMs) {
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
-function registerCorePlaceholders() {
+function registerCorePlaceholders({ userAgeStatistics } = {}) {
registerFieldPolicy({
field_id: "core.custom_commands.static_response",
label: "Custom command response",
field_type: "command_response",
output_audience: "user",
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"
});
registerPlaceholders([
@@ -461,6 +498,9 @@ function registerCorePlaceholders() {
""
}
]);
+ if (userAgeStatistics?.placeholderDefinitions) {
+ registerPlaceholders(userAgeStatistics.placeholderDefinitions());
+ }
registerCustomPlaceholders(getSetting("custom_placeholders", []));
}
diff --git a/src/services/top.js b/src/services/top.js
index d132f3a..cb8c669 100644
--- a/src/services/top.js
+++ b/src/services/top.js
@@ -5,6 +5,7 @@ const { getSetting } = require("./settings");
const { getEnabledPlatformIds } = require("./platforms");
const { getPluginLeaderboards } = require("./plugin-stats");
const { getPlugins, pluginsDir } = require("./plugins");
+const { userAgeStatistics } = require("./user-age-statistics");
const coreProviders = new Map();
const coreOrder = [];
@@ -97,17 +98,31 @@ function ensureCoreProviders() {
description: "Most popular commands across platforms.",
getRows: ({ limit }) => buildCommandUsageRows(limit)
});
- registerTopProvider({
- id: "followage",
- label: "Top followage",
- section: "Platforms",
- valueLabel: "Days",
- description: "Longest follower durations.",
- getRows: () => ({
- rows: [],
- emptyMessage: "Followage tracking is not configured yet."
- })
- });
+ 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({
+ ...definition,
+ section: "Platform tenure",
+ valueLabel: "Recorded tenure",
+ description: `${definition.label} across linked Lumi profiles.`,
+ getRows: ({ limit }) => ({
+ rows: userAgeStatistics.getLeaderboard(definition.type, {
+ platform: definition.platform,
+ limit
+ }),
+ emptyMessage: "No recorded tenure is available yet."
+ })
+ });
+ }
registerTopProvider({
id: "watchtime",
label: "Top watchtime",
diff --git a/src/services/twitch-eventsub.js b/src/services/twitch-eventsub.js
index 8108469..dd3bf28 100644
--- a/src/services/twitch-eventsub.js
+++ b/src/services/twitch-eventsub.js
@@ -3,6 +3,7 @@ const { getSetting, setSetting } = require("./settings");
const { emitLumiEvent } = require("./lumi-events");
const { createLogger } = require("./logger");
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 eventLog = createLogger("platform:twitch:eventsub", { category: "integration" });
@@ -70,6 +71,8 @@ class TwitchEventSubManager {
this.running = false;
this.reconnectTimer = null;
this.reconnectAttempt = 0;
+ this.reconcileTimer = null;
+ this.reconcileContext = null;
this.generation = 0;
this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 };
}
@@ -98,7 +101,10 @@ class TwitchEventSubManager {
this.running = false;
this.generation += 1;
clearTimeout(this.reconnectTimer);
+ clearInterval(this.reconcileTimer);
this.reconnectTimer = null;
+ this.reconcileTimer = null;
+ this.reconcileContext = null;
const socket = this.socket;
this.socket = null;
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) {
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 } });
}
+ 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)
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)
@@ -235,6 +250,15 @@ class TwitchEventSubManager {
}
}
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 = {
state: "connected",
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",
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",
- 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" });
@@ -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.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.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.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", {
diff --git a/src/services/twitch-user-age.js b/src/services/twitch-user-age.js
new file mode 100644
index 0000000..a0e0a22
--- /dev/null
+++ b/src/services/twitch-user-age.js
@@ -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
+};
diff --git a/src/services/twitch.js b/src/services/twitch.js
index bf728e9..d771322 100644
--- a/src/services/twitch.js
+++ b/src/services/twitch.js
@@ -5,6 +5,7 @@ const { ensureUserForIdentity } = require("./users");
const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat");
const { resolveBetterTtvEmotes, resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets");
+const { userAgeStatistics } = require("./user-age-statistics");
const twitchLog = createLogger("platform:twitch", { category: "integration" });
@@ -57,6 +58,28 @@ async function startTwitchBot({ commandRouter } = {}) {
displayName,
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({
id: tags.id,
platform: "twitch",
diff --git a/src/services/user-age-statistics.js b/src/services/user-age-statistics.js
new file mode 100644
index 0000000..062e775
--- /dev/null
+++ b/src/services/user-age-statistics.js
@@ -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
+};
diff --git a/src/services/youtube.js b/src/services/youtube.js
index 4713ec0..44440e7 100644
--- a/src/services/youtube.js
+++ b/src/services/youtube.js
@@ -5,6 +5,7 @@ const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat");
const { emitLumiEvent } = require("./lumi-events");
const { setPlatformLiveState } = require("./platform-live-state");
+const { userAgeStatistics } = require("./user-age-statistics");
const youtubeLog = createLogger("platform:youtube", { category: "integration" });
@@ -119,14 +120,41 @@ async function handleChatItem(state, liveChatId, item) {
if (!snippet || !author) {
return;
}
- const messageText = snippet.displayMessage;
- if (!messageText) {
- return;
- }
+ const messageText = snippet.displayMessage || "";
const displayName = author.displayName || "YouTube User";
const avatar = author.profileImageUrl || null;
const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
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({
provider: "youtube",
providerUserId: author.channelId,
diff --git a/src/web/server.js b/src/web/server.js
index 708ecdb..4c7b3d8 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -80,6 +80,7 @@ const { getClient: getTwitchClient } = require("../services/twitch");
const { twitchEventSubManager } = require("../services/twitch-eventsub");
const { eventHooksApi } = require("../services/overlay-event-hooks");
const { streamTestingService } = require("../services/stream-testing");
+const { userAgeStatistics } = require("../services/user-age-statistics");
const {
getReverseProxyIngestSettings,
saveReverseProxyIngestSettings
@@ -379,7 +380,7 @@ function getExpressionUserSummary(userId) {
function buildUserStatsPayload(userId) {
if (!userId) {
- return { stats: null, expression: null, pluginStats: [] };
+ return { stats: null, expression: null, tenureStats: [], pluginStats: [] };
}
const stats = db
.prepare("SELECT * FROM stats WHERE user_id = ?")
@@ -387,6 +388,7 @@ function buildUserStatsPayload(userId) {
return {
stats,
expression: getExpressionUserSummary(userId),
+ tenureStats: userAgeStatistics.getProfileStatistics(userId),
pluginStats: getPluginProfileStats(userId)
};
}
@@ -425,6 +427,14 @@ function buildCompareRows(leftStats, rightStats) {
];
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) {
const leftExpression = leftStats.expression
? [
@@ -3113,7 +3123,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
// network. Public clients remain unable to forge forwarding headers.
app.set("trust proxy", isTrustedProxyAddress);
const webhooks = createWebhookService();
- placeholders.registerCorePlaceholders();
+ placeholders.registerCorePlaceholders({ userAgeStatistics });
placeholders.registerPlatformPlaceholders({
discordClient,
getTwitchClient,
@@ -5280,6 +5290,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
title: "Your stats",
stats: payload.stats,
expression: payload.expression,
+ tenureStats: payload.tenureStats,
pluginStats: payload.pluginStats,
statsOwner: {
username: req.session.user.username,
@@ -5324,6 +5335,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
title: `${profile.internal_username}'s stats`,
stats: payload.stats,
expression: payload.expression,
+ tenureStats: payload.tenureStats,
pluginStats: payload.pluginStats,
statsOwner: {
username: profile.internal_username,
@@ -6212,6 +6224,21 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
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) => {
res.set("Cache-Control", "no-store");
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: "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: "Logs", path: "/admin/logs", 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/privileges") return "privileges";
if (pathName === "/admin/diagnostics") return "admin";
+ if (pathName === "/admin/platform-tenure") return "admin";
if (pathName === "/admin/logs") return "logs";
if (pathName === "/admin/updates") return "updates";
if (pathName === "/admin/commands") return "commands";
diff --git a/src/web/views/admin-platform-tenure.ejs b/src/web/views/admin-platform-tenure.ejs
new file mode 100644
index 0000000..39a3209
--- /dev/null
+++ b/src/web/views/admin-platform-tenure.ejs
@@ -0,0 +1,98 @@
+<%- include("partials/layout-top", { title }) %>
+ No matching intervals have been recorded.Filter intervals
+
+ Recorded intervals
+ <%= intervals.length %> shown
+
+
+
+
+
+
+
+ <% intervals.forEach((interval) => { %>
+ State
+ Platform / statistic
+ User / scope
+ Start
+ End
+ Timestamp provenance
+
+
+ <% }) %>
+
+ "><%= interval.end_at === null ? "Current" : "Previous" %>
+ <%= interval.platform %>
+
<%= interval.statistic_type %>
+ <%= interval.platform_user_id %>
Scope <%= interval.scope_id %>
+
+ <% if (interval.end_at === null) { %>
+ Active
+ <% } else { %>
+
+ <% } %>
+
+
+ <%= interval.start_source %>
+
+
+ <%= interval.start_authoritative ? "Authoritative" : "First observed" %> · <%= interval.start_precision %>
+ <% if (interval.end_at !== null) { %>
+
+ Ended by <%= interval.end_source || "unknown source" %> · <%= interval.end_authoritative ? "authoritative" : "observed" %>
+ <% } %>
+
<%= entryLabel %>
diff --git a/src/web/views/stats.ejs b/src/web/views/stats.ejs
index 9b0ce33..5e38308 100644
--- a/src/web/views/stats.ejs
+++ b/src/web/views/stats.ejs
@@ -35,6 +35,25 @@
<% } %>
+ Follow, membership, role, and server tenure will appear here as Lumi observes it.
+ <% } else { %> +