Packages

A Slack Web & Real Time Messaging API client.

Retired package: Renamed - 1.0.0-alpha.0

Current section

Files

Jump to
slack_kit lib mix tasks update_slack_api.exs
Raw

lib/mix/tasks/update_slack_api.exs

# Regenerates the per-method JSON docs in lib/slack/web/docs/ by scraping
# https://docs.slack.dev/reference/methods.md and each linked method page.
#
# mix run lib/mix/tasks/update_slack_api.exs # all methods
# mix run lib/mix/tasks/update_slack_api.exs chat.postMessage # subset
#
# The universal `token` argument is excluded — Slack.Web injects it at call time.
defmodule UpdateSlackApi do
@index_url "https://docs.slack.dev/reference/methods.md"
@out_dir "lib/slack/web/docs"
@user_agent "slack_kit-doc-generator"
@method_link_re ~r"\[([A-Za-z][\w.]*)\]\((https://docs\.slack\.dev/reference/methods/[\w.]+\.md)\)"
@desc_re ~r/\*\*Description\*\*\s*([^\n]+)/
@args_section_re ~r/##\s*Arguments\s*\{#arguments\}(.*?)(?=\n##\s|\z)/s
@errors_section_re ~r/##\s*Errors\s*\{#errors\}(.*?)(?=\n##\s|\z)/s
@arg_header_re ~r/\*\*`([^`]+)`\*\*(?:`([^`]+)`)?(Required|Optional)/
@example_re ~r/_Example:_\s*`([^`]+)`/
@default_re ~r/_Default:_\s*`([^`]+)`/
@meta_split_re ~r/_(?:Example|Default):_/
@subsection_re ~r/\n###\s.*\z/s
@error_name_re ~r/^`([a-z][a-z0-9_]*)`$/
@trailing_rule_re ~r/\s*\*\s*\*\s*\*\s*\z/
@markdown_link_re ~r/\[([^\]]+)\]\([^)]+\)/
@usage_section_re ~r/##\s*Usage info\s*\{#usage-info\}(.*?)(?=\n##\s|\z)/s
@code_block_re ~r/```.*?```/s
@heading_line_re ~r/^#+\s+.*$/m
@hr_line_re ~r/^\s*\*\s*\*\s*\*\s*$/m
@bullet_line_re ~r/\n[ \t]*\*[ \t]+/
@sentence_split_re ~r/(?<=[.!?])\s+(?=[A-Z])/
@arg_ref_re ~r/`([a-z][a-z0-9_]*)`/
def run(args) do
Application.ensure_all_started(:req)
methods = list_methods() |> filter(args)
IO.puts("Fetching #{length(methods)} methods...")
File.mkdir_p!(@out_dir)
methods
|> Task.async_stream(&write_doc/1,
max_concurrency: 8,
timeout: 60_000,
on_timeout: :kill_task
)
|> Enum.each(fn
{:ok, {:ok, name}} -> IO.puts("ok #{name}")
{:ok, {:error, name, reason}} -> IO.puts(:stderr, "err #{name}: #{reason}")
{:exit, reason} -> IO.puts(:stderr, "exit: #{inspect(reason)}")
end)
end
defp filter(methods, []), do: methods
defp filter(methods, args) do
set = MapSet.new(args)
Enum.filter(methods, fn {name, _} -> name in set end)
end
defp list_methods do
@index_url
|> fetch!()
|> then(&Regex.scan(@method_link_re, &1))
|> Enum.reduce(%{}, fn [_, name, url], acc ->
# Method names always contain a dot (e.g. chat.postMessage).
# Skip incidental links to other reference pages.
if String.contains?(name, "."), do: Map.put_new(acc, name, url), else: acc
end)
|> Enum.sort()
end
defp write_doc({name, url}) do
try do
data = url |> fetch!() |> parse_method_page()
json = (data |> :json.format() |> IO.iodata_to_binary()) <> "\n"
File.write!(Path.join(@out_dir, "#{name}.json"), json)
{:ok, name}
rescue
e -> {:error, name, Exception.message(e)}
end
end
defp fetch!(url) do
%{body: body} = Req.get!(url, headers: [{"user-agent", @user_agent}])
if is_binary(body), do: body, else: to_string(body)
end
defp parse_method_page(text) do
%{
"desc" => extract(text, @desc_re),
"args" => parse_args(text),
"errors" => parse_errors(text)
}
end
defp extract(text, re) do
case Regex.run(re, text) do
[_, value] -> value |> String.trim() |> strip_links()
_ -> ""
end
end
defp strip_links(text), do: Regex.replace(@markdown_link_re, text, "\\1")
defp parse_args(text) do
case Regex.run(@args_section_re, text) do
[_, section] ->
section
|> do_parse_args()
|> Map.delete("token")
|> apply_usage_constraints(text)
_ ->
%{}
end
end
# Slack documents cross-argument constraints (e.g. "Exactly one of `team_id`
# or `enterprise_id` is required") as prose in the Usage info section rather
# than per-argument. Scan that section for sentences that reference one or
# more known argument names in backticks and append each such sentence to the
# matching arg's desc, so the constraint is visible at the call site.
defp apply_usage_constraints(args, _text) when map_size(args) == 0, do: args
defp apply_usage_constraints(args, text) do
case Regex.run(@usage_section_re, text) do
[_, usage] -> append_constraints(args, usage)
_ -> args
end
end
defp append_constraints(args, usage) do
arg_names = MapSet.new(Map.keys(args))
usage
|> sanitize_usage()
|> extract_sentences()
|> Enum.reduce(args, fn sentence, acc ->
case mentioned_args(sentence, arg_names) do
# Cross-argument constraints reference two or more args by name in the
# same sentence — skip single-arg mentions, which are usually just
# descriptive prose covered by the arg's own description.
mentioned when length(mentioned) >= 2 ->
Enum.reduce(mentioned, acc, &append_constraint(&2, &1, sentence))
_ ->
acc
end
end)
end
defp sanitize_usage(usage) do
usage
|> then(&Regex.replace(@code_block_re, &1, ""))
|> then(&Regex.replace(@heading_line_re, &1, ""))
|> then(&Regex.replace(@hr_line_re, &1, ""))
# Promote each bullet item to its own paragraph so the sentence splitter
# treats them independently instead of merging the whole list.
|> then(&Regex.replace(@bullet_line_re, &1, "\n\n"))
|> String.replace("**", "")
|> strip_links()
end
defp extract_sentences(text) do
text
|> String.split(~r/\n\s*\n/)
|> Enum.flat_map(&split_paragraph/1)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp split_paragraph(paragraph) do
paragraph
|> String.replace(~r/\s+/, " ")
|> String.trim()
|> String.split(@sentence_split_re)
end
defp mentioned_args(sentence, arg_names) do
@arg_ref_re
|> Regex.scan(sentence, capture: :all_but_first)
|> List.flatten()
|> Enum.filter(&MapSet.member?(arg_names, &1))
|> Enum.uniq()
end
defp append_constraint(args, arg_name, sentence) do
Map.update!(args, arg_name, fn entry ->
Map.update(entry, "desc", sentence, &append_sentence(&1, sentence))
end)
end
defp append_sentence("", sentence), do: sentence
defp append_sentence(desc, sentence) do
separator = if String.ends_with?(desc, [".", "!", "?"]), do: " ", else: ". "
desc <> separator <> sentence
end
defp do_parse_args(section) do
section
|> then(&Regex.replace(@arg_header_re, &1, "\u{0000}\\0"))
|> String.split("\u{0000}", trim: true)
|> Enum.flat_map(&parse_arg_chunk/1)
|> Map.new()
end
defp parse_arg_chunk(chunk) do
case Regex.run(@arg_header_re, chunk) do
[matched, name, type, required] ->
body =
chunk
|> String.replace_prefix(matched, "")
|> then(&Regex.replace(@subsection_re, &1, ""))
|> String.trim()
desc =
body
|> String.split(@meta_split_re, parts: 2)
|> hd()
|> String.trim()
|> strip_links()
entry =
%{"required" => required == "Required", "desc" => desc}
|> maybe_put("type", non_empty(type))
|> maybe_put("example", capture(@example_re, body))
|> maybe_put("default", capture(@default_re, body))
[{name, entry}]
_ ->
[]
end
end
defp parse_errors(text) do
case Regex.run(@errors_section_re, text) do
[_, section] -> do_parse_errors(section)
_ -> %{}
end
end
defp do_parse_errors(section) do
{name, lines, errors} =
section
|> String.split("\n")
|> Enum.reduce({nil, [], %{}}, &reduce_error_line/2)
flush_error(name, lines, errors)
end
defp reduce_error_line(line, {current, lines, errors}) do
stripped = String.trim(line)
case Regex.run(@error_name_re, stripped) do
[_, new_name] -> {new_name, [], flush_error(current, lines, errors)}
_ -> accumulate_error_line(stripped, current, lines, errors)
end
end
defp accumulate_error_line(_stripped, nil, lines, errors), do: {nil, lines, errors}
defp accumulate_error_line("", current, lines, errors), do: {current, lines, errors}
defp accumulate_error_line(stripped, current, lines, errors),
do: {current, [stripped | lines], errors}
defp flush_error(nil, _, errors), do: errors
defp flush_error(name, lines, errors) do
joined =
lines
|> Enum.reverse()
|> Enum.join(" ")
|> String.trim()
|> then(&Regex.replace(@trailing_rule_re, &1, ""))
|> String.trim()
|> strip_links()
if joined == "" or Map.has_key?(errors, name) do
errors
else
Map.put(errors, name, joined)
end
end
defp capture(re, text) do
case Regex.run(re, text) do
[_, value] -> value
_ -> nil
end
end
defp non_empty(nil), do: nil
defp non_empty(""), do: nil
defp non_empty(string), do: string
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end
UpdateSlackApi.run(System.argv())