mirror of
				https://github.com/usememos/memos.git
				synced 2025-11-01 01:06:04 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			359 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			359 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package integration
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"context"
 | |
| 	"encoding/json"
 | |
| 	"fmt"
 | |
| 	"path/filepath"
 | |
| 	"slices"
 | |
| 	"strconv"
 | |
| 	"time"
 | |
| 	"unicode/utf16"
 | |
| 
 | |
| 	"github.com/lithammer/shortuuid/v4"
 | |
| 	"github.com/pkg/errors"
 | |
| 	"github.com/yourselfhosted/gomark/ast"
 | |
| 	"github.com/yourselfhosted/gomark/parser"
 | |
| 	"github.com/yourselfhosted/gomark/parser/tokenizer"
 | |
| 
 | |
| 	"github.com/usememos/memos/plugin/telegram"
 | |
| 	"github.com/usememos/memos/plugin/webhook"
 | |
| 	storepb "github.com/usememos/memos/proto/gen/store"
 | |
| 	apiv1 "github.com/usememos/memos/server/route/api/v1"
 | |
| 	apiv2 "github.com/usememos/memos/server/route/api/v2"
 | |
| 	"github.com/usememos/memos/store"
 | |
| )
 | |
| 
 | |
| type TelegramHandler struct {
 | |
| 	store *store.Store
 | |
| }
 | |
| 
 | |
| func NewTelegramHandler(store *store.Store) *TelegramHandler {
 | |
| 	return &TelegramHandler{store: store}
 | |
| }
 | |
| 
 | |
| func (t *TelegramHandler) BotToken(ctx context.Context) string {
 | |
| 	if setting, err := t.store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
 | |
| 		Name: apiv1.SystemSettingTelegramBotTokenName.String(),
 | |
| 	}); err == nil && setting != nil {
 | |
| 		return setting.Value
 | |
| 	}
 | |
| 	return ""
 | |
| }
 | |
| 
 | |
| const (
 | |
| 	workingMessage = "Working on sending your memo..."
 | |
| 	successMessage = "Success"
 | |
| )
 | |
| 
 | |
