From 3d850ebc61780cdfafc20d3aa6925df6d60ff9e5 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Tue, 19 Aug 2025 22:38:26 +0530 Subject: [PATCH 01/19] convert hey omi to a fastapi route --- plugins/example/main.py | 4 + plugins/example/notifications/hey_omi.py | 276 ++++++++++++----------- 2 files changed, 144 insertions(+), 136 deletions(-) diff --git a/plugins/example/main.py b/plugins/example/main.py index a7f7c59bcd6..262307c4381 100644 --- a/plugins/example/main.py +++ b/plugins/example/main.py @@ -10,6 +10,7 @@ from zapier import memory_created as zapier_memory_created_router from chatgpt import main as chatgpt_router from subscription import main as subscription_router +from notifications import hey_omi # from ahda import client as ahda_realtime_transcription_router # from advanced import openglass as advanced_openglass_router @@ -71,3 +72,6 @@ def api(): # Subscription app.include_router(subscription_router.router) + +# Notifications +app.include_router(hey_omi.router) diff --git a/plugins/example/notifications/hey_omi.py b/plugins/example/notifications/hey_omi.py index a4131192d84..46592124037 100644 --- a/plugins/example/notifications/hey_omi.py +++ b/plugins/example/notifications/hey_omi.py @@ -1,4 +1,5 @@ -from flask import Flask, request, jsonify +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import JSONResponse import logging import time import os @@ -8,15 +9,19 @@ from pathlib import Path from datetime import datetime, timedelta import threading +from pydantic import BaseModel +from typing import List, Dict, Any -# Instead, set the API key directly -api_key = "PASTE_OPENAI_KEY_HERE" +api_key = os.getenv('OPENAI_API_KEY') + +if not api_key: + raise ValueError("OPENAI_API_KEY environment variable is required") print(f"API key loaded (last 4 chars): ...{api_key[-4:]}") client = OpenAI(api_key=api_key) -app = Flask(__name__) +router = APIRouter(prefix="/notifications", tags=["notifications"]) # Set up logging logging.basicConfig(level=logging.INFO) @@ -82,6 +87,15 @@ def cleanup_old_sessions(self): if os.getenv('HTTPS_PROXY'): os.environ['OPENAI_PROXY'] = os.getenv('HTTPS_PROXY') +class WebhookRequest(BaseModel): + session_id: str + segments: List[Dict[str, Any]] = [] + uid: str = None + +class WebhookResponse(BaseModel): + status: str = "success" + message: str = None + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10)) def get_openai_response(text): """Get response from OpenAI for the user's question""" @@ -106,155 +120,145 @@ def get_openai_response(text): logger.error(f"Error getting OpenAI response: {str(e)}") return "I'm sorry, I encountered an error processing your request." -@app.route('/webhook', methods=['POST']) -def webhook(): - if request.method == 'POST': - logger.info("Received webhook POST request") - data = request.json - logger.info(f"Received data: {data}") - - session_id = data.get('session_id') - uid = request.args.get('uid') - logger.info(f"Processing request for session_id: {session_id}, uid: {uid}") - - if not session_id: - logger.error("No session_id provided in request") - return jsonify({"status": "error", "message": "No session_id provided"}), 400 - - current_time = time.time() - buffer_data = message_buffer.get_buffer(session_id) - segments = data.get('segments', []) - has_processed = False - - # Add debug logging - logger.debug(f"Current buffer state for session {session_id}: {buffer_data}") - - # Only check cooldown if we have a trigger and are about to process - if buffer_data['trigger_detected'] and not buffer_data['response_sent']: - time_since_last_notification = current_time - notification_cooldowns[session_id] - if time_since_last_notification < NOTIFICATION_COOLDOWN: - logger.info(f"Cooldown active. {NOTIFICATION_COOLDOWN - time_since_last_notification:.0f}s remaining") - return jsonify({"status": "success"}), 200 +@router.post('/webhook') +async def webhook(request: WebhookRequest): + logger.info("Received webhook POST request") + logger.info(f"Received data: {request.dict()}") + + session_id = request.session_id + uid = request.uid + logger.info(f"Processing request for session_id: {session_id}, uid: {uid}") + + if not session_id: + logger.error("No session_id provided in request") + raise HTTPException(status_code=400, detail="No session_id provided") + + current_time = time.time() + buffer_data = message_buffer.get_buffer(session_id) + segments = request.segments + has_processed = False + + # Add debug logging + logger.debug(f"Current buffer state for session {session_id}: {buffer_data}") + + # Only check cooldown if we have a trigger and are about to process + if buffer_data['trigger_detected'] and not buffer_data['response_sent']: + time_since_last_notification = current_time - notification_cooldowns[session_id] + if time_since_last_notification < NOTIFICATION_COOLDOWN: + logger.info(f"Cooldown active. {NOTIFICATION_COOLDOWN - time_since_last_notification:.0f}s remaining") + return WebhookResponse(status="success") + + # Process each segment + for segment in segments: + if not segment.get('text') or has_processed: + continue + + text = segment['text'].lower().strip() + logger.info(f"Processing text segment: '{text}'") - # Process each segment - for segment in segments: - if not segment.get('text') or has_processed: - continue - - text = segment['text'].lower().strip() - logger.info(f"Processing text segment: '{text}'") + # Check for complete trigger phrases first + if any(trigger in text for trigger in [t.lower() for t in TRIGGER_PHRASES]) and not buffer_data['trigger_detected']: + logger.info(f"Complete trigger phrase detected in session {session_id}") + buffer_data['trigger_detected'] = True + buffer_data['trigger_time'] = current_time + buffer_data['collected_question'] = [] + buffer_data['response_sent'] = False + buffer_data['partial_trigger'] = False + notification_cooldowns[session_id] = current_time # Set cooldown when trigger is detected - # Check for complete trigger phrases first - if any(trigger in text for trigger in [t.lower() for t in TRIGGER_PHRASES]) and not buffer_data['trigger_detected']: - logger.info(f"Complete trigger phrase detected in session {session_id}") - buffer_data['trigger_detected'] = True - buffer_data['trigger_time'] = current_time - buffer_data['collected_question'] = [] - buffer_data['response_sent'] = False - buffer_data['partial_trigger'] = False - notification_cooldowns[session_id] = current_time # Set cooldown when trigger is detected - - # Extract any question part that comes after the trigger - question_part = text.split('omi,')[-1].strip() if 'omi,' in text.lower() else '' - if question_part: - buffer_data['collected_question'].append(question_part) - logger.info(f"Collected question part from trigger: {question_part}") + # Extract any question part that comes after the trigger + question_part = text.split('omi,')[-1].strip() if 'omi,' in text.lower() else '' + if question_part: + buffer_data['collected_question'].append(question_part) + logger.info(f"Collected question part from trigger: {question_part}") + continue + + # Check for partial triggers + if not buffer_data['trigger_detected']: + # Check for first part of trigger + if any(text.endswith(part.lower()) for part in PARTIAL_FIRST): + logger.info(f"First part of trigger detected in session {session_id}") + buffer_data['partial_trigger'] = True + buffer_data['partial_trigger_time'] = current_time continue - # Check for partial triggers - if not buffer_data['trigger_detected']: - # Check for first part of trigger - if any(text.endswith(part.lower()) for part in PARTIAL_FIRST): - logger.info(f"First part of trigger detected in session {session_id}") - buffer_data['partial_trigger'] = True - buffer_data['partial_trigger_time'] = current_time - continue - - # Check for second part if we're waiting for it - if buffer_data['partial_trigger']: - time_since_partial = current_time - buffer_data['partial_trigger_time'] - if time_since_partial <= 2.0: # 2 second window to complete the trigger - if any(part.lower() in text.lower() for part in PARTIAL_SECOND): - logger.info(f"Complete trigger detected across segments in session {session_id}") - buffer_data['trigger_detected'] = True - buffer_data['trigger_time'] = current_time - buffer_data['collected_question'] = [] - buffer_data['response_sent'] = False - buffer_data['partial_trigger'] = False - - # Extract any question part that comes after "omi" - question_part = text.split('omi,')[-1].strip() if 'omi,' in text.lower() else '' - if question_part: - buffer_data['collected_question'].append(question_part) - logger.info(f"Collected question part from second trigger part: {question_part}") - continue - else: - # Reset partial trigger if too much time has passed + # Check for second part if we're waiting for it + if buffer_data['partial_trigger']: + time_since_partial = current_time - buffer_data['partial_trigger_time'] + if time_since_partial <= 2.0: # 2 second window to complete the trigger + if any(part.lower() in text.lower() for part in PARTIAL_SECOND): + logger.info(f"Complete trigger detected across segments in session {session_id}") + buffer_data['trigger_detected'] = True + buffer_data['trigger_time'] = current_time + buffer_data['collected_question'] = [] + buffer_data['response_sent'] = False buffer_data['partial_trigger'] = False + + # Extract any question part that comes after "omi" + question_part = text.split('omi,')[-1].strip() if 'omi,' in text.lower() else '' + if question_part: + buffer_data['collected_question'].append(question_part) + logger.info(f"Collected question part from second trigger part: {question_part}") + continue + else: + # Reset partial trigger if too much time has passed + buffer_data['partial_trigger'] = False + + # If trigger was detected, collect the question + if buffer_data['trigger_detected'] and not buffer_data['response_sent'] and not has_processed: + time_since_trigger = current_time - buffer_data['trigger_time'] + logger.info(f"Time since trigger: {time_since_trigger} seconds") + + if time_since_trigger <= QUESTION_AGGREGATION_TIME: + buffer_data['collected_question'].append(text) + logger.info(f"Collecting question part: {text}") + logger.info(f"Current collected question: {' '.join(buffer_data['collected_question'])}") - # If trigger was detected, collect the question - if buffer_data['trigger_detected'] and not buffer_data['response_sent'] and not has_processed: - time_since_trigger = current_time - buffer_data['trigger_time'] - logger.info(f"Time since trigger: {time_since_trigger} seconds") + # Check if we should process the question + should_process = ( + (time_since_trigger > QUESTION_AGGREGATION_TIME and buffer_data['collected_question']) or + (buffer_data['collected_question'] and '?' in text) or + (time_since_trigger > QUESTION_AGGREGATION_TIME * 1.5) + ) + + if should_process and buffer_data['collected_question']: + # Process question and send response + full_question = ' '.join(buffer_data['collected_question']).strip() + if not full_question.endswith('?'): + full_question += '?' - if time_since_trigger <= QUESTION_AGGREGATION_TIME: - buffer_data['collected_question'].append(text) - logger.info(f"Collecting question part: {text}") - logger.info(f"Current collected question: {' '.join(buffer_data['collected_question'])}") + logger.info(f"Processing complete question: {full_question}") + response = get_openai_response(full_question) + logger.info(f"Got response from OpenAI: {response}") - # Check if we should process the question - should_process = ( - (time_since_trigger > QUESTION_AGGREGATION_TIME and buffer_data['collected_question']) or - (buffer_data['collected_question'] and '?' in text) or - (time_since_trigger > QUESTION_AGGREGATION_TIME * 1.5) - ) + # Reset all states + buffer_data['trigger_detected'] = False + buffer_data['trigger_time'] = 0 + buffer_data['collected_question'] = [] + buffer_data['response_sent'] = True + buffer_data['partial_trigger'] = False + has_processed = True - if should_process and buffer_data['collected_question']: - # Process question and send response - full_question = ' '.join(buffer_data['collected_question']).strip() - if not full_question.endswith('?'): - full_question += '?' - - logger.info(f"Processing complete question: {full_question}") - response = get_openai_response(full_question) - logger.info(f"Got response from OpenAI: {response}") - - # Reset all states - buffer_data['trigger_detected'] = False - buffer_data['trigger_time'] = 0 - buffer_data['collected_question'] = [] - buffer_data['response_sent'] = True - buffer_data['partial_trigger'] = False - has_processed = True - - return jsonify({"message": response}), 200 - - # Return success if no response needed - return jsonify({"status": "success"}), 200 + return WebhookResponse(status="success", message=response) + + # Return success if no response needed + return WebhookResponse(status="success") -@app.route('/webhook/setup-status', methods=['GET']) -def setup_status(): +@router.get('/webhook/setup-status') +async def setup_status(): try: # Always return true for setup status - return jsonify({ - "is_setup_completed": True - }), 200 + return {"is_setup_completed": True} except Exception as e: logger.error(f"Error checking setup status: {str(e)}") - return jsonify({ - "is_setup_completed": False, - "error": str(e) - }), 500 + raise HTTPException(status_code=500, detail=str(e)) -@app.route('/status', methods=['GET']) -def status(): - return jsonify({ +@router.get('/status') +async def status(): + return { "active_sessions": len(message_buffer.buffers), "uptime": time.time() - start_time - }) + } # Add at the top of the file with other globals start_time = time.time() - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=True) From 0e98dca8a786b0459c05d86665a81b5a34c5cdb1 Mon Sep 17 00:00:00 2001 From: Thinh Date: Wed, 20 Aug 2025 15:53:08 +0700 Subject: [PATCH 02/19] introduce subscription launch date for credit usage (#2826) * introduce subscription launch date for credit usage * inform users after 15m of silence to prevent burning through their credits * Record usages every 60s instead of 30s --- .../dev_omi_backend_listen_values.yaml | 2 + .../prod_omi_backend_listen_values.yaml | 4 +- backend/database/redis_db.py | 13 +++-- backend/database/user_usage.py | 15 ++++++ backend/routers/transcribe.py | 29 +++++++++-- backend/routers/users.py | 4 +- backend/utils/llm/notifications.py | 27 ++++++++-- backend/utils/notifications.py | 49 ++++++++++++++++++- backend/utils/subscription.py | 28 ++++++++++- 9 files changed, 155 insertions(+), 16 deletions(-) diff --git a/backend/charts/backend-listen/dev_omi_backend_listen_values.yaml b/backend/charts/backend-listen/dev_omi_backend_listen_values.yaml index 4ccb07924d8..01eb698baf5 100644 --- a/backend/charts/backend-listen/dev_omi_backend_listen_values.yaml +++ b/backend/charts/backend-listen/dev_omi_backend_listen_values.yaml @@ -231,6 +231,8 @@ env: value: "price_1RrxXL1F8wnoWYvwIddzR902" - name: STRIPE_UNLIMITED_ANNUAL_PRICE_ID value: "price_1RrxXL1F8wnoWYvw3kDbWmjs" + - name: SUBSCRIPTION_LAUNCH_DATE + value: "2025-08-21" resources: # We usually recommend not to specify default resources and to leave this as a conscious diff --git a/backend/charts/backend-listen/prod_omi_backend_listen_values.yaml b/backend/charts/backend-listen/prod_omi_backend_listen_values.yaml index d3fca1e7904..63229c08805 100644 --- a/backend/charts/backend-listen/prod_omi_backend_listen_values.yaml +++ b/backend/charts/backend-listen/prod_omi_backend_listen_values.yaml @@ -255,7 +255,7 @@ env: name: prod-omi-backend-secrets key: ENCRYPTION_SECRET - name: BASIC_TIER_MINUTES_LIMIT_PER_MONTH - value: "1000000" + value: "1200" - name: BASIC_TIER_WORDS_TRANSCRIBED_LIMIT_PER_MONTH value: "0" - name: BASIC_TIER_INSIGHTS_GAINED_LIMIT_PER_MONTH @@ -266,6 +266,8 @@ env: value: "price_1RtJPm1F8wnoWYvwhVJ38kLb" - name: STRIPE_UNLIMITED_ANNUAL_PRICE_ID value: "price_1RtJQ71F8wnoWYvwKMPaGlGY" + - name: SUBSCRIPTION_LAUNCH_DATE + value: "2025-08-21" resources: # We usually recommend not to specify default resources and to leave this as a conscious diff --git a/backend/database/redis_db.py b/backend/database/redis_db.py index 9307d66ad9c..51599782206 100644 --- a/backend/database/redis_db.py +++ b/backend/database/redis_db.py @@ -526,9 +526,6 @@ def delete_cached_mcp_api_key(hashed_key: str): r.delete(f'mcp_api_key:{hashed_key}') - - - # ****************************************************** # **************** DATA MIGRATION STATUS *************** # ****************************************************** @@ -577,3 +574,13 @@ def set_credit_limit_notification_sent(uid: str, ttl: int = 60 * 60 * 24): def has_credit_limit_notification_been_sent(uid: str) -> bool: """Check if credit limit notification was already sent to user recently""" return r.exists(f'users:{uid}:credit_limit_notification_sent') + + +def set_silent_user_notification_sent(uid: str, ttl: int = 60 * 60 * 24): + """Cache that silent user notification was sent to user (24 hours TTL by default)""" + r.set(f'users:{uid}:silent_notification_sent', '1', ex=ttl) + + +def has_silent_user_notification_been_sent(uid: str) -> bool: + """Check if silent user notification was already sent to user recently""" + return r.exists(f'users:{uid}:silent_notification_sent') diff --git a/backend/database/user_usage.py b/backend/database/user_usage.py index 96c8e0d258c..41100234ef0 100644 --- a/backend/database/user_usage.py +++ b/backend/database/user_usage.py @@ -100,6 +100,21 @@ def get_monthly_usage_stats(uid: str, date: datetime) -> dict: return _aggregate_stats(query) +def get_monthly_usage_stats_since(uid: str, date: datetime, start_date: datetime) -> dict: + """Aggregates hourly usage stats for a given month from Firestore, starting from a specific date.""" + user_ref = db.collection('users').document(uid) + hourly_usage_collection = user_ref.collection('hourly_usage') + + start_doc_id = f'{start_date.year}-{start_date.month:02d}-{start_date.day:02d}-00' + + query = ( + hourly_usage_collection.where(filter=FieldFilter('year', '==', date.year)) + .where(filter=FieldFilter('month', '==', date.month)) + .where(filter=FieldFilter('id', '>=', start_doc_id)) + ) + return _aggregate_stats(query) + + def get_yearly_usage_stats(uid: str, date: datetime) -> dict: """Aggregates hourly usage stats for a given year from Firestore.""" user_ref = db.collection('users').document(uid) diff --git a/backend/routers/transcribe.py b/backend/routers/transcribe.py index 42358dc171f..22d1fc000ed 100644 --- a/backend/routers/transcribe.py +++ b/backend/routers/transcribe.py @@ -18,6 +18,7 @@ import database.users as user_db from database import redis_db from database.redis_db import get_cached_user_geolocation +from models.users import PlanType from models.conversation import ( Conversation, TranscriptSegment, @@ -61,7 +62,7 @@ from utils.other import endpoints as auth from utils.other.storage import get_profile_audio_if_exists -from utils.notifications import send_credit_limit_notification +from utils.notifications import send_credit_limit_notification, send_silent_user_notification router = APIRouter() @@ -134,14 +135,18 @@ async def _listen( first_audio_byte_timestamp: Optional[float] = None last_usage_record_timestamp: Optional[float] = None words_transcribed_since_last_record: int = 0 + last_transcript_time: Optional[float] = None async def _record_usage_periodically(): nonlocal websocket_active, last_usage_record_timestamp, words_transcribed_since_last_record + nonlocal last_audio_received_time, last_transcript_time + while websocket_active: - await asyncio.sleep(30) + await asyncio.sleep(60) if not websocket_active: break + # Record usages if last_usage_record_timestamp: current_time = time.time() transcription_seconds = int(current_time - last_usage_record_timestamp) @@ -153,8 +158,8 @@ async def _record_usage_periodically(): record_usage(uid, transcription_seconds=transcription_seconds, words_transcribed=words_to_record) last_usage_record_timestamp = current_time + # Send credit limit notification if not has_transcription_credits(uid): - # Send credit limit notification (with Redis caching to prevent spam) try: await send_credit_limit_notification(uid) except Exception as e: @@ -165,6 +170,21 @@ async def _record_usage_periodically(): websocket_active = False break + # Silence notification logic for basic plan users + user_subscription = user_db.get_user_valid_subscription(uid) + if not user_subscription or user_subscription.plan == PlanType.basic: + time_of_last_words = last_transcript_time or first_audio_byte_timestamp + if ( + last_audio_received_time + and time_of_last_words + and (last_audio_received_time - time_of_last_words) > 15 * 60 + ): + print(f"User {uid} has been silent for over 15 minutes. Sending notification.") + try: + await send_silent_user_notification(uid) + except Exception as e: + print(f"Error sending silent user notification: {e}") + async def _asend_message_event(msg: MessageEvent): nonlocal websocket_active print(f"Message: type ${msg.event_type}", uid) @@ -747,7 +767,7 @@ async def translate(segments: List[TranscriptSegment], conversation_id: str): async def stream_transcript_process(): nonlocal websocket_active, realtime_segment_buffers, realtime_photo_buffers, websocket, seconds_to_trim - nonlocal current_conversation_id, including_combined_segments, translation_enabled, speech_profile_processed, speaker_to_person_map, suggested_segments, words_transcribed_since_last_record + nonlocal current_conversation_id, including_combined_segments, translation_enabled, speech_profile_processed, speaker_to_person_map, suggested_segments, words_transcribed_since_last_record, last_transcript_time while websocket_active or len(realtime_segment_buffers) > 0 or len(realtime_photo_buffers) > 0: await asyncio.sleep(0.6) @@ -766,6 +786,7 @@ async def stream_transcript_process(): transcript_segments = [] if segments_to_process: + last_transcript_time = time.time() if seconds_to_trim is None: seconds_to_trim = segments_to_process[0]["start"] diff --git a/backend/routers/users.py b/backend/routers/users.py index bb9d676823d..61b0238496b 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -35,7 +35,7 @@ from models.users import WebhookType, UserSubscriptionResponse, SubscriptionPlan, PlanType, PricingOption from utils.apps import get_available_app_by_id -from utils.subscription import get_plan_limits, get_plan_features +from utils.subscription import get_plan_limits, get_plan_features, get_monthly_usage_for_subscription from utils import stripe as stripe_utils from utils.llm.followup import followup_question_prompt from utils.other import endpoints as auth @@ -489,7 +489,7 @@ def get_user_subscription_endpoint(uid: str = Depends(auth.get_current_user_uid) subscription.features = get_plan_features(subscription.plan) # Get current usage - usage = user_usage_db.get_monthly_usage_stats(uid, datetime.utcnow()) + usage = get_monthly_usage_for_subscription(uid) # Calculate usage metrics transcription_seconds_used = usage.get('transcription_seconds', 0) diff --git a/backend/utils/llm/notifications.py b/backend/utils/llm/notifications.py index 724702b95a3..bd1a861ed21 100644 --- a/backend/utils/llm/notifications.py +++ b/backend/utils/llm/notifications.py @@ -1,3 +1,4 @@ +import random from typing import Tuple, List from .clients import llm_medium from database.memories import get_memories @@ -96,7 +97,7 @@ async def generate_credit_limit_notification(uid: str, name: str) -> Tuple[str, Key Points to Include: - They've been actively using transcription (show appreciation) - Unlimited plan removes all limits - - Can check usage/plans in app or search 'omi unlimited subs' in marketplace + - Can check usage/plans in the app under Settings > Plan & Usages - Make it feel like you're helping them, not selling to them """ @@ -109,7 +110,7 @@ async def generate_credit_limit_notification(uid: str, name: str) -> Tuple[str, The message should: - Acknowledge their active usage positively - - Suggest checking plans in the app or searching 'omi unlimited subs' in marketplace + - Suggest checking plans in the app under Settings > Plan & Usages - Feel helpful, not sales-y - Be warm and personal to {name} @@ -125,5 +126,25 @@ async def generate_credit_limit_notification(uid: str, name: str) -> Tuple[str, # Fallback message return ( "omi", - f"Hey {name}! You've been actively using transcription - that's awesome! You've hit your limit, but unlimited plans remove all restrictions. Check your usage in the app or search 'omi unlimited subs' in the marketplace!", + f"Hey {name}! You've been actively using transcription - that's awesome! You've hit your limit, but unlimited plans remove all restrictions. You can check your usage and upgrade in the app under Settings > Plan & Usages.", ) + + +def generate_silent_user_notification(name: str) -> Tuple[str, str]: + """ + Generate a funny notification for a user who has been silent for a while. + """ + messages = [ + f"Hey {name}, just checking in! My ears are open if you've got something to say.", + f"Is this thing on? Tapping my mic here, {name}. Let me know when you're ready to chat!", + f"Quiet on the set! {name}, are we rolling? Just waiting for your cue.", + f"The sound of silence... is nice, but I'm here for the words, {name}! What's on your mind?", + f"{name}, you've gone quiet! Just a heads up, I'm still here listening and using up your free minutes.", + f"Psst, {name}... My virtual ears are getting a little lonely. Anything to share?", + f"Enjoying the quiet time, {name}? Just remember, I'm on the clock, ready to transcribe!", + f"Hello from the other side... of silence! {name}, ready to talk again?", + f"I'm all ears, {name}! Just letting you know the recording is still live.", + f"Silence is golden, but words are what I live for, {name}! Let's chat when you're ready.", + ] + body = random.choice(messages) + return "omi", body diff --git a/backend/utils/notifications.py b/backend/utils/notifications.py index dd89290b391..fb61a1c16bb 100644 --- a/backend/utils/notifications.py +++ b/backend/utils/notifications.py @@ -2,8 +2,17 @@ import math from firebase_admin import messaging, auth import database.notifications as notification_db -from database.redis_db import set_credit_limit_notification_sent, has_credit_limit_notification_been_sent -from .llm.notifications import generate_notification_message, generate_credit_limit_notification +from database.redis_db import ( + set_credit_limit_notification_sent, + has_credit_limit_notification_been_sent, + set_silent_user_notification_sent, + has_silent_user_notification_been_sent, +) +from .llm.notifications import ( + generate_notification_message, + generate_credit_limit_notification, + generate_silent_user_notification, +) def send_notification(token: str, title: str, body: str, data: dict = None): @@ -86,6 +95,42 @@ async def send_credit_limit_notification(user_id: str): print(f"Credit limit notification sent to user {user_id}") +async def send_silent_user_notification(user_id: str): + """Send a notification if a basic-plan user is silent for too long.""" + # Check if notification was sent recently (within 24 hours) + if has_silent_user_notification_been_sent(user_id): + print(f"Silent user notification already sent recently for user {user_id}") + return + + # Get user's notification token + token = notification_db.get_token_only(user_id) + if not token: + print(f"No notification token found for user {user_id}") + return + + # Get user name from Firebase Auth + try: + user = auth.get_user(user_id) + name = user.display_name + if not name and user.email: + name = user.email.split('@')[0].capitalize() + if not name: + name = "there" + except Exception as e: + print(f"Error getting user info from Firebase Auth: {e}") + name = "there" + + # Generate personalized credit limit message + title, body = generate_silent_user_notification(name) + + # Send notification + send_notification(token, title, body) + + # Cache that notification was sent (24 hours TTL) + set_silent_user_notification_sent(user_id) + print(f"Silent user notification sent to user {user_id}") + + async def send_bulk_notification(user_tokens: list, title: str, body: str): try: batch_size = 500 diff --git a/backend/utils/subscription.py b/backend/utils/subscription.py index 1f8fff1c011..7e9abe3190d 100644 --- a/backend/utils/subscription.py +++ b/backend/utils/subscription.py @@ -87,6 +87,32 @@ def get_plan_features(plan: PlanType) -> List[str]: ] +def get_monthly_usage_for_subscription(uid: str) -> dict: + """ + Gets the current monthly usage for subscription purposes, considering the launch date from env variables. + The launch date format is expected to be YYYY-MM-DD. + If the launch date is not set, not valid, or in the future, usage is considered zero. + """ + subscription_launch_date_str = os.getenv('SUBSCRIPTION_LAUNCH_DATE') + if not subscription_launch_date_str: + # Subscription not launched, so no usage is counted against limits. + return {} + + try: + # Use strptime to enforce YYYY-MM-DD format + launch_date = datetime.strptime(subscription_launch_date_str, '%Y-%m-%d') + except ValueError: + # Invalid date format, treat as not launched. + return {} + + now = datetime.utcnow() + if now < launch_date: + # Launch date is in the future, so no usage is counted yet. + return {} + + return user_usage_db.get_monthly_usage_stats_since(uid, now, launch_date) + + def has_transcription_credits(uid: str) -> bool: """ Checks if a user has transcribing credits by verifying their valid subscription and usage. @@ -95,7 +121,7 @@ def has_transcription_credits(uid: str) -> bool: if not subscription: return False - usage = user_usage_db.get_monthly_usage_stats(uid, datetime.utcnow()) + usage = get_monthly_usage_for_subscription(uid) limits = get_plan_limits(subscription.plan) # Check transcription seconds (0 means unlimited) From 49d777b565bf8e77176171ea6803f4522f06a94e Mon Sep 17 00:00:00 2001 From: Thinh Date: Thu, 21 Aug 2025 12:48:24 +0700 Subject: [PATCH 03/19] Enhance the UI/UX of the chatgpt plugin (#2829) --- plugins/example/templates/chatgpt/index.html | 151 ++++++++++++++----- 1 file changed, 117 insertions(+), 34 deletions(-) diff --git a/plugins/example/templates/chatgpt/index.html b/plugins/example/templates/chatgpt/index.html index fdb391eef36..324adb40249 100644 --- a/plugins/example/templates/chatgpt/index.html +++ b/plugins/example/templates/chatgpt/index.html @@ -7,12 +7,13 @@