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,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Integrations
|
|
7
|
+
module Slack
|
|
8
|
+
# Watches a Slack channel for new messages and spawns agents.
|
|
9
|
+
#
|
|
10
|
+
# Polls `conversations.history` for top-level messages. Each new
|
|
11
|
+
# message becomes a spawn event. When `monitor_thread: true` (the
|
|
12
|
+
# default), the spawned agent automatically gets a `slack_thread`
|
|
13
|
+
# monitor that feeds user replies back as injections.
|
|
14
|
+
#
|
|
15
|
+
# Config keys:
|
|
16
|
+
# channel (required) — Slack channel ID (e.g. "C0123456789")
|
|
17
|
+
# token (optional) — Bot token, falls back to SLACK_BOT_TOKEN
|
|
18
|
+
# channel_name (optional) — Human-readable name for templates
|
|
19
|
+
# filter_pattern (optional) — Regex; only matching messages trigger spawns
|
|
20
|
+
# ignore_bots (optional, default true) — skip bot messages
|
|
21
|
+
# ignore_threads (optional, default true) — skip threaded replies
|
|
22
|
+
# monitor_thread (optional, default true) — auto-attach slack_thread monitor
|
|
23
|
+
class Spawner < Superkick::Spawner
|
|
24
|
+
API_URL = "https://slack.com"
|
|
25
|
+
|
|
26
|
+
attr_reader :conn, :seen_ts, :bot_user_id, :user_cache
|
|
27
|
+
|
|
28
|
+
def self.type = :slack
|
|
29
|
+
|
|
30
|
+
def self.description
|
|
31
|
+
"Watches a Slack channel for new messages and spawns AI coding " \
|
|
32
|
+
"agents to handle them. Supports thread monitoring so users " \
|
|
33
|
+
"can interact with the agent via Slack replies."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.required_config = %i[channel]
|
|
37
|
+
|
|
38
|
+
def self.spawn_templates_dir
|
|
39
|
+
File.join(__dir__, "templates", "spawn")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.agent_id(event)
|
|
43
|
+
"slack-message-#{event[:channel_id]}-#{event[:message_ts].tr(".", "-")}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.setup_label = "Slack"
|
|
47
|
+
|
|
48
|
+
def self.setup_config
|
|
49
|
+
<<~YAML
|
|
50
|
+
slack:
|
|
51
|
+
type: slack
|
|
52
|
+
channel: C0123456789 # Slack channel ID
|
|
53
|
+
token: <%= env("SLACK_BOT_TOKEN") %>
|
|
54
|
+
# channel_name: general # for display only
|
|
55
|
+
# ignore_bots: true # ignore bot messages (default)
|
|
56
|
+
# ignore_threads: true # only top-level messages (default)
|
|
57
|
+
# monitor_thread: true # auto-attach thread monitor (default)
|
|
58
|
+
# filter_pattern: "^!ask " # regex to filter messages
|
|
59
|
+
# max_duration: 3600
|
|
60
|
+
YAML
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(name:, config:, handler:, connection: nil)
|
|
64
|
+
super(name:, config:, handler:)
|
|
65
|
+
@conn = connection
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def on_start
|
|
69
|
+
@conn ||= build_connection
|
|
70
|
+
@bot_user_id = fetch_bot_user_id
|
|
71
|
+
@oldest = Time.now.to_f.to_s
|
|
72
|
+
@seen_ts = Set.new
|
|
73
|
+
@user_cache = {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def tick
|
|
77
|
+
messages = fetch_messages
|
|
78
|
+
messages.each do |msg|
|
|
79
|
+
event = build_event(msg)
|
|
80
|
+
attach_spawn_monitors(event, msg)
|
|
81
|
+
dispatch(event)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def fetch_messages
|
|
88
|
+
resp = @conn.get("/api/conversations.history") do |req|
|
|
89
|
+
req.params[:channel] = self[:channel]
|
|
90
|
+
req.params[:oldest] = @oldest
|
|
91
|
+
req.params[:limit] = 100
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
handle_response!(resp)
|
|
95
|
+
data = resp.body
|
|
96
|
+
return [] unless data[:ok]
|
|
97
|
+
|
|
98
|
+
messages = data[:messages] || []
|
|
99
|
+
|
|
100
|
+
# Filter bot messages
|
|
101
|
+
if self[:ignore_bots] != false
|
|
102
|
+
messages = messages.reject { it[:subtype] == "bot_message" || it[:bot_id] }
|
|
103
|
+
messages = messages.reject { it[:user] == @bot_user_id } if @bot_user_id
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Filter threaded replies (only top-level messages)
|
|
107
|
+
if self[:ignore_threads] != false
|
|
108
|
+
messages = messages.reject { it[:thread_ts] && it[:thread_ts] != it[:ts] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Apply filter_pattern
|
|
112
|
+
if self[:filter_pattern]
|
|
113
|
+
pattern = Regexp.new(self[:filter_pattern])
|
|
114
|
+
messages = messages.select { pattern.match?(it[:text].to_s) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Within-session dedup
|
|
118
|
+
new_messages = messages.reject { @seen_ts.include?(it[:ts]) }
|
|
119
|
+
new_messages.each { @seen_ts.add(it[:ts]) }
|
|
120
|
+
|
|
121
|
+
# Advance watermark
|
|
122
|
+
if messages.any?
|
|
123
|
+
latest = messages.map { it[:ts] }.max
|
|
124
|
+
@oldest = latest if latest && latest > @oldest
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
Superkick.logger.info(log_tag) { "Found #{messages.size} messages, #{new_messages.size} new" }
|
|
128
|
+
new_messages
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_event(msg)
|
|
132
|
+
user_name = resolve_user_name(msg[:user])
|
|
133
|
+
|
|
134
|
+
sender = UserDrop.new(
|
|
135
|
+
id: msg[:user],
|
|
136
|
+
name: user_name
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
channel = ChannelDrop.new(
|
|
140
|
+
id: self[:channel],
|
|
141
|
+
name: self[:channel_name]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
message = MessageDrop.new(
|
|
145
|
+
text: msg[:text].to_s,
|
|
146
|
+
sender:,
|
|
147
|
+
channel:,
|
|
148
|
+
message_ts: msg[:ts],
|
|
149
|
+
thread_ts: msg[:ts]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
{
|
|
153
|
+
event_type: :slack_message,
|
|
154
|
+
channel_id: self[:channel],
|
|
155
|
+
channel_name: self[:channel_name] || self[:channel],
|
|
156
|
+
message_ts: msg[:ts],
|
|
157
|
+
slack_thread_ts: msg[:ts],
|
|
158
|
+
user: msg[:user],
|
|
159
|
+
user_name:,
|
|
160
|
+
text: msg[:text].to_s,
|
|
161
|
+
message:,
|
|
162
|
+
sender:,
|
|
163
|
+
channel:
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def attach_spawn_monitors(event, _msg)
|
|
168
|
+
return unless self[:monitor_thread] != false
|
|
169
|
+
|
|
170
|
+
token = self[:token] || ENV["SLACK_BOT_TOKEN"]
|
|
171
|
+
event[:_spawn_monitors] = {
|
|
172
|
+
slack_thread: {
|
|
173
|
+
type: :slack_thread,
|
|
174
|
+
channel_id: self[:channel],
|
|
175
|
+
thread_ts: event[:message_ts],
|
|
176
|
+
token:
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Auto-attach a per-agent Slack notifier so lifecycle updates
|
|
181
|
+
# and agent events flow back to the originating Slack thread.
|
|
182
|
+
event[:_spawn_notifiers] = {
|
|
183
|
+
slack_thread: {
|
|
184
|
+
type: :slack,
|
|
185
|
+
token:,
|
|
186
|
+
channel: self[:channel],
|
|
187
|
+
thread_ts: event[:message_ts]
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def resolve_user_name(user_id)
|
|
193
|
+
return nil unless user_id
|
|
194
|
+
return @user_cache[user_id] if @user_cache.key?(user_id)
|
|
195
|
+
|
|
196
|
+
resp = @conn.get("/api/users.info") do |req|
|
|
197
|
+
req.params[:user] = user_id
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if resp.status == 200 && resp.body[:ok]
|
|
201
|
+
name = resp.body.dig(:user, :profile, :display_name)
|
|
202
|
+
name = resp.body.dig(:user, :real_name) if name.to_s.empty?
|
|
203
|
+
name = resp.body.dig(:user, :name) if name.to_s.empty?
|
|
204
|
+
@user_cache[user_id] = name || user_id
|
|
205
|
+
else
|
|
206
|
+
@user_cache[user_id] = user_id
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
@user_cache[user_id]
|
|
210
|
+
rescue => e
|
|
211
|
+
Superkick.logger.debug(log_tag) { "Could not resolve user #{user_id}: #{e.message}" }
|
|
212
|
+
@user_cache[user_id] = user_id
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def fetch_bot_user_id
|
|
216
|
+
resp = @conn.get("/api/auth.test")
|
|
217
|
+
return nil unless resp.status == 200 && resp.body[:ok]
|
|
218
|
+
|
|
219
|
+
resp.body[:user_id]
|
|
220
|
+
rescue => e
|
|
221
|
+
Superkick.logger.warn(log_tag) { "Could not fetch bot identity: #{e.message}" }
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def handle_response!(resp)
|
|
226
|
+
case resp.status
|
|
227
|
+
when 200..299
|
|
228
|
+
# Slack API returns 200 for most errors; check :ok field in caller
|
|
229
|
+
nil
|
|
230
|
+
when 401, 403
|
|
231
|
+
raise FatalError, "Slack auth failed (HTTP #{resp.status})"
|
|
232
|
+
when 429
|
|
233
|
+
raise RateLimited, "Slack rate limited"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def build_connection
|
|
238
|
+
token = self[:token] || ENV["SLACK_BOT_TOKEN"]
|
|
239
|
+
|
|
240
|
+
Faraday.new(url: API_URL) do |f|
|
|
241
|
+
f.request :authorization, "Bearer", token if token
|
|
242
|
+
f.request :json
|
|
243
|
+
f.response :json, parser_options: {symbolize_names: true}
|
|
244
|
+
f.options.timeout = 10
|
|
245
|
+
f.options.open_timeout = 10
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{% header %}{{ event_type | event_emoji }} {{ event_type | format_title }}{% endheader %}
|
|
2
|
+
{% section %}{{ message }}{% endsection %}
|
|
3
|
+
{% capture meta %}
|
|
4
|
+
{% if agent_id %}*Agent:* `{{ agent_id }}`
|
|
5
|
+
{% endif %}
|
|
6
|
+
{% if monitor %}*Monitor:* {{ monitor.name }}
|
|
7
|
+
*Type:* {{ monitor.type }}
|
|
8
|
+
{% endif %}
|
|
9
|
+
{% if team %}*Team:* `{{ team.id }}`
|
|
10
|
+
{% endif %}
|
|
11
|
+
{% if agent.role %}*Role:* {{ agent.role }}
|
|
12
|
+
{% endif %}
|
|
13
|
+
{% if spawner %}*Spawner:* {{ spawner.name }}
|
|
14
|
+
{% endif %}
|
|
15
|
+
{% endcapture %}
|
|
16
|
+
{% if meta != blank %}{% context %}{{ meta | join_present: " | " }}{% endcontext %}{% endif %}
|
|
17
|
+
{{ message }}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
SUPERKICK [{{ "now" | time }}]: Slack message from {{ user_name }} in {{ channel_name }}
|
|
2
|
+
|
|
3
|
+
## Message
|
|
4
|
+
|
|
5
|
+
{{ text }}
|
|
6
|
+
|
|
7
|
+
Please read the message carefully and work on the request. If the user
|
|
8
|
+
provided specific instructions, follow them. If you need clarification,
|
|
9
|
+
use superkick_post_update to communicate your progress or questions.
|
|
10
|
+
When you are done, use superkick_signal_goal to signal completion.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Integrations
|
|
7
|
+
module Slack
|
|
8
|
+
# Polls a Slack thread for new replies and injects them into the agent.
|
|
9
|
+
#
|
|
10
|
+
# Typically auto-attached by the Slack spawner via `_spawn_monitors`
|
|
11
|
+
# when `monitor_thread: true`. Each new reply from a human user
|
|
12
|
+
# becomes a high-priority injection event.
|
|
13
|
+
#
|
|
14
|
+
# Config keys (injected at spawn time):
|
|
15
|
+
# channel_id (required) — Slack channel ID
|
|
16
|
+
# thread_ts (required) — Root message timestamp
|
|
17
|
+
# token (optional) — Bot token, falls back to SLACK_BOT_TOKEN
|
|
18
|
+
class ThreadMonitor < Superkick::Monitor
|
|
19
|
+
API_URL = "https://slack.com"
|
|
20
|
+
|
|
21
|
+
attr_reader :conn, :seen_ts, :bot_user_id
|
|
22
|
+
|
|
23
|
+
def self.type = :slack_thread
|
|
24
|
+
|
|
25
|
+
def self.description
|
|
26
|
+
"Polls a Slack thread for new replies and injects them as " \
|
|
27
|
+
"high-priority prompts into the agent."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.required_config = %i[channel_id thread_ts]
|
|
31
|
+
|
|
32
|
+
def self.templates_dir
|
|
33
|
+
File.join(__dir__, "templates")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(name:, config:, handler:, agent: nil, server_context: {}, connection: nil)
|
|
37
|
+
super(name:, config:, handler:, agent:, server_context:)
|
|
38
|
+
@conn = connection
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def on_start
|
|
42
|
+
@conn ||= build_connection
|
|
43
|
+
@bot_user_id = fetch_bot_user_id
|
|
44
|
+
@oldest = self[:thread_ts]
|
|
45
|
+
@seen_ts = Set.new([self[:thread_ts]])
|
|
46
|
+
@user_cache = {}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def tick
|
|
50
|
+
replies = fetch_replies
|
|
51
|
+
replies.each do |reply|
|
|
52
|
+
user_name = resolve_user_name(reply[:user])
|
|
53
|
+
sender = Slack::UserDrop.new(id: reply[:user], name: user_name)
|
|
54
|
+
channel = Slack::ChannelDrop.new(id: self[:channel_id])
|
|
55
|
+
|
|
56
|
+
dispatch(
|
|
57
|
+
event_type: :slack_reply,
|
|
58
|
+
user: reply[:user],
|
|
59
|
+
user_name:,
|
|
60
|
+
text: reply[:text].to_s,
|
|
61
|
+
message_ts: reply[:ts],
|
|
62
|
+
sender:,
|
|
63
|
+
channel:,
|
|
64
|
+
injection_priority: :high,
|
|
65
|
+
injection_ttl: 900
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def fetch_replies
|
|
73
|
+
resp = @conn.get("/api/conversations.replies") do |req|
|
|
74
|
+
req.params[:channel] = self[:channel_id]
|
|
75
|
+
req.params[:ts] = self[:thread_ts]
|
|
76
|
+
req.params[:oldest] = @oldest
|
|
77
|
+
req.params[:limit] = 100
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
handle_response!(resp)
|
|
81
|
+
data = resp.body
|
|
82
|
+
return [] unless data[:ok]
|
|
83
|
+
|
|
84
|
+
messages = data[:messages] || []
|
|
85
|
+
|
|
86
|
+
# Skip bot's own messages
|
|
87
|
+
if @bot_user_id
|
|
88
|
+
messages = messages.reject { it[:user] == @bot_user_id }
|
|
89
|
+
end
|
|
90
|
+
messages = messages.reject { it[:subtype] == "bot_message" || it[:bot_id] }
|
|
91
|
+
|
|
92
|
+
# Within-session dedup
|
|
93
|
+
new_replies = messages.reject { @seen_ts.include?(it[:ts]) }
|
|
94
|
+
new_replies.each { @seen_ts.add(it[:ts]) }
|
|
95
|
+
|
|
96
|
+
# Advance watermark
|
|
97
|
+
if messages.any?
|
|
98
|
+
latest = messages.map { it[:ts] }.max
|
|
99
|
+
@oldest = latest if latest && latest > @oldest
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
Superkick.logger.info(log_tag) { "Found #{new_replies.size} new replies" }
|
|
103
|
+
new_replies
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def resolve_user_name(user_id)
|
|
107
|
+
return nil unless user_id
|
|
108
|
+
return @user_cache[user_id] if @user_cache.key?(user_id)
|
|
109
|
+
|
|
110
|
+
resp = @conn.get("/api/users.info") do |req|
|
|
111
|
+
req.params[:user] = user_id
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if resp.status == 200 && resp.body[:ok]
|
|
115
|
+
name = resp.body.dig(:user, :profile, :display_name)
|
|
116
|
+
name = resp.body.dig(:user, :real_name) if name.to_s.empty?
|
|
117
|
+
name = resp.body.dig(:user, :name) if name.to_s.empty?
|
|
118
|
+
@user_cache[user_id] = name || user_id
|
|
119
|
+
else
|
|
120
|
+
@user_cache[user_id] = user_id
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
@user_cache[user_id]
|
|
124
|
+
rescue => e
|
|
125
|
+
Superkick.logger.debug(log_tag) { "Could not resolve user #{user_id}: #{e.message}" }
|
|
126
|
+
@user_cache[user_id] = user_id
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def fetch_bot_user_id
|
|
130
|
+
resp = @conn.get("/api/auth.test")
|
|
131
|
+
return nil unless resp.status == 200 && resp.body[:ok]
|
|
132
|
+
|
|
133
|
+
resp.body[:user_id]
|
|
134
|
+
rescue => e
|
|
135
|
+
Superkick.logger.warn(log_tag) { "Could not fetch bot identity: #{e.message}" }
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def handle_response!(resp)
|
|
140
|
+
case resp.status
|
|
141
|
+
when 200..299 then nil
|
|
142
|
+
when 401, 403 then raise FatalError, "Slack auth failed (HTTP #{resp.status})"
|
|
143
|
+
when 429 then raise RateLimited, "Slack rate limited"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def build_connection
|
|
148
|
+
token = self[:token] || ENV["SLACK_BOT_TOKEN"]
|
|
149
|
+
|
|
150
|
+
Faraday.new(url: API_URL) do |f|
|
|
151
|
+
f.request :authorization, "Bearer", token if token
|
|
152
|
+
f.request :json
|
|
153
|
+
f.response :json, parser_options: {symbolize_names: true}
|
|
154
|
+
f.options.timeout = 10
|
|
155
|
+
f.options.open_timeout = 10
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "slack/drops"
|
|
4
|
+
require_relative "slack/notifier"
|
|
5
|
+
require_relative "slack/spawner"
|
|
6
|
+
require_relative "slack/thread_monitor"
|
|
7
|
+
|
|
8
|
+
module Superkick
|
|
9
|
+
Notifier.register(Integrations::Slack::Notifier)
|
|
10
|
+
Spawner.register(Integrations::Slack::Spawner)
|
|
11
|
+
Monitor.register(Integrations::Slack::ThreadMonitor)
|
|
12
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Liquid — includable DSL for declaring Liquid customizations on monitors,
|
|
5
|
+
# spawners, and notifiers. Provides `liquid do ... end` class-level DSL.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
#
|
|
9
|
+
# class MyMonitor < Monitor
|
|
10
|
+
# liquid do
|
|
11
|
+
# filter :pr_ref do |number, title = nil|
|
|
12
|
+
# title ? "##{number} \"#{title}\"" : "##{number}"
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# class MyNotifier < Notifier
|
|
18
|
+
# liquid do
|
|
19
|
+
# context do
|
|
20
|
+
# attribute :blocks, default: -> { [] }
|
|
21
|
+
# attribute :text
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# block :header do |ctx, content|
|
|
25
|
+
# ctx.blocks << { type: "header", text: content }
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
module Liquid
|
|
30
|
+
def self.included(base)
|
|
31
|
+
base.extend(ClassMethods)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module ClassMethods
|
|
35
|
+
def liquid(&definition)
|
|
36
|
+
@liquid_config = Config.new
|
|
37
|
+
@liquid_config.instance_eval(&definition)
|
|
38
|
+
@liquid_config.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def liquid_config
|
|
42
|
+
@liquid_config || Config::EMPTY
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Config — captures DSL declarations (filters, context class, blocks).
|
|
47
|
+
# Immutable after freeze.
|
|
48
|
+
class Config
|
|
49
|
+
attr_reader :filters, :context_class, :blocks, :tags
|
|
50
|
+
|
|
51
|
+
def initialize
|
|
52
|
+
@filters = {}
|
|
53
|
+
@context_class = nil
|
|
54
|
+
@blocks = {}
|
|
55
|
+
@tags = {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Declare an inline filter. Generates a Liquid filter module behind the scenes.
|
|
59
|
+
def filter(name, &handler)
|
|
60
|
+
@filters[name] = handler
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Declare the context class for block tag accumulation.
|
|
64
|
+
# Without a block: sets a pre-existing class.
|
|
65
|
+
# With a block: builds an anonymous context class with attribute DSL.
|
|
66
|
+
def context(klass = nil, &definition)
|
|
67
|
+
if klass
|
|
68
|
+
@context_class = klass
|
|
69
|
+
elsif definition
|
|
70
|
+
@context_class = Context.build(&definition)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Declare a Liquid block tag.
|
|
75
|
+
def block(name, &handler)
|
|
76
|
+
@blocks[name] = handler
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Declare a bodyless Liquid tag (no end tag required).
|
|
80
|
+
# Handler receives (accumulator, attributes).
|
|
81
|
+
def tag(name, &handler)
|
|
82
|
+
@tags[name] = handler
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def freeze
|
|
86
|
+
@filters.freeze
|
|
87
|
+
@blocks.freeze
|
|
88
|
+
@tags.freeze
|
|
89
|
+
super
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
EMPTY = new.freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Context — builder for anonymous context classes used as block tag
|
|
96
|
+
# accumulators. Builds a class with declared attributes and defaults.
|
|
97
|
+
class Context
|
|
98
|
+
def self.build(&definition)
|
|
99
|
+
builder = new
|
|
100
|
+
builder.instance_eval(&definition)
|
|
101
|
+
builder.build_class
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def initialize
|
|
105
|
+
@attributes = {}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def attribute(name, default: nil)
|
|
109
|
+
@attributes[name] = default
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_class
|
|
113
|
+
attrs = @attributes.dup
|
|
114
|
+
Class.new do
|
|
115
|
+
attrs.each_key do |name|
|
|
116
|
+
attr_accessor name
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
define_method(:initialize) do
|
|
120
|
+
attrs.each do |name, default|
|
|
121
|
+
value = default.respond_to?(:call) ? default.call : default
|
|
122
|
+
instance_variable_set(:"@#{name}", value)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|