feat(slack): fire TaskSpawners on emoji reactions

KN
4d ago
+1342-29

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.name is 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-2 matches its base name.
  • A reaction trigger cannot set pattern or mentionOptional, which a CEL
    rule on SlackTrigger enforces.
  • channels and excludeFilters apply as they do to messages.
    excludePatterns is 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 its rich_text blocks drop
    mentions.
  • botMessagePolicy does 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. The Triggers godoc and
    the triggers[].pattern reference row both say so.
  • Reaction triggers plus an empty trigger ({}): the spawner fires on
    reactions and on every bot mention.

Handling.

  • The reaction_added event 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 from conversations.replies.
  • Before fetching, it checks the emoji, channels and excludeFilters, 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-id annotation 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 under missingkey=error.
  • {{.Kind}} is SlackReaction for 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.

  • reaction is added only to v1alpha2.
  • 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-triggers annotation 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 empty triggers keeps its existing meaning.
  • reaction is 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 to SlackTrigger.

Trade-offs of this shape.

  • Reaction triggers share the existing 8-item triggers cap.
  • 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 older kelos CLI or a GitOps
    controller, when it reads and writes back a TaskSpawner. Every shape that puts
    reactions under slack has this problem. docs/reference.md says to remove
    reaction triggers before downgrading, or before managing these spawners with
    such a client.
  • Stripped annotation. A v1alpha1 client 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
    triggers max as constants in api/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.
  • Handler (fake Slack API):
    • top-level message, thread reply, and a thread parent (whose thread_ts
      equals its own ts) 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.
  • 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 the triggers cap.
  • Integration: CRD validation accepts reaction triggers. It rejects an empty
    name, :gear:, Gear, and reaction combined with pattern or
    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.