superkick 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CLA.md +91 -0
- data/CLAUDE.md +2226 -0
- data/CONTRIBUTING.md +104 -0
- data/LICENSE +108 -0
- data/LICENSE-COMMERCIAL.md +39 -0
- data/PLAN.md +161 -0
- data/README.md +1155 -0
- data/exe/superkick +6 -0
- data/lib/superkick/agent/runtime.rb +82 -0
- data/lib/superkick/agent/runtimes/local.rb +74 -0
- data/lib/superkick/agent/runtimes.rb +4 -0
- data/lib/superkick/agent.rb +209 -0
- data/lib/superkick/agent_store.rb +85 -0
- data/lib/superkick/attach/client.rb +245 -0
- data/lib/superkick/attach/protocol.rb +71 -0
- data/lib/superkick/attach/server.rb +371 -0
- data/lib/superkick/budget_checker.rb +120 -0
- data/lib/superkick/buffer/client.rb +91 -0
- data/lib/superkick/buffer/server.rb +127 -0
- data/lib/superkick/cli/agent.rb +524 -0
- data/lib/superkick/cli/completion.rb +591 -0
- data/lib/superkick/cli/goal.rb +71 -0
- data/lib/superkick/cli/mcp.rb +34 -0
- data/lib/superkick/cli/monitor.rb +47 -0
- data/lib/superkick/cli/notifier.rb +39 -0
- data/lib/superkick/cli/repository.rb +46 -0
- data/lib/superkick/cli/server.rb +106 -0
- data/lib/superkick/cli/setup.rb +166 -0
- data/lib/superkick/cli/spawner.rb +85 -0
- data/lib/superkick/cli/team.rb +407 -0
- data/lib/superkick/cli.rb +175 -0
- data/lib/superkick/client_registry.rb +30 -0
- data/lib/superkick/configuration.rb +178 -0
- data/lib/superkick/connection.rb +56 -0
- data/lib/superkick/control/client.rb +78 -0
- data/lib/superkick/control/reply.rb +43 -0
- data/lib/superkick/control/server.rb +1271 -0
- data/lib/superkick/cost_accumulator.rb +53 -0
- data/lib/superkick/cost_extractor.rb +65 -0
- data/lib/superkick/cost_poller.rb +70 -0
- data/lib/superkick/driver/profile_source.rb +134 -0
- data/lib/superkick/driver.rb +179 -0
- data/lib/superkick/drivers/claude_code.rb +110 -0
- data/lib/superkick/drivers/codex.rb +57 -0
- data/lib/superkick/drivers/copilot.rb +75 -0
- data/lib/superkick/drivers/gemini.rb +86 -0
- data/lib/superkick/drivers/goose.rb +74 -0
- data/lib/superkick/drivers.rb +16 -0
- data/lib/superkick/drop.rb +80 -0
- data/lib/superkick/drops.rb +76 -0
- data/lib/superkick/environment_executor.rb +90 -0
- data/lib/superkick/goal.rb +95 -0
- data/lib/superkick/goals/agent_exit.rb +41 -0
- data/lib/superkick/goals/agent_signal.rb +42 -0
- data/lib/superkick/goals/command.rb +103 -0
- data/lib/superkick/history_buffer.rb +38 -0
- data/lib/superkick/hosted/attach/bridge.rb +52 -0
- data/lib/superkick/hosted/attach/client.rb +208 -0
- data/lib/superkick/hosted/attach/relay.rb +313 -0
- data/lib/superkick/hosted/attach/relay_store.rb +48 -0
- data/lib/superkick/hosted/bridge.rb +263 -0
- data/lib/superkick/hosted/buffer/bridge.rb +42 -0
- data/lib/superkick/hosted/buffer/client.rb +63 -0
- data/lib/superkick/hosted/buffer/relay.rb +126 -0
- data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
- data/lib/superkick/hosted/control/client.rb +84 -0
- data/lib/superkick/hosted/mcp_proxy.rb +144 -0
- data/lib/superkick/inject_handler.rb +24 -0
- data/lib/superkick/injection_guard.rb +26 -0
- data/lib/superkick/injection_queue.rb +177 -0
- data/lib/superkick/injector.rb +65 -0
- data/lib/superkick/input_buffer.rb +171 -0
- data/lib/superkick/integrations/bugsnag/README.md +98 -0
- data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
- data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/bugsnag.rb +7 -0
- data/lib/superkick/integrations/circleci/README.md +75 -0
- data/lib/superkick/integrations/circleci/monitor.rb +185 -0
- data/lib/superkick/integrations/circleci/probe.rb +36 -0
- data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
- data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/circleci.rb +8 -0
- data/lib/superkick/integrations/datadog/README.md +253 -0
- data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
- data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
- data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
- data/lib/superkick/integrations/datadog/notifier.rb +294 -0
- data/lib/superkick/integrations/datadog/spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
- data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
- data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
- data/lib/superkick/integrations/datadog.rb +14 -0
- data/lib/superkick/integrations/docker/README.md +256 -0
- data/lib/superkick/integrations/docker/client.rb +295 -0
- data/lib/superkick/integrations/docker/runtime.rb +218 -0
- data/lib/superkick/integrations/docker.rb +4 -0
- data/lib/superkick/integrations/git/repository_source.rb +66 -0
- data/lib/superkick/integrations/git/version_control.rb +119 -0
- data/lib/superkick/integrations/git.rb +8 -0
- data/lib/superkick/integrations/github/README.md +300 -0
- data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
- data/lib/superkick/integrations/github/drops.rb +114 -0
- data/lib/superkick/integrations/github/goal.rb +135 -0
- data/lib/superkick/integrations/github/issue_goal.rb +104 -0
- data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
- data/lib/superkick/integrations/github/monitor.rb +251 -0
- data/lib/superkick/integrations/github/probe.rb +30 -0
- data/lib/superkick/integrations/github/repository_source.rb +228 -0
- data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
- data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
- data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
- data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
- data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
- data/lib/superkick/integrations/github.rb +16 -0
- data/lib/superkick/integrations/honeybadger/README.md +97 -0
- data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
- data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
- data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
- data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/honeybadger.rb +9 -0
- data/lib/superkick/integrations/shell/README.md +83 -0
- data/lib/superkick/integrations/shell/monitor.rb +87 -0
- data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
- data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
- data/lib/superkick/integrations/shell.rb +7 -0
- data/lib/superkick/integrations/shortcut/README.md +193 -0
- data/lib/superkick/integrations/shortcut/drops.rb +91 -0
- data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
- data/lib/superkick/integrations/shortcut/probe.rb +34 -0
- data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
- data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
- data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
- data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
- data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
- data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
- data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
- data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
- data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
- data/lib/superkick/integrations/shortcut.rb +11 -0
- data/lib/superkick/integrations/slack/README.md +297 -0
- data/lib/superkick/integrations/slack/drops.rb +70 -0
- data/lib/superkick/integrations/slack/notifier.rb +426 -0
- data/lib/superkick/integrations/slack/spawner.rb +251 -0
- data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
- data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
- data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
- data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
- data/lib/superkick/integrations/slack.rb +12 -0
- data/lib/superkick/liquid.rb +129 -0
- data/lib/superkick/local/repository_source.rb +148 -0
- data/lib/superkick/mcp_server.rb +596 -0
- data/lib/superkick/monitor.rb +215 -0
- data/lib/superkick/notification_dispatcher.rb +280 -0
- data/lib/superkick/notifier.rb +173 -0
- data/lib/superkick/notifier_state_store.rb +55 -0
- data/lib/superkick/notifier_template.rb +121 -0
- data/lib/superkick/notifiers/command.rb +124 -0
- data/lib/superkick/notifiers/terminal_bell.rb +41 -0
- data/lib/superkick/output_logger.rb +54 -0
- data/lib/superkick/poller.rb +126 -0
- data/lib/superkick/process_runner.rb +87 -0
- data/lib/superkick/pty_proxy.rb +403 -0
- data/lib/superkick/registry.rb +75 -0
- data/lib/superkick/repository_source.rb +195 -0
- data/lib/superkick/server.rb +211 -0
- data/lib/superkick/session_recorder.rb +154 -0
- data/lib/superkick/setup.rb +160 -0
- data/lib/superkick/spawn/agent_spawner.rb +311 -0
- data/lib/superkick/spawn/approval_store.rb +113 -0
- data/lib/superkick/spawn/handler.rb +144 -0
- data/lib/superkick/spawn/injector.rb +119 -0
- data/lib/superkick/spawn/workflow_executor.rb +196 -0
- data/lib/superkick/spawn/workflow_validator.rb +77 -0
- data/lib/superkick/spawner.rb +67 -0
- data/lib/superkick/supervisor.rb +516 -0
- data/lib/superkick/team/artifact_store.rb +92 -0
- data/lib/superkick/team/log.rb +140 -0
- data/lib/superkick/team/log_entry_drop.rb +34 -0
- data/lib/superkick/team/log_monitor.rb +84 -0
- data/lib/superkick/team/log_notifier.rb +96 -0
- data/lib/superkick/team/log_store.rb +40 -0
- data/lib/superkick/template_filters.rb +24 -0
- data/lib/superkick/template_renderer.rb +223 -0
- data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
- data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
- data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
- data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
- data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
- data/lib/superkick/version.rb +5 -0
- data/lib/superkick/version_control.rb +135 -0
- data/lib/superkick/yaml_config.rb +302 -0
- data/lib/superkick.rb +198 -0
- data/plan.md +267 -0
- metadata +404 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Integrations
|
|
5
|
+
module Slack
|
|
6
|
+
# Liquid Drop for a Slack message. Wraps the symbol-keyed hash built
|
|
7
|
+
# by Slack::Spawner so templates can use {{ message.text }},
|
|
8
|
+
# {{ message.sender.tag }}, {{ message.channel.ref }}, etc.
|
|
9
|
+
# Embeds UserDrop and ChannelDrop as nested drops for composition.
|
|
10
|
+
# Survives workflow context serialization via drop_type.
|
|
11
|
+
class MessageDrop < Superkick::Drop
|
|
12
|
+
def self.drop_type = "slack_message"
|
|
13
|
+
|
|
14
|
+
def text = @data[:text]
|
|
15
|
+
|
|
16
|
+
def sender = @data[:sender]
|
|
17
|
+
|
|
18
|
+
def channel = @data[:channel]
|
|
19
|
+
|
|
20
|
+
def message_ts = @data[:message_ts]
|
|
21
|
+
|
|
22
|
+
def thread_ts = @data[:thread_ts]
|
|
23
|
+
|
|
24
|
+
# Formatted reference: "@user_name in #channel"
|
|
25
|
+
def ref
|
|
26
|
+
sender_name = sender&.name || "unknown"
|
|
27
|
+
channel_ref = channel&.ref || "#unknown"
|
|
28
|
+
"@#{sender_name} in #{channel_ref}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Liquid Drop for a Slack user. Used in thread monitor events
|
|
33
|
+
# so templates can use {{ sender.name }}, {{ sender.tag }}.
|
|
34
|
+
class UserDrop < Superkick::Drop
|
|
35
|
+
def self.drop_type = "slack_user"
|
|
36
|
+
|
|
37
|
+
def id = @data[:id]
|
|
38
|
+
|
|
39
|
+
def name = @data[:name]
|
|
40
|
+
|
|
41
|
+
# Formatted tag: "Display Name (<@U123>)"
|
|
42
|
+
def tag
|
|
43
|
+
display = name || id || "unknown"
|
|
44
|
+
user_id = id
|
|
45
|
+
user_id ? "#{display} (<@#{user_id}>)" : display
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Liquid Drop for a Slack channel. Used in spawner and thread monitor
|
|
50
|
+
# events so templates can use {{ channel.name }}, {{ channel.ref }}.
|
|
51
|
+
class ChannelDrop < Superkick::Drop
|
|
52
|
+
def self.drop_type = "slack_channel"
|
|
53
|
+
|
|
54
|
+
def id = @data[:id]
|
|
55
|
+
|
|
56
|
+
def name = @data[:name]
|
|
57
|
+
|
|
58
|
+
# Formatted reference: "#channel-name" or the raw ID if no name.
|
|
59
|
+
def ref
|
|
60
|
+
channel_name = name || id
|
|
61
|
+
"##{channel_name}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Superkick::Drop.register(Superkick::Integrations::Slack::MessageDrop)
|
|
69
|
+
Superkick::Drop.register(Superkick::Integrations::Slack::UserDrop)
|
|
70
|
+
Superkick::Drop.register(Superkick::Integrations::Slack::ChannelDrop)
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Integrations
|
|
5
|
+
module Slack
|
|
6
|
+
# Posts a message to Slack when an injection or lifecycle event occurs.
|
|
7
|
+
#
|
|
8
|
+
# Supports two authentication modes:
|
|
9
|
+
#
|
|
10
|
+
# 1. Incoming Webhook — simplest setup, posts to a single channel:
|
|
11
|
+
# notifications:
|
|
12
|
+
# - type: slack
|
|
13
|
+
# webhook_url: <%= env("SLACK_WEBHOOK_URL") %>
|
|
14
|
+
#
|
|
15
|
+
# 2. Web API (chat.postMessage) — can target any channel:
|
|
16
|
+
# notifications:
|
|
17
|
+
# - type: slack
|
|
18
|
+
# token: <%= env("SLACK_BOT_TOKEN") %>
|
|
19
|
+
# channel: "#superkick-notifications"
|
|
20
|
+
#
|
|
21
|
+
# Messages use Block Kit for rich formatting with a plain-text
|
|
22
|
+
# fallback in the top-level `text` field.
|
|
23
|
+
#
|
|
24
|
+
# Template support:
|
|
25
|
+
# Block Kit structure is defined via Liquid templates with custom
|
|
26
|
+
# block tags and bodyless tags covering all Block Kit block types.
|
|
27
|
+
#
|
|
28
|
+
# Block tags: {% header %}, {% section %}, {% context %}, {% image %},
|
|
29
|
+
# {% button %}, {% fields %}, {% rich_text %}, {% video %}, {% input %}
|
|
30
|
+
#
|
|
31
|
+
# Bodyless tags: {% divider %}, {% file %}
|
|
32
|
+
#
|
|
33
|
+
# Consecutive {% button %} tags auto-group into one actions block.
|
|
34
|
+
#
|
|
35
|
+
# Templates also have access to the `event_emoji`, `format_title`,
|
|
36
|
+
# and `join_present` filters for event type formatting.
|
|
37
|
+
#
|
|
38
|
+
# Templates are resolved from:
|
|
39
|
+
# 1. ~/.superkick/templates/notifications/slack/<event_type>.liquid
|
|
40
|
+
# 2. <bundled>/templates/<event_type>.liquid
|
|
41
|
+
# 3. ~/.superkick/templates/notifications/slack/default.liquid
|
|
42
|
+
# 4. <bundled>/templates/default.liquid
|
|
43
|
+
#
|
|
44
|
+
# Thread correlation (Web API only):
|
|
45
|
+
# When using the Web API, the notifier is stateful and tracks
|
|
46
|
+
# `thread_ts` per agent. The first message for an agent creates
|
|
47
|
+
# a new thread; subsequent events reply to that thread. This keeps
|
|
48
|
+
# the channel clean when many agents are active. Webhook mode
|
|
49
|
+
# cannot thread (Slack webhooks don't return a `ts`), so messages
|
|
50
|
+
# are standalone.
|
|
51
|
+
class Notifier < Superkick::Notifier
|
|
52
|
+
def self.type = :slack
|
|
53
|
+
|
|
54
|
+
def self.templates_dir = File.expand_path("templates", __dir__)
|
|
55
|
+
|
|
56
|
+
def self.setup_label = "Slack"
|
|
57
|
+
|
|
58
|
+
def self.setup_config
|
|
59
|
+
<<~YAML
|
|
60
|
+
# Slack — choose one authentication mode:
|
|
61
|
+
|
|
62
|
+
# Option 1: Incoming Webhook (single channel, no token needed)
|
|
63
|
+
- type: slack
|
|
64
|
+
webhook_url: <%= env("SLACK_WEBHOOK_URL") %>
|
|
65
|
+
|
|
66
|
+
# Option 2: Web API (any channel, supports threading)
|
|
67
|
+
# - type: slack
|
|
68
|
+
# token: <%= env("SLACK_BOT_TOKEN") %>
|
|
69
|
+
# channel: "#superkick-notifications"
|
|
70
|
+
# # events: # restrict which events fire
|
|
71
|
+
# # - agent_completed
|
|
72
|
+
# # - agent_failed
|
|
73
|
+
# # - agent_blocked
|
|
74
|
+
YAML
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
WEBHOOK_TIMEOUT = 10
|
|
78
|
+
API_URL = "https://slack.com"
|
|
79
|
+
|
|
80
|
+
EMOJI_MAP = {
|
|
81
|
+
"agent_spawned" => ":rocket:",
|
|
82
|
+
"agent_completed" => ":white_check_mark:",
|
|
83
|
+
"agent_failed" => ":x:",
|
|
84
|
+
"agent_timed_out" => ":hourglass:",
|
|
85
|
+
"agent_blocked" => ":warning:",
|
|
86
|
+
"agent_stalled" => ":zzz:",
|
|
87
|
+
"agent_terminated" => ":stop_sign:",
|
|
88
|
+
"agent_claimed" => ":raised_hand:",
|
|
89
|
+
"agent_unclaimed" => ":wave:",
|
|
90
|
+
"agent_pending_approval" => ":eyes:",
|
|
91
|
+
"workflow_triggered" => ":gear:",
|
|
92
|
+
"workflow_iterations_exceeded" => ":no_entry:",
|
|
93
|
+
"budget_warning" => ":money_with_wings:",
|
|
94
|
+
"budget_exceeded" => ":moneybag:",
|
|
95
|
+
"team_created" => ":busts_in_silhouette:",
|
|
96
|
+
"team_completed" => ":trophy:",
|
|
97
|
+
"team_failed" => ":boom:",
|
|
98
|
+
"team_timed_out" => ":hourglass_flowing_sand:",
|
|
99
|
+
"worker_spawned" => ":construction_worker:",
|
|
100
|
+
"teammate_message" => ":speech_balloon:",
|
|
101
|
+
"teammate_blocker" => ":octagonal_sign:",
|
|
102
|
+
"attach_promoted" => ":arrow_up:",
|
|
103
|
+
"attach_demoted" => ":arrow_down:",
|
|
104
|
+
"attach_idle_timeout" => ":zzz:",
|
|
105
|
+
"attach_force_takeover" => ":rotating_light:",
|
|
106
|
+
"agent_update" => ":memo:",
|
|
107
|
+
"artifact_published" => ":package:"
|
|
108
|
+
}.freeze
|
|
109
|
+
|
|
110
|
+
# Keyword-to-emoji mapping for fuzzy matching on unknown event types.
|
|
111
|
+
# Checked in order — first keyword found in the event type string wins.
|
|
112
|
+
KEYWORD_EMOJI = [
|
|
113
|
+
["completed", ":white_check_mark:"],
|
|
114
|
+
["succeeded", ":white_check_mark:"],
|
|
115
|
+
["passed", ":white_check_mark:"],
|
|
116
|
+
["failed", ":x:"],
|
|
117
|
+
["errored", ":x:"],
|
|
118
|
+
["error", ":x:"],
|
|
119
|
+
["timed_out", ":hourglass:"],
|
|
120
|
+
["timeout", ":hourglass:"],
|
|
121
|
+
["warning", ":warning:"],
|
|
122
|
+
["blocked", ":warning:"],
|
|
123
|
+
["stalled", ":zzz:"],
|
|
124
|
+
["spawned", ":rocket:"],
|
|
125
|
+
["created", ":busts_in_silhouette:"],
|
|
126
|
+
["started", ":rocket:"],
|
|
127
|
+
["terminated", ":stop_sign:"],
|
|
128
|
+
["stopped", ":stop_sign:"],
|
|
129
|
+
["exceeded", ":no_entry:"],
|
|
130
|
+
["triggered", ":gear:"],
|
|
131
|
+
["message", ":speech_balloon:"],
|
|
132
|
+
["approved", ":white_check_mark:"],
|
|
133
|
+
["rejected", ":no_entry:"],
|
|
134
|
+
["deployed", ":rocket:"],
|
|
135
|
+
["merged", ":white_check_mark:"]
|
|
136
|
+
].freeze
|
|
137
|
+
|
|
138
|
+
def self.event_emoji(event_type)
|
|
139
|
+
type_str = event_type.to_s
|
|
140
|
+
return EMOJI_MAP[type_str] if EMOJI_MAP.key?(type_str)
|
|
141
|
+
|
|
142
|
+
KEYWORD_EMOJI.each do |keyword, emoji|
|
|
143
|
+
return emoji if type_str.include?(keyword)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
":bell:"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
liquid do
|
|
150
|
+
context do
|
|
151
|
+
attribute :blocks, default: -> { [] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
filter :event_emoji do |event_type|
|
|
155
|
+
Superkick::Integrations::Slack::Notifier.event_emoji(event_type)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
filter :format_title do |event_type|
|
|
159
|
+
event_type.to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
filter :join_present do |input, delimiter = " | "|
|
|
163
|
+
input.to_s.split("\n").map(&:strip).reject(&:empty?).join(delimiter)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
block :header do |ctx, text, _attrs|
|
|
167
|
+
ctx.blocks << {type: "header", text: {type: "plain_text", text:, emoji: true}}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
block :section do |ctx, text, _attrs|
|
|
171
|
+
ctx.blocks << {type: "section", text: {type: "mrkdwn", text:}}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
block :context do |ctx, text, _attrs|
|
|
175
|
+
ctx.blocks << {type: "context", elements: [{type: "mrkdwn", text:}]}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
tag :divider do |ctx, _attrs|
|
|
179
|
+
ctx.blocks << {type: "divider"}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
block :image do |ctx, text, attrs|
|
|
183
|
+
block = {
|
|
184
|
+
type: "image",
|
|
185
|
+
image_url: attrs["url"],
|
|
186
|
+
alt_text: attrs["alt_text"] || ""
|
|
187
|
+
}
|
|
188
|
+
block[:title] = {type: "plain_text", text:, emoji: true} unless text.empty?
|
|
189
|
+
ctx.blocks << block
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
block :button do |ctx, text, attrs|
|
|
193
|
+
element = {
|
|
194
|
+
type: "button",
|
|
195
|
+
text: {type: "plain_text", text:, emoji: true}
|
|
196
|
+
}
|
|
197
|
+
element[:url] = attrs["url"] if attrs["url"]
|
|
198
|
+
element[:action_id] = attrs["action_id"] if attrs["action_id"]
|
|
199
|
+
element[:value] = attrs["value"] if attrs["value"]
|
|
200
|
+
element[:style] = attrs["style"] if attrs["style"]
|
|
201
|
+
|
|
202
|
+
# Auto-group: consecutive buttons share one actions block
|
|
203
|
+
if ctx.blocks.last&.dig(:type) == "actions"
|
|
204
|
+
ctx.blocks.last[:elements] << element
|
|
205
|
+
else
|
|
206
|
+
ctx.blocks << {type: "actions", elements: [element]}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
block :fields do |ctx, text, _attrs|
|
|
211
|
+
field_texts = text.split("\n").map(&:strip).reject(&:empty?)
|
|
212
|
+
fields = field_texts.map { {type: "mrkdwn", text: it} }
|
|
213
|
+
ctx.blocks << {type: "section", fields:}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
block :rich_text do |ctx, text, _attrs|
|
|
217
|
+
ctx.blocks << {
|
|
218
|
+
type: "rich_text",
|
|
219
|
+
elements: [{
|
|
220
|
+
type: "rich_text_section",
|
|
221
|
+
elements: [{type: "text", text:}]
|
|
222
|
+
}]
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
block :video do |ctx, text, attrs|
|
|
227
|
+
block = {
|
|
228
|
+
type: "video",
|
|
229
|
+
title: {type: "plain_text", text:, emoji: true},
|
|
230
|
+
video_url: attrs["url"],
|
|
231
|
+
thumbnail_url: attrs["thumbnail_url"] || "",
|
|
232
|
+
alt_text: attrs["alt_text"] || ""
|
|
233
|
+
}
|
|
234
|
+
block[:description] = {type: "plain_text", text: attrs["description"]} if attrs["description"]
|
|
235
|
+
ctx.blocks << block
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
tag :file do |ctx, attrs|
|
|
239
|
+
ctx.blocks << {
|
|
240
|
+
type: "file",
|
|
241
|
+
external_id: attrs["external_id"],
|
|
242
|
+
source: attrs["source"] || "remote"
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
block :input do |ctx, text, attrs|
|
|
247
|
+
element = {type: attrs["type"] || "plain_text_input"}
|
|
248
|
+
element[:action_id] = attrs["action_id"] if attrs["action_id"]
|
|
249
|
+
if attrs["placeholder"]
|
|
250
|
+
element[:placeholder] = {type: "plain_text", text: attrs["placeholder"]}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
block = {
|
|
254
|
+
type: "input",
|
|
255
|
+
label: {type: "plain_text", text:, emoji: true},
|
|
256
|
+
element:
|
|
257
|
+
}
|
|
258
|
+
block[:dispatch_action] = true if attrs["dispatch_action"] == "true"
|
|
259
|
+
ctx.blocks << block
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def initialize(webhook_url: nil, token: nil, channel: nil, thread_ts: nil, connection: nil, **opts)
|
|
264
|
+
super(**opts)
|
|
265
|
+
@webhook_url = webhook_url
|
|
266
|
+
@token = token
|
|
267
|
+
@channel = channel
|
|
268
|
+
@initial_thread_ts = thread_ts
|
|
269
|
+
@connection = connection
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Stateful when using the Web API — tracks thread_ts per agent.
|
|
273
|
+
# Webhook mode cannot thread (no ts in response), so it's effectively stateless.
|
|
274
|
+
def stateful? = !!(@token && @channel)
|
|
275
|
+
|
|
276
|
+
def agent_finished(agent_id:)
|
|
277
|
+
@state_store.delete(:slack, agent_id.to_s)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def notify(payload)
|
|
281
|
+
body = build_body(payload)
|
|
282
|
+
|
|
283
|
+
if @webhook_url
|
|
284
|
+
post_webhook(body)
|
|
285
|
+
elsif @token && @channel
|
|
286
|
+
thread_key = payload.dig(:context, :team)&.id || payload[:agent_id]
|
|
287
|
+
seed_thread_ts = payload.dig(:context, :slack_thread_ts)
|
|
288
|
+
post_api(body, agent_id: payload[:agent_id], thread_key:, seed_thread_ts:)
|
|
289
|
+
else
|
|
290
|
+
Superkick.logger.warn("notifier:slack") { "No webhook_url or token+channel configured" }
|
|
291
|
+
end
|
|
292
|
+
rescue => e
|
|
293
|
+
Superkick.logger.warn("notifier:slack") { "Failed: #{e.message}" }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
private
|
|
297
|
+
|
|
298
|
+
def post_webhook(body)
|
|
299
|
+
uri = URI.parse(@webhook_url)
|
|
300
|
+
conn = @connection || build_connection(url: "#{uri.scheme}://#{uri.host}")
|
|
301
|
+
response = conn.post(uri.request_uri) do |req|
|
|
302
|
+
req.body = body
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
unless (200..299).cover?(response.status)
|
|
306
|
+
Superkick.logger.warn("notifier:slack") { "Webhook returned HTTP #{response.status}" }
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def post_api(body, agent_id: nil, thread_key: nil, seed_thread_ts: nil)
|
|
311
|
+
body[:channel] = @channel
|
|
312
|
+
|
|
313
|
+
key = thread_key || agent_id
|
|
314
|
+
thread_ts = key && @state_store.get(:slack, key.to_s)&.dig(:thread_ts)
|
|
315
|
+
|
|
316
|
+
# Seed from constructor (per-agent notifier) or spawn context
|
|
317
|
+
seed = seed_thread_ts || @initial_thread_ts
|
|
318
|
+
if !thread_ts && seed && key
|
|
319
|
+
thread_ts = seed
|
|
320
|
+
@state_store.put(:slack, key.to_s, {thread_ts:})
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
body[:thread_ts] = thread_ts if thread_ts
|
|
324
|
+
|
|
325
|
+
conn = @connection || build_connection(url: API_URL, token: @token)
|
|
326
|
+
response = conn.post("/api/chat.postMessage") do |req|
|
|
327
|
+
req.body = body
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
if response.status == 200
|
|
331
|
+
data = response.body
|
|
332
|
+
if data[:ok]
|
|
333
|
+
# Capture thread_ts from the first message for this agent/team
|
|
334
|
+
if !thread_ts && key && data[:ts]
|
|
335
|
+
existing = @state_store.get(:slack, key.to_s)
|
|
336
|
+
@state_store.put(:slack, key.to_s, {thread_ts: data[:ts]}) unless existing
|
|
337
|
+
end
|
|
338
|
+
else
|
|
339
|
+
Superkick.logger.warn("notifier:slack") { "Slack API error: #{data[:error]}" }
|
|
340
|
+
end
|
|
341
|
+
else
|
|
342
|
+
Superkick.logger.warn("notifier:slack") { "Slack API returned HTTP #{response.status}" }
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def build_connection(url:, token: nil)
|
|
347
|
+
Faraday.new(url:) do |f|
|
|
348
|
+
f.request :json
|
|
349
|
+
f.request :authorization, "Bearer", token if token
|
|
350
|
+
f.response :json, parser_options: {symbolize_names: true}
|
|
351
|
+
f.options.timeout = WEBHOOK_TIMEOUT
|
|
352
|
+
f.options.open_timeout = WEBHOOK_TIMEOUT
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def build_body(payload)
|
|
357
|
+
template_result = render_notification(payload)
|
|
358
|
+
|
|
359
|
+
if template_result && !template_result[:structured].blocks.empty?
|
|
360
|
+
{
|
|
361
|
+
text: template_result[:text],
|
|
362
|
+
blocks: template_result[:structured].blocks
|
|
363
|
+
}
|
|
364
|
+
else
|
|
365
|
+
build_body_fallback(payload)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def build_body_fallback(payload)
|
|
370
|
+
event_type = payload[:event_type]
|
|
371
|
+
emoji = self.class.event_emoji(event_type)
|
|
372
|
+
title = format_title(event_type)
|
|
373
|
+
message = payload[:message]
|
|
374
|
+
|
|
375
|
+
{
|
|
376
|
+
text: message,
|
|
377
|
+
blocks: build_blocks_fallback(emoji:, title:, payload:)
|
|
378
|
+
}
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def build_blocks_fallback(emoji:, title:, payload:)
|
|
382
|
+
blocks = [
|
|
383
|
+
{
|
|
384
|
+
type: "header",
|
|
385
|
+
text: {type: "plain_text", text: "#{emoji} #{title}", emoji: true}
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
type: "section",
|
|
389
|
+
text: {type: "mrkdwn", text: payload[:message]}
|
|
390
|
+
}
|
|
391
|
+
]
|
|
392
|
+
|
|
393
|
+
context_parts = []
|
|
394
|
+
context_parts << "*Agent:* `#{payload[:agent_id]}`" unless payload[:agent_id].to_s.empty?
|
|
395
|
+
|
|
396
|
+
monitor = payload[:monitor]
|
|
397
|
+
if monitor
|
|
398
|
+
context_parts << "*Monitor:* #{monitor.name}" unless monitor.name.to_s.empty?
|
|
399
|
+
context_parts << "*Type:* #{monitor.type}" unless monitor.type.to_s.empty?
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
ctx = payload[:context] || {}
|
|
403
|
+
team = ctx[:team]
|
|
404
|
+
context_parts << "*Team:* `#{team.id}`" if team&.id
|
|
405
|
+
agent = ctx[:agent]
|
|
406
|
+
context_parts << "*Role:* #{agent.role}" if agent&.role
|
|
407
|
+
spawner = ctx[:spawner]
|
|
408
|
+
context_parts << "*Spawner:* #{spawner.name}" if spawner
|
|
409
|
+
|
|
410
|
+
unless context_parts.empty?
|
|
411
|
+
blocks << {
|
|
412
|
+
type: "context",
|
|
413
|
+
elements: [{type: "mrkdwn", text: context_parts.join(" | ")}]
|
|
414
|
+
}
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
blocks
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def format_title(event_type)
|
|
421
|
+
event_type.to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|