[core] Add rule-subscription feature

- Add remote url to rules
- Add cron to update remote URLs hourly
- Add frontend display for shared rules
- Add frontend feature to subscribe rules

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2022-10-07 19:10:22 +02:00
parent 50e8336a50
commit 61cc2d64b3
Signed by: luzifer
GPG key ID: D91C3E91E4CAD6F5
7 changed files with 216 additions and 10 deletions

View file

@ -7,6 +7,7 @@
**Changelog:** **Changelog:**
* New Features * New Features
* [core] Add rule-subscription feature
* [templating] Add jsonAPI template function * [templating] Add jsonAPI template function
* Improvements * Improvements

View file

@ -28,6 +28,8 @@ var (
configReloadHooks = map[string]func(){} configReloadHooks = map[string]func(){}
configReloadHooksLock sync.RWMutex configReloadHooksLock sync.RWMutex
errSaveNotRequired = errors.New("save not required")
) )
func registerConfigReloadHook(hook func()) func() { func registerConfigReloadHook(hook func()) func() {
@ -187,7 +189,16 @@ func patchConfig(filename, authorName, authorEmail, summary string, patcher func
cfgFile.fixMissingUUIDs() cfgFile.fixMissingUUIDs()
if err = patcher(cfgFile); err != nil { err = patcher(cfgFile)
switch {
case errors.Is(err, nil):
// This is fine
case errors.Is(err, errSaveNotRequired):
// This is also fine but we don't need to save
return nil
default:
return errors.Wrap(err, "patching config") return errors.Wrap(err, "patching config")
} }

View file

@ -89,7 +89,14 @@ func configEditorRulesAdd(w http.ResponseWriter, r *http.Request) {
return return
} }
if msg.SubscribeFrom != nil {
if _, err = msg.UpdateFromSubscription(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
msg.UUID = uuid.Must(uuid.NewV4()).String() msg.UUID = uuid.Must(uuid.NewV4()).String()
}
if err := patchConfig(cfg.Config, user, "", "Add rule", func(c *configFile) error { if err := patchConfig(cfg.Config, user, "", "Add rule", func(c *configFile) error {
c.Rules = append(c.Rules, msg) c.Rules = append(c.Rules, msg)

48
configRemoteUpdate.go Normal file
View file

@ -0,0 +1,48 @@
package main
import (
"fmt"
"math/rand"
log "github.com/sirupsen/logrus"
)
func updateConfigCron() string {
minute := rand.Intn(60) //nolint:gomnd,gosec // Only used to distribute load
return fmt.Sprintf("%d * * * *", minute)
}
func updateConfigFromRemote() {
err := patchConfig(
cfg.Config,
"Remote Update", "twitch-bot@luzifer.io",
"update rules from subscription URLs",
func(cfg *configFile) error {
var hasUpdate bool
for _, r := range cfg.Rules {
logger := log.WithField("rule", r.MatcherID())
rhu, err := r.UpdateFromSubscription()
if err != nil {
logger.WithError(err).Error("updating rule")
continue
}
if rhu {
hasUpdate = true
logger.Info("updated rule from remote URL")
}
}
if !hasUpdate {
return errSaveNotRequired
}
return nil
},
)
if err != nil {
log.WithError(err).Error("updating config rules from subscriptions")
}
}

View file

@ -247,6 +247,11 @@ func main() {
// are retried on error each time // are retried on error each time
cronService.AddFunc("@every 30s", twitchWatch.Check) cronService.AddFunc("@every 30s", twitchWatch.Check)
// Allow config to subscribe to external rules
updCron := updateConfigCron()
cronService.AddFunc(updCron, updateConfigFromRemote)
log.WithField("cron", updCron).Debug("Initialized remote update cron")
router.Use(corsMiddleware) router.Use(corsMiddleware)
router.HandleFunc("/openapi.html", handleSwaggerHTML) router.HandleFunc("/openapi.html", handleSwaggerHTML)
router.HandleFunc("/openapi.json", handleSwaggerRequest) router.HandleFunc("/openapi.json", handleSwaggerRequest)

View file

@ -1,7 +1,12 @@
package plugins package plugins
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"net/http"
"net/url"
"path"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@ -10,15 +15,19 @@ import (
"github.com/mitchellh/hashstructure/v2" "github.com/mitchellh/hashstructure/v2"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v2"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/pkg/twitch" "github.com/Luzifer/twitch-bot/pkg/twitch"
) )
const remoteRuleFetchTimeout = 2 * time.Second
type ( type (
Rule struct { Rule struct {
UUID string `hash:"-" json:"uuid,omitempty" yaml:"uuid,omitempty"` UUID string `hash:"-" json:"uuid,omitempty" yaml:"uuid,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"`
SubscribeFrom *string `json:"subscribe_from,omitempty" yaml:"subscribe_from,omitempty"`
Actions []*RuleAction `json:"actions,omitempty" yaml:"actions,omitempty"` Actions []*RuleAction `json:"actions,omitempty" yaml:"actions,omitempty"`
@ -60,11 +69,7 @@ func (r Rule) MatcherID() string {
return r.UUID return r.UUID
} }
h, err := hashstructure.Hash(r, hashstructure.FormatV2, nil) return r.hash()
if err != nil {
panic(errors.Wrap(err, "hashing automessage"))
}
return fmt.Sprintf("hashstructure:%x", h)
} }
func (r *Rule) Matches(m *irc.Message, event *string, timerStore TimerStore, msgFormatter MsgFormatter, twitchClient *twitch.Client, eventData *FieldCollection) bool { func (r *Rule) Matches(m *irc.Message, event *string, timerStore TimerStore, msgFormatter MsgFormatter, twitchClient *twitch.Client, eventData *FieldCollection) bool {
@ -132,6 +137,62 @@ func (r *Rule) SetCooldown(timerStore TimerStore, m *irc.Message, evtData *Field
} }
} }
func (r *Rule) UpdateFromSubscription() (bool, error) {
if r.SubscribeFrom == nil || len(*r.SubscribeFrom) == 0 {
return false, nil
}
prevHash := r.hash()
remoteURL, err := url.Parse(*r.SubscribeFrom)
if err != nil {
return false, errors.Wrap(err, "parsing remote subscription url")
}
ctx, cancel := context.WithTimeout(context.Background(), remoteRuleFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteURL.String(), nil)
if err != nil {
return false, errors.Wrap(err, "assembling request")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, errors.Wrap(err, "executing request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, errors.Errorf("unxpected HTTP status %d", resp.StatusCode)
}
var newRule Rule
switch path.Ext(remoteURL.Path) {
case ".json":
err = json.NewDecoder(resp.Body).Decode(&newRule)
case ".yaml", ".yml":
err = yaml.NewDecoder(resp.Body).Decode(&newRule)
default:
return false, errors.New("unexpected format")
}
if err != nil {
return false, errors.Wrap(err, "decoding remote rule")
}
if newRule.hash() == prevHash {
// No update, exit now
return false, nil
}
*r = newRule
return true, nil
}
func (r *Rule) allowExecuteBadgeBlacklist(logger *log.Entry, m *irc.Message, event *string, badges twitch.BadgeCollection, evtData *FieldCollection) bool { func (r *Rule) allowExecuteBadgeBlacklist(logger *log.Entry, m *irc.Message, event *string, badges twitch.BadgeCollection, evtData *FieldCollection) bool {
for _, b := range r.DisableOn { for _, b := range r.DisableOn {
if badges.Has(b) { if badges.Has(b) {
@ -397,3 +458,11 @@ func (r *Rule) allowExecuteUserWhitelist(logger *log.Entry, m *irc.Message, even
return true return true
} }
func (r Rule) hash() string {
h, err := hashstructure.Hash(r, hashstructure.FormatV2, nil)
if err != nil {
panic(errors.Wrap(err, "hashing rule"))
}
return fmt.Sprintf("hashstructure:%x", h)
}

View file

@ -30,7 +30,19 @@
> >
<template #cell(_actions)="data"> <template #cell(_actions)="data">
<b-button-group size="sm"> <b-button-group size="sm">
<b-button @click="editRule(data.item)"> <b-button
v-if="data.item.subscribe_from"
disabled
>
<font-awesome-icon
fixed-width
:icon="['fas', 'download']"
/>
</b-button>
<b-button
v-else
@click="editRule(data.item)"
>
<font-awesome-icon <font-awesome-icon
fixed-width fixed-width
:icon="['fas', 'pen']" :icon="['fas', 'pen']"
@ -63,6 +75,13 @@
<template v-if="data.item.description"> <template v-if="data.item.description">
{{ data.item.description }}<br> {{ data.item.description }}<br>
</template> </template>
<b-badge
v-if="data.item.subscribe_from"
class="mt-1 mr-1"
variant="primary"
>
Shared
</b-badge>
<b-badge <b-badge
v-if="data.item.disable" v-if="data.item.disable"
class="mt-1 mr-1" class="mt-1 mr-1"
@ -90,12 +109,45 @@
:icon="['fas', 'plus']" :icon="['fas', 'plus']"
/> />
</b-button> </b-button>
<b-button
variant="secondary"
@click="showRuleSubscribeModal=true"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'download']"
/>
</b-button>
</b-button-group> </b-button-group>
</template> </template>
</b-table> </b-table>
</b-col> </b-col>
</b-row> </b-row>
<b-modal
v-if="showRuleSubscribeModal"
hide-header-close
:ok-disabled="!models.subscriptionURL"
ok-title="Subscribe"
size="md"
:visible="showRuleSubscribeModal"
title="Subscribe Rule"
@hidden="showRuleSubscribeModal=false"
@ok="subscribeRule"
>
<b-form-group
label="Rule Subscription URL"
label-for="formRuleSubURL"
>
<b-form-input
id="formRuleSubURL"
v-model="models.subscriptionURL"
:state="Boolean(models.subscriptionURL)"
type="text"
/>
</b-form-group>
</b-modal>
<!-- Rule Editor --> <!-- Rule Editor -->
<b-modal <b-modal
v-if="showRuleEditModal" v-if="showRuleEditModal"
@ -702,6 +754,7 @@ export default {
addAction: '', addAction: '',
addException: '', addException: '',
rule: {}, rule: {},
subscriptionURL: '',
}, },
rules: [], rules: [],
@ -727,6 +780,7 @@ export default {
], ],
showRuleEditModal: false, showRuleEditModal: false,
showRuleSubscribeModal: false,
validateReason: null, validateReason: null,
} }
}, },
@ -1027,6 +1081,17 @@ export default {
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err)) .catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
}, },
subscribeRule() {
axios.post(`config-editor/rules`, {
subscribe_from: this.models.subscriptionURL,
}, this.$root.axiosOptions)
.then(() => {
this.models.subscriptionURL = ''
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, true)
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
validateActionArgument(idx, key) { validateActionArgument(idx, key) {
const action = this.models.rule.actions[idx] const action = this.models.rule.actions[idx]
const def = this.getActionDefinitionByType(action.type) const def = this.getActionDefinitionByType(action.type)