From eefce6ade3482c9aee42543fb709145b35d050ac Mon Sep 17 00:00:00 2001 From: Steven Date: Sat, 23 Dec 2023 11:17:35 +0800 Subject: [PATCH] chore: implement webhook dispatch in v2 api --- api/v2/memo_relation_service.go | 91 +++++++++ api/v2/memo_resource_service.go | 66 ++++++ api/v2/memo_service.go | 254 +++++++++++------------- server/version/version.go | 2 +- web/src/components/MemoEditor/index.tsx | 26 ++- web/src/pages/MemoDetail.tsx | 8 +- 6 files changed, 298 insertions(+), 149 deletions(-) create mode 100644 api/v2/memo_relation_service.go create mode 100644 api/v2/memo_resource_service.go diff --git a/api/v2/memo_relation_service.go b/api/v2/memo_relation_service.go new file mode 100644 index 00000000..0f3fa6c2 --- /dev/null +++ b/api/v2/memo_relation_service.go @@ -0,0 +1,91 @@ +package v2 + +import ( + "context" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + apiv2pb "github.com/usememos/memos/proto/gen/api/v2" + "github.com/usememos/memos/store" +) + +func (s *APIV2Service) SetMemoRelations(ctx context.Context, request *apiv2pb.SetMemoRelationsRequest) (*apiv2pb.SetMemoRelationsResponse, error) { + referenceType := store.MemoRelationReference + // Delete all reference relations first. + if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ + MemoID: &request.Id, + Type: &referenceType, + }); err != nil { + return nil, status.Errorf(codes.Internal, "failed to delete memo relation") + } + + for _, relation := range request.Relations { + if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: request.Id, + RelatedMemoID: relation.RelatedMemoId, + Type: convertMemoRelationTypeToStore(relation.Type), + }); err != nil { + return nil, status.Errorf(codes.Internal, "failed to upsert memo relation") + } + } + + return &apiv2pb.SetMemoRelationsResponse{}, nil +} + +func (s *APIV2Service) ListMemoRelations(ctx context.Context, request *apiv2pb.ListMemoRelationsRequest) (*apiv2pb.ListMemoRelationsResponse, error) { + relationList := []*apiv2pb.MemoRelation{} + tempList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &request.Id, + }) + if err != nil { + return nil, err + } + for _, relation := range tempList { + relationList = append(relationList, convertMemoRelationFromStore(relation)) + } + tempList, err = s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{ + RelatedMemoID: &request.Id, + }) + if err != nil { + return nil, err + } + for _, relation := range tempList { + relationList = append(relationList, convertMemoRelationFromStore(relation)) + } + + response := &apiv2pb.ListMemoRelationsResponse{ + Relations: relationList, + } + return response, nil +} + +func convertMemoRelationFromStore(memoRelation *store.MemoRelation) *apiv2pb.MemoRelation { + return &apiv2pb.MemoRelation{ + MemoId: memoRelation.MemoID, + RelatedMemoId: memoRelation.RelatedMemoID, + Type: convertMemoRelationTypeFromStore(memoRelation.Type), + } +} + +func convertMemoRelationTypeFromStore(relationType store.MemoRelationType) apiv2pb.MemoRelation_Type { + switch relationType { + case store.MemoRelationReference: + return apiv2pb.MemoRelation_REFERENCE + case store.MemoRelationComment: + return apiv2pb.MemoRelation_COMMENT + default: + return apiv2pb.MemoRelation_TYPE_UNSPECIFIED + } +} + +func convertMemoRelationTypeToStore(relationType apiv2pb.MemoRelation_Type) store.MemoRelationType { + switch relationType { + case apiv2pb.MemoRelation_REFERENCE: + return store.MemoRelationReference + case apiv2pb.MemoRelation_COMMENT: + return store.MemoRelationComment + default: + return store.MemoRelationReference + } +} diff --git a/api/v2/memo_resource_service.go b/api/v2/memo_resource_service.go new file mode 100644 index 00000000..5c64efc0 --- /dev/null +++ b/api/v2/memo_resource_service.go @@ -0,0 +1,66 @@ +package v2 + +import ( + "context" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + apiv2pb "github.com/usememos/memos/proto/gen/api/v2" + "github.com/usememos/memos/store" +) + +func (s *APIV2Service) SetMemoResources(ctx context.Context, request *apiv2pb.SetMemoResourcesRequest) (*apiv2pb.SetMemoResourcesResponse, error) { + resources, err := s.Store.ListResources(ctx, &store.FindResource{MemoID: &request.Id}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list resources") + } + + // Delete resources that are not in the request. + for _, resource := range resources { + found := false + for _, requestResource := range request.Resources { + if resource.ID == int32(requestResource.Id) { + found = true + break + } + } + if !found { + if err = s.Store.DeleteResource(ctx, &store.DeleteResource{ + ID: int32(resource.ID), + MemoID: &request.Id, + }); err != nil { + return nil, status.Errorf(codes.Internal, "failed to delete resource") + } + } + } + + // Update resources' memo_id in the request. + for _, resource := range request.Resources { + if _, err := s.Store.UpdateResource(ctx, &store.UpdateResource{ + ID: resource.Id, + MemoID: &request.Id, + }); err != nil { + return nil, status.Errorf(codes.Internal, "failed to update resource") + } + } + + return &apiv2pb.SetMemoResourcesResponse{}, nil +} + +func (s *APIV2Service) ListMemoResources(ctx context.Context, request *apiv2pb.ListMemoResourcesRequest) (*apiv2pb.ListMemoResourcesResponse, error) { + resources, err := s.Store.ListResources(ctx, &store.FindResource{ + MemoID: &request.Id, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list resources") + } + + response := &apiv2pb.ListMemoResourcesResponse{ + Resources: []*apiv2pb.Resource{}, + } + for _, resource := range resources { + response.Resources = append(response.Resources, s.convertResourceFromStore(ctx, resource)) + } + return response, nil +} diff --git a/api/v2/memo_service.go b/api/v2/memo_service.go index 36e7ebf5..f8b1f702 100644 --- a/api/v2/memo_service.go +++ b/api/v2/memo_service.go @@ -8,15 +8,20 @@ import ( "github.com/google/cel-go/cel" "github.com/pkg/errors" + "go.uber.org/zap" expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" apiv1 "github.com/usememos/memos/api/v1" + "github.com/usememos/memos/internal/log" "github.com/usememos/memos/plugin/gomark/parser" "github.com/usememos/memos/plugin/gomark/parser/tokenizer" + "github.com/usememos/memos/plugin/webhook" apiv2pb "github.com/usememos/memos/proto/gen/api/v2" + storepb "github.com/usememos/memos/proto/gen/store" + "github.com/usememos/memos/server/service/metric" "github.com/usememos/memos/store" ) @@ -38,11 +43,17 @@ func (s *APIV2Service) CreateMemo(ctx context.Context, request *apiv2pb.CreateMe if err != nil { return nil, err } + metric.Enqueue("memo create") memoMessage, err := s.convertMemoFromStore(ctx, memo) if err != nil { return nil, errors.Wrap(err, "failed to convert memo") } + // Try to dispatch webhook when memo is created. + if err := s.DispatchMemoCreatedWebhook(ctx, memoMessage); err != nil { + log.Warn("Failed to dispatch memo created webhook", zap.Error(err)) + } + response := &apiv2pb.CreateMemoResponse{ Memo: memoMessage, } @@ -222,6 +233,11 @@ func (s *APIV2Service) UpdateMemo(ctx context.Context, request *apiv2pb.UpdateMe if err != nil { return nil, errors.Wrap(err, "failed to convert memo") } + // Try to dispatch webhook when memo is updated. + if err := s.DispatchMemoUpdatedWebhook(ctx, memoMessage); err != nil { + log.Warn("Failed to dispatch memo updated webhook", zap.Error(err)) + } + return &apiv2pb.UpdateMemoResponse{ Memo: memoMessage, }, nil @@ -252,112 +268,12 @@ func (s *APIV2Service) DeleteMemo(ctx context.Context, request *apiv2pb.DeleteMe return &apiv2pb.DeleteMemoResponse{}, nil } -func (s *APIV2Service) SetMemoResources(ctx context.Context, request *apiv2pb.SetMemoResourcesRequest) (*apiv2pb.SetMemoResourcesResponse, error) { - resources, err := s.Store.ListResources(ctx, &store.FindResource{MemoID: &request.Id}) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to list resources") - } - - // Delete resources that are not in the request. - for _, resource := range resources { - found := false - for _, requestResource := range request.Resources { - if resource.ID == int32(requestResource.Id) { - found = true - break - } - } - if !found { - if err = s.Store.DeleteResource(ctx, &store.DeleteResource{ - ID: int32(resource.ID), - MemoID: &request.Id, - }); err != nil { - return nil, status.Errorf(codes.Internal, "failed to delete resource") - } - } - } - - // Update resources' memo_id in the request. - for _, resource := range request.Resources { - if _, err := s.Store.UpdateResource(ctx, &store.UpdateResource{ - ID: resource.Id, - MemoID: &request.Id, - }); err != nil { - return nil, status.Errorf(codes.Internal, "failed to update resource") - } - } - - return &apiv2pb.SetMemoResourcesResponse{}, nil -} - -func (s *APIV2Service) ListMemoResources(ctx context.Context, request *apiv2pb.ListMemoResourcesRequest) (*apiv2pb.ListMemoResourcesResponse, error) { - resources, err := s.Store.ListResources(ctx, &store.FindResource{ - MemoID: &request.Id, - }) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to list resources") - } - - response := &apiv2pb.ListMemoResourcesResponse{ - Resources: []*apiv2pb.Resource{}, - } - for _, resource := range resources { - response.Resources = append(response.Resources, s.convertResourceFromStore(ctx, resource)) - } - return response, nil -} - -func (s *APIV2Service) SetMemoRelations(ctx context.Context, request *apiv2pb.SetMemoRelationsRequest) (*apiv2pb.SetMemoRelationsResponse, error) { - referenceType := store.MemoRelationReference - // Delete all reference relations first. - if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ - MemoID: &request.Id, - Type: &referenceType, - }); err != nil { - return nil, status.Errorf(codes.Internal, "failed to delete memo relation") - } - - for _, relation := range request.Relations { - if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{ - MemoID: request.Id, - RelatedMemoID: relation.RelatedMemoId, - Type: convertMemoRelationTypeToStore(relation.Type), - }); err != nil { - return nil, status.Errorf(codes.Internal, "failed to upsert memo relation") - } - } - - return &apiv2pb.SetMemoRelationsResponse{}, nil -} - -func (s *APIV2Service) ListMemoRelations(ctx context.Context, request *apiv2pb.ListMemoRelationsRequest) (*apiv2pb.ListMemoRelationsResponse, error) { - relationList := []*apiv2pb.MemoRelation{} - tempList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{ - MemoID: &request.Id, - }) - if err != nil { - return nil, err - } - for _, relation := range tempList { - relationList = append(relationList, convertMemoRelationFromStore(relation)) - } - tempList, err = s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{ - RelatedMemoID: &request.Id, - }) - if err != nil { - return nil, err - } - for _, relation := range tempList { - relationList = append(relationList, convertMemoRelationFromStore(relation)) - } - - response := &apiv2pb.ListMemoRelationsResponse{ - Relations: relationList, - } - return response, nil -} - func (s *APIV2Service) CreateMemoComment(ctx context.Context, request *apiv2pb.CreateMemoCommentRequest) (*apiv2pb.CreateMemoCommentResponse, error) { + relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: &request.Id}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get memo") + } + // Create the comment memo first. createMemoResponse, err := s.CreateMemo(ctx, request.Create) if err != nil { @@ -374,6 +290,34 @@ func (s *APIV2Service) CreateMemoComment(ctx context.Context, request *apiv2pb.C if err != nil { return nil, status.Errorf(codes.Internal, "failed to create memo relation") } + if memo.Visibility != apiv2pb.Visibility_PRIVATE { + activity, err := s.Store.CreateActivity(ctx, &store.Activity{ + CreatorID: memo.CreatorId, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{ + MemoComment: &storepb.ActivityMemoCommentPayload{ + MemoId: memo.Id, + RelatedMemoId: request.Id, + }, + }, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create activity") + } + if _, err := s.Store.CreateInbox(ctx, &store.Inbox{ + SenderID: memo.CreatorId, + ReceiverID: relatedMemo.CreatorID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_TYPE_MEMO_COMMENT, + ActivityId: &activity.ID, + }, + }); err != nil { + return nil, status.Errorf(codes.Internal, "failed to create inbox") + } + } + metric.Enqueue("memo comment create") response := &apiv2pb.CreateMemoCommentResponse{ Memo: memo, @@ -473,36 +417,6 @@ func (s *APIV2Service) getMemoDisplayWithUpdatedTsSettingValue(ctx context.Conte return memoDisplayWithUpdatedTs, nil } -func convertMemoRelationFromStore(memoRelation *store.MemoRelation) *apiv2pb.MemoRelation { - return &apiv2pb.MemoRelation{ - MemoId: memoRelation.MemoID, - RelatedMemoId: memoRelation.RelatedMemoID, - Type: convertMemoRelationTypeFromStore(memoRelation.Type), - } -} - -func convertMemoRelationTypeFromStore(relationType store.MemoRelationType) apiv2pb.MemoRelation_Type { - switch relationType { - case store.MemoRelationReference: - return apiv2pb.MemoRelation_REFERENCE - case store.MemoRelationComment: - return apiv2pb.MemoRelation_COMMENT - default: - return apiv2pb.MemoRelation_TYPE_UNSPECIFIED - } -} - -func convertMemoRelationTypeToStore(relationType apiv2pb.MemoRelation_Type) store.MemoRelationType { - switch relationType { - case apiv2pb.MemoRelation_REFERENCE: - return store.MemoRelationReference - case apiv2pb.MemoRelation_COMMENT: - return store.MemoRelationComment - default: - return store.MemoRelationReference - } -} - func convertVisibilityFromStore(visibility store.Visibility) apiv2pb.Visibility { switch visibility { case store.Private: @@ -613,3 +527,73 @@ func findField(callExpr *expr.Expr_Call, filter *ListMemosFilter) { } } } + +// DispatchMemoCreatedWebhook dispatches webhook when memo is created. +func (s *APIV2Service) DispatchMemoCreatedWebhook(ctx context.Context, memo *apiv2pb.Memo) error { + return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.created") +} + +// DispatchMemoUpdatedWebhook dispatches webhook when memo is updated. +func (s *APIV2Service) DispatchMemoUpdatedWebhook(ctx context.Context, memo *apiv2pb.Memo) error { + return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.updated") +} + +func (s *APIV2Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *apiv2pb.Memo, activityType string) error { + webhooks, err := s.Store.ListWebhooks(ctx, &store.FindWebhook{ + CreatorID: &memo.CreatorId, + }) + if err != nil { + return err + } + metric.Enqueue("webhook dispatch") + for _, hook := range webhooks { + payload := convertMemoToWebhookPayload(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 convertMemoToWebhookPayload(memo *apiv2pb.Memo) *webhook.WebhookPayload { + return &webhook.WebhookPayload{ + CreatorID: memo.CreatorId, + CreatedTs: time.Now().Unix(), + Memo: &webhook.Memo{ + ID: memo.Id, + CreatorID: memo.CreatorId, + CreatedTs: memo.CreateTime.Seconds, + UpdatedTs: memo.UpdateTime.Seconds, + Content: memo.Content, + Visibility: memo.Visibility.String(), + Pinned: memo.Pinned, + ResourceList: func() []*webhook.Resource { + resources := []*webhook.Resource{} + for _, resource := range memo.Resources { + resources = append(resources, &webhook.Resource{ + ID: resource.Id, + Filename: resource.Filename, + ExternalLink: resource.ExternalLink, + Type: resource.Type, + Size: resource.Size, + }) + } + return resources + }(), + RelationList: func() []*webhook.MemoRelation { + relations := []*webhook.MemoRelation{} + for _, relation := range memo.Relations { + relations = append(relations, &webhook.MemoRelation{ + MemoID: relation.MemoId, + RelatedMemoID: relation.RelatedMemoId, + Type: relation.Type.String(), + }) + } + return relations + }(), + }, + } +} diff --git a/server/version/version.go b/server/version/version.go index d7365959..9dcba4b3 100644 --- a/server/version/version.go +++ b/server/version/version.go @@ -12,7 +12,7 @@ import ( var Version = "0.18.1" // DevVersion is the service current development version. -var DevVersion = "0.18.1" +var DevVersion = "0.18.2" func GetCurrentVersion(mode string) string { if mode == "dev" || mode == "demo" { diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index f444f064..b26c61b3 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -9,7 +9,7 @@ import { TAB_SPACE_WIDTH, UNKNOWN_ID } from "@/helpers/consts"; import { useGlobalStore, useResourceStore } from "@/store/module"; import { useMemoStore, useUserStore } from "@/store/v1"; import { MemoRelation, MemoRelation_Type } from "@/types/proto/api/v2/memo_relation_service"; -import { Visibility } from "@/types/proto/api/v2/memo_service"; +import { Memo, Visibility } from "@/types/proto/api/v2/memo_service"; import { Resource } from "@/types/proto/api/v2/resource_service"; import { UserSetting } from "@/types/proto/api/v2/user_service"; import { useTranslate } from "@/utils/i18n"; @@ -28,6 +28,7 @@ interface Props { editorClassName?: string; cacheKey?: string; memoId?: number; + parentMemoId?: number; relationList?: MemoRelation[]; onConfirm?: (memoId: number) => void; } @@ -41,7 +42,7 @@ interface State { } const MemoEditor = (props: Props) => { - const { className, editorClassName, cacheKey, memoId, onConfirm } = props; + const { className, editorClassName, cacheKey, memoId, parentMemoId, onConfirm } = props; const { i18n } = useTranslation(); const t = useTranslate(); const contentCacheKey = `memo-editor-${cacheKey}`; @@ -260,6 +261,7 @@ const MemoEditor = (props: Props) => { }); const content = editorRef.current?.getContent() ?? ""; try { + // Update memo. if (memoId && memoId !== UNKNOWN_ID) { const prevMemo = await memoStore.getOrFetchMemoById(memoId ?? UNKNOWN_ID); if (prevMemo) { @@ -284,10 +286,22 @@ const MemoEditor = (props: Props) => { } } } else { - const memo = await memoStore.createMemo({ - content, - visibility: state.memoVisibility, - }); + // Create memo or memo comment. + const request = !parentMemoId + ? memoStore.createMemo({ + content, + visibility: state.memoVisibility, + }) + : memoServiceClient + .createMemoComment({ + id: parentMemoId, + create: { + content, + visibility: state.memoVisibility, + }, + }) + .then(({ memo }) => memo as Memo); + const memo = await request; await memoServiceClient.setMemoResources({ id: memo.id, resources: state.resourceList, diff --git a/web/src/pages/MemoDetail.tsx b/web/src/pages/MemoDetail.tsx index c06c294f..3b455249 100644 --- a/web/src/pages/MemoDetail.tsx +++ b/web/src/pages/MemoDetail.tsx @@ -15,7 +15,6 @@ import MobileHeader from "@/components/MobileHeader"; import showShareMemoDialog from "@/components/ShareMemoDialog"; import UserAvatar from "@/components/UserAvatar"; import VisibilityIcon from "@/components/VisibilityIcon"; -import { UNKNOWN_ID } from "@/helpers/consts"; import { getDateTimeString } from "@/helpers/datetime"; import useCurrentUser from "@/hooks/useCurrentUser"; import useNavigateTo from "@/hooks/useNavigateTo"; @@ -214,12 +213,7 @@ const MemoDetail = () => { {/* Only show comment editor when user login */} {currentUser && ( - + )}