Skip to content

Commit

Permalink
first commit
Browse files Browse the repository at this point in the history
  • Loading branch information
ThaddeusJiang committed Oct 25, 2024
1 parent b74c335 commit 625fdfd
Show file tree
Hide file tree
Showing 18 changed files with 382 additions and 50 deletions.
13 changes: 8 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ 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*
_stag*
_dev*
nohup.out
44 changes: 38 additions & 6 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 @@ -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=<YOUR_TELEGRAM_BOT_TOKEN>

export TYPESENSE_URL=<YOUR_TYPESENSE_URL>
export TYPESENSE_API_KEY=<YOUR_TYPESENSE_API_KEY>

export GOOGLE_OAUTH_CLIENT_ID=<YOUR_GOOGLE_OAUTH_CLIENT_ID>
export GOOGLE_OAUTH_CLIENT_SECRET=<YOUR_GOOGLE_OAUTH_CLIENT_SECRET>

iex -S mix run --no-halt
```

2. execute permission

```sh
chmod +x start.sh
```

3. run

```sh
./start.sh
```
File renamed without changes.
9 changes: 9 additions & 0 deletions docs/dev-logs/2024-10-25.md
Original file line number Diff line number Diff line change
@@ -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)
```
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
42 changes: 42 additions & 0 deletions lib/migration/typesense.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Migration.Typesense do
def list_collections() do
req = build_request("/collections")
{:ok, res} = Req.get(req)

res.body
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
29 changes: 29 additions & 0 deletions lib/migration/typesense/note.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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" => "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
39 changes: 39 additions & 0 deletions lib/migration/typesense/photo.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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},
# "telegram://<bot_id>/<file_id>"
# TODO: 不能再简单的 reset 了,reset 会导致数据丢失,应该合理 migrate 数据
%{"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 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
48 changes: 48 additions & 0 deletions lib/save_it/bot.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ defmodule SaveIt.Bot do

alias SaveIt.TypesensePhoto

alias SaveIt.NoteService

alias SmallSdk.Telegram

@bot :save_it_bot
Expand All @@ -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")

Expand Down Expand Up @@ -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)
Expand All @@ -130,6 +137,47 @@ defmodule SaveIt.Bot do
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, %{chat: chat, text: text, reply_to_message: reply_to_message}} = msg,
_context
)
when is_binary(text) do
Logger.debug("photo: #{inspect(reply_to_message.photo)}")

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 ->
# photo_url = photo_url(chat.id, file_id)
# TypesensePhoto.update_photo!(photo_url, %{"note" => text})

note =
NoteService.create_note!(%{
content: note_content,
file_id: file_id,
belongs_to_id: chat.id
})

case note do
nil -> send_message(chat.id, "Failed to add note.")
_ -> 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
Expand Down
31 changes: 31 additions & 0 deletions lib/save_it/note_service.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
defmodule SaveIt.NoteService do
require Logger

alias SmallSdk.Typesense

def create_note!(%{
content: content,
file_id: file_id,
belongs_to_id: belongs_to_id
}) do
now_unix = DateTime.utc_now() |> DateTime.to_unix()

note_create_input =
%{
content: content,
file_id: file_id
}
|> Map.put(:belongs_to_id, Integer.to_string(belongs_to_id))
|> Map.put(:inserted_at, now_unix)
|> Map.put(:updated_at, now_unix)

doc =
Typesense.create_document!(
"notes",
note_create_input
)

Logger.debug("doc: #{inspect(doc)}")
doc
end
end
10 changes: 4 additions & 6 deletions lib/save_it/typesense_photo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading

0 comments on commit 625fdfd

Please sign in to comment.