cleanup: update .gitignore, sanitize .env.example, remove stale files

- Expanded .gitignore: miku-app/, dashboard/, .continue/, *.code-workspace,
  cheshire-cat artifacts (venv, benchmarks, test output), jinja templates
- Sanitized .env.example: replaced real webhook URL and user ID with placeholders
- Removed SECRETS_CONFIGURED.md (contained sensitive token info)
- Removed bot/static/system.html.bak (stale backup)
- Removed bot/utils/voice_receiver.py.old (superseded)
This commit is contained in:
2026-03-04 00:17:05 +02:00
parent a226bc41df
commit 431f675fc7
6 changed files with 23 additions and 1429 deletions

View File

@@ -11,7 +11,7 @@ DISCORD_BOT_TOKEN=your_discord_bot_token_here
CHESHIRE_CAT_API_KEY= # Empty = no auth
# Error Reporting (Optional)
ERROR_WEBHOOK_URL=https://discord.com/api/webhooks/1462216811293708522/4kdGenpxZFsP0z3VBgebYENODKmcRrmEzoIwCN81jCirnAxuU2YvxGgwGCNBb6TInA9Z
ERROR_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN
# Owner Configuration
OWNER_USER_ID=209381657369772032 # Your Discord user ID for admin features
OWNER_USER_ID=YOUR_DISCORD_USER_ID # Your Discord user ID for admin features

21
.gitignore vendored
View File

@@ -77,3 +77,24 @@ temp_*
backups/
*.bak
*.backup
# WIP / experimental subprojects
miku-app/
# Abandoned directories
dashboard/
# IDE / editor workspace files
*.code-workspace
.continue/
# Cheshire Cat local artifacts
cheshire-cat/venv/
cheshire-cat/benchmark_results_*.json
cheshire-cat/streaming_benchmark_*.json
cheshire-cat/test_*_output.txt
cheshire-cat/test_*_final.txt
cheshire-cat/extracted_facts.json
# Jinja templates (referenced by llama-swap config, not source)
llama31_notool_template.jinja

View File

