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:
2026-06-03 23:25:10 -04:00
parent 3ba93c55f4
commit f5203e0466
12 changed files with 684 additions and 9 deletions
+7
View File
@@ -15,6 +15,7 @@ import blueprints.rag
import blueprints.users import blueprints.users
import blueprints.whatsapp import blueprints.whatsapp
import blueprints.imessage import blueprints.imessage
import blueprints.scheduled_messages
import blueprints.users.models import blueprints.users.models
from config.db import TORTOISE_CONFIG 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.rag.rag_blueprint)
app.register_blueprint(blueprints.whatsapp.whatsapp_blueprint) app.register_blueprint(blueprints.whatsapp.whatsapp_blueprint)
app.register_blueprint(blueprints.imessage.imessage_blueprint) app.register_blueprint(blueprints.imessage.imessage_blueprint)
app.register_blueprint(blueprints.scheduled_messages.scheduled_messages_blueprint)
async def _obsidian_sync_loop(): async def _obsidian_sync_loop():
@@ -86,8 +88,13 @@ async def lifespan():
if os.getenv("OBSIDIAN_CONTINUOUS_SYNC") == "true": if os.getenv("OBSIDIAN_CONTINUOUS_SYNC") == "true":
watcher_task = asyncio.create_task(_obsidian_sync_loop()) 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 yield
scheduler_task.cancel()
if watcher_task is not None: if watcher_task is not None:
watcher_task.cancel() watcher_task.cancel()
+3 -1
View File
@@ -224,6 +224,8 @@ async def webhook():
user=user, 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 return jsonify({"status": "ok"}), 200
+147
View File
@@ -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"})
+37
View File
@@ -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)
+1
View File
@@ -16,6 +16,7 @@ TORTOISE_CONFIG = {
"blueprints.conversation.models", "blueprints.conversation.models",
"blueprints.users.models", "blueprints.users.models",
"blueprints.email.models", "blueprints.email.models",
"blueprints.scheduled_messages.models",
"aerich.models", "aerich.models",
], ],
"default_connection": "default", "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();
+20 -8
View File
@@ -1,11 +1,12 @@
import { useCallback, useState, useRef } from "react"; 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 { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble"; import { AnswerBubble } from "./AnswerBubble";
import { ToolBubble } from "./ToolBubble"; import { ToolBubble } from "./ToolBubble";
import { MessageInput } from "./MessageInput"; import { MessageInput } from "./MessageInput";
import { ConversationList } from "./ConversationList"; import { ConversationList } from "./ConversationList";
import { AdminPanel } from "./AdminPanel"; import { AdminPanel } from "./AdminPanel";
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useConversations } from "../hooks/useConversations"; import { useConversations } from "../hooks/useConversations";
import { useChat } from "../hooks/useChat"; import { useChat } from "../hooks/useChat";
@@ -22,6 +23,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
const [showConversations, setShowConversations] = useState(false); const [showConversations, setShowConversations] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showAdminPanel, setShowAdminPanel] = useState(false); const [showAdminPanel, setShowAdminPanel] = useState(false);
const [showScheduler, setShowScheduler] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const isLoadingRef = useRef(false); const isLoadingRef = useRef(false);
@@ -157,13 +159,22 @@ 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"> <div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
{isAdmin && ( {isAdmin && (
<button <>
onClick={() => setShowAdminPanel(true)} <button
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" 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"
<Shield size={14} /> >
<span>Admin</span> <Shield size={14} />
</button> <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 <button
onClick={handleLogout} onClick={handleLogout}
@@ -178,6 +189,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
</aside> </aside>
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />} {showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0"> <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"> <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", default: "bg-leaf-pale text-leaf-dark border border-leaf-light/50",
amber: "bg-amber-pale text-amber-glow border border-amber-soft/40", amber: "bg-amber-pale text-amber-glow border border-amber-soft/40",
muted: "bg-sand-light/60 text-warm-gray border border-sand/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: { 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 };
}
+35
View File
@@ -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()