mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -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 LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
||||
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
||||
const STORE_VERSION = 2;
|
||||
const STORE_VERSION = 3;
|
||||
const identityEvents = new EventEmitter();
|
||||
|
||||
let db = null;
|
||||
@@ -169,7 +169,10 @@ function ensureSchema(conn) {
|
||||
deterrence_by text,
|
||||
muted_enabled integer not null default 0,
|
||||
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 (
|
||||
@@ -220,7 +223,8 @@ function ensureSchema(conn) {
|
||||
/*
|
||||
SQLite's `create table if not exists` leaves an existing table untouched.
|
||||
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(
|
||||
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
|
||||
@@ -234,6 +238,15 @@ function ensureSchema(conn) {
|
||||
if (!statusColumns.has('muted_by')) {
|
||||
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()) {
|
||||
@@ -418,6 +431,7 @@ function setSocketIdentityState(socket, user, identity = {}) {
|
||||
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
||||
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
||||
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
||||
socket.data.hasAudioGainBoost = Boolean(user.audioGainBoost?.enabled);
|
||||
}
|
||||
|
||||
function identifySocket(socket, payload = {}) {
|
||||
@@ -509,6 +523,11 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
|
||||
mutedAt: status.muted_at || 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,
|
||||
};
|
||||
}
|
||||
@@ -726,21 +745,48 @@ function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
||||
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) {
|
||||
return Boolean(socket?.data?.isVerified);
|
||||
}
|
||||
|
||||
function hasAudioGainBoost(socket) {
|
||||
return Boolean(socket?.data?.hasAudioGainBoost);
|
||||
}
|
||||
|
||||
function isDeterred(socket) {
|
||||
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();
|
||||
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
||||
const where = [];
|
||||
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 (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 ')}`;
|
||||
sql += ' order by users.updated_at desc';
|
||||
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),
|
||||
mutedAt: user.deterrence?.mutedAt || 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);
|
||||
}
|
||||
|
||||
function listAudioGainBoostUsers() {
|
||||
return listUsers({ audioGainBoost: true }).map(userToLegacyIdentityEntry);
|
||||
}
|
||||
|
||||
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
||||
const value = String(selector || '').trim();
|
||||
if (!value) return { error: 'selector_required' };
|
||||
@@ -1031,11 +1084,14 @@ module.exports = {
|
||||
setVerified,
|
||||
setDeterrence,
|
||||
setMuted,
|
||||
setAudioGainBoost,
|
||||
isVerified,
|
||||
isDeterred,
|
||||
hasAudioGainBoost,
|
||||
listVerifiedUsers,
|
||||
listDeterredUsers,
|
||||
listMutedUsers,
|
||||
listAudioGainBoostUsers,
|
||||
resolveUserBySelector,
|
||||
userToLegacyIdentityEntry,
|
||||
createJsonStore,
|
||||
|
||||
@@ -27,9 +27,11 @@ const {
|
||||
setVerified,
|
||||
setDeterrence,
|
||||
setMuted,
|
||||
setAudioGainBoost,
|
||||
listVerifiedUsers,
|
||||
listDeterredUsers,
|
||||
listMutedUsers,
|
||||
listAudioGainBoostUsers,
|
||||
resolveUserBySelector,
|
||||
userToLegacyIdentityEntry,
|
||||
} = require('../identityService');
|
||||
@@ -113,10 +115,17 @@ function refreshSocketIdentityFlags(socket) {
|
||||
operational chat/audio tools while preserving the flag for normal roles.
|
||||
*/
|
||||
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 {
|
||||
isVerified: socket.data.isVerified,
|
||||
isDeterred: socket.data.isDeterred,
|
||||
isMuted: socket.data.isMuted,
|
||||
hasAudioGainBoost: socket.data.hasAudioGainBoost,
|
||||
matchedRecordId: user.id,
|
||||
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
||||
userId: user.id,
|
||||
@@ -565,6 +574,53 @@ function setUserMute(selector, enabled, actor = null) {
|
||||
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) {
|
||||
return setUserMute(selector, true, actor);
|
||||
}
|
||||
@@ -696,9 +752,13 @@ module.exports = {
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listAudioGainBoostUsers,
|
||||
grantAudioGainBoost,
|
||||
revokeAudioGainBoost,
|
||||
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
||||
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
||||
isMuted: (socket) => Boolean(socket?.data?.isMuted),
|
||||
hasAudioGainBoost: (socket) => Boolean(socket?.data?.hasAudioGainBoost),
|
||||
reevaluateSocketVerification,
|
||||
reevaluateSocketDeterrence,
|
||||
verificationEvents,
|
||||
|
||||
Reference in New Issue
Block a user