Compare commits
2 Commits
fix/1544-v
...
fix/1537
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17317aaf64 | ||
|
|
da258e772c |
47
app.js
47
app.js
@@ -5,7 +5,6 @@ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
|
||||
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
|
||||
import { SpatialMemory } from './nexus/components/spatial-memory.js';
|
||||
import { SpatialAudio } from './nexus/components/spatial-audio.js';
|
||||
import { SpatialChatAudio } from './nexus/components/spatial-chat-audio.js';
|
||||
import { MemoryBirth } from './nexus/components/memory-birth.js';
|
||||
import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
|
||||
import { MemoryInspect } from './nexus/components/memory-inspect.js';
|
||||
@@ -769,7 +768,6 @@ async function init() {
|
||||
SpatialMemory.setCamera(camera);
|
||||
SpatialAudio.init(camera, scene);
|
||||
SpatialAudio.bindSpatialMemory(SpatialMemory);
|
||||
SpatialChatAudio.init(camera);
|
||||
MemoryInspect.init({ onNavigate: _navigateToMemory });
|
||||
MemoryPulse.init(SpatialMemory);
|
||||
ReasoningTrace.init();
|
||||
@@ -2150,7 +2148,7 @@ function setupControls() {
|
||||
|
||||
function sendChatMessage(overrideText = null) {
|
||||
// Mine chat message to MemPalace
|
||||
if (overrideText) {
|
||||
if (overrideText && window.electronAPI?.execPython) {
|
||||
window.electronAPI.execPython(`mempalace add_drawer "${this.wing}" "chat" "${overrideText}"`);
|
||||
}
|
||||
const input = document.getElementById('chat-input');
|
||||
@@ -2158,19 +2156,25 @@ function sendChatMessage(overrideText = null) {
|
||||
if (!text) return;
|
||||
addChatMessage('user', text);
|
||||
if (!overrideText) input.value = '';
|
||||
setTimeout(() => {
|
||||
const responses = [
|
||||
'Processing your request through the harness...',
|
||||
'I have noted this in my thought stream.',
|
||||
'Acknowledged. Routing to appropriate agent loop.',
|
||||
'The sovereign space recognizes your command.',
|
||||
'Running analysis. Results will appear on the main terminal.',
|
||||
'My crystal ball says... yes. Implementing.',
|
||||
'Understood, Alexander. Adjusting priorities.',
|
||||
];
|
||||
const resp = responses[Math.floor(Math.random() * responses.length)];
|
||||
addChatMessage('timmy', resp);
|
||||
}, 500 + Math.random() * 1000);
|
||||
|
||||
if (!wsConnected || !hermesWs || hermesWs.readyState !== WebSocket.OPEN) {
|
||||
addChatMessage('error', 'Message not sent. Nexus gateway offline.');
|
||||
input.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
hermesWs.send(JSON.stringify({
|
||||
type: 'chat',
|
||||
text,
|
||||
agent: 'nexus',
|
||||
source: 'nexus_chat',
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Failed to send Nexus chat message:', err);
|
||||
addChatMessage('error', 'Message not sent. Nexus gateway offline.');
|
||||
}
|
||||
input.blur();
|
||||
}
|
||||
|
||||
@@ -2968,17 +2972,6 @@ function loadSession() {
|
||||
function addChatMessage(agent, text, shouldSave = true) {
|
||||
// Mine chat messages for MemPalace
|
||||
mineMemPalaceContent(text);
|
||||
|
||||
// 3D spatial audio notification (issue #1544)
|
||||
if (window.SpatialChatAudio && agent !== 'system') {
|
||||
// Find agent position from tracked agents or fallback to origin
|
||||
let pos = { x: 0, y: 0, z: 0 };
|
||||
const agentEntry = Array.isArray(window._trackedAgents) && window._trackedAgents.find(a => a.name === agent);
|
||||
if (agentEntry && agentEntry.position) {
|
||||
pos = agentEntry.position;
|
||||
}
|
||||
window.SpatialChatAudio.playChatSound(agent, new THREE.Vector3(pos.x, pos.y, pos.z));
|
||||
}
|
||||
// Mine chat messages for MemPalace
|
||||
mineMemPalaceContent(text);
|
||||
const container = document.getElementById('chat-messages');
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# Spatial Chat Audio — 3D Audio for Chat Messages
|
||||
|
||||
Refs: the-nexus #1544
|
||||
|
||||
## Overview
|
||||
|
||||
Adds spatial awareness to chat notifications so nearby users/agents sound louder.
|
||||
Volume scales with avatar distance from the camera.
|
||||
|
||||
## Features
|
||||
|
||||
### Chat Notification Sounds
|
||||
- Each agent has a distinct tone (frequency + waveform)
|
||||
- Volume decreases with distance (inverse rolloff)
|
||||
- Stereo panning based on relative position to camera
|
||||
- Sounds auto-cleanup after playback
|
||||
|
||||
### 3D Positional Voice (WebRTC-ready)
|
||||
- `createVoiceSource()` returns a PannerNode for real voice streams
|
||||
- HRTF panning model for realistic 3D positioning
|
||||
- Update position in real-time as avatars move
|
||||
|
||||
### Configurable Parameters
|
||||
- `maxHearingDistance` — max distance to hear sounds (default: 40)
|
||||
- `refDistance` — full volume within this range (default: 5)
|
||||
- `rolloffFactor` — volume falloff curve (default: 1.5)
|
||||
- `baseVolume` — master volume cap (default: 0.3)
|
||||
|
||||
## Usage
|
||||
|
||||
```javascript
|
||||
import { SpatialChatAudio } from './nexus/components/spatial-chat-audio.js';
|
||||
|
||||
// Initialize with camera
|
||||
SpatialChatAudio.init(camera);
|
||||
|
||||
// Set max hearing distance
|
||||
SpatialChatAudio.setMaxHearingDistance(50);
|
||||
|
||||
// Play a chat sound when a message arrives
|
||||
// position = avatar/agent position in 3D world
|
||||
SpatialChatAudio.playChatSound('timmy', agentPosition);
|
||||
|
||||
// For voice chat: create a persistent 3D source
|
||||
const voice = SpatialChatAudio.createVoiceSource('user', avatarPosition);
|
||||
// Update as avatar moves
|
||||
voice.updatePosition(newPosition);
|
||||
// Cleanup when disconnected
|
||||
voice.destroy();
|
||||
```
|
||||
|
||||
## Agent Sound Profiles
|
||||
|
||||
| Agent | Frequency | Waveform |
|
||||
|--------|-----------|------------|
|
||||
| timmy | 440 Hz | sine |
|
||||
| user | 523 Hz | sine |
|
||||
| system | 330 Hz | triangle |
|
||||
| kimi | 659 Hz | sine |
|
||||
| claude | 392 Hz | sine |
|
||||
| grok | 587 Hz | triangle |
|
||||
| gemini | 494 Hz | sine |
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
node tests/test_spatial_chat_audio.js
|
||||
```
|
||||
@@ -1,236 +0,0 @@
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// SPATIAL CHAT AUDIO — 3D Audio for Chat Messages (issue #1544)
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Volume scales with avatar distance — closer agents sound louder.
|
||||
// 3D positional audio places chat sounds in world space.
|
||||
//
|
||||
// Usage from app.js:
|
||||
// import { SpatialChatAudio } from './nexus/components/spatial-chat-audio.js';
|
||||
// SpatialChatAudio.init(camera);
|
||||
// SpatialChatAudio.playChatSound('timmy', agentPosition);
|
||||
//
|
||||
// Configuration:
|
||||
// SpatialChatAudio.setMaxHearingDistance(50); // default 40 units
|
||||
// SpatialChatAudio.setEnabled(true/false);
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const SpatialChatAudio = (() => {
|
||||
|
||||
// ─── CONFIG ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
let _config = {
|
||||
maxHearingDistance: 40, // Distance at which volume reaches 0
|
||||
refDistance: 5, // Full volume within this range
|
||||
rolloffFactor: 1.5, // Volume rolloff curve
|
||||
baseVolume: 0.3, // Master volume for chat sounds
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// ─── STATE ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
let _camera = null;
|
||||
let _listener = null;
|
||||
let _ctx = null;
|
||||
let _masterGain = null;
|
||||
let _initialized = false;
|
||||
|
||||
// Agent sound profiles (frequency + waveform)
|
||||
const AGENT_SOUNDS = {
|
||||
timmy: { freq: 440, type: 'sine' }, // A4 - clear
|
||||
user: { freq: 523, type: 'sine' }, // C5 - higher
|
||||
system: { freq: 330, type: 'triangle' }, // E4 - neutral
|
||||
kimi: { freq: 659, type: 'sine' }, // E5 - bright
|
||||
claude: { freq: 392, type: 'sine' }, // G4 - warm
|
||||
grok: { freq: 587, type: 'triangle' }, // D5 - sharp
|
||||
gemini: { freq: 494, type: 'sine' }, // B4 - balanced
|
||||
default: { freq: 440, type: 'sine' }, // A4 - default
|
||||
};
|
||||
|
||||
// ─── INIT ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
function init(camera) {
|
||||
if (_initialized) return;
|
||||
|
||||
_camera = camera;
|
||||
|
||||
// Create or reuse AudioListener from camera
|
||||
if (!_listener) {
|
||||
_listener = new THREE.AudioListener();
|
||||
camera.add(_listener);
|
||||
} else {
|
||||
_listener = camera.getObjectByProperty('type', 'AudioListener') || _listener;
|
||||
}
|
||||
|
||||
// Get audio context from listener
|
||||
_ctx = _listener.context;
|
||||
_masterGain = _ctx.createGain();
|
||||
_masterGain.gain.value = _config.baseVolume;
|
||||
_masterGain.connect(_ctx.destination);
|
||||
|
||||
_initialized = true;
|
||||
console.info('[SpatialChatAudio] Initialized — max hearing distance:', _config.maxHearingDistance);
|
||||
|
||||
// Resume context if suspended (browser autoplay policy)
|
||||
if (_ctx.state === 'suspended') {
|
||||
const resume = () => {
|
||||
_ctx.resume().then(() => {
|
||||
console.info('[SpatialChatAudio] AudioContext resumed');
|
||||
document.removeEventListener('click', resume);
|
||||
document.removeEventListener('keydown', resume);
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', resume);
|
||||
document.addEventListener('keydown', resume);
|
||||
}
|
||||
|
||||
return _listener;
|
||||
}
|
||||
|
||||
// ─── PLAY CHAT SOUND ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
function playChatSound(agent, position) {
|
||||
if (!_initialized || !_config.enabled) return;
|
||||
|
||||
const sound = AGENT_SOUNDS[agent] || AGENT_SOUNDS.default;
|
||||
const camPos = _camera.position;
|
||||
|
||||
// Calculate distance
|
||||
const dx = position.x - camPos.x;
|
||||
const dy = position.y - camPos.y;
|
||||
const dz = position.z - camPos.z;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
||||
// Calculate volume based on distance
|
||||
let volume = 0;
|
||||
if (dist < _config.maxHearingDistance) {
|
||||
volume = 1 / (1 + _config.rolloffFactor * (dist - _config.refDistance));
|
||||
volume = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
// Skip if too quiet
|
||||
if (volume < 0.01) return;
|
||||
|
||||
// Create audio nodes
|
||||
const osc = _ctx.createOscillator();
|
||||
osc.type = sound.type;
|
||||
osc.frequency.value = sound.freq;
|
||||
|
||||
const gain = _ctx.createGain();
|
||||
gain.gain.value = volume;
|
||||
|
||||
const panner = _ctx.createStereoPanner();
|
||||
|
||||
// Calculate stereo panning
|
||||
const camRight = new THREE.Vector3();
|
||||
_camera.getWorldDirection(camRight);
|
||||
camRight.cross(_camera.up).normalize();
|
||||
const toSource = new THREE.Vector3(dx, 0, dz).normalize();
|
||||
const pan = THREE.MathUtils.clamp(toSource.dot(camRight), -1, 1);
|
||||
panner.pan.value = pan;
|
||||
|
||||
// Connect and play
|
||||
osc.connect(gain);
|
||||
gain.connect(panner);
|
||||
panner.connect(_masterGain);
|
||||
|
||||
// Short envelope (attack + decay)
|
||||
const now = _ctx.currentTime;
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(volume, now + 0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.35);
|
||||
|
||||
// Cleanup
|
||||
osc.onended = () => {
|
||||
osc.disconnect();
|
||||
gain.disconnect();
|
||||
panner.disconnect();
|
||||
};
|
||||
|
||||
console.debug(`[SpatialChatAudio] ${agent} at ${dist.toFixed(1)}m, vol=${volume.toFixed(2)}, pan=${pan.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// ─── CONFIGURATION ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
function setMaxHearingDistance(dist) {
|
||||
_config.maxHearingDistance = Math.max(5, dist);
|
||||
console.info('[SpatialChatAudio] Max hearing distance:', _config.maxHearingDistance);
|
||||
}
|
||||
|
||||
function getMaxHearingDistance() {
|
||||
return _config.maxHearingDistance;
|
||||
}
|
||||
|
||||
function setEnabled(enabled) {
|
||||
_config.enabled = enabled;
|
||||
console.info('[SpatialChatAudio]', enabled ? 'Enabled' : 'Disabled');
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
return _config.enabled;
|
||||
}
|
||||
|
||||
function setMasterVolume(vol) {
|
||||
if (_masterGain) {
|
||||
_masterGain.gain.setTargetAtTime(
|
||||
THREE.MathUtils.clamp(vol, 0, 1),
|
||||
_ctx.currentTime,
|
||||
0.05
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VOICE CHAT SUPPORT (WebRTC placeholder) ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
function createVoiceSource(agentId, position) {
|
||||
if (!_initialized) return null;
|
||||
|
||||
// Create a PannerNode for 3D voice positioning
|
||||
const panner = _ctx.createPanner();
|
||||
panner.panningModel = 'HRTF';
|
||||
panner.distanceModel = 'inverse';
|
||||
panner.refDistance = _config.refDistance;
|
||||
panner.maxDistance = _config.maxHearingDistance;
|
||||
panner.rolloffFactor = _config.rolloffFactor;
|
||||
|
||||
// Set initial position
|
||||
panner.positionX.value = position.x;
|
||||
panner.positionY.value = position.y;
|
||||
panner.positionZ.value = position.z;
|
||||
|
||||
// Connect to master
|
||||
panner.connect(_masterGain);
|
||||
|
||||
console.info(`[SpatialChatAudio] Voice source created for ${agentId}`);
|
||||
|
||||
return {
|
||||
panner,
|
||||
agentId,
|
||||
updatePosition(pos) {
|
||||
panner.positionX.setValueAtTime(pos.x, _ctx.currentTime);
|
||||
panner.positionY.setValueAtTime(pos.y, _ctx.currentTime);
|
||||
panner.positionZ.setValueAtTime(pos.z, _ctx.currentTime);
|
||||
},
|
||||
destroy() {
|
||||
panner.disconnect();
|
||||
console.info(`[SpatialChatAudio] Voice source destroyed for ${agentId}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── API ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
return {
|
||||
init,
|
||||
playChatSound,
|
||||
createVoiceSource,
|
||||
setMaxHearingDistance,
|
||||
getMaxHearingDistance,
|
||||
setEnabled,
|
||||
isEnabled,
|
||||
setMasterVolume,
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for module or global usage
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { SpatialChatAudio };
|
||||
} else if (typeof window !== 'undefined') {
|
||||
window.SpatialChatAudio = SpatialChatAudio;
|
||||
}
|
||||
113
nexus/telegram_bridge.py
Normal file
113
nexus/telegram_bridge.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
class TelegramBridgeConfig:
|
||||
def __init__(self, bot_token: str, chat_id: str, poll_timeout: int = 4):
|
||||
self.bot_token = bot_token
|
||||
self.chat_id = chat_id
|
||||
self.poll_timeout = poll_timeout
|
||||
|
||||
@property
|
||||
def api_base(self) -> str:
|
||||
return f"https://api.telegram.org/bot{self.bot_token}"
|
||||
|
||||
|
||||
def config_from_env(env: Optional[dict[str, str]] = None) -> Optional[TelegramBridgeConfig]:
|
||||
env = env or os.environ
|
||||
bot_token = env.get("NEXUS_TELEGRAM_BOT_TOKEN") or env.get("TELEGRAM_BOT_TOKEN")
|
||||
chat_id = (
|
||||
env.get("NEXUS_TELEGRAM_CHAT_ID")
|
||||
or env.get("TELEGRAM_HOME_CHANNEL")
|
||||
or env.get("TELEGRAM_CHAT_ID")
|
||||
)
|
||||
if not bot_token or not chat_id:
|
||||
return None
|
||||
return TelegramBridgeConfig(bot_token=bot_token, chat_id=str(chat_id), poll_timeout=int(env.get("NEXUS_TELEGRAM_POLL_TIMEOUT", "4")))
|
||||
|
||||
|
||||
def format_outbound_text(sender: str, text: str) -> str:
|
||||
sender = (sender or "Nexus").strip() or "Nexus"
|
||||
return f"[{sender}] {text}"
|
||||
|
||||
|
||||
def extract_update_message(update: dict[str, Any], expected_chat_id: str) -> Optional[dict[str, Any]]:
|
||||
message = update.get("message") or update.get("edited_message")
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
if str((message.get("chat") or {}).get("id")) != str(expected_chat_id):
|
||||
return None
|
||||
text = message.get("text")
|
||||
if not text:
|
||||
return None
|
||||
sender_info = message.get("from") or {}
|
||||
sender = sender_info.get("first_name") or sender_info.get("username") or "Telegram"
|
||||
return {
|
||||
"update_id": update.get("update_id", 0),
|
||||
"sender": sender,
|
||||
"text": text,
|
||||
"timestamp": message.get("date"),
|
||||
"source": "telegram",
|
||||
}
|
||||
|
||||
|
||||
def _default_get_json(url: str) -> dict[str, Any]:
|
||||
with urllib.request.urlopen(url, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def _default_post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
class TelegramBridge:
|
||||
def __init__(
|
||||
self,
|
||||
config: TelegramBridgeConfig,
|
||||
*,
|
||||
get_json: Optional[Callable[[str], dict[str, Any]]] = None,
|
||||
post_json: Optional[Callable[[str, dict[str, Any]], dict[str, Any]]] = None,
|
||||
logger: Any = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.get_json = get_json or _default_get_json
|
||||
self.post_json = post_json or _default_post_json
|
||||
self.logger = logger
|
||||
|
||||
async def send_chat(self, text: str, sender: str = "Nexus") -> dict[str, Any]:
|
||||
payload = {
|
||||
"chat_id": self.config.chat_id,
|
||||
"text": format_outbound_text(sender, text),
|
||||
}
|
||||
result = await asyncio.to_thread(self.post_json, f"{self.config.api_base}/sendMessage", payload)
|
||||
if self.logger and not result.get("ok", True):
|
||||
self.logger.error("Telegram sendMessage failed: %s", result)
|
||||
return result
|
||||
|
||||
async def poll_once(self, last_update_id: int) -> tuple[int, list[dict[str, Any]]]:
|
||||
params = urllib.parse.urlencode({
|
||||
"offset": last_update_id + 1,
|
||||
"timeout": self.config.poll_timeout,
|
||||
})
|
||||
result = await asyncio.to_thread(self.get_json, f"{self.config.api_base}/getUpdates?{params}")
|
||||
if not result.get("ok", False):
|
||||
if self.logger:
|
||||
self.logger.error("Telegram getUpdates failed: %s", result)
|
||||
return last_update_id, []
|
||||
messages: list[dict[str, Any]] = []
|
||||
new_last_update_id = last_update_id
|
||||
for update in result.get("result", []):
|
||||
new_last_update_id = max(new_last_update_id, int(update.get("update_id", last_update_id)))
|
||||
msg = extract_update_message(update, self.config.chat_id)
|
||||
if msg:
|
||||
messages.append(msg)
|
||||
return new_last_update_id, messages
|
||||
108
server.py
108
server.py
@@ -23,6 +23,8 @@ import time
|
||||
from typing import Set, Dict, Optional
|
||||
from collections import defaultdict
|
||||
|
||||
from nexus.telegram_bridge import TelegramBridge, config_from_env
|
||||
|
||||
# Branch protected file - see POLICY.md
|
||||
import websockets
|
||||
|
||||
@@ -47,6 +49,34 @@ logger = logging.getLogger("nexus-gateway")
|
||||
clients: Set[websockets.WebSocketServerProtocol] = set()
|
||||
connection_tracker: Dict[str, list] = defaultdict(list) # IP -> [timestamps]
|
||||
message_tracker: Dict[int, list] = defaultdict(list) # connection_id -> [timestamps]
|
||||
telegram_bridge: Optional[TelegramBridge] = None
|
||||
|
||||
|
||||
async def broadcast_json(payload, exclude: Optional[websockets.WebSocketServerProtocol] = None):
|
||||
"""Broadcast a payload to all connected clients except *exclude*."""
|
||||
if not clients:
|
||||
return
|
||||
disconnected = set()
|
||||
message = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
task_client_pairs = []
|
||||
for client in clients:
|
||||
if client != exclude and client.open:
|
||||
task = asyncio.create_task(client.send(message))
|
||||
task_client_pairs.append((task, client))
|
||||
|
||||
if not task_client_pairs:
|
||||
return
|
||||
|
||||
tasks = [pair[0] for pair in task_client_pairs]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
target_client = task_client_pairs[i][1]
|
||||
logger.error(f"Failed to send to client {target_client.remote_address}: {result}")
|
||||
disconnected.add(target_client)
|
||||
|
||||
if disconnected:
|
||||
clients.difference_update(disconnected)
|
||||
|
||||
def check_rate_limit(ip: str) -> bool:
|
||||
"""Check if IP has exceeded connection rate limit."""
|
||||
@@ -215,6 +245,31 @@ async def pty_handler(websocket: websockets.WebSocketServerProtocol):
|
||||
logger.info(f"[PTY] Shell session ended for {addr}")
|
||||
|
||||
|
||||
async def telegram_bridge_loop() -> None:
|
||||
"""Poll Telegram and rebroadcast inbound chat into the Nexus websocket fabric."""
|
||||
if telegram_bridge is None:
|
||||
return
|
||||
|
||||
last_update_id = 0
|
||||
logger.info("Telegram bridge polling started")
|
||||
while True:
|
||||
try:
|
||||
last_update_id, messages = await telegram_bridge.poll_once(last_update_id)
|
||||
for msg in messages:
|
||||
await broadcast_json({
|
||||
"type": "chat",
|
||||
"text": msg["text"],
|
||||
"agent": msg["sender"],
|
||||
"source": "telegram",
|
||||
"timestamp": msg.get("timestamp"),
|
||||
})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram bridge polling error: {e}")
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
|
||||
async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
||||
"""Handles individual client connections and message broadcasting."""
|
||||
addr = websocket.remote_address
|
||||
@@ -245,46 +300,33 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
||||
"message": "Message rate limit exceeded"
|
||||
}))
|
||||
continue
|
||||
|
||||
|
||||
data = None
|
||||
msg_type = None
|
||||
# Parse for logging/validation if it's JSON
|
||||
try:
|
||||
data = json.loads(message)
|
||||
msg_type = data.get("type", "unknown")
|
||||
# Optional: log specific important message types
|
||||
if msg_type in ["agent_register", "thought", "action"]:
|
||||
if msg_type in ["agent_register", "thought", "action", "chat"]:
|
||||
logger.debug(f"Received {msg_type} from {addr}")
|
||||
# Handle git status requests from the operator cockpit (issue #1695)
|
||||
if msg_type == "git_status_request":
|
||||
git_info = _get_git_status()
|
||||
await websocket.send(json.dumps(git_info))
|
||||
continue
|
||||
if msg_type == "chat" and telegram_bridge and data.get("source") != "telegram":
|
||||
text = str(data.get("text", "")).strip()
|
||||
if text:
|
||||
sender = data.get("agent") or data.get("user") or "Nexus"
|
||||
await telegram_bridge.send_chat(text, sender=str(sender))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
data = None
|
||||
msg_type = None
|
||||
|
||||
# Broadcast to all OTHER clients
|
||||
if not clients:
|
||||
continue
|
||||
|
||||
disconnected = set()
|
||||
# Create broadcast tasks, tracking which client each task targets
|
||||
task_client_pairs = []
|
||||
for client in clients:
|
||||
if client != websocket and client.open:
|
||||
task = asyncio.create_task(client.send(message))
|
||||
task_client_pairs.append((task, client))
|
||||
await broadcast_json(data if data is not None else message, exclude=websocket)
|
||||
|
||||
if task_client_pairs:
|
||||
tasks = [pair[0] for pair in task_client_pairs]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
target_client = task_client_pairs[i][1]
|
||||
logger.error(f"Failed to send to client {target_client.remote_address}: {result}")
|
||||
disconnected.add(target_client)
|
||||
|
||||
if disconnected:
|
||||
clients.difference_update(disconnected)
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.debug(f"Connection closed by client {addr}")
|
||||
except Exception as e:
|
||||
@@ -295,6 +337,8 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
||||
|
||||
async def main():
|
||||
"""Main server loop with graceful shutdown."""
|
||||
global telegram_bridge
|
||||
|
||||
# Log security configuration
|
||||
if AUTH_TOKEN:
|
||||
logger.info("Authentication: ENABLED (token required)")
|
||||
@@ -314,6 +358,15 @@ async def main():
|
||||
# Set up signal handlers for graceful shutdown
|
||||
loop = asyncio.get_running_loop()
|
||||
stop = loop.create_future()
|
||||
telegram_task = None
|
||||
|
||||
bridge_config = config_from_env(os.environ)
|
||||
if bridge_config:
|
||||
telegram_bridge = TelegramBridge(bridge_config, logger=logger)
|
||||
telegram_task = asyncio.create_task(telegram_bridge_loop())
|
||||
logger.info(f"Telegram bridge enabled for chat {bridge_config.chat_id}")
|
||||
else:
|
||||
logger.info("Telegram bridge disabled — set NEXUS_TELEGRAM_BOT_TOKEN/NEXUS_TELEGRAM_CHAT_ID to enable")
|
||||
|
||||
def shutdown():
|
||||
if not stop.done():
|
||||
@@ -332,6 +385,11 @@ async def main():
|
||||
async with websockets.serve(pty_handler, "127.0.0.1", PTY_PORT):
|
||||
logger.info(f"PTY shell gateway listening on ws://127.0.0.1:{PTY_PORT}/pty")
|
||||
await stop
|
||||
|
||||
if telegram_task:
|
||||
telegram_task.cancel()
|
||||
await asyncio.gather(telegram_task, return_exceptions=True)
|
||||
telegram_bridge = None
|
||||
|
||||
logger.info("Shutting down Nexus WS gateway...")
|
||||
# Close any remaining client connections (handlers may have already cleaned up)
|
||||
|
||||
12
tests/test_nexus_chat_gateway_wiring.py
Normal file
12
tests/test_nexus_chat_gateway_wiring.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
APP_JS = ROOT / "app.js"
|
||||
|
||||
|
||||
def test_send_chat_message_uses_gateway_not_mock_replies():
|
||||
source = APP_JS.read_text(encoding="utf-8")
|
||||
assert "Processing your request through the harness..." not in source
|
||||
assert "Message not sent. Nexus gateway offline." in source
|
||||
assert "hermesWs.send(JSON.stringify({" in source
|
||||
assert "source: 'nexus_chat'" in source
|
||||
97
tests/test_nexus_telegram_bridge.py
Normal file
97
tests/test_nexus_telegram_bridge.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MODULE_PATH = ROOT / "nexus" / "telegram_bridge.py"
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = spec_from_file_location("nexus_telegram_bridge", MODULE_PATH)
|
||||
module = module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_config_from_env_prefers_nexus_specific_values():
|
||||
module = load_module()
|
||||
cfg = module.config_from_env({
|
||||
"NEXUS_TELEGRAM_BOT_TOKEN": "bridge-token",
|
||||
"NEXUS_TELEGRAM_CHAT_ID": "-100123",
|
||||
"TELEGRAM_BOT_TOKEN": "shared-token",
|
||||
"TELEGRAM_HOME_CHANNEL": "-100999",
|
||||
})
|
||||
assert cfg.bot_token == "bridge-token"
|
||||
assert cfg.chat_id == "-100123"
|
||||
|
||||
|
||||
def test_extract_update_message_filters_wrong_chat_and_formats_sender():
|
||||
module = load_module()
|
||||
update = {
|
||||
"update_id": 7,
|
||||
"message": {
|
||||
"chat": {"id": -100123},
|
||||
"from": {"first_name": "Alex", "username": "alex"},
|
||||
"text": "hello from telegram",
|
||||
"date": 1710000000,
|
||||
},
|
||||
}
|
||||
msg = module.extract_update_message(update, "-100123")
|
||||
assert msg["sender"] == "Alex"
|
||||
assert msg["text"] == "hello from telegram"
|
||||
assert module.extract_update_message(update, "-100999") is None
|
||||
|
||||
|
||||
def test_poll_once_normalizes_updates_and_advances_offset():
|
||||
module = load_module()
|
||||
cfg = module.TelegramBridgeConfig(bot_token="token", chat_id="-100123", poll_timeout=1)
|
||||
|
||||
async def run_test():
|
||||
bridge = module.TelegramBridge(
|
||||
cfg,
|
||||
get_json=lambda url: {
|
||||
"ok": True,
|
||||
"result": [
|
||||
{
|
||||
"update_id": 11,
|
||||
"message": {
|
||||
"chat": {"id": -100123},
|
||||
"from": {"username": "timmy-time"},
|
||||
"text": "nexus ping",
|
||||
"date": 1710000001,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
post_json=lambda url, payload: {"ok": True},
|
||||
)
|
||||
last_id, messages = await bridge.poll_once(0)
|
||||
assert last_id == 11
|
||||
assert messages == [{
|
||||
"update_id": 11,
|
||||
"sender": "timmy-time",
|
||||
"text": "nexus ping",
|
||||
"timestamp": 1710000001,
|
||||
"source": "telegram",
|
||||
}]
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
||||
def test_send_chat_formats_sender_prefix():
|
||||
module = load_module()
|
||||
sent = {}
|
||||
cfg = module.TelegramBridgeConfig(bot_token="token", chat_id="-100123")
|
||||
|
||||
async def run_test():
|
||||
bridge = module.TelegramBridge(
|
||||
cfg,
|
||||
get_json=lambda url: {"ok": True, "result": []},
|
||||
post_json=lambda url, payload: sent.setdefault("payload", payload) or {"ok": True},
|
||||
)
|
||||
await bridge.send_chat("hello nexus", sender="Nexus")
|
||||
|
||||
asyncio.run(run_test())
|
||||
assert sent["payload"]["chat_id"] == "-100123"
|
||||
assert sent["payload"]["text"] == "[Nexus] hello nexus"
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Tests for SpatialChatAudio component (issue #1544)
|
||||
*/
|
||||
|
||||
import { SpatialChatAudio } from '../nexus/components/spatial-chat-audio.js';
|
||||
|
||||
// Mock DOM and THREE for Node.js testing
|
||||
if (typeof document === 'undefined') {
|
||||
global.document = {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof THREE === 'undefined') {
|
||||
global.THREE = {
|
||||
Vector3: class {
|
||||
constructor(x=0, y=0, z=0) { this.x = x; this.y = y; this.z = z; }
|
||||
normalize() { return this; }
|
||||
dot() { return 0; }
|
||||
cross() { return this; }
|
||||
},
|
||||
MathUtils: { clamp: (v, min, max) => Math.max(min, Math.min(max, v)) },
|
||||
AudioListener: class {
|
||||
constructor() {
|
||||
this.context = {
|
||||
state: 'running',
|
||||
currentTime: 0,
|
||||
createOscillator: () => ({
|
||||
type: 'sine',
|
||||
frequency: { value: 440 },
|
||||
connect: () => {},
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
disconnect: () => {},
|
||||
onended: null,
|
||||
}),
|
||||
createGain: () => ({
|
||||
gain: { value: 1, setValueAtTime: () => {}, linearRampToValueAtTime: () => {}, exponentialRampToValueAtTime: () => {}, setTargetAtTime: () => {} },
|
||||
connect: () => {},
|
||||
disconnect: () => {},
|
||||
}),
|
||||
createStereoPanner: () => ({
|
||||
pan: { value: 0, setValueAtTime: () => {}, setTargetAtTime: () => {} },
|
||||
connect: () => {},
|
||||
disconnect: () => {},
|
||||
}),
|
||||
createPanner: () => ({
|
||||
panningModel: '',
|
||||
distanceModel: '',
|
||||
refDistance: 0,
|
||||
maxDistance: 0,
|
||||
rolloffFactor: 0,
|
||||
positionX: { value: 0, setValueAtTime: () => {} },
|
||||
positionY: { value: 0, setValueAtTime: () => {} },
|
||||
positionZ: { value: 0, setValueAtTime: () => {} },
|
||||
connect: () => {},
|
||||
disconnect: () => {},
|
||||
}),
|
||||
resume: () => Promise.resolve(),
|
||||
destination: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
console.error(`❌ FAILED: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✔ PASSED: ${message}`);
|
||||
}
|
||||
|
||||
console.log('--- Running SpatialChatAudio Tests ---');
|
||||
|
||||
// Test 1: Module exports
|
||||
assert(typeof SpatialChatAudio === 'object', 'SpatialChatAudio exports an object');
|
||||
assert(typeof SpatialChatAudio.init === 'function', 'SpatialChatAudio has init method');
|
||||
assert(typeof SpatialChatAudio.playChatSound === 'function', 'SpatialChatAudio has playChatSound method');
|
||||
assert(typeof SpatialChatAudio.createVoiceSource === 'function', 'SpatialChatAudio has createVoiceSource method');
|
||||
|
||||
// Test 2: Config defaults
|
||||
assert(SpatialChatAudio.isEnabled() === true, 'Enabled by default');
|
||||
assert(SpatialChatAudio.getMaxHearingDistance() === 40, 'Default max hearing distance is 40');
|
||||
|
||||
// Test 3: Configuration changes
|
||||
SpatialChatAudio.setMaxHearingDistance(60);
|
||||
assert(SpatialChatAudio.getMaxHearingDistance() === 60, 'Max hearing distance updated to 60');
|
||||
|
||||
SpatialChatAudio.setEnabled(false);
|
||||
assert(SpatialChatAudio.isEnabled() === false, 'Can disable audio');
|
||||
|
||||
SpatialChatAudio.setEnabled(true);
|
||||
assert(SpatialChatAudio.isEnabled() === true, 'Can re-enable audio');
|
||||
|
||||
// Test 4: Initialization with mock camera
|
||||
const mockCamera = {
|
||||
position: new THREE.Vector3(0, 0, 0),
|
||||
getWorldDirection: () => new THREE.Vector3(1, 0, 0),
|
||||
up: new THREE.Vector3(0, 1, 0),
|
||||
add: () => {},
|
||||
getObjectByProperty: () => null,
|
||||
};
|
||||
|
||||
SpatialChatAudio.init(mockCamera);
|
||||
assert(true, 'SpatialChatAudio initializes with camera');
|
||||
|
||||
// Test 5: Voice source creation
|
||||
const voiceSource = SpatialChatAudio.createVoiceSource('timmy', new THREE.Vector3(10, 0, 0));
|
||||
assert(voiceSource !== null, 'Voice source created');
|
||||
assert(voiceSource.agentId === 'timmy', 'Voice source has correct agentId');
|
||||
assert(typeof voiceSource.updatePosition === 'function', 'Voice source has updatePosition');
|
||||
assert(typeof voiceSource.destroy === 'function', 'Voice source has destroy');
|
||||
|
||||
// Test 6: Voice source position update
|
||||
voiceSource.updatePosition(new THREE.Vector3(20, 0, 0));
|
||||
assert(true, 'Voice source position updated');
|
||||
|
||||
// Test 7: Voice source cleanup
|
||||
voiceSource.destroy();
|
||||
assert(true, 'Voice source destroyed');
|
||||
|
||||
console.log('--- All SpatialChatAudio Tests Passed ---');
|
||||
Reference in New Issue
Block a user