Packages

A Slack Web & Real Time Messaging API client.

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 priv/docs/methods/ 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
@base_url "https://docs.slack.dev"
@index_url "https://docs.slack.dev/reference/methods.md"
@out_dir "priv/docs/methods"
@common_errors_path "priv/docs/common_errors.json"
@common_errors_markdown_path "guides/common_errors.md"
# An error code is "common" if it appears on at least this fraction of the
# method pages we successfully scraped. In practice ~30 boilerplate errors
# hit ~86% of pages and the next most common (channel_not_found) hits ~33%,
# so any threshold in (0.34, 0.85) yields the same set. 0.70 leaves
# headroom against Slack tweaking a handful of pages without shifting it.
@common_errors_threshold 0.70
@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/\[([^\]]+)\]\([^)]+\)/
@scopes_section_re ~r/\*\*Scopes\*\*(.*?)(?=\n\n\*\*|\n##\s)/s
@no_scopes_re ~r/_No scopes required_/
@scope_token_split_re ~r/\b(Bot token|User token|App token):\s*/
@scope_link_re ~r/\[`([^`]+)`\]\(([^)]+)\)/
@rate_limit_re ~r/\*\*Rate Limits\*\*\s*\[([^\]]+)\]\(([^)]+)\)/
@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)
parsed = fetch_all(methods)
common_errors =
case args do
[] ->
# Full regen — derive the common-error set from the fetched data and
# rewrite both the JSON source of truth and the human-facing guide.
common = compute_common_errors(parsed)
write_common_errors_files(common)
common
_ ->
# Subset regen — keep the existing curated/derived set intact;
# we don't have enough data to redetermine "common" from one method.
load_common_errors()
end
common_names = MapSet.new(Map.keys(common_errors))
Enum.each(parsed, &write_parsed(&1, common_names))
end
defp fetch_all(methods) do
methods
|> Task.async_stream(&fetch_and_parse/1,
max_concurrency: 8,
timeout: 60_000,
on_timeout: :kill_task
)
|> Enum.flat_map(fn
{:ok, {:ok, _name, _data} = ok} ->
[ok]
{:ok, {:error, name, reason}} ->
IO.puts(:stderr, "err #{name}: #{reason}")
[]
{:exit, reason} ->
IO.puts(:stderr, "exit: #{inspect(reason)}")
[]
end)
end
defp fetch_and_parse({name, url}) do
data = url |> fetch!() |> parse_method_page()
{:ok, name, data}
rescue
e -> {:error, name, Exception.message(e)}
end
defp write_parsed({:ok, name, data}, common_names) do
stripped = strip_common_errors(data, common_names)
json = (stripped |> :json.format() |> IO.iodata_to_binary()) <> "\n"
File.write!(Path.join(@out_dir, "#{name}.json"), json)
IO.puts("ok #{name}")
end
# Picks every error code that appears on >= @common_errors_threshold of the
# successfully parsed method pages, and chooses each one's canonical
# description as the most-frequent variant (ties broken by longest).
defp compute_common_errors([]), do: %{}
defp compute_common_errors(parsed) do
total = length(parsed)
threshold = max(1, ceil(total * @common_errors_threshold))
parsed
|> Enum.flat_map(fn
{:ok, _name, %{"errors" => errs}} when is_map(errs) -> Map.to_list(errs)
_ -> []
end)
|> Enum.group_by(fn {name, _} -> name end, fn {_, desc} -> desc end)
|> Enum.filter(fn {_name, descs} -> length(descs) >= threshold end)
|> Map.new(fn {name, descs} -> {name, canonical_description(descs)} end)
end
defp canonical_description(descs) do
descs
|> Enum.frequencies()
|> Enum.max_by(fn {desc, count} -> {count, String.length(desc)} end)
|> elem(0)
end
defp write_common_errors_files(common) do
json = (common |> :json.format() |> IO.iodata_to_binary()) <> "\n"
File.write!(@common_errors_path, json)
File.mkdir_p!(Path.dirname(@common_errors_markdown_path))
File.write!(@common_errors_markdown_path, render_common_errors_markdown(common))
end
defp render_common_errors_markdown(common) do
body =
common
|> Enum.sort()
|> Enum.map_join("\n", fn {name, desc} -> "* `#{name}` — #{desc}" end)
"""
# Common Errors
Slack documents the same set of errors on nearly every Web API method page —
authentication failures, rate limiting, deprecated endpoints, transport
problems, and so on. To keep per-method docs focused, those shared errors
are listed here once and stripped from each method's own error list.
Method-specific errors (e.g. `channel_not_found`, `is_archived`) remain on
the method itself.
## Errors
#{body}
"""
end
defp load_common_errors do
@common_errors_path
|> File.read!()
|> JSON.decode!()
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 strip_common_errors(%{"errors" => errors} = data, common_errors) when is_map(errors) do
Map.put(data, "errors", Map.reject(errors, fn {k, _} -> MapSet.member?(common_errors, k) end))
end
defp strip_common_errors(data, _common_errors), do: data
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),
"scopes" => parse_scopes(text),
"rate_limit" => parse_rate_limit(text)
}
end
defp parse_scopes(text) do
case Regex.run(@scopes_section_re, text) do
[_, section] ->
if Regex.match?(@no_scopes_re, section),
do: %{},
else: extract_scope_groups(section)
_ ->
%{}
end
end
# The section opens with `**Scopes**` followed by zero or more
# `Bot token:` / `User token:` / `App token:` blocks. For each labeled
# block, the body runs from the label's end to the next label or section end.
defp extract_scope_groups(section) do
matches = Regex.scan(@scope_token_split_re, section, return: :index)
section_len = byte_size(section)
matches
|> Enum.with_index()
|> Enum.reduce(%{}, fn {[{m_start, m_len}, {label_start, label_len}], i}, acc ->
body_start = m_start + m_len
body_end =
case Enum.at(matches, i + 1) do
[{next_start, _} | _] -> next_start
_ -> section_len
end
label = binary_part(section, label_start, label_len)
body = binary_part(section, body_start, body_end - body_start)
maybe_add_scopes(acc, scope_token_key(label), extract_scope_links(body))
end)
end
defp scope_token_key("Bot token"), do: "bot"
defp scope_token_key("User token"), do: "user"
defp scope_token_key("App token"), do: "app"
defp scope_token_key(_), do: nil
defp extract_scope_links(body) do
@scope_link_re
|> Regex.scan(body, capture: :all_but_first)
|> Enum.map(fn [name, url] ->
%{"name" => name, "url" => absolute_url(url)}
end)
end
defp maybe_add_scopes(acc, nil, _scopes), do: acc
defp maybe_add_scopes(acc, _key, []), do: acc
defp maybe_add_scopes(acc, key, scopes), do: Map.put(acc, key, scopes)
defp parse_rate_limit(text) do
case Regex.run(@rate_limit_re, text) do
[_, label, url] ->
%{"label" => String.trim(label), "url" => absolute_url(url)}
_ ->
nil
end
end
defp absolute_url("/" <> _ = path), do: @base_url <> path
defp absolute_url(url), do: url
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")
# Argument descriptions must be single-line: the upstream docs sometimes
# interleave horizontal rules or stray scalar values (e.g. a lone `0` minimum)
# between the prose and the `_Example:_` marker, and we don't want those
# smuggled into the JSON as newline-separated cruft.
defp normalize_whitespace(text) do
text
|> then(&Regex.replace(@trailing_rule_re, &1, ""))
|> String.replace(~r/\s+/, " ")
|> String.trim()
end
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()
|> normalize_whitespace()
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())