From 99c366ada03568f5471af4465b46fc542404cc4f Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Thu, 15 Feb 2024 18:25:16 +0100 Subject: [PATCH] [kofi] Add `kofi_donation` event and Ko-fi webhook handler Signed-off-by: Knut Ahlers --- docs/content/configuration/events.md | 15 ++++ docs/content/modules/kofi.md | 36 ++++++++ events.go | 2 + internal/apimodules/kofi/kofi.go | 122 +++++++++++++++++++++++++++ internal/apimodules/kofi/schema.go | 51 +++++++++++ plugins_core.go | 2 + 6 files changed, 228 insertions(+) create mode 100644 docs/content/modules/kofi.md create mode 100644 internal/apimodules/kofi/kofi.go create mode 100644 internal/apimodules/kofi/schema.go diff --git a/docs/content/configuration/events.md b/docs/content/configuration/events.md index c90c9ca..b163f09 100644 --- a/docs/content/configuration/events.md +++ b/docs/content/configuration/events.md @@ -100,6 +100,21 @@ Fields: - `channel` - The channel the event occurred in - `user` - The login-name of the user who joined +## `kofi_donation` + +A Ko-fi donation was received through the API-Webhook. + +Fields: + +- `channel` - The channel the event occurred for +- `from` - The name submitted by Ko-fi (can be arbitrarily entered) +- `amount` - The amount donated as submitted by Ko-fi (i.e. 27.95) +- `currency` - The currency of the amount (i.e. USD) +- `isSubscription` - Boolean, true on monthly subscriptions, false on single-donations +- `isFirstSubPayment` - Boolean, true on first montly payment, false otherwise +- `message` - The message entered by the donator (**not** present when donation was marked as private!) +- `tier` - The tier the subscriber subscribed to (seems not to be filled on the first transaction?) + ## `outbound_raid` The channel has raided another channel. (The event is issued in the moment the raid is executed, not when the raid timer starts!) diff --git a/docs/content/modules/kofi.md b/docs/content/modules/kofi.md new file mode 100644 index 0000000..206fe9d --- /dev/null +++ b/docs/content/modules/kofi.md @@ -0,0 +1,36 @@ +--- +title: "Ko-fi Event-Integration" +--- + +If you are an active user of Ko-fi you probably want to have the Ko-fi events sent to your bot to trigger chat-messages, alerts in overlays or just to have the event registered in the bot for overlays or other purposes. To do so you can use the Ko-fi integration of the bot to receive events for donations and subscriptions (shop-orders currently are not supported). + +## Setting up + +You will need + +- a Ko-fi account +- an instance of the bot with access to the configuration +- the verification token available in the "API" menu entry in your settings page on Ko-fi + +Given the bot web-interface is available at `https://example.com/` your webhook URL would be `https://example.com/kofi/webhook/` so for example `https://example.com/kofi/webhook/luziferus`. You will later enter this into the "Webhook URL" field in the "API" settings-panel. + +At first copy your "Verification Token" from the "API" settings-panel (and don't tell anyone!). You need to create a new block within your bots configuration file (at the moment there is no way to configure this through the web-interface): + +```yaml +# Module configuration by channel or defining bot-wide defaults. See +# module specific documentation for options to configure in this +# section. All modules come with internal defaults so there is no +# need to configure this but you can overwrite the internal defaults. +module_config: + kofi: + luziferus: # put your channel name here, is the same as in the URL + verification_token: 'your verification token' +``` + +You can configure one token per channel and **should not** use the `default` entry as that would be used for all channels. This for example applies if your bot instance manages multiple channels with different Ko-fi accounts attached to them. + +Now that we know about the verification token, you can put the URL into the "API" settings-panel and click the "Update" button. + +As soon as you now click the "Send Single Donation Test", "Send First Monthly Test" or "Send Membership Tier Test" buttons you should see a `kofi_donation` event in the [debug overlay]({{< ref "../overlays/_index.md" >}}). + +From here you can create rules using the [`kofi_donation` event]({{< ref "../configuration/events.md" >}}#kofi_donation) doing stuff. diff --git a/events.go b/events.go index 73ebc9c..07b576b 100644 --- a/events.go +++ b/events.go @@ -27,6 +27,7 @@ var ( eventTypeFollow = ptrStr("follow") eventTypeGiftPaidUpgrade = ptrStr("giftpaidupgrade") eventTypeJoin = ptrStr("join") + eventKoFiDonation = ptrStr("kofi_donation") eventTypeOutboundRaid = ptrStr("outbound_raid") eventTypePart = ptrStr("part") eventTypePermit = ptrStr("permit") @@ -61,6 +62,7 @@ var ( eventTypeFollow, eventTypeGiftPaidUpgrade, eventTypeJoin, + eventKoFiDonation, eventTypeOutboundRaid, eventTypePart, eventTypePermit, diff --git a/internal/apimodules/kofi/kofi.go b/internal/apimodules/kofi/kofi.go new file mode 100644 index 0000000..76f8b3e --- /dev/null +++ b/internal/apimodules/kofi/kofi.go @@ -0,0 +1,122 @@ +// Package kofi contains a webhook listener to be used in the Ko-fi +// API to receive information about (recurring) donations / shop orders +package kofi + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/Luzifer/twitch-bot/v3/plugins" + "github.com/gorilla/mux" + "github.com/sirupsen/logrus" +) + +const actorName = "kofi" + +var ( + eventCreatorFunc plugins.EventHandlerFunc + getModuleConfig plugins.ModuleConfigGetterFunc + + ptrStringEmpty = func(s string) *string { return &s }("") +) + +// Register provides the plugins.RegisterFunc +func Register(args plugins.RegistrationArguments) (err error) { + eventCreatorFunc = args.CreateEvent + getModuleConfig = args.GetModuleConfigForChannel + + if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ + Description: "Endpoint to handle Ko-fi Webhook posts", + HandlerFunc: handleKoFiPost, + Method: http.MethodPost, + Module: actorName, + Name: "Handle Ko-fi Webhook", + Path: "/webhook/{channel}", + ResponseType: plugins.HTTPRouteResponseTypeJSON, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "Channel to create the event in", + Name: "channel", + }, + }, + }); err != nil { + return fmt.Errorf("registering API route: %w", err) + } + + return nil +} + +func handleKoFiPost(w http.ResponseWriter, r *http.Request) { + channel := mux.Vars(r)["channel"] + + channelModuleConf := getModuleConfig(actorName, channel) + + // The data is sent (posted) with a content type of application/x-www-form-urlencoded. + // A field named 'data' contains the payment information as a JSON string. + jsonData := r.FormValue("data") + if jsonData == "" { + // Well, no. + logrus.WithField("remote_addr", r.RemoteAddr).Warn("received KoFi hook without payload") + http.Error(w, "you missed something", http.StatusBadRequest) + return + } + + var ( + err error + payload hookPayload + ) + + // Read the payload + if err = json.Unmarshal([]byte(jsonData), &payload); err != nil { + logrus.WithError(err).Error("unmarshalling KoFi JSON data") + http.Error(w, "that's not valid json, you know", http.StatusBadRequest) + return + } + + // If we know the verification token, validate the payload + if validateToken := channelModuleConf.MustString("verification_token", ptrStringEmpty); validateToken != "" && payload.VerificationToken != validateToken { + logrus.WithFields(logrus.Fields{ + "expected": fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(validateToken))), + "provided": fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(payload.VerificationToken))), + }).Error("received Ko-fi payload with invalid verification token") + + http.Error(w, "ehm, who are you?", http.StatusForbidden) + return + } + + fields := plugins.NewFieldCollection() + fields.Set("channel", "#"+strings.TrimLeft(channel, "#")) + + switch payload.Type { + case hookTypeDonation, hookTypeSubscription: + // Single or Recurring Donation + fields.Set("from", payload.FromName) + fields.Set("amount", payload.Amount) + fields.Set("currency", payload.Currency) + fields.Set("isSubscription", payload.IsSubscriptionPayment) + fields.Set("isFirstSubPayment", payload.IsFirstSubscriptionPayment) + + if payload.IsPublic { + fields.Set("message", payload.Message) + } + + if payload.IsSubscriptionPayment && payload.TierName != nil { + fields.Set("tier", *payload.TierName) + } + + if err = eventCreatorFunc("kofi_donation", fields); err != nil { + logrus.WithError(err).Error("creating kofi_donation event") + http.Error(w, "ehm, that didn't work, I'm sorry", http.StatusInternalServerError) + return + } + + default: + // Unsupported, we take that and discard it + logrus.WithField("type", payload.Type).Warn("received unhandled hook type") + } + + w.WriteHeader(http.StatusOK) +} diff --git a/internal/apimodules/kofi/schema.go b/internal/apimodules/kofi/schema.go new file mode 100644 index 0000000..2e1c707 --- /dev/null +++ b/internal/apimodules/kofi/schema.go @@ -0,0 +1,51 @@ +package kofi + +import "time" + +type ( + hookPayload struct { + VerificationToken string `json:"verification_token"` + MessageID string `json:"message_id"` + Timestamp time.Time `json:"timestamp"` + Type hookType `json:"type"` + IsPublic bool `json:"is_public"` + FromName string `json:"from_name"` + Message *string `json:"message"` + Amount float64 `json:"amount,string"` + URL string `json:"url"` + Email string `json:"email"` + Currency string `json:"currency"` + IsSubscriptionPayment bool `json:"is_subscription_payment"` + IsFirstSubscriptionPayment bool `json:"is_first_subscription_payment"` + KofiTransactionID string `json:"kofi_transaction_id"` + ShopItems []shopItem `json:"shop_items"` + TierName *string `json:"tier_name"` + Shipping shippingInfo `json:"shipping"` + } + + hookType string + + shippingInfo struct { + FullName string `json:"full_name"` + StreetAddress string `json:"street_address"` + City string `json:"city"` + StateOrProvince string `json:"state_or_province"` + PostalCode string `json:"postal_code"` + Country string `json:"country"` + CountryCode string `json:"country_code"` + Telephone string `json:"telephone"` + } + + shopItem struct { + DirectLinkCode string `json:"direct_link_code"` + VariationName string `json:"variation_name"` + Quantity int `json:"quantity"` + } +) + +const ( + hookTypeCommission hookType = "Commission" + hookTypeDonation hookType = "Donation" + hookTypeShopOrder hookType = "Shop Order" + hookTypeSubscription hookType = "Subscription" +) diff --git a/plugins_core.go b/plugins_core.go index 06f6861..e88efc7 100644 --- a/plugins_core.go +++ b/plugins_core.go @@ -39,6 +39,7 @@ import ( "github.com/Luzifer/twitch-bot/v3/internal/actors/vip" "github.com/Luzifer/twitch-bot/v3/internal/actors/whisper" "github.com/Luzifer/twitch-bot/v3/internal/apimodules/customevent" + "github.com/Luzifer/twitch-bot/v3/internal/apimodules/kofi" "github.com/Luzifer/twitch-bot/v3/internal/apimodules/msgformat" "github.com/Luzifer/twitch-bot/v3/internal/apimodules/overlays" "github.com/Luzifer/twitch-bot/v3/internal/apimodules/raffle" @@ -101,6 +102,7 @@ var ( // API-only modules customevent.Register, + kofi.Register, msgformat.Register, overlays.Register, raffle.Register,