Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: update photo's caption #24

Merged
merged 10 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
})
Comment on lines +151 to 156
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider extracting duplicate photo creation logic.

The photo creation logic is duplicated in two handlers. Consider extracting it into a private function:

+ defp create_photo(photo_file_content, file_id, caption, chat_id) do
+   PhotoService.create_photo!(%{
+     image: Base.encode64(photo_file_content),
+     caption: caption,
+     file_id: file_id,
+     belongs_to_id: chat_id
+   })
+ end

Then use it in both handlers:

typesense_photo = create_photo(photo_file_content, file.file_id, caption, chat_id)

Also applies to: 188-192


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
Comment on lines +6 to +11
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling for HTTP request.

The Req.post!/2 call could fail with network or server errors. Consider using Req.post/2 with proper error handling.

 def create_collection!(schema) do
   req = build_request("/collections")
-  res = Req.post!(req, json: schema)
-
-  Typesense.handle_response!(res)
+  case Req.post(req, json: schema) do
+    {:ok, res} -> Typesense.handle_response!(res)
+    {:error, error} -> raise "Failed to create collection: #{inspect(error)}"
+  end
 end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def create_collection!(schema) do
req = build_request("/collections")
res = Req.post!(req, json: schema)
Typesense.handle_response!(res)
end
def create_collection!(schema) do
req = build_request("/collections")
case Req.post(req, json: schema) do
{:ok, res} -> Typesense.handle_response!(res)
{:error, error} -> raise "Failed to create collection: #{inspect(error)}"
end
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
Comment on lines +13 to +32
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

URI encode collection names in URLs.

Collection names should be URI encoded to prevent injection attacks and handle special characters correctly.

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

   Typesense.handle_response!(res)
 end

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

   Typesense.handle_response!(res)
 end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
def update_collection!(collection_name, schema) do
req = build_request("/collections/#{URI.encode(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/#{URI.encode(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
Loading