diff --git a/History.md b/History.md
index cd312b2..5d9f91c 100644
--- a/History.md
+++ b/History.md
@@ -7,6 +7,7 @@
**Changelog:**
* New Features
+ * [core] Add rule-subscription feature
* [templating] Add jsonAPI template function
* Improvements
diff --git a/config.go b/config.go
index 7dcecd3..c6a2121 100644
--- a/config.go
+++ b/config.go
@@ -28,6 +28,8 @@ var (
configReloadHooks = map[string]func(){}
configReloadHooksLock sync.RWMutex
+
+ errSaveNotRequired = errors.New("save not required")
)
func registerConfigReloadHook(hook func()) func() {
@@ -187,7 +189,16 @@ func patchConfig(filename, authorName, authorEmail, summary string, patcher func
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")
}
diff --git a/configEditor_rules.go b/configEditor_rules.go
index 651ae29..a91c3ac 100644
--- a/configEditor_rules.go
+++ b/configEditor_rules.go
@@ -89,7 +89,14 @@ func configEditorRulesAdd(w http.ResponseWriter, r *http.Request) {
return
}
- msg.UUID = uuid.Must(uuid.NewV4()).String()
+ 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()
+ }
if err := patchConfig(cfg.Config, user, "", "Add rule", func(c *configFile) error {
c.Rules = append(c.Rules, msg)
diff --git a/configRemoteUpdate.go b/configRemoteUpdate.go
new file mode 100644
index 0000000..714a5d8
--- /dev/null
+++ b/configRemoteUpdate.go
@@ -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")
+ }
+}
diff --git a/main.go b/main.go
index a522c69..48a33c4 100644
--- a/main.go
+++ b/main.go
@@ -247,6 +247,11 @@ func main() {
// are retried on error each time
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.HandleFunc("/openapi.html", handleSwaggerHTML)
router.HandleFunc("/openapi.json", handleSwaggerRequest)
diff --git a/plugins/rule.go b/plugins/rule.go
index ca87fab..1f3b076 100644
--- a/plugins/rule.go
+++ b/plugins/rule.go
@@ -1,7 +1,12 @@
package plugins
import (
+ "context"
+ "encoding/json"
"fmt"
+ "net/http"
+ "net/url"
+ "path"
"regexp"
"strings"
"time"
@@ -10,15 +15,19 @@ import (
"github.com/mitchellh/hashstructure/v2"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
+ "gopkg.in/yaml.v2"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/pkg/twitch"
)
+const remoteRuleFetchTimeout = 2 * time.Second
+
type (
Rule struct {
- UUID string `hash:"-" json:"uuid,omitempty" yaml:"uuid,omitempty"`
- Description string `json:"description,omitempty" yaml:"description,omitempty"`
+ UUID string `hash:"-" json:"uuid,omitempty" yaml:"uuid,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"`
@@ -60,11 +69,7 @@ func (r Rule) MatcherID() string {
return r.UUID
}
- h, err := hashstructure.Hash(r, hashstructure.FormatV2, nil)
- if err != nil {
- panic(errors.Wrap(err, "hashing automessage"))
- }
- return fmt.Sprintf("hashstructure:%x", h)
+ return r.hash()
}
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 {
for _, b := range r.DisableOn {
if badges.Has(b) {
@@ -397,3 +458,11 @@ func (r *Rule) allowExecuteUserWhitelist(logger *log.Entry, m *irc.Message, even
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)
+}
diff --git a/src/rules.vue b/src/rules.vue
index 3a7c209..ecd1529 100644
--- a/src/rules.vue
+++ b/src/rules.vue
@@ -30,7 +30,19 @@
>
-
+
+
+
+
{{ data.item.description }}
+
+ Shared
+
+
+
+
+
+
+
+
+
+
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) {
const action = this.models.rule.actions[idx]
const def = this.getActionDefinitionByType(action.type)