├── test ├── test_helper.exs └── exas_test.exs ├── compose.yml ├── config └── runtime.exs ├── lib ├── exas │ └── application.ex ├── db-server.ex ├── env-config.ex ├── tg-voice.ex ├── ai-wrapper.ex ├── main.ex ├── tools.ex └── sql.ex ├── README.md ├── .dockerignore ├── .gitignore ├── mix.exs ├── Dockerfile └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | app: 5 | build: . 6 | volumes: 7 | - exasdata:/app/data/ 8 | container_name: exas_bot 9 | restart: unless-stopped 10 | 11 | volumes: 12 | exasdata: 13 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | EnvConfig.init() 4 | 5 | config :telegex, 6 | token: EnvConfig.get_tg_token(), 7 | caller_adapter: HTTPoison 8 | 9 | config :openai, 10 | http_options: [recv_timeout: :infinity, async: :once] 11 | -------------------------------------------------------------------------------- /lib/exas/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Exas.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | IO.puts("Starting Exas...") 6 | EnvConfig.init() 7 | 8 | children = [ 9 | Exas.DbServer, 10 | {Task, 11 | fn -> 12 | Exas.start() 13 | end} 14 | ] 15 | 16 | opts = [strategy: :one_for_one, name: Exas.Supervisor] 17 | Supervisor.start_link(children, opts) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI assistant that works in Telegram. Built with Elixir 2 | 3 | Built this project to just try out Elixir language. 4 | 5 | ## Local launch 6 | 1. create a `.env` file with vars from `lib/env-config.ex` file 7 | 2. run `mix dev`, and enjoy✨ 8 | 9 | ## Features 10 | - voice understanding 11 | - cleaning context with `/new` command 12 | 13 | ## Todo 14 | - handle `/start` command 15 | - add tools for task management 16 | - image understanding 17 | - more test coverage 18 | -------------------------------------------------------------------------------- /lib/db-server.ex: -------------------------------------------------------------------------------- 1 | defmodule Exas.DbServer do 2 | use GenServer 3 | 4 | # API 5 | 6 | def start_link(_args) do 7 | GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 8 | end 9 | 10 | def get_conn do 11 | GenServer.call(__MODULE__, :get_conn) 12 | end 13 | 14 | # Callbacks 15 | 16 | def init(:ok) do 17 | path = EnvConfig.get_db_path() 18 | IO.puts("running db in #{path}") 19 | db = Exas.Sql.connect(path) 20 | 21 | Exas.Sql.migrate(db) 22 | Exas.Sql.create_default_info(db, EnvConfig.get_chat_id(), "first") 23 | 24 | {:ok, db} 25 | end 26 | 27 | def handle_call(:get_conn, _from, db) do 28 | {:reply, db, db} 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 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 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Ignore package tarball (built via "mix hex.build"). 20 | exas-*.tar 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | 26 | # local sqlite db 27 | data.db 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 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Ignore package tarball (built via "mix hex.build"). 20 | exas-*.tar 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # env file for secrets. open lib/env-config.ex to see required env vars 26 | .env 27 | .env.* 28 | 29 | # local sqlite db 30 | data.db 31 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Exas.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :exas, 7 | version: "0.1.0", 8 | elixir: "~> 1.18", 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 | mod: {Exas.Application, []}, 18 | extra_applications: [:logger] 19 | ] 20 | end 21 | 22 | defp deps do 23 | [ 24 | {:dotenv_parser, "~> 2.0"}, 25 | {:jason, "~> 1.4"}, 26 | {:openai, "~> 0.6.2"}, 27 | {:telegex, "~> 1.9.0-rc.0"}, 28 | {:exqlite, "~> 0.27"}, 29 | {:nanoid, "~> 2.0"} 30 | 31 | # {:ecto_sql, "~> 3.11"}, 32 | # {:ecto_sqlite3, "~> 0.13.0"} 33 | # {:nadia, "~> 0.7.0"} 34 | ] 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/env-config.ex: -------------------------------------------------------------------------------- 1 | defmodule EnvConfig do 2 | def init do 3 | case get!("MIX_ENV") do 4 | "dev" -> 5 | :ok = DotenvParser.load_file(".env") 6 | :ok 7 | 8 | "prod" -> 9 | :ok = DotenvParser.load_file(".env.prod") 10 | :ok 11 | end 12 | end 13 | 14 | @spec get!(String.t()) :: String.t() 15 | def get!(name) do 16 | case System.get_env(name) do 17 | nil -> raise "env #{name} not found" 18 | s -> s 19 | end 20 | end 21 | 22 | def get_openai_token do 23 | "OPENAI_KEY" |> get!() 24 | end 25 | 26 | def get_openai_model do 27 | "OPENAI_MODEL" |> get!() 28 | end 29 | 30 | def get_db_path do 31 | "DBPATH" |> get!() 32 | end 33 | 34 | def get_tg_token do 35 | "TELEGRAM_TOKEN" |> get!() 36 | end 37 | 38 | def get_chat_id do 39 | "TELEGRAM_CHAT_ID" |> get!() 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile для Elixir 1.18 приложения 2 | 3 | # Этап сборки 4 | FROM elixir:1.18-alpine AS builder 5 | 6 | ENV ERL_AFLAGS="+JMsingle true" 7 | 8 | # Установка необходимых зависимостей для сборки 9 | RUN apk add --no-cache build-base git 10 | 11 | # Установка рабочей директории 12 | WORKDIR /app 13 | 14 | # Установка hex и rebar 15 | RUN mix local.hex --force && \ 16 | mix local.rebar --force 17 | 18 | # Копирование файлов проекта 19 | COPY mix.exs mix.lock ./ 20 | COPY config config 21 | 22 | # Получение зависимостей 23 | RUN mix deps.clean --all 24 | RUN mix clean 25 | RUN mix deps.get --only prod 26 | 27 | # Копирование исходного кода 28 | COPY lib lib 29 | 30 | ENV MIX_ENV=prod 31 | 32 | # Компиляция проекта 33 | RUN mix compile 34 | RUN mix release --overwrite 35 | 36 | # Финальный этап 37 | FROM alpine:3.18 38 | 39 | # Установка необходимых пакетов для запуска 40 | RUN apk add --no-cache openssl ncurses-libs libgcc libstdc++ ffmpeg 41 | 42 | 43 | # Установка рабочей директории 44 | WORKDIR /app 45 | 46 | # Копирование релиза из этапа сборки 47 | COPY --from=builder --chown=app:app /app/_build/prod/rel/exas ./ 48 | 49 | COPY .env.prod . 50 | 51 | ENV MIX_ENV=prod 52 | # Запуск приложения 53 | CMD ["bin/exas", "start"] 54 | -------------------------------------------------------------------------------- /lib/tg-voice.ex: -------------------------------------------------------------------------------- 1 | defmodule TelegramVoiceDownloader do 2 | import EnvConfig 3 | 4 | def api_url do 5 | "https://api.telegram.org/bot#{get_tg_token()}" 6 | end 7 | 8 | def file_url do 9 | "https://api.telegram.org/file/bot#{get_tg_token()}" 10 | end 11 | 12 | def get_file_path(file_id) do 13 | url = "#{api_url()}/getFile?file_id=#{file_id}" 14 | 15 | case HTTPoison.get(url) do 16 | {:ok, %{status_code: 200, body: body}} -> 17 | body 18 | |> Jason.decode!() 19 | |> get_in(["result", "file_path"]) 20 | 21 | error -> 22 | IO.inspect(error, label: "get_file_path error") 23 | nil 24 | end 25 | end 26 | 27 | # Скачать файл на диск 28 | def download_voice(file_id, save_as \\ "voice.ogg") do 29 | case get_file_path(file_id) do 30 | nil -> 31 | {:error, :no_file_path} 32 | 33 | path -> 34 | url = "#{file_url()}/#{path}" 35 | 36 | case HTTPoison.get(url) do 37 | {:ok, %{status_code: 200, body: body}} -> 38 | File.write(save_as, body) 39 | {:ok, save_as} 40 | 41 | error -> 42 | IO.inspect(error, label: "download_voice error") 43 | {:error, :download_failed} 44 | end 45 | end 46 | end 47 | 48 | def ogg_to_mp3(input_path, output_path) do 49 | cmd = "ffmpeg" 50 | 51 | args = [ 52 | "-y", 53 | "-i", 54 | input_path, 55 | "-vn", 56 | "-ar", 57 | "44100", 58 | "-ac", 59 | "2", 60 | "-b:a", 61 | "192k", 62 | output_path 63 | ] 64 | 65 | case System.cmd(cmd, args, stderr_to_stdout: true) do 66 | {_, 0} -> {:ok, output_path} 67 | {output, code} -> {:error, %{exit_code: code, message: output}} 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/exas_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExasTest do 2 | use ExUnit.Case 3 | import Exas.Sql 4 | doctest Exas 5 | 6 | test "full db test" do 7 | conn = connect(":memory:") 8 | migrate(conn) 9 | create_default_info(conn, "some_id", "first") 10 | create_msg(conn, "hello", "user", "first") 11 | create_msg(conn, "hellod", "assistant", "first") 12 | create_msg(conn, "hellod", "assistant", "dd") 13 | msgs = list_msg(conn, "first") 14 | IO.inspect(msgs) 15 | assert length(msgs) == 2 16 | end 17 | 18 | test "info db test" do 19 | conn = connect(":memory:") 20 | migrate(conn) 21 | create_default_info(conn, "some_id", "first") 22 | create_default_info(conn, "some_id", "first") 23 | create_default_info(conn, "some_id", "first") 24 | create_default_info(conn, "some_id", "first") 25 | info = get_current_info(conn) 26 | assert info.id == "some_id" 27 | assert length(list_infos(conn)) === 1 28 | 29 | create_info(conn, "some_id", "second") 30 | info = get_current_info(conn) 31 | assert info.current_chat === "second" 32 | infos = list_infos(conn) 33 | infos |> IO.inspect() 34 | assert length(infos) === 2 35 | end 36 | 37 | test "task database test" do 38 | conn = connect(":memory:") 39 | migrate(conn) 40 | 41 | create_task(conn, Nanoid.generate(16), "first task", "desc") 42 | tasks = list_tasks(conn) 43 | 44 | assert length(tasks) == 1 45 | assert Enum.at(tasks, 0).title == "first task" 46 | 47 | update_task(conn, Enum.at(tasks, 0).id, "updated task", "desc") 48 | tasks = list_tasks(conn) 49 | 50 | assert length(tasks) == 1 51 | assert Enum.at(tasks, 0).title == "updated task" 52 | 53 | Enum.at(tasks, 0) |> IO.inspect() 54 | 55 | delete_task(conn, Enum.at(tasks, 0).id) 56 | tasks = list_tasks(conn) 57 | 58 | assert length(tasks) == 0 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/ai-wrapper.ex: -------------------------------------------------------------------------------- 1 | defmodule Exas.AiWrapper do 2 | import EnvConfig 3 | 4 | @system """ 5 | be consise and blunt 6 | """ 7 | 8 | def run_chat(history) do 9 | # print history for logging purpuses 10 | "--- History" |> IO.puts() 11 | history = [%{role: "developer", content: @system}] ++ history 12 | 13 | history 14 | |> Enum.each(fn f -> 15 | f |> IO.inspect() 16 | end) 17 | 18 | "--- History end" |> IO.puts() 19 | 20 | OpenAI.chat_completion( 21 | [ 22 | model: get_openai_model(), 23 | messages: history, 24 | n: 1, 25 | tools: Exas.Tools.get_full_definitions() 26 | ], 27 | %OpenAI.Config{ 28 | api_key: get_openai_token() 29 | } 30 | ) 31 | end 32 | 33 | def handle_chat(history) do 34 | case run_chat(history) do 35 | {:error, e} -> 36 | IO.inspect(e) 37 | :error 38 | 39 | {:ok, data} -> 40 | # IO.inspect(data) 41 | content = data[:choices] |> Enum.at(0) |> Map.get("message") 42 | 43 | case {content["content"], content["tool_calls"]} do 44 | {c, _} when not is_nil(c) -> 45 | # IO.puts(c) 46 | h = history ++ [%{role: "assistant", content: c}] 47 | {:ok, h, c} 48 | 49 | {_, f} when not is_nil(f) -> 50 | h = 51 | (history ++ [%{role: "assistant", tool_calls: f}]) ++ 52 | (f |> Enum.map(fn c -> call_fn(c) end)) 53 | 54 | handle_chat(h) 55 | end 56 | end 57 | end 58 | 59 | def call_fn(call) do 60 | id = call["id"] 61 | name = call["function"]["name"] 62 | args = call["function"]["arguments"] 63 | output = Exas.Tools.call_functions(name, args) 64 | %{role: "tool", tool_call_id: id, content: output} 65 | end 66 | 67 | def get_tool_defs do 68 | [ 69 | %{ 70 | type: "function", 71 | function: %{ 72 | name: "get_weather", 73 | description: "Get current temperature for a given location.", 74 | parameters: %{ 75 | type: "object", 76 | properties: %{ 77 | location: %{ 78 | type: "string", 79 | description: "City and country e.g. Bogotá, Colombia" 80 | } 81 | }, 82 | required: [ 83 | "location" 84 | ], 85 | additionalProperties: false 86 | }, 87 | strict: true 88 | } 89 | } 90 | ] 91 | end 92 | 93 | def transcribe(path) do 94 | case OpenAI.audio_transcription( 95 | # file path 96 | path, 97 | [ 98 | model: "gpt-4o-transcribe" 99 | ], 100 | %OpenAI.Config{ 101 | api_key: get_openai_token() 102 | } 103 | ) do 104 | {:ok, %{text: text}} -> 105 | IO.puts(text) 106 | {:ok, text} 107 | 108 | {:error, reason} -> 109 | IO.puts(reason) 110 | :error 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/main.ex: -------------------------------------------------------------------------------- 1 | defmodule Exas do 2 | alias Exas.AiWrapper 3 | 4 | def start() do 5 | loop(0) 6 | end 7 | 8 | defp loop(offset) do 9 | case Telegex.get_updates(offset: offset) do 10 | {:ok, updates} -> 11 | new_offset = 12 | Enum.reduce(updates, offset, fn update, acc_offset -> 13 | handle_update(update) 14 | max(update.update_id + 1, acc_offset) 15 | end) 16 | 17 | loop(new_offset) 18 | 19 | {:error, _} -> 20 | nil 21 | :timer.sleep(1000) 22 | loop(offset) 23 | end 24 | end 25 | 26 | defp handle_update(update) do 27 | db = Exas.DbServer.get_conn() 28 | message = update.message 29 | %{chat: %{id: chat_id}} = message 30 | %{current_chat: chat, id: id} = Exas.Sql.get_current_info(db) 31 | 32 | if id != chat_id |> Integer.to_string() do 33 | IO.inspect(id, label: "id") 34 | IO.inspect(chat_id, label: "chat_id") 35 | IO.puts("got: #{chat_id}, expected: #{id}") 36 | Telegex.send_message(chat_id, "not allowed") 37 | else 38 | history = 39 | Exas.Sql.list_msg(db, chat) |> Enum.map(fn h -> %{role: h.role, content: h.content} end) 40 | 41 | case {message.text, message.voice} do 42 | {text, _} when is_binary(text) -> 43 | case text do 44 | "/new" -> 45 | Telegex.send_message(chat_id, "history cleaned") 46 | Exas.Sql.create_info(db, EnvConfig.get_chat_id(), Nanoid.generate(10)) 47 | 48 | msg -> 49 | {:ok, _, c} = 50 | Exas.AiWrapper.handle_chat(history ++ [%{role: "user", content: msg}]) 51 | 52 | Telegex.send_message(chat_id, c) 53 | 54 | Exas.Sql.create_msg(db, msg, "user", chat) 55 | Exas.Sql.create_msg(db, c, "assistant", chat) 56 | end 57 | 58 | {_, %Telegex.Type.Voice{} = voice} -> 59 | {:ok, text, ogg_path, mp3_path} = handle_voice(voice.file_id) 60 | 61 | {:ok, _, c} = 62 | Exas.AiWrapper.handle_chat(history ++ [%{role: "user", content: text}]) 63 | 64 | Telegex.send_message(chat_id, c) 65 | 66 | Exas.Sql.create_msg(db, text, "user", chat) 67 | Exas.Sql.create_msg(db, c, "assistant", chat) 68 | delete_voice(ogg_path, mp3_path) 69 | 70 | {a, b} -> 71 | "TGAPI: got something else" |> IO.puts() 72 | "text" |> IO.puts() 73 | a |> IO.inspect() 74 | "voice" |> IO.puts() 75 | b |> IO.inspect() 76 | end 77 | end 78 | end 79 | 80 | def handle_voice(id) do 81 | random_id = Nanoid.generate(20) 82 | ogg_path = random_id <> ".ogg" 83 | mp3_path = random_id <> ".mp3" 84 | {:ok, _} = TelegramVoiceDownloader.download_voice(id, ogg_path) 85 | {:ok, _} = TelegramVoiceDownloader.ogg_to_mp3(ogg_path, mp3_path) 86 | 87 | case AiWrapper.transcribe(mp3_path) do 88 | {:ok, text} -> 89 | {:ok, text, ogg_path, mp3_path} 90 | 91 | _ -> 92 | :error 93 | end 94 | end 95 | 96 | def delete_voice(ogg_path, mp3_path) do 97 | case {File.rm(ogg_path), File.rm(mp3_path)} do 98 | {:ok, :ok} -> 99 | IO.puts("files deleted") 100 | 101 | {{:error, reason}, {:error, reason2}} -> 102 | IO.puts("some error happend while deleting ogg,mp3 files") 103 | IO.inspect(reason) 104 | IO.inspect(reason2) 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/tools.ex: -------------------------------------------------------------------------------- 1 | defmodule Exas.Tools do 2 | def get_full_definitions() do 3 | definitions() 4 | |> Enum.map(fn %{name: name, description: d, properties: p} -> 5 | %{ 6 | type: "function", 7 | function: %{ 8 | name: name, 9 | description: d, 10 | parameters: %{ 11 | type: "object", 12 | properties: p, 13 | required: Map.keys(p) |> Enum.map(&to_string/1), 14 | additionalProperties: false 15 | }, 16 | strict: true 17 | } 18 | } 19 | end) 20 | end 21 | 22 | def call_functions(name, args) do 23 | IO.puts("calling #{name}") 24 | IO.puts("args #{args}") 25 | 26 | case definitions() |> Enum.find(fn d -> d.name === name end) do 27 | nil -> 28 | "tool #{name} not found" 29 | 30 | tool -> 31 | tool.call.(Jason.decode!(args)) 32 | end 33 | end 34 | 35 | defp definitions() do 36 | [ 37 | %{ 38 | call: fn args -> 39 | db = Exas.DbServer.get_conn() 40 | # logging 41 | "list_tasks is calling" |> IO.puts() 42 | IO.inspect(args) 43 | 44 | # calling 45 | tasks = Exas.Sql.list_tasks(db) 46 | 47 | # encoding to json 48 | case Jason.encode(tasks) do 49 | {:ok, s} -> s 50 | {:error, reason} -> "list tasks failed: #{reason}" 51 | end 52 | end, 53 | name: "list_tasks", 54 | description: "Returns all user's tasks", 55 | properties: %{} 56 | }, 57 | %{ 58 | call: fn args -> 59 | db = Exas.DbServer.get_conn() 60 | # logging 61 | "create_task is calling" |> IO.puts() 62 | IO.inspect(args) 63 | 64 | # calling 65 | id = Nanoid.generate(16) 66 | Exas.Sql.create_task(db, id, args["title"], args["description"]) 67 | 68 | # encoding to json 69 | "new task created with id = #{id}}" 70 | end, 71 | name: "create_task", 72 | description: "Creates a new task", 73 | properties: %{ 74 | title: %{ 75 | type: "string", 76 | description: "Title of the task" 77 | }, 78 | description: %{ 79 | type: "string", 80 | description: "Description of the task" 81 | } 82 | } 83 | }, 84 | %{ 85 | call: fn args -> 86 | db = Exas.DbServer.get_conn() 87 | # logging 88 | "update_task is calling" |> IO.puts() 89 | IO.inspect(args) 90 | 91 | # calling 92 | Exas.Sql.update_task(db, args["id"], args["title"], args["description"]) 93 | 94 | # encoding to json 95 | "task updated" 96 | end, 97 | name: "update_task", 98 | description: "Updates the existing task", 99 | properties: %{ 100 | id: %{ 101 | type: "string", 102 | description: "ID of the task" 103 | }, 104 | title: %{ 105 | type: "string", 106 | description: "Title of the task" 107 | }, 108 | description: %{ 109 | type: "string", 110 | description: "Description of the task" 111 | } 112 | } 113 | }, 114 | %{ 115 | call: fn args -> 116 | db = Exas.DbServer.get_conn() 117 | # logging 118 | "delete_task is calling" |> IO.puts() 119 | IO.inspect(args) 120 | 121 | # calling 122 | Exas.Sql.delete_task(db, args["id"]) 123 | 124 | # encoding to json 125 | "task deleted" 126 | end, 127 | name: "delete_task", 128 | description: "Deletes the existing task by ID", 129 | properties: %{ 130 | id: %{ 131 | type: "string", 132 | description: "ID of the task" 133 | } 134 | } 135 | } 136 | ] 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/sql.ex: -------------------------------------------------------------------------------- 1 | defmodule Exas.Sql do 2 | defmodule Message do 3 | @derive Jason.Encoder 4 | defstruct [:id, :content, :role, :chat] 5 | end 6 | 7 | defmodule Info do 8 | @derive Jason.Encoder 9 | defstruct [:id, :current_chat, :created_at] 10 | end 11 | 12 | defmodule UserTask do 13 | @derive Jason.Encoder 14 | defstruct [:id, :title, :description] 15 | end 16 | 17 | @spec connect(binary()) :: Exqlite.Sqlite3.db() 18 | def connect(path) do 19 | case Exqlite.Sqlite3.open(path) do 20 | {:ok, db} -> 21 | db 22 | 23 | {:error, r} -> 24 | raise r 25 | end 26 | end 27 | 28 | def migrate(db) do 29 | :ok = 30 | Exqlite.Sqlite3.execute( 31 | db, 32 | """ 33 | create table if not exists messages ( 34 | id text primary key, 35 | content text not null, 36 | role text not null, 37 | chat text not null 38 | ); 39 | 40 | create table if not exists infos ( 41 | id text not null, 42 | current_chat text not null, 43 | created_at text not null 44 | ); 45 | 46 | create table if not exists tasks ( 47 | id text not null, 48 | title text not null, 49 | description text not null 50 | ) 51 | """ 52 | ) 53 | end 54 | 55 | def create_msg(db, content, role, chat) do 56 | id = Nanoid.generate(16) 57 | 58 | {:ok, stt} = 59 | Exqlite.Sqlite3.prepare( 60 | db, 61 | "INSERT INTO messages (id, content, role, chat) VALUES (?, ?, ?, ?)" 62 | ) 63 | 64 | :ok = Exqlite.Sqlite3.bind(stt, [id, content, role, chat]) 65 | :done = Exqlite.Sqlite3.step(db, stt) 66 | :ok = Exqlite.Sqlite3.release(db, stt) 67 | end 68 | 69 | def create_default_info(db, id, chat_name) do 70 | {:ok, stt} = 71 | Exqlite.Sqlite3.prepare( 72 | db, 73 | """ 74 | INSERT INTO infos (id, current_chat, created_at) 75 | SELECT ?, ?, ? 76 | WHERE NOT EXISTS (SELECT 1 FROM infos); 77 | """ 78 | ) 79 | 80 | :ok = Exqlite.Sqlite3.bind(stt, [id, chat_name, DateTime.utc_now() |> DateTime.to_iso8601()]) 81 | :done = Exqlite.Sqlite3.step(db, stt) 82 | :ok = Exqlite.Sqlite3.release(db, stt) 83 | end 84 | 85 | def create_info(db, id, chat_name) do 86 | {:ok, stt} = 87 | Exqlite.Sqlite3.prepare( 88 | db, 89 | """ 90 | INSERT INTO infos (id, current_chat, created_at) VALUES (?,?,?) 91 | """ 92 | ) 93 | 94 | :ok = Exqlite.Sqlite3.bind(stt, [id, chat_name, DateTime.utc_now() |> DateTime.to_iso8601()]) 95 | :done = Exqlite.Sqlite3.step(db, stt) 96 | :ok = Exqlite.Sqlite3.release(db, stt) 97 | end 98 | 99 | def create_task(db, id, title, desc) do 100 | {:ok, stt} = 101 | Exqlite.Sqlite3.prepare( 102 | db, 103 | """ 104 | INSERT INTO tasks (id, title, description) VALUES (?,?,?) 105 | """ 106 | ) 107 | 108 | :ok = Exqlite.Sqlite3.bind(stt, [id, title, desc]) 109 | :done = Exqlite.Sqlite3.step(db, stt) 110 | :ok = Exqlite.Sqlite3.release(db, stt) 111 | end 112 | 113 | def update_task(db, id, title, desc) do 114 | {:ok, stt} = 115 | Exqlite.Sqlite3.prepare( 116 | db, 117 | """ 118 | UPDATE tasks 119 | SET title = ?, description = ? 120 | WHERE id = ?; 121 | """ 122 | ) 123 | 124 | :ok = Exqlite.Sqlite3.bind(stt, [title, desc, id]) 125 | :done = Exqlite.Sqlite3.step(db, stt) 126 | :ok = Exqlite.Sqlite3.release(db, stt) 127 | end 128 | 129 | def delete_task(db, id) do 130 | {:ok, stt} = 131 | Exqlite.Sqlite3.prepare( 132 | db, 133 | """ 134 | DELETE FROM tasks 135 | WHERE id = ?; 136 | """ 137 | ) 138 | 139 | :ok = Exqlite.Sqlite3.bind(stt, [id]) 140 | :done = Exqlite.Sqlite3.step(db, stt) 141 | :ok = Exqlite.Sqlite3.release(db, stt) 142 | end 143 | 144 | def get_current_info(db) do 145 | {:ok, stt} = 146 | Exqlite.Sqlite3.prepare( 147 | db, 148 | """ 149 | SELECT * FROM infos ORDER BY created_at DESC LIMIT 1 150 | """ 151 | ) 152 | 153 | {:row, [id, current_chat, created_at]} = Exqlite.Sqlite3.step(db, stt) 154 | 155 | %Info{ 156 | id: id, 157 | current_chat: current_chat, 158 | created_at: created_at 159 | } 160 | end 161 | 162 | def list_infos(db) do 163 | {:ok, stmt} = Exqlite.Sqlite3.prepare(db, "SELECT * FROM infos ") 164 | 165 | fetch_all_rows(db, stmt) 166 | |> Enum.map(fn [id, current_chat, created_at] -> 167 | %Info{ 168 | id: id, 169 | current_chat: current_chat, 170 | created_at: created_at 171 | } 172 | end) 173 | end 174 | 175 | def list_msg(db, chat) do 176 | {:ok, stmt} = Exqlite.Sqlite3.prepare(db, "SELECT * FROM messages where chat = ?") 177 | :ok = Exqlite.Sqlite3.bind(stmt, [chat]) 178 | 179 | fetch_all_rows(db, stmt) 180 | |> Enum.map(fn [id, content, role, chat] -> 181 | %Message{ 182 | id: id, 183 | content: content, 184 | role: role, 185 | chat: chat 186 | } 187 | end) 188 | end 189 | 190 | def list_tasks(db) do 191 | {:ok, stmt} = Exqlite.Sqlite3.prepare(db, "SELECT * FROM tasks") 192 | 193 | fetch_all_rows(db, stmt) 194 | |> Enum.map(fn [id, title, description] -> 195 | %UserTask{ 196 | id: id, 197 | title: title, 198 | description: description 199 | } 200 | end) 201 | end 202 | 203 | defp fetch_all_rows(conn, stmt) do 204 | Stream.repeatedly(fn -> Exqlite.Sqlite3.step(conn, stmt) end) 205 | |> Enum.take_while(fn 206 | :done -> false 207 | {:row, _} -> true 208 | end) 209 | |> Enum.map(fn {:row, values} -> 210 | values 211 | end) 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, 3 | "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, 4 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 5 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 6 | "dotenv_parser": {:hex, :dotenv_parser, "2.0.1", "7b2f02dd04f7f68458fe9942caf7de52a5dbada580fd87c722a8bed9f7fca0f2", [:mix], [], "hexpm", "f00780ad69e9089bd9b782e88d3e8110141dff0c54577b6c3735cef6925e4272"}, 7 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, 8 | "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, 9 | "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.13.0", "0c3dc8ff24f378ef108619fd5c18bbbea43cb86dc8733c1c596bd7e0a5bb9e28", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.9", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "8ab7d8bf6663b811b80c9fa8730780f7077106c40a3fdbae384fe8f82315b257"}, 10 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 11 | "exqlite": {:hex, :exqlite, "0.30.1", "a85ed253ab7304c3733a74d3bc62b68afb0c7245ce30416aa6f9d0cfece0e58f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "15714871147d8d6c12be034013d351ce670e02c09b7f49accabb23e9290d80a0"}, 12 | "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [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.4.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", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, 13 | "httpoison": {:hex, :httpoison, "2.2.3", "a599d4b34004cc60678999445da53b5e653630651d4da3d14675fedc9dd34bd6", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "fa0f2e3646d3762fdc73edb532104c8619c7636a6997d20af4003da6cfc53e53"}, 14 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 15 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 16 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 17 | "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 18 | "nanoid": {:hex, :nanoid, "2.1.0", "d192a5bf1d774258bc49762b480fca0e3128178fa6d35a464af2a738526607fd", [:mix], [], "hexpm", "ebc7a342d02d213534a7f93a091d569b9fea7f26fcd3a638dc655060fc1f76ac"}, 19 | "openai": {:hex, :openai, "0.6.2", "48ee0dc74f4d0327ebf78eaeeed8c3595e10eca88fd2a7cdd8b53473645078d6", [:mix], [{:httpoison, "~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "508c8c2937ef8627d111d9142ff9cc284d39cd0c9b8244339551ac5f5fe0e643"}, 20 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 21 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 22 | "telegex": {:hex, :telegex, "1.9.0-rc.0", "0f5b76a21879c900ddbaa56c582a10b168ebd218d007ed88e11ff1003364f6d5", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "0ab1b2413e29a2b6c0c7ecaa7be4e921dfd7f1adf12e8d5167f27f5bacd78050"}, 23 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 24 | "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, 25 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 26 | } 27 | --------------------------------------------------------------------------------