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:
@@ -15,6 +15,7 @@ import blueprints.rag
|
||||
import blueprints.users
|
||||
import blueprints.whatsapp
|
||||
import blueprints.imessage
|
||||
import blueprints.scheduled_messages
|
||||
import blueprints.users.models
|
||||
from config.db import TORTOISE_CONFIG
|
||||
|
||||
@@ -52,6 +53,7 @@ app.register_blueprint(blueprints.email.email_blueprint)
|
||||
app.register_blueprint(blueprints.rag.rag_blueprint)
|
||||
app.register_blueprint(blueprints.whatsapp.whatsapp_blueprint)
|
||||
app.register_blueprint(blueprints.imessage.imessage_blueprint)
|
||||
app.register_blueprint(blueprints.scheduled_messages.scheduled_messages_blueprint)
|
||||
|
||||
|
||||
async def _obsidian_sync_loop():
|
||||
@@ -86,8 +88,13 @@ async def lifespan():
|
||||
if os.getenv("OBSIDIAN_CONTINUOUS_SYNC") == "true":
|
||||
watcher_task = asyncio.create_task(_obsidian_sync_loop())
|
||||
|
||||
from blueprints.scheduled_messages.scheduler import scheduled_messages_loop
|
||||
|
||||
scheduler_task = asyncio.create_task(scheduled_messages_loop())
|
||||
|
||||
yield
|
||||
|
||||
scheduler_task.cancel()
|
||||
if watcher_task is not None:
|
||||
watcher_task.cancel()
|
||||
|
||||
|
||||
@@ -224,6 +224,8 @@ async def webhook():
|
||||
user=user,
|
||||
)
|
||||
|
||||
await send_imessage(from_number, response_text)
|
||||
from utils.strip_markdown import strip_markdown
|
||||
|
||||
await send_imessage(from_number, strip_markdown(response_text))
|
||||
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
@@ -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)
|
||||
@@ -16,6 +16,7 @@ TORTOISE_CONFIG = {
|
||||
"blueprints.conversation.models",
|
||||
"blueprints.users.models",
|
||||
"blueprints.email.models",
|
||||
"blueprints.scheduled_messages.models",
|
||||
"aerich.models",
|
||||
],
|
||||
"default_connection": "default",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { userService } from "./userService";
|
||||
|
||||
export interface ScheduledMessage {
|
||||
id: string;
|
||||
recipient: string;
|
||||
channel: "imessage" | "email";
|
||||
content: string;
|
||||
subject: string | null;
|
||||
scheduled_at: string;
|
||||
status: "pending" | "sent" | "failed" | "cancelled";
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateScheduledMessage {
|
||||
recipient: string;
|
||||
channel: "imessage" | "email";
|
||||
content: string;
|
||||
subject?: string;
|
||||
scheduled_at: string;
|
||||
}
|
||||
|
||||
class ScheduledMessageService {
|
||||
private baseUrl = "/api/scheduled-messages";
|
||||
|
||||
async list(): Promise<ScheduledMessage[]> {
|
||||
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`);
|
||||
if (!response.ok) throw new Error("Failed to list scheduled messages");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async create(data: CreateScheduledMessage): Promise<ScheduledMessage> {
|
||||
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error ?? "Failed to create scheduled message");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<CreateScheduledMessage> & { status?: string }): Promise<ScheduledMessage> {
|
||||
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error ?? "Failed to update scheduled message");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error ?? "Failed to delete scheduled message");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const scheduledMessageService = new ScheduledMessageService();
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useState, useRef } from "react";
|
||||
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
|
||||
import { LogOut, Shield, Clock, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
|
||||
import { QuestionBubble } from "./QuestionBubble";
|
||||
import { AnswerBubble } from "./AnswerBubble";
|
||||
import { ToolBubble } from "./ToolBubble";
|
||||
import { MessageInput } from "./MessageInput";
|
||||
import { ConversationList } from "./ConversationList";
|
||||
import { AdminPanel } from "./AdminPanel";
|
||||
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useConversations } from "../hooks/useConversations";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
@@ -22,6 +23,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
const [showConversations, setShowConversations] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [showAdminPanel, setShowAdminPanel] = useState(false);
|
||||
const [showScheduler, setShowScheduler] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const isLoadingRef = useRef(false);
|
||||
@@ -157,6 +159,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
|
||||
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowAdminPanel(true)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
@@ -164,6 +167,14 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
<Shield size={14} />
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowScheduler(true)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<Clock size={14} />
|
||||
<span>Scheduler</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
@@ -178,6 +189,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
</aside>
|
||||
|
||||
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
|
||||
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60">
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useState } from "react";
|
||||
import { X, Clock, Send, Trash2, XCircle, RotateCcw } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Badge } from "./ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "./ui/table";
|
||||
import { useScheduledMessages } from "../hooks/useScheduledMessages";
|
||||
import {
|
||||
scheduledMessageService,
|
||||
type CreateScheduledMessage,
|
||||
} from "../api/scheduledMessageService";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, "amber" | "default" | "destructive" | "muted"> = {
|
||||
pending: "amber",
|
||||
sent: "default",
|
||||
failed: "destructive",
|
||||
cancelled: "muted",
|
||||
};
|
||||
|
||||
export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||
const { messages, loading, refresh } = useScheduledMessages();
|
||||
const [channel, setChannel] = useState<"imessage" | "email">("imessage");
|
||||
const [recipient, setRecipient] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError("");
|
||||
if (!recipient || !content || !scheduledAt) {
|
||||
setError("Recipient, content, and scheduled time are required.");
|
||||
return;
|
||||
}
|
||||
if (channel === "email" && !subject) {
|
||||
setError("Subject is required for email.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data: CreateScheduledMessage = {
|
||||
recipient,
|
||||
channel,
|
||||
content,
|
||||
scheduled_at: new Date(scheduledAt).toISOString(),
|
||||
};
|
||||
if (channel === "email") data.subject = subject;
|
||||
await scheduledMessageService.create(data);
|
||||
setRecipient("");
|
||||
setSubject("");
|
||||
setContent("");
|
||||
setScheduledAt("");
|
||||
refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to schedule message");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
try {
|
||||
await scheduledMessageService.update(id, { status: "cancelled" });
|
||||
refresh();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await scheduledMessageService.remove(id);
|
||||
refresh();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleRetry = async (id: string) => {
|
||||
try {
|
||||
const futureTime = new Date(Date.now() + 30_000).toISOString();
|
||||
await scheduledMessageService.update(id, { scheduled_at: futureTime });
|
||||
refresh();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-charcoal/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-warm-white rounded-3xl shadow-2xl shadow-charcoal/20",
|
||||
"w-full max-w-3xl mx-4 max-h-[85vh] flex flex-col",
|
||||
"border border-sand-light/60",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-amber-pale flex items-center justify-center">
|
||||
<Clock size={14} className="text-amber-glow" />
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-charcoal">
|
||||
Scheduled Messages
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 rounded-b-3xl">
|
||||
{/* Create form */}
|
||||
<div className="px-6 py-5 border-b border-sand-light/60 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setChannel("imessage")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer",
|
||||
channel === "imessage"
|
||||
? "bg-leaf-pale text-leaf-dark"
|
||||
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
|
||||
)}
|
||||
>
|
||||
iMessage
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChannel("email")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer",
|
||||
channel === "email"
|
||||
? "bg-leaf-pale text-leaf-dark"
|
||||
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
|
||||
)}
|
||||
>
|
||||
Email
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder={channel === "imessage" ? "+15551234567" : "user@example.com"}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="w-52"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{channel === "email" && (
|
||||
<Input
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Subject"
|
||||
/>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Message content..."
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-sand bg-cream-light px-3 py-2 text-sm text-charcoal placeholder:text-warm-gray/50 focus:outline-none focus:ring-2 focus:ring-leaf/30 resize-none"
|
||||
/>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<Button onClick={handleCreate} disabled={submitting} size="sm">
|
||||
<Send size={12} />
|
||||
{submitting ? "Scheduling..." : "Schedule"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Message list */}
|
||||
{loading ? (
|
||||
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||
<div className="flex justify-center gap-1.5 mb-3">
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
</div>
|
||||
Loading...
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||
No scheduled messages yet.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead>Recipient</TableHead>
|
||||
<TableHead>Content</TableHead>
|
||||
<TableHead>Scheduled</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-28">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{messages.map((msg) => (
|
||||
<TableRow key={msg.id}>
|
||||
<TableCell className="capitalize text-xs">
|
||||
{msg.channel}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs truncate max-w-[140px]" title={msg.recipient}>
|
||||
{msg.recipient}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs truncate max-w-[180px]" title={msg.content}>
|
||||
{msg.content.length > 60
|
||||
? msg.content.slice(0, 60) + "..."
|
||||
: msg.content}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-warm-gray">
|
||||
{new Date(msg.scheduled_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_BADGE[msg.status]}>{msg.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{msg.status === "pending" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost-dark"
|
||||
onClick={() => handleCancel(msg.id)}
|
||||
title="Cancel"
|
||||
>
|
||||
<XCircle size={11} />
|
||||
</Button>
|
||||
)}
|
||||
{msg.status === "failed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost-dark"
|
||||
onClick={() => handleRetry(msg.id)}
|
||||
title="Retry"
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
</Button>
|
||||
)}
|
||||
{(msg.status === "pending" || msg.status === "cancelled") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(msg.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ const badgeVariants = cva(
|
||||
default: "bg-leaf-pale text-leaf-dark border border-leaf-light/50",
|
||||
amber: "bg-amber-pale text-amber-glow border border-amber-soft/40",
|
||||
muted: "bg-sand-light/60 text-warm-gray border border-sand/40",
|
||||
destructive: "bg-red-50 text-red-600 border border-red-200/50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
scheduledMessageService,
|
||||
type ScheduledMessage,
|
||||
} from "../api/scheduledMessageService";
|
||||
|
||||
export function useScheduledMessages() {
|
||||
const [messages, setMessages] = useState<ScheduledMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
scheduledMessageService
|
||||
.list()
|
||||
.then(setMessages)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { messages, loading, refresh };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import re
|
||||
|
||||
|
||||
def strip_markdown(text: str) -> str:
|
||||
"""Strip markdown formatting from text for plain-text channels like iMessage."""
|
||||
# Code blocks (fenced)
|
||||
text = re.sub(
|
||||
r"```[\s\S]*?```", lambda m: re.sub(r"```\w*\n?", "", m.group()), text
|
||||
)
|
||||
# Inline code
|
||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||
# Images
|
||||
text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", text)
|
||||
# Links — keep the link text
|
||||
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
||||
# Bold/italic (order matters: bold+italic first)
|
||||
text = re.sub(r"\*\*\*(.+?)\*\*\*", r"\1", text)
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||
text = re.sub(r"___(.+?)___", r"\1", text)
|
||||
text = re.sub(r"__(.+?)__", r"\1", text)
|
||||
text = re.sub(r"_(.+?)_", r"\1", text)
|
||||
# Headers
|
||||
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
|
||||
# Horizontal rules
|
||||
text = re.sub(r"^[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
|
||||
# Bullet lists — remove the bullet marker
|
||||
text = re.sub(r"^[\s]*[-*+]\s+", "", text, flags=re.MULTILINE)
|
||||
# Numbered lists — remove the number marker
|
||||
text = re.sub(r"^[\s]*\d+\.\s+", "", text, flags=re.MULTILINE)
|
||||
# Blockquotes
|
||||
text = re.sub(r"^>\s?", "", text, flags=re.MULTILINE)
|
||||
# Collapse multiple blank lines
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
Reference in New Issue
Block a user