release: publish Lumi 0.3.10 platform tenure
This commit is contained in:
parent
5c85ba400d
commit
0fb0089e1c
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
44
docs/platform-tenure.md
Normal file
44
docs/platform-tenure.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
175
scripts/verify-user-age-statistics.js
Normal file
175
scripts/verify-user-age-statistics.js
Normal 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);
|
||||
});
|
||||
@ -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() },
|
||||
|
||||
@ -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"
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -55,6 +56,11 @@ async function startBot({ commandRouter } = {}) {
|
||||
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;
|
||||
|
||||
54
src/services/duration-format.js
Normal file
54
src/services/duration-format.js
Normal 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
|
||||
};
|
||||
@ -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 };
|
||||
|
||||
@ -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", []));
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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", {
|
||||
|
||||
122
src/services/twitch-user-age.js
Normal file
122
src/services/twitch-user-age.js
Normal 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
|
||||
};
|
||||
@ -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",
|
||||
|
||||
423
src/services/user-age-statistics.js
Normal file
423
src/services/user-age-statistics.js
Normal 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
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
98
src/web/views/admin-platform-tenure.ejs
Normal file
98
src/web/views/admin-platform-tenure.ejs
Normal 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") %>
|
||||
@ -40,7 +40,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<% 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") { %>
|
||||
<% if (entry.href) { %>
|
||||
<a class="link" href="<%= entry.href %>"><code><%= entryLabel %></code></a>
|
||||
|
||||
@ -35,6 +35,25 @@
|
||||
<% } %>
|
||||
</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">
|
||||
<h2>Expression Interaction</h2>
|
||||
<% if (!expression) { %>
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "Lumi Core",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.10",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-07-26",
|
||||
"released_at": "2026-07-27",
|
||||
"compatible_from": "0.1.9",
|
||||
"migration_kind": "patch",
|
||||
"replaces_versions": [
|
||||
"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,
|
||||
"requirements": [
|
||||
"Node.js 18 or newer"
|
||||
@ -457,6 +457,18 @@
|
||||
],
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user