mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
feat(identity): add audioGainBoost user flag
Adds an audio_gain_boost_enabled status column (plus _at/_by audit fields) to user_status, exposed as user.audioGainBoost and copied onto sockets as socket.data.hasAudioGainBoost. The flag marks VIPs allowed to raise their personal horn/TTS/mic gain ceiling past the global admin gain settings. Grant/revoke goes through verificationService so socket flags refresh and an event is published. Resolution is restricted to verified users, so an unverified visitor sharing a nickname can never be matched. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
|
|||||||
const DB_PATH = resolveDataPath('identity.sqlite');
|
const DB_PATH = resolveDataPath('identity.sqlite');
|
||||||
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
||||||
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
||||||
const STORE_VERSION = 2;
|
const STORE_VERSION = 3;
|
||||||
const identityEvents = new EventEmitter();
|
const identityEvents = new EventEmitter();
|
||||||
|
|
||||||
let db = null;
|
let db = null;
|
||||||
@@ -169,7 +169,10 @@ function ensureSchema(conn) {
|
|||||||
deterrence_by text,
|
deterrence_by text,
|
||||||
muted_enabled integer not null default 0,
|
muted_enabled integer not null default 0,
|
||||||
muted_at integer,
|
muted_at integer,
|
||||||
muted_by text
|
muted_by text,
|
||||||
|
audio_gain_boost_enabled integer not null default 0,
|
||||||
|
audio_gain_boost_at integer,
|
||||||
|
audio_gain_boost_by text
|
||||||
);
|
);
|
||||||
|
|
||||||
create table if not exists verification_requests (
|
create table if not exists verification_requests (
|
||||||
@@ -220,7 +223,8 @@ function ensureSchema(conn) {
|
|||||||
/*
|
/*
|
||||||
SQLite's `create table if not exists` leaves an existing table untouched.
|
SQLite's `create table if not exists` leaves an existing table untouched.
|
||||||
Add the mute columns explicitly for installations created before store
|
Add the mute columns explicitly for installations created before store
|
||||||
version 2, while the column-name check keeps every later startup idempotent.
|
version 2, and the audio gain boost columns for those created before store
|
||||||
|
version 3. The column-name check keeps every later startup idempotent.
|
||||||
*/
|
*/
|
||||||
const statusColumns = new Set(
|
const statusColumns = new Set(
|
||||||
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
|
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
|
||||||
@@ -234,6 +238,15 @@ function ensureSchema(conn) {
|
|||||||
if (!statusColumns.has('muted_by')) {
|
if (!statusColumns.has('muted_by')) {
|
||||||
conn.exec('alter table user_status add column muted_by text');
|
conn.exec('alter table user_status add column muted_by text');
|
||||||
}
|
}
|
||||||
|
if (!statusColumns.has('audio_gain_boost_enabled')) {
|
||||||
|
conn.exec('alter table user_status add column audio_gain_boost_enabled integer not null default 0');
|
||||||
|
}
|
||||||
|
if (!statusColumns.has('audio_gain_boost_at')) {
|
||||||
|
conn.exec('alter table user_status add column audio_gain_boost_at integer');
|
||||||
|
}
|
||||||
|
if (!statusColumns.has('audio_gain_boost_by')) {
|
||||||
|
conn.exec('alter table user_status add column audio_gain_boost_by text');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createUser(conn = getDb(), ts = nowMs()) {
|
function createUser(conn = getDb(), ts = nowMs()) {
|
||||||
@@ -418,6 +431,7 @@ function setSocketIdentityState(socket, user, identity = {}) {
|
|||||||
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
||||||
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
||||||
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
||||||
|
socket.data.hasAudioGainBoost = Boolean(user.audioGainBoost?.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function identifySocket(socket, payload = {}) {
|
function identifySocket(socket, payload = {}) {
|
||||||
@@ -509,6 +523,11 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
|
|||||||
mutedAt: status.muted_at || null,
|
mutedAt: status.muted_at || null,
|
||||||
mutedBy: status.muted_by || null,
|
mutedBy: status.muted_by || null,
|
||||||
},
|
},
|
||||||
|
audioGainBoost: {
|
||||||
|
enabled: Boolean(status.audio_gain_boost_enabled),
|
||||||
|
at: status.audio_gain_boost_at || null,
|
||||||
|
by: status.audio_gain_boost_by || null,
|
||||||
|
},
|
||||||
features,
|
features,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -726,21 +745,48 @@ function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
|||||||
return getUserById(id);
|
return getUserById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The audio gain boost flag lets a trusted VIP raise their personal horn/TTS/mic
|
||||||
|
gain ceiling past the global admin gain settings. It stays a status column
|
||||||
|
rather than feature state so it can be filtered in SQL alongside the other
|
||||||
|
moderation flags and copied onto the socket at identify time.
|
||||||
|
*/
|
||||||
|
function setAudioGainBoost(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
||||||
|
const id = String(userId || '').trim();
|
||||||
|
if (!id) throw new Error('userId required');
|
||||||
|
ensureUserStatus(getDb(), id);
|
||||||
|
getDb().prepare(`
|
||||||
|
update user_status
|
||||||
|
set audio_gain_boost_enabled = ?, audio_gain_boost_at = ?, audio_gain_boost_by = ?
|
||||||
|
where user_id = ?
|
||||||
|
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
|
||||||
|
identityEvents.emit('change', {
|
||||||
|
reason: enabled ? 'audio_gain_boost_granted' : 'audio_gain_boost_revoked',
|
||||||
|
userId: id,
|
||||||
|
});
|
||||||
|
return getUserById(id);
|
||||||
|
}
|
||||||
|
|
||||||
function isVerified(socket) {
|
function isVerified(socket) {
|
||||||
return Boolean(socket?.data?.isVerified);
|
return Boolean(socket?.data?.isVerified);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAudioGainBoost(socket) {
|
||||||
|
return Boolean(socket?.data?.hasAudioGainBoost);
|
||||||
|
}
|
||||||
|
|
||||||
function isDeterred(socket) {
|
function isDeterred(socket) {
|
||||||
return Boolean(socket?.data?.isDeterred);
|
return Boolean(socket?.data?.isDeterred);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listUsers({ verified = null, deterred = null, muted = null } = {}) {
|
function listUsers({ verified = null, deterred = null, muted = null, audioGainBoost = null } = {}) {
|
||||||
const conn = getDb();
|
const conn = getDb();
|
||||||
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
||||||
const where = [];
|
const where = [];
|
||||||
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
|
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
|
||||||
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
|
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
|
||||||
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
|
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
|
||||||
|
if (audioGainBoost !== null) where.push(`user_status.audio_gain_boost_enabled = ${audioGainBoost ? 1 : 0}`);
|
||||||
if (where.length) sql += ` where ${where.join(' and ')}`;
|
if (where.length) sql += ` where ${where.join(' and ')}`;
|
||||||
sql += ' order by users.updated_at desc';
|
sql += ' order by users.updated_at desc';
|
||||||
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
|
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
|
||||||
@@ -762,6 +808,9 @@ function userToLegacyIdentityEntry(user) {
|
|||||||
muted: Boolean(user.deterrence?.muted),
|
muted: Boolean(user.deterrence?.muted),
|
||||||
mutedAt: user.deterrence?.mutedAt || null,
|
mutedAt: user.deterrence?.mutedAt || null,
|
||||||
mutedBy: user.deterrence?.mutedBy || null,
|
mutedBy: user.deterrence?.mutedBy || null,
|
||||||
|
audioGainBoost: Boolean(user.audioGainBoost?.enabled),
|
||||||
|
audioGainBoostAt: user.audioGainBoost?.at || null,
|
||||||
|
audioGainBoostBy: user.audioGainBoost?.by || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,6 +826,10 @@ function listMutedUsers() {
|
|||||||
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
|
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listAudioGainBoostUsers() {
|
||||||
|
return listUsers({ audioGainBoost: true }).map(userToLegacyIdentityEntry);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
||||||
const value = String(selector || '').trim();
|
const value = String(selector || '').trim();
|
||||||
if (!value) return { error: 'selector_required' };
|
if (!value) return { error: 'selector_required' };
|
||||||
@@ -1031,11 +1084,14 @@ module.exports = {
|
|||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
setMuted,
|
setMuted,
|
||||||
|
setAudioGainBoost,
|
||||||
isVerified,
|
isVerified,
|
||||||
isDeterred,
|
isDeterred,
|
||||||
|
hasAudioGainBoost,
|
||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
listMutedUsers,
|
listMutedUsers,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
resolveUserBySelector,
|
resolveUserBySelector,
|
||||||
userToLegacyIdentityEntry,
|
userToLegacyIdentityEntry,
|
||||||
createJsonStore,
|
createJsonStore,
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ const {
|
|||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
setMuted,
|
setMuted,
|
||||||
|
setAudioGainBoost,
|
||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
listMutedUsers,
|
listMutedUsers,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
resolveUserBySelector,
|
resolveUserBySelector,
|
||||||
userToLegacyIdentityEntry,
|
userToLegacyIdentityEntry,
|
||||||
} = require('../identityService');
|
} = require('../identityService');
|
||||||
@@ -113,10 +115,17 @@ function refreshSocketIdentityFlags(socket) {
|
|||||||
operational chat/audio tools while preserving the flag for normal roles.
|
operational chat/audio tools while preserving the flag for normal roles.
|
||||||
*/
|
*/
|
||||||
socket.data.isMuted = isAdminRole(role) ? false : mutedByUser;
|
socket.data.isMuted = isAdminRole(role) ? false : mutedByUser;
|
||||||
|
/*
|
||||||
|
Admins already control the global gain settings, so they carry the raised
|
||||||
|
audio ceiling implicitly for the same reason they carry verification: a
|
||||||
|
stored per-user grant should never be what gates an operator's own tools.
|
||||||
|
*/
|
||||||
|
socket.data.hasAudioGainBoost = verifiedByRole || Boolean(user.audioGainBoost?.enabled);
|
||||||
return {
|
return {
|
||||||
isVerified: socket.data.isVerified,
|
isVerified: socket.data.isVerified,
|
||||||
isDeterred: socket.data.isDeterred,
|
isDeterred: socket.data.isDeterred,
|
||||||
isMuted: socket.data.isMuted,
|
isMuted: socket.data.isMuted,
|
||||||
|
hasAudioGainBoost: socket.data.hasAudioGainBoost,
|
||||||
matchedRecordId: user.id,
|
matchedRecordId: user.id,
|
||||||
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -565,6 +574,53 @@ function setUserMute(selector, enabled, actor = null) {
|
|||||||
return userToLegacyIdentityEntry(user);
|
return userToLegacyIdentityEntry(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Audio gain boost is a VIP-only grant. resolveUserBySelector's `includeVerified`
|
||||||
|
flag reads as an inclusion toggle but actually relaxes the verified filter, so
|
||||||
|
passing `false` is what restricts the nickname lookup to verified users. That
|
||||||
|
keeps an unverified visitor from being matched by a shared nickname and handed
|
||||||
|
a raised gain ceiling they are not eligible to use.
|
||||||
|
*/
|
||||||
|
function setUserAudioGainBoost(selector, enabled, actor = null) {
|
||||||
|
const resolved = resolveUserBySelector(selector, { includeVerified: false, includeDeterred: true });
|
||||||
|
if (resolved.error || !resolved.user) {
|
||||||
|
throw new Error(resolved.error === 'ambiguous_nickname'
|
||||||
|
? 'Nickname matches multiple VIPs.'
|
||||||
|
: 'VIP not found. Only verified users can be granted an audio gain boost.');
|
||||||
|
}
|
||||||
|
if (!resolved.user.verified?.enabled) {
|
||||||
|
throw new Error('Only verified VIPs can be granted an audio gain boost.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = setAudioGainBoost(resolved.user.id, {
|
||||||
|
enabled,
|
||||||
|
actor: actor ? String(actor) : null,
|
||||||
|
at: Date.now(),
|
||||||
|
});
|
||||||
|
refreshSocketsForUser(user.id);
|
||||||
|
publishEvent({
|
||||||
|
source: 'moderation',
|
||||||
|
type: enabled ? 'audio.gainBoostGranted' : 'audio.gainBoostRevoked',
|
||||||
|
payload: {
|
||||||
|
userId: user.id,
|
||||||
|
cookieUserId: user.cookieUserIds[0] || null,
|
||||||
|
nickname: user.nickname,
|
||||||
|
actor: actor ? String(actor) : null,
|
||||||
|
ts: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emitChange(enabled ? 'audio_gain_boost_grant' : 'audio_gain_boost_revoke', { userId: user.id });
|
||||||
|
return userToLegacyIdentityEntry(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantAudioGainBoost(selector, actor = null) {
|
||||||
|
return setUserAudioGainBoost(selector, true, actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeAudioGainBoost(selector, actor = null) {
|
||||||
|
return setUserAudioGainBoost(selector, false, actor);
|
||||||
|
}
|
||||||
|
|
||||||
function muteUser(selector, actor = null) {
|
function muteUser(selector, actor = null) {
|
||||||
return setUserMute(selector, true, actor);
|
return setUserMute(selector, true, actor);
|
||||||
}
|
}
|
||||||
@@ -696,9 +752,13 @@ module.exports = {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
|
grantAudioGainBoost,
|
||||||
|
revokeAudioGainBoost,
|
||||||
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
||||||
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
||||||
isMuted: (socket) => Boolean(socket?.data?.isMuted),
|
isMuted: (socket) => Boolean(socket?.data?.isMuted),
|
||||||
|
hasAudioGainBoost: (socket) => Boolean(socket?.data?.hasAudioGainBoost),
|
||||||
reevaluateSocketVerification,
|
reevaluateSocketVerification,
|
||||||
reevaluateSocketDeterrence,
|
reevaluateSocketDeterrence,
|
||||||
verificationEvents,
|
verificationEvents,
|
||||||
|
|||||||
Reference in New Issue
Block a user