Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
54 changes: 54 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Build and Push Docker Image

on:
push:
branches: [main]
workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up QEMU
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=sha,prefix=

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
20 changes: 16 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
# Build stage
FROM cgr.dev/chainguard/go:latest AS builder
# Build stage - 使用多阶段构建支持多架构
FROM --platform=$BUILDPLATFORM cgr.dev/chainguard/go:latest AS builder
ARG TARGETPLATFORM
ARG BUILDPLATFORM
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o memogram ./bin/memogram
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o memogram ./bin/memogram
RUN chmod +x memogram
RUN adduser -D -u 1000 memogram
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
RUN mkdir -p /app/data && chown memogram:memogram /app/data && chmod 700 /app/data

# Run stage
FROM cgr.dev/chainguard/static:latest-glibc
FROM cgr.dev/chainguard/static:latest
WORKDIR /app
ENV SERVER_ADDR=dns:localhost:5230
Comment thread
aiastia marked this conversation as resolved.
ENV BOT_TOKEN=your_telegram_bot_token
ENV DATA=/app/data/data.txt
COPY .env.example .env
COPY --from=builder /app/memogram .
# 从 builder 复制数据目录(chainguard/static 没有 shell,无法用 RUN)
COPY --from=builder /app/data /app/data
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
USER memogram
CMD ["./memogram"]
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,14 @@ Or you can start the service with Docker:
docker run -d --name memogram \
-e SERVER_ADDR=dns:localhost:5230 \
-e BOT_TOKEN=your_telegram_bot_token \
-v memogram-data:/app/data \
memogram
```

3. The Memogram service should now be running inside the Docker container. You can interact with it via your Telegram bot.

> **💡 数据持久化说明**:`-v memogram-data:/app/data` 将数据文件映射到 Docker named volume,这样更新镜像或重建容器时不会丢失用户数据。如果你想将数据直接存到宿主机指定目录,可以用 `-v /你的宿主机路径/data:/app/data` 替代。

#### Starting with Docker Compose

Or you can start the service with Docker Compose. This can be combined with the `memos` itself in the same compose file:
Expand All @@ -106,13 +109,19 @@ Or you can start the service with Docker Compose. This can be combined with the
env_file: .env
build: memogram
container_name: memogram
restart: unless-stopped
volumes:
- ./memogram-data:/app/data
```
5. Run the bot via `docker compose up -d`
6. The Memogram service should now be running inside the Docker container. You can interact with it via your Telegram bot.

> **💡 数据持久化说明**:`volumes` 配置将用户数据(如 access token 映射)持久化到宿主机目录 `./memogram-data` 中。更新镜像时只需 `docker compose up -d --build`,数据不会丢失。如果你想使用 Docker named volume,可以将 volume 改为 `memogram-data:/app/data` 并在文件末尾添加 `volumes:` 定义。

### Interaction Commands

- `/start <access_token>`: Start the bot with your Memos access token.
- Send text messages: Save the message content as a memo.
- Send files (photos, documents): Save the files as resources in a memo.
- `/search <words>`: Search for the memos.
- `/search <words>`: Search for your own memos.
- `/search --all <words>`: Search for all public memos.
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: "3.8"

