diff --git a/.air/build-errors.log b/.air/build-errors.log new file mode 100644 index 0000000..9393ddb --- /dev/null +++ b/.air/build-errors.log @@ -0,0 +1 @@ +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/.air/memogram b/.air/memogram new file mode 100755 index 0000000..b1d32cc Binary files /dev/null and b/.air/memogram differ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f585237 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +SERVER_ADDR=demo.usememos.com +ACCESS_TOKEN=your_access_token +BOT_TOKEN=your_bot_token \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/bin/memogram/main.go b/bin/memogram/main.go new file mode 100644 index 0000000..1f99ea4 --- /dev/null +++ b/bin/memogram/main.go @@ -0,0 +1,11 @@ +package main + +import "github.com/usememos/memogram" + +func main() { + service, err := memogram.NewService() + if err != nil { + panic(err) + } + service.Start() +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..6456306 --- /dev/null +++ b/client.go @@ -0,0 +1,22 @@ +package memogram + +import ( + v1pb "github.com/usememos/memos/proto/gen/api/v1" + "google.golang.org/grpc" +) + +type MemosClient struct { + AuthService v1pb.AuthServiceClient + UserService v1pb.UserServiceClient + MemoService v1pb.MemoServiceClient + ResourceService v1pb.ResourceServiceClient +} + +func NewMemosClient(conn *grpc.ClientConn) *MemosClient { + return &MemosClient{ + AuthService: v1pb.NewAuthServiceClient(conn), + UserService: v1pb.NewUserServiceClient(conn), + MemoService: v1pb.NewMemoServiceClient(conn), + ResourceService: v1pb.NewResourceServiceClient(conn), + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..948e17f --- /dev/null +++ b/config.go @@ -0,0 +1,25 @@ +package memogram + +import ( + "github.com/caarlos0/env" + "github.com/joho/godotenv" + "github.com/pkg/errors" +) + +type Config struct { + ServerAddr string `env:"SERVER_ADDR,required"` + BotToken string `env:"BOT_TOKEN,required"` +} + +func getConfigFromEnv() (*Config, error) { + err := godotenv.Load(".env") + if err != nil { + panic(err.Error()) + } + + config := Config{} + if err := env.Parse(&config); err != nil { + return nil, errors.Wrap(err, "invalid configuration") + } + return &config, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4b8cace --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/usememos/memogram + +go 1.22.2 + +require ( + github.com/caarlos0/env v3.5.0+incompatible + github.com/usememos/memos v0.21.1-0.20240509141027-584c66906883 + google.golang.org/grpc v1.63.2 +) + +require ( + github.com/go-telegram/bot v1.2.2 + github.com/joho/godotenv v1.5.1 +) + +require ( + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect + github.com/pkg/errors v0.9.1 + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be // indirect + google.golang.org/protobuf v1.34.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..72d8e63 --- /dev/null +++ b/go.sum @@ -0,0 +1,36 @@ +github.com/caarlos0/env v3.5.0+incompatible h1:Yy0UN8o9Wtr/jGHZDpCBLpNrzcFLLM2yixi/rBrKyJs= +github.com/caarlos0/env v3.5.0+incompatible/go.mod h1:tdCsowwCzMLdkqRYDlHpZCp2UooDD3MspDBjZ2AD02Y= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-telegram/bot v1.2.2 h1:LwGbSzjcSi0w4Ke8JUpgbBhJwwYTl2ITmhubeM2WvN8= +github.com/go-telegram/bot v1.2.2/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/usememos/memos v0.21.1-0.20240509141027-584c66906883 h1:eEEnUzReqdPbYZZtMTTes6p976POfHeW6lxz9LZuyFI= +github.com/usememos/memos v0.21.1-0.20240509141027-584c66906883/go.mod h1:EOB3cAQEaeYvVggKIe7oMyOJYQoZ0w2vTNS5uWUYSaU= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be h1:Zz7rLWqp0ApfsR/l7+zSHhY3PMiH2xqgxlfYfAfNpoU= +google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be/go.mod h1:dvdCTIoAGbkWbcIKBniID56/7XHTt6WfxXNMxuziJ+w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be h1:LG9vZxsWGOmUKieR8wPAUR3u3MpnYFQZROPIMaXh7/A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/memogram.go b/memogram.go new file mode 100644 index 0000000..d5284cc --- /dev/null +++ b/memogram.go @@ -0,0 +1,134 @@ +package memogram + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + "github.com/pkg/errors" + v1pb "github.com/usememos/memos/proto/gen/api/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" +) + +// userAccessTokenCache is a cache for user access token. +// Key is the user id from telegram. +// Value is the access token from memos. +var userAccessTokenCache sync.Map // map[int64]string + +type Service struct { + config *Config + client *MemosClient +} + +func NewService() (*Service, error) { + config, err := getConfigFromEnv() + if err != nil { + return nil, errors.Wrap(err, "failed to get config from env") + } + + conn, err := grpc.Dial(config.ServerAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + slog.Error("failed to connect to server", slog.Any("err", err)) + return nil, errors.Wrap(err, "failed to connect to server") + } + client := NewMemosClient(conn) + + return &Service{ + config, + client, + }, nil +} + +func (s *Service) Start() { + config, err := getConfigFromEnv() + if err != nil { + slog.Error("failed to get config from env", slog.Any("err", err)) + return + } + + opts := []bot.Option{ + bot.WithDefaultHandler(s.handler), + } + + b, err := bot.New(config.BotToken, opts...) + if err != nil { + slog.Error("failed to create bot", slog.Any("err", err)) + return + } + + slog.Info("memogram started") + + b.Start(context.Background()) +} + +func (s *Service) handler(ctx context.Context, b *bot.Bot, m *models.Update) { + if strings.HasPrefix(m.Message.Text, "/start ") { + s.startHandler(ctx, b, m) + return + } + + userID := m.Message.From.ID + if _, ok := userAccessTokenCache.Load(userID); !ok { + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: m.Message.Chat.ID, + Text: "Please start the bot with /start ", + }) + return + } + + message := m.Message + text := message.Text + if text == "" { + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: m.Message.Chat.ID, + Text: "Please input memo content", + }) + return + } + + accessToken, _ := userAccessTokenCache.Load(userID) + ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs("Authorization", fmt.Sprintf("Bearer %s", accessToken.(string)))) + memo, err := s.client.MemoService.CreateMemo(ctx, &v1pb.CreateMemoRequest{ + Content: text, + }) + if err != nil { + slog.Error("failed to create memo", slog.Any("err", err)) + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: m.Message.Chat.ID, + Text: "Failed to create memo", + }) + return + } + + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: m.Message.Chat.ID, + Text: fmt.Sprintf("Memo created with %s", memo.Name), + }) +} + +func (s *Service) startHandler(ctx context.Context, b *bot.Bot, m *models.Update) { + userID := m.Message.From.ID + accessToken := strings.TrimPrefix(m.Message.Text, "/start ") + + ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs("Authorization", fmt.Sprintf("Bearer %s", accessToken))) + user, err := s.client.AuthService.GetAuthStatus(ctx, &v1pb.GetAuthStatusRequest{}) + if err != nil { + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: m.Message.Chat.ID, + Text: "Invalid access token", + }) + return + } + + userAccessTokenCache.Store(userID, accessToken) + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: m.Message.Chat.ID, + Text: fmt.Sprintf("Hello %s!", user.Nickname), + }) +} diff --git a/scripts/.air.toml b/scripts/.air.toml new file mode 100644 index 0000000..6c3208e --- /dev/null +++ b/scripts/.air.toml @@ -0,0 +1,16 @@ +root = "." +tmp_dir = ".air" + +[build] +bin = "./.air/memogram" +cmd = "go build -o ./.air/memogram ./bin/memogram/main.go" +delay = 1000 +exclude_dir = [".air", "build"] +include_ext = ["go", "mod", "sum"] +exclude_file = [] +exclude_regex = [] +exclude_unchanged = true +follow_symlink = false +send_interrupt = true +kill_delay = 2000 +stop_on_error = true