├── test ├── test_helper.exs └── image_bot_test.exs ├── .formatter.exs ├── config ├── runtime.exs └── config.exs ├── Dockerfile ├── README.md ├── .github └── workflows │ └── docker.yml ├── lib ├── search │ └── search │ │ └── google.ex ├── image_bot │ └── application.ex ├── search.ex ├── keys.ex └── image_bot.ex ├── .gitignore ├── mix.exs └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/image_bot_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ImageBotTest do 2 | use ExUnit.Case 3 | doctest ImageBot 4 | end 5 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Config.config_env() == :dev do 4 | DotenvParser.load_file(".env") 5 | end 6 | 7 | config :image_bot, 8 | bot_token: System.fetch_env!("BOT_TOKEN"), 9 | key_db_path: System.get_env("KEY_DB_PATH", "./keys.db"), 10 | feedback_chat: System.get_env("FEEDBACK_CHAT", "-599173182") 11 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | case Config.config_env() do 4 | :dev -> 5 | config :logger, level: :debug 6 | 7 | _ -> 8 | config :logger, 9 | level: :info, 10 | compile_time_purge_matching: [ 11 | [application: :tesla] 12 | ] 13 | end 14 | 15 | config :tesla, adapter: Tesla.Adapter.Hackney 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM bitwalker/alpine-elixir:1.11 AS build 2 | 3 | ENV MIX_ENV=prod 4 | 5 | WORKDIR /build/ 6 | 7 | COPY . . 8 | RUN mix deps.get 9 | RUN mix release 10 | 11 | FROM bitwalker/alpine-elixir:1.11 AS run 12 | 13 | RUN apk add tzdata 14 | ENV TZ Europe/Amsterdam 15 | 16 | COPY --from=build /build/_build/prod/rel/image_bot/ ./ 17 | RUN chmod +x ./bin/image_bot 18 | CMD ./bin/image_bot start 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ImageBot 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `image_bot` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:image_bot, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at [https://hexdocs.pm/image_bot](https://hexdocs.pm/image_bot). 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Push docker 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Log in to GitHub Container Registry 17 | uses: docker/login-action@v3 18 | with: 19 | registry: ghcr.io 20 | username: ${{ github.actor }} 21 | password: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | - name: Build and push 24 | uses: docker/build-push-action@v6 25 | with: 26 | push: ${{ github.ref == 'refs/heads/main' }} 27 | tags: ghcr.io/bo0tzz/image_bot:${{ github.ref_name }} -------------------------------------------------------------------------------- /lib/search/search/google.ex: -------------------------------------------------------------------------------- 1 | defmodule Search.Google do 2 | require Logger 3 | 4 | def find_images(api_key, query) do 5 | res = 6 | GoogleApi.CustomSearch.V1.Connection.new() 7 | |> GoogleApi.CustomSearch.V1.Api.Cse.search_cse_list(params(api_key, query)) 8 | 9 | case res do 10 | {:error, response} -> 11 | Logger.warn("Google API call failed: #{response.body}") 12 | {:error, response.status} 13 | 14 | {:ok, response} -> 15 | {:ok, response.items} 16 | end 17 | end 18 | 19 | defp params(api_key, query) do 20 | [ 21 | cx: "016322137100648159445:e9nsxf_q_-m", 22 | searchType: "image", 23 | key: api_key, 24 | q: query 25 | ] 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | image_bot-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | 29 | .env 30 | keys.db* 31 | .idea 32 | *.iml -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ImageBot.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :image_bot, 7 | version: "0.1.0", 8 | elixir: "~> 1.11", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger], 18 | mod: {ImageBot.Application, []} 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:ex_gram, "~> 0.21"}, 26 | {:tesla, "~> 1.2"}, 27 | {:hackney, "~> 1.12"}, 28 | {:jason, ">= 1.0.0"}, 29 | {:google_api_custom_search, "~> 0.16"}, 30 | {:elixir_uuid, "~> 1.2"}, 31 | {:dotenv_parser, "~> 1.2"}, 32 | {:cachex, "~> 3.3"} 33 | ] 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/image_bot/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ImageBot.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | require Cachex.Spec 8 | 9 | @impl true 10 | def start(_type, _args) do 11 | children = [ 12 | {Cachex, 13 | [ 14 | name: :search_cache, 15 | expiration: Cachex.Spec.expiration(interval: nil, default: 84600), 16 | limit: Cachex.Spec.limit(size: 5000) 17 | ]}, 18 | Keys, 19 | ExGram, 20 | {ImageBot, [method: :polling, token: Application.fetch_env!(:image_bot, :bot_token)]} 21 | ] 22 | 23 | # See https://hexdocs.pm/elixir/Supervisor.html 24 | # for other strategies and supported options 25 | opts = [strategy: :one_for_one, name: ImageBot.Supervisor] 26 | Supervisor.start_link(children, opts) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/search.ex: -------------------------------------------------------------------------------- 1 | defmodule Search do 2 | require Logger 3 | 4 | def query(user_id, query) do 5 | case Cachex.get(:search_cache, query) do 6 | {:ok, nil} -> 7 | case search(user_id, query) do 8 | {:ok, items} -> 9 | Cachex.put(:search_cache, query, items) 10 | {:ok, items} 11 | 12 | error -> 13 | error 14 | end 15 | 16 | {:ok, items} -> 17 | Logger.info("Cache hit") 18 | {:ok, items} 19 | end 20 | end 21 | 22 | defp search(user_id, query, tries \\ 3) 23 | 24 | defp search(user_id, _, 0) do 25 | Logger.error("Query from user [#{user_id}] failed retries") 26 | {:error, :unknown} 27 | end 28 | 29 | defp search(user_id, query, tries) do 30 | Logger.debug("Searching for query '#{query}'") 31 | 32 | case Keys.get_key(user_id) do 33 | {:ok, nil} -> 34 | {:error, :limited} 35 | 36 | {:ok, key} -> 37 | case Search.Google.find_images(key, query) do 38 | {:error, error} -> 39 | Logger.warn("Query from user [#{user_id}] caused error #{error}!") 40 | 41 | case error do 42 | c when c in [400, 403] -> 43 | Keys.mark_bad(user_id, key) 44 | search(user_id, query, tries - 1) 45 | 46 | 429 -> 47 | Keys.mark_limited(user_id, key) 48 | search(user_id, query, tries - 1) 49 | 50 | other -> 51 | {:error, other} 52 | end 53 | 54 | {:ok, nil} -> 55 | {:ok, nil} 56 | 57 | {:ok, items} -> 58 | {:ok, items} 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/keys.ex: -------------------------------------------------------------------------------- 1 | defmodule Keys do 2 | use GenServer 3 | 4 | require Logger 5 | 6 | defstruct [ 7 | :storage_path, 8 | :key_mappings 9 | ] 10 | 11 | def start_link(path), do: GenServer.start_link(__MODULE__, path, name: __MODULE__) 12 | 13 | @impl true 14 | def init(_) do 15 | Process.flag(:trap_exit, true) 16 | path = Application.fetch_env!(:image_bot, :key_db_path) 17 | 18 | { 19 | :ok, 20 | %Keys{ 21 | storage_path: path, 22 | key_mappings: load_keys(path) 23 | } 24 | } 25 | end 26 | 27 | @impl true 28 | def terminate(_, state) do 29 | Logger.debug("Terminating keys management server") 30 | :ok = save(state) 31 | end 32 | 33 | @impl true 34 | def handle_continue(:save, state) do 35 | save(state) 36 | {:noreply, state} 37 | end 38 | 39 | @impl true 40 | def handle_cast({:add, {id, key}}, %Keys{key_mappings: mappings} = state) do 41 | mappings = 42 | Map.update(mappings, id, [{key, 0}], fn keys -> 43 | [{key, 0} | keys] 44 | end) 45 | 46 | { 47 | :noreply, 48 | %{state | key_mappings: mappings}, 49 | {:continue, :save} 50 | } 51 | end 52 | 53 | @impl true 54 | def handle_cast({:mark_limited, id, key}, %Keys{key_mappings: mappings} = state) do 55 | mappings = 56 | case Map.get(mappings, id) do 57 | nil -> 58 | mark_limited(mappings, :shared, key) 59 | 60 | keys -> 61 | case Enum.any?(keys, &match?({^key, _}, &1)) do 62 | false -> mark_limited(mappings, :shared, key) 63 | true -> mark_limited(mappings, id, key) 64 | end 65 | end 66 | 67 | { 68 | :noreply, 69 | %{state | key_mappings: mappings}, 70 | {:continue, :save} 71 | } 72 | end 73 | 74 | defp mark_limited(mappings, id, key) do 75 | until = DateTime.utc_now() |> DateTime.add(86_400, :second) |> DateTime.to_unix() 76 | 77 | Map.update(mappings, id, [], fn keys -> 78 | Enum.map(keys, fn k -> 79 | case k do 80 | {^key, 0} -> {key, until} 81 | pass -> pass 82 | end 83 | end) 84 | end) 85 | end 86 | 87 | @impl true 88 | def handle_cast({:mark_bad, id, key}, %Keys{key_mappings: mappings} = state) do 89 | Logger.warn("Marking key [#{key}] as bad") 90 | 91 | mappings = 92 | case Map.get(mappings, id) do 93 | nil -> 94 | reject_key(mappings, :shared, key) 95 | 96 | keys -> 97 | case Enum.any?(keys, &match?({^key, _}, &1)) do 98 | false -> 99 | reject_key(mappings, :shared, key) 100 | 101 | true -> 102 | reject_key(mappings, id, key) 103 | end 104 | end 105 | 106 | { 107 | :noreply, 108 | %{state | key_mappings: mappings}, 109 | {:continue, :save} 110 | } 111 | end 112 | 113 | defp reject_key(mappings, id, key) do 114 | Map.update( 115 | mappings, 116 | id, 117 | [], 118 | fn keys -> 119 | Enum.reject(keys, &match?({^key, _}, &1)) 120 | end 121 | ) 122 | end 123 | 124 | @impl true 125 | def handle_call({:get_key, id}, _, %Keys{key_mappings: mappings} = state) do 126 | {key, mappings} = 127 | case find_active_key(mappings, :shared) do 128 | {nil, mappings} -> find_active_key(mappings, id) 129 | km -> km 130 | end 131 | 132 | { 133 | :reply, 134 | {:ok, key}, 135 | %{state | key_mappings: mappings} 136 | } 137 | end 138 | 139 | defp find_active_key(mappings, id) do 140 | case Map.get(mappings, id) do 141 | nil -> 142 | {nil, mappings} 143 | 144 | keys -> 145 | {key, keys} = first_active_key(keys) 146 | {key, Map.put(mappings, id, keys)} 147 | end 148 | end 149 | 150 | defp first_active_key([]), do: {nil, []} 151 | defp first_active_key([{key, 0} | _rest] = keys), do: {key, keys} 152 | 153 | defp first_active_key([{key, inactive_until} = curr_key | rest]) do 154 | case DateTime.utc_now() |> DateTime.to_unix() do 155 | now when now > inactive_until -> 156 | {key, [{key, 0} | rest]} 157 | 158 | _ -> 159 | {key, rest} = first_active_key(rest) 160 | {key, [curr_key | rest]} 161 | end 162 | end 163 | 164 | defp load_keys(path) do 165 | case File.read(path) do 166 | {:ok, bin} -> 167 | Logger.info("Loading keys database from path #{path}") 168 | :erlang.binary_to_term(bin) 169 | 170 | {:error, reason} -> 171 | Logger.warn("Could not read keys database at path #{path}: #{:file.format_error(reason)}") 172 | %{} 173 | end 174 | end 175 | 176 | defp save(%Keys{storage_path: path, key_mappings: keys} = state) do 177 | Logger.debug("Saving state: #{inspect(state)}") 178 | data = :erlang.term_to_binary(keys) 179 | 180 | case File.write(path, data) do 181 | :ok -> 182 | Logger.info("Saved api key data") 183 | :ok 184 | 185 | {:error, reason} -> 186 | Logger.error("Failed to write keys database to #{path}: #{:file.format_error(reason)}") 187 | end 188 | end 189 | 190 | def add(id, key), do: GenServer.cast(__MODULE__, {:add, {id, key}}) 191 | def add(key), do: GenServer.cast(__MODULE__, {:add, {:shared, key}}) 192 | 193 | def get_key(id), do: GenServer.call(__MODULE__, {:get_key, id}) 194 | 195 | def mark_bad(id, key), do: GenServer.cast(__MODULE__, {:mark_bad, id, key}) 196 | def mark_limited(id, key), do: GenServer.cast(__MODULE__, {:mark_limited, id, key}) 197 | end 198 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cachex": {:hex, :cachex, "3.3.0", "6f2ebb8f27491fe39121bd207c78badc499214d76c695658b19d6079beeca5c2", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d90e5ee1dde14cef33f6b187af4335b88748b72b30c038969176cd4e6ccc31a1"}, 3 | "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, 4 | "dotenv_parser": {:hex, :dotenv_parser, "1.2.0", "f062900aeb57727b619aeb182fa4a8b1cbb7b4260ebec2b70b3d5c064885aff3", [:mix], [], "hexpm", "eddd69e7fde28618adb2e4153fa380db5c56161b32341e7a4e0530d86987c47f"}, 5 | "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, 6 | "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, 7 | "ex_gram": {:hex, :ex_gram, "0.22.0", "10e9e8985e0cc358a60c3b47524970d8a521dc1008a0c0cf7b5872a2459b7301", [:mix], [{:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.12", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:maxwell, "~> 2.3.1", [hex: :maxwell, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:tesla, "~> 1.2", [hex: :tesla, repo: "hexpm", optional: true]}], "hexpm", "3bb8272e00a03f1c50b26aa893c4c2111c7f3c678283d55eddd2e8ecce23fe0f"}, 8 | "google_api_custom_search": {:hex, :google_api_custom_search, "0.16.0", "00b06d3a4a693e1810ffc3f1c041467b37965fb60c6fe9658f8e3762968b0120", [:mix], [{:google_gax, "~> 0.4", [hex: :google_gax, repo: "hexpm", optional: false]}], "hexpm", "778ba93feec952998cdca952e07cc23f85f2ada62670784eb74f3a8c9a66c099"}, 9 | "google_gax": {:hex, :google_gax, "0.4.0", "83651f8561c02a295826cb96b4bddde030e2369747bbddc592c4569526bafe94", [:mix], [{:poison, ">= 3.0.0 and < 5.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "a95d36f1dd753ab31268dd8bb6de9911243c911cfda9080f64778f6297b9ac57"}, 10 | "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, 11 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 12 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 13 | "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, 14 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 15 | "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, 16 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 17 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 18 | "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, 19 | "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, 20 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 21 | "tesla": {:hex, :tesla, "1.4.1", "ff855f1cac121e0d16281b49e8f066c4a0d89965f98864515713878cca849ac8", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "95f5de35922c8c4b3945bee7406f66eb680b0955232f78f5fb7e853aa1ce201a"}, 22 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 23 | "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, 24 | } 25 | -------------------------------------------------------------------------------- /lib/image_bot.ex: -------------------------------------------------------------------------------- 1 | defmodule ImageBot do 2 | require Logger 3 | 4 | @bot :image_bot 5 | 6 | use ExGram.Bot, 7 | name: @bot, 8 | setup_commands: true 9 | 10 | def bot(), do: @bot 11 | def me(), do: ExGram.get_me(bot: bot()) 12 | 13 | command("get", description: "Search for an image on google") 14 | command("donatekey", description: "Donate a shared API key to the bot") 15 | command("addkey", description: "Add a personal API key to the bot") 16 | command("feedback", description: "Send feedback about the bot") 17 | command("info", description: "Information about this bot") 18 | command("limits", description: "Information about the API limits") 19 | command("start", description: "Get started") 20 | 21 | middleware(ExGram.Middleware.IgnoreUsername) 22 | 23 | def handle({:inline_query, %{query: query} = msg}, context) do 24 | case String.trim(query) do 25 | "" -> nil 26 | _ -> handle_inline_query(msg, context) 27 | end 28 | end 29 | 30 | def handle({:command, :start, %{text: "limited_info_request"} = msg}, context), 31 | do: handle({:command, :limits, msg}, context) 32 | 33 | def handle({:command, :start, msg}, context), do: handle({:command, :info, msg}, context) 34 | 35 | def handle({:command, :limits, _msg}, context) do 36 | answer( 37 | context, 38 | """ 39 | Unfortunately this bot can only make a limited number of free searches every day. For now, you'll need to wait until the limits reset, or add an API key. 40 | To help increase the limits for everyone, you can add extra Google API keys through the /donatekey command. 41 | Alternatively, if you just want to add a key for yourself, use /addkey. 42 | """ 43 | ) 44 | end 45 | 46 | def handle({:command, :info, _msg}, context) do 47 | {:ok, me} = me() 48 | 49 | answer( 50 | context, 51 | """ 52 | This is a bot for searching through Google images and sending results to a group\\. 53 | To use it, just type @#{me.username} \\ in the chat box\\. For example, to find pictures of dogs, you could type: 54 | @#{me.username} dogs 55 | 56 | There is a limit to the amount of searches that can be done through this bot every day\\. For more detail see the /limits command\\. 57 | 58 | [Source code on GitHub](https://github.com/bo0tzz/ImageBot-ex) 59 | """, 60 | parse_mode: "MarkdownV2" 61 | ) 62 | end 63 | 64 | def handle({:command, :feedback, %{text: ""}}, context), 65 | do: 66 | answer(context, """ 67 | This command can be used to send feedback to the owner of this bot. To use it, just put your feedback after the command. For example: 68 | /feedback I really like this bot! 69 | """) 70 | 71 | def handle({:command, :feedback, %{from: user, text: feedback}}, context) do 72 | user_ref = parse_user_ref(user) 73 | Logger.info("User [#{user_ref}] sent feedback [#{feedback}]") 74 | 75 | notify_owner(""" 76 | User #{user_ref} sent feedback: 77 | #{feedback} 78 | """) 79 | 80 | answer( 81 | context, 82 | "Thank you for sending feedback! If you asked a question, we will get in touch with you soon." 83 | ) 84 | end 85 | 86 | def handle({:command, :donatekey, %{text: ""}}, context), 87 | do: 88 | answer( 89 | context, 90 | """ 91 | With this command, you can donate a Google API key to be added to the pool of shared keys\\. This means it will be used for requests made by all users of this bot\\. 92 | If you prefer to add a key for yourself only, use the /addkey command\\. 93 | To donate a key, send it after this command, like /donatekey MY\\_API\\_KEY\\. 94 | To create a key, use the "Get a Key" button on [this page](https://developers.google.com/custom-search/v1/introduction#identify_your_application_to_google_with_api_key)\\. 95 | """, 96 | parse_mode: "MarkdownV2", 97 | disable_web_page_preview: true 98 | ) 99 | 100 | def handle({:command, :donatekey, %{from: user, text: key}}, context) do 101 | user_ref = parse_user_ref(user) 102 | Logger.info("User [#{user_ref}] graciously donated a key! <3") 103 | Keys.add(key) 104 | notify_owner("User #{user_ref} donated a key <3") 105 | answer(context, "Thank you so much for donating a key! We love you <3") 106 | end 107 | 108 | def handle({:command, :addkey, %{text: ""}}, context), 109 | do: 110 | answer( 111 | context, 112 | """ 113 | With this command, you can add a personal Google API key\\. This means it will only be used for requests made by you\\. 114 | If you prefer to add a shared key for all users, use the /donatekey command\\. 115 | To add a key, send it after this command, like /addkey MY\\_API\\_KEY\\. 116 | To create a key, use the "Get a Key" button on [this page](https://developers.google.com/custom-search/v1/introduction#identify_your_application_to_google_with_api_key)\\. 117 | """, 118 | parse_mode: "MarkdownV2", 119 | disable_web_page_preview: true 120 | ) 121 | 122 | def handle({:command, :addkey, %{from: %{id: user_id}, text: key}}, context) do 123 | Logger.info("User [#{user_id}] added a personal key") 124 | Keys.add(user_id, key) 125 | answer(context, "Your key has been added!") 126 | end 127 | 128 | def handle({:command, :get, %{text: ""}}, context) do 129 | {:ok, me} = me() 130 | 131 | answer( 132 | context, 133 | """ 134 | You can use this command to search for images, for example: /get dogs 135 | You can also use this bot through inline mode, by typing @#{me.username} dogs 136 | """ 137 | ) 138 | end 139 | 140 | def handle( 141 | {:command, :get, %{chat: %{id: chat_id}, from: %{id: user_id}, text: query}}, 142 | context 143 | ) do 144 | Logger.info("/get query from user [#{user_id}]") 145 | 146 | case Search.query(user_id, query) do 147 | {:error, :limited} -> 148 | answer(context, "The request limit has been reached. Use /limits for more information.") 149 | 150 | {:error, :unknown} -> 151 | answer(context, "Something went wrong, please try again") 152 | 153 | {:error, other} -> 154 | answer(context, "An unexpected error with code #{other} occurred") 155 | 156 | {:ok, nil} -> 157 | answer(context, "No images found") 158 | 159 | {:ok, items} -> 160 | try_send_photo(items, chat_id) 161 | end 162 | end 163 | 164 | def handle({:command, cmd, _}, _), do: Logger.warn("Ignoring unknown command: #{cmd}") 165 | def handle(event, _), do: Logger.warn("Ignoring unknown #{elem(event, 0)}") 166 | 167 | def try_send_photo(items, chat, tries \\ 3) 168 | 169 | def try_send_photo(_, chat, 0) do 170 | Logger.warn("Sending photo message failed retries") 171 | ExGram.send_message(chat, "Something went wrong! Please try again.", bot: bot()) 172 | end 173 | 174 | def try_send_photo(items, chat, tries) do 175 | url = Enum.random(items).link 176 | Logger.debug("Responding to /get with url #{url}") 177 | 178 | case ExGram.send_photo(chat, url, bot: bot()) do 179 | {:ok, _} -> 180 | :ok 181 | 182 | {:error, %{message: error_message}} -> 183 | Logger.warn("Failed to send photo message: #{error_message}") 184 | try_send_photo(items, chat, tries - 1) 185 | end 186 | end 187 | 188 | defp handle_inline_query(%{from: %{id: user_id}, query: query}, context) do 189 | Logger.info("Inline query from user [#{user_id}]") 190 | 191 | {response, opts} = 192 | case Search.query(user_id, query) do 193 | {:error, :limited} -> 194 | inline_error_response(:limited) 195 | 196 | {:error, :unknown} -> 197 | inline_error_response("Error", "Something went wrong, please try again") 198 | 199 | {:error, other} -> 200 | inline_error_response( 201 | "Error #{other}", 202 | "An unexpected error with code #{other} occurred" 203 | ) 204 | 205 | {:ok, nil} -> 206 | inline_error_response("No results", "No results were found for this query") 207 | 208 | {:ok, items} -> 209 | {as_inline_query_results(items), []} 210 | end 211 | 212 | answer_inline_query(context, response, opts) 213 | end 214 | 215 | defp as_inline_query_results(items) do 216 | Enum.map(items, fn item -> 217 | %ExGram.Model.InlineQueryResultPhoto{ 218 | id: UUID.uuid4(), 219 | type: "photo", 220 | photo_url: item.link, 221 | thumb_url: item.image.thumbnailLink, 222 | photo_width: item.image.width, 223 | photo_height: item.image.height 224 | } 225 | end) 226 | end 227 | 228 | defp inline_error_response(:limited), 229 | do: 230 | {[], 231 | [ 232 | switch_pm_text: "Request limit reached. Click here for more information.", 233 | switch_pm_parameter: "limited_info_request" 234 | ]} 235 | 236 | defp inline_error_response(title, message, opts \\ []), 237 | do: 238 | {[ 239 | %ExGram.Model.InlineQueryResultArticle{ 240 | type: "article", 241 | id: UUID.uuid4(), 242 | title: title, 243 | description: message, 244 | input_message_content: %ExGram.Model.InputTextMessageContent{ 245 | message_text: title <> "\n" <> message 246 | } 247 | } 248 | ], Keyword.merge([cache_time: 0], opts)} 249 | 250 | defp parse_user_ref(%{username: username}), do: "@" <> username 251 | 252 | defp parse_user_ref(%{id: id, first_name: first_name, last_name: last_name}), 253 | do: "[#{first_name} #{last_name}](tg://user?id=#{id})" 254 | 255 | defp parse_user_ref(%{id: id, first_name: first_name}), 256 | do: "[#{first_name}](tg://user?id=#{id})" 257 | 258 | defp notify_owner(message), 259 | do: 260 | ExGram.send_message( 261 | Application.fetch_env!(:image_bot, :feedback_chat), 262 | message, 263 | bot: bot() 264 | ) 265 | end 266 | --------------------------------------------------------------------------------