defmodule Hotline.Generator do @moduledoc false @api_url "https://raw.githubusercontent.com/PaulSonOfLars/telegram-bot-api-spec/main/api.json" @types_dir "lib/hotline/types" @generated_api_file "lib/hotline/generated_api.ex" def run do IO.puts("Fetching Telegram Bot API spec...") {:ok, %{status: 200, body: body}} = Req.get(@api_url, decode_body: false) spec = JSON.decode!(body) IO.puts("Found #{map_size(spec["types"])} types and #{map_size(spec["methods"])} methods.") IO.puts("Generating types...") generate_types(spec["types"]) IO.puts("Generating API methods...") generate_methods(spec["methods"], spec["types"]) IO.puts("Done! Run `mix format` to format generated files.") end # --- Type Generation --- defp generate_types(types) do File.mkdir_p!(@types_dir) for {name, type_def} <- types do filename = Macro.underscore(name) <> ".ex" path = Path.join(@types_dir, filename) content = generate_type_module(name, type_def, types) File.write!(path, content) end end defp generate_type_module(name, type_def, all_types) do module_name = "Hotline.Types.#{name}" fields = type_def["fields"] || [] description = type_def["description"] || "" field_atoms = Enum.map(fields, fn f -> ":#{f["name"]}" end) nested = find_nested_fields(fields, all_types) typespecs = Enum.map(fields, &generate_typespec(&1, all_types)) nested_block = if nested != [] do entries = Enum.map_join(nested, ",\n ", fn {field, parser} -> "#{field}: #{parser}" end) """ def parse_nested(_map) do %{ #{entries} } end """ else "" end typespec_block = if typespecs != [] do entries = Enum.map_join(typespecs, ",\n ", fn {n, s} -> "#{n}: #{s}" end) """ @type t :: %__MODULE__{ #{entries} } """ else " @type t :: %__MODULE__{}\n" end """ defmodule #{module_name} do @moduledoc \"\"\" #{String.replace(description, "\"\"\"", ~S(\\\"\\\"\\\""))} \"\"\" use Hotline.Type defstruct [#{Enum.join(field_atoms, ", ")}] #{typespec_block} #{nested_block}end """ end defp find_nested_fields(fields, all_types) do for %{"name" => name, "types" => types} <- fields, parser = find_parser(types, all_types), parser != nil do {String.to_atom(name), parser} end end defp find_parser(types, all_types) do cond do Enum.any?(types, &match?("Array of " <> _, &1)) -> inner = types |> Enum.find(&match?("Array of " <> _, &1)) |> String.replace_prefix("Array of ", "") # Handle "Array of Array of X" inner = if String.starts_with?(inner, "Array of ") do String.replace_prefix(inner, "Array of ", "") else inner end if Map.has_key?(all_types, inner) do "&Hotline.Types.#{inner}.parse_list/1" else nil end Enum.any?(types, &Map.has_key?(all_types, &1)) -> type_name = Enum.find(types, &Map.has_key?(all_types, &1)) "&Hotline.Types.#{type_name}.parse/1" true -> nil end end defp generate_typespec(%{"name" => name, "types" => types} = field, all_types) do required = Map.get(field, "required", false) spec = types |> Enum.map(&map_type(&1, all_types)) |> Enum.uniq() |> Enum.join(" | ") spec = if required, do: spec, else: "#{spec} | nil" {String.to_atom(name), spec} end defp map_type("Integer", _), do: "integer()" defp map_type("String", _), do: "String.t()" defp map_type("Boolean", _), do: "boolean()" defp map_type("Float", _), do: "float()" defp map_type("Float number", _), do: "float()" defp map_type("True", _), do: "true" defp map_type("InputFile", _), do: "term()" defp map_type("Array of " <> inner, all_types) do "[#{map_type(inner, all_types)}]" end defp map_type(type_name, all_types) do if Map.has_key?(all_types, type_name) do "Hotline.Types.#{type_name}.t()" else "term()" end end # --- Method Generation --- defp generate_methods(methods, types) do functions = methods |> Enum.sort_by(fn {name, _} -> name end) |> Enum.map(fn {name, method_def} -> generate_method(name, method_def, types) end) content = """ defmodule Hotline.GeneratedAPI do @moduledoc \"\"\" Auto-generated Telegram Bot API methods. Generated by `mix hotline.gen` from the official Bot API spec. \"\"\" alias Hotline.Client #{Enum.join(functions, "\n")}end """ File.write!(@generated_api_file, content) end defp generate_method(name, method_def, types) do description = (method_def["description"] || "") |> String.slice(0..500) |> String.replace("\"\"\"", ~S(\\\"\\\"\\\"")) returns = method_def["returns"] || [] func_name = Macro.underscore(name) return_parser = find_return_parser(returns, types) body = if return_parser do """ with {:ok, result} <- Client.request("#{name}", Map.new(params), opts) do {:ok, #{return_parser}} end\ """ else """ Client.request("#{name}", Map.new(params), opts)\ """ end """ @doc \"\"\" #{description} \"\"\" def #{func_name}(params \\\\ %{}, opts \\\\ []) do #{body} end """ end defp find_return_parser(returns, types) do cond do Enum.any?(returns, &match?("Array of " <> _, &1)) -> inner = returns |> Enum.find(&match?("Array of " <> _, &1)) |> String.replace_prefix("Array of ", "") if Map.has_key?(types, inner) do "Hotline.Types.#{inner}.parse_list(result)" else nil end Enum.any?(returns, &Map.has_key?(types, &1)) -> type_name = Enum.find(returns, &Map.has_key?(types, &1)) "Hotline.Types.#{type_name}.parse(result)" true -> nil end end end Hotline.Generator.run()