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:
@@ -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
21
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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!
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user