From: Christopher Coté Date: Thu, 7 May 2026 20:08:31 +0000 (-0400) Subject: initial commit X-Git-Url: http://git.entropealabs.com/?a=commitdiff_plain;h=HEAD;p=notifier.git initial commit --- 6222e1c21be99f57ec96b37480c02fe630156764 diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2813e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Temporary files, for example, from tests. +/tmp/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +notifier-*.tar + +local.env diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..f927231 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.19.5-otp-28 +erlang 28.4.2 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9206b55 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +ARG ELIXIR_VERSION=1.19.5 +ARG OTP_VERSION=28.4.2 +ARG ALPINE_VERSION=3.23.4 + +ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-alpine-${ALPINE_VERSION}" +ARG RUNNER_IMAGE="alpine:${ALPINE_VERSION}" + +FROM ${BUILDER_IMAGE} AS builder + +RUN apk add --no-cache \ + gcc \ + g++ \ + git \ + make \ + musl-dev \ + cmake \ + linux-headers + +# prepare build dir +WORKDIR /app + +# install hex + rebar +RUN mix local.hex --force && \ + mix local.rebar --force + +# set build ENV +ENV MIX_ENV="prod" + +# install mix dependencies +COPY mix.exs mix.lock ./ +RUN mix deps.get --only $MIX_ENV + +RUN mix deps.compile + +COPY lib lib + +# Compile the release +RUN mix compile + +RUN mix release + +# start a new build stage so that the final image will only contain +# the compiled release and other runtime necessities +FROM ${RUNNER_IMAGE} + +RUN apk add --no-cache bash libstdc++ openssl musl-dev + +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 + +WORKDIR "/app" +RUN chown nobody /app + +# set runner ENV +ENV MIX_ENV="prod" + +# Only copy the final release from the build stage +COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/notifier ./ + +USER nobody + +# If using an environment that doesn't automatically reap zombie processes, it is +# advised to add an init process such as tini via `apt-get install` +# above and adding an entrypoint. See https://github.com/krallin/tini for details +# ENTRYPOINT ["/tini", "--"] + +ENTRYPOINT ["/app/bin/notifier", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ec2216 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Notifier + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `notifier` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:notifier, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..98be84d --- /dev/null +++ b/build.sh @@ -0,0 +1 @@ +docker build . --tag alexandria.local:9500/notifier:latest diff --git a/default.env b/default.env new file mode 100644 index 0000000..b619e8b --- /dev/null +++ b/default.env @@ -0,0 +1,8 @@ +export SMTP_USERNAME=notifier@entropealabs.com +export SMTP_PASSWORD=XXXXXXXXXXXXXXXX +export SMTP_HOSTNAME=smtp.protonmail.ch +export SMTP_PORT=587 +export SMTP_SNI="*.protonmail.ch" +export FB_PAGE_IDS="111:test1,222:test2,333:test3" +export NOTIFY_EMAIL=chris@entropealabs.com + diff --git a/lib/notifier.ex b/lib/notifier.ex new file mode 100644 index 0000000..78c9a4e --- /dev/null +++ b/lib/notifier.ex @@ -0,0 +1,18 @@ +defmodule Notifier do + @moduledoc """ + Documentation for `Notifier`. + """ + + @doc """ + Hello world. + + ## Examples + + iex> Notifier.hello() + :world + + """ + def hello do + :world + end +end diff --git a/lib/notifier/application.ex b/lib/notifier/application.ex new file mode 100644 index 0000000..f59a548 --- /dev/null +++ b/lib/notifier/application.ex @@ -0,0 +1,65 @@ +defmodule Notifier.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + alias Notifier.{FBGraph, HTTPPool, Proton, SMTP} + + @impl true + def start(_type, _args) do + [hostname, port, username, password, sni, fb_ids, notify] = + get_env_vars([ + "SMTP_HOSTNAME", + {"SMTP_PORT", :int}, + "SMTP_USERNAME", + "SMTP_PASSWORD", + "SMTP_SNI", + {"FB_PAGE_IDS", :split}, + "NOTIFY_EMAIL" + ]) + + children = [ + {Finch, name: HTTPPool}, + {SMTP, + name: Proton, + relay: hostname, + hostname: hostname, + port: port, + username: username, + password: password, + auth: :always, + tls: :always, + ssl: false, + tls_options: [ + verify: :verify_peer, + cacerts: :certifi.cacerts(), + server_name_indication: ~c"#{sni}", + depth: 99 + ]}, + {FBGraph.Supervisor, + ids: fb_ids, notifier: Proton, http: HTTPPool, from: username, notify: [notify]} + ] + + opts = [strategy: :one_for_one, name: Notifier.Supervisor] + Supervisor.start_link(children, opts) + end + + defp get_env_vars(vars, acc \\ []) + defp get_env_vars([], acc), do: acc + + defp get_env_vars([{var, :int} | rest], acc), + do: get_env_vars(rest, acc ++ [var |> System.fetch_env!() |> String.to_integer()]) + + defp get_env_vars([{var, :split} | rest], acc), + do: + get_env_vars( + rest, + acc ++ + [var |> System.fetch_env!() |> String.split(",") |> Enum.map(&String.split(&1, ":"))] + ) + + defp get_env_vars([var | rest], acc), + do: get_env_vars(rest, acc ++ [System.fetch_env!(var)]) +end diff --git a/lib/notifier/fb_graph/supervisor.ex b/lib/notifier/fb_graph/supervisor.ex new file mode 100644 index 0000000..ca363e2 --- /dev/null +++ b/lib/notifier/fb_graph/supervisor.ex @@ -0,0 +1,18 @@ +defmodule Notifier.FBGraph.Supervisor do + use Supervisor + + alias Notifier.FBGraph.Worker + + def start_link(init_arg) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + def init(ids: ids, notifier: notifier, http: http, from: from, notify: notify) do + children = + Enum.map(ids, fn id -> + {Worker, id: id, notifier: notifier, http: http, from: from, notify: notify} + end) + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/lib/notifier/fb_graph/worker.ex b/lib/notifier/fb_graph/worker.ex new file mode 100644 index 0000000..0cbad30 --- /dev/null +++ b/lib/notifier/fb_graph/worker.ex @@ -0,0 +1,149 @@ +defmodule Notifier.FBGraph.Worker do + use GenServer + require Logger + + alias Notifier.SMTP + + @graph_url "https://www.facebook.com/api/graphql" + + def child_spec(args) do + [_id, name] = Keyword.get(args, :id) + + %{ + id: :"FBWorker-#{name}", + start: {__MODULE__, :start_link, [args]} + } + end + + def start_link(args) do + GenServer.start_link(__MODULE__, args) + end + + def init(id: [id, name], notifier: notifier, http: http, from: from, notify: notify) do + {:ok, + %{ + id: id, + name: name, + notifier: notifier, + from: from, + http: http, + notify: notify, + last_post: %{id: nil} + }, {:continue, :get_latest_post}} + end + + def handle_continue(:get_latest_post, %{http: http, id: id} = state) do + last_post = + :post + |> Finch.build(@graph_url, headers(), body(id)) + |> Finch.request(http) + |> handle_response() + |> handle_post() + |> maybe_notify(state) + + poll_interval = 60_000 * (15..25 |> Enum.random()) + Logger.info("Will poll #{state.name} again in #{poll_interval}ms") + Process.send_after(self(), :get_posts, poll_interval) + {:noreply, %{state | last_post: last_post}} + end + + def handle_info(:get_posts, state), do: {:noreply, state, {:continue, :get_latest_post}} + + defp maybe_notify({:error, _}, %{last_post: last_post}), do: last_post + + defp maybe_notify(%{id: id}, %{last_post: %{id: id} = last_post}), + do: last_post + + defp maybe_notify( + %{message: message, images: images} = post, + %{notifier: notifier, from: from, notify: notify, name: name} + ) do + SMTP.send_html( + notifier, + from, + notify, + "FB post from #{name}", + create_notification(message, images) + ) + + Logger.info("Notification sent to: #{inspect(notify)} for #{name} post #{post.id}") + post + end + + defp create_notification(message, images) do + imgs = + Enum.map(images, fn i -> + "#{get_in(i, ["accessibility_caption"])}" + end) + + "

