diff --git a/Makefile b/Makefile index 431df6e..b74edf2 100644 --- a/Makefile +++ b/Makefile @@ -57,9 +57,14 @@ trivy: # -- Documentation Site -- +docs: actor_docs template_docs + actor_docs: go run . --storage-conn-string $(shell mktemp).db actor-docs >docs/content/configuration/actors.md +template_docs: + go run . --storage-conn-string $(shell mktemp).db tpl-docs >docs/content/configuration/templating.md + eventclient_docs: echo -e "---\ntitle: EventClient\nweight: 10000\n---\n" >docs/content/overlays/eventclient.md docker run --rm -i -v $(CURDIR):$(CURDIR) -w $(CURDIR) node:18-alpine sh -ec 'npx --yes jsdoc-to-markdown --files ./internal/apimodules/overlays/default/eventclient.js' >>docs/content/overlays/eventclient.md diff --git a/cli_tplDocs.go b/cli_tplDocs.go new file mode 100644 index 0000000..b8d6bce --- /dev/null +++ b/cli_tplDocs.go @@ -0,0 +1,26 @@ +package main + +import ( + "bytes" + "os" + + "github.com/pkg/errors" +) + +func init() { + cli.Add(cliRegistryEntry{ + Name: "tpl-docs", + Description: "Generate markdown documentation for available template functions", + Run: func(args []string) error { + doc, err := generateTplDocs() + if err != nil { + return errors.Wrap(err, "generating template docs") + } + if _, err = os.Stdout.Write(append(bytes.TrimSpace(doc), '\n')); err != nil { + return errors.Wrap(err, "writing actor docs to stdout") + } + + return nil + }, + }) +} diff --git a/docs/content/configuration/templating.md b/docs/content/configuration/templating.md index 015acd7..3a8331a 100644 --- a/docs/content/configuration/templating.md +++ b/docs/content/configuration/templating.md @@ -30,7 +30,8 @@ Examples below are using this syntax in the code block: ! Message matcher used for the input message > Input message if used in the example # Template used in the fields -< Output from the template +< Output from the template (Rendered during docs generation) +* Output from the template (Static output, template not rendered) ``` ### `arg` @@ -96,7 +97,7 @@ Example: ``` # {{ channelCounter "test" }} -< 5 +< #example:test ``` ### `counterValue` @@ -109,7 +110,7 @@ Example: ``` # {{ counterValue (list .channel "test" | join ":") }} -< 5 +* 5 ``` ### `counterValueAdd` @@ -119,9 +120,10 @@ Adds the given value (or 1 if no value) to the counter and returns its new value Syntax: `counterValueAdd [increase=1]` Example: + ``` # {{ counterValueAdd "myCounter" }} {{ counterValueAdd "myCounter" 5 }} -< 1 6 +* 1 6 ``` ### `displayName` @@ -134,7 +136,7 @@ Example: ``` # {{ displayName "luziferus" }} - {{ displayName "notexistinguser" "foobar" }} -< Luziferus - foobar +* Luziferus - foobar ``` ### `doesFollow` @@ -147,7 +149,7 @@ Example: ``` # {{ doesFollow "tezrian" "luziferus" }} -< true +* true ``` ### `doesFollowLongerThan` @@ -160,7 +162,7 @@ Example: ``` # {{ doesFollowLongerThan "tezrian" "luziferus" "168h" }} -< true +* true ``` ### `fixUsername` @@ -173,20 +175,7 @@ Example: ``` # {{ fixUsername .channel }} - {{ fixUsername "@luziferus" }} -< luziferus - luziferus -``` - -### `formatDuration` - -Returns a formated duration. Pass empty strings to leave out the specific duration part. - -Syntax: `formatDuration ` - -Example: - -``` -# {{ formatDuration (streamUptime .channel) "hours" "minutes" "seconds" }} - {{ formatDuration (streamUptime .channel) "hours" "minutes" "" }} -< 5 hours, 33 minutes, 12 seconds - 5 hours, 33 minutes +< example - luziferus ``` ### `followAge` @@ -199,7 +188,7 @@ Example: ``` # {{ followAge "tezrian" "luziferus" }} -< 15004h14m59.116620989s +* 15004h14m59.116620989s ``` ### `followDate` @@ -212,7 +201,20 @@ Example: ``` # {{ followDate "tezrian" "luziferus" }} -< 2021-04-10 16:07:07 +0000 UTC +* 2021-04-10 16:07:07 +0000 UTC +``` + +### `formatDuration` + +Returns a formated duration. Pass empty strings to leave out the specific duration part. + +Syntax: `formatDuration ` + +Example: + +``` +# {{ formatDuration .testDuration "hours" "minutes" "seconds" }} - {{ formatDuration .testDuration "hours" "minutes" "" }} +< 5 hours, 33 minutes, 12 seconds - 5 hours, 33 minutes ``` ### `group` @@ -224,7 +226,7 @@ Syntax: `group [fallback]` Example: ``` -! !command ([0-9]+) ([a-z]+) ([a-z]*) +! !command ([0-9]+) ([a-z]+) ?([a-z]*) > !command 12 test # {{ group 2 "oops" }} - {{ group 3 "oops" }} < test - oops @@ -234,7 +236,7 @@ Example: Tests whether a string is in a given list of strings (for conditional templates). -Syntax: `inList "search" "item1" "item2" [...]` +Syntax: `inList <...string>` Example: @@ -249,15 +251,13 @@ Example: Fetches remote URL and applies jq-like query to it returning the result as string. (Remote API needs to return status 200 within 5 seconds.) -Syntax: `jsonAPI "https://example.com/doc.json" ".data.exampleString" ["fallback"]` +Syntax: `jsonAPI [fallback]` Example: ``` -! !mycmd -> !mycmd -# {{ jsonAPI "https://example.com/doc.json" ".data.exampleString" }} -< example string +# {{ jsonAPI "https://api.github.com/repos/Luzifer/twitch-bot" ".owner.login" }} +* Luzifer ``` ### `lastPoll` @@ -270,7 +270,7 @@ Example: ``` # Last Poll: {{ (lastPoll .channel).Title }} -< Last Poll: Und wie siehts im Template aus? +* Last Poll: Und wie siehts im Template aus? ``` See schema of returned object in [`pkg/twitch/polls.go#L13`](https://github.com/Luzifer/twitch-bot/blob/master/pkg/twitch/polls.go#L13) @@ -285,7 +285,7 @@ Example: ``` # Last Quote: #{{ lastQuoteIndex }} -< Last Quote: #32 +* Last Quote: #32 ``` ### `mention` @@ -310,11 +310,11 @@ Syntax: `pow ` Example: ``` -# {{ printf "%.0f" (pow 10 4) }}% +# {{ printf "%.0f" (pow 10 4) }} < 10000 ``` -### `profileImage` +### `profileImage` Gets the URL of the given users profile image @@ -324,20 +324,20 @@ Example: ``` # {{ profileImage .username }} -< https://static-cdn.jtvnw.net/jtv_user_pictures/[...].png +* https://static-cdn.jtvnw.net/jtv_user_pictures/[...].png ``` ### `randomString` Randomly picks a string from a list of strings -Syntax: `randomString "a" [...]` +Syntax: `randomString [...string]` Example: ``` # {{ randomString "a" "b" "c" "d" }} -< a +* a ``` ### `recentGame` @@ -350,10 +350,9 @@ Example: ``` # {{ recentGame "luziferus" "none" }} - {{ recentGame "thisuserdoesnotexist123" "none" }} -< Metro Exodus - none +* Metro Exodus - none ``` - ### `recentTitle` Returns the last stream title of the specified user or the `fallback` if the title could not be fetched. If no fallback was supplied the message will fail and not be sent. @@ -364,7 +363,7 @@ Example: ``` # {{ recentGame "luziferus" "none" }} - {{ recentGame "thisuserdoesnotexist123" "none" }} -< Die Oper haben wir überlebt, mal sehen was uns sonst noch alles töten möchte… - none +* Die Oper haben wir überlebt, mal sehen was uns sonst noch alles töten möchte… - none ``` ### `seededRandom` @@ -376,8 +375,8 @@ Syntax: `seededRandom ` Example: ``` -# Your int this hour: {{ printf "%.0f" (mul (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}% -< Your int this hour: 17% +# Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}% +< Your int this hour: 84% ``` ### `streamUptime` @@ -390,7 +389,7 @@ Example: ``` # {{ formatDuration (streamUptime "luziferus") "hours" "minutes" "" }} -< 3 hours, 56 minutes +* 3 hours, 56 minutes ``` ### `subCount` @@ -403,7 +402,7 @@ Example: ``` # {{ subCount "luziferus" }} -< 26 +* 26 ``` ### `subPoints` @@ -416,7 +415,7 @@ Example: ``` # {{ subPoints "luziferus" }} -< 26 +* 26 ``` ### `tag` @@ -428,15 +427,15 @@ Syntax: `tag ` Example: ``` -# {{ tag "login" }} -< luziferus +# {{ tag "display-name" }} +< ExampleUser ``` ### `textAPI` Fetches remote URL and returns the result as string. (Remote API needs to return status 200 within 5 seconds.) -Syntax: `textAPI "https://example.com/" ["fallback"]` +Syntax: `textAPI [fallback]` Example: @@ -444,7 +443,7 @@ Example: ! !weather (.*) > !weather Hamburg # {{ textAPI (printf "https://api.scorpstuff.com/weather.php?units=metric&city=%s" (urlquery (group 1))) }} -< Weather for Hamburg, DE: Few clouds with a temperature of 22 C (71.6 F). [...] +* Weather for Hamburg, DE: Few clouds with a temperature of 22 C (71.6 F). [...] ``` ### `variable` @@ -457,7 +456,7 @@ Example: ``` # {{ variable "foo" "fallback" }} - {{ variable "unsetvar" "fallback" }} -< test - fallback +* test - fallback ``` ## Upgrade from `v2.x` to `v3.x` diff --git a/functions.go b/functions.go index b6b4b1e..b213f48 100644 --- a/functions.go +++ b/functions.go @@ -23,6 +23,7 @@ var ( ) type templateFuncProvider struct { + docs []plugins.TemplateFuncDocumentation funcs map[string]plugins.TemplateFuncGetter lock *sync.RWMutex } @@ -72,7 +73,7 @@ func (t *templateFuncProvider) GetFuncNames() []string { return out } -func (t *templateFuncProvider) Register(name string, fg plugins.TemplateFuncGetter) { +func (t *templateFuncProvider) Register(name string, fg plugins.TemplateFuncGetter, doc ...plugins.TemplateFuncDocumentation) { t.lock.Lock() defer t.lock.Unlock() @@ -81,6 +82,11 @@ func (t *templateFuncProvider) Register(name string, fg plugins.TemplateFuncGett } t.funcs[name] = fg + + if len(doc) > 0 { + doc[0].Name = name + t.docs = append(t.docs, doc[0]) + } } func init() { @@ -112,5 +118,12 @@ func init() { } return strings.Join(parts, ", ") - })) + }), plugins.TemplateFuncDocumentation{ + Description: "Returns a formated duration. Pass empty strings to leave out the specific duration part.", + Syntax: "formatDuration ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ formatDuration .testDuration "hours" "minutes" "seconds" }} - {{ formatDuration .testDuration "hours" "minutes" "" }}`, + ExpectedOutput: "5 hours, 33 minutes, 12 seconds - 5 hours, 33 minutes", + }, + }) } diff --git a/functions_irc.go b/functions_irc.go index 50c844f..d1e12c4 100644 --- a/functions_irc.go +++ b/functions_irc.go @@ -5,8 +5,8 @@ import ( "github.com/go-irc/irc" "github.com/pkg/errors" - log "github.com/sirupsen/logrus" + "github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/plugins" ) @@ -20,26 +20,42 @@ func init() { return msgParts[arg], nil } + }, plugins.TemplateFuncDocumentation{ + Description: "Takes the message sent to the channel, splits by space and returns the Nth element", + Syntax: "arg ", + Example: &plugins.TemplateFuncDocumentationExample{ + MessageContent: "!bsg @tester", + Template: `{{ arg 1 }} please refrain from BSG`, + ExpectedOutput: `@tester please refrain from BSG`, + }, }) tplFuncs.Register("botHasBadge", func(m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) interface{} { return func(badge string) bool { - channel, err := fields.String("channel") - if err != nil { - log.Trace("Fields for botHasBadge function had no channel") - return false - } - - state := botUserstate.Get(channel) - if state == nil { - return false - } - - return state.Badges.Has(badge) + badges := twitch.ParseBadgeLevels(m) + return badges.Has(badge) } + }, plugins.TemplateFuncDocumentation{ + Description: "Checks whether bot has the given badge in the current channel", + Syntax: "botHasBadge ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ botHasBadge "moderator" }}`, + ExpectedOutput: "true", + }, }) - tplFuncs.Register("fixUsername", plugins.GenericTemplateFunctionGetter(func(username string) string { return strings.TrimLeft(username, "@#") })) + tplFuncs.Register( + "fixUsername", + plugins.GenericTemplateFunctionGetter(func(username string) string { return strings.TrimLeft(username, "@#") }), + plugins.TemplateFuncDocumentation{ + Description: "Ensures the username no longer contains the `@` or `#` prefix", + Syntax: "fixUsername ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ fixUsername .channel }} - {{ fixUsername "@luziferus" }}`, + ExpectedOutput: "example - luziferus", + }, + }, + ) tplFuncs.Register("group", func(m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) interface{} { return func(idx int, fallback ...string) (string, error) { @@ -54,14 +70,41 @@ func init() { return fields[idx], nil } + }, plugins.TemplateFuncDocumentation{ + Description: "Gets matching group specified by index from `match_message` regular expression, when `fallback` is defined, it is used when group has an empty match", + Syntax: "group [fallback]", + Example: &plugins.TemplateFuncDocumentationExample{ + MatchMessage: "!command ([0-9]+) ([a-z]+) ?([a-z]*)", + MessageContent: "!command 12 test", + Template: `{{ group 2 "oops" }} - {{ group 3 "oops" }}`, + ExpectedOutput: "test - oops", + }, }) - tplFuncs.Register("mention", plugins.GenericTemplateFunctionGetter(func(username string) string { return "@" + strings.TrimLeft(username, "@#") })) + tplFuncs.Register( + "mention", + plugins.GenericTemplateFunctionGetter(func(username string) string { return "@" + strings.TrimLeft(username, "@#") }), + plugins.TemplateFuncDocumentation{ + Description: "Strips username and converts into a mention", + Syntax: "mention ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ mention "@user" }} {{ mention "user" }} {{ mention "#user" }}`, + ExpectedOutput: "@user @user @user", + }, + }, + ) tplFuncs.Register("tag", func(m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) interface{} { return func(tag string) string { s, _ := m.GetTag(tag) return s } + }, plugins.TemplateFuncDocumentation{ + Description: "Takes the message sent to the channel, returns the value of the tag specified", + Syntax: "tag ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ tag "display-name" }}`, + ExpectedOutput: "ExampleUser", + }, }) } diff --git a/functions_twitch.go b/functions_twitch.go index d5c92ab..93aa6b0 100644 --- a/functions_twitch.go +++ b/functions_twitch.go @@ -13,16 +13,96 @@ import ( ) func init() { - tplFuncs.Register("displayName", plugins.GenericTemplateFunctionGetter(tplTwitchDisplayName)) - tplFuncs.Register("doesFollow", plugins.GenericTemplateFunctionGetter(tplTwitchDoesFollow)) - tplFuncs.Register("followAge", plugins.GenericTemplateFunctionGetter(tplTwitchFollowAge)) - tplFuncs.Register("followDate", plugins.GenericTemplateFunctionGetter(tplTwitchFollowDate)) - tplFuncs.Register("doesFollowLongerThan", plugins.GenericTemplateFunctionGetter(tplTwitchDoesFollowLongerThan)) - tplFuncs.Register("lastPoll", plugins.GenericTemplateFunctionGetter(tplTwitchLastPoll)) - tplFuncs.Register("profileImage", plugins.GenericTemplateFunctionGetter(tplTwitchProfileImage)) - tplFuncs.Register("recentGame", plugins.GenericTemplateFunctionGetter(tplTwitchRecentGame)) - tplFuncs.Register("recentTitle", plugins.GenericTemplateFunctionGetter(tplTwitchRecentTitle)) - tplFuncs.Register("streamUptime", plugins.GenericTemplateFunctionGetter(tplTwitchStreamUptime)) + tplFuncs.Register("displayName", plugins.GenericTemplateFunctionGetter(tplTwitchDisplayName), plugins.TemplateFuncDocumentation{ + Description: "Returns the display name the specified user set for themselves", + Syntax: "displayName [fallback]", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ displayName "luziferus" }} - {{ displayName "notexistinguser" "foobar" }}`, + FakedOutput: "Luziferus - foobar", + }, + }) + + tplFuncs.Register("doesFollowLongerThan", plugins.GenericTemplateFunctionGetter(tplTwitchDoesFollowLongerThan), plugins.TemplateFuncDocumentation{ + Description: "Returns whether `from` follows `to` for more than `duration`", + Syntax: "doesFollowLongerThan ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ doesFollowLongerThan "tezrian" "luziferus" "168h" }}`, + FakedOutput: "true", + }, + }) + + tplFuncs.Register("doesFollow", plugins.GenericTemplateFunctionGetter(tplTwitchDoesFollow), plugins.TemplateFuncDocumentation{ + Description: "Returns whether `from` follows `to`", + Syntax: "doesFollow ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ doesFollow "tezrian" "luziferus" }}`, + FakedOutput: "true", + }, + }) + + tplFuncs.Register("followAge", plugins.GenericTemplateFunctionGetter(tplTwitchFollowAge), plugins.TemplateFuncDocumentation{ + Description: "Looks up when `from` followed `to` and returns the duration between then and now", + Syntax: "followAge ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ followAge "tezrian" "luziferus" }}`, + FakedOutput: "15004h14m59.116620989s", + }, + }) + + tplFuncs.Register("followDate", plugins.GenericTemplateFunctionGetter(tplTwitchFollowDate), plugins.TemplateFuncDocumentation{ + Description: "Looks up when `from` followed `to`", + Syntax: "followDate ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ followDate "tezrian" "luziferus" }}`, + FakedOutput: "2021-04-10 16:07:07 +0000 UTC", + }, + }) + + tplFuncs.Register("lastPoll", plugins.GenericTemplateFunctionGetter(tplTwitchLastPoll), plugins.TemplateFuncDocumentation{ + Description: "Gets the last (currently running or archived) poll for the given channel (the channel must have given extended permission for poll access!)", + Syntax: "lastPoll ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `Last Poll: {{ (lastPoll .channel).Title }}`, + FakedOutput: "Last Poll: Und wie siehts im Template aus?", + }, + Remarks: "See schema of returned object in [`pkg/twitch/polls.go#L13`](https://github.com/Luzifer/twitch-bot/blob/master/pkg/twitch/polls.go#L13)", + }) + + tplFuncs.Register("profileImage", plugins.GenericTemplateFunctionGetter(tplTwitchProfileImage), plugins.TemplateFuncDocumentation{ + Description: "Gets the URL of the given users profile image", + Syntax: "profileImage ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ profileImage .username }}`, + FakedOutput: "https://static-cdn.jtvnw.net/jtv_user_pictures/[...].png", + }, + }) + + tplFuncs.Register("recentGame", plugins.GenericTemplateFunctionGetter(tplTwitchRecentGame), plugins.TemplateFuncDocumentation{ + Description: "Returns the last played game name of the specified user (see shoutout example) or the `fallback` if the game could not be fetched. If no fallback was supplied the message will fail and not be sent.", + Syntax: "recentGame [fallback]", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ recentGame "luziferus" "none" }} - {{ recentGame "thisuserdoesnotexist123" "none" }}`, + FakedOutput: "Metro Exodus - none", + }, + }) + + tplFuncs.Register("recentTitle", plugins.GenericTemplateFunctionGetter(tplTwitchRecentTitle), plugins.TemplateFuncDocumentation{ + Description: "Returns the last stream title of the specified user or the `fallback` if the title could not be fetched. If no fallback was supplied the message will fail and not be sent.", + Syntax: "recentTitle [fallback]", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ recentGame "luziferus" "none" }} - {{ recentGame "thisuserdoesnotexist123" "none" }}`, + FakedOutput: "Die Oper haben wir überlebt, mal sehen was uns sonst noch alles töten möchte… - none", + }, + }) + + tplFuncs.Register("streamUptime", plugins.GenericTemplateFunctionGetter(tplTwitchStreamUptime), plugins.TemplateFuncDocumentation{ + Description: "Returns the duration the stream is online (causes an error if no current stream is found)", + Syntax: "streamUptime ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ formatDuration (streamUptime "luziferus") "hours" "minutes" "" }}`, + FakedOutput: "3 hours, 56 minutes", + }, + }) } func tplTwitchDisplayName(username string, v ...string) (string, error) { diff --git a/internal/actors/counter/actor.go b/internal/actors/counter/actor.go index 617ffe4..9622587 100644 --- a/internal/actors/counter/actor.go +++ b/internal/actors/counter/actor.go @@ -131,11 +131,25 @@ func Register(args plugins.RegistrationArguments) error { return strings.Join([]string{channel, name}, ":"), nil } + }, plugins.TemplateFuncDocumentation{ + Description: "Wraps the counter name into a channel specific counter name including the channel name", + Syntax: "channelCounter ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ channelCounter "test" }}`, + ExpectedOutput: "#example:test", + }, }) args.RegisterTemplateFunction("counterValue", plugins.GenericTemplateFunctionGetter(func(name string, _ ...string) (int64, error) { return GetCounterValue(db, name) - })) + }), plugins.TemplateFuncDocumentation{ + Description: "Returns the current value of the counter which identifier was supplied", + Syntax: "counterValue ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ counterValue (list .channel "test" | join ":") }}`, + FakedOutput: "5", + }, + }) args.RegisterTemplateFunction("counterValueAdd", plugins.GenericTemplateFunctionGetter(func(name string, val ...int64) (int64, error) { var mod int64 = 1 @@ -148,7 +162,14 @@ func Register(args plugins.RegistrationArguments) error { } return GetCounterValue(db, name) - })) + }), plugins.TemplateFuncDocumentation{ + Description: "Adds the given value (or 1 if no value) to the counter and returns its new value", + Syntax: "counterValueAdd [increase=1]", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ counterValueAdd "myCounter" }} {{ counterValueAdd "myCounter" 5 }}`, + FakedOutput: "1 6", + }, + }) return nil } diff --git a/internal/actors/quotedb/actor.go b/internal/actors/quotedb/actor.go index 9c29192..70bc15a 100644 --- a/internal/actors/quotedb/actor.go +++ b/internal/actors/quotedb/actor.go @@ -87,6 +87,13 @@ func Register(args plugins.RegistrationArguments) error { return func() (int, error) { return GetMaxQuoteIdx(db, plugins.DeriveChannel(m, nil)) } + }, plugins.TemplateFuncDocumentation{ + Description: "Gets the last quote index in the quote database for the current channel", + Syntax: "lastQuoteIndex", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `Last Quote: #{{ lastQuoteIndex }}`, + FakedOutput: "Last Quote: #32", + }, }) return nil diff --git a/internal/actors/variables/actor.go b/internal/actors/variables/actor.go index 35a20e3..f80833d 100644 --- a/internal/actors/variables/actor.go +++ b/internal/actors/variables/actor.go @@ -20,6 +20,7 @@ var ( ptrStringEmpty = func(s string) *string { return &s }("") ) +//nolint:funlen // Function contains only documentation registration func Register(args plugins.RegistrationArguments) error { db = args.GetDatabaseConnector() if err := db.DB().AutoMigrate(&variable{}); err != nil { @@ -116,7 +117,14 @@ func Register(args plugins.RegistrationArguments) error { return defVal[0], nil } return value, nil - })) + }), plugins.TemplateFuncDocumentation{ + Description: "Returns the variable value or default in case it is empty", + Syntax: "variable [default]", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ variable "foo" "fallback" }} - {{ variable "unsetvar" "fallback" }}`, + FakedOutput: "test - fallback", + }, + }) return nil } diff --git a/internal/template/api/api.go b/internal/template/api/api.go index 96a59b4..4993018 100644 --- a/internal/template/api/api.go +++ b/internal/template/api/api.go @@ -3,8 +3,25 @@ package api import "github.com/Luzifer/twitch-bot/v3/plugins" func Register(args plugins.RegistrationArguments) error { - args.RegisterTemplateFunction("jsonAPI", plugins.GenericTemplateFunctionGetter(jsonAPI)) - args.RegisterTemplateFunction("textAPI", plugins.GenericTemplateFunctionGetter(textAPI)) + args.RegisterTemplateFunction("jsonAPI", plugins.GenericTemplateFunctionGetter(jsonAPI), plugins.TemplateFuncDocumentation{ + Description: "Fetches remote URL and applies jq-like query to it returning the result as string. (Remote API needs to return status 200 within 5 seconds.)", + Syntax: "jsonAPI [fallback]", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ jsonAPI "https://api.github.com/repos/Luzifer/twitch-bot" ".owner.login" }}`, + FakedOutput: "Luzifer", + }, + }) + + args.RegisterTemplateFunction("textAPI", plugins.GenericTemplateFunctionGetter(textAPI), plugins.TemplateFuncDocumentation{ + Description: "Fetches remote URL and returns the result as string. (Remote API needs to return status 200 within 5 seconds.)", + Syntax: "textAPI [fallback]", + Example: &plugins.TemplateFuncDocumentationExample{ + MatchMessage: "!weather (.*)", + MessageContent: "!weather Hamburg", + Template: `{{ textAPI (printf "https://api.scorpstuff.com/weather.php?units=metric&city=%s" (urlquery (group 1))) }}`, + FakedOutput: "Weather for Hamburg, DE: Few clouds with a temperature of 22 C (71.6 F). [...]", + }, + }) return nil } diff --git a/internal/template/numeric/numeric.go b/internal/template/numeric/numeric.go index b82865e..b6d7f05 100644 --- a/internal/template/numeric/numeric.go +++ b/internal/template/numeric/numeric.go @@ -7,6 +7,14 @@ import ( ) func Register(args plugins.RegistrationArguments) error { - args.RegisterTemplateFunction("pow", plugins.GenericTemplateFunctionGetter(math.Pow)) + args.RegisterTemplateFunction("pow", plugins.GenericTemplateFunctionGetter(math.Pow), plugins.TemplateFuncDocumentation{ + Description: "Returns float from calculation: `float1 ** float2`", + Syntax: "pow ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ printf "%.0f" (pow 10 4) }}`, + ExpectedOutput: "10000", + }, + }) + return nil } diff --git a/internal/template/random/random.go b/internal/template/random/random.go index f27969b..a0ed3c0 100644 --- a/internal/template/random/random.go +++ b/internal/template/random/random.go @@ -12,8 +12,23 @@ import ( ) func Register(args plugins.RegistrationArguments) error { - args.RegisterTemplateFunction("randomString", plugins.GenericTemplateFunctionGetter(randomString)) - args.RegisterTemplateFunction("seededRandom", plugins.GenericTemplateFunctionGetter(stableRandomFromSeed)) + args.RegisterTemplateFunction("randomString", plugins.GenericTemplateFunctionGetter(randomString), plugins.TemplateFuncDocumentation{ + Description: "Randomly picks a string from a list of strings", + Syntax: "randomString [...string]", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ randomString "a" "b" "c" "d" }}`, + FakedOutput: "a", + }, + }) + + args.RegisterTemplateFunction("seededRandom", plugins.GenericTemplateFunctionGetter(stableRandomFromSeed), plugins.TemplateFuncDocumentation{ + Description: "Returns a float value stable for the given seed", + Syntax: "seededRandom ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}%`, + }, + }) + return nil } diff --git a/internal/template/slice/slice.go b/internal/template/slice/slice.go index 010f71e..c770ea3 100644 --- a/internal/template/slice/slice.go +++ b/internal/template/slice/slice.go @@ -6,6 +6,17 @@ import ( ) func Register(args plugins.RegistrationArguments) error { - args.RegisterTemplateFunction("inList", plugins.GenericTemplateFunctionGetter(str.StringInSlice)) + args.RegisterTemplateFunction("inList", plugins.GenericTemplateFunctionGetter(func(search string, list ...string) bool { + return str.StringInSlice(search, list) + }), plugins.TemplateFuncDocumentation{ + Description: "Tests whether a string is in a given list of strings (for conditional templates).", + Syntax: "inList <...string>", + Example: &plugins.TemplateFuncDocumentationExample{ + MatchMessage: "!command (.*)", + MessageContent: "!command foo", + Template: `{{ inList (group 1) "foo" "bar" }}`, + ExpectedOutput: "true", + }, + }) return nil } diff --git a/internal/template/strings/strings.go b/internal/template/strings/strings.go index b43a870..5a9c2b7 100644 --- a/internal/template/strings/strings.go +++ b/internal/template/strings/strings.go @@ -9,8 +9,24 @@ import ( ) func Register(args plugins.RegistrationArguments) error { - args.RegisterTemplateFunction("b64urlenc", plugins.GenericTemplateFunctionGetter(base64URLEncode)) - args.RegisterTemplateFunction("b64urldec", plugins.GenericTemplateFunctionGetter(base64URLDecode)) + args.RegisterTemplateFunction("b64urlenc", plugins.GenericTemplateFunctionGetter(base64URLEncode), plugins.TemplateFuncDocumentation{ + Description: "Encodes the input using base64 URL-encoding (like `b64enc` but using `URLEncoding` instead of `StdEncoding`)", + Syntax: "b64urlenc ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ b64urlenc "mystring" }}`, + ExpectedOutput: "bXlzdHJpbmc=", + }, + }) + + args.RegisterTemplateFunction("b64urldec", plugins.GenericTemplateFunctionGetter(base64URLDecode), plugins.TemplateFuncDocumentation{ + Description: "Decodes the input using base64 URL-encoding (like `b64dec` but using `URLEncoding` instead of `StdEncoding`)", + Syntax: "b64urldec ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ b64urldec "bXlzdHJpbmc=" }}`, + ExpectedOutput: "mystring", + }, + }) + return nil } diff --git a/internal/template/subscriber/subscriber.go b/internal/template/subscriber/subscriber.go index dad4668..49d082a 100644 --- a/internal/template/subscriber/subscriber.go +++ b/internal/template/subscriber/subscriber.go @@ -19,8 +19,24 @@ func Register(args plugins.RegistrationArguments) error { permCheckFn = args.HasPermissionForChannel tcGetter = args.GetTwitchClientForChannel - args.RegisterTemplateFunction("subCount", plugins.GenericTemplateFunctionGetter(subCount)) - args.RegisterTemplateFunction("subPoints", plugins.GenericTemplateFunctionGetter(subPoints)) + args.RegisterTemplateFunction("subCount", plugins.GenericTemplateFunctionGetter(subCount), plugins.TemplateFuncDocumentation{ + Description: "Returns the number of subscribers (accounts) currently subscribed to the given channel", + Syntax: "subCount ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ subCount "luziferus" }}`, + FakedOutput: "26", + }, + }) + + args.RegisterTemplateFunction("subPoints", plugins.GenericTemplateFunctionGetter(subPoints), plugins.TemplateFuncDocumentation{ + Description: "Returns the number of sub-points currently given through the T1 / T2 / T3 subscriptions to the given channel", + Syntax: "subPoints ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ subPoints "luziferus" }}`, + FakedOutput: "26", + }, + }) + return nil } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..67c639a --- /dev/null +++ b/main_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "os" + "testing" + + "github.com/robfig/cron/v3" + log "github.com/sirupsen/logrus" + + "github.com/Luzifer/twitch-bot/v3/internal/service/access" + "github.com/Luzifer/twitch-bot/v3/internal/service/timer" + "github.com/Luzifer/twitch-bot/v3/pkg/database" +) + +func TestMain(m *testing.M) { + var err error + + if db, err = database.New("sqlite", "file::memory:?cache=shared", "encpass"); err != nil { + log.WithError(err).Fatal("opening storage backend") + } + + if accessService, err = access.New(db); err != nil { + log.WithError(err).Fatal("applying access migration") + } + + cronService = cron.New(cron.WithSeconds()) + + if timerService, err = timer.New(db, cronService); err != nil { + log.WithError(err).Fatal("applying timer migration") + } + + if err = initCorePlugins(); err != nil { + log.WithError(err).Fatal("Unable to load core plugins") + } + + os.Exit(m.Run()) +} diff --git a/plugins/interface.go b/plugins/interface.go index 9c293f3..df49d9a 100644 --- a/plugins/interface.go +++ b/plugins/interface.go @@ -112,7 +112,7 @@ type ( } TemplateFuncGetter func(*irc.Message, *Rule, *FieldCollection) interface{} - TemplateFuncRegister func(name string, fg TemplateFuncGetter) + TemplateFuncRegister func(name string, fg TemplateFuncGetter, doc ...TemplateFuncDocumentation) TemplateValidatorFunc func(raw string) error diff --git a/plugins/tplfuncdoc.go b/plugins/tplfuncdoc.go new file mode 100644 index 0000000..bb721e6 --- /dev/null +++ b/plugins/tplfuncdoc.go @@ -0,0 +1,19 @@ +package plugins + +type ( + TemplateFuncDocumentation struct { + Name string + Description string + Syntax string + Example *TemplateFuncDocumentationExample + Remarks string + } + + TemplateFuncDocumentationExample struct { + MatchMessage string + MessageContent string + Template string + ExpectedOutput string + FakedOutput string + } +) diff --git a/tplDocs.go b/tplDocs.go new file mode 100644 index 0000000..cb4d076 --- /dev/null +++ b/tplDocs.go @@ -0,0 +1,94 @@ +package main + +import ( + "bytes" + _ "embed" + "runtime/debug" + "sort" + "text/template" + "time" + + "github.com/go-irc/irc" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/Luzifer/twitch-bot/v3/plugins" +) + +//go:embed tplDocs.tpl +var tplDocsTemplate string + +func generateTplDocs() ([]byte, error) { + tpl, err := template.New("tplDocs").Funcs(map[string]any{ + "renderExample": generateTplDocsRender, + }).Parse(tplDocsTemplate) + if err != nil { + return nil, errors.Wrap(err, "parsing tplDocs template") + } + + sort.Slice(tplFuncs.docs, func(i, j int) bool { return tplFuncs.docs[i].Name < tplFuncs.docs[j].Name }) + + buf := new(bytes.Buffer) + if err := tpl.Execute(buf, struct { + Funcs []plugins.TemplateFuncDocumentation + }{ + Funcs: tplFuncs.docs, + }); err != nil { + return nil, errors.Wrap(err, "rendering tplDocs template") + } + + return buf.Bytes(), nil +} + +func generateTplDocsRender(e *plugins.TemplateFuncDocumentationExample) (string, error) { + defer func() { + if err := recover(); err != nil { + logrus.WithError(err.(error)).Fatalf("%s", debug.Stack()) + } + }() + + content := e.MessageContent + if content == "" { + content = "Hello World" + } + + msg := &irc.Message{ + Command: "PRIVMSG", + Params: []string{ + "#example", + content, + }, + Prefix: &irc.Prefix{ + Name: "exampleuser", + User: "exampleuser", + Host: "exampleuser.tmi.twitch.tv", + }, + Tags: map[string]irc.TagValue{ + "badge-info": "subscriber/26", + "badges": "moderator/1,subscriber/24", + "color": "#8A2BE2", + "display-name": "ExampleUser", + "emotes": "", + "first-msg": "0", + "flags": "", + "id": "d3167f1f-5a0c-4d78-ba68-1a6c0018d284", + "mod": "1", + "returning-chatter": "0", + "room-id": "123456", + "subscriber": "1", + "tmi-sent-ts": "1679582970403", + "turbo": "0", + "user-id": "987654", + "user-type": "mod", + }, + } + + rule := &plugins.Rule{} + if e.MatchMessage != "" { + rule.MatchMessage = &e.MatchMessage + } + + return formatMessage(e.Template, msg, rule, plugins.FieldCollectionFromData(map[string]any{ + "testDuration": 5*time.Hour + 33*time.Minute + 12*time.Second, + })) +} diff --git a/tplDocs.tpl b/tplDocs.tpl new file mode 100644 index 0000000..d0b592c --- /dev/null +++ b/tplDocs.tpl @@ -0,0 +1,79 @@ +--- +title: "Templating" +--- + +{{"{{< lead >}}"}} +Generally speaking the templating uses [Golang `text/template`](https://pkg.go.dev/text/template) template syntax. All fields with templating enabled do support the full synax from the `text/template` package. +{{"{{< /lead >}}"}} + +## Variables + +There are certain variables available in the strings with templating enabled: + +- `channel` - Channel the message was sent to, only available for regular messages not events +- `msg` - The message object, used in functions, should not be sent to chat +- `permitTimeout` - Value of `permit_timeout` in seconds +- `username` - The username of the message author + + +## Functions + +Within templates following functions can be used: + +- built-in functions in `text/template` engine +- functions from [sprig](https://masterminds.github.io/sprig/) function collection +- functions mentioned below + +Examples below are using this syntax in the code block: + +``` +! Message matcher used for the input message +> Input message if used in the example +# Template used in the fields +< Output from the template (Rendered during docs generation) +* Output from the template (Static output, template not rendered) +``` + +{{ range .Funcs -}} +### `{{ .Name }}` + +{{ .Description }} + +Syntax: `{{ .Syntax }}` + +{{- if .Example }} + +Example: + +``` +{{- if .Example.MatchMessage }} +! {{ .Example.MatchMessage }} +{{- end }} +{{- if .Example.MessageContent }} +> {{ .Example.MessageContent }} +{{- end }} +# {{ .Example.Template }} +{{- if .Example.FakedOutput }} +* {{ .Example.FakedOutput }} +{{- else }} +< {{ renderExample .Example }} +{{- end }} +``` +{{- end }} + +{{ if .Remarks -}} +{{ .Remarks }} + +{{ end -}} +{{- end -}} + +## Upgrade from `v2.x` to `v3.x` + +When adding [sprig](https://masterminds.github.io/sprig/) function collection some functions collided and needed replacement. You need to adapt your templates accordingly: + +- Math functions (`add`, `div`, `mod`, `mul`, `multiply`, `sub`) were replaced with their sprig-equivalent and are now working with integers instead of floats. If you need them to continue to work with floats you need to use their [float-variants](https://masterminds.github.io/sprig/mathf.html). +- `now` does no longer format the current date as a string but return the current date. You need to replace this: `now "2006-01-02"` becomes `now | date "2006-01-02"`. +- `concat` is now used to concat arrays. To join strings you will need to modify your code: `concat ":" "string1" "string2"` becomes `lists "string1" "string2" | join ":"`. +- `toLower` / `toUpper` need to be replaced with their sprig equivalent `lower` and `upper`. + +{{ if false }}{{ end }} diff --git a/tplDocs_test.go b/tplDocs_test.go new file mode 100644 index 0000000..ab340b8 --- /dev/null +++ b/tplDocs_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTemplateFuncDocs(t *testing.T) { + for _, fd := range tplFuncs.docs { + t.Run(fd.Name, func(t *testing.T) { + if fd.Example == nil { + t.Skip("no example present") + } + + if fd.Example.ExpectedOutput == "" { + t.Skip("no expected output present") + } + + out, err := generateTplDocsRender(fd.Example) + assert.NoError(t, err) + assert.Equal(t, fd.Example.ExpectedOutput, out) + }) + } +}