diff --git a/messaging/internal/repository/webhook.go b/messaging/internal/repository/webhook.go new file mode 100644 index 000000000..327d265f0 --- /dev/null +++ b/messaging/internal/repository/webhook.go @@ -0,0 +1,124 @@ +package repository + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/titpetric/factory" + + "github.com/crusttech/crust/messaging/types" +) + +type ( + WebhookRepository interface { + With(ctx context.Context, db *factory.DB) WebhookRepository + + Create(*types.Webhook) (*types.Webhook, error) + Update(*types.Webhook) (*types.Webhook, error) + + Get(webhookID uint64) (*types.Webhook, error) + GetByToken(webhookID uint64, webhookToken string) (*types.Webhook, error) + + Find(filter *types.WebhookFilter) (types.WebhookSet, error) + + Delete(webhookID uint64) error + DeleteByToken(webhookID uint64, webhookToken string) error + } + + webhook struct { + webhook string + + *repository + } +) + +func Webhook(ctx context.Context, db *factory.DB) WebhookRepository { + return (&webhook{}).With(ctx, db) +} + +func (r *webhook) With(ctx context.Context, db *factory.DB) WebhookRepository { + return &webhook{ + webhook: "messaging_webhook", + repository: r.repository.With(ctx, db), + } +} + +func (r *webhook) Create(webhook *types.Webhook) (*types.Webhook, error) { + webhook.ID = factory.Sonyflake.NextID() + webhook.CreatedAt = time.Now() + + return webhook, errors.WithStack(r.db().Insert(r.webhook, webhook)) +} + +func (r *webhook) Update(webhook *types.Webhook) (*types.Webhook, error) { + webhook.UpdatedAt = timeNowPtr() + + return webhook, errors.WithStack(r.db().Replace(r.webhook, webhook)) +} + +func (r *webhook) Get(webhookID uint64) (*types.Webhook, error) { + hook := &types.Webhook{} + if err := r.db().Get(hook, "select * from "+r.webhook+" where id=?", webhookID); err != nil { + return nil, errors.WithStack(err) + } + return hook, nil +} + +func (r *webhook) GetByToken(webhookID uint64, webhookToken string) (*types.Webhook, error) { + webhook, err := r.Get(webhookID) + switch { + case err != nil: + return nil, errors.WithStack(err) + case webhook.AuthToken == webhookToken: + return webhook, nil + default: + return nil, errors.New("Invalid Webhook Token") + } +} + +// Find webhooks based on filter +// +// If ChannelID > 0 it returns webhooks created on a specific channel +// If OwnerUserID > 0 it returns webhooks owned by a specific user +func (r *webhook) Find(filter *types.WebhookFilter) (types.WebhookSet, error) { + params := make([]interface{}, 0) + vv := types.WebhookSet{} + sql := "select * from messaging_webhook where 1=1" + + if filter != nil { + if filter.OwnerUserID > 0 { + // scope: only channel we have access to + sql += " AND rel_owner=?" + params = append(params, filter.OwnerUserID) + } + if filter.OutgoingTrigger != "" { + // scope: only channel we have access to + sql += " AND outgoing_trigger=?" + params = append(params, filter.OutgoingTrigger) + } + if filter.ChannelID > 0 { + // scope: only channel we have access to + sql += " AND rel_channel=?" + params = append(params, filter.ChannelID) + } + } + + return vv, errors.WithStack(r.db().Select(&vv, sql, params...)) +} + +func (r *webhook) Delete(webhookID uint64) error { + if _, err := r.Get(webhookID); err != nil { + return err + } + _, err := r.db().Exec("delete from "+r.webhook+" where id=?", webhookID) + return errors.WithStack(err) +} + +func (r *webhook) DeleteByToken(webhookID uint64, webhookToken string) error { + if _, err := r.GetByToken(webhookID, webhookToken); err != nil { + return err + } + _, err := r.db().Exec("delete from "+r.webhook+" where id=?", webhookID) + return errors.WithStack(err) +} diff --git a/messaging/internal/service/command.go b/messaging/internal/service/command.go new file mode 100644 index 000000000..795bcb0f1 --- /dev/null +++ b/messaging/internal/service/command.go @@ -0,0 +1,73 @@ +package service + +import ( + "context" + + "github.com/crusttech/crust/messaging/types" +) + +type ( + command struct { + ctx context.Context + } + + CommandService interface { + With(context.Context) CommandService + + Do(channelID uint64, command, input string) (*types.Message, error) + } +) + +func Command(ctx context.Context) CommandService { + return (&command{}).With(ctx) +} + +func (svc *command) With(ctx context.Context) CommandService { + return &command{ + ctx: ctx, + } +} + +func (svc *command) Do(channelID uint64, command, input string) (*types.Message, error) { + switch command { + case "me": + if input != "" { + return DefaultMessage.With(svc.ctx).Create(&types.Message{ + Type: types.MessageTypeIlleism, + ChannelID: channelID, + Message: input, + }) + } + return nil, nil + case "tableflip": + fallthrough + case "unflip": + fallthrough + case "shrug": + messages := map[string]string{ + "tableflip": `(╯°□°)╯︵ ┻━┻`, + "unflip": `┬─┬ ノ( ゜-゜ノ)`, + "shrug": `¯\\_(ツ)_/¯`, + } + msg := &types.Message{ + ChannelID: channelID, + Message: messages[command], + } + + if input != "" { + msg.Message = input + " " + msg.Message + } + return DefaultMessage.With(svc.ctx).Create(msg) + default: + webhookSvc := DefaultWebhook.With(svc.ctx) + webhooks, err := webhookSvc.Find(&types.WebhookFilter{ + ChannelID: channelID, + OutgoingTrigger: command, + }) + if err != nil || len(webhooks) == 0 { + return nil, err + } + return webhookSvc.Do(webhooks[0], input) + } + return nil, ErrUnknownCommand.new() +} diff --git a/messaging/internal/service/error.go b/messaging/internal/service/error.go index c4385ef95..1f6b149b6 100644 --- a/messaging/internal/service/error.go +++ b/messaging/internal/service/error.go @@ -1,5 +1,9 @@ package service +import ( + "github.com/pkg/errors" +) + type ( readableError string ) @@ -8,6 +12,11 @@ func (e readableError) Error() string { return string(e) } +func (e readableError) new() error { + return errors.WithStack(e) +} + const ( - ErrNoPermissions readableError = "You don't have permissions for this operation" + ErrNoPermissions readableError = "You don't have permissions for this operation" + ErrUnknownCommand readableError = "Unknown command" ) diff --git a/messaging/internal/service/main_test.go b/messaging/internal/service/main_test.go index 114a2dbdf..17f16e026 100644 --- a/messaging/internal/service/main_test.go +++ b/messaging/internal/service/main_test.go @@ -30,11 +30,16 @@ func TestMain(m *testing.M) { factory.Database.Add("messaging", dsn) factory.Database.Add("system", dsn) + // @todo: add flags to configure the sql profiler in tests + db := factory.Database.MustGet() db.Profiler = &factory.Database.ProfilerStdout + dbSystem := factory.Database.MustGet("system") + dbSystem.Profiler = &factory.Database.ProfilerStdout + // migrate database schema - if err := systemMigrate.Migrate(db); err != nil { + if err := systemMigrate.Migrate(dbSystem); err != nil { log.Printf("Error running migrations: %+v\n", err) return } diff --git a/messaging/internal/service/message.go b/messaging/internal/service/message.go index 7640fface..f8a3604b3 100644 --- a/messaging/internal/service/message.go +++ b/messaging/internal/service/message.go @@ -2,6 +2,7 @@ package service import ( "context" + "io" "regexp" "strings" @@ -37,9 +38,10 @@ type ( FindThreads(filter *types.MessageFilter) (types.MessageSet, error) Create(messages *types.Message) (*types.Message, error) - Update(messages *types.Message) (*types.Message, error) + CreateWithAvatar(message *types.Message, avatar io.Reader) (*types.Message, error) + React(messageID uint64, reaction string) error RemoveReaction(messageID uint64, reaction string) error @@ -129,6 +131,11 @@ func (svc *message) FindThreads(filter *types.MessageFilter) (mm types.MessageSe return mm, svc.preload(mm) } +func (svc *message) CreateWithAvatar(in *types.Message, avatar io.Reader) (*types.Message, error) { + // @todo: avatar + return svc.Create(in) +} + func (svc message) channelAccessCheck(IDs ...uint64) error { for _, ID := range IDs { if ID > 0 { diff --git a/messaging/internal/service/service.go b/messaging/internal/service/service.go index 03d340764..4c1b3e56e 100644 --- a/messaging/internal/service/service.go +++ b/messaging/internal/service/service.go @@ -22,6 +22,8 @@ var ( DefaultPubSub *pubSub DefaultEvent EventService DefaultPermissions PermissionsService + DefaultCommand CommandService + DefaultWebhook WebhookService ) func Init() error { @@ -30,7 +32,7 @@ func Init() error { return err } - _, err = http.New(&config.HTTPClient{ + client, err := http.New(&config.HTTPClient{ Timeout: 10, }) if err != nil { @@ -45,6 +47,8 @@ func Init() error { DefaultMessage = Message(ctx) DefaultChannel = Channel(ctx) DefaultPubSub = PubSub() + DefaultCommand = Command(ctx) + DefaultWebhook = Webhook(ctx, client) return nil } diff --git a/messaging/internal/service/webhook.go b/messaging/internal/service/webhook.go new file mode 100644 index 000000000..9220b9176 --- /dev/null +++ b/messaging/internal/service/webhook.go @@ -0,0 +1,254 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "io/ioutil" + "net/url" + "strings" + + "github.com/pkg/errors" + + "github.com/crusttech/crust/internal/auth" + "github.com/crusttech/crust/internal/http" + "github.com/crusttech/crust/internal/store" + "github.com/crusttech/crust/messaging/internal/repository" + "github.com/crusttech/crust/messaging/types" + systemService "github.com/crusttech/crust/system/service" + systemTypes "github.com/crusttech/crust/system/types" +) + +type ( + webhook struct { + db db + ctx context.Context + client *http.Client + + users systemService.UserService + webhook repository.WebhookRepository + perms PermissionsService + } + + WebhookService interface { + With(ctx context.Context) WebhookService + + Get(webhookID uint64) (*types.Webhook, error) + + Find(*types.WebhookFilter) (types.WebhookSet, error) + + Delete(webhookID uint64) error + DeleteByToken(webhookID uint64, webhookToken string) error + + Message(webhookID uint64, webhookToken string, username string, avatar io.Reader, message string) (interface{}, error) + + Create(kind types.WebhookKind, channelID uint64, params types.WebhookRequest) (*types.Webhook, error) + Update(webhookID uint64, kind types.WebhookKind, channelID uint64, params types.WebhookRequest) (*types.Webhook, error) + + Do(webhook *types.Webhook, message string) (*types.Message, error) + } +) + +func Webhook(ctx context.Context, client *http.Client) WebhookService { + return (&webhook{ + client: client, + }).With(ctx) +} + +func (svc *webhook) With(ctx context.Context) WebhookService { + db := repository.DB(ctx) + return &webhook{ + db: db, + ctx: ctx, + client: svc.client, + + users: systemService.User(ctx), + webhook: repository.Webhook(ctx, db), + perms: Permissions(ctx), + } +} + +func (svc *webhook) Create(kind types.WebhookKind, channelID uint64, params types.WebhookRequest) (*types.Webhook, error) { + var userID = repository.Identity(svc.ctx) + + webhook := &types.Webhook{ + Kind: kind, + OwnerUserID: userID, + ChannelID: channelID, + OutgoingTrigger: params.OutgoingTrigger, + OutgoingURL: params.OutgoingURL, + } + + if !svc.perms.CanManageWebhooks(webhook) && !svc.perms.CanManageOwnWebhooks(webhook) { + return nil, errors.WithStack(ErrNoPermissions) + } + + botUser := &systemTypes.User{ + Username: params.Username, + Name: params.Username, + Handle: params.Username, + Kind: systemTypes.BotUser, + RelatedUserID: userID, + } + + user, err := svc.users.CreateWithAvatar(botUser, params.Avatar) + if err != nil { + return nil, err + } + webhook.UserID = user.ID + + wh, err := svc.webhook.Create(webhook) + if err != nil { + // cross service rollback (delete user) + svc.users.Delete(user.ID) + return nil, err + } + return wh, err +} + +func (svc *webhook) Update(webhookID uint64, kind types.WebhookKind, channelID uint64, params types.WebhookRequest) (*types.Webhook, error) { + var userID = repository.Identity(svc.ctx) + webhook, err := svc.Get(webhookID) + if err != nil { + return nil, err + } + + if !svc.perms.CanManageWebhooks(webhook) && !(webhook.OwnerUserID == userID && svc.perms.CanManageOwnWebhooks(webhook)) { + return nil, errors.WithStack(ErrNoPermissions) + } + + botUser, err := svc.users.FindByID(webhook.UserID) + if err != nil { + return nil, errors.Wrapf(err, "Error when looking for User ID %d", webhook.UserID) + } + + if _, err := svc.users.UpdateWithAvatar(botUser, params.Avatar); err != nil { + return nil, err + } + + webhook.Kind = kind + webhook.ChannelID = channelID + webhook.OutgoingTrigger = params.OutgoingTrigger + webhook.OutgoingURL = params.OutgoingURL + + return svc.webhook.Update(webhook) +} + +func (svc *webhook) Get(webhookID uint64) (*types.Webhook, error) { + return svc.webhook.Get(webhookID) +} + +func (svc *webhook) Find(filter *types.WebhookFilter) (types.WebhookSet, error) { + return svc.webhook.Find(filter) +} + +func (svc *webhook) Delete(webhookID uint64) error { + webhook, err := svc.Get(webhookID) + if err != nil { + return err + } + if svc.perms.CanManageWebhooks(webhook) { + return svc.webhook.Delete(webhookID) + } + var userID = repository.Identity(svc.ctx) + if webhook.OwnerUserID == userID && svc.perms.CanManageOwnWebhooks(webhook) { + return svc.webhook.Delete(webhookID) + } + return errors.WithStack(ErrNoPermissions) +} + +func (svc *webhook) DeleteByToken(webhookID uint64, webhookToken string) error { + return svc.webhook.DeleteByToken(webhookID, webhookToken) +} + +func (svc *webhook) Message(webhookID uint64, webhookToken string, username string, avatar io.Reader, message string) (interface{}, error) { + if webhook, err := svc.webhook.GetByToken(webhookID, webhookToken); err != nil { + return nil, err + } else { + msg := &types.Message{ + Message: message, + Meta: &types.MessageMeta{ + Username: username, + }, + } + return svc.sendMessage(webhook, msg, avatar) + } +} + +// Do executes an outgoing HTTP webhook request +func (svc *webhook) Do(webhook *types.Webhook, message string) (*types.Message, error) { + if webhook.Kind != types.OutgoingWebhook { + return nil, errors.Errorf("Unsupported webhook type: %s", webhook.Kind) + } + + // replace url query %s with message + url := strings.Replace(webhook.OutgoingURL, "%s", url.QueryEscape(message), -1) + + // post body contains only `text` + requestBody := types.WebhookBody{ + Text: message, + } + req, err := svc.client.Post(url, requestBody) + if err != nil { + return nil, err + } + + // execute outgoing webhook + resp, err := svc.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // parse response body + responseBody := types.WebhookBody{} + contentType := resp.Header.Get("Content-Type") + switch { + case strings.Contains(contentType, "text/plain"): + // keep plain/text as-is + if b, err := ioutil.ReadAll(resp.Body); err != nil { + return nil, errors.WithStack(err) + } else { + responseBody.Text = string(b) + } + default: + switch resp.StatusCode { + case 200: + // assume the response is an expected json structure + if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil { + return nil, errors.WithStack(err) + } + if responseBody.Text == "" { + return nil, errors.New("Empty webhook response") + } + default: + return nil, http.ToError(resp) + } + } + + msg := &types.Message{ + Message: responseBody.Text, + Meta: &types.MessageMeta{ + Username: responseBody.Username, + }, + } + + avatar, err := store.FromURL(responseBody.Avatar) + if err != nil { + return svc.sendMessage(webhook, msg, nil) + } + defer avatar.Close() + + return svc.sendMessage(webhook, msg, avatar) +} + +func (svc *webhook) sendMessage(webhook *types.Webhook, msg *types.Message, avatar io.Reader) (*types.Message, error) { + // We need a webhook user context for message service + ctx := auth.SetIdentityToContext(svc.ctx, &systemTypes.User{ID: webhook.UserID}) + messageSvc := Message(ctx) + + msg.ChannelID = webhook.ChannelID + msg.UserID = webhook.UserID + + return messageSvc.CreateWithAvatar(msg, avatar) +} diff --git a/messaging/internal/service/webhook_test.go b/messaging/internal/service/webhook_test.go new file mode 100644 index 000000000..fa4b4351d --- /dev/null +++ b/messaging/internal/service/webhook_test.go @@ -0,0 +1,87 @@ +// +build integration,external + +package service + +import ( + "context" + "strings" + "testing" + + "github.com/crusttech/crust/internal/auth" + "github.com/crusttech/crust/internal/config" + "github.com/crusttech/crust/internal/http" + "github.com/crusttech/crust/internal/test" + "github.com/crusttech/crust/messaging/internal/repository" + "github.com/crusttech/crust/messaging/types" + systemService "github.com/crusttech/crust/system/service" + systemTypes "github.com/crusttech/crust/system/types" +) + +func TestOutgoingWebhook(t *testing.T) { + var user = &systemTypes.User{ID: 1} + var channel = &types.Channel{ID: 1} + + ctx := context.WithValue(context.Background(), "testing", true) + ctx = auth.SetIdentityToContext(ctx, user) + + // set up user + { + svc := systemService.User(ctx) + _, err := svc.Create(user) + test.Assert(t, err == nil, "Error when creating user: %+v", err) + } + + // set up channel + { + rpo := repository.Channel(ctx, repository.DB(ctx)) + _, err := rpo.Create(channel) + test.Assert(t, err == nil, "Error when creating channel: %+v", err) + } + + client, err := http.New(&config.HTTPClient{ + Timeout: 10, + }) + test.Assert(t, err == nil, "Error creating HTTP client: %+v", err) + + svc := Webhook(ctx, client) + + { + /* create outgoing webhook */ + webhook, err := svc.Create(types.OutgoingWebhook, channel.ID, types.WebhookRequest{ + Username: "test-webhook", + OutgoingTrigger: "fortune", + OutgoingURL: "https://api.scene-si.org/fortune.php", + }) + test.Assert(t, err == nil, "Error when creating webhook: %+v", err) + + /* find outgoing webhook */ + webhooks, err := svc.Find(&types.WebhookFilter{ + OutgoingTrigger: webhook.OutgoingTrigger, + }) + test.Assert(t, err == nil, "Error when finding webhook: %+v", err) + test.Assert(t, len(webhooks) == 1, "Expected to find 1 webhook, got %d", len(webhooks)) + + /* trigger outgoing webhook */ + { + message, err := svc.Do(webhooks[0], "") + test.Assert(t, err == nil, "Error when triggering webhook: %+v", err) + test.Assert(t, strings.Contains(message.Message, "BOFH"), "Unexpected webhook output: %s", message.Message) + } + + // update webhook + wh, err := svc.Update(webhook.ID, types.OutgoingWebhook, channel.ID, types.WebhookRequest{ + Username: "test-webhook-json", + OutgoingTrigger: "fortune-json", + OutgoingURL: "https://api.scene-si.org/fortune.php?username=test", + }) + test.Assert(t, err == nil, "Error when updating webhook: %+v", err) + + /* trigger outgoing webhook */ + { + message, err := svc.Do(wh, "") + test.Assert(t, err == nil, "Error when triggering webhook: %+v", err) + test.Assert(t, message.Meta.Username == "test", "Expected message.meta.username = 'test', got: '%s'", message.Meta.Username) + test.Assert(t, strings.Contains(message.Message, "BOFH"), "Unexpected webhook output: %s", message.Message) + } + } +} diff --git a/messaging/rest/commands.go b/messaging/rest/commands.go index 170059a8c..6b90ca57d 100644 --- a/messaging/rest/commands.go +++ b/messaging/rest/commands.go @@ -27,5 +27,11 @@ func (ctrl *Commands) List(ctx context.Context, r *request.CommandsList) (interf &types.Command{ Name: "shrug", Description: "It does exactly what it says on the tin"}, + &types.Command{ + Name: "tableflip", + Description: "Flip a table in anger"}, + &types.Command{ + Name: "unflip", + Description: "Put the table back from a flip"}, }, nil } diff --git a/messaging/rest/handlers/webhooks.go b/messaging/rest/handlers/webhooks.go new file mode 100644 index 000000000..d0d4e3281 --- /dev/null +++ b/messaging/rest/handlers/webhooks.go @@ -0,0 +1,96 @@ +package handlers + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `webhooks.go`, `webhooks.util.go` or `webhooks_test.go` to + implement your API calls, helper functions and tests. The file `webhooks.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "context" + + "net/http" + + "github.com/go-chi/chi" + "github.com/titpetric/factory/resputil" + + "github.com/crusttech/crust/messaging/rest/request" +) + +// Internal API interface +type WebhooksAPI interface { + List(context.Context, *request.WebhooksList) (interface{}, error) + Create(context.Context, *request.WebhooksCreate) (interface{}, error) + Update(context.Context, *request.WebhooksUpdate) (interface{}, error) + Get(context.Context, *request.WebhooksGet) (interface{}, error) + Delete(context.Context, *request.WebhooksDelete) (interface{}, error) +} + +// HTTP API interface +type Webhooks struct { + List func(http.ResponseWriter, *http.Request) + Create func(http.ResponseWriter, *http.Request) + Update func(http.ResponseWriter, *http.Request) + Get func(http.ResponseWriter, *http.Request) + Delete func(http.ResponseWriter, *http.Request) +} + +func NewWebhooks(wh WebhooksAPI) *Webhooks { + return &Webhooks{ + List: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWebhooksList() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return wh.List(r.Context(), params) + }) + }, + Create: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWebhooksCreate() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return wh.Create(r.Context(), params) + }) + }, + Update: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWebhooksUpdate() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return wh.Update(r.Context(), params) + }) + }, + Get: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWebhooksGet() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return wh.Get(r.Context(), params) + }) + }, + Delete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWebhooksDelete() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return wh.Delete(r.Context(), params) + }) + }, + } +} + +func (wh *Webhooks) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Get("/webhooks/", wh.List) + r.Post("/webhooks/", wh.Create) + r.Post("/webhooks/{webhookID}", wh.Update) + r.Get("/webhooks/{webhookID}", wh.Get) + r.Delete("/webhooks/{webhookID}", wh.Delete) + }) +} diff --git a/messaging/rest/handlers/webhooks_public.go b/messaging/rest/handlers/webhooks_public.go new file mode 100644 index 000000000..acc83515d --- /dev/null +++ b/messaging/rest/handlers/webhooks_public.go @@ -0,0 +1,66 @@ +package handlers + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `webhooks_public.go`, `webhooks_public.util.go` or `webhooks_public_test.go` to + implement your API calls, helper functions and tests. The file `webhooks_public.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "context" + + "net/http" + + "github.com/go-chi/chi" + "github.com/titpetric/factory/resputil" + + "github.com/crusttech/crust/messaging/rest/request" +) + +// Internal API interface +type WebhooksPublicAPI interface { + Delete(context.Context, *request.WebhooksPublicDelete) (interface{}, error) + Create(context.Context, *request.WebhooksPublicCreate) (interface{}, error) +} + +// HTTP API interface +type WebhooksPublic struct { + Delete func(http.ResponseWriter, *http.Request) + Create func(http.ResponseWriter, *http.Request) +} + +func NewWebhooksPublic(wh WebhooksPublicAPI) *WebhooksPublic { + return &WebhooksPublic{ + Delete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWebhooksPublicDelete() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return wh.Delete(r.Context(), params) + }) + }, + Create: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWebhooksPublicCreate() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return wh.Create(r.Context(), params) + }) + }, + } +} + +func (wh *WebhooksPublic) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Delete("/webhooks/{webhookID}/{webhookToken}", wh.Delete) + r.Post("/webhooks/{webhookID}/{webhookToken}", wh.Create) + }) +} diff --git a/messaging/rest/message.go b/messaging/rest/message.go index e9ab125a6..b881cb7eb 100644 --- a/messaging/rest/message.go +++ b/messaging/rest/message.go @@ -17,8 +17,8 @@ var _ = errors.Wrap type ( Message struct { svc struct { - msg service.MessageService - event service.EventService + msg service.MessageService + command service.CommandService } } ) @@ -26,6 +26,7 @@ type ( func (Message) New() *Message { ctrl := &Message{} ctrl.svc.msg = service.DefaultMessage + ctrl.svc.command = service.DefaultCommand return ctrl } @@ -53,32 +54,7 @@ func (ctrl *Message) Edit(ctx context.Context, r *request.MessageEdit) (interfac } func (ctrl Message) ExecuteCommand(ctx context.Context, r *request.MessageExecuteCommand) (interface{}, error) { - switch r.Command { - case "me": - if r.Input != "" { - return ctrl.svc.msg.With(ctx).Create(&types.Message{ - Type: types.MessageTypeIlleism, - ChannelID: r.ChannelID, - Message: r.Input, - }) - } - - return nil, nil - - case "shrug": - msg := &types.Message{ - ChannelID: r.ChannelID, - Message: `¯\\_(ツ)_/¯`, - } - - if r.Input != "" { - msg.Message = r.Input + " " + msg.Message - } - - return ctrl.svc.msg.With(ctx).Create(msg) - } - - return nil, errors.New("unknown command") + return ctrl.svc.command.With(ctx).Do(r.ChannelID, r.Command, r.Input) } func (ctrl *Message) Delete(ctx context.Context, r *request.MessageDelete) (interface{}, error) { diff --git a/messaging/rest/request/webhooks.go b/messaging/rest/request/webhooks.go new file mode 100644 index 000000000..490abbb21 --- /dev/null +++ b/messaging/rest/request/webhooks.go @@ -0,0 +1,324 @@ +package request + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `webhooks.go`, `webhooks.util.go` or `webhooks_test.go` to + implement your API calls, helper functions and tests. The file `webhooks.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "io" + "strings" + + "encoding/json" + "mime/multipart" + "net/http" + + "github.com/go-chi/chi" + "github.com/pkg/errors" + + "github.com/crusttech/crust/messaging/types" +) + +var _ = chi.URLParam +var _ = multipart.FileHeader{} + +// Webhooks list request parameters +type WebhooksList struct { + ChannelID uint64 `json:",string"` + UserID uint64 `json:",string"` +} + +func NewWebhooksList() *WebhooksList { + return &WebhooksList{} +} + +func (wReq *WebhooksList) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(wReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := get["channelID"]; ok { + + wReq.ChannelID = parseUInt64(val) + } + if val, ok := get["userID"]; ok { + + wReq.UserID = parseUInt64(val) + } + + return err +} + +var _ RequestFiller = NewWebhooksList() + +// Webhooks create request parameters +type WebhooksCreate struct { + ChannelID uint64 `json:",string"` + Kind types.WebhookKind + Trigger string + Url string + Username string + Avatar *multipart.FileHeader + AvatarURL string +} + +func NewWebhooksCreate() *WebhooksCreate { + return &WebhooksCreate{} +} + +func (wReq *WebhooksCreate) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(wReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseMultipartForm(32 << 20); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := post["channelID"]; ok { + + wReq.ChannelID = parseUInt64(val) + } + if val, ok := post["kind"]; ok { + + wReq.Kind = types.WebhookKind(val) + } + if val, ok := post["trigger"]; ok { + + wReq.Trigger = val + } + if val, ok := post["url"]; ok { + + wReq.Url = val + } + if val, ok := post["username"]; ok { + + wReq.Username = val + } + if _, wReq.Avatar, err = r.FormFile("avatar"); err != nil { + return errors.Wrap(err, "error procesing uploaded file") + } + + if val, ok := post["avatarURL"]; ok { + + wReq.AvatarURL = val + } + + return err +} + +var _ RequestFiller = NewWebhooksCreate() + +// Webhooks update request parameters +type WebhooksUpdate struct { + WebhookID uint64 `json:",string"` + ChannelID uint64 `json:",string"` + Kind types.WebhookKind + Trigger string + Url string + Username string + Avatar *multipart.FileHeader + AvatarURL string +} + +func NewWebhooksUpdate() *WebhooksUpdate { + return &WebhooksUpdate{} +} + +func (wReq *WebhooksUpdate) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(wReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseMultipartForm(32 << 20); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + wReq.WebhookID = parseUInt64(chi.URLParam(r, "webhookID")) + if val, ok := post["channelID"]; ok { + + wReq.ChannelID = parseUInt64(val) + } + if val, ok := post["kind"]; ok { + + wReq.Kind = types.WebhookKind(val) + } + if val, ok := post["trigger"]; ok { + + wReq.Trigger = val + } + if val, ok := post["url"]; ok { + + wReq.Url = val + } + if val, ok := post["username"]; ok { + + wReq.Username = val + } + if _, wReq.Avatar, err = r.FormFile("avatar"); err != nil { + return errors.Wrap(err, "error procesing uploaded file") + } + + if val, ok := post["avatarURL"]; ok { + + wReq.AvatarURL = val + } + + return err +} + +var _ RequestFiller = NewWebhooksUpdate() + +// Webhooks get request parameters +type WebhooksGet struct { + WebhookID uint64 `json:",string"` +} + +func NewWebhooksGet() *WebhooksGet { + return &WebhooksGet{} +} + +func (wReq *WebhooksGet) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(wReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + wReq.WebhookID = parseUInt64(chi.URLParam(r, "webhookID")) + + return err +} + +var _ RequestFiller = NewWebhooksGet() + +// Webhooks delete request parameters +type WebhooksDelete struct { + WebhookID uint64 `json:",string"` +} + +func NewWebhooksDelete() *WebhooksDelete { + return &WebhooksDelete{} +} + +func (wReq *WebhooksDelete) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(wReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + wReq.WebhookID = parseUInt64(chi.URLParam(r, "webhookID")) + + return err +} + +var _ RequestFiller = NewWebhooksDelete() diff --git a/messaging/rest/request/webhooks_public.go b/messaging/rest/request/webhooks_public.go new file mode 100644 index 000000000..46828a698 --- /dev/null +++ b/messaging/rest/request/webhooks_public.go @@ -0,0 +1,136 @@ +package request + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `webhooks_public.go`, `webhooks_public.util.go` or `webhooks_public_test.go` to + implement your API calls, helper functions and tests. The file `webhooks_public.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "io" + "strings" + + "encoding/json" + "mime/multipart" + "net/http" + + "github.com/go-chi/chi" + "github.com/pkg/errors" +) + +var _ = chi.URLParam +var _ = multipart.FileHeader{} + +// WebhooksPublic delete request parameters +type WebhooksPublicDelete struct { + WebhookID uint64 `json:",string"` + WebhookToken string +} + +func NewWebhooksPublicDelete() *WebhooksPublicDelete { + return &WebhooksPublicDelete{} +} + +func (wReq *WebhooksPublicDelete) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(wReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + wReq.WebhookID = parseUInt64(chi.URLParam(r, "webhookID")) + wReq.WebhookToken = chi.URLParam(r, "webhookToken") + + return err +} + +var _ RequestFiller = NewWebhooksPublicDelete() + +// WebhooksPublic create request parameters +type WebhooksPublicCreate struct { + Username string + AvatarURL string + Content string + WebhookID uint64 `json:",string"` + WebhookToken string +} + +func NewWebhooksPublicCreate() *WebhooksPublicCreate { + return &WebhooksPublicCreate{} +} + +func (wReq *WebhooksPublicCreate) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(wReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := get["username"]; ok { + + wReq.Username = val + } + if val, ok := get["avatarURL"]; ok { + + wReq.AvatarURL = val + } + if val, ok := get["content"]; ok { + + wReq.Content = val + } + wReq.WebhookID = parseUInt64(chi.URLParam(r, "webhookID")) + wReq.WebhookToken = chi.URLParam(r, "webhookToken") + + return err +} + +var _ RequestFiller = NewWebhooksPublicCreate() diff --git a/messaging/rest/webhooks.go b/messaging/rest/webhooks.go new file mode 100644 index 000000000..acc4ab14f --- /dev/null +++ b/messaging/rest/webhooks.go @@ -0,0 +1,69 @@ +package rest + +import ( + "context" + + "github.com/pkg/errors" + + "github.com/crusttech/crust/internal/store" + "github.com/crusttech/crust/messaging/internal/service" + "github.com/crusttech/crust/messaging/rest/request" + "github.com/crusttech/crust/messaging/types" +) + +var _ = errors.Wrap + +type Webhooks struct { + webhook service.WebhookService +} + +func (Webhooks) New() *Webhooks { + return &Webhooks{} +} + +func (ctrl *Webhooks) Get(ctx context.Context, r *request.WebhooksGet) (interface{}, error) { + return ctrl.webhook.With(ctx).Get(r.WebhookID) +} + +func (ctrl *Webhooks) Delete(ctx context.Context, r *request.WebhooksDelete) (interface{}, error) { + return nil, ctrl.webhook.With(ctx).Delete(r.WebhookID) +} + +func (ctrl *Webhooks) List(ctx context.Context, r *request.WebhooksList) (interface{}, error) { + return ctrl.webhook.With(ctx).Find(&types.WebhookFilter{ + ChannelID: r.ChannelID, + OwnerUserID: r.UserID, + }) +} + +func (ctrl *Webhooks) Create(ctx context.Context, r *request.WebhooksCreate) (interface{}, error) { + avatar, err := store.FromAny(r.Avatar, r.AvatarURL) + if err != nil { + return nil, err + } + defer avatar.Close() + // Webhook request parameters + parameters := types.WebhookRequest{ + r.Username, + avatar, + r.Trigger, + r.Url, + } + return ctrl.webhook.With(ctx).Create(r.Kind, r.ChannelID, parameters) +} + +func (ctrl *Webhooks) Update(ctx context.Context, r *request.WebhooksUpdate) (interface{}, error) { + avatar, err := store.FromAny(r.Avatar, r.AvatarURL) + if err != nil { + return nil, err + } + defer avatar.Close() + // Webhook request parameters + parameters := types.WebhookRequest{ + r.Username, + avatar, + r.Trigger, + r.Url, + } + return ctrl.webhook.With(ctx).Update(r.WebhookID, r.Kind, r.ChannelID, parameters) +} diff --git a/messaging/rest/webhooks_public.go b/messaging/rest/webhooks_public.go new file mode 100644 index 000000000..4c9648604 --- /dev/null +++ b/messaging/rest/webhooks_public.go @@ -0,0 +1,34 @@ +package rest + +import ( + "context" + + "github.com/pkg/errors" + + "github.com/crusttech/crust/internal/store" + "github.com/crusttech/crust/messaging/internal/service" + "github.com/crusttech/crust/messaging/rest/request" +) + +var _ = errors.Wrap + +type WebhooksPublic struct { + webhook service.WebhookService +} + +func (WebhooksPublic) New() *WebhooksPublic { + return &WebhooksPublic{} +} + +func (ctrl *WebhooksPublic) Delete(ctx context.Context, r *request.WebhooksPublicDelete) (interface{}, error) { + return nil, ctrl.webhook.With(ctx).DeleteByToken(r.WebhookID, r.WebhookToken) +} + +func (ctrl *WebhooksPublic) Create(ctx context.Context, r *request.WebhooksPublicCreate) (interface{}, error) { + avatar, err := store.FromAny(nil, r.AvatarURL) + if err != nil { + return nil, err + } + defer avatar.Close() + return ctrl.webhook.With(ctx).Message(r.WebhookID, r.WebhookToken, r.Username, avatar, r.Content) +} diff --git a/messaging/types/webhook.go b/messaging/types/webhook.go index 7f2bcae06..c8f3a139f 100644 --- a/messaging/types/webhook.go +++ b/messaging/types/webhook.go @@ -1,10 +1,9 @@ package types import ( + "io" "time" - "mime/multipart" - "github.com/crusttech/crust/internal/rules" ) @@ -12,8 +11,8 @@ type ( Webhook struct { ID uint64 `json:"id" db:"id"` - Kind WebhookKind `json:"kind" db:"webhook_kind"` - AuthToken string `json:"-" db:"webhook_token"` + Kind WebhookKind `json:"kind" db:"kind"` + AuthToken string `json:"-" db:"token"` OwnerUserID uint64 `json:"userId" db:"rel_owner"` @@ -33,8 +32,7 @@ type ( WebhookRequest struct { Username string - Avatar *multipart.FileHeader - AvatarURL string + Avatar io.Reader OutgoingTrigger string OutgoingURL string