services:
memogram:
image: ghcr.io/usememos/telegram-integration:latest
container_name: memogram
restart: unless-stopped
volumes:
- ./memogram-data:/app/data
environment:
- SERVER_ADDR=${SERVER_ADDR:-dns:localhost:5230}
Comment thread
aiastia marked this conversation as resolved.
- BOT_TOKEN=${BOT_TOKEN}
- BOT_PROXY_ADDR=${BOT_PROXY_ADDR:-}
- ALLOWED_USERNAMES=${ALLOWED_USERNAMES:-}
69 changes: 56 additions & 13 deletions memogram.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"html"
"io"
"log/slog"
"net/http"
Expand Down Expand Up @@ -110,7 +111,7 @@ func (s *Service) Start(ctx context.Context) {
},
{
Command: "search",
Description: "Search for the memos",
Description: "Search memos, use --all for public",
},
}
_, err = s.bot.SetMyCommands(ctx, &bot.SetMyCommandsParams{Commands: commands})
Expand All @@ -124,7 +125,8 @@ func (s *Service) Start(ctx context.Context) {
func (s *Service) createMemo(ctx context.Context, client *MemosClient, content string) (*v1pb.Memo, error) {
resp, err := client.MemoService.CreateMemo(ctx, connect.NewRequest(&v1pb.CreateMemoRequest{
Memo: &v1pb.Memo{
Content: content,
Content: content,
Visibility: v1pb.Visibility_PRIVATE,
},
}))
if err != nil {
Expand Down Expand Up @@ -297,13 +299,13 @@ func (s *Service) handler(ctx context.Context, b *bot.Bot, m *models.Update) {
return
}

baseURL := s.config.ServerAddr
baseURL := normalizeBaseURL(s.config.ServerAddr)
if s.instanceProfile != nil && s.instanceProfile.InstanceUrl != "" {
baseURL = s.instanceProfile.InstanceUrl
baseURL = normalizeBaseURL(s.instanceProfile.InstanceUrl)
}
b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: message.Chat.ID,
Text: fmt.Sprintf("Content saved as %s with [%s](%s/memos/%s)", v1pb.Visibility_name[int32(memo.Visibility)], memo.Name, baseURL, memoUID),
Text: fmt.Sprintf("Content saved as %s\n[%s](%s/memos/%s)", v1pb.Visibility_name[int32(memo.Visibility)], memo.Name, baseURL, memoUID),
ParseMode: models.ParseModeMarkdown,
DisableNotification: true,
ReplyParameters: &models.ReplyParameters{
Expand Down Expand Up @@ -453,9 +455,9 @@ func (s *Service) callbackQueryHandler(ctx context.Context, b *bot.Bot, update *
})
return
}
baseURL := s.config.ServerAddr
baseURL := normalizeBaseURL(s.config.ServerAddr)
if s.instanceProfile != nil && s.instanceProfile.InstanceUrl != "" {
baseURL = s.instanceProfile.InstanceUrl
baseURL = normalizeBaseURL(s.instanceProfile.InstanceUrl)
}
b.EditMessageText(ctx, &bot.EditMessageTextParams{
ChatID: update.CallbackQuery.Message.Message.Chat.ID,
Expand All @@ -477,10 +479,26 @@ func (s *Service) searchHandler(ctx context.Context, b *bot.Bot, m *models.Updat
if searchString == "" {
b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: m.Message.Chat.ID,
Text: "Usage: /search <words>",
Text: "Usage: /search [--all] <words>",
})
return
}

// Parse --all flag for searching all public memos
searchAll := false
parts := strings.Fields(searchString)
if len(parts) > 0 && parts[0] == "--all" {
searchAll = true
searchString = strings.Join(parts[1:], " ")
}
if searchString == "" {
b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: m.Message.Chat.ID,
Text: "Usage: /search [--all] <words>",
})
return
}

accessToken, ok := s.store.GetUserAccessToken(userID)
if !ok {
b.SendMessage(ctx, &bot.SendMessageParams{
Expand All @@ -499,7 +517,7 @@ func (s *Service) searchHandler(ctx context.Context, b *bot.Bot, m *models.Updat
return
}
user := resp.Msg.User
filter := buildMemoSearchFilter(searchString, user)
filter := buildMemoSearchFilter(searchString, user, searchAll)
results, err := authClient.MemoService.ListMemos(ctx, connect.NewRequest(&v1pb.ListMemosRequest{
PageSize: 10,
Filter: filter,
Expand All @@ -511,24 +529,41 @@ func (s *Service) searchHandler(ctx context.Context, b *bot.Bot, m *models.Updat

memos := results.Msg.GetMemos()

baseURL := normalizeBaseURL(s.config.ServerAddr)
if s.instanceProfile != nil && s.instanceProfile.InstanceUrl != "" {
baseURL = normalizeBaseURL(s.instanceProfile.InstanceUrl)
}
if len(memos) == 0 {
b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: m.Message.Chat.ID,
Text: "No memos found for the specified search criteria.",
})
} else {
for _, memo := range results.Msg.GetMemos() {
tgMessage := memo.Name + "\n" + memo.Content
memoUID, err := ExtractMemoUIDFromName(memo.Name)
escapedContent := html.EscapeString(memo.Content)
var tgMessage string
if err != nil {
slog.Error("failed to extract memo UID", slog.Any("err", err))
tgMessage = fmt.Sprintf("<b>%s</b>\n<code>%s</code>", html.EscapeString(memo.Name), escapedContent)
} else {
memoLink := fmt.Sprintf("%s/memos/%s", baseURL, memoUID)
tgMessage = fmt.Sprintf("<a href=\"%s\">%s</a>\n<code>%s</code>", memoLink, memo.Name, escapedContent)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: m.Message.Chat.ID,
Text: tgMessage,
ChatID: m.Message.Chat.ID,
Text: tgMessage,
ParseMode: models.ParseModeHTML,
})
}
}
}

func buildMemoSearchFilter(searchString string, user *v1pb.User) string {
func buildMemoSearchFilter(searchString string, user *v1pb.User, searchAll bool) string {
filter := fmt.Sprintf("content.contains(%q)", searchString)
if searchAll {
return fmt.Sprintf("%s && visibility == \"PUBLIC\"", filter)
}
if user == nil {
return filter
}
Expand Down Expand Up @@ -612,6 +647,14 @@ func (s *Service) sendError(b *bot.Bot, chatID int64, err error) {
})
}

func normalizeBaseURL(raw string) string {
raw = strings.TrimPrefix(raw, "dns:")
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
raw = "http://" + raw
}
return strings.TrimRight(raw, "/")
}

func parseAllowedUsernames(raw string) map[string]struct{} {
allowed := make(map[string]struct{})
for _, entry := range strings.Split(raw, ",") {
Expand Down
27 changes: 23 additions & 4 deletions memogram_search_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ func TestBuildMemoSearchFilterUsesUsernameResourceName(t *testing.T) {
got := buildMemoSearchFilter("needle", &v1pb.User{
Name: "users/alice",
Username: "alice",
})
}, false)
want := `content.contains("needle") && creator == "users/alice"`
if got != want {
t.Fatalf("unexpected filter:\nwant: %q\ngot: %q", want, got)
Expand All @@ -20,25 +20,44 @@ func TestBuildMemoSearchFilterUsesUsernameResourceName(t *testing.T) {
func TestBuildMemoSearchFilterFallsBackToUsername(t *testing.T) {
got := buildMemoSearchFilter("needle", &v1pb.User{
Username: "alice",
})
}, false)
want := `content.contains("needle") && creator == "users/alice"`
if got != want {
t.Fatalf("unexpected filter:\nwant: %q\ngot: %q", want, got)
}
}

func TestBuildMemoSearchFilterEscapesSearchString(t *testing.T) {
got := buildMemoSearchFilter(`quote " test`, &v1pb.User{Name: "users/alice"})
got := buildMemoSearchFilter(`quote " test`, &v1pb.User{Name: "users/alice"}, false)
want := `content.contains("quote \" test") && creator == "users/alice"`
if got != want {
t.Fatalf("unexpected filter:\nwant: %q\ngot: %q", want, got)
}
}

func TestBuildMemoSearchFilterAllowsUnknownUser(t *testing.T) {
got := buildMemoSearchFilter("needle", nil)
got := buildMemoSearchFilter("needle", nil, false)
want := `content.contains("needle")`
if got != want {
t.Fatalf("unexpected filter:\nwant: %q\ngot: %q", want, got)
}
}

func TestBuildMemoSearchFilterSearchAll(t *testing.T) {
got := buildMemoSearchFilter("needle", &v1pb.User{
Name: "users/alice",
Username: "alice",
}, true)
want := `content.contains("needle") && visibility == "PUBLIC"`
if got != want {
t.Fatalf("unexpected filter:\nwant: %q\ngot: %q", want, got)
}
}

func TestBuildMemoSearchFilterSearchAllNoUser(t *testing.T) {
got := buildMemoSearchFilter("needle", nil, true)
want := `content.contains("needle") && visibility == "PUBLIC"`
if got != want {
t.Fatalf("unexpected filter:\nwant: %q\ngot: %q", want, got)
}
}