release: publish Lumi 0.3.10 platform tenure
This commit is contained in:
parent
5c85ba400d
commit
0fb0089e1c
@ -1,5 +1,13 @@
|
|||||||
# Lumi changelog
|
# Lumi changelog
|
||||||
|
|
||||||
|
## 0.3.10
|
||||||
|
|
||||||
|
- Added an extensible UTC interval-history engine for Twitch follows, subscriptions, moderators, editors, and VIPs; YouTube channel memberships and moderators; and Discord membership and server boosts.
|
||||||
|
- Added idempotent live-event handling, complete-snapshot reconciliation that never treats API failures as empty state, authoritative timestamp backfill for open intervals, and preserved history across restarts and account changes.
|
||||||
|
- Added contextual `current` and `total` age placeholders with deterministic years/months, selected-unit remainder flow, singular/plural output, and the shared embedded `{format.YY-MM-DD-HH-mm-ss}` grammar.
|
||||||
|
- Added Platform tenure summaries to Stats and scope-separated Platform tenure boards to Leaderboards, plus admin interval diagnostics with source, precision, and authoritative/first-observed provenance.
|
||||||
|
- Preserved all existing settings, identities, statistics, interval history, plugins, pairing records, databases, uploads, models, and secrets through additive migrations and the normal updater.
|
||||||
|
|
||||||
## 0.3.9
|
## 0.3.9
|
||||||
|
|
||||||
- Restored animated Twitch/BTTV and Discord emotes plus Discord GIF media across the existing normalized OBS and native Companion overlay paths, and rendered real Twitch badge artwork with safe fallbacks.
|
- Restored animated Twitch/BTTV and Discord emotes plus Discord GIF media across the existing normalized OBS and native Companion overlay paths, and rendered real Twitch badge artwork with safe fallbacks.
|
||||||
|
|||||||
@ -11,6 +11,10 @@ The frontend must not declare placeholder permissions, allowed plugins, or
|
|||||||
sensitivity. Editable fields reference a trusted `field_id`, and the server
|
sensitivity. Editable fields reference a trusted `field_id`, and the server
|
||||||
uses that field policy to decide which placeholders are available.
|
uses that field policy to decide which placeholders are available.
|
||||||
|
|
||||||
|
Platform membership and role duration placeholders, including their embedded
|
||||||
|
`{format.YY-MM-DD}` grammar, are documented in
|
||||||
|
[platform-tenure.md](platform-tenure.md).
|
||||||
|
|
||||||
Core and plugins can register:
|
Core and plugins can register:
|
||||||
|
|
||||||
- placeholder definitions with metadata and a resolver function
|
- placeholder definitions with metadata and a resolver function
|
||||||
|
|||||||
44
docs/platform-tenure.md
Normal file
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.
|
Lumi is the core web UI and bot runtime.
|
||||||
## Runtime
|
## Runtime
|
||||||
Package: lumi-bot
|
Package: lumi-bot
|
||||||
Version: 0.3.9
|
Version: 0.3.10
|
||||||
## Routes
|
## Routes
|
||||||
- POST /api/diagnostics/v1/run
|
- POST /api/diagnostics/v1/run
|
||||||
- GET /api/events
|
- GET /api/events
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.9",
|
"version": "0.3.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.9",
|
"version": "0.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.6.0",
|
"adm-zip": "^0.6.0",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.9",
|
"version": "0.3.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -26,7 +26,8 @@
|
|||||||
"verify:transcription": "node plugins/lumi_transcription/tests/verify.js",
|
"verify:transcription": "node plugins/lumi_transcription/tests/verify.js",
|
||||||
"verify:companion-overlay": "node plugins/lumi_overlay/tests/verify.js",
|
"verify:companion-overlay": "node plugins/lumi_overlay/tests/verify.js",
|
||||||
"verify:auto-vc": "node plugins/auto-vc/tests/verify.js",
|
"verify:auto-vc": "node plugins/auto-vc/tests/verify.js",
|
||||||
"verify:dev-updates": "node scripts/verify-local-development-updates.js"
|
"verify:dev-updates": "node scripts/verify-local-development-updates.js",
|
||||||
|
"verify:user-age": "node scripts/verify-user-age-statistics.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@ -2,6 +2,39 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"releases": [
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "0.3.10",
|
||||||
|
"ref": "refs/tags/v0.3.10",
|
||||||
|
"released_at": "2026-07-27",
|
||||||
|
"installable": true,
|
||||||
|
"rollback_safe": true,
|
||||||
|
"replaces_versions": [
|
||||||
|
"1.2.0"
|
||||||
|
],
|
||||||
|
"data_policy": "preserve",
|
||||||
|
"dependency_policy": "sync_on_restart",
|
||||||
|
"migration_notes": "Adds reusable platform-tenure interval history for Twitch, YouTube, and Discord; contextual current and total duration placeholders with deterministic formatting; safe platform reconciliation; admin diagnostics; and Platform tenure sections on Stats and Leaderboards. Existing settings, identities, statistics, intervals, plugins, pairing records, databases, uploads, models, and secrets remain preserved.",
|
||||||
|
"plugins": {
|
||||||
|
"auto-vc": "0.1.7",
|
||||||
|
"birthday": "0.1.3",
|
||||||
|
"economy-framework": "0.2.10",
|
||||||
|
"economy-games": "0.1.7",
|
||||||
|
"expression-interaction": "0.2.1",
|
||||||
|
"lumi_ai": "0.8.5",
|
||||||
|
"lumi_overlay": "0.1.1",
|
||||||
|
"lumi_transcription": "0.2.7",
|
||||||
|
"moderation": "0.1.5",
|
||||||
|
"now_playing": "0.1.3",
|
||||||
|
"okf": "0.1.2",
|
||||||
|
"quotes": "0.1.2",
|
||||||
|
"sample-plugin": "0.1.0",
|
||||||
|
"throne_wishlist": "0.1.2",
|
||||||
|
"welcome_messages": "0.1.1"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"lumi_ai_web_search": "0.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.3.9",
|
"version": "0.3.9",
|
||||||
"ref": "refs/tags/v0.3.9",
|
"ref": "refs/tags/v0.3.9",
|
||||||
|
|||||||
@ -11,6 +11,7 @@ const checks = [
|
|||||||
"scripts/verify-feedback-system.js",
|
"scripts/verify-feedback-system.js",
|
||||||
"scripts/verify-logging.js",
|
"scripts/verify-logging.js",
|
||||||
"scripts/verify-placeholders.js",
|
"scripts/verify-placeholders.js",
|
||||||
|
"scripts/verify-user-age-statistics.js",
|
||||||
"scripts/verify-release-metadata.js",
|
"scripts/verify-release-metadata.js",
|
||||||
"scripts/verify-update-system.js",
|
"scripts/verify-update-system.js",
|
||||||
"scripts/verify-local-development-updates.js",
|
"scripts/verify-local-development-updates.js",
|
||||||
|
|||||||
@ -4,9 +4,9 @@ const path = require("path");
|
|||||||
const { findSafeTarget } = require("../src/services/versioning");
|
const { findSafeTarget } = require("../src/services/versioning");
|
||||||
|
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const releaseVersion = "0.3.9";
|
const releaseVersion = "0.3.10";
|
||||||
const previousStableVersion = "0.3.8";
|
const previousStableVersion = "0.3.9";
|
||||||
const priorStableVersion = "0.3.7";
|
const priorStableVersion = "0.3.8";
|
||||||
const earliestCompatibleCoreVersion = "0.1.9";
|
const earliestCompatibleCoreVersion = "0.1.9";
|
||||||
const introducedPlugins = {
|
const introducedPlugins = {
|
||||||
"auto-vc": { version: "0.1.7", knowledge: "auto-vc", compatibleFrom: "0.1.5" },
|
"auto-vc": { version: "0.1.7", knowledge: "auto-vc", compatibleFrom: "0.1.5" },
|
||||||
@ -84,4 +84,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
|
|||||||
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
|
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
||||||
|
|
||||||
console.log("Release metadata verification passed: stable core 0.3.9 after 0.3.8 with synchronized Companion and plugin metadata.");
|
console.log("Release metadata verification passed: stable core 0.3.10 after 0.3.9 with synchronized Companion and plugin metadata.");
|
||||||
|
|||||||
@ -24,7 +24,7 @@ function readJson(relativePath) {
|
|||||||
|
|
||||||
const releaseIndex = readJson("release-index.json");
|
const releaseIndex = readJson("release-index.json");
|
||||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
||||||
assert.deepEqual(releaseVersions, ["0.3.9", "0.3.8", "0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
|
assert.deepEqual(releaseVersions, ["0.3.10", "0.3.9", "0.3.8", "0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
|
||||||
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||||
for (const release of releaseIndex.releases) {
|
for (const release of releaseIndex.releases) {
|
||||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||||
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
|
|||||||
const coreManifest = readJson("update-manifest.json");
|
const coreManifest = readJson("update-manifest.json");
|
||||||
assert.equal(packageVersion, coreManifest.version);
|
assert.equal(packageVersion, coreManifest.version);
|
||||||
assert.equal(coreManifest.channel, "stable");
|
assert.equal(coreManifest.channel, "stable");
|
||||||
assert.equal(packageVersion, "0.3.9");
|
assert.equal(packageVersion, "0.3.10");
|
||||||
assert.equal(currentRelease.version, "0.3.9");
|
assert.equal(currentRelease.version, "0.3.10");
|
||||||
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
||||||
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
||||||
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
||||||
@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
|
|||||||
const baseTarget = {
|
const baseTarget = {
|
||||||
current_version: "0.2.4",
|
current_version: "0.2.4",
|
||||||
available_versions: [
|
available_versions: [
|
||||||
|
{ version: "0.3.10", ref: "refs/tags/v0.3.10", rollback_safe: true },
|
||||||
{ version: "0.3.9", ref: "refs/tags/v0.3.9", rollback_safe: true },
|
{ version: "0.3.9", ref: "refs/tags/v0.3.9", rollback_safe: true },
|
||||||
{ version: "0.3.8", ref: "refs/tags/v0.3.8", rollback_safe: true },
|
{ version: "0.3.8", ref: "refs/tags/v0.3.8", rollback_safe: true },
|
||||||
{ version: "0.3.7", ref: "refs/tags/v0.3.7", rollback_safe: true },
|
{ version: "0.3.7", ref: "refs/tags/v0.3.7", rollback_safe: true },
|
||||||
@ -158,7 +159,7 @@ const corrected = buildStatus({
|
|||||||
channel: "stable"
|
channel: "stable"
|
||||||
});
|
});
|
||||||
assert.equal(corrected.version_correction, true);
|
assert.equal(corrected.version_correction, true);
|
||||||
assert.equal(corrected.safe_target_version, "0.3.9");
|
assert.equal(corrected.safe_target_version, "0.3.10");
|
||||||
assert.equal(corrected.update_available, true);
|
assert.equal(corrected.update_available, true);
|
||||||
assert.equal(corrected.blocked, false);
|
assert.equal(corrected.blocked, false);
|
||||||
|
|
||||||
|
|||||||
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 { cleanupSnapshots } = require("./services/update-manager");
|
||||||
const { twitchEventSubManager } = require("./services/twitch-eventsub");
|
const { twitchEventSubManager } = require("./services/twitch-eventsub");
|
||||||
const { streamTestingService } = require("./services/stream-testing");
|
const { streamTestingService } = require("./services/stream-testing");
|
||||||
|
const { userAgeStatistics } = require("./services/user-age-statistics");
|
||||||
const {
|
const {
|
||||||
isSafeModeRequested,
|
isSafeModeRequested,
|
||||||
markStartupVerification
|
markStartupVerification
|
||||||
@ -56,7 +57,8 @@ async function main() {
|
|||||||
plugin_count: completedPluginSync.plugin_ids.length
|
plugin_count: completedPluginSync.plugin_ids.length
|
||||||
}, { event: "bundled_plugin_sync_completed" });
|
}, { event: "bundled_plugin_sync_completed" });
|
||||||
}
|
}
|
||||||
registerCorePlaceholders();
|
userAgeStatistics.start();
|
||||||
|
registerCorePlaceholders({ userAgeStatistics });
|
||||||
const logCleanup = logger.cleanupLogs({
|
const logCleanup = logger.cleanupLogs({
|
||||||
maxAgeDays: getSetting("log_retention_days", logger.DEFAULT_MAX_AGE_DAYS),
|
maxAgeDays: getSetting("log_retention_days", logger.DEFAULT_MAX_AGE_DAYS),
|
||||||
maxEntries: getSetting("log_retention_max_entries", logger.DEFAULT_MAX_ENTRIES)
|
maxEntries: getSetting("log_retention_max_entries", logger.DEFAULT_MAX_ENTRIES)
|
||||||
@ -205,6 +207,7 @@ async function main() {
|
|||||||
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
|
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
|
||||||
for (const service of [
|
for (const service of [
|
||||||
{ name: "stream_testing", stop: () => streamTestingService.close() },
|
{ name: "stream_testing", stop: () => streamTestingService.close() },
|
||||||
|
{ name: "user_age_statistics", stop: () => userAgeStatistics.stop() },
|
||||||
{ name: "overlay_connectors", stop: () => overlayConnectorManager.stop() },
|
{ name: "overlay_connectors", stop: () => overlayConnectorManager.stop() },
|
||||||
{ name: "twitch_eventsub", stop: () => twitchEventSubManager.stop() },
|
{ name: "twitch_eventsub", stop: () => twitchEventSubManager.stop() },
|
||||||
{ name: "plugins", stop: () => stopPlugins() },
|
{ name: "plugins", stop: () => stopPlugins() },
|
||||||
|
|||||||
@ -92,7 +92,7 @@ function buildTwitchEventAuthUrl(state, redirectOverride) {
|
|||||||
client_id: clientId || "",
|
client_id: clientId || "",
|
||||||
redirect_uri: redirectUri || "",
|
redirect_uri: redirectUri || "",
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
scope: "moderator:read:followers channel:read:subscriptions bits:read channel:read:redemptions",
|
scope: "moderator:read:followers moderation:read channel:read:subscriptions channel:read:editors channel:read:vips bits:read channel:read:redemptions",
|
||||||
state,
|
state,
|
||||||
force_verify: "true"
|
force_verify: "true"
|
||||||
});
|
});
|
||||||
|
|||||||
@ -103,6 +103,39 @@ function migrate() {
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_age_intervals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
platform_user_id TEXT NOT NULL,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
statistic_type TEXT NOT NULL,
|
||||||
|
start_at INTEGER NOT NULL,
|
||||||
|
end_at INTEGER,
|
||||||
|
start_source TEXT NOT NULL,
|
||||||
|
start_precision TEXT NOT NULL DEFAULT 'millisecond',
|
||||||
|
start_authoritative INTEGER NOT NULL DEFAULT 0,
|
||||||
|
end_source TEXT,
|
||||||
|
end_precision TEXT,
|
||||||
|
end_authoritative INTEGER,
|
||||||
|
observed_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS user_age_intervals_one_open_idx
|
||||||
|
ON user_age_intervals (platform, platform_user_id, scope_id, statistic_type)
|
||||||
|
WHERE end_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS user_age_intervals_lookup_idx
|
||||||
|
ON user_age_intervals (platform, scope_id, statistic_type, platform_user_id, start_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_age_events (
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
processed_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (platform, event_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS custom_pages (
|
CREATE TABLE IF NOT EXISTS custom_pages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ const { createLogger } = require("./logger");
|
|||||||
const { publishOverlayChatMessage } = require("./overlay-chat");
|
const { publishOverlayChatMessage } = require("./overlay-chat");
|
||||||
const { discordOverlayContent } = require("./discord-chat-content");
|
const { discordOverlayContent } = require("./discord-chat-content");
|
||||||
const { emitLumiEvent } = require("./lumi-events");
|
const { emitLumiEvent } = require("./lumi-events");
|
||||||
|
const { userAgeStatistics } = require("./user-age-statistics");
|
||||||
|
|
||||||
const discordLog = createLogger("platform:discord", { category: "integration" });
|
const discordLog = createLogger("platform:discord", { category: "integration" });
|
||||||
|
|
||||||
@ -55,6 +56,11 @@ async function startBot({ commandRouter } = {}) {
|
|||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
setSetting("bot_avatar_url", avatarUrl);
|
setSetting("bot_avatar_url", avatarUrl);
|
||||||
}
|
}
|
||||||
|
reconcileDiscordTenure(client).catch((error) => {
|
||||||
|
discordLog.warn("Discord platform tenure could not be reconciled", {
|
||||||
|
error
|
||||||
|
}, { event: "user_age_reconciliation_failed" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("messageCreate", async (message) => {
|
client.on("messageCreate", async (message) => {
|
||||||
@ -76,6 +82,9 @@ async function startBot({ commandRouter } = {}) {
|
|||||||
displayName,
|
displayName,
|
||||||
avatar: avatarUrl
|
avatar: avatarUrl
|
||||||
});
|
});
|
||||||
|
if (!message.author.bot && message.member) {
|
||||||
|
observeDiscordMember(message.member, message.createdTimestamp || Date.now(), `discord-message:${message.id}`);
|
||||||
|
}
|
||||||
publishOverlayChatMessage({
|
publishOverlayChatMessage({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
platform: "discord",
|
platform: "discord",
|
||||||
@ -140,6 +149,29 @@ async function startBot({ commandRouter } = {}) {
|
|||||||
source: "discord-gateway",
|
source: "discord-gateway",
|
||||||
occurredAt: member.joinedTimestamp || Date.now()
|
occurredAt: member.joinedTimestamp || Date.now()
|
||||||
});
|
});
|
||||||
|
if (member.premiumSinceTimestamp) {
|
||||||
|
emitDiscordBoostEvent(member, true, member.premiumSinceTimestamp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("guildMemberRemove", (member) => {
|
||||||
|
if (!member?.user || member.user.bot || !isConfiguredGuild(member.guild?.id)) return;
|
||||||
|
const now = Date.now();
|
||||||
|
emitLumiEvent("discord.member_leave", discordMemberPayload(member), {
|
||||||
|
id: `${member.guild?.id || "guild"}:${member.user.id}:leave:${now}`,
|
||||||
|
source: "discord-gateway",
|
||||||
|
occurredAt: now
|
||||||
|
});
|
||||||
|
if (member.premiumSinceTimestamp) emitDiscordBoostEvent(member, false, now);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("guildMemberUpdate", (previous, current) => {
|
||||||
|
if (!current?.user || current.user.bot || !isConfiguredGuild(current.guild?.id)) return;
|
||||||
|
const wasBoosting = Boolean(previous?.premiumSinceTimestamp);
|
||||||
|
const isBoosting = Boolean(current.premiumSinceTimestamp);
|
||||||
|
if (wasBoosting !== isBoosting) {
|
||||||
|
emitDiscordBoostEvent(current, isBoosting, isBoosting ? current.premiumSinceTimestamp : Date.now());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.login(token);
|
await client.login(token);
|
||||||
@ -176,6 +208,98 @@ function resolveIntent(key, legacyKey) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConfiguredGuild(guildId) {
|
||||||
|
const configuredGuildId = String(getSetting("discord_guild_id", "") || "");
|
||||||
|
return !configuredGuildId || configuredGuildId === String(guildId || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function discordMemberPayload(member) {
|
||||||
|
return {
|
||||||
|
guild_id: member.guild?.id || null,
|
||||||
|
guild_name: member.guild?.name || null,
|
||||||
|
user_id: member.user?.id || member.id || null,
|
||||||
|
user_name: member.user?.globalName || member.user?.username || member.user?.tag || null,
|
||||||
|
joined_at: member.joinedTimestamp || null,
|
||||||
|
boosted_at: member.premiumSinceTimestamp || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitDiscordBoostEvent(member, active, occurredAt) {
|
||||||
|
emitLumiEvent(active ? "discord.boost_start" : "discord.boost_end", discordMemberPayload(member), {
|
||||||
|
id: `${member.guild?.id || "guild"}:${member.user?.id || member.id}:boost:${active ? "start" : "end"}:${occurredAt}`,
|
||||||
|
source: "discord-gateway",
|
||||||
|
occurredAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeDiscordMember(member, observedAt, eventPrefix) {
|
||||||
|
if (!member?.user || member.user.bot || !isConfiguredGuild(member.guild?.id)) return;
|
||||||
|
userAgeStatistics.setState({
|
||||||
|
platform: "discord",
|
||||||
|
userId: member.user.id,
|
||||||
|
scopeId: member.guild.id,
|
||||||
|
type: "member",
|
||||||
|
active: true,
|
||||||
|
startAt: member.joinedTimestamp || undefined,
|
||||||
|
occurredAt: observedAt,
|
||||||
|
eventId: `${eventPrefix}:member`,
|
||||||
|
source: member.joinedTimestamp ? "discord-guild-member" : "discord-first-observed",
|
||||||
|
authoritative: Boolean(member.joinedTimestamp)
|
||||||
|
});
|
||||||
|
userAgeStatistics.setState({
|
||||||
|
platform: "discord",
|
||||||
|
userId: member.user.id,
|
||||||
|
scopeId: member.guild.id,
|
||||||
|
type: "nitro",
|
||||||
|
active: Boolean(member.premiumSinceTimestamp),
|
||||||
|
startAt: member.premiumSinceTimestamp || undefined,
|
||||||
|
occurredAt: observedAt,
|
||||||
|
eventId: `${eventPrefix}:nitro`,
|
||||||
|
source: member.premiumSinceTimestamp ? "discord-premium-since" : "discord-guild-member",
|
||||||
|
authoritative: Boolean(member.premiumSinceTimestamp)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcileDiscordTenure(discordClient) {
|
||||||
|
const configuredGuildId = String(getSetting("discord_guild_id", "") || "");
|
||||||
|
const guilds = configuredGuildId
|
||||||
|
? [discordClient.guilds.cache.get(configuredGuildId)].filter(Boolean)
|
||||||
|
: Array.from(discordClient.guilds.cache.values());
|
||||||
|
for (const guild of guilds) {
|
||||||
|
const members = await guild.members.fetch();
|
||||||
|
const humans = Array.from(members.values()).filter((member) => !member.user?.bot);
|
||||||
|
const observedAt = Date.now();
|
||||||
|
userAgeStatistics.reconcileSnapshot({
|
||||||
|
platform: "discord",
|
||||||
|
scopeId: guild.id,
|
||||||
|
type: "member",
|
||||||
|
activeRecords: humans.map((member) => ({
|
||||||
|
userId: member.user.id,
|
||||||
|
startAt: member.joinedTimestamp,
|
||||||
|
source: member.joinedTimestamp ? "discord-guild-member" : "discord-first-observed",
|
||||||
|
authoritative: Boolean(member.joinedTimestamp)
|
||||||
|
})),
|
||||||
|
observedAt,
|
||||||
|
source: "discord-guild-reconciliation",
|
||||||
|
complete: true
|
||||||
|
});
|
||||||
|
userAgeStatistics.reconcileSnapshot({
|
||||||
|
platform: "discord",
|
||||||
|
scopeId: guild.id,
|
||||||
|
type: "nitro",
|
||||||
|
activeRecords: humans.filter((member) => member.premiumSinceTimestamp).map((member) => ({
|
||||||
|
userId: member.user.id,
|
||||||
|
startAt: member.premiumSinceTimestamp,
|
||||||
|
source: "discord-premium-since",
|
||||||
|
authoritative: true
|
||||||
|
})),
|
||||||
|
observedAt,
|
||||||
|
source: "discord-guild-reconciliation",
|
||||||
|
complete: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePartial(key, legacyKey) {
|
function resolvePartial(key, legacyKey) {
|
||||||
if (Partials?.[key] !== undefined) return Partials[key];
|
if (Partials?.[key] !== undefined) return Partials[key];
|
||||||
return legacyKey;
|
return legacyKey;
|
||||||
|
|||||||
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 [
|
for (const definition of [
|
||||||
{ id: "twitch.follow", label: "Twitch follow", description: "A viewer follows a configured Twitch channel.", platform: "twitch" },
|
{ id: "twitch.follow", label: "Twitch follow", description: "A viewer follows a configured Twitch channel.", platform: "twitch" },
|
||||||
|
{ id: "twitch.unsubscribe", label: "Twitch subscription ended", description: "A viewer's subscription ends in a configured Twitch channel.", platform: "twitch" },
|
||||||
{ id: "twitch.raid", label: "Twitch raid received", description: "A configured Twitch channel receives a raid that appears in chat.", platform: "twitch" },
|
{ id: "twitch.raid", label: "Twitch raid received", description: "A configured Twitch channel receives a raid that appears in chat.", platform: "twitch" },
|
||||||
{ id: "twitch.subscribe", label: "Twitch subscription", description: "A configured Twitch channel receives a new subscription.", platform: "twitch", supportsTier: true },
|
{ id: "twitch.subscribe", label: "Twitch subscription", description: "A configured Twitch channel receives a new subscription.", platform: "twitch", supportsTier: true },
|
||||||
|
{ id: "twitch.moderator_add", label: "Twitch moderator added", description: "A viewer becomes a channel moderator.", platform: "twitch" },
|
||||||
|
{ id: "twitch.moderator_remove", label: "Twitch moderator removed", description: "A viewer stops being a channel moderator.", platform: "twitch" },
|
||||||
|
{ id: "twitch.vip_add", label: "Twitch VIP added", description: "A viewer becomes a channel VIP.", platform: "twitch" },
|
||||||
|
{ id: "twitch.vip_remove", label: "Twitch VIP removed", description: "A viewer stops being a channel VIP.", platform: "twitch" },
|
||||||
{ id: "twitch.subscription_gift", label: "Twitch gifted subscriptions", description: "A viewer gifts one or more subscriptions in a configured Twitch channel.", platform: "twitch", supportsTier: true },
|
{ id: "twitch.subscription_gift", label: "Twitch gifted subscriptions", description: "A viewer gifts one or more subscriptions in a configured Twitch channel.", platform: "twitch", supportsTier: true },
|
||||||
{ id: "twitch.cheer", label: "Twitch cheer", description: "A viewer cheers Bits in a configured Twitch channel.", platform: "twitch" },
|
{ id: "twitch.cheer", label: "Twitch cheer", description: "A viewer cheers Bits in a configured Twitch channel.", platform: "twitch" },
|
||||||
{ id: "twitch.channel_points", label: "Twitch channel-points redemption", description: "A viewer redeems a channel-points reward.", platform: "twitch" },
|
{ id: "twitch.channel_points", label: "Twitch channel-points redemption", description: "A viewer redeems a channel-points reward.", platform: "twitch" },
|
||||||
@ -71,7 +76,10 @@ for (const definition of [
|
|||||||
{ id: "youtube.membership_gift", label: "YouTube gifted membership", description: "A YouTube viewer gifts channel memberships.", platform: "youtube" },
|
{ id: "youtube.membership_gift", label: "YouTube gifted membership", description: "A YouTube viewer gifts channel memberships.", platform: "youtube" },
|
||||||
{ id: "youtube.super_chat", label: "YouTube Super Chat", description: "A viewer sends a Super Chat.", platform: "youtube" },
|
{ id: "youtube.super_chat", label: "YouTube Super Chat", description: "A viewer sends a Super Chat.", platform: "youtube" },
|
||||||
{ id: "youtube.super_sticker", label: "YouTube Super Sticker", description: "A viewer sends a Super Sticker.", platform: "youtube" },
|
{ id: "youtube.super_sticker", label: "YouTube Super Sticker", description: "A viewer sends a Super Sticker.", platform: "youtube" },
|
||||||
{ id: "discord.member_join", label: "Discord member joined", description: "A member joins the configured Discord server.", platform: "discord" }
|
{ id: "discord.member_join", label: "Discord member joined", description: "A member joins the configured Discord server.", platform: "discord" },
|
||||||
|
{ id: "discord.member_leave", label: "Discord member left", description: "A member leaves the configured Discord server.", platform: "discord" },
|
||||||
|
{ id: "discord.boost_start", label: "Discord boost started", description: "A member starts boosting the configured Discord server.", platform: "discord" },
|
||||||
|
{ id: "discord.boost_end", label: "Discord boost ended", description: "A member stops boosting the configured Discord server.", platform: "discord" }
|
||||||
]) registerEventType(definition);
|
]) registerEventType(definition);
|
||||||
|
|
||||||
module.exports = { emitLumiEvent, listEventTypes, onLumiEvent, registerEventType };
|
module.exports = { emitLumiEvent, listEventTypes, onLumiEvent, registerEventType };
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const { db } = require("./db");
|
const { db } = require("./db");
|
||||||
const { getSetting } = require("./settings");
|
const { getSetting } = require("./settings");
|
||||||
const { hasAccess } = require("./rbac");
|
const { hasAccess } = require("./rbac");
|
||||||
|
const { parseDurationFormat } = require("./duration-format");
|
||||||
|
|
||||||
const ROLE_LEVELS = Object.freeze({
|
const ROLE_LEVELS = Object.freeze({
|
||||||
public: 0,
|
public: 0,
|
||||||
@ -23,6 +24,7 @@ const SENSITIVITY_LEVELS = Object.freeze({
|
|||||||
const VALUE_TYPES = new Set(["string", "number", "boolean", "url", "json", "date"]);
|
const VALUE_TYPES = new Set(["string", "number", "boolean", "url", "json", "date"]);
|
||||||
const placeholders = new Map();
|
const placeholders = new Map();
|
||||||
const fieldPolicies = new Map();
|
const fieldPolicies = new Map();
|
||||||
|
const PLACEHOLDER_PATTERN = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\.\{\s*format\.([A-Za-z-]+)\s*\}\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
|
||||||
|
|
||||||
function normalizeRole(value, fallback = "user") {
|
function normalizeRole(value, fallback = "user") {
|
||||||
const role = String(value || fallback).trim().toLowerCase();
|
const role = String(value || fallback).trim().toLowerCase();
|
||||||
@ -100,7 +102,8 @@ function normalizeDefinition(definition = {}) {
|
|||||||
example: definition.example === undefined ? null : String(definition.example),
|
example: definition.example === undefined ? null : String(definition.example),
|
||||||
plugin_id: definition.plugin_id ? String(definition.plugin_id).trim() : null,
|
plugin_id: definition.plugin_id ? String(definition.plugin_id).trim() : null,
|
||||||
resolver: typeof definition.resolver === "function" ? definition.resolver : () => "",
|
resolver: typeof definition.resolver === "function" ? definition.resolver : () => "",
|
||||||
available: typeof definition.available === "function" ? definition.available : null
|
available: typeof definition.available === "function" ? definition.available : null,
|
||||||
|
supports_duration_format: Boolean(definition.supports_duration_format)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,13 +249,14 @@ function findDefinition(token) {
|
|||||||
|
|
||||||
function parsePlaceholders(template) {
|
function parsePlaceholders(template) {
|
||||||
const found = [];
|
const found = [];
|
||||||
const matcher = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
|
const matcher = new RegExp(PLACEHOLDER_PATTERN.source, PLACEHOLDER_PATTERN.flags);
|
||||||
let match = null;
|
let match = null;
|
||||||
while ((match = matcher.exec(String(template || "")))) {
|
while ((match = matcher.exec(String(template || "")))) {
|
||||||
found.push({
|
found.push({
|
||||||
token: match[0],
|
token: match[0],
|
||||||
id: normalizeId(match[1]),
|
id: normalizeId(match[1] || match[3]),
|
||||||
index: match.index
|
index: match.index,
|
||||||
|
format: match[2] || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
@ -277,6 +281,7 @@ function catalog({ fieldId, user, outputAudience, runtimeContext } = {}) {
|
|||||||
value_type: definition.value_type,
|
value_type: definition.value_type,
|
||||||
sensitivity: definition.sensitivity,
|
sensitivity: definition.sensitivity,
|
||||||
group: definition.group,
|
group: definition.group,
|
||||||
|
supports_duration_format: definition.supports_duration_format,
|
||||||
example: definition.sensitivity === "public_safe" ? definition.example : null
|
example: definition.sensitivity === "public_safe" ? definition.example : null
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.token.localeCompare(b.token));
|
.sort((a, b) => a.token.localeCompare(b.token));
|
||||||
@ -302,6 +307,19 @@ function validateTemplate({ fieldId, template, user, outputAudience, runtimeCont
|
|||||||
}
|
}
|
||||||
for (const token of parsePlaceholders(template)) {
|
for (const token of parsePlaceholders(template)) {
|
||||||
const definition = findDefinition(token.id);
|
const definition = findDefinition(token.id);
|
||||||
|
if (token.format) {
|
||||||
|
try {
|
||||||
|
if (!definition?.supports_duration_format) throw new Error("unsupported");
|
||||||
|
parseDurationFormat(token.format);
|
||||||
|
} catch {
|
||||||
|
errors.push({
|
||||||
|
token: token.token,
|
||||||
|
id: token.id,
|
||||||
|
reason: definition?.supports_duration_format ? "invalid_duration_format" : "duration_format_unsupported"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
const access = checkPlaceholderAccess(definition, policy, {
|
const access = checkPlaceholderAccess(definition, policy, {
|
||||||
user,
|
user,
|
||||||
outputAudience,
|
outputAudience,
|
||||||
@ -328,8 +346,8 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const rendered = await replaceAsync(String(template || ""), /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, async (match, rawId) => {
|
const rendered = await replaceAsync(String(template || ""), PLACEHOLDER_PATTERN, async (match, formattedId, format, regularId) => {
|
||||||
const id = normalizeId(rawId);
|
const id = normalizeId(formattedId || regularId);
|
||||||
const definition = findDefinition(id);
|
const definition = findDefinition(id);
|
||||||
const access = checkPlaceholderAccess(definition, policy, {
|
const access = checkPlaceholderAccess(definition, policy, {
|
||||||
user,
|
user,
|
||||||
@ -340,6 +358,16 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
|
|||||||
errors.push({ token: match, id, reason: access.reason });
|
errors.push({ token: match, id, reason: access.reason });
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
if (format) {
|
||||||
|
try {
|
||||||
|
if (!definition.supports_duration_format) throw new Error("unsupported");
|
||||||
|
parseDurationFormat(format);
|
||||||
|
} catch {
|
||||||
|
const reason = definition.supports_duration_format ? "invalid_duration_format" : "duration_format_unsupported";
|
||||||
|
errors.push({ token: match, id, reason });
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const value = await withTimeout(Promise.resolve(definition.resolver({
|
const value = await withTimeout(Promise.resolve(definition.resolver({
|
||||||
user,
|
user,
|
||||||
@ -347,7 +375,8 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
|
|||||||
outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience),
|
outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience),
|
||||||
runtimeContext,
|
runtimeContext,
|
||||||
token: match,
|
token: match,
|
||||||
id
|
id,
|
||||||
|
format: format || null
|
||||||
})), runtimeContext?.placeholder_timeout_ms);
|
})), runtimeContext?.placeholder_timeout_ms);
|
||||||
return stringifyResolvedValue(value);
|
return stringifyResolvedValue(value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -393,14 +422,22 @@ function withTimeout(promise, requestedMs) {
|
|||||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerCorePlaceholders() {
|
function registerCorePlaceholders({ userAgeStatistics } = {}) {
|
||||||
registerFieldPolicy({
|
registerFieldPolicy({
|
||||||
field_id: "core.custom_commands.static_response",
|
field_id: "core.custom_commands.static_response",
|
||||||
label: "Custom command response",
|
label: "Custom command response",
|
||||||
field_type: "command_response",
|
field_type: "command_response",
|
||||||
output_audience: "user",
|
output_audience: "user",
|
||||||
min_editor_role: "mod",
|
min_editor_role: "mod",
|
||||||
allowed_namespaces: ["core.main", "core.command", "custom", "user.public"],
|
allowed_namespaces: [
|
||||||
|
"core.main",
|
||||||
|
"core.command",
|
||||||
|
"custom",
|
||||||
|
"user.public",
|
||||||
|
"twitch.user",
|
||||||
|
"youtube.user",
|
||||||
|
"discord.user"
|
||||||
|
],
|
||||||
max_sensitivity: "public_safe"
|
max_sensitivity: "public_safe"
|
||||||
});
|
});
|
||||||
registerPlaceholders([
|
registerPlaceholders([
|
||||||
@ -461,6 +498,9 @@ function registerCorePlaceholders() {
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
if (userAgeStatistics?.placeholderDefinitions) {
|
||||||
|
registerPlaceholders(userAgeStatistics.placeholderDefinitions());
|
||||||
|
}
|
||||||
registerCustomPlaceholders(getSetting("custom_placeholders", []));
|
registerCustomPlaceholders(getSetting("custom_placeholders", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ const { getSetting } = require("./settings");
|
|||||||
const { getEnabledPlatformIds } = require("./platforms");
|
const { getEnabledPlatformIds } = require("./platforms");
|
||||||
const { getPluginLeaderboards } = require("./plugin-stats");
|
const { getPluginLeaderboards } = require("./plugin-stats");
|
||||||
const { getPlugins, pluginsDir } = require("./plugins");
|
const { getPlugins, pluginsDir } = require("./plugins");
|
||||||
|
const { userAgeStatistics } = require("./user-age-statistics");
|
||||||
|
|
||||||
const coreProviders = new Map();
|
const coreProviders = new Map();
|
||||||
const coreOrder = [];
|
const coreOrder = [];
|
||||||
@ -97,17 +98,31 @@ function ensureCoreProviders() {
|
|||||||
description: "Most popular commands across platforms.",
|
description: "Most popular commands across platforms.",
|
||||||
getRows: ({ limit }) => buildCommandUsageRows(limit)
|
getRows: ({ limit }) => buildCommandUsageRows(limit)
|
||||||
});
|
});
|
||||||
registerTopProvider({
|
for (const definition of [
|
||||||
id: "followage",
|
{ id: "followage", platform: "twitch", type: "follow", label: "Longest Twitch follows" },
|
||||||
label: "Top followage",
|
{ id: "twitch_subscriber_age", platform: "twitch", type: "subscriber", label: "Longest Twitch subscriptions" },
|
||||||
section: "Platforms",
|
{ id: "twitch_mod_age", platform: "twitch", type: "moderator", label: "Longest Twitch moderator tenure" },
|
||||||
valueLabel: "Days",
|
{ id: "twitch_editor_age", platform: "twitch", type: "editor", label: "Longest Twitch editor tenure" },
|
||||||
description: "Longest follower durations.",
|
{ id: "twitch_vip_age", platform: "twitch", type: "vip", label: "Longest Twitch VIP tenure" },
|
||||||
getRows: () => ({
|
{ id: "youtube_member_age", platform: "youtube", type: "subscriber", label: "Longest YouTube memberships" },
|
||||||
rows: [],
|
{ id: "youtube_mod_age", platform: "youtube", type: "moderator", label: "Longest YouTube moderator tenure" },
|
||||||
emptyMessage: "Followage tracking is not configured yet."
|
{ 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({
|
registerTopProvider({
|
||||||
id: "watchtime",
|
id: "watchtime",
|
||||||
label: "Top watchtime",
|
label: "Top watchtime",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ const { getSetting, setSetting } = require("./settings");
|
|||||||
const { emitLumiEvent } = require("./lumi-events");
|
const { emitLumiEvent } = require("./lumi-events");
|
||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
const { setPlatformLiveState } = require("./platform-live-state");
|
const { setPlatformLiveState } = require("./platform-live-state");
|
||||||
|
const { reconcileTwitchUserAges } = require("./twitch-user-age");
|
||||||
|
|
||||||
const EVENTSUB_URL = "wss://eventsub.wss.twitch.tv/ws?keepalive_timeout_seconds=30";
|
const EVENTSUB_URL = "wss://eventsub.wss.twitch.tv/ws?keepalive_timeout_seconds=30";
|
||||||
const eventLog = createLogger("platform:twitch:eventsub", { category: "integration" });
|
const eventLog = createLogger("platform:twitch:eventsub", { category: "integration" });
|
||||||
@ -70,6 +71,8 @@ class TwitchEventSubManager {
|
|||||||
this.running = false;
|
this.running = false;
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
|
this.reconcileTimer = null;
|
||||||
|
this.reconcileContext = null;
|
||||||
this.generation = 0;
|
this.generation = 0;
|
||||||
this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 };
|
this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 };
|
||||||
}
|
}
|
||||||
@ -98,7 +101,10 @@ class TwitchEventSubManager {
|
|||||||
this.running = false;
|
this.running = false;
|
||||||
this.generation += 1;
|
this.generation += 1;
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
|
clearInterval(this.reconcileTimer);
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
|
this.reconcileTimer = null;
|
||||||
|
this.reconcileContext = null;
|
||||||
const socket = this.socket;
|
const socket = this.socket;
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Lumi shutdown");
|
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Lumi shutdown");
|
||||||
@ -204,8 +210,17 @@ class TwitchEventSubManager {
|
|||||||
}
|
}
|
||||||
if (scopes.has("channel:read:subscriptions") && broadcaster.id === credentials.validation.user_id) {
|
if (scopes.has("channel:read:subscriptions") && broadcaster.id === credentials.validation.user_id) {
|
||||||
definitions.push({ type: "channel.subscribe", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
definitions.push({ type: "channel.subscribe", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
|
definitions.push({ type: "channel.subscription.end", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
definitions.push({ type: "channel.subscription.gift", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
definitions.push({ type: "channel.subscription.gift", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
}
|
}
|
||||||
|
if (scopes.has("moderation:read") && broadcaster.id === credentials.validation.user_id) {
|
||||||
|
definitions.push({ type: "channel.moderator.add", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
|
definitions.push({ type: "channel.moderator.remove", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
|
}
|
||||||
|
if (broadcaster.id === credentials.validation.user_id && (scopes.has("channel:read:vips") || scopes.has("channel:manage:vips"))) {
|
||||||
|
definitions.push({ type: "channel.vip.add", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
|
definitions.push({ type: "channel.vip.remove", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
|
}
|
||||||
if (scopes.has("bits:read") && broadcaster.id === credentials.validation.user_id)
|
if (scopes.has("bits:read") && broadcaster.id === credentials.validation.user_id)
|
||||||
definitions.push({ type: "channel.cheer", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
definitions.push({ type: "channel.cheer", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
if ((scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions")) && broadcaster.id === credentials.validation.user_id)
|
if ((scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions")) && broadcaster.id === credentials.validation.user_id)
|
||||||
@ -235,6 +250,15 @@ class TwitchEventSubManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (generation !== this.generation) return;
|
if (generation !== this.generation) return;
|
||||||
|
this.reconcileContext = { ...credentials, broadcasters: users.data || [] };
|
||||||
|
await reconcileTwitchUserAges(this.reconcileContext);
|
||||||
|
clearInterval(this.reconcileTimer);
|
||||||
|
this.reconcileTimer = setInterval(() => {
|
||||||
|
if (!this.running || generation !== this.generation || !this.reconcileContext) return;
|
||||||
|
reconcileTwitchUserAges(this.reconcileContext).catch((error) => {
|
||||||
|
eventLog.warn("Scheduled Twitch tenure reconciliation failed", { error }, { event: "user_age_reconciliation_failed" });
|
||||||
|
});
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
this.status = {
|
this.status = {
|
||||||
state: "connected",
|
state: "connected",
|
||||||
detail: active ? `Receiving ${active} Twitch event feed${active === 1 ? "" : "s"}.` : "Connected, but no configured channel events could be subscribed.",
|
detail: active ? `Receiving ${active} Twitch event feed${active === 1 ? "" : "s"}.` : "Connected, but no configured channel events could be subscribed.",
|
||||||
@ -243,7 +267,10 @@ class TwitchEventSubManager {
|
|||||||
cheers: scopes.has("bits:read") ? "available" : "missing bits:read",
|
cheers: scopes.has("bits:read") ? "available" : "missing bits:read",
|
||||||
channel_points: scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions") ? "available" : "missing channel:read:redemptions",
|
channel_points: scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions") ? "available" : "missing channel:read:redemptions",
|
||||||
subscriptions: scopes.has("channel:read:subscriptions") ? "available" : "missing channel:read:subscriptions",
|
subscriptions: scopes.has("channel:read:subscriptions") ? "available" : "missing channel:read:subscriptions",
|
||||||
follows: scopes.has("moderator:read:followers") ? "available" : "missing moderator:read:followers"
|
follows: scopes.has("moderator:read:followers") ? "available" : "missing moderator:read:followers",
|
||||||
|
moderators: scopes.has("moderation:read") ? "available" : "missing moderation:read",
|
||||||
|
vips: scopes.has("channel:read:vips") || scopes.has("channel:manage:vips") ? "available" : "missing channel:read:vips",
|
||||||
|
editors: scopes.has("channel:read:editors") ? "available" : "missing channel:read:editors"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
eventLog.info("Twitch EventSub connected", { channels: users.data?.length || 0, subscriptions: active }, { event: "eventsub_ready" });
|
eventLog.info("Twitch EventSub connected", { channels: users.data?.length || 0, subscriptions: active }, { event: "eventsub_ready" });
|
||||||
@ -265,6 +292,11 @@ class TwitchEventSubManager {
|
|||||||
if (subscription.type === "channel.follow") emitLumiEvent("twitch.follow", { ...common, followed_at: event.followed_at }, metadata);
|
if (subscription.type === "channel.follow") emitLumiEvent("twitch.follow", { ...common, followed_at: event.followed_at }, metadata);
|
||||||
if (subscription.type === "channel.raid") emitLumiEvent("twitch.raid", { ...common, viewers: Number(event.viewers || 0) }, metadata);
|
if (subscription.type === "channel.raid") emitLumiEvent("twitch.raid", { ...common, viewers: Number(event.viewers || 0) }, metadata);
|
||||||
if (subscription.type === "channel.subscribe") emitLumiEvent("twitch.subscribe", { ...common, tier: event.tier, gifted: Boolean(event.is_gift) }, metadata);
|
if (subscription.type === "channel.subscribe") emitLumiEvent("twitch.subscribe", { ...common, tier: event.tier, gifted: Boolean(event.is_gift) }, metadata);
|
||||||
|
if (subscription.type === "channel.subscription.end") emitLumiEvent("twitch.unsubscribe", { ...common, tier: event.tier }, metadata);
|
||||||
|
if (subscription.type === "channel.moderator.add") emitLumiEvent("twitch.moderator_add", common, metadata);
|
||||||
|
if (subscription.type === "channel.moderator.remove") emitLumiEvent("twitch.moderator_remove", common, metadata);
|
||||||
|
if (subscription.type === "channel.vip.add") emitLumiEvent("twitch.vip_add", common, metadata);
|
||||||
|
if (subscription.type === "channel.vip.remove") emitLumiEvent("twitch.vip_remove", common, metadata);
|
||||||
if (subscription.type === "channel.subscription.gift") emitLumiEvent("twitch.subscription_gift", { ...common, tier: event.tier, total: Number(event.total || 0), anonymous: Boolean(event.is_anonymous) }, metadata);
|
if (subscription.type === "channel.subscription.gift") emitLumiEvent("twitch.subscription_gift", { ...common, tier: event.tier, total: Number(event.total || 0), anonymous: Boolean(event.is_anonymous) }, metadata);
|
||||||
if (subscription.type === "channel.cheer") emitLumiEvent("twitch.cheer", { ...common, bits: Number(event.bits || 0), message: event.message || "", anonymous: Boolean(event.is_anonymous) }, metadata);
|
if (subscription.type === "channel.cheer") emitLumiEvent("twitch.cheer", { ...common, bits: Number(event.bits || 0), message: event.message || "", anonymous: Boolean(event.is_anonymous) }, metadata);
|
||||||
if (subscription.type === "channel.channel_points_custom_reward_redemption.add") emitLumiEvent("twitch.channel_points", {
|
if (subscription.type === "channel.channel_points_custom_reward_redemption.add") emitLumiEvent("twitch.channel_points", {
|
||||||
|
|||||||
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 { createLogger } = require("./logger");
|
||||||
const { publishOverlayChatMessage } = require("./overlay-chat");
|
const { publishOverlayChatMessage } = require("./overlay-chat");
|
||||||
const { resolveBetterTtvEmotes, resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets");
|
const { resolveBetterTtvEmotes, resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets");
|
||||||
|
const { userAgeStatistics } = require("./user-age-statistics");
|
||||||
|
|
||||||
const twitchLog = createLogger("platform:twitch", { category: "integration" });
|
const twitchLog = createLogger("platform:twitch", { category: "integration" });
|
||||||
|
|
||||||
@ -57,6 +58,28 @@ async function startTwitchBot({ commandRouter } = {}) {
|
|||||||
displayName,
|
displayName,
|
||||||
avatar
|
avatar
|
||||||
});
|
});
|
||||||
|
if (!self && tags["room-id"]) {
|
||||||
|
const observedAt = Number(tags["tmi-sent-ts"]) || Date.now();
|
||||||
|
const badgeNames = new Set(Object.keys(tags.badges || {}));
|
||||||
|
for (const [type, active] of [
|
||||||
|
["subscriber", badgeNames.has("subscriber") || badgeNames.has("founder") || Boolean(tags.subscriber)],
|
||||||
|
["moderator", badgeNames.has("moderator") || tags.mod === true || tags.mod === "1"],
|
||||||
|
["vip", badgeNames.has("vip")]
|
||||||
|
]) {
|
||||||
|
userAgeStatistics.setState({
|
||||||
|
platform: "twitch",
|
||||||
|
userId,
|
||||||
|
scopeId: tags["room-id"],
|
||||||
|
type,
|
||||||
|
active,
|
||||||
|
occurredAt: observedAt,
|
||||||
|
eventId: `twitch-chat:${tags.id || `${tags["room-id"]}:${userId}:${observedAt}`}:${type}`,
|
||||||
|
source: "twitch-chat-tags",
|
||||||
|
precision: "millisecond",
|
||||||
|
authoritative: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
publishOverlayChatMessage({
|
publishOverlayChatMessage({
|
||||||
id: tags.id,
|
id: tags.id,
|
||||||
platform: "twitch",
|
platform: "twitch",
|
||||||
|
|||||||
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 { publishOverlayChatMessage } = require("./overlay-chat");
|
||||||
const { emitLumiEvent } = require("./lumi-events");
|
const { emitLumiEvent } = require("./lumi-events");
|
||||||
const { setPlatformLiveState } = require("./platform-live-state");
|
const { setPlatformLiveState } = require("./platform-live-state");
|
||||||
|
const { userAgeStatistics } = require("./user-age-statistics");
|
||||||
|
|
||||||
const youtubeLog = createLogger("platform:youtube", { category: "integration" });
|
const youtubeLog = createLogger("platform:youtube", { category: "integration" });
|
||||||
|
|
||||||
@ -119,14 +120,41 @@ async function handleChatItem(state, liveChatId, item) {
|
|||||||
if (!snippet || !author) {
|
if (!snippet || !author) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const messageText = snippet.displayMessage;
|
const messageText = snippet.displayMessage || "";
|
||||||
if (!messageText) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const displayName = author.displayName || "YouTube User";
|
const displayName = author.displayName || "YouTube User";
|
||||||
const avatar = author.profileImageUrl || null;
|
const avatar = author.profileImageUrl || null;
|
||||||
const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
|
const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
|
||||||
emitYouTubeEvent(item, state, liveChatId, displayName);
|
emitYouTubeEvent(item, state, liveChatId, displayName);
|
||||||
|
if (!isSelf && state.channelId && author.channelId) {
|
||||||
|
const observedAt = Date.parse(snippet.publishedAt) || Date.now();
|
||||||
|
const membershipStart = ["newSponsorEvent", "giftMembershipReceivedEvent"].includes(snippet.type);
|
||||||
|
userAgeStatistics.setState({
|
||||||
|
platform: "youtube",
|
||||||
|
userId: author.channelId,
|
||||||
|
scopeId: state.channelId,
|
||||||
|
type: "subscriber",
|
||||||
|
active: Boolean(author.isChatSponsor || membershipStart),
|
||||||
|
startAt: membershipStart ? observedAt : undefined,
|
||||||
|
occurredAt: observedAt,
|
||||||
|
eventId: `youtube-chat:${item.id}:subscriber`,
|
||||||
|
source: membershipStart ? "youtube-membership-event" : "youtube-live-chat-author",
|
||||||
|
precision: "millisecond",
|
||||||
|
authoritative: membershipStart
|
||||||
|
});
|
||||||
|
userAgeStatistics.setState({
|
||||||
|
platform: "youtube",
|
||||||
|
userId: author.channelId,
|
||||||
|
scopeId: state.channelId,
|
||||||
|
type: "moderator",
|
||||||
|
active: Boolean(author.isChatModerator),
|
||||||
|
occurredAt: observedAt,
|
||||||
|
eventId: `youtube-chat:${item.id}:moderator`,
|
||||||
|
source: "youtube-live-chat-author",
|
||||||
|
precision: "millisecond",
|
||||||
|
authoritative: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!messageText) return;
|
||||||
const profile = isSelf ? null : ensureUserForIdentity({
|
const profile = isSelf ? null : ensureUserForIdentity({
|
||||||
provider: "youtube",
|
provider: "youtube",
|
||||||
providerUserId: author.channelId,
|
providerUserId: author.channelId,
|
||||||
|
|||||||
@ -80,6 +80,7 @@ const { getClient: getTwitchClient } = require("../services/twitch");
|
|||||||
const { twitchEventSubManager } = require("../services/twitch-eventsub");
|
const { twitchEventSubManager } = require("../services/twitch-eventsub");
|
||||||
const { eventHooksApi } = require("../services/overlay-event-hooks");
|
const { eventHooksApi } = require("../services/overlay-event-hooks");
|
||||||
const { streamTestingService } = require("../services/stream-testing");
|
const { streamTestingService } = require("../services/stream-testing");
|
||||||
|
const { userAgeStatistics } = require("../services/user-age-statistics");
|
||||||
const {
|
const {
|
||||||
getReverseProxyIngestSettings,
|
getReverseProxyIngestSettings,
|
||||||
saveReverseProxyIngestSettings
|
saveReverseProxyIngestSettings
|
||||||
@ -379,7 +380,7 @@ function getExpressionUserSummary(userId) {
|
|||||||
|
|
||||||
function buildUserStatsPayload(userId) {
|
function buildUserStatsPayload(userId) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { stats: null, expression: null, pluginStats: [] };
|
return { stats: null, expression: null, tenureStats: [], pluginStats: [] };
|
||||||
}
|
}
|
||||||
const stats = db
|
const stats = db
|
||||||
.prepare("SELECT * FROM stats WHERE user_id = ?")
|
.prepare("SELECT * FROM stats WHERE user_id = ?")
|
||||||
@ -387,6 +388,7 @@ function buildUserStatsPayload(userId) {
|
|||||||
return {
|
return {
|
||||||
stats,
|
stats,
|
||||||
expression: getExpressionUserSummary(userId),
|
expression: getExpressionUserSummary(userId),
|
||||||
|
tenureStats: userAgeStatistics.getProfileStatistics(userId),
|
||||||
pluginStats: getPluginProfileStats(userId)
|
pluginStats: getPluginProfileStats(userId)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -425,6 +427,14 @@ function buildCompareRows(leftStats, rightStats) {
|
|||||||
];
|
];
|
||||||
pushSection("Community Interaction", leftCommunity, rightCommunity);
|
pushSection("Community Interaction", leftCommunity, rightCommunity);
|
||||||
|
|
||||||
|
if (leftStats.tenureStats?.length || rightStats.tenureStats?.length) {
|
||||||
|
pushSection(
|
||||||
|
"Platform tenure",
|
||||||
|
(leftStats.tenureStats || []).map((entry) => ({ label: entry.label, value: entry.total })),
|
||||||
|
(rightStats.tenureStats || []).map((entry) => ({ label: entry.label, value: entry.total }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (leftStats.expression || rightStats.expression) {
|
if (leftStats.expression || rightStats.expression) {
|
||||||
const leftExpression = leftStats.expression
|
const leftExpression = leftStats.expression
|
||||||
? [
|
? [
|
||||||
@ -3113,7 +3123,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
// network. Public clients remain unable to forge forwarding headers.
|
// network. Public clients remain unable to forge forwarding headers.
|
||||||
app.set("trust proxy", isTrustedProxyAddress);
|
app.set("trust proxy", isTrustedProxyAddress);
|
||||||
const webhooks = createWebhookService();
|
const webhooks = createWebhookService();
|
||||||
placeholders.registerCorePlaceholders();
|
placeholders.registerCorePlaceholders({ userAgeStatistics });
|
||||||
placeholders.registerPlatformPlaceholders({
|
placeholders.registerPlatformPlaceholders({
|
||||||
discordClient,
|
discordClient,
|
||||||
getTwitchClient,
|
getTwitchClient,
|
||||||
@ -5280,6 +5290,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
title: "Your stats",
|
title: "Your stats",
|
||||||
stats: payload.stats,
|
stats: payload.stats,
|
||||||
expression: payload.expression,
|
expression: payload.expression,
|
||||||
|
tenureStats: payload.tenureStats,
|
||||||
pluginStats: payload.pluginStats,
|
pluginStats: payload.pluginStats,
|
||||||
statsOwner: {
|
statsOwner: {
|
||||||
username: req.session.user.username,
|
username: req.session.user.username,
|
||||||
@ -5324,6 +5335,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
title: `${profile.internal_username}'s stats`,
|
title: `${profile.internal_username}'s stats`,
|
||||||
stats: payload.stats,
|
stats: payload.stats,
|
||||||
expression: payload.expression,
|
expression: payload.expression,
|
||||||
|
tenureStats: payload.tenureStats,
|
||||||
pluginStats: payload.pluginStats,
|
pluginStats: payload.pluginStats,
|
||||||
statsOwner: {
|
statsOwner: {
|
||||||
username: profile.internal_username,
|
username: profile.internal_username,
|
||||||
@ -6212,6 +6224,21 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
renderDiagnosticsAdmin(req, res);
|
renderDiagnosticsAdmin(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/admin/platform-tenure", requireRole("admin"), (req, res) => {
|
||||||
|
const filters = {
|
||||||
|
platform: String(req.query.platform || "").trim().toLowerCase(),
|
||||||
|
scopeId: String(req.query.scope || "").trim(),
|
||||||
|
userId: String(req.query.user || "").trim(),
|
||||||
|
type: String(req.query.type || "").trim().toLowerCase()
|
||||||
|
};
|
||||||
|
res.render("admin-platform-tenure", {
|
||||||
|
title: "Platform tenure diagnostics",
|
||||||
|
intervals: userAgeStatistics.listDiagnostics(filters),
|
||||||
|
statistics: userAgeStatistics.listStatistics(),
|
||||||
|
filters
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/admin/stream-testing", requireRole("admin"), (req, res) => {
|
app.get("/admin/stream-testing", requireRole("admin"), (req, res) => {
|
||||||
res.set("Cache-Control", "no-store");
|
res.set("Cache-Control", "no-store");
|
||||||
res.render("admin-stream-testing", {
|
res.render("admin-stream-testing", {
|
||||||
@ -7969,6 +7996,7 @@ function collectNavItems(user, pluginNav, currentPath) {
|
|||||||
},
|
},
|
||||||
{ label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" },
|
{ label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" },
|
||||||
{ label: "Stream testing", path: "/admin/stream-testing", role: "admin", section: "admin" },
|
{ label: "Stream testing", path: "/admin/stream-testing", role: "admin", section: "admin" },
|
||||||
|
{ label: "Platform tenure", path: "/admin/platform-tenure", role: "admin", section: "admin" },
|
||||||
{ label: "Diagnostics", path: "/admin/diagnostics", role: "admin", section: "admin" },
|
{ label: "Diagnostics", path: "/admin/diagnostics", role: "admin", section: "admin" },
|
||||||
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
|
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
|
||||||
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
|
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
|
||||||
@ -8237,6 +8265,7 @@ function getDefaultNavIcon(item) {
|
|||||||
if (pathName === "/admin/theming") return "theming";
|
if (pathName === "/admin/theming") return "theming";
|
||||||
if (pathName === "/admin/privileges") return "privileges";
|
if (pathName === "/admin/privileges") return "privileges";
|
||||||
if (pathName === "/admin/diagnostics") return "admin";
|
if (pathName === "/admin/diagnostics") return "admin";
|
||||||
|
if (pathName === "/admin/platform-tenure") return "admin";
|
||||||
if (pathName === "/admin/logs") return "logs";
|
if (pathName === "/admin/logs") return "logs";
|
||||||
if (pathName === "/admin/updates") return "updates";
|
if (pathName === "/admin/updates") return "updates";
|
||||||
if (pathName === "/admin/commands") return "commands";
|
if (pathName === "/admin/commands") return "commands";
|
||||||
|
|||||||
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>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<% if (rowType === "user" && entry.username) { %>
|
<% if (rowType === "user" && entry.username) { %>
|
||||||
<a class="link" href="/stats/<%= encodeURIComponent(entry.username) %>"><%= entry.username %></a>
|
<a class="link" href="/stats/<%= encodeURIComponent(entry.username) %>"><%= entryLabel %></a>
|
||||||
<% } else if (rowType === "command") { %>
|
<% } else if (rowType === "command") { %>
|
||||||
<% if (entry.href) { %>
|
<% if (entry.href) { %>
|
||||||
<a class="link" href="<%= entry.href %>"><code><%= entryLabel %></code></a>
|
<a class="link" href="<%= entry.href %>"><code><%= entryLabel %></code></a>
|
||||||
|
|||||||
@ -35,6 +35,25 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Platform tenure</h2>
|
||||||
|
<% if (!tenureStats || !tenureStats.length) { %>
|
||||||
|
<p>Follow, membership, role, and server tenure will appear here as Lumi observes it.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<% tenureStats.forEach((stat) => { %>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label"><%= stat.label %></span>
|
||||||
|
<span class="stat-value"><%= stat.total %></span>
|
||||||
|
<% if (stat.current) { %>
|
||||||
|
<span class="help">Current period: <%= stat.current %></span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Expression Interaction</h2>
|
<h2>Expression Interaction</h2>
|
||||||
<% if (!expression) { %>
|
<% if (!expression) { %>
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "Lumi Core",
|
"name": "Lumi Core",
|
||||||
"version": "0.3.9",
|
"version": "0.3.10",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"released_at": "2026-07-26",
|
"released_at": "2026-07-27",
|
||||||
"compatible_from": "0.1.9",
|
"compatible_from": "0.1.9",
|
||||||
"migration_kind": "patch",
|
"migration_kind": "patch",
|
||||||
"replaces_versions": [
|
"replaces_versions": [
|
||||||
"1.2.0"
|
"1.2.0"
|
||||||
],
|
],
|
||||||
"migration_notes": "Restores animated emotes, Twitch badge artwork, and Discord GIF media through the existing OBS and native Companion overlay paths; adds persistent Auto VC welcome controls and timed ownership claiming; and releases Companion 0.2.7. Existing settings, lobbies, rooms, owners, permissions, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved.",
|
"migration_notes": "Adds reusable platform-tenure interval history for Twitch, YouTube, and Discord; contextual current and total duration placeholders with deterministic formatting; safe platform reconciliation; admin diagnostics; and Platform tenure sections on Stats and Leaderboards. Existing settings, identities, statistics, intervals, plugins, pairing records, databases, uploads, models, and secrets remain preserved.",
|
||||||
"rollback_safe": true,
|
"rollback_safe": true,
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"Node.js 18 or newer"
|
"Node.js 18 or newer"
|
||||||
@ -457,6 +457,18 @@
|
|||||||
],
|
],
|
||||||
"rollback_safe": true,
|
"rollback_safe": true,
|
||||||
"migration_notes": "Adds the native Windows Lumi Overlay through the existing paired-device and provider pipelines, preserves active and revoked device semantics, and releases Companion 0.2.6 with draft-safe settings, direct single-page plugin navigation, and clearer overlay controls. Existing settings, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved."
|
"migration_notes": "Adds the native Windows Lumi Overlay through the existing paired-device and provider pipelines, preserves active and revoked device semantics, and releases Companion 0.2.6 with draft-safe settings, direct single-page plugin navigation, and clearer overlay controls. Existing settings, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.3.9",
|
||||||
|
"channel": "stable",
|
||||||
|
"released_at": "2026-07-26",
|
||||||
|
"compatible_from": "0.1.9",
|
||||||
|
"migration_kind": "patch",
|
||||||
|
"replaces_versions": [
|
||||||
|
"1.2.0"
|
||||||
|
],
|
||||||
|
"rollback_safe": true,
|
||||||
|
"migration_notes": "Restores animated emotes, Twitch badge artwork, and Discord GIF media through the existing OBS and native Companion overlay paths; adds persistent Auto VC welcome controls and timed ownership claiming; and releases Companion 0.2.7. Existing settings, lobbies, rooms, owners, permissions, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user