defmodule Symbiont.Telepathy.MessageStore do @moduledoc """ Persistent message store backed by JSONL, compatible with the Python Telepathy messages.jsonl format. Message schema: %{"id" => hex12, "timestamp" => iso8601, "source" => string, "subject" => string, "content" => string, "read" => boolean} """ use GenServer require Logger defstruct [:path, messages: []] def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end def store(message) do GenServer.call(__MODULE__, {:store, message}) end def unread do GenServer.call(__MODULE__, :unread) end def mark_read(msg_id) do GenServer.call(__MODULE__, {:mark_read, msg_id}) end def list(opts \\ []) do GenServer.call(__MODULE__, {:list, opts}) end def count do GenServer.call(__MODULE__, :count) end # -- Server -- @impl true def init(opts) do path = Keyword.get(opts, :path, "/data/telepathy/messages.jsonl") File.mkdir_p!(Path.dirname(path)) unless File.exists?(path), do: File.write!(path, "") messages = load(path) Logger.info("Telepathy MessageStore loaded: #{length(messages)} messages") {:ok, %__MODULE__{path: path, messages: messages}} end @impl true def handle_call({:store, msg}, _from, state) do entry = Map.merge( %{ "id" => generate_id(), "timestamp" => DateTime.utc_now() |> DateTime.to_iso8601(), "read" => false }, msg ) new_messages = state.messages ++ [entry] append!(state.path, entry) Logger.info("Telepathy message stored: #{entry["id"]} from #{entry["source"]}") {:reply, {:ok, entry["id"]}, %{state | messages: new_messages}} end @impl true def handle_call(:unread, _from, state) do unread = Enum.filter(state.messages, &(not &1["read"])) {:reply, unread, state} end @impl true def handle_call({:mark_read, msg_id}, _from, state) do new_messages = Enum.map(state.messages, fn msg -> if msg["id"] == msg_id, do: Map.put(msg, "read", true), else: msg end) persist!(state.path, new_messages) {:reply, :ok, %{state | messages: new_messages}} end @impl true def handle_call({:list, opts}, _from, state) do limit = Keyword.get(opts, :limit, 50) msgs = Enum.take(state.messages, -limit) {:reply, msgs, state} end @impl true def handle_call(:count, _from, state) do {:reply, length(state.messages), state} end # -- Private -- defp load(path) do path |> File.stream!() |> Stream.reject(&(&1 in ["", "\n"])) |> Enum.map(fn line -> case Jason.decode(String.trim(line)) do {:ok, msg} -> msg {:error, _} -> nil end end) |> Enum.reject(&is_nil/1) end defp append!(path, entry) do File.write!(path, Jason.encode!(entry) <> "\n", [:append]) end defp persist!(path, messages) do content = Enum.map_join(messages, fn m -> Jason.encode!(m) <> "\n" end) File.write!(path, content) end defp generate_id do :crypto.strong_rand_bytes(6) |> Base.encode16(case: :lower) end end