@@ -1,235 +0,0 @@
# Secrets Configuration - Complete
## Summary
Successfully populated all missing secrets from git history and removed hardcoded values from the codebase.
## Secrets Found and Configured
### 1. Discord Bot Token ✅
**Source**: Found in old `docker-compose.yml` commit `eb557f6`
**Value**:
```
MTM0ODAyMjY0Njc3NTc0NjY1MQ.GXsxML.nNCDOplmgNxKgqdgpAomFM2PViX10GjxyuV8uw
```
**Status**: ✅ Added to `.env`
---
### 2. Cheshire Cat API Key ✅
**Source**: Searched git history for `CHESHIRE_CAT_API_KEY`
**Finding**: Was always empty in git history (`API_KEY=`)
**Reason**: Cheshire Cat doesn't require authentication by default for local deployments
**Status**: ✅ Set to empty in `.env` (correct configuration)
**Note**: If you need to enable Cheshire Cat authentication in the future, add the API key to `.env`
---
### 3. Error Webhook URL ✅
**Source**: Found hardcoded in `bot/utils/error_handler.py` (line 12)
**Value**:
```
https://discord.com/api/webhooks/1462216811293708522/4kdGenpxZFsP0z3VBgebYENODKmcRrmEzoIwCN81jCirnAxuU2YvxGgwGCNBb6TInA9Z
```
**Status**:
- ✅ Added to `.env`
- ✅ Removed hardcoded value from `bot/utils/error_handler.py`
- ✅ Updated to import from `config.ERROR_WEBHOOK_URL`
---
### 4. Owner User ID ✅
**Status**: Already correctly set
**Value**: `209381657369772032`
**Source**: Default value from config
---
## Changes Made
### Files Modified
#### 1. `.env` ✅
```bash
# Discord Configuration
DISCORD_BOT_TOKEN=MTM0ODAyMjY0Njc3NTc0NjY1MQ.GXsxML.nNCDOplmgNxKgqdgpAomFM2PViX10GjxyuV8uw
# API Keys
CHESHIRE_CAT_API_KEY= # Empty = no auth
# Error Reporting (Optional)
ERROR_WEBHOOK_URL=https://discord.com/api/webhooks/1462216811293708522/4kdGenpxZFsP0z3VBgebYENODKmcRrmEzoIwCN81jCirnAxuU2YvxGgwGCNBb6TInA9Z
# Owner Configuration
OWNER_USER_ID=209381657369772032
```
#### 2. `.env.example` ✅
Updated to reflect actual values:
```bash
DISCORD_BOT_TOKEN=your_discord_bot_token_here
CHESHIRE_CAT_API_KEY= # Empty = no auth
ERROR_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN
OWNER_USER_ID=209381657369772032
```
#### 3. `bot/utils/error_handler.py` ✅
**Before**:
```python
# Webhook URL for error notifications
ERROR_WEBHOOK_URL = "https://discord.com/api/webhooks/1462216811293708522/4kdGenpxZFsP0z3VBgebYENODKmcRrmEzoIwCN81jCirnAxuU2YvxGgwGCNBb6TInA9Z"
```
**After**:
```python
# Import from config system
from config import ERROR_WEBHOOK_URL
```
---
## Security Improvements
### ✅ Hardcoded Secrets Removed
- **Removed**: Error webhook URL from `bot/utils/error_handler.py`
- **Reason**: Secrets should never be hardcoded in source code
### ✅ All Secrets in `.env`
All sensitive values now centralized in `.env` file:
- `DISCORD_BOT_TOKEN`
- `CHESHIRE_CAT_API_KEY`
- `ERROR_WEBHOOK_URL`
- `OWNER_USER_ID`
### ✅ `.env` in `.gitignore`
`.env` file is excluded from version control to prevent accidentally committing secrets
---
## Configuration Validation
### All Secrets Configured ✅
| Variable | Value | Status | Required |
|----------|--------|--------|----------|
| `DISCORD_BOT_TOKEN` | `MTM0ODAy...` | ✅ Set | Yes |
| `CHESHIRE_CAT_API_KEY` | `(empty)` | ✅ Set (no auth) | No |
| `ERROR_WEBHOOK_URL` | `https://discord.com/...` | ✅ Set | No |
| `OWNER_USER_ID` | `209381657369772032` | ✅ Set | Yes |
### No Hardcoded Secrets Remaining ✅
Verified no hardcoded secrets in `bot/` directory:
- ✅ No Discord webhooks found
- ✅ No API keys found
- ✅ No tokens found
---
## Git History Analysis
### Discord Bot Token
- **Found in**: `docker-compose.yml` commit `eb557f6`
- **Commit date**: Recent
- **Status**: Already exposed in git history
### Error Webhook URL
- **Found in**: `bot/utils/error_handler.py` (added in commit Sun Jan 18 01:30:26 2026)
- **Commit message**: "Error in llama-swap catchall implemented + webhook notifier"
- **Status**: Already exposed in git history
### Cheshire Cat API Key
- **Searched**: Full git history
- **Finding**: Never set (always `API_KEY=`)
- **Reason**: Cheshire Cat doesn't require authentication for local deployments
- **Status**: Correctly left empty
---
## Next Steps
### Immediate (Recommended)
1. ✅ All secrets configured - **DONE**
2. ⚠️ Test bot startup: `docker compose up -d miku-bot`
3. ⚠️ Verify error webhook notifications work
### Optional
4. Review Cheshire Cat documentation if you want to enable authentication in the future
5. Create a new Discord webhook for error notifications if you want to change the current one
6. Regenerate Discord bot token if you want to (current token still valid)
---
## Verification Commands
### Verify `.env` Configuration
```bash
# Show all configured secrets
grep -E "^(DISCORD_BOT_TOKEN|CHESHIRE_CAT_API_KEY|ERROR_WEBHOOK_URL|OWNER_USER_ID)=" .env
```
### Validate Configuration
```bash
# Run configuration validation
python3 -c "from bot.config import validate_config; is_valid, errors = validate_config(); print(f'Valid: {is_valid}'); print(f'Errors: {errors}')"
```
### Check for Hardcoded Secrets
```bash
# Search for any remaining hardcoded Discord webhooks/tokens
grep -r "discord\.com/api/webhooks\|api\.discord\.com" bot/ --include="*.py" | grep -v "__pycache__"
```
### Test Bot Startup
```bash
# Start the bot
docker compose up -d miku-bot
# Check logs
docker compose logs -f miku-bot
```
---
## Security Best Practices Applied
### ✅ Separation of Concerns
- Secrets in `.env` (not committed)
- Configuration in `config.yaml` (committed)
- Code imports from `config.py`
### ✅ Type Safety
- Pydantic validates all environment variables at startup
- Type errors caught before runtime
### ✅ No Hardcoded Secrets
- All secrets moved to environment variables
- Code reads from `config.py`, never hardcoded values
### ✅ Git History Awareness
- Secrets already in git history acknowledged
- No attempt to hide existing history
- Focus on preventing future exposures
---
## Summary
**All secrets successfully configured**
**Discord bot token** restored from git history
**Error webhook URL** moved to `.env`
**Cheshire Cat API key** correctly left empty (no auth needed)
**Hardcoded webhook URL** removed from code
**Configuration system** fully operational
**No remaining hardcoded secrets**
The bot is now ready to run with all secrets properly configured and no hardcoded values in the codebase!

