Skip to content

feat(slack): fire TaskSpawners on emoji reactions - #1807

Open
knechtionscoding wants to merge 1 commit into
kelos-dev:mainfrom
datagravity-ai:hans/grav-1033
Open

knechtionscoding wants to merge 1 commit into
kelos-dev:mainfrom
datagravity-ai:hans/grav-1033

Conversation

@knechtionscoding

@knechtionscoding knechtionscoding commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 17 files

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread internal/conversion/taskspawner.go Resolved
Comment thread api/v1alpha2/taskspawner_types.go Resolved
Comment thread internal/slack/thread.go Resolved
@knechtionscoding

Copy link
Copy Markdown
Contributor Author

bump @gjkim

1 similar comment
@knechtionscoding

Copy link
Copy Markdown
Contributor Author

bump @gjkim

Add spec.when.slack.triggers[].reaction, a block whose name is a
canonical Slack emoji name. A reaction trigger fires when someone adds
that emoji to a message: the Slack server fetches the message (channel
history, or the thread for a reply) and creates a Task for it, honoring
channels, excludeFilters and excludePatterns. A reaction trigger cannot
set pattern or mentionOptional, and a pattern trigger never fires on a
reaction. Nesting the emoji under reaction leaves room for per-reaction
options without growing the CEL rule.

Empty triggers still mean every bot mention fires. A spawner whose
triggers are all reaction triggers fires on reactions only, so bot
mentions and slash commands do not fire it; adding an empty trigger
restores firing on every bot mention.

Reactions by the bot itself and reactions on non-message items are
ignored; reactions by other bots fire. Before fetching the message, the
handler checks the emoji, channels and excludeFilters, so reactions no
spawner wants cost no Slack API calls. Task names hash the channel,
message ts and emoji, so the same emoji added by a second person
resolves to the existing Task. The task gets new template variables
ChannelID, MessageTS, ThreadTS, Reaction and ReactionUserID (present and
empty for message-triggered Tasks), Kind "SlackReaction", and the
reactor as kelos.dev/slack-user-id. kelos get taskspawner shows
reaction triggers as :emoji: in the triggers list.

The reaction field exists only in v1alpha2. On conversion to v1alpha1,
reaction triggers are removed from the triggers list, where they would
read as empty triggers that fire on every bot mention, and preserved in
a kelos.dev/v1alpha2-slack-reaction-triggers annotation that is
re-validated against the field's markers on restore.

A reaction on a bot message with section or context blocks routes on
those blocks' text, and fills Body with it. Slack can return a bot
reply's text field as one line with its newlines collapsed, which a
line-anchored excludePattern never matches. A person's message routes
on its text, since its rich_text blocks drop mentions.

The Slack app needs the reaction_added event and the reactions:read
scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="api/v1alpha2/taskspawner_types.go">

<violation number="1" location="api/v1alpha2/taskspawner_types.go:766">
P3: This comment overstates block-text handling: normal bot message events still match `ExcludePatterns` against `event.Text`; only reaction fetches call `reactedText` to extract section/context blocks. Qualify this clause as applying to reactions on bot messages, or update the normal message path to match the documented behavior.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

// matching so patterns target semantic content. Does NOT apply to
// slash commands.
// matching so patterns target semantic content. For a reaction trigger,
// the text is the reacted-to message's; for a bot message with section or

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This comment overstates block-text handling: normal bot message events still match ExcludePatterns against event.Text; only reaction fetches call reactedText to extract section/context blocks. Qualify this clause as applying to reactions on bot messages, or update the normal message path to match the documented behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At api/v1alpha2/taskspawner_types.go, line 766:

<comment>This comment overstates block-text handling: normal bot message events still match `ExcludePatterns` against `event.Text`; only reaction fetches call `reactedText` to extract section/context blocks. Qualify this clause as applying to reactions on bot messages, or update the normal message path to match the documented behavior.</comment>

<file context>
@@ -763,7 +763,8 @@ type Slack struct {
 	// syntax (RE2, unanchored). Leading @-mentions are stripped before
 	// matching so patterns target semantic content. For a reaction trigger,
-	// the text is the reacted-to message's. Does NOT apply to slash commands.
+	// the text is the reacted-to message's; for a bot message with section or
+	// context blocks, it is those blocks' text. Does NOT apply to slash commands.
 	// +optional
</file context>
Suggested change
// the text is the reacted-to message's; for a bot message with section or
// the text is the reacted-to message's; for a reaction on a bot message with section or

@knechtionscoding

Copy link
Copy Markdown
Contributor Author

@gjkim42 bump here

This branch is waiting to be deployed

1 waiting deployment
ok-to-test — bee6a543 Waiting Oct 22, 2026 by knechtionscoding via fork-e2e / e2e-with-environment #1945
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/api Categorizes issue or PR as related to API changes needs-actor needs-priority needs-triage release-note

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant