mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
241 lines
9.8 KiB
JavaScript
241 lines
9.8 KiB
JavaScript
// Chat Context Builders
|
|
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
|
|
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const io = require('../../globals/io');
|
|
const roverManager = require('../roverManager');
|
|
const { getRole } = require('../roleService');
|
|
const { describeAssignment } = require('../assignmentService');
|
|
const { getNickname } = require('../nicknameService');
|
|
const ptzCameraService = require('../ptzCameraService');
|
|
|
|
function resolvePtzChatTarget(socketId) {
|
|
return ptzCameraService.getChatTargetForSocket(socketId) || null;
|
|
}
|
|
|
|
function resolveRoverId(socketId) {
|
|
const ptzTarget = resolvePtzChatTarget(socketId);
|
|
if (ptzTarget?.roverId) return ptzTarget.roverId;
|
|
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
|
if (primary) return primary;
|
|
const assignment = describeAssignment(socketId);
|
|
return assignment?.roverId || null;
|
|
}
|
|
|
|
function resolveRoverColor(roverId) {
|
|
if (!roverId) return null;
|
|
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
|
return ptzCameraService.getPublicState()?.color || null;
|
|
}
|
|
const record = roverManager.rovers.get(String(roverId));
|
|
return record?.meta?.color || null;
|
|
}
|
|
|
|
function resolveRoverName(roverId) {
|
|
if (!roverId) return null;
|
|
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
|
return ptzCameraService.getPublicState()?.name || null;
|
|
}
|
|
const record = roverManager.rovers.get(String(roverId));
|
|
return record?.meta?.name || null;
|
|
}
|
|
|
|
function isPtzChatTargetId(roverId) {
|
|
/*
|
|
PTZ is intentionally treated as a virtual rover for chat identity only. It
|
|
does not live in roverManager.rovers because movement, video authorization,
|
|
and queue ownership are PTZ-service concerns, but chat needs one stable
|
|
"rover-like" id so the existing web UI, Discord bridge, and AI transcript
|
|
code can all render the same badge without learning PTZ internals.
|
|
*/
|
|
return Boolean(roverId) && String(roverId) === ptzCameraService.PTZ_CAMERA_ID;
|
|
}
|
|
|
|
function isPublicChatTargetId(roverId, socket = null) {
|
|
if (!roverId) return false;
|
|
/*
|
|
Normal rovers remain governed by the existing replay visibility rule, which
|
|
is also the rule chat historically used to avoid exposing closed private
|
|
rover activity. PTZ gets an explicit allow-list entry here because it is a
|
|
public chat target that deliberately pretends to be a rover, even though it
|
|
is not a roverManager record.
|
|
*/
|
|
if (isPtzChatTargetId(roverId)) return true;
|
|
return roverManager.canReplayRoverId(roverId, socket) === true;
|
|
}
|
|
|
|
function isPrivateClosedRoverId(roverId) {
|
|
if (!roverId) return false;
|
|
/*
|
|
PTZ uses the existing rover badge fields so chat rows can reuse RoverLabel,
|
|
but it is not a private rover. Let PTZ-badged messages broadcast normally
|
|
instead of falling into the closed-private rover path for unknown ids.
|
|
*/
|
|
return !isPublicChatTargetId(roverId);
|
|
}
|
|
|
|
function normalizeProfileImageUrl(value) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = new URL(raw);
|
|
const protocol = String(parsed.protocol || '').toLowerCase();
|
|
if (protocol !== 'http:' && protocol !== 'https:') return null;
|
|
return parsed.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isChargingFromSensors(sensors = {}) {
|
|
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
|
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true;
|
|
const code = sensors?.chargingState?.code;
|
|
return code === 2 || code === 3 || code === 4;
|
|
}
|
|
|
|
function buildRoverCtxSnapshot(roverId) {
|
|
if (!roverId) return null;
|
|
const key = String(roverId);
|
|
const record = roverManager.rovers.get(key);
|
|
if (!record) return null;
|
|
const sensors = record?.lastSensor?.decoded || {};
|
|
const batteryState = record?.batteryState || null;
|
|
const { getActiveDrivers } = require('../turnService');
|
|
const activeDrivers = getActiveDrivers();
|
|
const driverSocketId = activeDrivers[key] || record?.drivers?.values?.().next?.().value || null;
|
|
const charging = isChargingFromSensors(sensors);
|
|
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
|
const wheelsOffGround = Boolean(sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight);
|
|
const latestDistanceM = Math.round((Math.abs(Number(sensors?.distanceMm) || 0) / 1000) * 10) / 10;
|
|
const latestTurnDeg = Math.round(Math.abs(Number(sensors?.angleDeg) || 0));
|
|
const latestBumps = (sensors?.bumpsAndWheelDrops?.bumpLeft ? 0.5 : 0) + (sensors?.bumpsAndWheelDrops?.bumpRight ? 0.5 : 0);
|
|
const light = sensors?.lightBumper || {};
|
|
const contactState = docked ? 'clear' : latestBumps >= 0.5 ? 'bumps_recent' : sensors?.wall || light.left || light.frontLeft || light.centerLeft || light.centerRight || light.frontRight || light.right ? 'wall_brush' : 'clear';
|
|
const hazardState = docked ? 'normal' : sensors?.virtualWall ? 'virtual_wall_seen' : sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight ? 'cliff_alert' : 'normal';
|
|
const mobilityState = wheelsOffGround ? 'wheels_off_ground' : 'normal';
|
|
const baseScore = Math.min(100, Math.round(Math.min(45, latestDistanceM * 25) + Math.min(30, latestTurnDeg / 12) + Math.min(25, latestBumps * 12)));
|
|
const activityScore = Math.max(0, Math.min(100, baseScore + (contactState === 'wall_brush' ? 6 : 0) + (contactState === 'bumps_recent' ? 12 : 0) + (hazardState !== 'normal' ? 8 : 0) + (wheelsOffGround ? -20 : 0)));
|
|
const activityBand = activityScore >= 75 ? 'intense' : activityScore >= 50 ? 'high' : activityScore >= 25 ? 'medium' : activityScore >= 8 ? 'low' : 'idle';
|
|
const moving = latestDistanceM > 0.05 || latestTurnDeg > 10;
|
|
let statusTag = 'idle';
|
|
if (charging) statusTag = 'charging';
|
|
else if (docked) statusTag = 'docked';
|
|
else if (driverSocketId && moving) statusTag = 'driving';
|
|
else if (driverSocketId) statusTag = 'active-idle';
|
|
|
|
return {
|
|
id: key,
|
|
status_tag: statusTag,
|
|
battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive),
|
|
docked,
|
|
charging,
|
|
wheels_off_ground: wheelsOffGround,
|
|
contact_state: contactState,
|
|
hazard_state: hazardState,
|
|
mobility_state: mobilityState,
|
|
activity_score: activityScore,
|
|
activity_band: activityBand,
|
|
activity_trend: 'steady',
|
|
activity_30s: {
|
|
distance_m: latestDistanceM,
|
|
turn_deg: latestTurnDeg,
|
|
bumps: latestBumps,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildMessage(socket, text, meta = {}) {
|
|
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
|
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
|
const roverName = meta.roverName ?? resolveRoverName(roverId);
|
|
const toolCalls = Array.isArray(meta.toolCalls)
|
|
? meta.toolCalls
|
|
.map((entry) => {
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
return {
|
|
tool: String(entry.tool || '').trim() || 'unknown',
|
|
status: String(entry.status || '').trim() || 'unknown',
|
|
args: entry.args && typeof entry.args === 'object' ? entry.args : {},
|
|
result: entry.result && typeof entry.result === 'object' ? entry.result : null,
|
|
error: entry.error ? String(entry.error) : null,
|
|
durationMs: Number.isFinite(entry.durationMs) ? Math.max(0, Math.round(entry.durationMs)) : null,
|
|
};
|
|
})
|
|
.filter(Boolean)
|
|
: null;
|
|
return {
|
|
id: uuidv4(),
|
|
ts: Date.now(),
|
|
socketId: socket?.id || null,
|
|
nickname: meta.nickname || getNickname(socket) || null,
|
|
role: meta.role || getRole(socket),
|
|
roverId,
|
|
roverName,
|
|
roverColor,
|
|
fromDiscord: Boolean(meta.fromDiscord),
|
|
discordGuildId: meta.discordGuildId || null,
|
|
discordGuildName: meta.discordGuildName || null,
|
|
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
|
discordChannelId: meta.discordChannelId || null,
|
|
discordUserId: meta.discordUserId || null,
|
|
discordUserName: meta.discordUserName || null,
|
|
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
|
profileImage: normalizeProfileImageUrl(meta.profileImage),
|
|
roverCtx: meta.roverCtx || null,
|
|
text,
|
|
toolCalls,
|
|
tts: meta.tts || null,
|
|
bot: Boolean(meta.bot),
|
|
};
|
|
}
|
|
|
|
function buildTypingPayload(socket, meta = {}) {
|
|
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
|
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
|
const roverName = meta.roverName ?? resolveRoverName(roverId);
|
|
const socketId = socket?.id || null;
|
|
const fromDiscord = Boolean(meta.fromDiscord);
|
|
let typingId = meta.typingId || null;
|
|
if (!typingId) {
|
|
if (fromDiscord) {
|
|
if (meta.discordUserId) typingId = `discord:${meta.discordUserId}`;
|
|
else if (meta.discordUserName) typingId = `discord:${meta.discordUserName}`;
|
|
else if (meta.nickname) typingId = `discord:${meta.nickname}`;
|
|
else typingId = 'discord:unknown';
|
|
} else if (socketId) typingId = `socket:${socketId}`;
|
|
else if (meta.nickname) typingId = `socket:${meta.nickname}`;
|
|
else typingId = 'socket:unknown';
|
|
}
|
|
return {
|
|
id: uuidv4(),
|
|
ts: Date.now(),
|
|
typingId,
|
|
isTyping: Boolean(meta.isTyping),
|
|
socketId,
|
|
nickname: meta.nickname || getNickname(socket) || null,
|
|
role: meta.role || getRole(socket),
|
|
roverId,
|
|
roverName,
|
|
roverColor,
|
|
fromDiscord,
|
|
discordGuildId: meta.discordGuildId || null,
|
|
discordGuildName: meta.discordGuildName || null,
|
|
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
|
discordChannelId: meta.discordChannelId || null,
|
|
discordUserId: meta.discordUserId || null,
|
|
discordUserName: meta.discordUserName || null,
|
|
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
resolveRoverId,
|
|
isPtzChatTargetId,
|
|
isPublicChatTargetId,
|
|
isPrivateClosedRoverId,
|
|
buildRoverCtxSnapshot,
|
|
buildMessage,
|
|
buildTypingPayload,
|
|
};
|