#{message}

" <> Enum.join(imgs, " ") + end + + defp handle_post([ + %{"data" => %{"node" => %{"timeline_list_feed_units" => %{"edges" => [node | _edges]}}}} + | _ + ]) do + id = get_in(node, ["node", "post_id"]) + text = get_in(node, ["node", "comet_sections", "content", "story", "message", "text"]) + + images = + get_in(node, [ + "node", + "comet_sections", + "content", + "story", + "attachments", + Access.all(), + "styles", + "attachment", + "media" + ]) + + %{id: id, message: text, images: images} + end + + defp handle_post({:error, status} = error) do + Logger.error("FB Graph returned error: #{status}") + error + end + + defp handle_response({:ok, %Finch.Response{status: 200, body: body}}) do + body + |> String.split("\r\n") + |> Enum.map(&Jason.decode!/1) + end + + defp handle_response({:ok, %Finch.Response{status: status}}) do + {:error, status} + end + + defp body(id) do + %{ + doc_id: "27119308971007907", + variables: + Jason.encode!(%{ + "feedLocation" => "TIMELINE", + "feedbackSource" => 0, + "omitPinnedPost" => true, + "id" => id, + "count" => 1 + }) + } + |> URI.encode_query(:www_form) + end + + defp headers, + do: [ + {"Content-Type", "application/x-www-form-urlencoded"}, + {"User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0"}, + {"DNT", "1"}, + {"Accept", "*/*"}, + {"Accept-Language", "en-US,en;q=0.9"}, + {"Connection", "close"}, + {"Host", "www.facebook.com"}, + {"Sec-Fetch-Dest", "empty"}, + {"Sec-Fetch-Mode", "cors"}, + {"Sec-Fetch-Site", "same-origin"}, + {"Sec-GPC", "1"} + ] +end diff --git a/lib/notifier/smtp.ex b/lib/notifier/smtp.ex new file mode 100644 index 0000000..60d4e71 --- /dev/null +++ b/lib/notifier/smtp.ex @@ -0,0 +1,43 @@ +defmodule Notifier.SMTP do + use GenServer + + def start_link(opts) do + {name, opts} = Keyword.pop(opts, :name) + GenServer.start_link(__MODULE__, opts, name: name) + end + + def init(opts) do + {:ok, opts} + end + + def send_html(smtp, from, to, subject, body) when is_list(to) do + GenServer.call(smtp, {:send_html, from, to, subject, body}, 30_000) + end + + def send_html(smtp, from, to, subject, body) do + send_html(smtp, from, [to], subject, body) + end + + def handle_call({:send_html, from, to, subject, body}, _from, opts) do + message = create_html_email(from, to, subject, body) + resp = :gen_smtp_client.send_blocking({from, to, message}, opts) + {:reply, resp, opts} + end + + defp create_html_email(from, [to | _], subject, body) do + email = + { + "text", + "html", + [ + {"From", from}, + {"To", to}, + {"Subject", subject} + ], + %{content_type_params: [{"charset", "utf-8"}], disposition: "inline"}, + body + } + + :mimemail.encode(email) + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..a6d2047 --- /dev/null +++ b/mix.exs @@ -0,0 +1,29 @@ +defmodule Notifier.MixProject do + use Mix.Project + + def project do + [ + app: :notifier, + version: "0.1.0", + elixir: "~> 1.19", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger], + mod: {Notifier.Application, []} + ] + end + + defp deps do + [ + {:finch, "~> 0.21.0"}, + {:gen_smtp, "~> 1.3"}, + {:jason, "~> 1.4"}, + {:certifi, "~> 2.16"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..9e45a77 --- /dev/null +++ b/mix.lock @@ -0,0 +1,13 @@ +%{ + "certifi": {:hex, :certifi, "2.16.0", "a4edfc1d2da3424d478a3271133bf28e0ec5e6fd8c009aab5a4ae980cb165ce9", [:rebar3], [], "hexpm", "8a64f6669d85e9cc0e5086fcf29a5b13de57a13efa23d3582874b9a19303f184"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, +} diff --git a/push.sh b/push.sh new file mode 100755 index 0000000..17e68bf --- /dev/null +++ b/push.sh @@ -0,0 +1 @@ +docker image push alexandria.local:9500/notifier:latest diff --git a/test/notifier_test.exs b/test/notifier_test.exs new file mode 100644 index 0000000..5179685 --- /dev/null +++ b/test/notifier_test.exs @@ -0,0 +1,8 @@ +defmodule NotifierTest do + use ExUnit.Case + doctest Notifier + + test "greets the world" do + assert Notifier.hello() == :world + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()