earl-bot 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/.ruby-version +1 -0
- data/CHANGELOG.md +40 -0
- data/CLAUDE.md +260 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +177 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/Rakefile +11 -0
- data/bin/README.md +21 -0
- data/bin/ci +49 -0
- data/bin/claude-context +155 -0
- data/bin/claude-usage +110 -0
- data/bin/coverage +221 -0
- data/bin/rubocop +10 -0
- data/bin/watch-ci +198 -0
- data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
- data/config/earl-claude-home/.claude/settings.json +34 -0
- data/earl-bot.gemspec +42 -0
- data/exe/earl +51 -0
- data/exe/earl-install +129 -0
- data/exe/earl-permission-server +39 -0
- data/lib/earl/claude_session/stats.rb +76 -0
- data/lib/earl/claude_session.rb +468 -0
- data/lib/earl/command_executor/constants.rb +53 -0
- data/lib/earl/command_executor/heartbeat_display.rb +54 -0
- data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
- data/lib/earl/command_executor/session_handler.rb +126 -0
- data/lib/earl/command_executor/spawn_handler.rb +99 -0
- data/lib/earl/command_executor/stats_formatter.rb +66 -0
- data/lib/earl/command_executor/usage_handler.rb +132 -0
- data/lib/earl/command_executor.rb +128 -0
- data/lib/earl/command_parser.rb +57 -0
- data/lib/earl/config.rb +94 -0
- data/lib/earl/cron_parser.rb +105 -0
- data/lib/earl/formatting.rb +14 -0
- data/lib/earl/heartbeat_config.rb +101 -0
- data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
- data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
- data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
- data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
- data/lib/earl/heartbeat_scheduler.rb +131 -0
- data/lib/earl/logging.rb +12 -0
- data/lib/earl/mattermost/api_client.rb +85 -0
- data/lib/earl/mattermost.rb +261 -0
- data/lib/earl/mcp/approval_handler.rb +304 -0
- data/lib/earl/mcp/config.rb +62 -0
- data/lib/earl/mcp/github_pat_handler.rb +450 -0
- data/lib/earl/mcp/handler_base.rb +13 -0
- data/lib/earl/mcp/heartbeat_handler.rb +310 -0
- data/lib/earl/mcp/memory_handler.rb +89 -0
- data/lib/earl/mcp/server.rb +123 -0
- data/lib/earl/mcp/tmux_handler.rb +562 -0
- data/lib/earl/memory/prompt_builder.rb +40 -0
- data/lib/earl/memory/store.rb +125 -0
- data/lib/earl/message_queue.rb +56 -0
- data/lib/earl/permission_config.rb +22 -0
- data/lib/earl/question_handler/question_posting.rb +58 -0
- data/lib/earl/question_handler.rb +116 -0
- data/lib/earl/runner/idle_management.rb +44 -0
- data/lib/earl/runner/lifecycle.rb +73 -0
- data/lib/earl/runner/message_handling.rb +121 -0
- data/lib/earl/runner/reaction_handling.rb +42 -0
- data/lib/earl/runner/response_lifecycle.rb +96 -0
- data/lib/earl/runner/service_builder.rb +48 -0
- data/lib/earl/runner/startup.rb +73 -0
- data/lib/earl/runner/thread_context_builder.rb +43 -0
- data/lib/earl/runner.rb +70 -0
- data/lib/earl/safari_automation.rb +497 -0
- data/lib/earl/session_manager/persistence.rb +46 -0
- data/lib/earl/session_manager/session_creation.rb +108 -0
- data/lib/earl/session_manager.rb +92 -0
- data/lib/earl/session_store.rb +84 -0
- data/lib/earl/streaming_response.rb +219 -0
- data/lib/earl/tmux/parsing.rb +80 -0
- data/lib/earl/tmux/processes.rb +34 -0
- data/lib/earl/tmux/sessions.rb +41 -0
- data/lib/earl/tmux.rb +122 -0
- data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
- data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
- data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
- data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
- data/lib/earl/tmux_monitor.rb +249 -0
- data/lib/earl/tmux_session_store.rb +133 -0
- data/lib/earl/tool_input_formatter.rb +44 -0
- data/lib/earl/version.rb +5 -0
- data/lib/earl.rb +87 -0
- data/lib/tasks/.keep +1 -0
- metadata +248 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "heartbeat_scheduler/heartbeat_state"
|
|
4
|
+
require_relative "heartbeat_scheduler/execution"
|
|
5
|
+
require_relative "heartbeat_scheduler/lifecycle"
|
|
6
|
+
require_relative "heartbeat_scheduler/config_reloading"
|
|
7
|
+
|
|
8
|
+
module Earl
|
|
9
|
+
# Runs heartbeat tasks on cron/interval/one-shot schedules. Spawns Claude sessions
|
|
10
|
+
# and posts results to Mattermost channels without waiting for user messages.
|
|
11
|
+
# Auto-reloads config when the YAML file changes. One-off tasks (once: true)
|
|
12
|
+
# are disabled in YAML after execution.
|
|
13
|
+
class HeartbeatScheduler
|
|
14
|
+
include Logging
|
|
15
|
+
include Execution
|
|
16
|
+
include Lifecycle
|
|
17
|
+
include ConfigReloading
|
|
18
|
+
|
|
19
|
+
CHECK_INTERVAL = 30 # seconds between scheduler checks
|
|
20
|
+
|
|
21
|
+
# Groups injected service dependencies to keep ivar count low.
|
|
22
|
+
Deps = Struct.new(:config, :mattermost, :heartbeat_config, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
# Groups scheduler control state.
|
|
25
|
+
Control = Struct.new(:scheduler_thread, :stop_requested, :config_mtime, :heartbeat_config_path, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
def initialize(config:, mattermost:)
|
|
28
|
+
heartbeat_config = HeartbeatConfig.new
|
|
29
|
+
@deps = Deps.new(config: config, mattermost: mattermost, heartbeat_config: heartbeat_config)
|
|
30
|
+
@control = Control.new(
|
|
31
|
+
scheduler_thread: nil, stop_requested: false,
|
|
32
|
+
config_mtime: nil, heartbeat_config_path: heartbeat_config.path
|
|
33
|
+
)
|
|
34
|
+
@states = {}
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def start
|
|
39
|
+
@control.stop_requested = false
|
|
40
|
+
definitions = @deps.heartbeat_config.definitions
|
|
41
|
+
initialize_states(definitions) unless definitions.empty?
|
|
42
|
+
@control.config_mtime = config_file_mtime
|
|
43
|
+
|
|
44
|
+
count = definitions.size
|
|
45
|
+
log(:info, "Heartbeat scheduler starting with #{count} heartbeat(s)")
|
|
46
|
+
|
|
47
|
+
@control.scheduler_thread = Thread.new { scheduler_loop }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def stop
|
|
51
|
+
@control.stop_requested = true
|
|
52
|
+
join_and_kill_thread(@control.scheduler_thread)
|
|
53
|
+
@control.scheduler_thread = nil
|
|
54
|
+
stop_heartbeat_threads
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def status
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
@states.values.map(&:to_status)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def join_and_kill_thread(thread)
|
|
66
|
+
return unless thread
|
|
67
|
+
|
|
68
|
+
thread.join(5)
|
|
69
|
+
thread.kill if thread.alive?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stop_heartbeat_threads
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
@states.each_value do |state|
|
|
75
|
+
thread = state.run_thread
|
|
76
|
+
thread&.join(3)
|
|
77
|
+
thread&.kill if thread&.alive?
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def initialize_states(definitions)
|
|
83
|
+
now = Time.now
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
definitions.each do |definition|
|
|
86
|
+
@states[definition.name] = build_initial_state(definition, now)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_initial_state(definition, now)
|
|
92
|
+
HeartbeatState.new(
|
|
93
|
+
definition: definition,
|
|
94
|
+
next_run_at: compute_next_run(definition, now),
|
|
95
|
+
running: false,
|
|
96
|
+
run_count: 0
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def scheduler_loop
|
|
101
|
+
loop do
|
|
102
|
+
break if @control.stop_requested
|
|
103
|
+
|
|
104
|
+
check_for_reload
|
|
105
|
+
check_and_dispatch
|
|
106
|
+
sleep CHECK_INTERVAL
|
|
107
|
+
rescue StandardError => error
|
|
108
|
+
log(:error, "Heartbeat scheduler error: #{error.message}")
|
|
109
|
+
log(:error, error.backtrace&.first(5)&.join("\n"))
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def check_and_dispatch
|
|
114
|
+
now = Time.now
|
|
115
|
+
@mutex.synchronize do
|
|
116
|
+
@states.each_value do |state|
|
|
117
|
+
dispatch_heartbeat(state, now) if should_run?(state, now)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def should_run?(state, now)
|
|
123
|
+
next_run = state.next_run_at
|
|
124
|
+
!state.running && next_run && now >= next_run
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def dispatch_heartbeat(state, now)
|
|
128
|
+
state.dispatch(now) { execute_heartbeat(state) }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/earl/logging.rb
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class Mattermost
|
|
5
|
+
# Handles HTTP requests to the Mattermost REST API, encapsulating
|
|
6
|
+
# authentication, connection setup, and JSON serialization.
|
|
7
|
+
class ApiClient
|
|
8
|
+
include Logging
|
|
9
|
+
|
|
10
|
+
# Encapsulates an HTTP request's method, path, and body.
|
|
11
|
+
Request = Struct.new(:method_class, :path, :body, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def get(path)
|
|
18
|
+
execute(Request.new(method_class: Net::HTTP::Get, path: path, body: nil))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def post(path, body)
|
|
22
|
+
execute(Request.new(method_class: Net::HTTP::Post, path: path, body: body))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def put(path, body)
|
|
26
|
+
execute(Request.new(method_class: Net::HTTP::Put, path: path, body: body))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def delete(path)
|
|
30
|
+
execute(Request.new(method_class: Net::HTTP::Delete, path: path, body: nil))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def execute(request)
|
|
36
|
+
uri = URI.parse(@config.api_url(request.path))
|
|
37
|
+
http_req = build_request(request.method_class, uri, request.body)
|
|
38
|
+
response = send_request(uri, http_req)
|
|
39
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
40
|
+
log(:error,
|
|
41
|
+
"Mattermost API #{http_req.method} #{uri.path} failed: " \
|
|
42
|
+
"#{response.code} #{response.body[0..200]}")
|
|
43
|
+
end
|
|
44
|
+
response
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_request(method_class, uri, body)
|
|
48
|
+
token = @config.bot_token
|
|
49
|
+
req = method_class.new(uri)
|
|
50
|
+
req["Authorization"] = "Bearer #{token}"
|
|
51
|
+
apply_json_body(req, body) if body
|
|
52
|
+
req
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def apply_json_body(req, body)
|
|
56
|
+
req["Content-Type"] = "application/json"
|
|
57
|
+
req.body = JSON.generate(body)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
MAX_RETRIES = 2
|
|
61
|
+
RETRY_DELAY = 1
|
|
62
|
+
private_constant :MAX_RETRIES, :RETRY_DELAY
|
|
63
|
+
|
|
64
|
+
def send_request(uri, req)
|
|
65
|
+
attempts = 0
|
|
66
|
+
begin
|
|
67
|
+
attempts += 1
|
|
68
|
+
http_start(uri.host, uri.port) { |http| http.request(req) }
|
|
69
|
+
rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED, IOError => error
|
|
70
|
+
raise if attempts > MAX_RETRIES
|
|
71
|
+
|
|
72
|
+
log(:warn, "Mattermost API retry #{attempts}/#{MAX_RETRIES} after #{error.class}: #{error.message}")
|
|
73
|
+
sleep RETRY_DELAY
|
|
74
|
+
retry
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def http_start(host, port, &)
|
|
79
|
+
Net::HTTP.start(host, port,
|
|
80
|
+
use_ssl: @config.mattermost_url.start_with?("https"),
|
|
81
|
+
open_timeout: 10, read_timeout: 15, &)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "websocket-client-simple"
|
|
4
|
+
require_relative "mattermost/api_client"
|
|
5
|
+
|
|
6
|
+
module Earl
|
|
7
|
+
# Connects to the Mattermost WebSocket API for real-time messaging and
|
|
8
|
+
# provides REST helpers for creating, updating posts and typing indicators.
|
|
9
|
+
class Mattermost
|
|
10
|
+
include Logging
|
|
11
|
+
|
|
12
|
+
attr_reader :config
|
|
13
|
+
|
|
14
|
+
# Groups WebSocket connection state.
|
|
15
|
+
Connection = Struct.new(:ws, :channel_ids, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
# Groups event callbacks.
|
|
18
|
+
Callbacks = Struct.new(:on_message, :on_reaction, :on_close, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
def initialize(config)
|
|
21
|
+
@config = config
|
|
22
|
+
@api = ApiClient.new(config)
|
|
23
|
+
@connection = Connection.new(ws: nil, channel_ids: Set.new([config.channel_id]))
|
|
24
|
+
@callbacks = Callbacks.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def configure_channels(channel_ids)
|
|
28
|
+
@connection.channel_ids = channel_ids
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def on_message(&block)
|
|
32
|
+
@callbacks.on_message = block
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def on_reaction(&block)
|
|
36
|
+
@callbacks.on_reaction = block
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_close(&block)
|
|
40
|
+
@callbacks.on_close = block
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def connect
|
|
44
|
+
@connection.ws = WebSocket::Client::Simple.connect(config.websocket_url)
|
|
45
|
+
setup_websocket_handlers
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_post(channel_id:, message:, root_id: nil)
|
|
49
|
+
body = { channel_id: channel_id, message: message }
|
|
50
|
+
body[:root_id] = root_id if root_id
|
|
51
|
+
parse_post_response(@api.post("/posts", body))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def update_post(post_id:, message:)
|
|
55
|
+
@api.put("/posts/#{post_id}", { id: post_id, message: message })
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def send_typing(channel_id:, parent_id: nil)
|
|
59
|
+
body = { channel_id: channel_id }
|
|
60
|
+
body[:parent_id] = parent_id if parent_id
|
|
61
|
+
@api.post("/users/me/typing", body)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add_reaction(post_id:, emoji_name:)
|
|
65
|
+
@api.post("/reactions", { user_id: config.bot_id, post_id: post_id, emoji_name: emoji_name })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def delete_post(post_id:)
|
|
69
|
+
@api.delete("/posts/#{post_id}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def get_user(user_id:)
|
|
73
|
+
parse_post_response(@api.get("/users/#{user_id}"))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def get_channel(channel_id:)
|
|
77
|
+
parse_post_response(@api.get("/channels/#{channel_id}"))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Fetches all posts in a thread, ordered oldest-first.
|
|
81
|
+
# Returns an array of hashes with :sender, :message, :is_bot.
|
|
82
|
+
def get_thread_posts(thread_id)
|
|
83
|
+
response = @api.get("/posts/#{thread_id}/thread")
|
|
84
|
+
return [] unless response.is_a?(Net::HTTPSuccess)
|
|
85
|
+
|
|
86
|
+
data = JSON.parse(response.body)
|
|
87
|
+
posts, order = data.values_at("posts", "order")
|
|
88
|
+
build_thread_posts(posts || {}, order || [])
|
|
89
|
+
rescue JSON::ParserError => error
|
|
90
|
+
log(:warn, "Failed to parse thread posts: #{error.message}")
|
|
91
|
+
[]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def build_thread_posts(posts, order)
|
|
97
|
+
bot_id = config.bot_id
|
|
98
|
+
order.reverse.filter_map do |id|
|
|
99
|
+
format_thread_post(posts[id], bot_id) if posts.key?(id)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def format_thread_post(post, bot_id)
|
|
104
|
+
from_bot = post.dig("props", "from_bot") == "true"
|
|
105
|
+
message = post["message"] || ""
|
|
106
|
+
user_id = post["user_id"]
|
|
107
|
+
{ sender: from_bot ? "EARL" : "user", message: message, is_bot: user_id == bot_id }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# WebSocket lifecycle methods extracted to reduce class method count.
|
|
111
|
+
module WebSocketHandling
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def setup_websocket_handlers
|
|
115
|
+
websocket_handler_map.each { |event, handler| @connection.ws.on(event, &handler) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def websocket_handler_map
|
|
119
|
+
msg_handler = method(:handle_websocket_message)
|
|
120
|
+
close_handler = method(:handle_websocket_close)
|
|
121
|
+
auth = method(:auth_payload)
|
|
122
|
+
{
|
|
123
|
+
open: -> { send(JSON.generate(auth.call)) },
|
|
124
|
+
message: ->(msg) { msg_handler.call(msg) },
|
|
125
|
+
error: ->(error) { Earl.logger.error "WebSocket error: #{error.message}" },
|
|
126
|
+
close: ->(event) { close_handler.call(event) }
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def auth_payload
|
|
131
|
+
Earl.logger.info "WebSocket connected, sending auth challenge"
|
|
132
|
+
{ seq: 1, action: "authentication_challenge", data: { token: config.bot_token } }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def handle_websocket_message(msg)
|
|
136
|
+
handle_ping if msg.type == :ping
|
|
137
|
+
parse_and_dispatch(msg.data)
|
|
138
|
+
rescue StandardError => error
|
|
139
|
+
log(:error, "WebSocket message error: #{error.class}: #{error.message}")
|
|
140
|
+
log(:error, error.backtrace&.first(5)&.join("\n"))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parse_and_dispatch(data)
|
|
144
|
+
return unless data && !data.empty?
|
|
145
|
+
|
|
146
|
+
event = parse_ws_json(data)
|
|
147
|
+
return unless event
|
|
148
|
+
|
|
149
|
+
log(:debug, "WS event: #{event["event"] || event.keys.first}")
|
|
150
|
+
dispatch_event(event)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def parse_ws_json(data)
|
|
154
|
+
JSON.parse(data)
|
|
155
|
+
rescue JSON::ParserError => error
|
|
156
|
+
log(:warn, "Failed to parse WebSocket message: #{error.message}")
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def handle_ping
|
|
161
|
+
log(:debug, "WS ping received, sending pong")
|
|
162
|
+
@connection.ws.send(nil, type: :pong)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def dispatch_event(event)
|
|
166
|
+
case event["event"]
|
|
167
|
+
when "hello"
|
|
168
|
+
log(:info, "Authenticated to Mattermost")
|
|
169
|
+
when "posted"
|
|
170
|
+
handle_posted_event(event)
|
|
171
|
+
when "reaction_added"
|
|
172
|
+
handle_reaction_event(event)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def handle_websocket_close(event)
|
|
177
|
+
log(:warn, "WebSocket closed: #{event&.code} #{event&.reason}")
|
|
178
|
+
log(:warn, "EARL will exit — restart process to reconnect")
|
|
179
|
+
@callbacks.on_close&.call
|
|
180
|
+
exit 1
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Event dispatching: routes posted and reaction events to callbacks.
|
|
185
|
+
module EventDispatching
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def handle_posted_event(event)
|
|
189
|
+
post = parse_post_data(event)
|
|
190
|
+
deliver_message(event, post) if post
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def handle_reaction_event(event)
|
|
194
|
+
reaction_data = event.dig("data", "reaction")
|
|
195
|
+
return unless reaction_data
|
|
196
|
+
|
|
197
|
+
reaction = JSON.parse(reaction_data)
|
|
198
|
+
dispatch_reaction(reaction)
|
|
199
|
+
rescue JSON::ParserError => error
|
|
200
|
+
log(:warn, "Failed to parse reaction data: #{error.message}")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def dispatch_reaction(reaction)
|
|
204
|
+
user_id, post_id, emoji_name = reaction.values_at("user_id", "post_id", "emoji_name")
|
|
205
|
+
return if user_id == config.bot_id
|
|
206
|
+
|
|
207
|
+
@callbacks.on_reaction&.call(user_id: user_id, post_id: post_id, emoji_name: emoji_name)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def parse_post_data(event)
|
|
211
|
+
post_data = event.dig("data", "post")
|
|
212
|
+
return unless post_data
|
|
213
|
+
|
|
214
|
+
post = JSON.parse(post_data)
|
|
215
|
+
return if post["user_id"] == config.bot_id || !@connection.channel_ids.include?(post["channel_id"])
|
|
216
|
+
|
|
217
|
+
post
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def deliver_message(event, post)
|
|
221
|
+
params = build_message_params(event, post)
|
|
222
|
+
log(:info,
|
|
223
|
+
"Message from @#{params[:sender_name]} in thread #{params[:thread_id][0..7]}: #{params[:text][0..80]}")
|
|
224
|
+
@callbacks.on_message&.call(**params)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def build_message_params(event, post)
|
|
228
|
+
post_id = post["id"]
|
|
229
|
+
root_id = post["root_id"]
|
|
230
|
+
sender = event.dig("data", "sender_name")&.delete_prefix("@") || "unknown"
|
|
231
|
+
{
|
|
232
|
+
sender_name: sender,
|
|
233
|
+
thread_id: root_id.to_s.empty? ? post_id : root_id,
|
|
234
|
+
text: post["message"] || "",
|
|
235
|
+
post_id: post_id,
|
|
236
|
+
channel_id: post["channel_id"]
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
include WebSocketHandling
|
|
242
|
+
include EventDispatching
|
|
243
|
+
|
|
244
|
+
def parse_post_response(response)
|
|
245
|
+
return {} unless successful?(response)
|
|
246
|
+
|
|
247
|
+
safe_json_parse(response.body)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def successful?(response)
|
|
251
|
+
response.is_a?(Net::HTTPSuccess)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def safe_json_parse(body)
|
|
255
|
+
JSON.parse(body)
|
|
256
|
+
rescue JSON::ParserError => error
|
|
257
|
+
log(:warn, "Failed to parse API response: #{error.message}")
|
|
258
|
+
{}
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|