]> Entropealabs - notifier.git/commitdiff
initial commit main
authorChristopher Coté <chris@entropealabs.com>
Thu, 7 May 2026 20:08:31 +0000 (16:08 -0400)
committerChristopher Coté <chris@entropealabs.com>
Thu, 7 May 2026 20:08:31 +0000 (16:08 -0400)
17 files changed:
.formatter.exs [new file with mode: 0644]
.gitignore [new file with mode: 0644]
.tool-versions [new file with mode: 0644]
Dockerfile [new file with mode: 0644]
README.md [new file with mode: 0644]
build.sh [new file with mode: 0755]
default.env [new file with mode: 0644]
lib/notifier.ex [new file with mode: 0644]
lib/notifier/application.ex [new file with mode: 0644]
lib/notifier/fb_graph/supervisor.ex [new file with mode: 0644]
lib/notifier/fb_graph/worker.ex [new file with mode: 0644]
lib/notifier/smtp.ex [new file with mode: 0644]
mix.exs [new file with mode: 0644]
mix.lock [new file with mode: 0644]
push.sh [new file with mode: 0755]
test/notifier_test.exs [new file with mode: 0644]
test/test_helper.exs [new file with mode: 0644]

diff --git a/.formatter.exs b/.formatter.exs
new file mode 100644 (file)
index 0000000..d2cda26
--- /dev/null
@@ -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 (file)
index 0000000..b2813e7
--- /dev/null
@@ -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 (file)
index 0000000..f927231
--- /dev/null
@@ -0,0 +1,2 @@
+elixir 1.19.5-otp-28
+erlang 28.4.2
diff --git a/Dockerfile b/Dockerfile
new file mode 100644 (file)
index 0000000..9206b55
--- /dev/null
@@ -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 (file)
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 <https://hexdocs.pm/notifier>.
+
diff --git a/build.sh b/build.sh
new file mode 100755 (executable)
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 (file)
index 0000000..b619e8b
--- /dev/null
@@ -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 (file)
index 0000000..78c9a4e
--- /dev/null
@@ -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 (file)
index 0000000..f59a548
--- /dev/null
@@ -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 (file)
index 0000000..ca363e2
--- /dev/null
@@ -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 (file)
index 0000000..0cbad30
--- /dev/null
@@ -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 ->
+        "<a href=\"#{get_in(i, ["photo_image", "uri"])}\">#{get_in(i, ["accessibility_caption"])}"
+      end)
+
+    "<p>#{message}</p>" <> Enum.join(imgs, "&nbsp;")
+  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 (file)
index 0000000..60d4e71
--- /dev/null
@@ -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 (file)
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 (file)
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 (executable)
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 (file)
index 0000000..5179685
--- /dev/null
@@ -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 (file)
index 0000000..869559e
--- /dev/null
@@ -0,0 +1 @@
+ExUnit.start()