-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
1041 lines (835 loc) · 35.3 KB
/
app.py
File metadata and controls
1041 lines (835 loc) · 35.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Overtalkerr - Multi-platform voice assistant for Overseerr
Supports:
- Amazon Alexa (ask-sdk-python)
- Siri Shortcuts (webhook)
- Home Assistant Assist (webhook-conversation)
- Web-based test harness
Note: Direct Google Assistant integration via Dialogflow was removed as Google
deprecated Conversational Actions in June 2023. Use Home Assistant Assist with
Google Assistant integration for "Hey Google" voice control.
This replaces the deprecated Flask-Ask framework with modern, production-ready code.
"""
import json
import os
import re
import datetime as dt
import base64
from typing import Any, Dict, List, Optional
from flask import Flask, request, jsonify, send_from_directory, Response
from ask_sdk_core.skill_builder import SkillBuilder
from ask_sdk_core.dispatch_components import AbstractRequestHandler, AbstractExceptionHandler
from ask_sdk_core.utils import is_request_type, is_intent_name
from ask_sdk_core.handler_input import HandlerInput
from ask_sdk_core.serialize import DefaultSerializer
from ask_sdk_model import Response as AlexaResponse
from ask_sdk_model.ui import SimpleCard
from config import Config
from logger import logger, log_request, log_error
from db import db_session, SessionState, cleanup_old_sessions
import overseerr
from overseerr import OverseerrError, OverseerrConnectionError
from voice_assistant_adapter import router, VoiceResponse, VoiceAssistantPlatform
from unified_voice_handler import unified_handler, load_state, save_state
# Initialize Flask app
app = Flask(__name__)
app.config['SECRET_KEY'] = Config.SECRET_KEY
# Static directory for test UI
STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
logger.info("Overtalkerr starting up...")
# Check backend connectivity on startup
if not Config.MOCK_BACKEND:
Config.check_connectivity()
# ========================================
# ALEXA SKILL (ask-sdk-python)
# ========================================
# Import Alexa handlers from dedicated module
from alexa_handlers import skill as alexa_skill
# Initialize Alexa serializer for request/response handling
alexa_serializer = DefaultSerializer()
# ========================================
# DASHBOARD / LANDING PAGE
# ========================================
@app.route('/')
def dashboard():
"""Serve the main dashboard"""
return send_from_directory('static', 'dashboard.html')
@app.route('/api/stats')
def get_stats():
"""Get dashboard statistics"""
try:
from db import SessionState, db_session
from datetime import datetime, timedelta
from media_backends import get_backend, BackendFactory
stats = {}
# Backend status
# Check if backend is configured
if not Config.MEDIA_BACKEND_URL or Config.MEDIA_BACKEND_URL == 'http://your-backend-url:5055':
stats['backend_connected'] = False
stats['backend_type'] = 'Not Configured'
stats['backend_configured'] = False
else:
stats['backend_configured'] = True
# Try to get backend info
try:
backend = get_backend()
stats['backend_type'] = backend.__class__.__name__.replace('Backend', '')
stats['backend_connected'] = True
except Exception as e:
# Couldn't initialize backend, but still show what we know
logger.warning(f"Could not initialize backend: {e}")
stats['backend_connected'] = False
# Try to guess backend type from URL or just show as configured
try:
detected_type = BackendFactory.detect_backend_type(Config.MEDIA_BACKEND_URL, Config.MEDIA_BACKEND_API_KEY)
stats['backend_type'] = detected_type.value.title()
except:
# Can't detect, use generic name
stats['backend_type'] = 'Backend'
# Database stats
with db_session() as session:
# Total requests (approximate by unique media titles in session state)
total_count = session.query(SessionState).count()
stats['total_requests'] = total_count
# Recent activity (last 10)
recent = session.query(SessionState).order_by(
SessionState.updated_at.desc()
).limit(10).all()
stats['recent_activity'] = []
for s in recent:
try:
# Parse the JSON from state_json column
import json
state_data = json.loads(s.state_json) if s.state_json else {}
# Try to get the result - could be in 'chosen_result' (old) or 'results' array (new)
result = None
if state_data:
# New format: results array with index
if 'results' in state_data and state_data['results']:
idx = state_data.get('index', 0)
if 0 <= idx < len(state_data['results']):
result = state_data['results'][idx]
# Old format: chosen_result
elif 'chosen_result' in state_data:
result = state_data['chosen_result']
if result:
# Extract title (could be '_title', 'title', or 'name')
title = result.get('_title') or result.get('title') or result.get('name', 'Unknown')
# Extract year from various possible fields
year = None
if result.get('year'):
year = result['year']
elif result.get('_date'):
try:
year = str(result['_date']).split('-')[0]
except:
pass
# Extract media type
media_type = result.get('_mediaType') or result.get('mediaType') or result.get('type', 'unknown')
stats['recent_activity'].append({
'title': title,
'year': year,
'media_type': media_type,
'timestamp': s.updated_at.isoformat() if s.updated_at else None
})
except (json.JSONDecodeError, KeyError, AttributeError, IndexError) as e:
# Skip sessions with invalid or incomplete data
logger.debug(f"Skipping session in activity feed: {e}")
continue
# Today's requests
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
today_count = session.query(SessionState).filter(
SessionState.updated_at >= today
).count()
stats['requests_today'] = today_count
# This week's requests
week_ago = datetime.now() - timedelta(days=7)
week_count = session.query(SessionState).filter(
SessionState.updated_at >= week_ago
).count()
stats['requests_this_week'] = week_count
# Configuration status
stats['config_complete'] = bool(
Config.MEDIA_BACKEND_URL and
Config.MEDIA_BACKEND_API_KEY and
Config.PUBLIC_BASE_URL
)
stats['public_url'] = Config.PUBLIC_BASE_URL or 'Not configured'
stats['backend_url'] = Config.MEDIA_BACKEND_URL or 'Not configured'
return jsonify(stats)
except Exception as e:
log_error("Failed to get stats", e)
return jsonify({"error": str(e)}), 500
@app.route('/api/version')
def get_version():
"""Get current version and check for updates from GitHub"""
import os
import requests
from datetime import datetime, timedelta
try:
# Read current version
version_file = os.path.join(os.path.dirname(__file__), 'VERSION')
with open(version_file, 'r') as f:
current_version = f.read().strip()
response_data = {
'current_version': current_version,
'update_available': False,
'latest_version': None,
'release_url': None,
'release_notes': None,
'checked_at': datetime.utcnow().isoformat()
}
# Check GitHub for latest release
try:
github_api = 'https://api.github.com/repos/mscodemonkey/overtalkerr/releases/latest'
headers = {'Accept': 'application/vnd.github.v3+json'}
gh_response = requests.get(github_api, headers=headers, timeout=5)
if gh_response.status_code == 200:
release_data = gh_response.json()
latest_version = release_data.get('tag_name', '').lstrip('v')
response_data['latest_version'] = latest_version
response_data['release_url'] = release_data.get('html_url')
response_data['release_notes'] = release_data.get('body', '')
response_data['published_at'] = release_data.get('published_at')
# Simple version comparison (works for semver like 1.0.0)
if latest_version and latest_version != current_version:
# Remove -beta, -alpha suffixes for comparison
current_clean = current_version.split('-')[0]
latest_clean = latest_version.split('-')[0]
current_parts = [int(x) for x in current_clean.split('.')]
latest_parts = [int(x) for x in latest_clean.split('.')]
if latest_parts > current_parts:
response_data['update_available'] = True
logger.info(f"Update available: {current_version} -> {latest_version}")
except Exception as e:
logger.warning(f"Could not check for updates: {e}")
response_data['check_error'] = str(e)
return jsonify(response_data)
except Exception as e:
log_error("Failed to get version info", e)
return jsonify({"error": str(e)}), 500
@app.route('/api/update', methods=['POST'])
def perform_update():
"""
Perform git pull to update Overtalkerr to the latest version.
This endpoint:
1. Runs git pull to get latest code
2. Returns update status
3. User must manually restart the service after update
"""
import subprocess
try:
# Check if we're in a git repository
repo_path = os.path.dirname(os.path.abspath(__file__))
# Check git status
try:
result = subprocess.run(
['git', 'status'],
cwd=repo_path,
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
return jsonify({
'success': False,
'error': 'Not a git repository or git not available'
}), 400
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
return jsonify({
'success': False,
'error': 'Git command failed or not found'
}), 400
# Perform git pull
logger.info("Performing git pull to update Overtalkerr")
result = subprocess.run(
['git', 'pull'],
cwd=repo_path,
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
output = result.stdout
# Check if anything was updated
if 'Already up to date' in output or 'Already up-to-date' in output:
return jsonify({
'success': True,
'updated': False,
'message': 'Already up to date',
'output': output,
'restart_required': False
})
else:
# Something was updated
logger.info(f"Update completed: {output}")
# Schedule a delayed restart so we can send the response first
# Using 'at' command or nohup with sleep to restart after response is sent
try:
# Test if systemctl is available
test_result = subprocess.run(
['systemctl', '--version'],
capture_output=True,
timeout=5
)
if test_result.returncode == 0:
# Schedule restart in background after 2 seconds
# This allows the response to be sent before the service dies
subprocess.Popen(
['bash', '-c', 'sleep 2 && systemctl restart overtalkerr'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
logger.info("Service restart scheduled in 2 seconds")
return jsonify({
'success': True,
'updated': True,
'restarted': True,
'message': 'Update successful! Service will restart in 2 seconds. The page will reload automatically.',
'output': output
})
else:
logger.warning("systemctl not available")
return jsonify({
'success': True,
'updated': True,
'restarted': False,
'message': 'Update successful but systemctl not available. Please restart manually: systemctl restart overtalkerr',
'output': output
})
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError) as e:
logger.warning(f"Could not schedule service restart: {e}")
return jsonify({
'success': True,
'updated': True,
'restarted': False,
'message': f'Update successful but could not restart service: {str(e)}. Please restart manually: systemctl restart overtalkerr',
'output': output,
'restart_error': str(e)
})
else:
error_output = result.stderr or result.stdout
logger.error(f"Git pull failed: {error_output}")
return jsonify({
'success': False,
'error': 'Git pull failed',
'output': error_output
}), 500
except subprocess.TimeoutExpired:
return jsonify({
'success': False,
'error': 'Update timed out'
}), 500
except Exception as e:
log_error("Update failed", e)
return jsonify({
'success': False,
'error': str(e)
}), 500
# ========================================
# ALEXA ENDPOINT
# ========================================
@app.route('/alexa', methods=['POST'])
def alexa_webhook():
"""
Alexa skill endpoint for handling voice requests.
This endpoint handles all Alexa skill requests with proper verification.
"""
try:
# Get raw request data
request_data = request.get_json()
logger.debug("Received Alexa request", extra={"request_type": request_data.get('request', {}).get('type')})
# Deserialize the JSON dict into a proper RequestEnvelope object
request_envelope = alexa_serializer.deserialize(
payload=json.dumps(request_data),
obj_type='ask_sdk_model.request_envelope.RequestEnvelope'
)
# Process with ask-sdk-python skill
response_envelope = alexa_skill.invoke(request_envelope=request_envelope, context=None)
# Serialize the ResponseEnvelope back to JSON dict
# Note: serialize() returns a dict, not a string
response_dict = alexa_serializer.serialize(response_envelope)
return jsonify(response_dict)
except Exception as e:
log_error("Error processing Alexa request", e)
return jsonify({
"version": "1.0",
"response": {
"outputSpeech": {
"type": "PlainText",
"text": "Sorry, I encountered an error processing your request."
},
"shouldEndSession": True
}
}), 500
# ========================================
# UNIVERSAL VOICE ENDPOINT
# ========================================
@app.route('/voice', methods=['POST'])
def universal_voice_webhook():
"""
Universal endpoint for Google Assistant, Siri Shortcuts, and other platforms.
Automatically detects the platform and routes appropriately.
"""
try:
request_data = request.get_json()
# Parse request using platform adapter
voice_request = router.parse_request(request_data)
if voice_request is None:
return jsonify({"error": "Could not parse request"}), 400
logger.info(f"Processing {voice_request.platform.value} request: {voice_request.intent_name}")
# Handle with unified handler
voice_response = unified_handler.route_intent(voice_request)
# Build platform-specific response
platform_response = router.build_response(voice_response, voice_request.platform)
return jsonify(platform_response)
except Exception as e:
log_error("Error processing voice request", e)
return jsonify({
"speech": "Sorry, I encountered an error processing your request."
}), 500
# ========================================
# HOME ASSISTANT ASSIST ENDPOINT
# ========================================
@app.route('/homeassistant', methods=['POST'])
def home_assistant_webhook():
"""
Home Assistant Assist endpoint for webhook-conversation integration.
This endpoint receives requests from the Home Assistant webhook-conversation
custom integration, allowing Overtalkerr to function as a conversation agent
for Home Assistant Assist.
Expected request format:
{
"conversation_id": "abc123",
"user_id": "user_xyz",
"language": "en",
"agent_id": "conversation.overtalkerr",
"query": "I want to download Inception",
"messages": [...],
"exposed_entities": {...}
}
Expected response format:
{
"output": "Response text to be spoken"
}
"""
try:
# Check if Home Assistant integration is enabled
if not Config.HA_ENABLED:
logger.warning("Received Home Assistant request but integration is disabled")
return jsonify({
"output": "Home Assistant integration is currently disabled."
}), 403
request_data = request.get_json()
# Verify authentication if webhook secret is configured
if Config.HA_WEBHOOK_SECRET:
auth_header = request.headers.get('Authorization')
if not auth_header or auth_header != f"Bearer {Config.HA_WEBHOOK_SECRET}":
logger.warning("Home Assistant request failed authentication")
return jsonify({
"output": "Authentication failed."
}), 401
logger.info(f"Received Home Assistant request: {request_data.get('query', '')}")
# Parse request using Home Assistant adapter
voice_request = router.parse_request(request_data)
if voice_request is None:
logger.error("Could not parse Home Assistant request")
return jsonify({
"output": "Sorry, I couldn't understand your request. Please try again."
}), 400
if voice_request.platform != VoiceAssistantPlatform.HOME_ASSISTANT:
logger.warning(f"Request was not detected as Home Assistant (detected as {voice_request.platform.value})")
# Continue anyway - the adapter might have worked
logger.info(f"Processing Home Assistant request - Intent: {voice_request.intent_name}, Slots: {voice_request.slots}")
# Handle with unified handler
voice_response = unified_handler.route_intent(voice_request)
# Build Home Assistant-specific response
platform_response = router.build_response(voice_response, VoiceAssistantPlatform.HOME_ASSISTANT)
logger.info(f"Sending Home Assistant response: {platform_response.get('output', '')[:100]}...")
return jsonify(platform_response)
except Exception as e:
log_error("Error processing Home Assistant request", e)
return jsonify({
"output": "Sorry, I encountered an error processing your request. Please try again later."
}), 500
# ========================================
# TEST HARNESS (Web UI)
# ========================================
def _is_local_ip(ip: str) -> bool:
"""Check if IP is from local network"""
if not ip:
return False
return (
ip.startswith('127.') or
ip == '::1' or
ip.startswith('192.168.') or
ip.startswith('10.') or
ip.startswith('172.16.') or
ip.startswith('172.17.') or
ip.startswith('172.18.') or
ip.startswith('172.19.') or
ip.startswith('172.2')
)
def _needs_auth() -> bool:
"""Check if request needs authentication"""
path = request.path or ''
if not path.startswith('/test'):
return False
# Get client IP
xff = request.headers.get('X-Forwarded-For', '')
ip = (xff.split(',')[0].strip() if xff else request.remote_addr) or ''
if _is_local_ip(ip):
return False
# Require auth only if credentials are configured
return bool(Config.BASIC_AUTH_USER and Config.BASIC_AUTH_PASS)
def _check_basic_auth() -> Optional[Response]:
"""Verify Basic Auth credentials"""
auth = request.headers.get('Authorization', '')
if not auth.startswith('Basic '):
return Response('Authentication required', 401, {'WWW-Authenticate': 'Basic realm="Test"'})
try:
raw = base64.b64decode(auth.split(' ', 1)[1]).decode('utf-8')
user, pwd = raw.split(':', 1)
except Exception:
return Response('Invalid auth header', 401, {'WWW-Authenticate': 'Basic realm="Test"'})
if user == Config.BASIC_AUTH_USER and pwd == Config.BASIC_AUTH_PASS:
return None
return Response('Unauthorized', 401, {'WWW-Authenticate': 'Basic realm="Test"'})
@app.before_request
def protect_test_endpoints():
"""Protect test endpoints with Basic Auth if configured"""
if _needs_auth():
failure = _check_basic_auth()
if failure is not None:
return failure
@app.route('/test', methods=['GET'])
def test_ui():
"""Serve test harness UI"""
return send_from_directory(STATIC_DIR, 'test_ui.html')
@app.route('/test/info', methods=['GET'])
def test_info():
"""Get environment info for test UI"""
return jsonify({
"mock": Config.MOCK_BACKEND,
"authProtected": _needs_auth(),
"platform": "multi-platform",
"version": "2.0.0"
})
@app.route('/test/start', methods=['POST'])
def test_start():
"""Start a new search (test harness)"""
data = request.get_json(silent=True) or {}
user_id = data.get('userId', 'test-user')
conv_id = data.get('conversationId') or f"test-{int(dt.datetime.utcnow().timestamp())}"
title = data.get('title', '')
year = data.get('year')
media_type_text = data.get('mediaType')
upcoming = bool(data.get('upcoming'))
season_text = data.get('season')
if not title:
return jsonify({"error": "title is required"}), 400
# Build voice request
voice_request = router.parse_request({
"platform": "siri", # Use Siri adapter for test harness
"userId": user_id,
"sessionId": conv_id,
"action": "DownloadIntent",
"parameters": {
"MediaTitle": title,
"Year": year,
"MediaType": media_type_text,
"Upcoming": "yes" if upcoming else "no",
"Season": season_text
}
})
# Handle request
voice_response = unified_handler.handle_download(voice_request)
# Load state to get result count
state = load_state(user_id, conv_id)
result = {
"userId": user_id,
"conversationId": conv_id,
"speech": voice_response.speech,
"end": voice_response.should_end_session
}
if state:
result["index"] = state.get('index', 0)
result["total"] = len(state.get('results', []))
if state.get('results'):
result["item"] = state['results'][state.get('index', 0)]
return jsonify(result)
@app.route('/test/yes', methods=['POST'])
def test_yes():
"""Confirm selection (test harness)"""
data = request.get_json(silent=True) or {}
user_id = data.get('userId', 'test-user')
conv_id = data.get('conversationId')
if not conv_id:
return jsonify({"error": "conversationId is required"}), 400
# Build voice request
voice_request = router.parse_request({
"platform": "siri",
"userId": user_id,
"sessionId": conv_id,
"action": "AMAZON.YesIntent",
"parameters": {}
})
# Handle request
voice_response = unified_handler.handle_yes(voice_request)
result = {
"speech": voice_response.speech,
"end": voice_response.should_end_session,
"conversationId": conv_id
}
# Check if request was created
if "requested" in voice_response.speech.lower() or "okay" in voice_response.speech.lower():
state = load_state(user_id, conv_id)
if state and state.get('results'):
chosen = state['results'][state.get('index', 0)]
result["requested"] = {
"mediaId": chosen.get('id'),
"mediaType": chosen.get('_mediaType')
}
return jsonify(result)
@app.route('/test/no', methods=['POST'])
def test_no():
"""Move to next result (test harness)"""
data = request.get_json(silent=True) or {}
user_id = data.get('userId', 'test-user')
conv_id = data.get('conversationId')
if not conv_id:
return jsonify({"error": "conversationId is required"}), 400
# Build voice request
voice_request = router.parse_request({
"platform": "siri",
"userId": user_id,
"sessionId": conv_id,
"action": "AMAZON.NoIntent",
"parameters": {}
})
# Handle request
voice_response = unified_handler.handle_no(voice_request)
# Load updated state
state = load_state(user_id, conv_id)
result = {
"speech": voice_response.speech,
"end": voice_response.should_end_session,
"conversationId": conv_id
}
if state:
result["index"] = state.get('index', 0)
result["total"] = len(state.get('results', []))
if state.get('results') and state.get('index', 0) < len(state['results']):
result["item"] = state['results'][state['index']]
return jsonify(result)
@app.route('/test/state', methods=['GET'])
def test_state():
"""Get conversation state (test harness)"""
user_id = request.args.get('userId', 'test-user')
conv_id = request.args.get('conversationId', '')
if not conv_id:
return jsonify({"error": "conversationId is required"}), 400
state = load_state(user_id, conv_id)
return jsonify({"state": state})
@app.route('/test/reset', methods=['POST'])
def test_reset():
"""Reset conversation state (test harness)"""
data = request.get_json(silent=True) or {}
user_id = data.get('userId', 'test-user')
conv_id = data.get('conversationId')
if not conv_id:
return jsonify({"error": "conversationId is required"}), 400
with db_session() as s:
q = s.query(SessionState).filter(
SessionState.user_id == user_id,
SessionState.conversation_id == conv_id
)
deleted = q.delete()
return jsonify({"ok": True, "deleted": deleted})
@app.route('/test/purge', methods=['POST'])
def test_purge():
"""Purge all or user-specific state (test harness)"""
data = request.get_json(silent=True) or {}
user_id = data.get('userId')
with db_session() as s:
if user_id:
q = s.query(SessionState).filter(SessionState.user_id == user_id)
deleted = q.delete()
else:
deleted = s.query(SessionState).delete()
return jsonify({"ok": True, "deleted": deleted})
# ========================================
# MAINTENANCE ENDPOINTS
# ========================================
@app.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint for monitoring"""
try:
# Check database
with db_session() as s:
count = s.query(SessionState).count()
# Check backend (if not in mock mode)
overseerr_status = "mock" if Config.MOCK_BACKEND else "connected"
if not Config.MOCK_BACKEND:
try:
# Quick connectivity check
overseerr_status = "connected" if Config.check_connectivity() else "unreachable"
except:
overseerr_status = "error"
return jsonify({
"status": "healthy",
"database": "ok",
"sessions": count,
"overseerr": overseerr_status,
"version": "2.0.0"
})
except Exception as e:
log_error("Health check failed", e)
return jsonify({
"status": "unhealthy",
"error": str(e)
}), 500
@app.route('/cleanup', methods=['POST'])
def cleanup_endpoint():
"""Manually trigger cleanup of old sessions"""
try:
hours = request.args.get('hours', Config.SESSION_TTL_HOURS, type=int)
deleted = cleanup_old_sessions(hours=hours)
logger.info(f"Cleanup completed: {deleted} sessions deleted")
return jsonify({
"ok": True,
"deleted": deleted,
"hours": hours
})
except Exception as e:
log_error("Cleanup failed", e)
return jsonify({"error": str(e)}), 500
# ========================================
# CONFIGURATION MANAGEMENT ENDPOINTS
# ========================================
@app.route('/config')
def config_ui():
"""Serve the configuration management UI"""
return send_from_directory('static', 'config_ui.html')
@app.route('/api/config', methods=['GET'])
def get_config():
"""Get current configuration (without sensitive values in production)"""
try:
from config_manager import ConfigManager
cm = ConfigManager()
config = cm.read_config()
# In production, mask sensitive values for display
if Config.FLASK_ENV == 'production':
# Show only last 4 characters of API key
if 'MEDIA_BACKEND_API_KEY' in config and config['MEDIA_BACKEND_API_KEY']:
api_key = config['MEDIA_BACKEND_API_KEY']
if len(api_key) > 8:
config['MEDIA_BACKEND_API_KEY'] = '*' * (len(api_key) - 4) + api_key[-4:]
return jsonify(config)
except Exception as e:
log_error("Failed to read config", e)
return jsonify({"error": str(e)}), 500
@app.route('/api/config', methods=['POST'])
def save_config():
"""Save configuration to .env file"""
try:
from config_manager import ConfigManager
cm = ConfigManager()
new_config = request.get_json()
if not new_config:
return jsonify({"error": "No configuration data provided"}), 400
# Clean up API key
if 'MEDIA_BACKEND_API_KEY' in new_config:
api_key = new_config['MEDIA_BACKEND_API_KEY']
# Check if API key is masked (all asterisks except last 4 chars)
# If so, restore the original value from the .env file
if api_key and api_key.startswith('*'):
# This is a masked value, restore original
existing_config = cm.read_config()
if 'MEDIA_BACKEND_API_KEY' in existing_config:
new_config['MEDIA_BACKEND_API_KEY'] = existing_config['MEDIA_BACKEND_API_KEY']
# Add base64 padding to API key if missing (Overseerr API requires it, but UI doesn't show it)
elif api_key and not api_key.endswith('='):
# Check if it looks like a base64 string that needs padding
if len(api_key) % 4 != 0:
# Add padding to make it a multiple of 4
padding = '=' * (4 - (len(api_key) % 4))
new_config['MEDIA_BACKEND_API_KEY'] = api_key + padding
logger.info(f"Added base64 padding to API key (Overseerr API requirement)")
# Validate configuration
is_valid, error_message = cm.validate_config(new_config)
if not is_valid:
return jsonify({"error": error_message}), 400
# Write configuration
cm.write_config(new_config)
logger.info("Configuration updated via web UI")
return jsonify({
"success": True,
"message": "Configuration saved. Restart the service to apply changes."
})
except Exception as e:
log_error("Failed to save config", e)
return jsonify({"error": str(e)}), 500
@app.route('/api/config/test-backend', methods=['POST'])
def test_backend_connection():
"""Test connection to media backend"""
try:
from config_manager import ConfigManager
cm = ConfigManager()
data = request.get_json()
if not data or 'url' not in data or 'apiKey' not in data:
return jsonify({"error": "URL and API key required"}), 400
result = cm.test_backend_connection(data['url'], data['apiKey'])
return jsonify(result)
except Exception as e:
log_error("Backend test failed", e)
return jsonify({
"success": False,
"error": str(e)
}), 500
@app.route('/api/config/restart', methods=['POST'])
def restart_service():
"""Restart the Overtalkerr service"""
try:
from config_manager import ConfigManager
cm = ConfigManager()
success, message = cm.restart_service()
if success:
logger.info("Service restart initiated via web UI")
return jsonify({