879 lines
32 KiB
Python
879 lines
32 KiB
Python
|
||
|
||
import asyncio
|
||
import re
|
||
from datetime import datetime, timedelta, timezone
|
||
from bot.command_registry import register_module
|
||
import ai.parser as ai_parser
|
||
import pytz
|
||
|
||
|
||
def _get_nearest_scheduled_time(times, user_tz=None):
|
||
"""Find the nearest scheduled time in user's timezone.
|
||
|
||
Args:
|
||
times: List of time strings like ["08:00", "20:00"]
|
||
user_tz: pytz timezone object or offset in minutes
|
||
|
||
Returns the time as HH:MM string, or None if no times provided.
|
||
"""
|
||
if not times:
|
||
return None
|
||
|
||
# Get current time in user's timezone
|
||
if user_tz is None:
|
||
# Default to UTC if no timezone provided
|
||
user_tz = timezone.utc
|
||
elif isinstance(user_tz, int):
|
||
# If user_tz is an offset in minutes, convert to timezone object.
|
||
# The int format is: positive = behind UTC (e.g. 480 = UTC-8).
|
||
# Python's timezone offset is: positive = ahead of UTC.
|
||
# So we negate the input to get the correct standard offset.
|
||
user_tz = timezone(timedelta(minutes=-user_tz))
|
||
|
||
now = datetime.now(user_tz)
|
||
now_minutes = now.hour * 60 + now.minute
|
||
|
||
# Find the time closest to now (within ±4 hours window)
|
||
best_time = None
|
||
best_diff = float("inf")
|
||
window_minutes = 4 * 60 # 4 hour window
|
||
|
||
for time_str in times:
|
||
try:
|
||
# Parse time string (e.g., "12:00")
|
||
hour, minute = map(int, time_str.split(":"))
|
||
time_minutes = hour * 60 + minute
|
||
|
||
# Calculate difference
|
||
diff = abs(time_minutes - now_minutes)
|
||
|
||
# Only consider times within the window
|
||
if diff <= window_minutes and diff < best_diff:
|
||
best_diff = diff
|
||
best_time = time_str
|
||
except (ValueError, AttributeError):
|
||
continue
|
||
|
||
# If no time within window, use the most recent past time
|
||
if best_time is None:
|
||
# Find the most recent past time
|
||
past_times = []
|
||
for time_str in times:
|
||
try:
|
||
hour, minute = map(int, time_str.split(":"))
|
||
time_minutes = hour * 60 + minute
|
||
|
||
# If time is in the past today, calculate minutes since then
|
||
if time_minutes <= now_minutes:
|
||
past_times.append((time_minutes, time_str))
|
||
else:
|
||
# If time is in the future, consider it as yesterday's time
|
||
past_times.append((time_minutes - 24 * 60, time_str))
|
||
except (ValueError, AttributeError):
|
||
continue
|
||
|
||
if past_times:
|
||
# Find the time closest to now (most recent past)
|
||
past_times.sort()
|
||
best_time = past_times[-1][1]
|
||
|
||
# Fallback: if still no time found, use the first scheduled time
|
||
if not best_time and times:
|
||
best_time = times[0]
|
||
|
||
return best_time
|
||
|
||
|
||
async def _get_user_timezone(message, session, token):
|
||
"""Get user's timezone offset. Returns offset_minutes or None if not set."""
|
||
# Check if user has timezone set in preferences
|
||
resp, status = api_request("get", "/api/preferences", token)
|
||
if status == 200 and resp:
|
||
offset = resp.get("timezone_offset")
|
||
if offset is not None:
|
||
return offset
|
||
|
||
# No timezone set
|
||
return None
|
||
|
||
|
||
def _parse_timezone(user_input):
|
||
"""Parse timezone string to offset in minutes.
|
||
|
||
Examples:
|
||
"UTC-8" -> 480 (8 hours behind UTC)
|
||
"-8" -> 480
|
||
"PST" -> 480
|
||
"EST" -> 300
|
||
"+5:30" -> -330
|
||
|
||
Returns offset in minutes (positive = behind UTC) or None if invalid.
|
||
"""
|
||
user_input = user_input.strip().upper()
|
||
|
||
# Common timezone abbreviations
|
||
tz_map = {
|
||
"PST": 480,
|
||
"PDT": 420,
|
||
"MST": 420,
|
||
"MDT": 360,
|
||
"CST": 360,
|
||
"CDT": 300,
|
||
"EST": 300,
|
||
"EDT": 240,
|
||
"GMT": 0,
|
||
"UTC": 0,
|
||
}
|
||
|
||
if user_input in tz_map:
|
||
return tz_map[user_input]
|
||
|
||
# Try to parse offset format
|
||
# Remove UTC prefix if present
|
||
if user_input.startswith("UTC"):
|
||
user_input = user_input[3:]
|
||
|
||
# Match patterns like -8, +5, -5:30, +5:30
|
||
match = re.match(r"^([+-])?(\d+)(?::(\d+))?$", user_input)
|
||
if match:
|
||
sign = -1 if match.group(1) == "-" else 1
|
||
hours = int(match.group(2))
|
||
minutes = int(match.group(3)) if match.group(3) else 0
|
||
|
||
# Convert to offset minutes (positive = behind UTC)
|
||
# If user says UTC-8, they're 8 hours BEHIND UTC, so offset is +480
|
||
offset_minutes = (hours * 60 + minutes) * sign * -1
|
||
return offset_minutes
|
||
|
||
return None
|
||
|
||
|
||
async def _get_scheduled_time_from_context(message, med_name):
|
||
"""Fetch recent messages and extract scheduled time from medication reminder.
|
||
|
||
Looks for bot messages in the last 10 messages that match the pattern:
|
||
"Time to take {med_name} (...) · HH:MM"
|
||
|
||
Returns the scheduled time string (e.g., "12:00") or None if not found.
|
||
"""
|
||
try:
|
||
print(
|
||
f"[DEBUG] Looking for reminder for '{med_name}' in channel history",
|
||
flush=True,
|
||
)
|
||
# Get last 10 messages from channel history (increased from 5)
|
||
async for msg in message.channel.history(limit=10):
|
||
# Skip the current message
|
||
if msg.id == message.id:
|
||
continue
|
||
|
||
# Check if this is a bot message
|
||
if not msg.author.bot:
|
||
continue
|
||
|
||
content = msg.content
|
||
print(f"[DEBUG] Checking bot message: {content[:100]}...", flush=True)
|
||
|
||
# Look for reminder pattern: "Time to take {med_name} (...) · HH:MM"
|
||
# First try exact match
|
||
pattern = rf"Time to take {re.escape(med_name)}.*?·\s*(\d{{1,2}}:\d{{2}})"
|
||
match = re.search(pattern, content, re.IGNORECASE)
|
||
|
||
if match:
|
||
scheduled_time = match.group(1)
|
||
print(f"[DEBUG] Found scheduled time: {scheduled_time}", flush=True)
|
||
return scheduled_time
|
||
|
||
# If no exact match, try to extract any medication name from reminder
|
||
# Pattern: "Time to take (name) (dosage) · time"
|
||
general_pattern = r"Time to take\s+(\w+)\s+\(.*?\)\s+·\s*(\d{1,2}:\d{2})"
|
||
general_match = re.search(general_pattern, content, re.IGNORECASE)
|
||
if general_match:
|
||
reminder_med_name = general_match.group(1)
|
||
# Check if the names match (case insensitive, or one contains the other)
|
||
if (
|
||
med_name.lower() in reminder_med_name.lower()
|
||
or reminder_med_name.lower() in med_name.lower()
|
||
):
|
||
scheduled_time = general_match.group(2)
|
||
print(
|
||
f"[DEBUG] Found scheduled time via partial match: {scheduled_time}",
|
||
flush=True,
|
||
)
|
||
return scheduled_time
|
||
|
||
print(f"[DEBUG] No reminder found for '{med_name}'", flush=True)
|
||
|
||
except Exception as e:
|
||
print(f"[DEBUG] Error in _get_scheduled_time_from_context: {e}", flush=True)
|
||
|
||
return None
|
||
|
||
|
||
async def handle_medication(message, session, parsed):
|
||
token = session["token"]
|
||
user_uuid = session["user_uuid"]
|
||
|
||
# --- PENDING CONFIRMATION HANDLER ---
|
||
# Check if we are waiting for a response (e.g., timezone, yes/no confirmation)
|
||
pending = session.get("pending_confirmations", {})
|
||
|
||
# 1. Handle Pending Timezone
|
||
if "timezone" in pending:
|
||
user_response = message.content.strip()
|
||
offset = _parse_timezone(user_response)
|
||
|
||
if offset is not None:
|
||
# Save to API
|
||
resp, status = api_request(
|
||
"put", "/api/preferences", token, {"timezone_offset": offset}
|
||
)
|
||
if status == 200:
|
||
# Retrieve the stored action context
|
||
prev_context = pending["timezone"]
|
||
del session["pending_confirmations"]["timezone"]
|
||
|
||
await message.channel.send(f"✅ Timezone set successfully.")
|
||
|
||
# Restore the previous action so we can resume it
|
||
# We merge the context into 'parsed' so the logic below continues correctly
|
||
parsed = prev_context
|
||
else:
|
||
await message.channel.send("Error saving timezone. Please try again.")
|
||
return
|
||
else:
|
||
await message.channel.send(
|
||
"I didn't understand that timezone format. Please say something like:\n"
|
||
'- "UTC-8" or "-8" for Pacific Time\n'
|
||
'- "UTC+1" or "+1" for Central European Time\n'
|
||
'- "PST", "EST", "CST", "MST" for US timezones'
|
||
)
|
||
return
|
||
|
||
# 2. Handle Generic Yes/No Confirmations (Add/Delete meds)
|
||
# Find keys that look like confirmations (excluding timezone)
|
||
confirm_keys = [k for k in pending if k.startswith("med_")]
|
||
if confirm_keys:
|
||
text = message.content.strip().lower()
|
||
key = confirm_keys[0] # Handle one confirmation at a time
|
||
stored_action = pending[key]
|
||
|
||
if text in ["yes", "y", "confirm"]:
|
||
del session["pending_confirmations"][key]
|
||
# Resume the action with confirmation bypassed
|
||
parsed = stored_action
|
||
# Force needs_confirmation to False just in case
|
||
parsed["needs_confirmation"] = False
|
||
|
||
elif text in ["no", "n", "cancel"]:
|
||
del session["pending_confirmations"][key]
|
||
await message.channel.send("Okay, cancelled.")
|
||
return
|
||
else:
|
||
# User said something else, remind them
|
||
await message.channel.send(
|
||
"I'm waiting for a confirmation. Please reply **yes** to proceed or **no** to cancel."
|
||
)
|
||
return
|
||
|
||
# --- MAIN ACTION HANDLER ---
|
||
action = parsed.get("action", "unknown")
|
||
|
||
if action == "list":
|
||
resp, status = api_request("get", "/api/medications", token)
|
||
if status == 200:
|
||
meds = resp if isinstance(resp, list) else []
|
||
if not meds:
|
||
await message.channel.send("You don't have any medications yet.")
|
||
else:
|
||
lines = [
|
||
f"- **{m['name']}**: {m['dosage']} {m['unit']} ({m.get('frequency', 'n/a')})"
|
||
for m in meds
|
||
]
|
||
await message.channel.send("**Your medications:**\n" + "\n".join(lines))
|
||
else:
|
||
await message.channel.send(
|
||
f"Error: {resp.get('error', 'Failed to fetch medications')}"
|
||
)
|
||
|
||
elif action == "add":
|
||
name = parsed.get("name")
|
||
dosage = parsed.get("dosage")
|
||
unit = parsed.get("unit", "mg")
|
||
frequency = parsed.get("frequency", "daily")
|
||
times = parsed.get("times", ["08:00"])
|
||
days_of_week = parsed.get("days_of_week", [])
|
||
interval_days = parsed.get("interval_days")
|
||
needs_confirmation = parsed.get("needs_confirmation", False)
|
||
confirmation_prompt = parsed.get("confirmation_prompt")
|
||
|
||
if not name or not dosage:
|
||
await message.channel.send("Please provide medication name and dosage.")
|
||
return
|
||
|
||
# Handle confirmation
|
||
if needs_confirmation:
|
||
# Store pending action in session for confirmation
|
||
if "pending_confirmations" not in session:
|
||
session["pending_confirmations"] = {}
|
||
|
||
confirmation_id = f"med_add_{name}"
|
||
session["pending_confirmations"][confirmation_id] = {
|
||
"action": "add",
|
||
"name": name,
|
||
"dosage": dosage,
|
||
"unit": unit,
|
||
"frequency": frequency,
|
||
"times": times,
|
||
"days_of_week": days_of_week,
|
||
"interval_days": interval_days,
|
||
}
|
||
|
||
schedule_desc = _format_schedule(
|
||
frequency, times, days_of_week, interval_days
|
||
)
|
||
await message.channel.send(
|
||
f"{confirmation_prompt}\n\n"
|
||
f"**Details:**\n"
|
||
f"- Name: {name}\n"
|
||
f"- Dosage: {dosage} {unit}\n"
|
||
f"- Schedule: {schedule_desc}\n\n"
|
||
f"Reply **yes** to confirm, or **no** to cancel."
|
||
)
|
||
return
|
||
|
||
await _add_medication(
|
||
message,
|
||
token,
|
||
name,
|
||
dosage,
|
||
unit,
|
||
frequency,
|
||
times,
|
||
days_of_week,
|
||
interval_days,
|
||
)
|
||
|
||
elif action == "take":
|
||
med_id = parsed.get("medication_id")
|
||
name = parsed.get("name")
|
||
|
||
med_id, name, found = await _find_medication_by_name(
|
||
message, token, med_id, name
|
||
)
|
||
if not found:
|
||
return
|
||
|
||
if not med_id:
|
||
await message.channel.send("Which medication did you take?")
|
||
return
|
||
|
||
# Check if we have user's timezone
|
||
timezone_offset = await _get_user_timezone(message, session, token)
|
||
|
||
if timezone_offset is None:
|
||
# Need to ask for timezone first
|
||
if "pending_confirmations" not in session:
|
||
session["pending_confirmations"] = {}
|
||
|
||
# Store that we're waiting for timezone, along with the med info
|
||
session["pending_confirmations"]["timezone"] = {
|
||
"action": "take",
|
||
"medication_id": med_id,
|
||
"name": name,
|
||
}
|
||
|
||
await message.channel.send(
|
||
"📍 I need to know your timezone to track medications correctly.\n\n"
|
||
'What timezone are you in? (e.g., "UTC-8", "PST", "EST", "+1")'
|
||
)
|
||
return
|
||
|
||
# Try to get scheduled time from recent reminder context
|
||
scheduled_time = await _get_scheduled_time_from_context(message, name)
|
||
|
||
# If not found in context, calculate from medication schedule
|
||
if not scheduled_time:
|
||
# Get medication details to find scheduled times
|
||
med_resp, med_status = api_request(
|
||
"get", f"/api/medications/{med_id}", token
|
||
)
|
||
if med_status == 200 and med_resp:
|
||
times = med_resp.get("times", [])
|
||
scheduled_time = _get_nearest_scheduled_time(times, timezone_offset)
|
||
|
||
# Build request body with scheduled_time if found
|
||
request_body = {}
|
||
if scheduled_time:
|
||
request_body["scheduled_time"] = scheduled_time
|
||
|
||
resp, status = api_request(
|
||
"post", f"/api/medications/{med_id}/take", token, request_body
|
||
)
|
||
if status == 201:
|
||
if scheduled_time:
|
||
await message.channel.send(
|
||
f"✅ Logged **{name}** for {scheduled_time}! Great job staying on track."
|
||
)
|
||
else:
|
||
await message.channel.send(
|
||
f"Logged **{name}**! Great job staying on track."
|
||
)
|
||
else:
|
||
await message.channel.send(f"Error: {resp.get('error', 'Failed to log')}")
|
||
|
||
elif action == "take_all":
|
||
# Auto-mark doses due within the last hour (clearly "just taken").
|
||
# Ask about doses due 1–6 hours ago (today or yesterday) that aren't logged.
|
||
timezone_offset = await _get_user_timezone(message, session, token)
|
||
if timezone_offset is None:
|
||
timezone_offset = 0
|
||
|
||
now_local = datetime.now(timezone(timedelta(minutes=-timezone_offset)))
|
||
now_min = now_local.hour * 60 + now_local.minute
|
||
current_hhmm = now_local.strftime("%H:%M")
|
||
|
||
CURRENT_WINDOW_MIN = 60 # ≤ 1 hour ago → auto-mark
|
||
LOOKBACK_MIN = 6 * 60 # 1–6 hours ago → ask
|
||
|
||
resp, status = api_request("get", "/api/medications/today", token)
|
||
if status != 200:
|
||
await message.channel.send("Error fetching today's medications.")
|
||
return
|
||
|
||
meds_today = resp if isinstance(resp, list) else []
|
||
auto_doses = [] # (med_id, med_name, time_str) → mark silently
|
||
ask_doses = [] # (med_id, med_name, time_str) → prompt user
|
||
|
||
for item in meds_today:
|
||
med = item.get("medication", {})
|
||
if item.get("is_prn"):
|
||
continue
|
||
times = item.get("scheduled_times", [])
|
||
taken = set(item.get("taken_times", []))
|
||
skipped = set(item.get("skipped_times", []))
|
||
med_id_local = med.get("id")
|
||
med_name = med.get("name", "Unknown")
|
||
|
||
for t in times:
|
||
if t in taken or t in skipped:
|
||
continue
|
||
|
||
h, m = map(int, t.split(":"))
|
||
dose_min = h * 60 + m
|
||
# Handle doses that cross midnight (yesterday's late doses)
|
||
minutes_ago = now_min - dose_min
|
||
if minutes_ago < 0:
|
||
minutes_ago += 24 * 60
|
||
|
||
if minutes_ago > LOOKBACK_MIN:
|
||
continue # too old — ignore
|
||
if t > current_hhmm and minutes_ago > CURRENT_WINDOW_MIN:
|
||
continue # future dose that somehow wasn't caught — skip
|
||
|
||
if minutes_ago <= CURRENT_WINDOW_MIN:
|
||
auto_doses.append((med_id_local, med_name, t))
|
||
else:
|
||
ask_doses.append((med_id_local, med_name, t))
|
||
|
||
# Mark the clearly-current doses immediately
|
||
marked = []
|
||
for med_id_local, med_name, t in auto_doses:
|
||
api_request("post", f"/api/medications/{med_id_local}/take", token, {"scheduled_time": t})
|
||
marked.append(f"**{med_name}** at {t}")
|
||
|
||
if marked:
|
||
lines = "\n".join(f"✅ {m}" for m in marked)
|
||
await message.channel.send(f"Logged as taken:\n{lines}")
|
||
|
||
# Ask about doses from 1–6 hours ago that weren't logged
|
||
if ask_doses:
|
||
if "pending_confirmations" not in session:
|
||
session["pending_confirmations"] = {}
|
||
session["pending_confirmations"]["med_past_due_check"] = {
|
||
"action": "take_all_past_confirm",
|
||
"interaction_type": "medication",
|
||
"needs_confirmation": False,
|
||
"doses": [[mid, name, t] for mid, name, t in ask_doses],
|
||
}
|
||
dose_lines = "\n".join(f"- **{name}** at {t}" for _, name, t in ask_doses)
|
||
await message.channel.send(
|
||
f"❓ Also found unlogged doses from the past 6 hours:\n{dose_lines}\n\n"
|
||
f"Did you take these too? Reply **yes** or **no**."
|
||
)
|
||
elif not marked:
|
||
await message.channel.send("✅ No past-due medications to log right now.")
|
||
|
||
elif action == "take_all_past_confirm":
|
||
# Handles yes-confirmation for past-due doses surfaced by take_all
|
||
doses = parsed.get("doses", [])
|
||
marked = []
|
||
for dose_info in doses:
|
||
if isinstance(dose_info, (list, tuple)) and len(dose_info) >= 3:
|
||
med_id_local, med_name, t = dose_info[0], dose_info[1], dose_info[2]
|
||
api_request(
|
||
"post", f"/api/medications/{med_id_local}/take", token,
|
||
{"scheduled_time": t}
|
||
)
|
||
marked.append(f"**{med_name}** at {t}")
|
||
if marked:
|
||
lines = "\n".join(f"✅ {m}" for m in marked)
|
||
await message.channel.send(f"Logged as taken:\n{lines}")
|
||
else:
|
||
await message.channel.send("No doses to log.")
|
||
|
||
elif action == "skip":
|
||
med_id = parsed.get("medication_id")
|
||
name = parsed.get("name")
|
||
reason = parsed.get("reason", "Skipped by user")
|
||
|
||
med_id, name, found = await _find_medication_by_name(
|
||
message, token, med_id, name
|
||
)
|
||
if not found:
|
||
return
|
||
|
||
if not med_id:
|
||
await message.channel.send("Which medication are you skipping?")
|
||
return
|
||
|
||
# Try to get scheduled time from recent reminder context
|
||
scheduled_time = await _get_scheduled_time_from_context(message, name)
|
||
|
||
# Build request body with scheduled_time if found
|
||
request_body = {"reason": reason}
|
||
if scheduled_time:
|
||
request_body["scheduled_time"] = scheduled_time
|
||
|
||
resp, status = api_request(
|
||
"post", f"/api/medications/{med_id}/skip", token, request_body
|
||
)
|
||
if status == 201:
|
||
await message.channel.send(f"Skipped **{name}**. {reason}")
|
||
else:
|
||
await message.channel.send(f"Error: {resp.get('error', 'Failed to log')}")
|
||
|
||
elif action == "today":
|
||
resp, status = api_request("get", "/api/medications/today", token)
|
||
if status == 200:
|
||
meds = resp if isinstance(resp, list) else []
|
||
if not meds:
|
||
await message.channel.send("No medications scheduled for today!")
|
||
else:
|
||
lines = []
|
||
for item in meds:
|
||
med = item.get("medication", {})
|
||
times = item.get("scheduled_times", [])
|
||
taken = item.get("taken_times", [])
|
||
skipped = item.get("skipped_times", [])
|
||
is_prn = item.get("is_prn", False)
|
||
|
||
med_name = med.get("name", "Unknown")
|
||
dosage = f"{med.get('dosage', '')} {med.get('unit', '')}".strip()
|
||
|
||
if is_prn:
|
||
status_icon = "💊"
|
||
lines.append(
|
||
f"{status_icon} **{med_name}** {dosage} (as needed)"
|
||
)
|
||
elif times:
|
||
for time in times:
|
||
if time in taken:
|
||
status_icon = "✅"
|
||
elif time in skipped:
|
||
status_icon = "⏭️"
|
||
else:
|
||
status_icon = "⏰"
|
||
lines.append(
|
||
f"{status_icon} **{med_name}** {dosage} at {time}"
|
||
)
|
||
else:
|
||
lines.append(f"💊 **{med_name}** {dosage}")
|
||
|
||
await message.channel.send(
|
||
"**Today's Medications:**\n" + "\n".join(lines)
|
||
)
|
||
else:
|
||
error_msg = "Failed to fetch today's schedule"
|
||
await message.channel.send(f"Error: {resp.get('error', error_msg)}")
|
||
|
||
elif action == "refills":
|
||
resp, status = api_request("get", "/api/medications/refills-due", token)
|
||
if status == 200:
|
||
meds = resp if isinstance(resp, list) else []
|
||
if not meds:
|
||
await message.channel.send("No refills due soon! 🎉")
|
||
else:
|
||
lines = []
|
||
for med in meds:
|
||
name = med.get("name", "Unknown")
|
||
qty = med.get("quantity_remaining")
|
||
refill_date = med.get("refill_date")
|
||
|
||
if qty is not None and qty <= 7:
|
||
lines.append(f"⚠️ **{name}**: Only {qty} remaining")
|
||
elif refill_date:
|
||
lines.append(f"📅 **{name}**: Refill due by {refill_date}")
|
||
|
||
await message.channel.send("**Refills Due:**\n" + "\n".join(lines))
|
||
else:
|
||
await message.channel.send(
|
||
f"Error: {resp.get('error', 'Failed to fetch refills')}"
|
||
)
|
||
|
||
elif action == "snooze":
|
||
med_id = parsed.get("medication_id")
|
||
name = parsed.get("name")
|
||
minutes = parsed.get("minutes", 15)
|
||
|
||
med_id, name, found = await _find_medication_by_name(
|
||
message, token, med_id, name
|
||
)
|
||
if not found:
|
||
return
|
||
|
||
if not med_id:
|
||
await message.channel.send("Which medication reminder should I snooze?")
|
||
return
|
||
|
||
resp, status = api_request(
|
||
"post", f"/api/medications/{med_id}/snooze", token, {"minutes": minutes}
|
||
)
|
||
if status == 200:
|
||
await message.channel.send(f"⏰ Snoozed **{name}** for {minutes} minutes.")
|
||
else:
|
||
await message.channel.send(
|
||
f"Error: {resp.get('error', 'Failed to snooze')}"
|
||
)
|
||
|
||
elif action == "adherence":
|
||
med_id = parsed.get("medication_id")
|
||
name = parsed.get("name")
|
||
|
||
if name and not med_id:
|
||
# Look up by name first
|
||
med_id, name, found = await _find_medication_by_name(
|
||
message, token, None, name
|
||
)
|
||
if not found:
|
||
return
|
||
|
||
if med_id:
|
||
resp, status = api_request(
|
||
"get", f"/api/medications/{med_id}/adherence", token
|
||
)
|
||
else:
|
||
resp, status = api_request("get", "/api/medications/adherence", token)
|
||
|
||
if status == 200:
|
||
if isinstance(resp, list):
|
||
lines = []
|
||
for m in resp:
|
||
adherence = m.get("adherence_percent")
|
||
if adherence is not None:
|
||
lines.append(
|
||
f"- **{m['name']}**: {adherence}% adherence ({m.get('taken', 0)}/{m.get('expected', 0)} doses)"
|
||
)
|
||
else:
|
||
lines.append(
|
||
f"- **{m['name']}**: PRN medication (no adherence tracking)"
|
||
)
|
||
await message.channel.send("**Adherence Stats:**\n" + "\n".join(lines))
|
||
else:
|
||
adherence = resp.get("adherence_percent")
|
||
if adherence is not None:
|
||
await message.channel.send(
|
||
f"**{resp.get('name')}**:\n"
|
||
f"- Adherence: {adherence}%\n"
|
||
f"- Taken: {resp.get('taken', 0)}/{resp.get('expected', 0)} doses\n"
|
||
f"- Skipped: {resp.get('skipped', 0)}"
|
||
)
|
||
else:
|
||
await message.channel.send(
|
||
f"**{resp.get('name')}**: PRN medication (no adherence tracking)"
|
||
)
|
||
else:
|
||
await message.channel.send(
|
||
f"Error: {resp.get('error', 'Failed to fetch adherence')}"
|
||
)
|
||
|
||
elif action == "delete":
|
||
med_id = parsed.get("medication_id")
|
||
name = parsed.get("name")
|
||
needs_confirmation = parsed.get("needs_confirmation", True)
|
||
|
||
med_id, name, found = await _find_medication_by_name(
|
||
message, token, med_id, name
|
||
)
|
||
if not found:
|
||
return
|
||
|
||
if not med_id:
|
||
await message.channel.send("Which medication should I delete?")
|
||
return
|
||
|
||
# Handle confirmation
|
||
if needs_confirmation:
|
||
if "pending_confirmations" not in session:
|
||
session["pending_confirmations"] = {}
|
||
|
||
confirmation_id = f"med_delete_{name}"
|
||
session["pending_confirmations"][confirmation_id] = {
|
||
"action": "delete",
|
||
"interaction_type": "medication",
|
||
"medication_id": med_id,
|
||
"name": name,
|
||
"needs_confirmation": False, # Skip confirmation next time
|
||
}
|
||
|
||
await message.channel.send(
|
||
f"⚠️ Are you sure you want to delete **{name}**?\n\n"
|
||
f"This will also delete all logs for this medication.\n\n"
|
||
f"Reply **yes** to confirm deletion, or **no** to cancel."
|
||
)
|
||
return
|
||
|
||
# Actually delete
|
||
resp, status = api_request("delete", f"/api/medications/{med_id}", token)
|
||
if status == 200:
|
||
await message.channel.send(f"🗑️ Deleted **{name}** and all its logs.")
|
||
else:
|
||
await message.channel.send(
|
||
f"Error: {resp.get('error', 'Failed to delete medication')}"
|
||
)
|
||
|
||
else:
|
||
await message.channel.send(
|
||
f"Unknown action: {action}. Try: list, add, delete, take, take_all, skip, today, refills, snooze, or adherence."
|
||
)
|
||
|
||
|
||
async def _add_medication(
|
||
message, token, name, dosage, unit, frequency, times, days_of_week, interval_days
|
||
):
|
||
"""Helper to add a medication."""
|
||
data = {
|
||
"name": name,
|
||
"dosage": dosage,
|
||
"unit": unit,
|
||
"frequency": frequency,
|
||
"times": times,
|
||
}
|
||
|
||
# Add optional fields if present
|
||
if days_of_week:
|
||
data["days_of_week"] = days_of_week
|
||
if interval_days:
|
||
data["interval_days"] = interval_days
|
||
|
||
resp, status = api_request("post", "/api/medications", token, data)
|
||
if status == 201:
|
||
schedule_desc = _format_schedule(frequency, times, days_of_week, interval_days)
|
||
await message.channel.send(
|
||
f"✅ Added **{name}** ({dosage} {unit}) - {schedule_desc}"
|
||
)
|
||
else:
|
||
error_msg = resp.get("error", "Failed to add medication")
|
||
if "invalid input syntax" in error_msg.lower():
|
||
await message.channel.send(
|
||
f"Error: I couldn't understand the schedule format. Try saying it differently, like 'every day at 9am' or 'on Monday and Wednesday at 8pm'."
|
||
)
|
||
else:
|
||
await message.channel.send(f"Error: {error_msg}")
|
||
|
||
|
||
async def _find_medication_by_name(message, token, med_id, name):
|
||
"""Helper to find medication by name if ID not provided. Returns (med_id, name, found)."""
|
||
if med_id:
|
||
return med_id, name, True
|
||
|
||
if not name:
|
||
return None, None, True # Will prompt user later
|
||
|
||
# Look up medication by name
|
||
resp, status = api_request("get", "/api/medications", token)
|
||
if status != 200:
|
||
await message.channel.send("Error looking up medication. Please try again.")
|
||
return None, None, False
|
||
|
||
meds = resp if isinstance(resp, list) else []
|
||
|
||
# Try exact match first
|
||
matching = [m for m in meds if m["name"].lower() == name.lower()]
|
||
|
||
# Then try partial match
|
||
if not matching:
|
||
matching = [
|
||
m
|
||
for m in meds
|
||
if name.lower() in m["name"].lower() or m["name"].lower() in name.lower()
|
||
]
|
||
|
||
if not matching:
|
||
await message.channel.send(
|
||
f"❌ I couldn't find a medication called '{name}'.\n\nYour medications:\n"
|
||
+ "\n".join([f"- {m['name']}" for m in meds[:10]])
|
||
)
|
||
return None, None, False
|
||
|
||
if len(matching) > 1:
|
||
# Multiple matches - show list
|
||
lines = [f"{i + 1}. {m['name']}" for i, m in enumerate(matching[:5])]
|
||
await message.channel.send(
|
||
f"🔍 I found multiple medications matching '{name}':\n"
|
||
+ "\n".join(lines)
|
||
+ "\n\nPlease specify which one (e.g., 'take 1' or say the full name)."
|
||
)
|
||
return None, None, False
|
||
|
||
return matching[0]["id"], matching[0]["name"], True
|
||
|
||
|
||
def _format_schedule(frequency, times, days_of_week, interval_days):
|
||
"""Helper to format schedule description."""
|
||
if frequency == "daily":
|
||
return f"daily at {', '.join(times)}"
|
||
elif frequency == "twice_daily":
|
||
return f"twice daily at {', '.join(times)}"
|
||
elif frequency == "specific_days":
|
||
days_str = ", ".join(days_of_week) if days_of_week else "certain days"
|
||
return f"on {days_str} at {', '.join(times)}"
|
||
elif frequency == "every_n_days":
|
||
return f"every {interval_days} days at {', '.join(times)}"
|
||
elif frequency == "as_needed":
|
||
return "as needed"
|
||
else:
|
||
return f"{frequency} at {', '.join(times)}"
|
||
|
||
|
||
def api_request(method, endpoint, token, data=None):
|
||
import requests
|
||
import os
|
||
|
||
API_URL = os.getenv("API_URL", "http://app:5000")
|
||
url = f"{API_URL}{endpoint}"
|
||
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
|
||
try:
|
||
resp = getattr(requests, method)(url, headers=headers, json=data, timeout=10)
|
||
try:
|
||
return resp.json(), resp.status_code
|
||
except ValueError:
|
||
return {}, resp.status_code
|
||
except requests.RequestException:
|
||
return {"error": "API unavailable"}, 503
|
||
|
||
|
||
def validate_medication_json(data):
|
||
errors = []
|
||
if not isinstance(data, dict):
|
||
return ["Response must be a JSON object"]
|
||
if "error" in data:
|
||
return []
|
||
if "action" not in data:
|
||
errors.append("Missing required field: action")
|
||
return errors
|
||
|
||
|
||
register_module("medication", handle_medication)
|
||
ai_parser.register_validator("medication", validate_medication_json) |