diff --git a/.gitignore b/.gitignore index c81d2b0..1c7fa49 100644 --- a/.gitignore +++ b/.gitignore @@ -25,10 +25,14 @@ save_it-*.tar # Temporary files, for example, from tests. /tmp/ .DS_Store -data -dev.sh -start.sh -run.sh -nohup.out +# data _local +data + +# scripts +_local* +_dev* +_stag* +_prod* +nohup.out diff --git a/README.md b/README.md index fdf3744..72bd025 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A telegram bot can Save photos and Search photos +## Features + - [x] Save photos via a link - [x] Search photos using semantic search - [x] Find similar photos by photo @@ -27,13 +29,8 @@ messages: ``` /search cat - /search dog - /search girl - -/similar photo - /similar photo ``` @@ -58,10 +55,45 @@ mix deps.get ``` ```sh -# run +# start typesense +docker compose up +``` + +```sh +# modify env export TELEGRAM_BOT_TOKEN= export TYPESENSE_URL= export TYPESENSE_API_KEY= iex -S mix run --no-halt ``` + +Pro Tips: create shell script for fast run app + +1. touch start.sh + +```sh +#!/bin/sh + +export TELEGRAM_BOT_TOKEN= + +export TYPESENSE_URL= +export TYPESENSE_API_KEY= + +export GOOGLE_OAUTH_CLIENT_ID= +export GOOGLE_OAUTH_CLIENT_SECRET= + +iex -S mix run --no-halt +``` + +2. execute permission + +```sh +chmod +x start.sh +``` + +3. run + +```sh +./start.sh +``` diff --git a/config/runtime.exs b/config/runtime.exs index 047283f..faaef97 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -9,5 +9,3 @@ config :save_it, :typesense_api_key, System.get_env("TYPESENSE_API_KEY", "xyz") # optional config :save_it, :google_oauth_client_id, System.get_env("GOOGLE_OAUTH_CLIENT_ID") config :save_it, :google_oauth_client_secret, System.get_env("GOOGLE_OAUTH_CLIENT_SECRET") - -config :save_it, :web_url, System.get_env("WEB_URL", "http://localhost:4000") diff --git a/docs/worklogs/2024-10-21.md b/docs/dev-logs/2024-10-21.md similarity index 100% rename from docs/worklogs/2024-10-21.md rename to docs/dev-logs/2024-10-21.md diff --git a/docs/dev-logs/2024-10-25.md b/docs/dev-logs/2024-10-25.md new file mode 100644 index 0000000..ddc6e58 --- /dev/null +++ b/docs/dev-logs/2024-10-25.md @@ -0,0 +1,9 @@ +# 2024-10-25 + +## Req call typesense API alway :timeout, but typesense was updated. + +```elixir +** (MatchError) no match of right hand side value: {:error, %Req.TransportError{reason: :timeout}} + (save_it 0.2.0-rc.1) lib/migration/typesense.ex:11: Migration.Typesense.create_collection!/1 + priv/typesense/reset.exs:3: (file) +``` diff --git a/docs/dev/readme.md b/docs/dev/readme.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/dev/typesense.md b/docs/dev/typesense.md new file mode 100644 index 0000000..c564cf3 --- /dev/null +++ b/docs/dev/typesense.md @@ -0,0 +1,14 @@ +# Typesense + +## Typesense API Errors + +``` +# 400 Bad Request - The request could not be understood due to malformed syntax. +# 401 Unauthorized - Your API key is wrong. +# 404 Not Found - The requested resource is not found. +# 409 Conflict - When a resource already exists. +# 422 Unprocessable Entity - Request is well-formed, but cannot be processed. +# 503 Service Unavailable - We’re temporarily offline. Please try again later. +``` + +docs: https://typesense.org/docs/27.1/api/api-errors.html#api-errors diff --git a/lib/migration/typesense.ex b/lib/migration/typesense.ex new file mode 100644 index 0000000..8bea87e --- /dev/null +++ b/lib/migration/typesense.ex @@ -0,0 +1,42 @@ +defmodule Migration.Typesense do + def create_collection!(schema) do + req = build_request("/collections") + {:ok, res} = Req.post(req, json: schema) + + res.body + end + + def delete_collection!(collection_name) do + req = build_request("/collections/#{collection_name}") + {:ok, res} = Req.delete(req) + + res.body + end + + def list_collections() do + req = build_request("/collections") + {:ok, res} = Req.get(req) + + res.body + end + + defp get_env() do + url = Application.fetch_env!(:save_it, :typesense_url) + api_key = Application.fetch_env!(:save_it, :typesense_api_key) + + {url, api_key} + end + + defp build_request(path) do + {url, api_key} = get_env() + + Req.new( + base_url: url, + url: path, + headers: [ + {"Content-Type", "application/json"}, + {"X-TYPESENSE-API-KEY", api_key} + ] + ) + end +end diff --git a/lib/migration/typesense/note.ex b/lib/migration/typesense/note.ex new file mode 100644 index 0000000..727e73a --- /dev/null +++ b/lib/migration/typesense/note.ex @@ -0,0 +1,30 @@ +defmodule Migration.Typesense.Note do + alias Migration.Typesense + + @notes_schema %{ + "name" => "notes", + "fields" => [ + # TODO: 第一步先实现文本,今后再考虑图片 + %{"name" => "content", "type" => "string"}, + # references photos.id + # note: 抉择:这个 app 核心是给予图片的视觉笔记,暂时不考虑单独 text 的笔记 + # %{"name" => "photo_id", "type" => "string"}, + # note: 既然不能实现 RDB reference,那么就直接存储 file_id + %{"name" => "message_id", "type" => "string"}, + %{"name" => "file_id", "type" => "string"}, + %{"name" => "belongs_to_id", "type" => "string"}, + %{"name" => "inserted_at", "type" => "int64"}, + %{"name" => "updated_at", "type" => "int64"} + ], + "default_sorting_field" => "inserted_at" + } + + def create_collection!() do + Typesense.create_collection!(@notes_schema) + end + + def reset!() do + Typesense.delete_collection!(@notes_schema["name"]) + Typesense.create_collection!(@notes_schema) + end +end diff --git a/lib/migration/typesense/photo.ex b/lib/migration/typesense/photo.ex new file mode 100644 index 0000000..fb66d48 --- /dev/null +++ b/lib/migration/typesense/photo.ex @@ -0,0 +1,35 @@ +defmodule Migration.Typesense.Photo do + alias Migration.Typesense + + @photos_schema %{ + "name" => "photos", + "fields" => [ + # image: base64 encoded string + %{"name" => "image", "type" => "image", "store" => false}, + %{ + "name" => "image_embedding", + "type" => "float[]", + "embed" => %{ + "from" => ["image"], + "model_config" => %{ + "model_name" => "ts/clip-vit-b-p32" + } + } + }, + %{"name" => "caption", "type" => "string", "optional" => true, "facet" => false}, + %{"name" => "file_id", "type" => "string"}, + %{"name" => "belongs_to_id", "type" => "string"}, + %{"name" => "inserted_at", "type" => "int64"} + ], + "default_sorting_field" => "inserted_at" + } + + def create_collection!() do + Typesense.create_collection!(@photos_schema) + end + + def reset!() do + Typesense.delete_collection!(@photos_schema["name"]) + Typesense.create_collection!(@photos_schema) + end +end diff --git a/lib/save_it/bot.ex b/lib/save_it/bot.ex index 3390101..4a38b06 100644 --- a/lib/save_it/bot.ex +++ b/lib/save_it/bot.ex @@ -5,7 +5,9 @@ defmodule SaveIt.Bot do alias SaveIt.GoogleDrive alias SaveIt.GoogleOAuth2DeviceFlow - alias SaveIt.TypesensePhoto + alias SaveIt.PhotoService + + alias SaveIt.NoteService alias SmallSdk.Telegram @@ -24,6 +26,7 @@ defmodule SaveIt.Bot do command("start") command("search", description: "Search photos") + command("note", description: "Add a note to a photo") command("similar", description: "Find similar photos") command("about", description: "About the bot") @@ -115,6 +118,10 @@ defmodule SaveIt.Bot do end end + def handle({:command, :search, %{chat: chat, text: nil}}, _context) do + send_message(chat.id, "What do you want to search? animal, food, etc.") + end + def handle({:command, :search, %{chat: chat, text: text}}, _context) when is_binary(text) do q = String.trim(text) @@ -124,12 +131,51 @@ defmodule SaveIt.Bot do send_message(chat.id, "What do you want to search? animal, food, etc.") _ -> - photos = TypesensePhoto.search_photos!(q, belongs_to_id: chat.id) + photos = PhotoService.search_photos!(q, belongs_to_id: chat.id) answer_photos(chat.id, photos) end end + # dev-notes: never reach here, text never be nil, "" + # def handle({:command, :note, %{chat: chat, text: nil}}, _context) do + # end + + def handle( + {:command, :note, + %{message_id: message_id, chat: chat, text: text, reply_to_message: reply_to_message}}, + _context + ) + when is_binary(text) do + file_id = reply_to_message.photo |> List.last() |> Map.get(:file_id) + + case file_id do + nil -> + send_message(chat.id, "Please reply to a photo to add a note.") + + _ -> + case String.trim(text) do + "" -> + send_message(chat.id, "What note do you want to add?") + + note_content -> + note = + NoteService.create_note!(%{ + content: note_content, + message_id: message_id, + file_id: file_id, + belongs_to_id: chat.id + }) + + case note do + nil -> send_message(chat.id, "Failed to add note.") + # TODO:nice_to_have: 添加一个 emoji 即可 + _ -> send_message(chat.id, "Note added successfully.") + end + end + end + end + def handle({:command, :similar, %{chat: chat, photo: nil}}, _context) do send_message(chat.id, "Upload a photo to find similar photos.") end @@ -139,25 +185,24 @@ defmodule SaveIt.Bot do # end # caption: nil -> find same photos - def handle({:message, %{chat: chat, caption: nil, photo: photos}}, ctx) do + def handle({:message, %{chat: chat, caption: nil, photo: photos}}, _ctx) do photo = List.last(photos) file = ExGram.get_file!(photo.file_id) photo_file_content = Telegram.download_file_content!(file.file_path) - bot_id = ctx.bot_info.id chat_id = chat.id typesense_photo = - TypesensePhoto.create_photo!(%{ + PhotoService.create_photo!(%{ image: Base.encode64(photo_file_content), caption: "", - url: photo_url(bot_id, file.file_id), + file_id: file.file_id, belongs_to_id: chat_id }) photos = - TypesensePhoto.search_similar_photos!( + PhotoService.search_similar_photos!( typesense_photo["id"], distance_threshold: 0.1, belongs_to_id: chat_id @@ -170,13 +215,12 @@ defmodule SaveIt.Bot do end # caption: contains /similar or /search -> search similar photos; otherwise, find same photos - def handle({:message, %{chat: chat, caption: caption, photo: photos}}, ctx) do + def handle({:message, %{chat: chat, caption: caption, photo: photos}}, _ctx) do photo = List.last(photos) file = ExGram.get_file!(photo.file_id) photo_file_content = Telegram.download_file_content!(file.file_path) - bot_id = ctx.bot_info.id chat_id = chat.id caption = @@ -187,17 +231,17 @@ defmodule SaveIt.Bot do end typesense_photo = - TypesensePhoto.create_photo!(%{ + PhotoService.create_photo!(%{ image: Base.encode64(photo_file_content), caption: caption, - url: photo_url(bot_id, file.file_id), + file_id: file.file_id, belongs_to_id: chat_id }) case caption do "" -> photos = - TypesensePhoto.search_similar_photos!( + PhotoService.search_similar_photos!( typesense_photo["id"], distance_threshold: 0.4, belongs_to_id: chat_id @@ -207,7 +251,7 @@ defmodule SaveIt.Bot do _ -> photos = - TypesensePhoto.search_similar_photos!( + PhotoService.search_similar_photos!( typesense_photo["id"], distance_threshold: 0.1, belongs_to_id: chat_id @@ -319,9 +363,24 @@ defmodule SaveIt.Bot do {:ok, nil} end - def handle({:edited_message, _msg}, _context) do - Logger.warning("this is an edited message, ignore it") - {:ok, nil} + def handle({:edited_message, msg}, _context) do + %{message_id: message_id, chat: chat, text: text} = msg + + edited_note_text = + case Regex.run(~r/\/note\s+(.*)/, text) do + [_, edited_note_text] -> edited_note_text + _ -> nil + end + + case String.contains?(text, "/note") do + true -> + note = NoteService.get_note!(message_id, chat.id) + + NoteService.update_note!(note["id"], %{"content" => edited_note_text}) + + false -> + Logger.info("edited message: #{inspect(text)}, ignore it") + end end def handle({:update, _update}, _context) do @@ -334,19 +393,6 @@ defmodule SaveIt.Bot do {:ok, nil} end - defp pick_file_id_from_photo_url(photo_url) do - captures = - Regex.named_captures(~r"/files/(?\d+)/(?.+)", photo_url) - - if captures == nil do - Logger.error("Invalid photo URL: #{photo_url}") - nil - else - %{"file_id" => file_id} = captures - file_id - end - end - defp answer_photos(chat_id, nil) do send_message(chat_id, "No photos found.") end @@ -360,7 +406,7 @@ defmodule SaveIt.Bot do Enum.map(similar_photos, fn photo -> %ExGram.Model.InputMediaPhoto{ type: "photo", - media: pick_file_id_from_photo_url(photo["url"]), + media: photo["file_id"], caption: "Found photos", show_caption_above_media: true } @@ -426,7 +472,7 @@ defmodule SaveIt.Bot do case file_extension(file_name) do ext when ext in [".png", ".jpg", ".jpeg"] -> {:ok, msg} = ExGram.send_photo(chat_id, content) - bot_id = msg.from.id + file_id = get_file_id(msg) image_base64 = @@ -435,10 +481,10 @@ defmodule SaveIt.Bot do {:file_content, file_content, _file_name} -> Base.encode64(file_content) end - TypesensePhoto.create_photo!(%{ + PhotoService.create_photo!(%{ image: image_base64, caption: file_name, - url: photo_url(bot_id, file_id), + file_id: file_id, belongs_to_id: chat_id }) @@ -487,12 +533,4 @@ defmodule SaveIt.Bot do """) end end - - defp photo_url(bot_id, file_id) do - proxy_url = Application.fetch_env!(:save_it, :web_url) <> "/telegram/files" - - encoded_bot_id = URI.encode(bot_id |> to_string()) - encoded_file_id = URI.encode(file_id) - "#{proxy_url}/#{encoded_bot_id}/#{encoded_file_id}" - end end diff --git a/lib/save_it/note_service.ex b/lib/save_it/note_service.ex new file mode 100644 index 0000000..16803b7 --- /dev/null +++ b/lib/save_it/note_service.ex @@ -0,0 +1,63 @@ +defmodule SaveIt.NoteService do + require Logger + + alias SmallSdk.Typesense + + def create_note!( + %{ + message_id: message_id, + belongs_to_id: belongs_to_id + } = note_params + ) do + now_unix = DateTime.utc_now() |> DateTime.to_unix() + + note_create_input = + note_params + |> Map.put(:message_id, Integer.to_string(message_id)) + |> Map.put(:belongs_to_id, Integer.to_string(belongs_to_id)) + |> Map.put(:inserted_at, now_unix) + |> Map.put(:updated_at, now_unix) + + Typesense.create_document!( + "notes", + note_create_input + ) + end + + def update_note!(id, %{} = note_params) do + now_unix = DateTime.utc_now() |> DateTime.to_unix() + + note_update_input = + note_params + |> Map.put(:updated_at, now_unix) + + Typesense.update_document!( + "notes", + id, + note_update_input + ) + end + + def get_note!(message_id, chat_id) do + docs = + Typesense.search_documents!( + "notes", + q: "*", + query_by: "content", + filter_by: "message_id:=#{message_id} && belongs_to_id:=#{chat_id}" + ) + + case docs do + nil -> + nil + + [] -> + nil + + [doc | rest] -> + Logger.warning("Found multiple notes, skipping the rest: #{inspect(rest)}") + + doc + end + end +end diff --git a/lib/save_it/typesense_photo.ex b/lib/save_it/photo_service.ex similarity index 86% rename from lib/save_it/typesense_photo.ex rename to lib/save_it/photo_service.ex index 587b84d..1325949 100644 --- a/lib/save_it/typesense_photo.ex +++ b/lib/save_it/photo_service.ex @@ -1,4 +1,4 @@ -defmodule SaveIt.TypesensePhoto do +defmodule SaveIt.PhotoService do require Logger alias SmallSdk.Typesense @@ -19,7 +19,7 @@ defmodule SaveIt.TypesensePhoto do end def update_photo(photo) do - Typesense.update_document("photos", photo.id, photo) + Typesense.update_document!("photos", photo.id, photo) end def get_photo(photo_id) do @@ -46,7 +46,8 @@ defmodule SaveIt.TypesensePhoto do req = build_request("/multi_search") {:ok, res} = Req.post(req, json: req_body) - res.body["results"] |> typesense_results_to_documents() + # FIXME: nil check + res.body["results"] |> hd() |> Map.get("hits") |> Enum.map(&Map.get(&1, "document")) end def search_similar_photos!(photo_id, opts \\ []) when is_binary(photo_id) do @@ -69,11 +70,8 @@ defmodule SaveIt.TypesensePhoto do req = build_request("/multi_search") {:ok, res} = Req.post(req, json: req_body) - res.body["results"] |> typesense_results_to_documents() - end - - defp typesense_results_to_documents(results) do - results |> hd() |> Map.get("hits") |> Enum.map(&Map.get(&1, "document")) + # FIXME: nil check + res.body["results"] |> hd() |> Map.get("hits") |> Enum.map(&Map.get(&1, "document")) end defp get_env() do diff --git a/lib/small_sdk/typesense.ex b/lib/small_sdk/typesense.ex index 6260713..75b27ff 100644 --- a/lib/small_sdk/typesense.ex +++ b/lib/small_sdk/typesense.ex @@ -1,25 +1,78 @@ defmodule SmallSdk.Typesense do require Logger + def handle_response(res) do + case res do + %Req.Response{status: 200} -> + res.body + + %Req.Response{status: 201} -> + res.body + + %Req.Response{status: 400} -> + Logger.error("Bad Request: #{inspect(res.body)}") + raise "Bad Request" + + %Req.Response{status: 401} -> + raise "Unauthorized" + + %Req.Response{status: 404} -> + nil + + %Req.Response{status: 409} -> + raise "Conflict" + + %Req.Response{status: 422} -> + raise "Unprocessable Entity" + + %Req.Response{status: 503} -> + raise "Service Unavailable" + + _ -> + raise "Unknown error" + end + end + def create_document!(collection_name, document) do req = build_request("/collections/#{collection_name}/documents") {:ok, res} = Req.post(req, json: document) - res.body + handle_response(res) + end + + def search_documents!(collection_name, opts) do + q = Keyword.get(opts, :q, "*") + query_by = Keyword.get(opts, :query_by, "") + filter_by = Keyword.get(opts, :filter_by, "") + + req = build_request("/collections/#{collection_name}/documents/search") + + {:ok, res} = + Req.get(req, + params: %{ + q: q, + query_by: query_by, + filter_by: filter_by + } + ) + + data = handle_response(res) + + data["hits"] |> Enum.map(&Map.get(&1, "document")) end def get_document(collection_name, document_id) do req = build_request("/collections/#{collection_name}/documents/#{document_id}") {:ok, res} = Req.get(req) - res.body + handle_response(res) end - def update_document(collection_name, document_id, update_input) do + def update_document!(collection_name, document_id, update_input) do req = build_request("/collections/#{collection_name}/documents/#{document_id}") {:ok, res} = Req.patch(req, json: update_input) - res.body + handle_response(res) end def create_search_key() do diff --git a/lib/small_sdk/typesense_admin.ex b/lib/small_sdk/typesense_admin.ex deleted file mode 100644 index 1b5fd6d..0000000 --- a/lib/small_sdk/typesense_admin.ex +++ /dev/null @@ -1,66 +0,0 @@ -defmodule SmallSdk.TypesenseAdmin do - @photos_schema %{ - "name" => "photos", - "fields" => [ - # image: base64 encoded string - %{"name" => "image", "type" => "image", "store" => false}, - %{ - "name" => "image_embedding", - "type" => "float[]", - "embed" => %{ - "from" => ["image"], - "model_config" => %{ - "model_name" => "ts/clip-vit-b-p32" - } - } - }, - %{"name" => "caption", "type" => "string", "optional" => true, "facet" => false}, - # "telegram:///" - %{"name" => "url", "type" => "string"}, - # chat.id -> string - %{"name" => "belongs_to_id", "type" => "string"}, - # unix timestamp - %{"name" => "inserted_at", "type" => "int64"} - ], - "default_sorting_field" => "inserted_at" - } - - def reset() do - delete_collection!(@photos_schema["name"]) - create_collection!(@photos_schema) - end - - def create_collection!(schema) do - req = build_request("/collections") - {:ok, res} = Req.post(req, json: schema) - - res.body - end - - def delete_collection!(collection_name) do - req = build_request("/collections/#{collection_name}") - {:ok, res} = Req.delete(req) - - res.body - end - - defp get_env() do - url = Application.fetch_env!(:save_it, :typesense_url) - api_key = Application.fetch_env!(:save_it, :typesense_api_key) - - {url, api_key} - end - - defp build_request(path) do - {url, api_key} = get_env() - - Req.new( - base_url: url, - url: path, - headers: [ - {"Content-Type", "application/json"}, - {"X-TYPESENSE-API-KEY", api_key} - ] - ) - end -end diff --git a/priv/typesense/2024-10-24_create_photos_collection.exs b/priv/typesense/2024-10-24_create_photos_collection.exs new file mode 100644 index 0000000..aa0aff4 --- /dev/null +++ b/priv/typesense/2024-10-24_create_photos_collection.exs @@ -0,0 +1 @@ +Migration.Typesense.Photo.create_collection!() diff --git a/priv/typesense/2024-10-25_create_notes_collections.exs b/priv/typesense/2024-10-25_create_notes_collections.exs new file mode 100644 index 0000000..4b028b0 --- /dev/null +++ b/priv/typesense/2024-10-25_create_notes_collections.exs @@ -0,0 +1 @@ +Migration.Typesense.Note.create_collection!() diff --git a/priv/typesense/reset.exs b/priv/typesense/reset.exs index 00773d2..885e5dc 100644 --- a/priv/typesense/reset.exs +++ b/priv/typesense/reset.exs @@ -1,3 +1,4 @@ # mix run priv/typesense/reset.exs -SmallSdk.TypesenseAdmin.reset() +Migration.Typesense.Photo.reset!() +Migration.Typesense.Note.reset!() diff --git a/zeabur/template.yaml b/zeabur/template.yaml new file mode 100644 index 0000000..d0c3d30 --- /dev/null +++ b/zeabur/template.yaml @@ -0,0 +1,83 @@ +# yaml-language-server: $schema=https://schema.zeabur.app/template.json +apiVersion: zeabur.com/v1 +kind: Template +metadata: + name: SaveIt +spec: + description: SaveIt is a telegram bot that helps you save photos. + icon: https://github.com/user-attachments/assets/fae196b8-716e-4be7-a8c2-3b141984c0e5 + tags: + - Bot + - Telegram + - Photos Storage + - Search Engine + coverImage: + + readme: |- + # SaveIt + A telegram bot can Save photos and Search photos + + ## Features + - Save photos via a link + - Search photos using semantic search + - Find similar photos by photo + + ## Learn more + https://github.com/ThaddeusJiang/save_it + + + + services: + - name: save_it + icon: https://github.com/user-attachments/assets/fae196b8-716e-4be7-a8c2-3b141984c0e5 + template: GIT + spec: + source: + source: GITHUB + repo: 831394769 + branch: main + rootDirectory: / + env: + TELEGRAM_BOT_TOKEN: + default: "" + expose: false + TYPESENSE_URL: + default: http://typesense.zeabur.internal:8108 + expose: false + TYPESENSE_API_KEY: + default: ${TYPESENSE_API_KEY} + expose: false + GOOGLE_OAUTH_CLIENT_ID: + default: "" + expose: false + GOOGLE_OAUTH_CLIENT_SECRET: + default: "" + expose: false + configs: [] + - name: typesense + icon: https://typesense.org/docs/images/typesense_logo.svg + template: PREBUILT_V2 + spec: + source: + image: typesense/typesense:27.1 + ports: + - id: web + port: 8108 + type: HTTP + volumes: + - id: data + dir: /data + instructions: + - type: PASSWORD + title: Typesense API Key + content: ${TYPESENSE_API_KEY} + category: Credentials + env: + TYPESENSE_API_KEY: + default: ${PASSWORD} + expose: true + TYPESENSE_DATA_DIR: + default: /data + expose: false + + configs: []