This commit is contained in:
legop3
2026-07-12 15:36:48 -04:00
parent 60eacf982c
commit e254eea9e4
16 changed files with 129 additions and 44 deletions
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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CTUSBKKf.js"></script> <script type="module" crossorigin src="/assets/index-DN9WPSGg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DRk-dX1r.css"> <link rel="stylesheet" crossorigin href="/assets/index-BN3kEVFL.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -40,6 +40,30 @@ function resolveRoverName(roverId) {
return record?.meta?.name || null; 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) { function isPrivateClosedRoverId(roverId) {
if (!roverId) return false; if (!roverId) return false;
/* /*
@@ -47,8 +71,7 @@ function isPrivateClosedRoverId(roverId) {
but it is not a private rover. Let PTZ-badged messages broadcast normally 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. instead of falling into the closed-private rover path for unknown ids.
*/ */
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return false; return !isPublicChatTargetId(roverId);
return roverManager.canReplayRoverId(roverId) !== true;
} }
function normalizeProfileImageUrl(value) { function normalizeProfileImageUrl(value) {
@@ -208,6 +231,8 @@ function buildTypingPayload(socket, meta = {}) {
module.exports = { module.exports = {
resolveRoverId, resolveRoverId,
isPtzChatTargetId,
isPublicChatTargetId,
isPrivateClosedRoverId, isPrivateClosedRoverId,
buildRoverCtxSnapshot, buildRoverCtxSnapshot,
buildMessage, buildMessage,
@@ -7,6 +7,7 @@ const { getRole } = require('../roleService');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { issueCommand } = require('../commandService'); const { issueCommand } = require('../commandService');
const { getAdminReason } = require('../adminReasonService'); const { getAdminReason } = require('../adminReasonService');
const ptzCameraService = require('../ptzCameraService');
const { const {
TYPING_NOTE_DURATION, TYPING_NOTE_DURATION,
ACCESS_NOTICE_COOLDOWN_MS, ACCESS_NOTICE_COOLDOWN_MS,
@@ -18,6 +19,13 @@ const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
function playTypingNote(roverId, note, socketId) { function playTypingNote(roverId, note, socketId) {
if (!roverId) return; if (!roverId) return;
/*
PTZ borrows the roverId field for chat badges, but it has no rover command
channel. Skipping the song command here keeps PTZ chat from producing noisy
"unknown rover" command attempts while still allowing the message itself to
behave like rover chat everywhere else.
*/
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
try { try {
issueCommand(roverId, { issueCommand(roverId, {
type: 'song', type: 'song',
@@ -83,6 +91,12 @@ function maybeSendAccessNotice(message, sendSystemMessage) {
function maybeSpeak(socket, message, ttsOptions) { function maybeSpeak(socket, message, ttsOptions) {
if (!ttsOptions || !message?.roverId) return; if (!ttsOptions || !message?.roverId) return;
/*
TTS is a physical-rover capability backed by commandService and rover audio
metadata. PTZ is only rover-like for chat identity, so a PTZ chat message
should not try to speak through a non-existent rover record.
*/
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
const record = roverManager.rovers.get(message.roverId); const record = roverManager.rovers.get(message.roverId);
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled); const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
if (!ttsEnabled) return; if (!ttsEnabled) return;
@@ -2,6 +2,7 @@
// Purpose: Bridges chat and typing between Discord and site sockets. // Purpose: Bridges chat and typing between Discord and site sockets.
// Scope: Handles inbound Discord messages plus outbound webhook and typing relay. // Scope: Handles inbound Discord messages plus outbound webhook and typing relay.
const { WebhookClient } = require('discord.js'); const { WebhookClient } = require('discord.js');
const { isPublicChatTargetId } = require('../../chatService/contextBuilders');
function summarizeToolCall(entry = {}) { function summarizeToolCall(entry = {}) {
const tool = String(entry?.tool || 'unknown'); const tool = String(entry?.tool || 'unknown');
@@ -23,7 +24,6 @@ function createChatBridgeHandlers(deps) {
const { const {
logger, logger,
client, client,
roverManager,
getGuildConfig, getGuildConfig,
listGuildConfigs, listGuildConfigs,
sendExternalMessage, sendExternalMessage,
@@ -59,7 +59,13 @@ function createChatBridgeHandlers(deps) {
function handleChatBridgeOutbound(event) { function handleChatBridgeOutbound(event) {
const payload = event?.payload; const payload = event?.payload;
if (!payload) return; if (!payload) return;
if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return; /*
Outbound bridge filtering must use chat visibility, not rover replay
visibility. PTZ deliberately uses roverId: "ptz-camera" so the existing
chat badge path can be reused, but that id is not a roverManager rover and
would be dropped by canReplayRoverId().
*/
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
const guildConfigs = listGuildConfigs(); const guildConfigs = listGuildConfigs();
if (!guildConfigs.length) return; if (!guildConfigs.length) return;
@@ -96,7 +102,11 @@ function createChatBridgeHandlers(deps) {
function handleChatTypingOutbound(event) { function handleChatTypingOutbound(event) {
const payload = event?.payload; const payload = event?.payload;
if (!payload || payload.fromDiscord) return; if (!payload || payload.fromDiscord) return;
if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return; /*
Typing indicators follow the same public-chat-target rule as messages so
PTZ users do not look present in web chat while disappearing from Discord.
*/
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
const guildConfigs = listGuildConfigs(); const guildConfigs = listGuildConfigs();
if (!guildConfigs.length) return; if (!guildConfigs.length) return;
@@ -24,7 +24,14 @@ function formatWebhookUsername(payload) {
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : ''; const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`; return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
} }
const roverTag = payload.roverId ? ` [${payload.roverId}]` : ''; /*
The chat payload already carries the resolved display name for rover-like
targets. Prefer that name so PTZ, which is intentionally pretending to be a
rover in chat, shows up as "PTZ Camera" instead of the internal id
"ptz-camera"; fall back to the id for older payloads or missing metadata.
*/
const roverTagLabel = payload.roverName || payload.roverId;
const roverTag = payload.roverId ? ` [${roverTagLabel}]` : '';
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`; return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
} }
@@ -1,6 +1,8 @@
// llm Commentary Service snapshot engine // llm Commentary Service snapshot engine
// Purpose: Tracks rover activity/history and builds model snapshot payloads from live rover/chat state. // Purpose: Tracks rover activity/history and builds model snapshot payloads from live rover/chat state.
// Scope: Keeps runtime behavior unchanged while isolating sensor aggregation and snapshot assembly logic. // Scope: Keeps runtime behavior unchanged while isolating sensor aggregation and snapshot assembly logic.
const { isPublicChatTargetId } = require('../chatService/contextBuilders');
function createSnapshotEngine(deps) { function createSnapshotEngine(deps) {
const { const {
io, io,
@@ -368,7 +370,12 @@ function createSnapshotEngine(deps) {
.filter((entry) => { .filter((entry) => {
const roverId = entry?.roverId ? String(entry.roverId) : null; const roverId = entry?.roverId ? String(entry.roverId) : null;
if (!roverId) return true; if (!roverId) return true;
return roverManager.canReplayRoverId(roverId); /*
This is a chat transcript filter, not a physical-rover filter. PTZ
chat intentionally carries a rover-like id so transcript consumers can
render it consistently, even though roverManager cannot replay that id.
*/
return isPublicChatTargetId(roverId);
}); });
const chatRecent = allRecentMessages const chatRecent = allRecentMessages
.filter((entry) => !entry?.bot) .filter((entry) => !entry?.bot)
@@ -15,6 +15,7 @@ const { getState: getNeatoState, neatoEvents } = neatoService;
const { getState: getLiftState, liftEvents } = liftService; const { getState: getLiftState, liftEvents } = liftService;
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { getRecentMessages, sendSystemMessage } = require('../chatService'); const { getRecentMessages, sendSystemMessage } = require('../chatService');
const { isPublicChatTargetId } = require('../chatService/contextBuilders');
const { subscribe } = require('../eventBus'); const { subscribe } = require('../eventBus');
const { const {
PROMPT_PATH, PROMPT_PATH,
@@ -378,7 +379,12 @@ async function runDecision(triggerReason) {
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt) .filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
.filter((entry) => { .filter((entry) => {
if (!entry?.roverId) return true; if (!entry?.roverId) return true;
return roverManager.canReplayRoverId(entry.roverId); /*
Chat context should preserve every public chat target, including the
PTZ virtual rover. Rover replay visibility alone would drop PTZ because
it is owned by ptzCameraService instead of roverManager.
*/
return isPublicChatTargetId(entry.roverId);
}) })
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT)); .slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
const conversationMessages = buildConversation({ recentMessages: recentConversation, name }); const conversationMessages = buildConversation({ recentMessages: recentConversation, name });
+13 -2
View File
@@ -10,7 +10,11 @@ const { managerEvents } = roverManager;
const assignmentService = require('../assignmentService'); const assignmentService = require('../assignmentService');
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService'); const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService'); const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const { getPublicState: getPtzCameraState, ptzCameraEvents } = require('../ptzCameraService'); const {
getPublicState: getPtzCameraState,
getChatTargetForSocket: getPtzChatTargetForSocket,
ptzCameraEvents,
} = require('../ptzCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService'); const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
const { getState: getNeatoState, neatoEvents } = require('../neatoService'); const { getState: getNeatoState, neatoEvents } = require('../neatoService');
const { getState: getLiftState, liftEvents } = require('../liftService'); const { getState: getLiftState, liftEvents } = require('../liftService');
@@ -62,12 +66,19 @@ function buildUserEntry(socket) {
const role = getRole(socket); const role = getRole(socket);
const assignment = assignmentService.describeAssignment(socket.id); const assignment = assignmentService.describeAssignment(socket.id);
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id); const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
const ptzChatTarget = getPtzChatTargetForSocket(socket.id);
return { return {
socketId: socket.id, socketId: socket.id,
userId: socket?.data?.userId || null, userId: socket?.data?.userId || null,
nickname: getNickname(socket) || null, nickname: getNickname(socket) || null,
role, role,
roverId: primaryRover || assignment?.roverId || null, /*
PTZ is not inserted into the physical rover roster, but for chat and user
presence it should read like the user moved to a rover-like target. Prefer
the PTZ chat target while the socket is queued or operating so presence,
queue lookup, and chat identity all agree.
*/
roverId: ptzChatTarget?.roverId || primaryRover || assignment?.roverId || null,
}; };
} }
@@ -22,8 +22,6 @@ export default function PtzLiveVideo({
className = 'relative h-full w-full bg-black', className = 'relative h-full w-full bg-black',
videoClassName = 'h-full w-full object-contain', videoClassName = 'h-full w-full object-contain',
statusClassName = 'pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]', statusClassName = 'pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]',
label = null,
labelClassName = 'pointer-events-none absolute left-0 top-0 bg-black/70 px-1 py-0.5 text-xs font-semibold text-white',
fallback = null, fallback = null,
}) { }) {
const videoRef = useRef(null); const videoRef = useRef(null);
@@ -199,7 +197,6 @@ export default function PtzLiveVideo({
{source?.error || 'Waiting for PTZ video...'} {source?.error || 'Waiting for PTZ video...'}
</div> </div>
)} )}
{label ? <div className={labelClassName}>{label}</div> : null}
{/* {/*
PTZ video uses the same low-profile diagnostic shape as the rover PTZ video uses the same low-profile diagnostic shape as the rover
players: no in-frame camera title, just a compact top-corner status. players: no in-frame camera title, just a compact top-corner status.
+10 -5
View File
@@ -1,7 +1,7 @@
// Vip PTZ Camera Card // Vip PTZ Camera Card
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera. // Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative. // Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import ChatPanel from '../ChatPanel/index.jsx'; import ChatPanel from '../ChatPanel/index.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
@@ -59,11 +59,16 @@ function PtzSnapshotPreview({ feed, label = 'PTZ Camera' }) {
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div> <div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
)} )}
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-1 py-0.5 text-xs font-semibold text-white"> {/*
{label} Even on this legacy VIP surface, keep the video pane itself identical
to rover media panes: no camera title inside the frame, only the small
top-left stream status. Any PTZ name/context belongs to the card chrome
around the media, not the media player.
*/}
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
</div> </div>
<div className="pointer-events-none absolute bottom-0 left-0 m-1 rounded bg-black/70 px-1 py-0.5 text-[0.7rem] text-slate-100">
{feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}
</div> </div>
</div> </div>
); );
@@ -65,11 +65,16 @@ function PtzSnapshotFallback({ label, source }) {
{snapshot?.error || source?.error || 'Waiting for PTZ snapshot...'} {snapshot?.error || source?.error || 'Waiting for PTZ snapshot...'}
</div> </div>
)} )}
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-1 py-0.5 text-xs font-semibold text-white"> {/*
{label} Keep the PTZ snapshot fallback visually aligned with RoverMediaPlayer:
the video surface owns only playback health, while the card around it
owns identity/context. That prevents a second in-frame camera title and
keeps fallback mode from looking different than live WHEP mode.
*/}
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {snapshot?.status || 'snapshot'}</span>
</div> </div>
<div className="pointer-events-none absolute bottom-0 left-0 m-1 rounded bg-black/70 px-1 py-0.5 text-[0.7rem] text-slate-100">
{snapshot?.status || 'snapshot'}
</div> </div>
</div> </div>
); );
@@ -80,9 +85,7 @@ function PtzLiveOrSnapshot({ label }) {
<PtzLiveVideo <PtzLiveVideo
enabled enabled
startMuted startMuted
label={label}
className="relative aspect-video w-full overflow-hidden rounded bg-black" className="relative aspect-video w-full overflow-hidden rounded bg-black"
statusClassName="pointer-events-none absolute bottom-0 left-0 m-1 rounded bg-black/70 px-1 py-0.5 text-[0.7rem] text-slate-100"
fallback={({ source }) => <PtzSnapshotFallback label={label} source={source} />} fallback={({ source }) => <PtzSnapshotFallback label={label} source={source} />}
/> />
); );