mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 18:10:47 -04:00
verifiction
This commit is contained in:
@@ -16,6 +16,7 @@ require('./src/services/commandService');
|
||||
require('./src/services/roverConnectionService');
|
||||
require('./src/services/assignmentService');
|
||||
require('./src/services/nicknameService');
|
||||
require('./src/services/verificationService');
|
||||
require('./src/services/chatService');
|
||||
require('./src/services/llmCommentaryService');
|
||||
require('./src/services/communityGoalService');
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-DETl6izG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DzeZhrlZ.css">
|
||||
<script type="module" crossorigin src="/assets/index-DlEkWSx3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dx4QsRNa.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -31,6 +31,14 @@ const {
|
||||
normalizeMode,
|
||||
VALID_MODES,
|
||||
} = require('./discordGuildStore');
|
||||
const {
|
||||
attachDmMessage,
|
||||
getRequestByMessageId,
|
||||
approveRequest,
|
||||
denyRequest,
|
||||
listVerifiedUsers,
|
||||
removeVerifiedUser,
|
||||
} = require('./verificationService');
|
||||
|
||||
const config = loadConfig();
|
||||
const discordConfig = config.discord || {};
|
||||
@@ -53,13 +61,16 @@ if (!enabled) {
|
||||
const intents = [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.GuildMessageReactions,
|
||||
GatewayIntentBits.GuildMessageTyping,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
GatewayIntentBits.DirectMessageReactions,
|
||||
GatewayIntentBits.MessageContent,
|
||||
];
|
||||
|
||||
const client = new Client({
|
||||
intents,
|
||||
partials: [Partials.Channel],
|
||||
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
|
||||
});
|
||||
|
||||
const channelCache = new Map();
|
||||
@@ -68,6 +79,8 @@ let skippedFirstModeAnnouncement = false;
|
||||
const PRESENCE_ROTATE_MS = 20000;
|
||||
let presenceInterval = null;
|
||||
let presenceShowGoal = false;
|
||||
const VERIFY_APPROVE_EMOJI = '✅';
|
||||
const VERIFY_DENY_EMOJI = '❌';
|
||||
function sanitizeMentions(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
@@ -271,6 +284,8 @@ function formatHelp() {
|
||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
||||
'`rs reason [text|clear]` — show or set admin mode reason',
|
||||
'`rs goal [text|clear]` — show or set community goal',
|
||||
'`rs verify list` — list verified users (lockdown admins)',
|
||||
'`rs verify remove <cookieUserId|nickname>` — remove verified user (lockdown admins)',
|
||||
'`ts` — show time status',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -580,6 +595,74 @@ async function handleGoalCommand(message, tokens) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatMaskedCookieKey(value) {
|
||||
const key = String(value || '').trim();
|
||||
if (!key) return 'n/a';
|
||||
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
||||
return `${key.slice(0, 6)}...${key.slice(-6)}`;
|
||||
}
|
||||
|
||||
async function handleVerifyCommand(message, tokens) {
|
||||
if (!isLockdownAdminUser(message.author?.id)) {
|
||||
await message.reply({
|
||||
content: 'Only lockdown admins can manage verified users.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const action = (tokens.shift() || 'list').toLowerCase();
|
||||
if (action === 'list') {
|
||||
const users = listVerifiedUsers();
|
||||
if (!users.length) {
|
||||
await message.reply({
|
||||
content: 'No verified users.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const lines = users.map((entry, idx) => {
|
||||
const updated = entry.updatedAt ? new Date(entry.updatedAt).toLocaleString() : 'unknown';
|
||||
const ipCount = Array.isArray(entry.knownIps) ? entry.knownIps.length : 0;
|
||||
return `${idx + 1}. ${entry.nickname || 'unknown'} | ${formatMaskedCookieKey(entry.cookieUserId)} | ips:${ipCount} | updated:${updated}`;
|
||||
});
|
||||
await message.reply({
|
||||
content: ['Verified users:', ...lines].join('\n').slice(0, 1900),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'remove') {
|
||||
const selector = tokens.join(' ').trim();
|
||||
if (!selector) {
|
||||
await message.reply({
|
||||
content: 'Usage: `rs verify remove <cookieUserId|nickname>`',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const removed = removeVerifiedUser(selector, message.author?.id || null);
|
||||
await message.reply({
|
||||
content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${formatMaskedCookieKey(removed.cookieUserId)}).`,
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
} catch (err) {
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Failed to remove verified user: ${err.message}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await message.reply({
|
||||
content: 'Unknown verify command. Use `rs verify list` or `rs verify remove <cookieUserId|nickname>`.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
}
|
||||
|
||||
function canManageBridge(message) {
|
||||
if (isAdminUser(message.author.id)) return true;
|
||||
if (!message.guild || !message.member) return false;
|
||||
@@ -757,7 +840,7 @@ async function handleCommand(message) {
|
||||
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
|
||||
const isBridgeAdmin = action === 'bridge' ? canManageBridge(message) : false;
|
||||
const mode = getMode();
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason']);
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify']);
|
||||
|
||||
if (
|
||||
!isAdmin &&
|
||||
@@ -768,7 +851,8 @@ async function handleCommand(message) {
|
||||
action !== 'replay' &&
|
||||
action !== 'bridge' &&
|
||||
action !== 'goal' &&
|
||||
action !== 'reason'
|
||||
action !== 'reason' &&
|
||||
action !== 'verify'
|
||||
) {
|
||||
return; // ignore non-admins for privileged commands
|
||||
}
|
||||
@@ -812,6 +896,9 @@ async function handleCommand(message) {
|
||||
case 'reason':
|
||||
await handleReasonCommand(message, tokens);
|
||||
break;
|
||||
case 'verify':
|
||||
await handleVerifyCommand(message, tokens);
|
||||
break;
|
||||
default:
|
||||
await message.reply(formatHelp());
|
||||
break;
|
||||
@@ -1348,6 +1435,96 @@ function handleBusEvent(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendVerificationRequestDms(event) {
|
||||
const payload = event?.payload || {};
|
||||
const requestId = payload.id;
|
||||
if (!requestId) return;
|
||||
const adminIdsToNotify = Array.from(lockdownAdminIds);
|
||||
if (!adminIdsToNotify.length) {
|
||||
logger.warn('No lockdown admins configured for verification request DM', { requestId });
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAt = payload.createdAt ? new Date(payload.createdAt).toLocaleString() : 'unknown';
|
||||
const content = [
|
||||
'**Verification Request**',
|
||||
`Request ID: \`${requestId}\``,
|
||||
`Nickname: ${sanitizeMentions(payload.nickname || 'unknown')}`,
|
||||
`Identity key: \`${payload.cookieUserId || 'unknown'}\``,
|
||||
`IP: \`${payload.ip || 'unknown'}\``,
|
||||
`Created: ${createdAt}`,
|
||||
'',
|
||||
`React with ${VERIFY_APPROVE_EMOJI} to approve or ${VERIFY_DENY_EMOJI} to deny.`,
|
||||
].join('\n');
|
||||
|
||||
await Promise.all(
|
||||
adminIdsToNotify.map(async (adminId) => {
|
||||
try {
|
||||
const user = await client.users.fetch(String(adminId));
|
||||
if (!user) return;
|
||||
const dm = await user.createDM();
|
||||
const message = await dm.send({ content, allowedMentions: { parse: [] } });
|
||||
try {
|
||||
await message.react(VERIFY_APPROVE_EMOJI);
|
||||
await message.react(VERIFY_DENY_EMOJI);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to add verification reactions', { requestId, adminId, error: err.message });
|
||||
}
|
||||
attachDmMessage(requestId, message.id, adminId);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to DM lockdown admin for verification request', {
|
||||
requestId,
|
||||
adminId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleVerificationReaction(reaction, user) {
|
||||
if (!reaction || !user || user.bot) return;
|
||||
const emoji = reaction.emoji?.name;
|
||||
if (emoji !== VERIFY_APPROVE_EMOJI && emoji !== VERIFY_DENY_EMOJI) return;
|
||||
if (!isLockdownAdminUser(user.id)) return;
|
||||
|
||||
const maybePartial = reaction.message?.partial || reaction.partial;
|
||||
if (maybePartial) {
|
||||
try {
|
||||
await reaction.fetch();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to fetch partial reaction', err.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const messageId = reaction.message?.id;
|
||||
if (!messageId) return;
|
||||
const linked = getRequestByMessageId(messageId);
|
||||
if (!linked?.request || linked.request.status !== 'pending') return;
|
||||
|
||||
try {
|
||||
if (emoji === VERIFY_APPROVE_EMOJI) {
|
||||
approveRequest(linked.request.id, user.id);
|
||||
await reaction.message.reply({
|
||||
content: `Approved request \`${linked.request.id}\`.`,
|
||||
allowedMentions: { parse: [] },
|
||||
});
|
||||
} else {
|
||||
denyRequest(linked.request.id, user.id);
|
||||
await reaction.message.reply({
|
||||
content: `Denied request \`${linked.request.id}\`.`,
|
||||
allowedMentions: { parse: [] },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to resolve verification request from reaction', {
|
||||
requestId: linked.request.id,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatBridgeOutbound(event) {
|
||||
const payload = event?.payload;
|
||||
if (!payload) return;
|
||||
@@ -1445,12 +1622,19 @@ client.on('typingStart', (typing) => {
|
||||
});
|
||||
});
|
||||
|
||||
client.on('messageReactionAdd', (reaction, user) => {
|
||||
handleVerificationReaction(reaction, user).catch((err) => {
|
||||
logger.warn('Error handling verification reaction', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
client.once('ready', () => {
|
||||
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
||||
schedulePresenceRotation();
|
||||
});
|
||||
|
||||
subscribe('*', handleBusEvent);
|
||||
subscribe('verification.requested', sendVerificationRequestDms);
|
||||
subscribe('chat:message', handleChatBridgeOutbound);
|
||||
subscribe('chat:typing', handleChatTypingOutbound);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('nicknameService');
|
||||
const { getRole } = require('./roleService');
|
||||
|
||||
const nicknameEvents = new EventEmitter();
|
||||
|
||||
@@ -19,15 +18,14 @@ function getNickname(socket) {
|
||||
|
||||
function setNickname(socket, nickname) {
|
||||
if (!socket) return null;
|
||||
const role = getRole(socket);
|
||||
// if (role === 'spectator') {
|
||||
// throw new Error('Spectators cannot set nicknames');
|
||||
// }
|
||||
const value = sanitizeNickname(nickname);
|
||||
if (!value) {
|
||||
throw new Error('Nickname required');
|
||||
}
|
||||
socket.data = socket.data || {};
|
||||
if (socket.data.nickname === value) {
|
||||
return value;
|
||||
}
|
||||
socket.data.nickname = value;
|
||||
nicknameEvents.emit('change', { socketId: socket.id, nickname: value });
|
||||
logger.info('Nickname set', { socketId: socket.id, nickname: value });
|
||||
|
||||
@@ -9,6 +9,11 @@ const { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService')
|
||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
||||
const { getNickname, nicknameEvents } = require('./nicknameService');
|
||||
const {
|
||||
getVerificationStateForSocket,
|
||||
getIdentitySummary,
|
||||
verificationEvents,
|
||||
} = require('./verificationService');
|
||||
const { getReplayState, replayEvents } = require('./replayService');
|
||||
const { getReplaySources } = require('./replaySourceService');
|
||||
const { getHealthSnapshot } = require('./healthService');
|
||||
@@ -83,6 +88,9 @@ function buildSession(socket) {
|
||||
kofi: {
|
||||
link: kofiLink,
|
||||
},
|
||||
identity: getIdentitySummary(socket),
|
||||
verification: getVerificationStateForSocket(socket),
|
||||
isVerified: Boolean(socket?.data?.isVerified),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,6 +237,17 @@ nicknameEvents.on('change', ({ socketId }) => {
|
||||
}
|
||||
});
|
||||
|
||||
verificationEvents.on('change', ({ socketId } = {}) => {
|
||||
if (socketId) {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (socket) {
|
||||
syncSocket(socket);
|
||||
return;
|
||||
}
|
||||
}
|
||||
syncAll();
|
||||
});
|
||||
|
||||
subscribe('communityGoal.updated', () => {
|
||||
logger.info('Community goal updated; syncing all clients');
|
||||
syncAll();
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('verificationService');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
const { getSocketIp, normalizeIp } = require('../helpers/ipResolver');
|
||||
const { getNickname, setNickname } = require('./nicknameService');
|
||||
const { getRole } = require('./roleService');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'verified-users.json');
|
||||
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
|
||||
|
||||
const verificationEvents = new EventEmitter();
|
||||
|
||||
let cache = null;
|
||||
|
||||
function sanitizeNickname(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.replace(/\*/g, 'nope').slice(0, 32);
|
||||
}
|
||||
|
||||
function normalizeStoreShape(store) {
|
||||
const next = store && typeof store === 'object' ? store : {};
|
||||
return {
|
||||
verifiedUsers: Array.isArray(next.verifiedUsers) ? next.verifiedUsers : [],
|
||||
pendingRequests: Array.isArray(next.pendingRequests) ? next.pendingRequests : [],
|
||||
dmMessages: Array.isArray(next.dmMessages) ? next.dmMessages : [],
|
||||
};
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = fs.readFileSync(STORE_PATH, 'utf8');
|
||||
cache = normalizeStoreShape(JSON.parse(raw));
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load verification store', err.message);
|
||||
}
|
||||
cache = normalizeStoreShape({});
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function writeStore(next) {
|
||||
const normalized = normalizeStoreShape(next);
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(tempPath, STORE_PATH);
|
||||
cache = normalized;
|
||||
return cache;
|
||||
}
|
||||
|
||||
function withStore(mutator) {
|
||||
const current = loadStore();
|
||||
const draft = {
|
||||
verifiedUsers: current.verifiedUsers.map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })),
|
||||
pendingRequests: current.pendingRequests.map((entry) => ({ ...entry })),
|
||||
dmMessages: current.dmMessages.map((entry) => ({ ...entry })),
|
||||
};
|
||||
const result = mutator(draft);
|
||||
writeStore(draft);
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateCookieUserId() {
|
||||
return `cu_${crypto.randomBytes(16).toString('hex')}`;
|
||||
}
|
||||
|
||||
function normalizeCookieUserId(value) {
|
||||
const raw = typeof value === 'string' ? value.trim() : '';
|
||||
if (!raw) return '';
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
|
||||
function isValidCookieUserId(value) {
|
||||
return COOKIE_USER_ID_RE.test(normalizeCookieUserId(value));
|
||||
}
|
||||
|
||||
function getKnownIp(socket) {
|
||||
return normalizeIp(getSocketIp(socket));
|
||||
}
|
||||
|
||||
function ensureSocketData(socket) {
|
||||
socket.data = socket.data || {};
|
||||
return socket.data;
|
||||
}
|
||||
|
||||
function findVerifiedMatch(store, { cookieUserId, ip }) {
|
||||
if (!cookieUserId && !ip) return null;
|
||||
const byCookie = cookieUserId
|
||||
? store.verifiedUsers.find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookieUserId) || null
|
||||
: null;
|
||||
if (byCookie) return byCookie;
|
||||
if (!ip) return null;
|
||||
return (
|
||||
store.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(ip)) || null
|
||||
);
|
||||
}
|
||||
|
||||
function emitChange(reason, payload = {}) {
|
||||
verificationEvents.emit('change', { reason, ...payload });
|
||||
}
|
||||
|
||||
function reevaluateSocketVerification(socket) {
|
||||
if (!socket) return { isVerified: false, matchedRecordId: null, reason: 'missing_socket' };
|
||||
const store = loadStore();
|
||||
const data = ensureSocketData(socket);
|
||||
const role = getRole(socket);
|
||||
const cookieUserId = normalizeCookieUserId(data.cookieUserId);
|
||||
const nickname = sanitizeNickname(getNickname(socket));
|
||||
const ip = getKnownIp(socket);
|
||||
|
||||
if (role === 'lockdown') {
|
||||
data.isVerified = true;
|
||||
data.verifiedRecordId = null;
|
||||
return {
|
||||
isVerified: true,
|
||||
matchedRecordId: null,
|
||||
reason: 'lockdown_admin',
|
||||
cookieUserId,
|
||||
nickname,
|
||||
ip,
|
||||
};
|
||||
}
|
||||
|
||||
const match = findVerifiedMatch(store, { cookieUserId, ip });
|
||||
const nicknameMatches = Boolean(match && nickname && sanitizeNickname(match.nickname) === nickname);
|
||||
|
||||
let isVerified = false;
|
||||
let reason = 'no_match';
|
||||
if (match && nicknameMatches) {
|
||||
isVerified = true;
|
||||
reason = 'matched';
|
||||
} else if (match && !nicknameMatches) {
|
||||
reason = 'nickname_mismatch';
|
||||
}
|
||||
|
||||
data.isVerified = isVerified;
|
||||
data.verifiedRecordId = isVerified ? match.id : null;
|
||||
|
||||
if (isVerified) {
|
||||
withStore((draft) => {
|
||||
const record = draft.verifiedUsers.find((entry) => entry.id === match.id);
|
||||
if (!record) return;
|
||||
record.updatedAt = Date.now();
|
||||
record.nickname = nickname;
|
||||
if (ip && !record.knownIps.includes(ip)) {
|
||||
record.knownIps.push(ip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isVerified,
|
||||
matchedRecordId: isVerified ? match.id : null,
|
||||
reason,
|
||||
cookieUserId,
|
||||
nickname,
|
||||
ip,
|
||||
};
|
||||
}
|
||||
|
||||
function identifySocket(socket, payload = {}) {
|
||||
if (!socket) {
|
||||
throw new Error('Socket required');
|
||||
}
|
||||
const data = ensureSocketData(socket);
|
||||
const incomingKey = normalizeCookieUserId(payload.cookieUserId);
|
||||
if (incomingKey && !isValidCookieUserId(incomingKey)) {
|
||||
throw new Error('Invalid identity key format.');
|
||||
}
|
||||
const currentKey = normalizeCookieUserId(data.cookieUserId);
|
||||
const safeCurrentKey = isValidCookieUserId(currentKey) ? currentKey : '';
|
||||
data.cookieUserId = incomingKey || safeCurrentKey || generateCookieUserId();
|
||||
|
||||
const incomingNickname = sanitizeNickname(payload.nickname);
|
||||
if (incomingNickname) {
|
||||
try {
|
||||
if (incomingNickname !== getNickname(socket)) {
|
||||
setNickname(socket, incomingNickname);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to set nickname from identify', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const verification = reevaluateSocketVerification(socket);
|
||||
emitChange('identify', { socketId: socket.id });
|
||||
return {
|
||||
cookieUserId: data.cookieUserId,
|
||||
isVerified: verification.isVerified,
|
||||
reason: verification.reason,
|
||||
identifiedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function getVerificationStatus(socket) {
|
||||
const data = socket?.data || {};
|
||||
return {
|
||||
isVerified: Boolean(data.isVerified),
|
||||
recordId: data.verifiedRecordId || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getIdentitySummary(socket) {
|
||||
const data = socket?.data || {};
|
||||
return {
|
||||
cookieUserId: normalizeCookieUserId(data.cookieUserId) || null,
|
||||
nickname: getNickname(socket) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getPendingRequestForIdentity(cookieUserId) {
|
||||
const key = normalizeCookieUserId(cookieUserId);
|
||||
if (!key) return null;
|
||||
const store = loadStore();
|
||||
return store.pendingRequests.find((entry) => entry.status === 'pending' && entry.cookieUserId === key) || null;
|
||||
}
|
||||
|
||||
function listVerifiedUsers() {
|
||||
const store = loadStore();
|
||||
return store.verifiedUsers.map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] }));
|
||||
}
|
||||
|
||||
function resolveVerifiedUserSelector(selector) {
|
||||
const value = String(selector || '').trim();
|
||||
if (!value) return { error: 'selector_required' };
|
||||
const store = loadStore();
|
||||
const byCookie = store.verifiedUsers.find((entry) => entry.cookieUserId === value) || null;
|
||||
if (byCookie) return { record: byCookie };
|
||||
const byNickname = store.verifiedUsers.filter((entry) => sanitizeNickname(entry.nickname) === sanitizeNickname(value));
|
||||
if (byNickname.length === 1) return { record: byNickname[0] };
|
||||
if (byNickname.length > 1) return { error: 'ambiguous_nickname' };
|
||||
return { error: 'not_found' };
|
||||
}
|
||||
|
||||
function removeVerifiedUser(selector, removedBy = null) {
|
||||
const resolved = resolveVerifiedUserSelector(selector);
|
||||
if (resolved.error) {
|
||||
throw new Error(
|
||||
resolved.error === 'ambiguous_nickname'
|
||||
? 'Nickname matches multiple users; remove by cookieUserId.'
|
||||
: 'Verified user not found.',
|
||||
);
|
||||
}
|
||||
const target = resolved.record;
|
||||
let removed = null;
|
||||
withStore((draft) => {
|
||||
const before = draft.verifiedUsers.length;
|
||||
draft.verifiedUsers = draft.verifiedUsers.filter((entry) => entry.id !== target.id);
|
||||
if (draft.verifiedUsers.length !== before) {
|
||||
removed = target;
|
||||
}
|
||||
});
|
||||
if (!removed) {
|
||||
throw new Error('Verified user not found.');
|
||||
}
|
||||
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
const data = ensureSocketData(socket);
|
||||
if (normalizeCookieUserId(data.cookieUserId) === removed.cookieUserId) {
|
||||
reevaluateSocketVerification(socket);
|
||||
}
|
||||
});
|
||||
|
||||
emitChange('remove', { cookieUserId: removed.cookieUserId });
|
||||
publishEvent({
|
||||
source: 'verification',
|
||||
type: 'verification.userRemoved',
|
||||
payload: {
|
||||
cookieUserId: removed.cookieUserId,
|
||||
nickname: removed.nickname,
|
||||
removedBy,
|
||||
removedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
function createVerificationRequest(socket) {
|
||||
if (!socket) {
|
||||
throw new Error('Socket required');
|
||||
}
|
||||
const data = ensureSocketData(socket);
|
||||
const cookieUserId = normalizeCookieUserId(data.cookieUserId);
|
||||
const nickname = sanitizeNickname(getNickname(socket));
|
||||
const ip = getKnownIp(socket);
|
||||
|
||||
if (data.isVerified) {
|
||||
throw new Error('You are already verified.');
|
||||
}
|
||||
|
||||
if (!cookieUserId) {
|
||||
throw new Error('Identity key missing. Reconnect and try again.');
|
||||
}
|
||||
if (!isValidCookieUserId(cookieUserId)) {
|
||||
throw new Error('Identity key format invalid.');
|
||||
}
|
||||
if (!nickname) {
|
||||
throw new Error('Nickname required before requesting verification.');
|
||||
}
|
||||
|
||||
const existingPending = getPendingRequestForIdentity(cookieUserId);
|
||||
if (existingPending) {
|
||||
return existingPending;
|
||||
}
|
||||
|
||||
const request = {
|
||||
id: `vr_${crypto.randomBytes(8).toString('hex')}`,
|
||||
status: 'pending',
|
||||
cookieUserId,
|
||||
nickname,
|
||||
ip,
|
||||
socketId: socket.id,
|
||||
createdAt: Date.now(),
|
||||
resolvedAt: null,
|
||||
resolvedBy: null,
|
||||
decision: null,
|
||||
};
|
||||
|
||||
withStore((draft) => {
|
||||
draft.pendingRequests.push(request);
|
||||
});
|
||||
|
||||
publishEvent({ source: 'verification', type: 'verification.requested', payload: request });
|
||||
emitChange('request', { requestId: request.id, socketId: socket.id });
|
||||
return request;
|
||||
}
|
||||
|
||||
function attachDmMessage(requestId, messageId, adminDiscordId) {
|
||||
if (!requestId || !messageId) return;
|
||||
withStore((draft) => {
|
||||
const exists = draft.dmMessages.find((entry) => entry.messageId === messageId);
|
||||
if (exists) return;
|
||||
draft.dmMessages.push({
|
||||
requestId,
|
||||
messageId,
|
||||
adminDiscordId: adminDiscordId ? String(adminDiscordId) : null,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getPendingRequestById(requestId) {
|
||||
if (!requestId) return null;
|
||||
const store = loadStore();
|
||||
return store.pendingRequests.find((entry) => entry.id === requestId && entry.status === 'pending') || null;
|
||||
}
|
||||
|
||||
function getRequestByMessageId(messageId) {
|
||||
if (!messageId) return null;
|
||||
const store = loadStore();
|
||||
const map = store.dmMessages.find((entry) => entry.messageId === messageId);
|
||||
if (!map) return null;
|
||||
const request = store.pendingRequests.find((entry) => entry.id === map.requestId) || null;
|
||||
return request ? { request, map } : null;
|
||||
}
|
||||
|
||||
function approveRequest(requestId, actorDiscordId) {
|
||||
const request = getPendingRequestById(requestId);
|
||||
if (!request) {
|
||||
throw new Error('Request not found or already resolved.');
|
||||
}
|
||||
|
||||
const approvedAt = Date.now();
|
||||
const actor = actorDiscordId ? String(actorDiscordId) : null;
|
||||
|
||||
withStore((draft) => {
|
||||
const pending = draft.pendingRequests.find((entry) => entry.id === requestId);
|
||||
if (!pending || pending.status !== 'pending') {
|
||||
throw new Error('Request not found or already resolved.');
|
||||
}
|
||||
pending.status = 'approved';
|
||||
pending.decision = 'approved';
|
||||
pending.resolvedAt = approvedAt;
|
||||
pending.resolvedBy = actor;
|
||||
|
||||
let target =
|
||||
draft.verifiedUsers.find((entry) => entry.cookieUserId === pending.cookieUserId) ||
|
||||
draft.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(pending.ip));
|
||||
|
||||
if (!target) {
|
||||
target = {
|
||||
id: `vu_${crypto.randomBytes(8).toString('hex')}`,
|
||||
cookieUserId: pending.cookieUserId,
|
||||
nickname: pending.nickname,
|
||||
knownIps: pending.ip ? [pending.ip] : [],
|
||||
createdAt: approvedAt,
|
||||
updatedAt: approvedAt,
|
||||
approvedBy: actor,
|
||||
};
|
||||
draft.verifiedUsers.push(target);
|
||||
} else {
|
||||
target.cookieUserId = pending.cookieUserId;
|
||||
target.nickname = pending.nickname;
|
||||
if (pending.ip && !target.knownIps.includes(pending.ip)) {
|
||||
target.knownIps.push(pending.ip);
|
||||
}
|
||||
target.updatedAt = approvedAt;
|
||||
target.approvedBy = actor;
|
||||
}
|
||||
});
|
||||
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
const data = ensureSocketData(socket);
|
||||
if (normalizeCookieUserId(data.cookieUserId) === request.cookieUserId) {
|
||||
reevaluateSocketVerification(socket);
|
||||
}
|
||||
});
|
||||
|
||||
publishEvent({
|
||||
source: 'verification',
|
||||
type: 'verification.resolved',
|
||||
payload: {
|
||||
requestId,
|
||||
decision: 'approved',
|
||||
cookieUserId: request.cookieUserId,
|
||||
nickname: request.nickname,
|
||||
resolvedBy: actor,
|
||||
resolvedAt: approvedAt,
|
||||
},
|
||||
});
|
||||
emitChange('approve', { requestId });
|
||||
}
|
||||
|
||||
function denyRequest(requestId, actorDiscordId) {
|
||||
const request = getPendingRequestById(requestId);
|
||||
if (!request) {
|
||||
throw new Error('Request not found or already resolved.');
|
||||
}
|
||||
|
||||
const deniedAt = Date.now();
|
||||
const actor = actorDiscordId ? String(actorDiscordId) : null;
|
||||
|
||||
withStore((draft) => {
|
||||
const pending = draft.pendingRequests.find((entry) => entry.id === requestId);
|
||||
if (!pending || pending.status !== 'pending') {
|
||||
throw new Error('Request not found or already resolved.');
|
||||
}
|
||||
pending.status = 'denied';
|
||||
pending.decision = 'denied';
|
||||
pending.resolvedAt = deniedAt;
|
||||
pending.resolvedBy = actor;
|
||||
});
|
||||
|
||||
publishEvent({
|
||||
source: 'verification',
|
||||
type: 'verification.resolved',
|
||||
payload: {
|
||||
requestId,
|
||||
decision: 'denied',
|
||||
cookieUserId: request.cookieUserId,
|
||||
nickname: request.nickname,
|
||||
resolvedBy: actor,
|
||||
resolvedAt: deniedAt,
|
||||
},
|
||||
});
|
||||
emitChange('deny', { requestId });
|
||||
}
|
||||
|
||||
function getVerificationStateForSocket(socket) {
|
||||
const identity = getIdentitySummary(socket);
|
||||
const pending = getPendingRequestForIdentity(identity.cookieUserId);
|
||||
return {
|
||||
isVerified: Boolean(socket?.data?.isVerified),
|
||||
pendingRequestId: pending?.id || null,
|
||||
pendingRequestedAt: pending?.createdAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
function isVerified(socket) {
|
||||
return Boolean(socket?.data?.isVerified);
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
identifySocket(socket, {});
|
||||
|
||||
socket.on('session:identify', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
const result = identifySocket(socket, payload || {});
|
||||
cb({ success: true, ...result });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('verification:request', (_, cb = () => {}) => {
|
||||
try {
|
||||
const request = createVerificationRequest(socket);
|
||||
cb({ success: true, requestId: request.id, status: request.status });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
identifySocket,
|
||||
getVerificationStatus,
|
||||
getIdentitySummary,
|
||||
getVerificationStateForSocket,
|
||||
createVerificationRequest,
|
||||
attachDmMessage,
|
||||
getRequestByMessageId,
|
||||
approveRequest,
|
||||
denyRequest,
|
||||
listVerifiedUsers,
|
||||
removeVerifiedUser,
|
||||
isVerified,
|
||||
reevaluateSocketVerification,
|
||||
verificationEvents,
|
||||
};
|
||||
Reference in New Issue
Block a user