diff --git a/api/v1/memo.go b/api/v1/memo.go index b304bd48..1c25fc95 100644 --- a/api/v1/memo.go +++ b/api/v1/memo.go @@ -14,6 +14,7 @@ import ( "github.com/usememos/memos/internal/log" "github.com/usememos/memos/internal/util" + "github.com/usememos/memos/plugin/webhook" storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/server/service/metric" "github.com/usememos/memos/store" @@ -392,7 +393,7 @@ func (s *APIV1Service) CreateMemo(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } - // send notification by telegram bot if memo is not Private + // Send notification to telegram if memo is not private. if memoResponse.Visibility != Private { // fetch all telegram UserID userSettings, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{Key: UserSettingTelegramUserIDKey.String()}) @@ -423,6 +424,11 @@ func (s *APIV1Service) CreateMemo(c echo.Context) error { } } } + // Try to dispatch webhook when memo is created. + if err := s.DispatchMemoCreatedWebhook(ctx, memoResponse); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to dispatch memo created webhook").SetInternal(err) + } + metric.Enqueue("memo create") return c.JSON(http.StatusOK, memoResponse) } @@ -795,6 +801,11 @@ func (s *APIV1Service) UpdateMemo(c echo.Context) error { if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } + // Try to dispatch webhook when memo is created. + if err := s.DispatchMemoCreatedWebhook(ctx, memoResponse); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to dispatch memo created webhook").SetInternal(err) + } + return c.JSON(http.StatusOK, memoResponse) } @@ -946,3 +957,76 @@ func getIDListDiff(oldList, newList []int32) (addedList, removedList []int32) { } return addedList, removedList } + +// DispatchMemoCreatedWebhook dispatches webhook when memo is created. +func (s *APIV1Service) DispatchMemoCreatedWebhook(ctx context.Context, memo *Memo) error { + return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.created") +} + +// DispatchMemoUpdatedWebhook dispatches webhook when memo is updated. +func (s *APIV1Service) DispatchMemoUpdatedWebhook(ctx context.Context, memo *Memo) error { + return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.updated") +} + +func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *Memo, activityType string) error { + webhooks, err := s.Store.ListWebhooks(ctx, &store.FindWebhook{ + CreatorID: &memo.CreatorID, + }) + if err != nil { + return err + } + for _, wb := range webhooks { + payload := convertMemoToWebhookPayload(memo) + payload.ActivityType = activityType + payload.URL = wb.Url + err := webhook.Post(*payload) + if err != nil { + return errors.Wrap(err, "failed to post webhook") + } + } + return nil +} + +func convertMemoToWebhookPayload(memo *Memo) *webhook.WebhookPayload { + return &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: func() []*webhook.Resource { + resources := []*webhook.Resource{} + for _, resource := range memo.ResourceList { + resources = append(resources, &webhook.Resource{ + ID: resource.ID, + CreatorID: resource.CreatorID, + CreatedTs: resource.CreatedTs, + UpdatedTs: resource.UpdatedTs, + Filename: resource.Filename, + InternalPath: resource.InternalPath, + ExternalLink: resource.ExternalLink, + Type: resource.Type, + Size: resource.Size, + }) + } + return resources + }(), + RelationList: func() []*webhook.MemoRelation { + relations := []*webhook.MemoRelation{} + for _, relation := range memo.RelationList { + relations = append(relations, &webhook.MemoRelation{ + MemoID: relation.MemoID, + RelatedMemoID: relation.RelatedMemoID, + Type: relation.Type.String(), + }) + } + return relations + }(), + }, + } +} diff --git a/api/v1/memo_relation.go b/api/v1/memo_relation.go index 61350af8..cd0e818d 100644 --- a/api/v1/memo_relation.go +++ b/api/v1/memo_relation.go @@ -18,6 +18,10 @@ const ( MemoRelationComment MemoRelationType = "COMMENT" ) +func (t MemoRelationType) String() string { + return string(t) +} + type MemoRelation struct { MemoID int32 `json:"memoId"` RelatedMemoID int32 `json:"relatedMemoId"` diff --git a/plugin/webhook/webhook.go b/plugin/webhook/webhook.go new file mode 100644 index 00000000..90f98a3d --- /dev/null +++ b/plugin/webhook/webhook.go @@ -0,0 +1,112 @@ +package webhook + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/pkg/errors" +) + +var ( + // timeout is the timeout for webhook request. Default to 30 seconds. + timeout = 30 * time.Second +) + +type Memo struct { + ID int32 `json:"id"` + CreatorID int32 `json:"creatorId"` + CreatedTs int64 `json:"createdTs"` + UpdatedTs int64 `json:"updatedTs"` + + // Domain specific fields + Content string `json:"content"` + Visibility string `json:"visibility"` + Pinned bool `json:"pinned"` + ResourceList []*Resource `json:"resourceList"` + RelationList []*MemoRelation `json:"relationList"` +} + +type Resource struct { + ID int32 `json:"id"` + + // Standard fields + CreatorID int32 `json:"creatorId"` + CreatedTs int64 `json:"createdTs"` + UpdatedTs int64 `json:"updatedTs"` + + // Domain specific fields + Filename string `json:"filename"` + InternalPath string `json:"internalPath"` + ExternalLink string `json:"externalLink"` + Type string `json:"type"` + Size int64 `json:"size"` +} + +type MemoRelation struct { + MemoID int32 `json:"memoId"` + RelatedMemoID int32 `json:"relatedMemoId"` + Type string `json:"type"` +} + +// WebhookPayload is the payload of webhook request. +// nolint +type WebhookPayload struct { + URL string `json:"url"` + ActivityType string `json:"activityType"` + CreatorID int32 `json:"creatorId"` + CreatedTs int64 `json:"createdTs"` + Memo *Memo `json:"memo"` +} + +// WebhookResponse is the response of webhook request. +// nolint +type WebhookResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Post posts the message to webhook endpoint. +func Post(payload WebhookPayload) error { + body, err := json.Marshal(&payload) + if err != nil { + return errors.Wrapf(err, "failed to marshal webhook request to %s", payload.URL) + } + req, err := http.NewRequest("POST", + payload.URL, bytes.NewBuffer(body)) + if err != nil { + return errors.Wrapf(err, "failed to construct webhook request to %s", payload.URL) + } + + req.Header.Set("Content-Type", "application/json") + client := &http.Client{ + Timeout: timeout, + } + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "failed to post webhook to %s", payload.URL) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrapf(err, "failed to read webhook response from %s", payload.URL) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.Errorf("failed to post webhook %s, status code: %d, response body: %s", payload.URL, resp.StatusCode, b) + } + + response := &WebhookResponse{} + if err := json.Unmarshal(b, response); err != nil { + return errors.Wrapf(err, "failed to unmarshal webhook response from %s", payload.URL) + } + + if response.Code != 0 { + return errors.Errorf("receive error code sent by webhook server, code %d, msg: %s", response.Code, response.Message) + } + + return nil +} diff --git a/plugin/webhook/webhook_test.go b/plugin/webhook/webhook_test.go new file mode 100644 index 00000000..d770c2c0 --- /dev/null +++ b/plugin/webhook/webhook_test.go @@ -0,0 +1 @@ +package webhook