| func (t *TelegramHandler) MessageHandle(ctx context.Context, bot *telegram.Bot, message telegram.Message, attachments []telegram.Attachment) error {
 | |
| 	reply, err := bot.SendReplyMessage(ctx, message.Chat.ID, message.MessageID, workingMessage)
 | |
| 	if err != nil {
 | |
| 		return errors.Wrap(err, "Failed to SendReplyMessage")
 | |
| 	}
 | |
| 
 | |
| 	messageSenderID := strconv.FormatInt(message.From.ID, 10)
 | |
| 	var creatorID int32
 | |
| 	userSettingList, err := t.store.ListUserSettings(ctx, &store.FindUserSetting{
 | |
| 		Key: storepb.UserSettingKey_USER_SETTING_TELEGRAM_USER_ID,
 | |
| 	})
 | |
| 	if err != nil {
 | |
| 		return errors.Wrap(err, "Failed to find userSettingList")
 | |
| 	}
 | |
| 	for _, userSetting := range userSettingList {
 | |
| 		if userSetting.GetTelegramUserId() == messageSenderID {
 | |
| 			creatorID = userSetting.UserId
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// If creatorID is not found, ask the user to set the telegram userid in UserSetting of memos.
 | |
| 	if creatorID == 0 {
 | |
| 		_, err := bot.EditMessage(ctx, message.Chat.ID, reply.MessageID, fmt.Sprintf("Please set your telegram userid %d in UserSetting of memos", message.From.ID), nil)
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	create := &store.Memo{
 | |
| 		UID:        shortuuid.New(),
 | |
| 		CreatorID:  creatorID,
 | |
| 		Visibility: store.Private,
 | |
| 	}
 | |
| 	if message.Text != nil {
 | |
| 		create.Content = convertToMarkdown(*message.Text, message.Entities)
 | |
| 	}
 | |
| 	if message.Caption != nil {
 | |
| 		create.Content = convertToMarkdown(*message.Caption, message.CaptionEntities)
 | |
| 	}
 | |
| 	if message.ForwardFromChat != nil {
 | |
| 		create.Content += fmt.Sprintf("\n\n[Message link](%s)", message.GetMessageLink())
 | |
| 	}
 | |
| 	memoMessage, err := t.store.CreateMemo(ctx, create)
 | |
| 	if err != nil {
 | |
| 		_, err := bot.EditMessage(ctx, message.Chat.ID, reply.MessageID, fmt.Sprintf("Failed to CreateMemo: %s", err), nil)
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	// Dynamically upsert tags from memo content.
 | |
| 	nodes, err := parser.Parse(tokenizer.Tokenize(create.Content))
 | |
| 	if err != nil {
 | |
| 		return errors.Wrap(err, "Failed to parse content")
 | |
| 	}
 | |
| 	tags := []string{}
 | |
| 	apiv2.TraverseASTNodes(nodes, func(node ast.Node) {
 | |
| 		if tagNode, ok := node.(*ast.Tag); ok {
 | |
| 			tag := tagNode.Content
 | |
| 			if !slices.Contains(tags, tag) {
 | |
| 				tags = append(tags, tag)
 | |
| 			}
 | |
| 		}
 | |
| 	})
 | |
| 	for _, tag := range tags {
 | |
| 		_, err := t.store.UpsertTag(ctx, &store.Tag{
 | |
| 			Name:      tag,
 | |
| 			CreatorID: creatorID,
 | |
| 		})
 | |
| 		if err != nil {
 | |
| 			return errors.Wrap(err, "Failed to upsert tag")
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Create memo related resources.
 | |
| 	for _, attachment := range attachments {
 | |
| 		// Fill the common field of create
 | |
| 		create := store.Resource{
 | |
| 			UID:       shortuuid.New(),
 | |
| 			CreatorID: creatorID,
 | |
| 			Filename:  filepath.Base(attachment.FileName),
 | |
| 			Type:      attachment.GetMimeType(),
 | |
| 			Size:      attachment.FileSize,
 | |
| 			MemoID:    &memoMessage.ID,
 | |
| 		}
 | |
| 
 | |
| 		err := apiv1.SaveResourceBlob(ctx, t.store, &create, bytes.NewReader(attachment.Data))
 | |
| 		if err != nil {
 | |
| 			_, err := bot.EditMessage(ctx, message.Chat.ID, reply.MessageID, fmt.Sprintf("Failed to SaveResourceBlob: %s", err), nil)
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		_, err = t.store.CreateResource(ctx, &create)
 | |
| 		if err != nil {
 | |
| 			_, err := bot.EditMessage(ctx, message.Chat.ID, reply.MessageID, fmt.Sprintf("Failed to CreateResource: %s", err), nil)
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	keyboard := generateKeyboardForMemoID(memoMessage.ID)
 | |
| 	_, err = bot.EditMessage(ctx, message.Chat.ID, reply.MessageID, fmt.Sprintf("Saved as %s Memo %d", memoMessage.Visibility, memoMessage.ID), keyboard)
 | |
| 	_ = t.dispatchMemoRelatedWebhook(ctx, *memoMessage, "memos.memo.created")
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| func (t *TelegramHandler) CallbackQueryHandle(ctx context.Context, bot *telegram.Bot, callbackQuery telegram.CallbackQuery) error {
 | |
| 	var memoID int32
 | |
| 	var visibility store.Visibility
 | |
| 	n, err := fmt.Sscanf(callbackQuery.Data, "%s %d", &visibility, &memoID)
 | |
| 	if err != nil || n != 2 {
 | |
| 		return bot.AnswerCallbackQuery(ctx, callbackQuery.ID, fmt.Sprintf("Failed to parse callbackQuery.Data %s", callbackQuery.Data))
 | |
| 	}
 | |
| 
 | |
| 	memo, err := t.store.GetMemo(ctx, &store.FindMemo{
 | |
| 		ID: &memoID,
 | |
| 	})
 | |
| 	if err != nil {
 | |
| 		return bot.AnswerCallbackQuery(ctx, callbackQuery.ID, fmt.Sprintf("Failed to call FindMemo %s", err))
 | |
| 	}
 | |
| 	if memo == nil {
 | |
| 		_, err = bot.EditMessage(ctx, callbackQuery.Message.Chat.ID, callbackQuery.Message.MessageID, fmt.Sprintf("Memo %d not found", memoID), nil)
 | |
| 		if err != nil {
 | |
| 			return bot.AnswerCallbackQuery(ctx, callbackQuery.ID, fmt.Sprintf("Failed to EditMessage %s", err))
 | |
| 		}
 | |
| 		return bot.AnswerCallbackQuery(ctx, callbackQuery.ID, fmt.Sprintf("Memo %d not found, possibly deleted elsewhere", memoID))
 | |
| 	}
 | |
| 
 | |
| 	setting, err := t.store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
 | |
| 		Name: apiv1.SystemSettingDisablePublicMemosName.String(),
 | |
| 	})
 | |
| 	if err != nil {
 | |
| 		return bot.AnswerCallbackQuery(ctx, callbackQuery.ID, fmt.Sprintf("Failed to get workspace setting %s", err))
 | |
| 	}
 | |
| 	if setting != nil && setting.Value != "" {
 | |
| 		disablePublicMemo := false
 | |
| 		err = json.Unmarshal([]byte(setting.Value), &disablePublicMemo)
 | |
| 		if err != nil {
 | |
| 			return bot.AnswerCallbackQuery(ctx, callbackQuery.ID, fmt.Sprintf("Failed to get workspace setting %s", err))
 | |
| 		}
 | |
| 		if disablePublicMemo && visibility == store.Public {
 | |
| 			return bot.AnswerCallbackQuery(ctx, callbackQuery.ID, fmt.Sprintf("Failed to changing Memo %d to %s\n(workspace disallowed public memo)", memoID, visibility))
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	update := store.UpdateMemo{
 | |
| 		ID:         memoID,
 | |
| 		Visibility: &visibility,
 | |
| 	}
 | |
| 	err = t.store.UpdateMemo(ctx, &update)
 | |
| 	if err != nil {
 | |
| 		return bot.AnswerCallbackQuery(ctx, callbackQuery.ID, fmt.Sprintf("Failed to call UpdateMemo %s", err))
 | |
| 	}
 | |
| 
 | |
| 	keyboard := generateKeyboardForMemoID(memoID)
 | |
| 	_, err = bot.EditMessage(ctx, callbackQuery.Message.Chat.ID, callbackQuery.Message.MessageID, fmt.Sprintf("Saved as %s Memo %d", visibility, memoID), keyboard)
 | |
| 	if err != nil {
 | |
| 		return bot.AnswerCallbackQuery(ctx, callbackQuery.ID, fmt.Sprintf("Failed to EditMessage %s", err))
 | |
| 	}
 | |
| 
 | |
| 	err = bot.AnswerCallbackQuery(ctx, callbackQuery.ID, fmt.Sprintf("Success changing Memo %d to %s", memoID, visibility))
 | |
| 
 | |
| 	memo, webhookErr := t.store.GetMemo(ctx, &store.FindMemo{
 | |
| 		ID: &memoID,
 | |
| 	})
 | |
| 	if webhookErr == nil {
 | |
| 		_ = t.dispatchMemoRelatedWebhook(ctx, *memo, "memos.memo.updated")
 | |
| 	}
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| func generateKeyboardForMemoID(id int32) [][]telegram.InlineKeyboardButton {
 | |
| 	allVisibility := []store.Visibility{
 | |
| 		store.Public,
 | |
| 		store.Protected,
 | |
| 		store.Private,
 | |
| 	}
 | |
| 
 | |
| 	buttons := make([]telegram.InlineKeyboardButton, 0, len(allVisibility))
 | |
| 	for _, v := range allVisibility {
 | |
| 		button := telegram.InlineKeyboardButton{
 | |
| 			Text:         v.String(),
 | |
| 			CallbackData: fmt.Sprintf("%s %d", v, id),
 | |
| 		}
 | |
| 		buttons = append(buttons, button)
 | |
| 	}
 | |
| 
 | |
| 	return [][]telegram.InlineKeyboardButton{buttons}
 | |
| }
 | |
| 
 | |
| func convertToMarkdown(text string, messageEntities []telegram.MessageEntity) string {
 | |
| 	insertions := make(map[int]string)
 | |
| 
 | |
| 	for _, e := range messageEntities {
 | |
| 		var before, after string
 | |
| 
 | |
| 		// this is supported by the current markdown
 | |
| 		switch e.Type {
 | |
| 		case telegram.Bold:
 | |
| 			before = "**"
 | |
| 			after = "**"
 | |
| 		case telegram.Italic:
 | |
| 			before = "*"
 | |
| 			after = "*"
 | |
| 		case telegram.Strikethrough:
 | |
| 			before = "~~"
 | |
| 			after = "~~"
 | |
| 		case telegram.Code:
 | |
| 			before = "`"
 | |
| 			after = "`"
 | |
| 		case telegram.Pre:
 | |
| 			before = "```" + e.Language
 | |
| 			after = "```"
 | |
| 		case telegram.TextLink:
 | |
| 			before = "["
 | |
| 			after = fmt.Sprintf(`](%s)`, e.URL)
 | |
| 		case telegram.Spoiler:
 | |
| 			before = "||"
 | |
| 			after = "||"
 | |
| 		}
 | |
| 
 | |
| 		if before != "" {
 | |
| 			insertions[e.Offset] += before
 | |
| 			insertions[e.Offset+e.Length] = after + insertions[e.Offset+e.Length]
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	input := []rune(text)
 | |
| 	var output []rune
 | |
| 	utf16pos := 0
 | |
| 
 | |
| 	for i := 0; i < len(input); i++ {
 | |
| 		output = append(output, []rune(insertions[utf16pos])...)
 | |
| 		output = append(output, input[i])
 | |
| 		utf16pos += len(utf16.Encode([]rune{input[i]}))
 | |
| 	}
 | |
| 	output = append(output, []rune(insertions[utf16pos])...)
 | |
| 
 | |
| 	return string(output)
 | |
| }
 | |
| 
 | |
| func (t *TelegramHandler) dispatchMemoRelatedWebhook(ctx context.Context, memo store.Memo, activityType string) error {
 | |
| 	webhooks, err := t.store.ListWebhooks(ctx, &store.FindWebhook{
 | |
| 		CreatorID: &memo.CreatorID,
 | |
| 	})
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	for _, hook := range webhooks {
 | |
| 		payload := t.convertMemoToWebhookPayload(ctx, memo)
 | |
| 		payload.ActivityType = activityType
 | |
| 		payload.URL = hook.Url
 | |
| 		err := webhook.Post(*payload)
 | |
| 		if err != nil {
 | |
| 			return errors.Wrap(err, "failed to post webhook")
 | |
| 		}
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (t *TelegramHandler) convertMemoToWebhookPayload(ctx context.Context, memo store.Memo) (payload *webhook.WebhookPayload) {
 | |
| 	payload = &webhook.WebhookPayload{
 | |
| 		CreatorID: memo.CreatorID,
 | |
| 		CreatedTs: time.Now().Unix(),
 | |
| 		Memo: &webhook.Memo{
 | |
| 			ID:           memo.ID,
 | |
| 			CreatorID:    memo.CreatorID,
 | |
| 			CreatedTs:    memo.CreatedTs,
 | |
| 			UpdatedTs:    memo.UpdatedTs,
 | |
| 			Content:      memo.Content,
 | |
| 			Visibility:   memo.Visibility.String(),
 | |
| 			Pinned:       memo.Pinned,
 | |
| 			ResourceList: make([]*webhook.Resource, 0),
 | |
| 			RelationList: make([]*webhook.MemoRelation, 0),
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	resourceList, err := t.store.ListResources(ctx, &store.FindResource{
 | |
| 		MemoID: &memo.ID,
 | |
| 	})
 | |
| 
 | |
| 	if err != nil {
 | |
| 		return payload
 | |
| 	}
 | |
| 	for _, resource := range resourceList {
 | |
| 		payload.Memo.ResourceList = append(payload.Memo.ResourceList, &webhook.Resource{
 | |
| 			ID:           resource.ID,
 | |
| 			CreatorID:    resource.CreatorID,
 | |
| 			CreatedTs:    resource.CreatedTs,
 | |
| 			UpdatedTs:    resource.UpdatedTs,
 | |
| 			Filename:     resource.Filename,
 | |
| 			Type:         resource.Type,
 | |
| 			Size:         resource.Size,
 | |
| 			InternalPath: resource.InternalPath,
 | |
| 			ExternalLink: resource.ExternalLink,
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	relationList, err := t.store.ListMemoRelations(ctx, &store.FindMemoRelation{
 | |
| 		MemoID: &memo.ID,
 | |
| 	})
 | |
| 
 | |
| 	if err != nil {
 | |
| 		return payload
 | |
| 	}
 | |
| 
 | |
| 	for _, relation := range relationList {
 | |
| 		payload.Memo.RelationList = append(payload.Memo.RelationList, &webhook.MemoRelation{
 | |
| 			MemoID:        relation.MemoID,
 | |
| 			RelatedMemoID: relation.RelatedMemoID,
 | |
| 			Type:          string(relation.Type),
 | |
| 		})
 | |
| 	}
 | |
| 	return payload
 | |
| }
 |