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
|
CHESHIRE_CAT_API_KEY= # Empty = no auth
|
||||||
|
|
||||||
# Error Reporting (Optional)
|
# 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 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/
|
backups/
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.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