Add scheduled messages and strip markdown from iMessage responses
Strip markdown formatting (bold, italic, headers, code, links, lists) from LLM responses before sending via iMessage. Add scheduled messages feature with CRUD API, background scheduler loop, and admin frontend panel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, request, jsonify
|
||||
|
||||
from blueprints.users.decorators import admin_required
|
||||
from .models import ScheduledMessage, MessageChannel, MessageStatus
|
||||
|
||||
scheduled_messages_blueprint = Blueprint(
|
||||
"scheduled_messages_api", __name__, url_prefix="/api/scheduled-messages"
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize(msg: ScheduledMessage) -> dict:
|
||||
return {
|
||||
"id": str(msg.id),
|
||||
"recipient": msg.recipient,
|
||||
"channel": msg.channel.value,
|
||||
"content": msg.content,
|
||||
"subject": msg.subject,
|
||||
"scheduled_at": msg.scheduled_at.isoformat(),
|
||||
"status": msg.status.value,
|
||||
"error_message": msg.error_message,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
"updated_at": msg.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/", methods=["GET"])
|
||||
@admin_required
|
||||
async def list_messages():
|
||||
messages = await ScheduledMessage.all().order_by("-scheduled_at")
|
||||
return jsonify([_serialize(m) for m in messages])
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/", methods=["POST"])
|
||||
@admin_required
|
||||
async def create_message():
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
recipient = (data.get("recipient") or "").strip()
|
||||
channel = data.get("channel")
|
||||
content = (data.get("content") or "").strip()
|
||||
subject = (data.get("subject") or "").strip() or None
|
||||
scheduled_at_str = data.get("scheduled_at")
|
||||
|
||||
if not recipient or not channel or not content or not scheduled_at_str:
|
||||
return jsonify(
|
||||
{"error": "recipient, channel, content, and scheduled_at are required"}
|
||||
), 400
|
||||
|
||||
try:
|
||||
channel_enum = MessageChannel(channel)
|
||||
except ValueError:
|
||||
return jsonify(
|
||||
{"error": f"Invalid channel: {channel}. Must be 'imessage' or 'email'"}
|
||||
), 400
|
||||
|
||||
if channel_enum == MessageChannel.EMAIL and not subject:
|
||||
return jsonify({"error": "subject is required for email messages"}), 400
|
||||
|
||||
try:
|
||||
scheduled_at = datetime.fromisoformat(scheduled_at_str)
|
||||
if scheduled_at.tzinfo is None:
|
||||
scheduled_at = scheduled_at.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid scheduled_at format"}), 400
|
||||
|
||||
if scheduled_at <= datetime.now(timezone.utc):
|
||||
return jsonify({"error": "scheduled_at must be in the future"}), 400
|
||||
|
||||
from quart_jwt_extended import get_jwt_identity
|
||||
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
msg = await ScheduledMessage.create(
|
||||
recipient=recipient,
|
||||
channel=channel_enum,
|
||||
content=content,
|
||||
subject=subject,
|
||||
scheduled_at=scheduled_at,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
return jsonify(_serialize(msg)), 201
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/<msg_id>", methods=["PUT"])
|
||||
@admin_required
|
||||
async def update_message(msg_id: str):
|
||||
msg = await ScheduledMessage.get_or_none(id=msg_id)
|
||||
if not msg:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
if msg.status != MessageStatus.PENDING:
|
||||
return jsonify({"error": "Can only update pending messages"}), 400
|
||||
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
if "recipient" in data:
|
||||
msg.recipient = data["recipient"].strip()
|
||||
if "channel" in data:
|
||||
try:
|
||||
msg.channel = MessageChannel(data["channel"])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid channel: {data['channel']}"}), 400
|
||||
if "content" in data:
|
||||
msg.content = data["content"].strip()
|
||||
if "subject" in data:
|
||||
msg.subject = data["subject"].strip() or None
|
||||
if "scheduled_at" in data:
|
||||
try:
|
||||
scheduled_at = datetime.fromisoformat(data["scheduled_at"])
|
||||
if scheduled_at.tzinfo is None:
|
||||
scheduled_at = scheduled_at.replace(tzinfo=timezone.utc)
|
||||
if scheduled_at <= datetime.now(timezone.utc):
|
||||
return jsonify({"error": "scheduled_at must be in the future"}), 400
|
||||
msg.scheduled_at = scheduled_at
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid scheduled_at format"}), 400
|
||||
if "status" in data and data["status"] == "cancelled":
|
||||
msg.status = MessageStatus.CANCELLED
|
||||
|
||||
if msg.channel == MessageChannel.EMAIL and not msg.subject:
|
||||
return jsonify({"error": "subject is required for email messages"}), 400
|
||||
|
||||
await msg.save()
|
||||
return jsonify(_serialize(msg))
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/<msg_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
async def delete_message(msg_id: str):
|
||||
msg = await ScheduledMessage.get_or_none(id=msg_id)
|
||||
if not msg:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
if msg.status not in (MessageStatus.PENDING, MessageStatus.CANCELLED):
|
||||
return jsonify({"error": "Can only delete pending or cancelled messages"}), 400
|
||||
|
||||
await msg.delete()
|
||||
return jsonify({"status": "deleted"})
|
||||
@@ -0,0 +1,37 @@
|
||||
import enum
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
|
||||
class MessageChannel(enum.Enum):
|
||||
IMESSAGE = "imessage"
|
||||
EMAIL = "email"
|
||||
|
||||
|
||||
class MessageStatus(enum.Enum):
|
||||
PENDING = "pending"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ScheduledMessage(Model):
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
recipient = fields.CharField(max_length=255)
|
||||
channel = fields.CharEnumField(enum_type=MessageChannel, max_length=10)
|
||||
content = fields.TextField()
|
||||
subject = fields.CharField(max_length=255, null=True)
|
||||
scheduled_at = fields.DatetimeField()
|
||||
status = fields.CharEnumField(
|
||||
enum_type=MessageStatus, max_length=10, default=MessageStatus.PENDING
|
||||
)
|
||||
error_message = fields.TextField(null=True)
|
||||
created_by = fields.ForeignKeyField(
|
||||
"models.User", related_name="scheduled_messages"
|
||||
)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "scheduled_messages"
|
||||
@@ -0,0 +1,57 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .models import ScheduledMessage, MessageChannel, MessageStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POLL_INTERVAL = 15
|
||||
|
||||
|
||||
async def scheduled_messages_loop():
|
||||
"""Background loop that polls for and sends due scheduled messages."""
|
||||
logger.info(f"Scheduled messages loop started (interval={POLL_INTERVAL}s)")
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
due = await ScheduledMessage.filter(
|
||||
status=MessageStatus.PENDING,
|
||||
scheduled_at__lte=now,
|
||||
).all()
|
||||
|
||||
for msg in due:
|
||||
try:
|
||||
if msg.channel == MessageChannel.IMESSAGE:
|
||||
from blueprints.imessage import send_imessage
|
||||
from utils.strip_markdown import strip_markdown
|
||||
|
||||
await send_imessage(msg.recipient, strip_markdown(msg.content))
|
||||
|
||||
elif msg.channel == MessageChannel.EMAIL:
|
||||
from blueprints.email import send_email_reply
|
||||
|
||||
await send_email_reply(
|
||||
to=msg.recipient,
|
||||
subject=msg.subject or "(no subject)",
|
||||
body=msg.content,
|
||||
)
|
||||
|
||||
msg.status = MessageStatus.SENT
|
||||
msg.error_message = None
|
||||
await msg.save()
|
||||
logger.info(
|
||||
f"Sent scheduled {msg.channel.value} message {msg.id} to {msg.recipient}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
msg.status = MessageStatus.FAILED
|
||||
msg.error_message = str(e)
|
||||
await msg.save()
|
||||
logger.error(f"Failed to send scheduled message {msg.id}: {e}")
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error in scheduled messages loop")
|
||||
|
||||
await asyncio.sleep(POLL_INTERVAL)
|
||||
Reference in New Issue
Block a user