Skip to main content

lib/mix/tasks/jido_messaging.demo.ex

defmodule Mix.Tasks.Jido.Messaging.Demo do
  @shortdoc "Runs a demo messaging service (echo, bridge, or agent mode)"
  @moduledoc """
  Starts a demo messaging service.

  ## Usage

  Echo mode (runtime only):

      mix jido.messaging.demo

  Bridge mode (Telegram <-> Discord):

      mix jido.messaging.demo --bridge --telegram-chat 123456 --discord-channel 789012

  Agent mode (Bridge + ChatAgent):

      mix jido.messaging.demo --agent --telegram-chat 123456 --discord-channel 789012

  YAML topology mode:

      mix jido.messaging.demo --topology config/demo.topology.yaml

  ## Configuration

  Create a `.env` file in the project root:

      TELEGRAM_BOT_TOKEN=your_telegram_token
      DISCORD_BOT_TOKEN=your_discord_token
      CEREBRAS_API_KEY=your_cerebras_key  # Required for agent mode

  ## Options

  - `--bridge` - Enable bridge mode (requires Discord)
  - `--agent` - Enable agent mode (bridge + ChatAgent, requires Cerebras API key)
  - `--telegram-chat ID` - Telegram chat ID to bridge
  - `--discord-channel ID` - Discord channel ID to bridge
  - `--topology PATH` - YAML topology bootstrap file

  ## What it does

  **Echo mode**: Starts only the messaging runtime (no platform ingress)

  **Bridge mode**: Messages sent in Telegram appear in Discord and vice versa:
  - Telegram message "hello" -> Discord shows "[TG @user] hello"
  - Discord message "hey" -> Telegram shows "[DC user] hey"

  **Agent mode**: Bridge + a ReAct ChatAgent that responds when mentioned:
  - Mention @ChatAgent in either platform to chat with the AI
  - Agent responses are bridged to both platforms

  Press Ctrl+C twice to stop.
  """
  use Mix.Task

  require Logger

  alias Jido.Messaging.Demo.Topology

  @impl Mix.Task
  def run(args) do
    {opts, _, _} =
      OptionParser.parse(args,
        switches: [
          bridge: :boolean,
          agent: :boolean,
          topology: :string,
          telegram_chat: :integer,
          discord_channel: :integer
        ],
        aliases: [b: :bridge, a: :agent]
      )

    load_dotenv()
    topology = maybe_load_topology!(opts[:topology])

    mode = resolve_mode(opts, topology)

    validate_config!(mode)
    adapter_modules = resolve_adapter_modules(mode, topology)
    configure_adapters!(adapter_modules)

    Logger.info("[Demo] Starting Jido.Messaging Demo (#{mode} mode)")
    Logger.info("[Demo] Press Ctrl+C twice to stop")

    start_applications!(mode, adapter_modules)

    supervisor_opts =
      case mode do
        :echo ->
          [mode: :echo]

        :bridge ->
          telegram_chat_id = resolve_telegram_chat_id(opts, topology)
          discord_channel_id = resolve_discord_channel_id(opts, topology)
          [telegram_adapter, discord_adapter] = adapter_modules
          telegram_bridge_id = resolve_bridge_id(topology, "telegram_bridge_id", to_string(telegram_adapter))
          discord_bridge_id = resolve_bridge_id(topology, "discord_bridge_id", to_string(discord_adapter))

          [
            mode: :bridge,
            telegram_chat_id: telegram_chat_id,
            discord_channel_id: discord_channel_id,
            telegram_adapter: telegram_adapter,
            discord_adapter: discord_adapter,
            telegram_bridge_id: telegram_bridge_id,
            discord_bridge_id: discord_bridge_id
          ]

        :agent ->
          telegram_chat_id = resolve_telegram_chat_id(opts, topology)
          discord_channel_id = resolve_discord_channel_id(opts, topology)
          [telegram_adapter, discord_adapter] = adapter_modules
          telegram_bridge_id = resolve_bridge_id(topology, "telegram_bridge_id", to_string(telegram_adapter))
          discord_bridge_id = resolve_bridge_id(topology, "discord_bridge_id", to_string(discord_adapter))

          [
            mode: :agent,
            telegram_chat_id: telegram_chat_id,
            discord_channel_id: discord_channel_id,
            telegram_adapter: telegram_adapter,
            discord_adapter: discord_adapter,
            telegram_bridge_id: telegram_bridge_id,
            discord_bridge_id: discord_bridge_id
          ]
      end

    # Start Jido runtime for agent mode
    if mode == :agent do
      {:ok, _jido} = Jido.start_link(name: Jido.Messaging.Demo.Jido)
      Logger.info("[Demo] Started Jido runtime for ChatAgent")
    end

    {:ok, _pid} = Jido.Messaging.Demo.Supervisor.start_link(supervisor_opts)
    apply_topology!(topology)

    Process.sleep(:infinity)
  end

  defp maybe_load_topology!(nil), do: nil
  defp maybe_load_topology!(""), do: nil

  defp maybe_load_topology!(path) when is_binary(path) do
    case Topology.load(path) do
      {:ok, topology} ->
        Logger.info("[Demo] Loaded topology: #{path}")
        topology

      {:error, reason} ->
        Mix.raise("Failed to load topology #{path}: #{inspect(reason)}")
    end
  end

  defp resolve_mode(opts, nil) do
    cond do
      opts[:agent] -> :agent
      opts[:bridge] -> :bridge
      true -> :echo
    end
  end

  defp resolve_mode(opts, topology) do
    cond do
      opts[:agent] -> :agent
      opts[:bridge] -> :bridge
      true -> Topology.mode(topology) || :echo
    end
  end

  defp load_dotenv do
    env_file = Path.join(File.cwd!(), ".env")

    if File.exists?(env_file) do
      Dotenvy.source!([env_file])
      Logger.info("[Demo] Loaded .env file")
    end

    :ok
  end

  defp validate_config!(:echo) do
    validate_telegram_token!()
  end

  defp validate_config!(:bridge) do
    validate_telegram_token!()
    validate_discord_token!()
  end

  defp validate_config!(:agent) do
    validate_telegram_token!()
    validate_discord_token!()
    validate_cerebras_key!()
  end

  defp validate_telegram_token! do
    token =
      Dotenvy.env!("TELEGRAM_BOT_TOKEN", :string, default: nil) ||
        Application.get_env(:jido_chat_telegram, :telegram_bot_token)

    unless token do
      Mix.raise("""
      Missing Telegram bot token!

      Add to .env:

          TELEGRAM_BOT_TOKEN=your_token

      Get a token from @BotFather on Telegram.
      """)
    end

    Application.put_env(:jido_chat_telegram, :telegram_bot_token, token)
  end

  defp validate_discord_token! do
    token =
      Dotenvy.env!("DISCORD_BOT_TOKEN", :string, default: nil) ||
        Application.get_env(:nostrum, :token)

    unless token do
      Mix.raise("""
      Missing Discord bot token!

      Add to .env:

          DISCORD_BOT_TOKEN=your_token

      Get a token from Discord Developer Portal.
      """)
    end

    Application.put_env(:nostrum, :token, token)
    Application.put_env(:jido_chat_discord, :discord_bot_token, token)
  end

  defp validate_cerebras_key! do
    key = Dotenvy.env!("CEREBRAS_API_KEY", :string, default: nil)

    unless key do
      Mix.raise("""
      Missing Cerebras API key for agent mode!

      Add to .env:

          CEREBRAS_API_KEY=your_key

      Get a key from Cerebras Cloud.
      """)
    end
  end

  defp resolve_adapter_modules(:echo, _topology), do: []

  defp resolve_adapter_modules(mode, topology) when mode in [:bridge, :agent] do
    telegram_adapter =
      Topology.adapter_module(topology || %{}, "telegram_adapter") ||
        resolve_adapter_module(
          :demo_telegram_adapter,
          "JIDO_MESSAGING_DEMO_TELEGRAM_ADAPTER",
          "Elixir.Jido.Chat.Telegram.Adapter"
        )

    discord_adapter =
      Topology.adapter_module(topology || %{}, "discord_adapter") ||
        resolve_adapter_module(
          :demo_discord_adapter,
          "JIDO_MESSAGING_DEMO_DISCORD_ADAPTER",
          "Elixir.Jido.Chat.Discord.Adapter"
        )

    [telegram_adapter, discord_adapter]
  end

  defp resolve_telegram_chat_id(opts, topology) do
    opts[:telegram_chat] ||
      Topology.bridge_value(topology || %{}, "telegram_chat_id") ||
      get_telegram_chat_id()
  end

  defp resolve_discord_channel_id(opts, topology) do
    opts[:discord_channel] ||
      Topology.bridge_value(topology || %{}, "discord_channel_id") ||
      get_discord_channel_id()
  end

  defp resolve_bridge_id(topology, key, default) do
    Topology.bridge_value(topology || %{}, key) || default
  end

  defp resolve_adapter_module(config_key, env_key, default_module_name) do
    configured =
      Application.get_env(:jido_messaging, config_key) ||
        Dotenvy.env!(env_key, :string, default: default_module_name)

    case configured do
      module when is_atom(module) ->
        module

      module_name when is_binary(module_name) and module_name != "" ->
        module_name
        |> String.split(".")
        |> Module.concat()

      other ->
        Mix.raise("Invalid adapter module configuration for #{config_key}: #{inspect(other)}")
    end
  end

  defp configure_adapters!([]), do: :ok

  defp configure_adapters!(adapter_modules) when is_list(adapter_modules) do
    if Enum.any?(adapter_modules, &(to_string(&1) =~ "Discord")) do
      Application.put_env(:nostrum, :gateway_intents, [
        :guilds,
        :guild_messages,
        :message_content,
        :direct_messages
      ])
    end

    :ok
  end

  defp start_applications!(:echo, _adapter_modules) do
    Application.ensure_all_started(:logger)
    Application.ensure_all_started(:jido_signal)
  end

  defp start_applications!(:bridge, adapter_modules) do
    Application.ensure_all_started(:logger)
    Application.ensure_all_started(:jido_signal)
    Enum.each(adapter_modules, &ensure_adapter_application_started!/1)
    maybe_start_nostrum(adapter_modules)
  end

  defp start_applications!(:agent, adapter_modules) do
    start_applications!(:bridge, adapter_modules)
    Application.ensure_all_started(:jido)
    Application.ensure_all_started(:jido_ai)
  end

  defp maybe_start_nostrum(adapter_modules) when is_list(adapter_modules) do
    if Enum.any?(adapter_modules, &(to_string(&1) =~ "Discord")) do
      case Application.ensure_all_started(:nostrum) do
        {:ok, _apps} -> :ok
        {:error, reason} -> Mix.raise("Failed to start Nostrum for Discord gateway: #{inspect(reason)}")
      end
    else
      :ok
    end
  end

  defp ensure_adapter_application_started!(adapter_module) do
    if Code.ensure_loaded?(adapter_module) do
      case Application.get_application(adapter_module) do
        nil ->
          :ok

        app ->
          Application.ensure_all_started(app)
      end
    else
      Mix.raise("""
      Adapter module not available: #{inspect(adapter_module)}

      Ensure the adapter package is added to your host application's deps,
      then run `mix deps.get`.
      """)
    end
  end

  defp get_telegram_chat_id do
    case Dotenvy.env!("TELEGRAM_CHAT_ID", :integer, default: nil) do
      nil ->
        Mix.raise("""
        Missing Telegram chat ID for bridge mode!

        Add to .env:

            TELEGRAM_CHAT_ID=123456789

        Or pass via command line:

            mix jido.messaging.demo --bridge --telegram-chat 123456789
        """)

      id ->
        id
    end
  end

  defp get_discord_channel_id do
    case Dotenvy.env!("DISCORD_CHANNEL_ID", :integer, default: nil) do
      nil ->
        Mix.raise("""
        Missing Discord channel ID for bridge mode!

        Add to .env:

            DISCORD_CHANNEL_ID=123456789012345678

        Or pass via command line:

            mix jido.messaging.demo --bridge --discord-channel 123456789012345678
        """)

      id ->
        id
    end
  end

  defp apply_topology!(nil), do: :ok

  defp apply_topology!(topology) when is_map(topology) do
    case Topology.apply(Jido.Messaging.Demo.Messaging, topology) do
      {:ok, summary} ->
        Logger.info("[Demo] Applied topology: #{inspect(summary)}")
        :ok

      {:error, reason} ->
        Mix.raise("Failed to apply topology: #{inspect(reason)}")
    end
  end
end