feat(slack): fire TaskSpawners on emoji reactions
What type of PR is this?
/kind api
What this PR does / why we need it:
Adds a reaction trigger to Slack TaskSpawners
(spec.when.slack.triggers[].reaction), so a spawner can fire when someone adds
an emoji reaction to a message. Today a Slack spawner only fires on messages (a
bot mention or a trigger pattern) and on slash commands. A reaction lets a
person act on a message with one click. For example, ✅ on a proposal an agent
posted approves it, and ⚙️ on an announcement records it.
spec:
when:
slack:
channels: ["C0123456789"]
triggers:
- reaction:
name: gear
Matching.
- A reaction trigger fires on its emoji only, never on a posted message. A
pattern trigger never fires on a reaction. reaction.nameis the emoji's canonical Slack name:+1, not the alias
thumbsup, because Slack reports reactions by canonical name. A skin-tone
variant such as+1::skin-tone-2matches its base name.- A reaction trigger cannot set
patternormentionOptional, which a CEL
rule onSlackTriggerenforces. channelsandexcludeFiltersapply as they do to messages.
excludePatternsis matched against the reacted-to message's text. For a bot
message with section or context blocks it is matched against those blocks'
text, which also fills{{.Body}}: Slack can return a bot reply's text field
as one line with its newlines collapsed, which a line-anchored pattern never
matches. A person's message keeps its text, since itsrich_textblocks drop
mentions.botMessagePolicydoes not apply. Reactions added by other bots fire the
trigger; only reactions the bot adds itself are ignored, as are reactions on
anything other than a message.
Which events fire the spawner.
- Empty
triggers: every bot mention fires, as before. - Only reaction triggers: the spawner fires only on reactions. Bot mentions
and slash commands do not fire it. - Reaction triggers plus pattern triggers: the spawner fires on reactions,
on matching messages, and on slash commands again. TheTriggersgodoc and
thetriggers[].patternreference row both say so. - Reaction triggers plus an empty trigger (
{}): the spawner fires on
reactions and on every bot mention.
Handling.
- The
reaction_addedevent carries only the channel and timestamp, so the
Slack server reads the message before matching. It reads from
conversations.history, and for a thread reply fromconversations.replies. - Before fetching, it checks the emoji,
channelsandexcludeFilters, so a
reaction no spawner wants costs no Slack API calls. - The Task reports into the reacted-to message's thread. Its
kelos.dev/slack-user-idannotation names the person who reacted.
Dedup. The Task name hashes the channel, message timestamp and emoji. When
a second person adds the same emoji, it resolves to the existing Task. A
different emoji on the same message gets its own Task.
Template variables.
- New:
{{.ChannelID}},{{.MessageTS}},{{.ThreadTS}},{{.Reaction}}and
{{.ReactionUserID}}. - They are set, as empty strings where they don't apply, for message-triggered
Tasks too. That way a shared template renders undermissingkey=error. {{.Kind}}isSlackReactionfor these Tasks.{{.MessageTS}}vs{{.ID}}.{{.MessageTS}}equals{{.ID}}except
for a slash command, where it is empty and{{.ID}}is the composite command
ID.{{.ID}}identifies the work item.{{.MessageTS}}is always a Slack
message timestamp, meant to be passed to Slack APIs alongside{{.ChannelID}}
and{{.ThreadTS}}. The reference docs explain both.
CLI. kelos get taskspawner <name> lists reaction triggers as :gear:
beside the patterns in Triggers.
API versions.
reactionis added only tov1alpha2.- Down-conversion: reaction triggers are removed from the
v1alpha1
triggers list. There they would read as empty triggers that fire on every bot
mention. They are kept, as[{"name":"gear"}], in a
kelos.dev/v1alpha2-slack-reaction-triggersannotation instead. - Up-conversion: they are appended back to the list. Triggers use OR
semantics, so the position change doesn't affect matching. The restored data
is re-validated against the field's markers, since the API server doesn't
validate conversion output.
Which issue(s) this PR is related to:
Related to #932 (Slack ChatOps sources).
Soemthing like: #1586 (scoring results by reaction) is a sibling that will share the reaction_added event plumbing, even if we don't do 1586 specifically. Easier to extend
Special notes for your reviewer:
Why this shape.
- Reactions live in
triggers. The list alone decides what fires the
spawner, and emptytriggerskeeps its existing meaning. reactionis a block, not a scalar. Likely next options, such as who may
react or a minimum count, go inside the block. They need no new CEL rule and
don't add pattern-only siblings toSlackTrigger.
Trade-offs of this shape.
- Reaction triggers share the existing 8-item
triggerscap. - Older releases and clients. A release without this field reads a reaction
trigger as{}, which fires on every bot mention. So does any client built
against the older API types, such as an olderkelosCLI or a GitOps
controller, when it reads and writes back a TaskSpawner. Every shape that puts
reactions underslackhas this problem.docs/reference.mdsays to remove
reaction triggers before downgrading, or before managing these spawners with
such a client. - Stripped annotation. A
v1alpha1client that strips the preservation
annotation drops the reaction triggers too, as it already does for
excludeFilters.
Validation.
reaction.name: required, a lowercase emoji name without colons or
whitespace, max 100 characters. Uppercase is rejected because Slack sends
emoji names in lowercase, and loosening this later is backward-compatible.- Drift guard: the conversion restore path mirrors these limits and the
triggersmax as constants inapi/v1alpha2/slackfilter_criteria.go, and
marker tests keep the two in sync.
Slack app setup. Reactions arrive only after the Slack app subscribes to
the reaction_added bot event and is granted the reactions:read scope, then
reinstalled. Without that, no events arrive and nothing errors. The new
"Slack reactions" section in docs/reference.md says so.
Tests.
- Matcher:
- listed and unlisted emoji, channels,
excludeFilters,excludePatterns; - reaction-only spawners ignoring mentions and slash commands;
- mixed spawners still firing on matching messages and slash commands;
- a reaction trigger never matching a message;
{}beside a reaction trigger firing on every mention.
- listed and unlisted emoji, channels,
- Handler (fake Slack API):
- top-level message, thread reply, and a thread parent (whose
thread_ts
equals its ownts) keeping an empty{{.ThreadTS}}; - skin-tone variant, bot's own reaction, reaction on a file, message not
found; - no Slack API calls for an unwanted emoji, an excluded channel, or a channel
outside the allowlist.
- top-level message, thread reply, and a thread parent (whose
- Dedup: the same emoji twice gives one Task, and a second emoji gives a
second Task. - Conversion:
- a mixed round-trip and a reaction-only round-trip;
- invalid annotation data being ignored, including a bare name list, a
missing name, and restoring past thetriggerscap.
- Integration: CRD validation accepts reaction triggers. It rejects an empty
name,:gear:,Gear, andreactioncombined withpatternor
mentionOptional.
make verify, make test and make test-integration pass.
Does this PR introduce a user-facing change?
Slack TaskSpawners can now fire on emoji reactions. Add a trigger with `reaction: {name: <emoji>}` to `spec.when.slack.triggers`, and adding that emoji to a message creates a Task for that message. A spawner whose triggers are all reaction triggers fires on reactions only. New template variables: `ChannelID`, `MessageTS`, `ThreadTS`, `Reaction` and `ReactionUserID`. The Slack app needs the `reaction_added` bot event and the `reactions:read` scope.