Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Whitestone
17317aaf64 feat: bridge Nexus chat with Telegram polling relay (#1537)
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / test (pull_request) Failing after 52s
CI / validate (pull_request) Failing after 52s
2026-04-22 12:18:32 -04:00
Alexander Whitestone
da258e772c test: add red coverage for Nexus Telegram bridge (#1537) 2026-04-22 12:10:21 -04:00
8 changed files with 325 additions and 481 deletions

47
app.js
View File

@@ -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');

View File

@@ -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
```

View File

@@ -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
View 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
View File

@@ -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)

View 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

View 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"

View File

@@ -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 ---');