--- /dev/null
+# Used by "mix format"
+[
+ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
+]
--- /dev/null
+# 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
--- /dev/null
+elixir 1.19.5-otp-28
+erlang 28.4.2
--- /dev/null
+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"]
--- /dev/null
+# 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 <https://hexdocs.pm/notifier>.
+
--- /dev/null
+docker build . --tag alexandria.local:9500/notifier:latest
--- /dev/null
+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
+
--- /dev/null
+defmodule Notifier do
+ @moduledoc """
+ Documentation for `Notifier`.
+ """
+
+ @doc """
+ Hello world.
+
+ ## Examples
+
+ iex> Notifier.hello()
+ :world
+
+ """
+ def hello do
+ :world
+ end
+end
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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 ->
+ "<a href=\"#{get_in(i, ["photo_image", "uri"])}\">#{get_in(i, ["accessibility_caption"])}"
+ end)
+
+ "<p>#{message}</p>" <> 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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+%{
+ "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"},
+}
--- /dev/null
+docker image push alexandria.local:9500/notifier:latest
--- /dev/null
+defmodule NotifierTest do
+ use ExUnit.Case
+ doctest Notifier
+
+ test "greets the world" do
+ assert Notifier.hello() == :world
+ end
+end
--- /dev/null
+ExUnit.start()