diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml new file mode 100644 index 00000000..b7b169af --- /dev/null +++ b/.github/workflows/ci-pr.yml @@ -0,0 +1,32 @@ +name: CI (Pull Requests) + +on: + pull_request: + branches: + - dev + - main + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: uv sync --extra dev + + - name: Run Ruff lint + run: uv run ruff check . + + - name: Run PyTest + run: uv run pytest --cov=src/loglife/app --cov-report=term-missing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..cf973d85 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI (Feature Branches) + +on: + push: + branches: + - "feature/**" # Run CI only for feature branches + +jobs: + ci: + if: github.event.pull_request == '' # ā— RUN ONLY if this push is NOT part of a PR + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: uv sync --extra dev + + - name: Run Ruff lint + run: uv run ruff check . + + - name: Run PyTest + run: uv run pytest --cov=src/loglife/app --cov-report=term-missing diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 00000000..00774c41 --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,46 @@ +name: Deploy to Server (Dev) + +on: + push: + branches: [ "main", "dev" ] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Setup SSH + uses: webfactory/ssh-agent@v0.7.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + # ========================= + # šŸš€ DEPLOY STAGING (DEV) + # ========================= + - name: Deploy to DEV Server + if: github.ref == 'refs/heads/dev' + run: | + ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} " + # =========== PULL LATEST CODE =========== + cd /home/ali/loglife-dev && + git pull && + + # =========== RESTART BACKEND SERVICES =========== + sudo systemctl restart loglife-dev && + sudo systemctl restart loglife-db-dev.service && + + # =========== DEPLOY WHATSAPP CLIENT =========== + cd /home/ali/loglife-dev/whatsapp-client && + npm ci --only=production && + screen -S loglife-dev -X quit || true && + screen -S loglife-dev -dm node index.js && + + # =========== BUILD MKDOCS & DEPLOY DOCS =========== + cd /home/ali/loglife-dev && + uv run mkdocs build && + sudo cp -r /home/ali/loglife-dev/site/* /var/www/docs.loglife.co/ && + sudo systemctl reload nginx + " diff --git a/src/loglife/app/config/__init__.py b/src/loglife/app/config/__init__.py index 0d22503d..ec53d666 100644 --- a/src/loglife/app/config/__init__.py +++ b/src/loglife/app/config/__init__.py @@ -25,13 +25,13 @@ WELCOME_MESSAGE, ) from .paths import ACCESS_LOG, DATABASE_FILE, ERROR_LOG, LOGS, SCHEMA_FILE +from .prompts import OPENAI_SUMMARIZATION_SYSTEM_PROMPT from .secrets import ASSEMBLYAI_API_KEY, OPENAI_API_KEY from .settings import ( COMMAND_ALIASES, DEFAULT_GOAL_EMOJI, FLASK_ENV, OPENAI_CHAT_MODEL, - OPENAI_SUMMARIZATION_SYSTEM_PROMPT, STYLE, ) diff --git a/src/loglife/app/config/messages.py b/src/loglife/app/config/messages.py index 50d691c4..216c40d8 100644 --- a/src/loglife/app/config/messages.py +++ b/src/loglife/app/config/messages.py @@ -39,36 +39,36 @@ # ----------------------------- # Help Messages # ----------------------------- -HELP_MESSAGE = """```LogLife Commands: +HELP_MESSAGE = """ā“ *LogLife Commands* -šŸ“‹ GOALS -• goals - Show your personal goals -• add goal 😓 Description - Add new goal -• enable journaling - Quick add journaling goal -• delete [number] - Delete a goal -• update [number] [time] - Update reminder time +šŸ“‹ *GOALS* +• `goals` - Show your personal goals +• `add goal 😓 Description` - Add new goal +• `enable journaling` - Quick add journaling goal +• `delete [number]` - Delete a goal +• `update [number] [time]` - Update reminder time -šŸ“Š TRACKING -• rate 2 3 - Rate goal #2 with rating 3 (1=fail, 2=partial, 3=success) -• 31232 - Rate all goals at once +šŸ“Š *TRACKING* +• `rate 2 3` - Rate goal #2 with rating 3 (1=fail, 2=partial, 3=success) +• `31232` - Rate all goals at once -šŸ“ˆ VIEWING -• week - Show week summary -• lookback 7 - Show last 7 days (or any number) +šŸ“ˆ *VIEWING* +• `week` - Show week summary +• `lookback 7` - Show last 7 days (or any number) -āš™ļø SETTINGS -• on transcript - Get text files with audio transcripts -• off transcript - Only get summary (no files) +āš™ļø *SETTINGS* +• `on transcript` - Get text files with audio transcripts +• `off transcript` - Only get summary (no files) -ā“ HELP -• help - Show this help message +ā“ *HELP* +• `help` - Show this help message -Examples: -• add goal šŸƒ Exercise daily -• rate 1 3 (rate first goal as success) -• lookback 3 (show last 3 days) -• delete 2 (delete goal #2) -• update 1 8pm (change goal #1 reminder to 8pm)```""" +*Examples:* +• `add goal šŸƒ Exercise daily` +• `rate 1 3` (rate first goal as success) +• `lookback 3` (show last 3 days) +• `delete 2` (delete goal #2) +• `update 1 8pm` (change goal #1 reminder to 8pm)""" # ----------------------------- # Referral Messages @@ -89,11 +89,11 @@ # ----------------------------- # Error Messages # ----------------------------- -ERROR_NO_GOALS_SET = "āŒ You don't have any goals yet. Add one with 'add goal 😓 Description'" +ERROR_NO_GOALS_SET = "āŒ You don't have any goals yet. Add one with `add goal 😓 Description`" ERROR_INVALID_INPUT_LENGTH = "āŒ Invalid input. Send digits." -ERROR_INVALID_GOAL_NUMBER = "Invalid goal number. Type 'goals' to see your goals." -ERROR_INVALID_DELETE_FORMAT = "Invalid format. Usage: delete [goal number]\nExample: delete 1" -ERROR_INVALID_UPDATE_FORMAT = "Usage: update [goal number] [time]\nExample: update 1 8pm" +ERROR_INVALID_GOAL_NUMBER = "Invalid goal number. Type `goals` to see your goals." +ERROR_INVALID_DELETE_FORMAT = "Invalid format. Usage: `delete [goal number]`\nExample: `delete 1`" +ERROR_INVALID_UPDATE_FORMAT = "Usage: `update [goal number] [time]`\nExample: `update 1 8pm`" ERROR_INVALID_TIME_FORMAT = "Invalid time format. Try: 8pm, 9:30am, 20:00" ERROR_ADD_GOAL_FIRST = "Please add a goal first." @@ -106,7 +106,7 @@ SUCCESS_RATINGS_SUBMITTED = "šŸ“… \n : " SUCCESS_INDIVIDUAL_RATING = "šŸ“… \n : " SUCCESS_GOAL_ADDED = "Goal Added successfully! When you would like to be reminded?" -SUCCESS_JOURNALING_ENABLED = "āœ… You already have a journaling goal! Check 'goals' to see it." +SUCCESS_JOURNALING_ENABLED = "āœ… You already have a journaling goal! Check `goals` to see it." SUCCESS_GOAL_DELETED = "āœ… Goal deleted: {goal_emoji} {goal_description}" SUCCESS_REMINDER_UPDATED = ( "āœ… Reminder updated! I'll remind you at {display_time} for {goal_emoji} {goal_desc}" @@ -121,7 +121,7 @@ # ----------------------------- # Lookback Summary Messages # ----------------------------- -LOOKBACK_NO_GOALS = "```No goals set. Use 'add goal 😓 Description' to add goals.```" +LOOKBACK_NO_GOALS = "No goals set. Use `add goal 😓 Description` to add goals." # ----------------------------- # Reminder Messages @@ -149,3 +149,27 @@ You can reply with a voice note. šŸ’­""" + + +# ------------------- +# New Centralized Messages +# ------------------- + +# From handlers.py +ERROR_GOAL_NOT_FOUND = "Goal not found." +SUCCESS_REMINDER_SET = ( + "Got it! I'll remind you daily at {display_time} for {goal_emoji} {goal_desc}." +) +GOALS_LIST_TIPS = ( + "\n\nšŸ’” _Tips:_\n" + "_Update reminders with `update [goal#] [time]`_\n" + "_Delete goals with `delete [goal#]`_" +) +ERROR_INVALID_TRANSCRIPT_CMD = "Invalid command. Usage: `transcript [on|off]`" + +# From processor.py +ERROR_TEXT_PROCESSOR = "Error in text processor: {exc}" +ERROR_WRONG_COMMAND = "Wrong command!" + +# From services/reminder/worker.py +REMINDER_UNTRACKED_HEADER = "- *Did you complete the goals?*\n" diff --git a/src/loglife/app/config/prompts.py b/src/loglife/app/config/prompts.py new file mode 100644 index 00000000..39984f5b --- /dev/null +++ b/src/loglife/app/config/prompts.py @@ -0,0 +1,11 @@ +"""Prompts for LLM interactions.""" + +OPENAI_SUMMARIZATION_SYSTEM_PROMPT = ( + "You are a compassionate journal summarization assistant. " + "Your task is to create concise, meaningful summaries of users' audio " + "journal entries. Focus on key events, emotions, achievements, challenges, " + "and any goals or action items mentioned. Keep the summary in first person " + "perspective, maintain a supportive tone, and highlight important insights. " + "Aim for 2-4 sentences that capture the essence of the entry." +) + diff --git a/src/loglife/app/config/settings.py b/src/loglife/app/config/settings.py index 1d22b09f..6fccdba7 100644 --- a/src/loglife/app/config/settings.py +++ b/src/loglife/app/config/settings.py @@ -1,17 +1,10 @@ """Application settings and configuration constants.""" -FLASK_ENV = "development" # development or production +import os -OPENAI_CHAT_MODEL = "gpt-5.1" +FLASK_ENV = os.getenv("FLASK_ENV", "development") # development or production -OPENAI_SUMMARIZATION_SYSTEM_PROMPT = ( - "You are a compassionate journal summarization assistant. " - "Your task is to create concise, meaningful summaries of users' audio " - "journal entries. Focus on key events, emotions, achievements, challenges, " - "and any goals or action items mentioned. Keep the summary in first person " - "perspective, maintain a supportive tone, and highlight important insights. " - "Aim for 2-4 sentences that capture the essence of the entry." -) +OPENAI_CHAT_MODEL = "gpt-5.1" DEFAULT_GOAL_EMOJI = "šŸŽÆ" @@ -24,3 +17,9 @@ COMMAND_ALIASES = { "journal now": "journal prompts", } + +SQLITE_WEB_URL = ( + "https://test.loglife.co/database/" + if FLASK_ENV == "production" + else "http://127.0.0.1:8080/" +) diff --git a/src/loglife/app/db/client.py b/src/loglife/app/db/client.py index c8977f50..025c8141 100644 --- a/src/loglife/app/db/client.py +++ b/src/loglife/app/db/client.py @@ -29,7 +29,11 @@ def __init__(self, db_path: Path = DATABASE_FILE) -> None: def conn(self) -> sqlite3.Connection: """Lazy loading of the database connection.""" if self._conn is None: - self._conn = sqlite3.connect(self.db_path, check_same_thread=False) + self._conn = sqlite3.connect( + self.db_path, + check_same_thread=False, + isolation_level=None, + ) self._conn.row_factory = sqlite3.Row # Enforce foreign keys self._conn.execute("PRAGMA foreign_keys = ON") diff --git a/src/loglife/app/db/sqlite.py b/src/loglife/app/db/sqlite.py index 08f27baa..4e0281b8 100644 --- a/src/loglife/app/db/sqlite.py +++ b/src/loglife/app/db/sqlite.py @@ -12,7 +12,11 @@ def connect() -> sqlite3.Connection: objects (so you can access columns by name), and hands back that ready-to-use connection. """ - conn: sqlite3.Connection = sqlite3.connect(DATABASE_FILE, check_same_thread=False) + conn: sqlite3.Connection = sqlite3.connect( + DATABASE_FILE, + check_same_thread=False, + isolation_level=None, + ) conn.row_factory = sqlite3.Row return conn diff --git a/src/loglife/app/logic/audio/processor.py b/src/loglife/app/logic/audio/processor.py index dbdd041c..dbe8cd22 100644 --- a/src/loglife/app/logic/audio/processor.py +++ b/src/loglife/app/logic/audio/processor.py @@ -16,20 +16,26 @@ logger = logging.getLogger(__name__) -def process_audio(sender: str, user: User, audio_data: str) -> str | tuple[str, str]: +def process_audio( + sender: str, + user: User, + audio_data: str, + client_type: str = "whatsapp", +) -> str | tuple[str, str]: """Process an incoming audio message from a user. Arguments: sender: The WhatsApp phone number of the sender user: The user record dictionary audio_data: Base64 encoded audio payload + client_type: The client type to send responses to (default: "whatsapp") Returns: The summarized text generated from the audio, or a tuple of (transcript_file_base64, summarized_text). """ - queue_async_message(sender, "Audio received. Transcribing...", client_type="whatsapp") + queue_async_message(sender, "Audio received. Transcribing...", client_type=client_type) try: try: @@ -41,7 +47,11 @@ def process_audio(sender: str, user: User, audio_data: str) -> str | tuple[str, if not transcript.strip(): return "Transcription was empty." - queue_async_message(sender, "Audio transcribed. Summarizing...", client_type="whatsapp") + queue_async_message( + sender, + "Audio transcribed. Summarizing...", + client_type=client_type, + ) try: summary: str = summarize_transcript(transcript) @@ -54,7 +64,11 @@ def process_audio(sender: str, user: User, audio_data: str) -> str | tuple[str, transcription_text=transcript, summary_text=summary, ) - queue_async_message(sender, "Summary stored in Database.", client_type="whatsapp") + queue_async_message( + sender, + "Summary stored in Database.", + client_type=client_type, + ) if user.send_transcript_file: transcript_file: str = transcript_to_base64(transcript) diff --git a/src/loglife/app/logic/router.py b/src/loglife/app/logic/router.py index 4560f37a..1c3bdaec 100644 --- a/src/loglife/app/logic/router.py +++ b/src/loglife/app/logic/router.py @@ -4,7 +4,6 @@ based on the message type. """ - import logging from typing import Any @@ -33,7 +32,12 @@ def route_message(message: Message) -> None: if message.msg_type == "chat": response = process_text(user, message.raw_payload) elif message.msg_type in {"audio", "ptt"}: - audio_response = process_audio(message.sender, user, message.raw_payload) + audio_response = process_audio( + message.sender, + user, + message.raw_payload, + client_type=message.client_type or "whatsapp", + ) if isinstance(audio_response, tuple): transcript_file, response = audio_response attachments = {"transcript_file": transcript_file} diff --git a/src/loglife/app/logic/text/handlers.py b/src/loglife/app/logic/text/handlers.py index c1ecfb7f..05d948b2 100644 --- a/src/loglife/app/logic/text/handlers.py +++ b/src/loglife/app/logic/text/handlers.py @@ -116,7 +116,7 @@ def handle(self, user: User, _message: str) -> str | None: if goals_not_tracked_today: return messages.JOURNAL_REMINDER_MESSAGE.replace( "", - "- *Did you complete the goals?*\n" + messages.REMINDER_UNTRACKED_HEADER + "\n".join([f"- {goal.goal_description}" for goal in goals_not_tracked_today]), ) @@ -152,7 +152,8 @@ def handle(self, user: User, message: str) -> str | None: db.goals.delete(goal.id) return messages.SUCCESS_GOAL_DELETED.format( - goal_emoji=goal.goal_emoji, goal_description=goal.goal_description, + goal_emoji=goal.goal_emoji, + goal_description=goal.goal_description, ) @@ -188,11 +189,15 @@ def handle(self, user: User, message: str) -> str | None: # Get the goal to display in confirmation goal: Goal | None = db.goals.get(goal_id) if not goal: - return "Goal not found." + return messages.ERROR_GOAL_NOT_FOUND goal_desc = goal.goal_description goal_emoji = goal.goal_emoji - return f"Got it! I'll remind you daily at {display_time} for {goal_emoji} {goal_desc}." + return messages.SUCCESS_REMINDER_SET.format( + display_time=display_time, + goal_emoji=goal_emoji, + goal_desc=goal_desc, + ) class GoalsListHandler(TextCommandHandler): @@ -229,11 +234,7 @@ def handle(self, user: User, _message: str) -> str | None: goal_lines.append(f"{i}. {goal_emoji} {goal_desc} (boost {boost}) {time_display}") response = "```" + "\n".join(goal_lines) + "```" - response += ( - "\n\nšŸ’” _Tips:_\n" - "_Update reminders with `update [goal#] [time]`_\n" - "_Delete goals with `delete [goal#]`_" - ) + response += messages.GOALS_LIST_TIPS return response @@ -274,7 +275,9 @@ def handle(self, user: User, message: str) -> str | None: goal_emoji = goal.goal_emoji goal_desc = goal.goal_description return messages.SUCCESS_REMINDER_UPDATED.format( - display_time=display_time, goal_emoji=goal_emoji, goal_desc=goal_desc, + display_time=display_time, + goal_emoji=goal_emoji, + goal_desc=goal_desc, ) @@ -296,7 +299,7 @@ def handle(self, user: User, message: str) -> str | None: if "off" in message: db.users.update(user_id, send_transcript_file=0) return messages.SUCCESS_TRANSCRIPT_DISABLED - return "Invalid command. Usage: transcript [on|off]" + return messages.ERROR_INVALID_TRANSCRIPT_CMD class WeekSummaryHandler(TextCommandHandler): diff --git a/src/loglife/app/logic/text/processor.py b/src/loglife/app/logic/text/processor.py index da2ac77e..c49ae216 100644 --- a/src/loglife/app/logic/text/processor.py +++ b/src/loglife/app/logic/text/processor.py @@ -3,7 +3,7 @@ import logging import re -from loglife.app.config import COMMAND_ALIASES +from loglife.app.config import COMMAND_ALIASES, messages from loglife.app.db.tables import User from loglife.app.logic.text.handlers import ( AddGoalHandler, @@ -77,6 +77,6 @@ def process_text(user: User, message: str) -> str: except Exception as exc: logger.exception("Error in text processor") - return f"Error in text processor: {exc}" + return messages.ERROR_TEXT_PROCESSOR.format(exc=exc) - return "Wrong command!" + return messages.ERROR_WRONG_COMMAND diff --git a/src/loglife/app/logic/text/week.py b/src/loglife/app/logic/text/week.py index 65be8111..e803c6b9 100644 --- a/src/loglife/app/logic/text/week.py +++ b/src/loglife/app/logic/text/week.py @@ -1,6 +1,5 @@ """Weekly look-back text helpers for summaries.""" - from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING diff --git a/src/loglife/app/logic/timezone.py b/src/loglife/app/logic/timezone.py index e0e1aa1b..98affc2b 100644 --- a/src/loglife/app/logic/timezone.py +++ b/src/loglife/app/logic/timezone.py @@ -1,6 +1,5 @@ """Utilities for working with user timezones based on phone numbers.""" - from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import phonenumbers diff --git a/src/loglife/app/services/__init__.py b/src/loglife/app/services/__init__.py index e7b7ad0e..4719ee47 100644 --- a/src/loglife/app/services/__init__.py +++ b/src/loglife/app/services/__init__.py @@ -1,7 +1,7 @@ """Services package.""" -from loglife.core.messaging import log_queue, send_message +from loglife.core.messaging import send_message from .reminder import start_reminder_service -__all__ = ["log_queue", "send_message", "start_reminder_service"] +__all__ = ["send_message", "start_reminder_service"] diff --git a/src/loglife/app/services/reminder/worker.py b/src/loglife/app/services/reminder/worker.py index e14a0bd7..1347e2f9 100644 --- a/src/loglife/app/services/reminder/worker.py +++ b/src/loglife/app/services/reminder/worker.py @@ -3,13 +3,16 @@ Runs a minutely job to check if any user reminders are due for their timezone. """ - import logging import threading import time from datetime import UTC, datetime -from loglife.app.config import JOURNAL_REMINDER_MESSAGE, REMINDER_MESSAGE +from loglife.app.config import ( + JOURNAL_REMINDER_MESSAGE, + REMINDER_MESSAGE, + messages, +) from loglife.app.db import db from loglife.app.db.tables import Goal, User from loglife.app.logic.timezone import get_timezone_safe @@ -54,7 +57,7 @@ def _build_journal_reminder_message(user_id: int) -> str: return JOURNAL_REMINDER_MESSAGE.replace("\n\n", "") goals_list = "\n".join([f"- {goal.goal_description}" for goal in goals_not_tracked]) - replacement = f"- *Did you complete the goals?*\n{goals_list}" + replacement = f"{messages.REMINDER_UNTRACKED_HEADER}{goals_list}" return JOURNAL_REMINDER_MESSAGE.replace("", replacement) @@ -62,7 +65,8 @@ def _build_journal_reminder_message(user_id: int) -> str: def _build_standard_reminder_message(goal: Goal) -> str: """Construct a standard reminder message for a specific goal.""" return REMINDER_MESSAGE.replace("", goal.goal_emoji).replace( - "", goal.goal_description, + "", + goal.goal_description, ) diff --git a/src/loglife/core/interface.py b/src/loglife/core/interface.py index f43f29da..d4457d70 100644 --- a/src/loglife/core/interface.py +++ b/src/loglife/core/interface.py @@ -56,4 +56,3 @@ def send_msg(message: Message | str, to: str | None = None) -> None: queue_async_message(to, message) else: enqueue_outbound_message(message) - diff --git a/src/loglife/core/messaging.py b/src/loglife/core/messaging.py index 15603020..a84c09c0 100644 --- a/src/loglife/core/messaging.py +++ b/src/loglife/core/messaging.py @@ -1,10 +1,11 @@ """Messaging module - unified interface for message handling.""" +import json import logging -from collections.abc import Callable, Mapping +from collections.abc import Callable, Generator, Mapping from dataclasses import dataclass, field from queue import Empty, Queue -from threading import Thread +from threading import Lock, Thread from typing import Any import requests @@ -16,6 +17,7 @@ # --- Message Class --- + @dataclass(slots=True) class Message: """Normalized representation of transport messages.""" @@ -38,21 +40,56 @@ def from_payload(cls, payload: Mapping[str, Any]) -> "Message": metadata=dict(payload.get("metadata") or {}), ) + +# --- Log Broadcaster --- + + +class LogBroadcaster: + """Broadcasts logs to multiple listeners (SSE clients).""" + + def __init__(self) -> None: + """Initialize the log broadcaster.""" + self._listeners: set[Queue[str]] = set() + self._lock = Lock() + + def publish(self, message: str) -> None: + """Send a message to all active listeners.""" + with self._lock: + for q in self._listeners: + q.put(message) + + def listen(self) -> Generator[str, None, None]: + """Yield messages for a single listener.""" + q: Queue[str] = Queue() + with self._lock: + self._listeners.add(q) + try: + while True: + msg = q.get() + yield msg + finally: + with self._lock: + if q in self._listeners: + self._listeners.remove(q) + + # --- Globals --- # Defined after Message class to avoid forward reference issues _inbound_queue: Queue[Message] = Queue() _outbound_queue: Queue[Message] = Queue() -log_queue: Queue[str] = Queue() # For streaming logs to emulator +log_broadcaster = LogBroadcaster() _router_worker_started = False _sender_worker_started = False # --- Inbound / Receiver Logic --- + def enqueue_inbound_message(message: Message) -> None: """Place an inbound message onto the queue for processing.""" _inbound_queue.put(message) + def start_message_worker(handler: Callable[[Message], None]) -> None: """Spin up a daemon thread that consumes inbound messages.""" global _router_worker_started # noqa: PLW0603 @@ -77,16 +114,20 @@ def _worker() -> None: Thread(target=_worker, daemon=True, name="router-worker").start() _router_worker_started = True + # --- Outbound / Sender Logic --- + def enqueue_outbound_message(message: Message) -> None: """Place a message onto the outbound queue.""" _outbound_queue.put(message) + def get_outbound_message(timeout: float | None = 0.1) -> Message: """Retrieve the next message destined for outbound transports.""" return _outbound_queue.get(timeout=timeout) + def build_outbound_message( number: str, text: str, @@ -105,6 +146,7 @@ def build_outbound_message( attachments=attachments or {}, ) + def start_sender_worker() -> None: """Start a daemon worker that drains the outbound queue.""" global _sender_worker_started # noqa: PLW0603 @@ -122,7 +164,9 @@ def _worker() -> None: break try: - logger.debug("Dispatching outbound message to %s via %s", message.sender, message.client_type) + logger.debug( + "Dispatching outbound message to %s via %s", message.sender, message.client_type + ) _dispatch_outbound(message) except Exception: logger.exception("Failed to deliver outbound message to %s", message.sender) @@ -130,20 +174,42 @@ def _worker() -> None: Thread(target=_worker, daemon=True, name="sender-worker").start() _sender_worker_started = True + def _dispatch_outbound(message: Message) -> None: client = message.client_type or "whatsapp" logger.debug("Dispatching to client type: %s", client) if client == "emulator": - _send_emulator_message(message.raw_payload) + _send_emulator_message(message.raw_payload, attachments=message.attachments) else: - _send_whatsapp_message(message.sender, message.raw_payload) + _send_whatsapp_message( + message.sender, message.raw_payload, attachments=message.attachments + ) -def _send_emulator_message(message: str) -> None: + +def _send_emulator_message(message: str, attachments: dict[str, Any] | None = None) -> None: logger.info("Sending emulator message: %s", message) - log_queue.put(message) -def _send_whatsapp_message(number: str, message: str) -> None: + if attachments and "transcript_file" in attachments: + try: + data = json.dumps({ + "text": message, + "transcript_file": attachments["transcript_file"] + }) + log_broadcaster.publish(data) + except Exception: + logger.exception("Failed to serialize emulator message") + log_broadcaster.publish(message) + else: + log_broadcaster.publish(message) + + +def _send_whatsapp_message( + number: str, message: str, attachments: dict[str, Any] | None = None +) -> None: payload = {"number": number, "message": message} + if attachments: + payload["attachments"] = attachments + headers = {"Content-Type": "application/json"} try: requests.post(WHATSAPP_API_URL, json=payload, headers=headers, timeout=30) @@ -152,6 +218,7 @@ def _send_whatsapp_message(number: str, message: str) -> None: logger.exception(error) raise RuntimeError(error) from exc + def queue_async_message( number: str, message: str, @@ -170,6 +237,7 @@ def queue_async_message( ) enqueue_outbound_message(outbound) + def send_message( number: str, message: str, @@ -186,7 +254,9 @@ def send_message( _send_whatsapp_message(number, message) else: logger.info( - "Queueing async message for %s (client_type=%s)", number, target_client or "unknown", + "Queueing async message for %s (client_type=%s)", + number, + target_client or "unknown", ) queue_async_message( number, diff --git a/src/loglife/core/routes/emulator/routes.py b/src/loglife/core/routes/emulator/routes.py index 254cb01c..b4b5ea50 100644 --- a/src/loglife/core/routes/emulator/routes.py +++ b/src/loglife/core/routes/emulator/routes.py @@ -7,7 +7,8 @@ from flask import Blueprint, Response, render_template -from loglife.core.messaging import log_queue +from loglife.app.config.settings import SQLITE_WEB_URL +from loglife.core.messaging import log_broadcaster emulator_bp = Blueprint( "emulator", @@ -20,7 +21,7 @@ @emulator_bp.route("/") def emulator() -> str: """Render the emulator HTML interface.""" - return render_template("emulator.html") + return render_template("emulator.html", db_url=SQLITE_WEB_URL) @emulator_bp.route("/events") @@ -28,8 +29,10 @@ def events() -> Response: """Stream realtime log events to the browser via SSE.""" def stream() -> Generator[str, None, None]: - while True: - msg = log_queue.get() - yield f"data: {msg}\n\n" + # Listen yields messages from the broadcaster + for msg in log_broadcaster.listen(): + # Handle multiline messages for SSE + formatted_msg = msg.replace("\n", "\ndata: ") + yield f"data: {formatted_msg}\n\n" return Response(stream(), mimetype="text/event-stream") diff --git a/src/loglife/core/routes/emulator/templates/emulator.html b/src/loglife/core/routes/emulator/templates/emulator.html index 59a0e0d4..2ee8ff66 100644 --- a/src/loglife/core/routes/emulator/templates/emulator.html +++ b/src/loglife/core/routes/emulator/templates/emulator.html @@ -66,6 +66,124 @@ font-size: 18px; } + .nav-actions { + display: flex; + align-items: center; + gap: 12px; + } + + .btn-minimal { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.7); + padding: 6px 12px; + font-size: 12px; + border-radius: var(--radius); + text-decoration: none; + font-weight: 600; + transition: all 0.2s ease; + height: 28px; + display: flex; + align-items: center; + } + + .btn-minimal:hover { + background: rgba(255, 255, 255, 0.05); + color: #ffffff; + border-color: rgba(255, 255, 255, 0.3); + } + + .btn-like-input { + width: 100%; + border: 1.5px solid var(--border); + background: #0D0226; + color: var(--text); + border-radius: var(--radius); + padding: 10px 12px; + font-size: 14px; + text-decoration: none; + display: flex; + align-items: center; + justify-content: center; + transition: border-color .2s, box-shadow .2s; + cursor: pointer; + } + + .btn-like-input:hover { + border-color: var(--primary); + box-shadow: 0 0 0 6px var(--ring); + color: var(--text); + } + + .preset-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 350px; + overflow-y: auto; + padding-right: 4px; + } + + .preset-list::-webkit-scrollbar { + width: 4px; + } + + .preset-list::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.25); + border-radius: 999px; + } + + .preset-item { + display: flex; + gap: 6px; + width: 100%; + } + + .preset-fill-btn { + flex: 1; + background: #0D0226; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; + color: var(--text); + text-align: left; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + } + + .preset-send-btn { + width: 36px; + background: #0D0226; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--muted); + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + } + + .preset-fill-btn:hover { + border-color: var(--primary); + background: rgba(59, 130, 246, 0.1); + color: #ffffff; + } + + .preset-send-btn:hover { + border-color: var(--primary); + background: rgba(59, 130, 246, 0.1); + color: var(--primary); + } + .container { max-width: 1280px; margin: 0 auto; @@ -231,7 +349,7 @@ .message-user { align-self: flex-end; - background: #1e40af; + background: #2ea043; color: #ffffff; } @@ -515,6 +633,8 @@
LogLife Emulator
+ @@ -534,30 +654,77 @@ + +
+
+
Database
+
+
+ +
+
+
Quick Presets
-
- - +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
@@ -575,6 +742,9 @@
Send a message to start chatting...
+