View File

@@ -1,772 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Settings - Miku Bot</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: white;
padding: 20px 30px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
color: #667eea;
font-size: 28px;
}
.header-actions {
display: flex;
gap: 10px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 20px;
}
.card {
background: white;
border-radius: 10px;
padding: 25px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card h2 {
color: #333;
margin-bottom: 20px;
font-size: 20px;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.global-settings {
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 5px;
}
.setting-row {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 10px;
}
.setting-row label {
font-weight: 600;
color: #495057;
min-width: 120px;
}
select {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 5px;
font-size: 14px;
background: white;
cursor: pointer;
}
.components-table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
.components-table th {
background: #667eea;
color: white;
padding: 12px;
text-align: left;
font-weight: 600;
}
.components-table td {
padding: 10px 12px;
border-bottom: 1px solid #dee2e6;
}
.components-table tr:hover {
background: #f8f9fa;
}
.level-checkboxes {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.level-checkbox {
display: flex;
align-items: center;
gap: 5px;
}
.level-checkbox input[type="checkbox"] {
cursor: pointer;
width: 18px;
height: 18px;
}
.level-checkbox label {
cursor: pointer;
user-select: none;
font-size: 13px;
}
.toggle {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 4px;
bottom: 4px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #667eea;
}
input:checked + .slider:before {
transform: translateX(26px);
}
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 5px;
}
.status-active {
background: #28a745;
}
.status-inactive {
background: #6c757d;
}
.api-filters {
margin-top: 15px;
padding: 15px;
background: #fff3cd;
border-radius: 5px;
border-left: 4px solid #ffc107;
}
.api-filters h3 {
color: #856404;
font-size: 16px;
margin-bottom: 10px;
}
.filter-row {
margin-bottom: 10px;
}
.filter-row label {
display: block;
font-weight: 600;
margin-bottom: 5px;
color: #495057;
}
input[type="text"], input[type="number"] {
width: 100%;
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 5px;
font-size: 14px;
}
.log-preview {
background: #212529;
color: #f8f9fa;
padding: 15px;
border-radius: 5px;
font-family: 'Courier New', monospace;
font-size: 12px;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.log-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.log-line {
margin-bottom: 5px;
line-height: 1.5;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
border-radius: 5px;
color: white;
font-weight: 600;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
z-index: 1000;
animation: slideIn 0.3s ease-out;
}
.notification-success {
background: #28a745;
}
.notification-error {
background: #dc3545;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.loading {
text-align: center;
padding: 40px;
color: #6c757d;
}
.component-description {
font-size: 12px;
color: #6c757d;
font-style: italic;
}
@media (max-width: 1200px) {
.content {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎛️ System Settings - Logging Configuration</h1>
<div class="header-actions">
<button class="btn btn-secondary" onclick="window.location.href='/'">← Back to Dashboard</button>
<button class="btn btn-primary" onclick="saveAllSettings()">💾 Save All</button>
<button class="btn btn-danger" onclick="resetToDefaults()">🔄 Reset to Defaults</button>
</div>
</div>
<div class="content">
<div class="card">
<h2>📊 Logging Components</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Enable or disable specific log levels for each component. You can toggle any combination of levels (e.g., only INFO + ERROR, or only WARNING + DEBUG).
</p>
<table class="components-table">
<thead>
<tr>
<th>Component</th>
<th>Enabled</th>
<th>Log Levels</th>
<th>Status</th>
</tr>
</thead>
<tbody id="componentsTable">
<tr>
<td colspan="4" class="loading">Loading components...</td>
</tr>
</tbody>
</table>
<div id="apiFilters" class="api-filters" style="display: none;">
<h3>🌐 API Request Filters</h3>
<div class="filter-row">
<label>Exclude Paths (comma-separated):</label>
<input type="text" id="excludePaths" placeholder="/health, /static/*">
</div>
<div class="filter-row">
<label>Exclude Status Codes (comma-separated):</label>
<input type="text" id="excludeStatus" placeholder="200, 304">
</div>
<div class="setting-row">
<label>Log Slow Requests (>1000ms):</label>
<label class="toggle">
<input type="checkbox" id="includeSlowRequests" checked>
<span class="slider"></span>
</label>
</div>
<div class="filter-row">
<label>Slow Request Threshold (ms):</label>
<input type="number" id="slowThreshold" value="1000" min="100" step="100">
</div>
<button class="btn btn-primary" onclick="saveApiFilters()" style="margin-top: 10px;">Save API Filters</button>
</div>
</div>
<div class="card">
<h2>📜 Live Log Preview</h2>
<div class="log-preview-header">
<div>
<label>Component: </label>
<select id="previewComponent" onchange="loadLogPreview()">
<option value="bot">Bot</option>
</select>
</div>
<button class="btn btn-secondary" onclick="loadLogPreview()">🔄 Refresh</button>
</div>
<div class="log-preview" id="logPreview">
<div class="loading">Select a component to view logs...</div>
</div>
</div>
</div>
</div>
<script>
let currentConfig = null;
let componentsData = null;
// Load configuration on page load
window.addEventListener('DOMContentLoaded', () => {
loadConfiguration();
loadComponents();
});
async function loadConfiguration() {
try {
const response = await fetch('/api/log/config');
const data = await response.json();
if (data.success) {
currentConfig = data.config;
// No global level to set - we use per-component levels only
} else {
showNotification('Failed to load configuration', 'error');
}
} catch (error) {
showNotification('Error loading configuration: ' + error.message, 'error');
}
}
async function loadComponents() {
try {
const response = await fetch('/api/log/components');
const data = await response.json();
if (data.success) {
componentsData = data;
renderComponentsTable();
populatePreviewSelect();
} else {
showNotification('Failed to load components', 'error');
}
} catch (error) {
showNotification('Error loading components: ' + error.message, 'error');
}
}
function renderComponentsTable() {
const tbody = document.getElementById('componentsTable');
tbody.innerHTML = '';
for (const [name, description] of Object.entries(componentsData.components)) {
const stats = componentsData.stats[name] || {};
const enabled = stats.enabled !== undefined ? stats.enabled : true;
const enabledLevels = stats.enabled_levels || ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
// Build checkboxes for each level
const allLevels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
if (name === 'api.requests') {
allLevels.push('API');
}
const levelCheckboxes = allLevels.map(level => {
const emoji = {'DEBUG': '🔍', 'INFO': '', 'WARNING': '⚠️', 'ERROR': '❌', 'CRITICAL': '🔥', 'API': '🌐'}[level];
const checked = enabledLevels.includes(level) ? 'checked' : '';
return `
<div class="level-checkbox">
<input type="checkbox"
id="level_${name}_${level}"
${checked}
onchange="updateComponentLevels('${name}')">
<label for="level_${name}_${level}">${emoji} ${level}</label>
</div>
`;
}).join('');
const row = document.createElement('tr');
row.innerHTML = `
<td>
<strong>${name}</strong><br>
<span class="component-description">${description}</span>
</td>
<td>
<label class="toggle">
<input type="checkbox" id="enabled_${name}" ${enabled ? 'checked' : ''} onchange="updateComponentEnabled('${name}')">
<span class="slider"></span>
</label>
</td>
<td>
<div class="level-checkboxes">
${levelCheckboxes}
</div>
</td>
<td>
<span class="status-indicator ${enabled ? 'status-active' : 'status-inactive'}"></span>
${enabled ? 'Active' : 'Inactive'}
</td>
`;
tbody.appendChild(row);
// Show API filters if api.requests is selected
if (name === 'api.requests') {
document.getElementById('enabled_' + name).addEventListener('change', (e) => {
document.getElementById('apiFilters').style.display = e.target.checked ? 'block' : 'none';
});
if (enabled) {
document.getElementById('apiFilters').style.display = 'block';
loadApiFilters();
}
}
}
}
function populatePreviewSelect() {
const select = document.getElementById('previewComponent');
select.innerHTML = '';
for (const name of Object.keys(componentsData.components)) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
}
loadLogPreview();
}
async function updateComponentEnabled(component) {
const enabled = document.getElementById('enabled_' + component).checked;
try {
const response = await fetch('/api/log/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
component: component,
enabled: enabled
})
});
const data = await response.json();
if (data.success) {
showNotification(`${enabled ? 'Enabled' : 'Disabled'} ${component}`, 'success');
// Update status indicator
const row = document.getElementById('enabled_' + component).closest('tr');
const statusCell = row.querySelector('td:last-child');
statusCell.innerHTML = `
<span class="status-indicator ${enabled ? 'status-active' : 'status-inactive'}"></span>
${enabled ? 'Active' : 'Inactive'}
`;
} else {
showNotification('Failed to update ' + component + ': ' + data.error, 'error');
}
} catch (error) {
showNotification('Error updating component: ' + error.message, 'error');
}
}
async function updateComponentLevels(component) {
// Collect all checked levels
const allLevels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
if (component === 'api.requests') {
allLevels.push('API');
}
const enabledLevels = allLevels.filter(level => {
const checkbox = document.getElementById(`level_${component}_${level}`);
return checkbox && checkbox.checked;
});
try {
const response = await fetch('/api/log/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
component: component,
enabled_levels: enabledLevels
})
});
const data = await response.json();
if (data.success) {
showNotification(`Updated levels for ${component}: ${enabledLevels.join(', ')}`, 'success');
} else {
showNotification('Failed to update ' + component + ': ' + data.error, 'error');
}
} catch (error) {
showNotification('Error updating component: ' + error.message, 'error');
}
}
async function updateGlobalLevel() {
// Deprecated - kept for compatibility
showNotification('Global level setting removed. Use individual component levels instead.', 'success');
}
async function loadApiFilters() {
if (!currentConfig || !currentConfig.components['api.requests']) return;
const filters = currentConfig.components['api.requests'].filters || {};
document.getElementById('excludePaths').value = (filters.exclude_paths || []).join(', ');
document.getElementById('excludeStatus').value = (filters.exclude_status || []).join(', ');
document.getElementById('includeSlowRequests').checked = filters.include_slow_requests !== false;
document.getElementById('slowThreshold').value = filters.slow_threshold_ms || 1000;
}
async function saveApiFilters() {
const excludePaths = document.getElementById('excludePaths').value
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
const excludeStatus = document.getElementById('excludeStatus').value
.split(',')
.map(s => parseInt(s.trim()))
.filter(n => !isNaN(n));
const includeSlowRequests = document.getElementById('includeSlowRequests').checked;
const slowThreshold = parseInt(document.getElementById('slowThreshold').value);
try {
const response = await fetch('/api/log/filters', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
exclude_paths: excludePaths,
exclude_status: excludeStatus,
include_slow_requests: includeSlowRequests,
slow_threshold_ms: slowThreshold
})
});
const data = await response.json();
if (data.success) {
showNotification('API filters saved', 'success');
} else {
showNotification('Failed to save filters: ' + data.error, 'error');
}
} catch (error) {
showNotification('Error saving filters: ' + error.message, 'error');
}
}
async function saveAllSettings() {
// Reload configuration to apply all changes
try {
const response = await fetch('/api/log/reload', {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showNotification('All settings saved and reloaded', 'success');
await loadConfiguration();
await loadComponents();
} else {
showNotification('Failed to reload settings: ' + data.error, 'error');
}
} catch (error) {
showNotification('Error saving settings: ' + error.message, 'error');
}
}
async function resetToDefaults() {
if (!confirm('Are you sure you want to reset all logging settings to defaults?')) {
return;
}
try {
const response = await fetch('/api/log/reset', {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showNotification('Settings reset to defaults', 'success');
await loadConfiguration();
await loadComponents();
} else {
showNotification('Failed to reset settings: ' + data.error, 'error');
}
} catch (error) {
showNotification('Error resetting settings: ' + error.message, 'error');
}
}
async function loadLogPreview() {
const component = document.getElementById('previewComponent').value;
const preview = document.getElementById('logPreview');
preview.innerHTML = '<div class="loading">Loading logs...</div>';
try {
const response = await fetch(`/api/log/files/${component}?lines=50`);
const data = await response.json();
if (data.success) {
if (data.lines.length === 0) {
preview.innerHTML = '<div class="loading">No logs yet for this component</div>';
} else {
preview.innerHTML = data.lines.map(line =>
`<div class="log-line">${escapeHtml(line)}</div>`
).join('');
// Scroll to bottom
preview.scrollTop = preview.scrollHeight;
}
} else {
preview.innerHTML = `<div class="loading">Error: ${data.error}</div>`;
}
} catch (error) {
preview.innerHTML = `<div class="loading">Error loading logs: ${error.message}</div>`;
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showNotification(message, type) {
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
// Auto-refresh log preview every 5 seconds
setInterval(() => {
if (document.getElementById('previewComponent').value) {
loadLogPreview();
}
}, 5000);
</script>
</body>
</html>

View File

@@ -1,419 +0,0 @@
"""
Discord Voice Receiver
Captures audio from Discord voice channels and streams to STT.
Handles opus decoding and audio preprocessing.
"""
import discord
import audioop
import numpy as np
import asyncio
import logging
from typing import Dict, Optional
from collections import deque
from utils.stt_client import STTClient
logger = logging.getLogger('voice_receiver')
class VoiceReceiver(discord.sinks.Sink):
"""
Voice Receiver for Discord Audio Capture
Captures audio from Discord voice channels using discord.py's voice websocket.
Processes Opus audio, decodes to PCM, resamples to 16kHz mono for STT.
Note: Standard discord.py doesn't have built-in audio receiving.
This implementation hooks into the voice websocket directly.
"""
import asyncio
import struct
import audioop
import logging
from typing import Dict, Optional, Callable
import discord
# Import opus decoder
try:
import discord.opus as opus
if not opus.is_loaded():
opus.load_opus('opus')
except Exception as e:
logging.error(f"Failed to load opus: {e}")
from utils.stt_client import STTClient
logger = logging.getLogger('voice_receiver')
class VoiceReceiver:
"""
Receives and processes audio from Discord voice channel.
This class monkey-patches the VoiceClient to intercept received RTP packets,
decodes Opus audio, and forwards to STT clients.
"""
def __init__(
self,
voice_client: discord.VoiceClient,
voice_manager,
stt_url: str = "ws://miku-stt:8001"
):
"""
Initialize voice receiver.
Args:
voice_client: Discord VoiceClient to receive audio from
voice_manager: Voice manager instance for callbacks
stt_url: Base URL for STT WebSocket server
"""
self.voice_client = voice_client
self.voice_manager = voice_manager
self.stt_url = stt_url
# Per-user STT clients
self.stt_clients: Dict[int, STTClient] = {}
# Opus decoder instances per SSRC (one per user)
self.opus_decoders: Dict[int, any] = {}
# Resampler state per user (for 48kHz → 16kHz)
self.resample_state: Dict[int, tuple] = {}
# Original receive method (for restoration)
self._original_receive = None
# Active flag
self.active = False
logger.info("VoiceReceiver initialized")
async def start_listening(self, user_id: int, user: discord.User):
"""
Start listening to a specific user's audio.
Args:
user_id: Discord user ID
user: Discord User object
"""
if user_id in self.stt_clients:
logger.warning(f"Already listening to user {user_id}")
return
try:
# Create STT client for this user
stt_client = STTClient(
user_id=user_id,
stt_url=self.stt_url,
on_vad_event=lambda event, prob: asyncio.create_task(
self.voice_manager.on_user_vad_event(user_id, event)
),
on_partial_transcript=lambda text: asyncio.create_task(
self.voice_manager.on_partial_transcript(user_id, text)
),
on_final_transcript=lambda text: asyncio.create_task(
self.voice_manager.on_final_transcript(user_id, text, user)
),
on_interruption=lambda prob: asyncio.create_task(
self.voice_manager.on_user_interruption(user_id, prob)
)
)
# Connect to STT server
await stt_client.connect()
# Store client
self.stt_clients[user_id] = stt_client
# Initialize opus decoder for this user if needed
# (Will be done when we receive their SSRC)
# Patch voice client to receive audio if not already patched
if not self.active:
await self._patch_voice_client()
logger.info(f"✓ Started listening to user {user_id} ({user.name})")
except Exception as e:
logger.error(f"Failed to start listening to user {user_id}: {e}", exc_info=True)
raise
async def stop_listening(self, user_id: int):
"""
Stop listening to a specific user.
Args:
user_id: Discord user ID
"""
if user_id not in self.stt_clients:
logger.warning(f"Not listening to user {user_id}")
return
try:
# Disconnect STT client
stt_client = self.stt_clients.pop(user_id)
await stt_client.disconnect()
# Clean up decoder and resampler state
# Note: We don't know the SSRC here, so we'll just remove by user_id
# Actual cleanup happens in _process_audio when we match SSRC to user_id
# If no more clients, unpatch voice client
if not self.stt_clients:
await self._unpatch_voice_client()
logger.info(f"✓ Stopped listening to user {user_id}")
except Exception as e:
logger.error(f"Failed to stop listening to user {user_id}: {e}", exc_info=True)
raise
async def _patch_voice_client(self):
"""Patch VoiceClient to intercept received audio packets."""
logger.warning("⚠️ Audio receiving not yet implemented - discord.py doesn't support receiving by default")
logger.warning("⚠️ You need discord.py-self or a custom fork with receiving support")
logger.warning("⚠️ STT will not receive any audio until this is implemented")
self.active = True
# TODO: Implement RTP packet receiving
# This requires either:
# 1. Using discord.py-self which has receiving support
# 2. Monkey-patching voice_client.ws to intercept packets
# 3. Using a separate UDP socket listener
async def _unpatch_voice_client(self):
"""Restore original VoiceClient behavior."""
self.active = False
logger.info("Unpatch voice client (receiving disabled)")
async def _process_audio(self, ssrc: int, opus_data: bytes):
"""
Process received Opus audio packet.
Args:
ssrc: RTP SSRC (identifies the audio source/user)
opus_data: Opus-encoded audio data
"""
# TODO: Map SSRC to user_id (requires tracking voice state updates)
# For now, this is a placeholder
pass
async def cleanup(self):
"""Clean up all resources."""
# Disconnect all STT clients
for user_id in list(self.stt_clients.keys()):
await self.stop_listening(user_id)
# Unpatch voice client
if self.active:
await self._unpatch_voice_client()
logger.info("VoiceReceiver cleanup complete") def __init__(self, voice_manager):
"""
Initialize voice receiver.
Args:
voice_manager: Reference to VoiceManager for callbacks
"""
super().__init__()
self.voice_manager = voice_manager
# Per-user STT clients
self.stt_clients: Dict[int, STTClient] = {}
# Audio buffers per user (for resampling)
self.audio_buffers: Dict[int, deque] = {}
# User info (for logging)
self.users: Dict[int, discord.User] = {}
logger.info("Voice receiver initialized")
async def start_listening(self, user_id: int, user: discord.User):
"""
Start listening to a specific user.
Args:
user_id: Discord user ID
user: Discord user object
"""
if user_id in self.stt_clients:
logger.warning(f"Already listening to user {user.name} ({user_id})")
return
logger.info(f"Starting to listen to user {user.name} ({user_id})")
# Store user info
self.users[user_id] = user
# Initialize audio buffer
self.audio_buffers[user_id] = deque(maxlen=1000) # Max 1000 chunks
# Create STT client with callbacks
stt_client = STTClient(
user_id=str(user_id),
on_vad_event=lambda event: self._on_vad_event(user_id, event),
on_partial_transcript=lambda text, ts: self._on_partial_transcript(user_id, text, ts),
on_final_transcript=lambda text, ts: self._on_final_transcript(user_id, text, ts),
on_interruption=lambda prob: self._on_interruption(user_id, prob)
)
# Connect to STT
try:
await stt_client.connect()
self.stt_clients[user_id] = stt_client
logger.info(f"✓ STT connected for user {user.name}")
except Exception as e:
logger.error(f"Failed to connect STT for user {user.name}: {e}")
async def stop_listening(self, user_id: int):
"""
Stop listening to a specific user.
Args:
user_id: Discord user ID
"""
if user_id not in self.stt_clients:
return
user = self.users.get(user_id)
logger.info(f"Stopping listening to user {user.name if user else user_id}")
# Disconnect STT client
stt_client = self.stt_clients[user_id]
await stt_client.disconnect()
# Cleanup
del self.stt_clients[user_id]
if user_id in self.audio_buffers:
del self.audio_buffers[user_id]
if user_id in self.users:
del self.users[user_id]
logger.info(f"✓ Stopped listening to user {user.name if user else user_id}")
async def stop_all(self):
"""Stop listening to all users."""
logger.info("Stopping all voice receivers")
user_ids = list(self.stt_clients.keys())
for user_id in user_ids:
await self.stop_listening(user_id)
logger.info("✓ All voice receivers stopped")
def write(self, data: discord.sinks.core.AudioData):
"""
Called by discord.py when audio is received.
Args:
data: Audio data from Discord
"""
# Get user ID from SSRC
user_id = data.user.id if data.user else None
if not user_id:
return
# Check if we're listening to this user
if user_id not in self.stt_clients:
return
# Process audio
try:
# Decode opus to PCM (48kHz stereo)
pcm_data = data.pcm
# Convert stereo to mono if needed
if len(pcm_data) % 4 == 0: # Stereo int16 (2 channels * 2 bytes)
# Average left and right channels
pcm_mono = audioop.tomono(pcm_data, 2, 0.5, 0.5)
else:
pcm_mono = pcm_data
# Resample from 48kHz to 16kHz
# Discord sends 20ms chunks at 48kHz = 960 samples
# We need 320 samples at 16kHz (20ms)
pcm_16k = audioop.ratecv(pcm_mono, 2, 1, 48000, 16000, None)[0]
# Send to STT
asyncio.create_task(self._send_audio_chunk(user_id, pcm_16k))
except Exception as e:
logger.error(f"Error processing audio for user {user_id}: {e}")
async def _send_audio_chunk(self, user_id: int, audio_data: bytes):
"""
Send audio chunk to STT client.
Args:
user_id: Discord user ID
audio_data: PCM audio (int16, 16kHz mono)
"""
stt_client = self.stt_clients.get(user_id)
if not stt_client or not stt_client.is_connected():
return
try:
await stt_client.send_audio(audio_data)
except Exception as e:
logger.error(f"Failed to send audio chunk for user {user_id}: {e}")
async def _on_vad_event(self, user_id: int, event: dict):
"""Handle VAD event from STT."""
user = self.users.get(user_id)
event_type = event.get('event')
probability = event.get('probability', 0)
logger.debug(f"VAD [{user.name if user else user_id}]: {event_type} (prob={probability:.3f})")
# Notify voice manager
if hasattr(self.voice_manager, 'on_user_vad_event'):
await self.voice_manager.on_user_vad_event(user_id, event)
async def _on_partial_transcript(self, user_id: int, text: str, timestamp: float):
"""Handle partial transcript from STT."""
user = self.users.get(user_id)
logger.info(f"Partial [{user.name if user else user_id}]: {text}")
# Notify voice manager
if hasattr(self.voice_manager, 'on_partial_transcript'):
await self.voice_manager.on_partial_transcript(user_id, text)
async def _on_final_transcript(self, user_id: int, text: str, timestamp: float):
"""Handle final transcript from STT."""
user = self.users.get(user_id)
logger.info(f"Final [{user.name if user else user_id}]: {text}")
# Notify voice manager - THIS TRIGGERS LLM RESPONSE
if hasattr(self.voice_manager, 'on_final_transcript'):
await self.voice_manager.on_final_transcript(user_id, text)
async def _on_interruption(self, user_id: int, probability: float):
"""Handle interruption detection from STT."""
user = self.users.get(user_id)
logger.info(f"Interruption from [{user.name if user else user_id}] (prob={probability:.3f})")
# Notify voice manager - THIS CANCELS MIKU'S SPEECH
if hasattr(self.voice_manager, 'on_user_interruption'):
await self.voice_manager.on_user_interruption(user_id, probability)
def cleanup(self):
"""Cleanup resources."""
logger.info("Cleaning up voice receiver")
# Async cleanup will be called separately
def get_listening_users(self) -> list:
"""Get list of users currently being listened to."""
return [
{
'user_id': user_id,
'username': user.name if user else 'Unknown',
'connected': client.is_connected()
}
for user_id, (user, client) in
[(uid, (self.users.get(uid), self.stt_clients.get(uid)))
for uid in self.stt_clients.keys()]
]

Submodule soprano_to_rvc deleted from 1b54e4d5e2