Skip to content

Commit

Permalink
feat: update photo's caption (#24)
Browse files Browse the repository at this point in the history
* first commit

* refactor, show photo's caption

* refactor, typesense sdk delete_document

* refactor

* refactor: 💡 handle_response and handle_response!

* refactor: 💡 get_env

* refactor: 💡 UrlHelper validate_url

* chore: 🤖 typesense migration

SaveIt.Migration.Typesense.Photo.migrate_photos_2024_10_29!()

* refactor

* fix: typesense list_documents
  • Loading branch information
ThaddeusJiang authored Oct 29, 2024
1 parent b74c335 commit 4e86a66
Show file tree
Hide file tree
Showing 20 changed files with 471 additions and 167 deletions.
14 changes: 9 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,13 +29,8 @@ messages:

```
/search cat
/search dog
/search girl
/similar photo
/similar photo
```

Expand All @@ -53,15 +50,18 @@ https://t.me/save_it_playground
## Development

```sh
# install
# Install
mix deps.get
```

```sh
# run
export TELEGRAM_BOT_TOKEN=
export TYPESENSE_URL=
export TYPESENSE_API_KEY=
# Start typesense
docker compose up
```

```sh
# Run
export TELEGRAM_BOT_TOKEN=<YOUR_TELEGRAM_BOT_TOKEN>

iex -S mix run --no-halt
```
2 changes: 0 additions & 2 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
File renamed without changes.
7 changes: 7 additions & 0 deletions docs/dev-logs/2024-10-25.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 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}}
```
Empty file added docs/dev/readme.md
Empty file.
14 changes: 14 additions & 0 deletions docs/dev/typesense.md
Original file line number Diff line number Diff line change
@@ -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
92 changes: 33 additions & 59 deletions lib/save_it/bot.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule SaveIt.Bot do
alias SaveIt.GoogleDrive
alias SaveIt.GoogleOAuth2DeviceFlow

alias SaveIt.TypesensePhoto
alias SaveIt.PhotoService

alias SmallSdk.Telegram

Expand Down Expand Up @@ -115,6 +115,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)
Expand All @@ -124,7 +128,7 @@ 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
Expand All @@ -134,30 +138,25 @@ defmodule SaveIt.Bot do
send_message(chat.id, "Upload a photo to find similar photos.")
end

# dev-notes: never reach here, it will be handled by handle({:message, %{chat: chat, caption: nil, photo: photos}}, ctx)
# def handle({:command, :similar, %{chat: _chat, photo: photo}}, _context) 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
Expand All @@ -170,13 +169,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 =
Expand All @@ -187,17 +185,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
Expand All @@ -207,7 +205,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
Expand Down Expand Up @@ -310,18 +308,15 @@ defmodule SaveIt.Bot do
end
end

def handle(
{:update,
%ExGram.Model.Update{message: nil, edited_message: nil, channel_post: _channel_post}},
_context
) do
Logger.warning("this is a channel post, ignore it")
def handle({:edited_message, %{photo: nil}}, _context) do
Logger.warning("this is an edited message, ignore it")
# TODO: edit /search trigger re-search
{: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, %{chat: chat, caption: caption, photo: photos}}, _context) do
file_id = photos |> List.last() |> Map.get(:file_id)
PhotoService.update_photo_caption!(file_id, chat.id, caption)
end

def handle({:update, _update}, _context) do
Expand All @@ -334,19 +329,6 @@ defmodule SaveIt.Bot do
{:ok, nil}
end

defp pick_file_id_from_photo_url(photo_url) do
captures =
Regex.named_captures(~r"/files/(?<bot_id>\d+)/(?<file_id>.+)", 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
Expand All @@ -360,8 +342,8 @@ defmodule SaveIt.Bot do
Enum.map(similar_photos, fn photo ->
%ExGram.Model.InputMediaPhoto{
type: "photo",
media: pick_file_id_from_photo_url(photo["url"]),
caption: "Found photos",
media: photo["file_id"],
caption: photo["caption"],
show_caption_above_media: true
}
end)
Expand Down Expand Up @@ -414,19 +396,19 @@ defmodule SaveIt.Bot do
Enum.each(filenames, fn filename -> bot_send_file(chat_id, filename, {:file, filename}) end)
end

defp bot_send_file(chat_id, file_name, file_content, _opts \\ []) do
defp bot_send_file(chat_id, file_name, file_content, opts \\ []) do
content =
case file_content do
{:file, file} -> {:file, file}
{:file_content, file_content, file_name} -> {:file_content, file_content, file_name}
end

# caption = opts[:caption]
caption = Keyword.get(opts, :caption, "")

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
{:ok, msg} = ExGram.send_photo(chat_id, content, caption: caption)

file_id = get_file_id(msg)

image_base64 =
Expand All @@ -435,21 +417,21 @@ 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),
caption: caption,
file_id: file_id,
belongs_to_id: chat_id
})

".mp4" ->
ExGram.send_video(chat_id, content, supports_streaming: true)
ExGram.send_video(chat_id, content, supports_streaming: true, caption: caption)

".gif" ->
ExGram.send_animation(chat_id, content)
ExGram.send_animation(chat_id, content, caption: caption)

_ ->
ExGram.send_document(chat_id, content)
ExGram.send_document(chat_id, content, caption: caption)
end
end

Expand Down Expand Up @@ -487,12 +469,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
54 changes: 54 additions & 0 deletions lib/save_it/migration/typesense.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
defmodule SaveIt.Migration.Typesense do
alias SmallSdk.Typesense

import Tj.UrlHelper, only: [validate_url!: 1]

def create_collection!(schema) do
req = build_request("/collections")
res = Req.post!(req, json: schema)

Typesense.handle_response!(res)
end

def update_collection!(collection_name, schema) do
req = build_request("/collections/#{collection_name}")
res = Req.patch!(req, json: schema)

Typesense.handle_response!(res)
end

def list_collections!() do
req = build_request("/collections")
res = Req.get!(req)

Typesense.handle_response!(res)
end

def delete_collection!(collection_name) do
req = build_request("/collections/#{collection_name}")
res = Req.delete!(req)

Typesense.handle_response!(res)
end

defp get_env() do
url = Application.fetch_env!(:save_it, :typesense_url) |> validate_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
Loading

0 comments on commit 4e86a66

Please sign in to comment.