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.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +40 -0
  4. data/CLAUDE.md +260 -0
  5. data/Gemfile +9 -0
  6. data/Gemfile.lock +177 -0
  7. data/LICENSE +21 -0
  8. data/README.md +106 -0
  9. data/Rakefile +11 -0
  10. data/bin/README.md +21 -0
  11. data/bin/ci +49 -0
  12. data/bin/claude-context +155 -0
  13. data/bin/claude-usage +110 -0
  14. data/bin/coverage +221 -0
  15. data/bin/rubocop +10 -0
  16. data/bin/watch-ci +198 -0
  17. data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
  18. data/config/earl-claude-home/.claude/settings.json +34 -0
  19. data/earl-bot.gemspec +42 -0
  20. data/exe/earl +51 -0
  21. data/exe/earl-install +129 -0
  22. data/exe/earl-permission-server +39 -0
  23. data/lib/earl/claude_session/stats.rb +76 -0
  24. data/lib/earl/claude_session.rb +468 -0
  25. data/lib/earl/command_executor/constants.rb +53 -0
  26. data/lib/earl/command_executor/heartbeat_display.rb +54 -0
  27. data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
  28. data/lib/earl/command_executor/session_handler.rb +126 -0
  29. data/lib/earl/command_executor/spawn_handler.rb +99 -0
  30. data/lib/earl/command_executor/stats_formatter.rb +66 -0
  31. data/lib/earl/command_executor/usage_handler.rb +132 -0
  32. data/lib/earl/command_executor.rb +128 -0
  33. data/lib/earl/command_parser.rb +57 -0
  34. data/lib/earl/config.rb +94 -0
  35. data/lib/earl/cron_parser.rb +105 -0
  36. data/lib/earl/formatting.rb +14 -0
  37. data/lib/earl/heartbeat_config.rb +101 -0
  38. data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
  39. data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
  40. data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
  41. data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
  42. data/lib/earl/heartbeat_scheduler.rb +131 -0
  43. data/lib/earl/logging.rb +12 -0
  44. data/lib/earl/mattermost/api_client.rb +85 -0
  45. data/lib/earl/mattermost.rb +261 -0
  46. data/lib/earl/mcp/approval_handler.rb +304 -0
  47. data/lib/earl/mcp/config.rb +62 -0
  48. data/lib/earl/mcp/github_pat_handler.rb +450 -0
  49. data/lib/earl/mcp/handler_base.rb +13 -0
  50. data/lib/earl/mcp/heartbeat_handler.rb +310 -0
  51. data/lib/earl/mcp/memory_handler.rb +89 -0
  52. data/lib/earl/mcp/server.rb +123 -0
  53. data/lib/earl/mcp/tmux_handler.rb +562 -0
  54. data/lib/earl/memory/prompt_builder.rb +40 -0
  55. data/lib/earl/memory/store.rb +125 -0
  56. data/lib/earl/message_queue.rb +56 -0
  57. data/lib/earl/permission_config.rb +22 -0
  58. data/lib/earl/question_handler/question_posting.rb +58 -0
  59. data/lib/earl/question_handler.rb +116 -0
  60. data/lib/earl/runner/idle_management.rb +44 -0
  61. data/lib/earl/runner/lifecycle.rb +73 -0
  62. data/lib/earl/runner/message_handling.rb +121 -0
  63. data/lib/earl/runner/reaction_handling.rb +42 -0
  64. data/lib/earl/runner/response_lifecycle.rb +96 -0
  65. data/lib/earl/runner/service_builder.rb +48 -0
  66. data/lib/earl/runner/startup.rb +73 -0
  67. data/lib/earl/runner/thread_context_builder.rb +43 -0
  68. data/lib/earl/runner.rb +70 -0
  69. data/lib/earl/safari_automation.rb +497 -0
  70. data/lib/earl/session_manager/persistence.rb +46 -0
  71. data/lib/earl/session_manager/session_creation.rb +108 -0
  72. data/lib/earl/session_manager.rb +92 -0
  73. data/lib/earl/session_store.rb +84 -0
  74. data/lib/earl/streaming_response.rb +219 -0
  75. data/lib/earl/tmux/parsing.rb +80 -0
  76. data/lib/earl/tmux/processes.rb +34 -0
  77. data/lib/earl/tmux/sessions.rb +41 -0
  78. data/lib/earl/tmux.rb +122 -0
  79. data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
  80. data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
  81. data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
  82. data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
  83. data/lib/earl/tmux_monitor.rb +249 -0
  84. data/lib/earl/tmux_session_store.rb +133 -0
  85. data/lib/earl/tool_input_formatter.rb +44 -0
  86. data/lib/earl/version.rb +5 -0
  87. data/lib/earl.rb +87 -0
  88. data/lib/tasks/.keep +1 -0
  89. metadata +248 -0
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ module Memory
5
+ # Pure Ruby file I/O for reading, writing, and searching memory files.
6
+ # Manages markdown-based persistent memory in <config_root>/memory/
7
+ # with SOUL.md (personality), USER.md (user notes), and YYYY-MM-DD.md (daily episodic).
8
+ class Store
9
+ def self.default_dir
10
+ @default_dir ||= File.join(Earl.config_root, "memory")
11
+ end
12
+
13
+ def initialize(dir: self.class.default_dir)
14
+ @dir = dir
15
+ end
16
+
17
+ def soul
18
+ read_file("SOUL.md")
19
+ end
20
+
21
+ def users
22
+ read_file("USER.md")
23
+ end
24
+
25
+ def recent_memories(days: 7, limit: 50)
26
+ entries = collect_entries(days)
27
+ entries.last(limit).join("\n")
28
+ rescue Errno::ENOENT
29
+ ""
30
+ end
31
+
32
+ def save(username:, text:)
33
+ FileUtils.mkdir_p(@dir)
34
+ now = Time.now.utc
35
+ today = now.strftime("%Y-%m-%d")
36
+ path = File.join(@dir, "#{today}.md")
37
+ entry = "- **#{now.strftime("%H:%M UTC")}** | `@#{username}` | #{text}"
38
+
39
+ write_with_header(path, today, entry)
40
+ { file: path, entry: entry }
41
+ end
42
+
43
+ def search(query:, limit: 20)
44
+ pattern = Regexp.new(Regexp.escape(query), Regexp::IGNORECASE)
45
+ grep_files(pattern, limit)
46
+ end
47
+
48
+ private
49
+
50
+ def collect_entries(days)
51
+ paths = date_files_descending(days)
52
+ paths.flat_map { |path| entries_from_file(path) }
53
+ end
54
+
55
+ def entries_from_file(path)
56
+ File.readlines(path).filter_map do |line|
57
+ stripped = line.strip
58
+ stripped unless stripped.empty? || stripped.start_with?("#")
59
+ end
60
+ end
61
+
62
+ def grep_files(pattern, limit)
63
+ search_files.each_with_object([]) do |path, matches|
64
+ file_matches = matches_in_file(path, pattern)
65
+ matches.concat(file_matches)
66
+ break matches if matches.size >= limit
67
+ rescue Errno::ENOENT
68
+ next
69
+ end.first(limit)
70
+ end
71
+
72
+ def matches_in_file(path, pattern)
73
+ basename = File.basename(path)
74
+ File.readlines(path).filter_map do |line|
75
+ { file: basename, line: line.strip } if pattern.match?(line)
76
+ end
77
+ end
78
+
79
+ def read_file(name)
80
+ path = file_at(name)
81
+ return "" unless path
82
+
83
+ File.read(path)
84
+ rescue Errno::ENOENT
85
+ ""
86
+ end
87
+
88
+ def file_at(name)
89
+ path = File.join(@dir, name)
90
+ path if File.exist?(path)
91
+ end
92
+
93
+ def date_files_descending(days)
94
+ today = Date.today
95
+ (0...days).filter_map do |offset|
96
+ file_at((today - offset).strftime("%Y-%m-%d.md"))
97
+ end
98
+ end
99
+
100
+ def search_files
101
+ priority = %w[SOUL.md USER.md].filter_map { |name| file_at(name) }
102
+ date_files = Dir.glob(File.join(@dir, "????-??-??.md")).reverse
103
+ priority + date_files
104
+ end
105
+
106
+ def write_with_header(path, today, entry)
107
+ content = build_entry_content(path, today, entry)
108
+ append_locked(path, content)
109
+ end
110
+
111
+ def build_entry_content(path, today, entry)
112
+ needs_header = !File.exist?(path) || File.empty?(path)
113
+ header = needs_header ? "# Memories for #{today}\n\n" : ""
114
+ "#{header}#{entry}\n"
115
+ end
116
+
117
+ def append_locked(path, content)
118
+ File.open(path, "a") do |file|
119
+ file.flock(File::LOCK_EX)
120
+ file.write(content)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ # Thread-safe message queue that tracks which threads are actively
5
+ # processing and buffers messages for busy threads.
6
+ class MessageQueue
7
+ include Logging
8
+
9
+ def initialize
10
+ @processing_threads = Set.new
11
+ @pending_messages = {}
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def try_claim(thread_id)
16
+ @mutex.synchronize do
17
+ if @processing_threads.include?(thread_id)
18
+ false
19
+ else
20
+ @processing_threads << thread_id
21
+ true
22
+ end
23
+ end
24
+ end
25
+
26
+ def enqueue(thread_id, text)
27
+ @mutex.synchronize do
28
+ queue = (@pending_messages[thread_id] ||= [])
29
+ queue << text
30
+ log(:debug, "Queued message for busy thread #{thread_id[0..7]}")
31
+ end
32
+ end
33
+
34
+ def dequeue(thread_id)
35
+ @mutex.synchronize do
36
+ msgs = @pending_messages[thread_id]
37
+ if msgs && !msgs.empty?
38
+ msgs.shift
39
+ else
40
+ @processing_threads.delete(thread_id)
41
+ nil
42
+ end
43
+ end
44
+ end
45
+
46
+ # Unconditionally releases the processing claim for a thread,
47
+ # discarding any pending messages. Use when the session is dead
48
+ # and queued messages cannot be delivered.
49
+ def release(thread_id)
50
+ @mutex.synchronize do
51
+ @processing_threads.delete(thread_id)
52
+ @pending_messages.delete(thread_id)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ # Shared permission configuration builder for MCP permission server.
5
+ # Used by both SessionManager (user-initiated) and HeartbeatScheduler (automated).
6
+ module PermissionConfig
7
+ private
8
+
9
+ def build_permission_env(config, channel_id:, thread_id: "")
10
+ return nil if config.skip_permissions?
11
+
12
+ {
13
+ "PLATFORM_URL" => config.mattermost_url,
14
+ "PLATFORM_TOKEN" => config.bot_token,
15
+ "PLATFORM_CHANNEL_ID" => channel_id,
16
+ "PLATFORM_THREAD_ID" => thread_id,
17
+ "PLATFORM_BOT_ID" => config.bot_id,
18
+ "ALLOWED_USERS" => config.allowed_users.join(",")
19
+ }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class QuestionHandler
5
+ # Handles building question messages, posting them, and managing reactions.
6
+ module QuestionPosting
7
+ private
8
+
9
+ def post_current_question(state)
10
+ question = state.current_question
11
+ message = build_question_message(question)
12
+
13
+ post_id = create_question_post(state.channel_id, state.thread_id, message)
14
+ register_question_post(state, post_id, (question["options"] || []).size)
15
+ end
16
+
17
+ def create_question_post(channel_id, thread_id, message)
18
+ result = @mattermost.create_post(channel_id: channel_id, message: message, root_id: thread_id)
19
+ result["id"]
20
+ end
21
+
22
+ def build_question_message(question)
23
+ options = question["options"] || []
24
+ lines = [":question: **#{question["question"]}**"]
25
+ options.each_with_index do |opt, index|
26
+ emoji = EMOJI_NUMBERS[index]
27
+ label = opt["label"] || opt.to_s
28
+ desc = opt["description"]
29
+ lines << ":#{emoji}: #{label}#{" — #{desc}" if desc}"
30
+ end
31
+ lines.join("\n")
32
+ end
33
+
34
+ def register_question_post(state, post_id, option_count)
35
+ if post_id
36
+ state.current_post_id = post_id
37
+ add_emoji_options(post_id, option_count)
38
+ @mutex.synchronize { @pending_questions[post_id] = state }
39
+ true
40
+ else
41
+ false
42
+ end
43
+ end
44
+
45
+ def add_emoji_options(post_id, count)
46
+ count.times do |index|
47
+ @mattermost.add_reaction(post_id: post_id, emoji_name: EMOJI_NUMBERS[index])
48
+ end
49
+ end
50
+
51
+ def delete_question_post(post_id)
52
+ @mattermost.delete_post(post_id: post_id)
53
+ rescue StandardError => error
54
+ log(:warn, "Failed to delete question post #{post_id}: #{error.message}")
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "question_handler/question_posting"
4
+
5
+ module Earl
6
+ # Handles AskUserQuestion tool_use events from Claude by posting numbered
7
+ # options to Mattermost and collecting answers via emoji reactions.
8
+ class QuestionHandler
9
+ include Logging
10
+ include QuestionPosting
11
+
12
+ EMOJI_NUMBERS = %w[one two three four].freeze
13
+ EMOJI_MAP = { "one" => 0, "two" => 1, "three" => 2, "four" => 3 }.freeze
14
+
15
+ # Tracks in-progress question flow: which tool_use triggered it, the list of
16
+ # questions, collected answers, and the Mattermost post/thread IDs.
17
+ QuestionState = Struct.new(:tool_use_id, :questions, :answers, :current_index,
18
+ :current_post_id, :thread_id, :channel_id, keyword_init: true) do
19
+ def current_question
20
+ questions[current_index]
21
+ end
22
+
23
+ def all_questions_answered?
24
+ current_index >= questions.size
25
+ end
26
+ end
27
+
28
+ def initialize(mattermost:)
29
+ @mattermost = mattermost
30
+ @pending_questions = {} # post_id -> QuestionState
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ def handle_tool_use(thread_id:, tool_use:, channel_id: nil)
35
+ name, input, tool_use_id = tool_use.values_at(:name, :input, :id)
36
+ return nil unless name == "AskUserQuestion"
37
+
38
+ questions = input["questions"] || []
39
+ return nil if questions.empty?
40
+
41
+ state = QuestionState.new(
42
+ tool_use_id: tool_use_id, questions: questions, answers: {},
43
+ current_index: 0, thread_id: thread_id, channel_id: channel_id
44
+ )
45
+ start_question_flow(state, tool_use_id)
46
+ end
47
+
48
+ def handle_reaction(post_id:, emoji_name:)
49
+ state = fetch_pending(post_id)
50
+ return nil unless state
51
+
52
+ selected = resolve_selected_option(state, emoji_name)
53
+ return nil unless selected
54
+
55
+ accept_answer(state, post_id, selected)
56
+ end
57
+
58
+ private
59
+
60
+ def start_question_flow(state, tool_use_id)
61
+ unless post_current_question(state)
62
+ log(:error, "Failed to post question for tool_use #{tool_use_id}, returning error answer")
63
+ return { tool_use_id: tool_use_id, answer_text: "Failed to post question to chat" }
64
+ end
65
+
66
+ { tool_use_id: tool_use_id }
67
+ end
68
+
69
+ def fetch_pending(post_id)
70
+ @mutex.synchronize { @pending_questions[post_id] }
71
+ end
72
+
73
+ def release_pending(post_id)
74
+ @mutex.synchronize { @pending_questions.delete(post_id) }
75
+ delete_question_post(post_id)
76
+ end
77
+
78
+ def accept_answer(state, post_id, selected)
79
+ index = state.current_index
80
+ record_answer(state, state.questions[index], selected)
81
+ release_pending(post_id)
82
+ advance_question(state, index)
83
+ end
84
+
85
+ def advance_question(state, index)
86
+ next_index = index + 1
87
+ state.current_index = next_index
88
+ return build_answer_json(state) unless next_index < state.questions.size
89
+
90
+ post_current_question(state)
91
+ nil
92
+ end
93
+
94
+ def resolve_selected_option(state, emoji_name)
95
+ answer_index = EMOJI_MAP[emoji_name]
96
+ return nil unless answer_index
97
+
98
+ options = state.questions[state.current_index]["options"] || []
99
+ answer_index < options.size ? options[answer_index] : nil
100
+ end
101
+
102
+ def record_answer(state, question, selected_option)
103
+ state.answers[question["question"]] = selected_option["label"] || selected_option.to_s
104
+ end
105
+
106
+ def build_answer_json(state)
107
+ answers = state.questions.map.with_index do |question, index|
108
+ q_text = question["question"]
109
+ answer = state.answers[q_text]
110
+ "Question #{index + 1}: #{q_text}\nAnswer: #{answer}"
111
+ end.join("\n\n")
112
+
113
+ { tool_use_id: state.tool_use_id, answer_text: answers }
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class Runner
5
+ # Idle session management.
6
+ module IdleManagement
7
+ private
8
+
9
+ def start_idle_checker
10
+ @app_state.idle_checker_thread = Thread.new do
11
+ loop do
12
+ sleep IDLE_CHECK_INTERVAL
13
+ check_idle_sessions
14
+ rescue StandardError => error
15
+ log(:error, "Idle checker error: #{error.message}")
16
+ end
17
+ end
18
+ end
19
+
20
+ def check_idle_sessions
21
+ @services.session_store.load.each do |thread_id, persisted|
22
+ stop_if_idle(thread_id, persisted)
23
+ end
24
+ end
25
+
26
+ def stop_if_idle(thread_id, persisted)
27
+ return if persisted.is_paused
28
+
29
+ idle_seconds = seconds_since_activity(persisted.last_activity_at)
30
+ return unless idle_seconds
31
+ return unless idle_seconds > IDLE_TIMEOUT
32
+
33
+ log(:info, "Stopping idle session for thread #{thread_id[0..7]} (idle #{(idle_seconds / 60).round}min)")
34
+ @services.session_manager.stop_session(thread_id)
35
+ end
36
+
37
+ def seconds_since_activity(last_activity_at)
38
+ return nil unless last_activity_at
39
+
40
+ Time.now - Time.parse(last_activity_at)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Earl
6
+ class Runner
7
+ # Shutdown, restart, and process lifecycle management.
8
+ module Lifecycle
9
+ private
10
+
11
+ def begin_shutdown(&)
12
+ return if @app_state.shutting_down
13
+
14
+ @app_state.shutting_down = true
15
+ @app_state.shutdown_thread = Thread.new(&)
16
+ end
17
+
18
+ def handle_shutdown_signal
19
+ begin_shutdown { shutdown }
20
+ end
21
+
22
+ def handle_restart_signal
23
+ @app_state.pending_restart = true
24
+ begin_shutdown { restart }
25
+ end
26
+
27
+ def setup_close_handler
28
+ @services.mattermost.on_close { handle_shutdown_signal }
29
+ end
30
+
31
+ def shutdown
32
+ log(:info, "Shutting down...")
33
+ @app_state.idle_checker_thread&.kill
34
+ @services.heartbeat_scheduler.stop
35
+ @services.tmux_monitor.stop
36
+ @services.session_manager.pause_all
37
+ log(:info, "Goodbye!")
38
+ end
39
+
40
+ def restart
41
+ updating = @app_state.pending_update
42
+ log(:info, updating ? "Updating EARL..." : "Restarting EARL...")
43
+ pull_latest if updating || !Earl.development?
44
+ update_dependencies if updating
45
+ shutdown
46
+ end
47
+
48
+ def wait_and_exec_restart
49
+ @app_state.shutdown_thread&.join
50
+ cmd = [RbConfig.ruby, $PROGRAM_NAME]
51
+ log(:info, "Exec: #{cmd.join(" ")}")
52
+ Bundler.with_unbundled_env { Kernel.exec(*cmd) }
53
+ end
54
+
55
+ def pull_latest
56
+ run_in_repo("git pull --ff-only", "git", "pull", "--ff-only")
57
+ end
58
+
59
+ def update_dependencies
60
+ run_in_repo("bundle install", { "RUBYOPT" => "-W0" }, "bundle", "install", "--quiet")
61
+ end
62
+
63
+ def run_in_repo(label, *cmd)
64
+ Dir.chdir(File.dirname($PROGRAM_NAME)) do
65
+ result = system(*cmd) ? "succeeded" : "failed (continuing)"
66
+ log(result.start_with?("s") ? :info : :warn, "#{label} #{result}")
67
+ end
68
+ rescue StandardError => error
69
+ log(:warn, "#{label} failed: #{error.message}")
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class Runner
5
+ # Message routing: receives user messages, dispatches commands or enqueues for Claude.
6
+ module MessageHandling
7
+ private
8
+
9
+ def setup_message_handler
10
+ @services.mattermost.on_message do |sender_name:, thread_id:, text:, channel_id:, **_extra|
11
+ if allowed_user?(sender_name)
12
+ msg = UserMessage.new(thread_id: thread_id, text: text, channel_id: channel_id,
13
+ sender_name: sender_name)
14
+ handle_incoming_message(msg)
15
+ end
16
+ end
17
+ end
18
+
19
+ def handle_incoming_message(msg)
20
+ if CommandParser.command?(msg.text)
21
+ dispatch_command(msg)
22
+ else
23
+ enqueue_message(msg)
24
+ end
25
+ end
26
+
27
+ def dispatch_command(msg)
28
+ command = CommandParser.parse(msg.text)
29
+ return unless command
30
+
31
+ thread_id = msg.thread_id
32
+ result = @services.command_executor.execute(command, thread_id: thread_id, channel_id: msg.channel_id)
33
+ enqueue_passthrough(result, msg) if result&.dig(:passthrough)
34
+ stop_active_response(thread_id) if %i[stop kill].include?(command.name)
35
+ end
36
+
37
+ def enqueue_passthrough(result, msg)
38
+ msg_thread_id, _text, msg_channel_id, msg_sender = msg.deconstruct
39
+ passthrough_msg = UserMessage.new(
40
+ thread_id: msg_thread_id, text: result[:passthrough],
41
+ channel_id: msg_channel_id, sender_name: msg_sender
42
+ )
43
+ enqueue_message(passthrough_msg)
44
+ end
45
+
46
+ def enqueue_message(msg)
47
+ thread_id = msg.thread_id
48
+ queue = @app_state.message_queue
49
+ if queue.try_claim(thread_id)
50
+ process_message(msg)
51
+ else
52
+ queue.enqueue(thread_id, msg.text)
53
+ end
54
+ end
55
+
56
+ def process_message(msg)
57
+ sent = false
58
+ thread_id = msg.thread_id
59
+ sent = process_message_send(msg, thread_id)
60
+ rescue StandardError => error
61
+ log_processing_error(thread_id, error)
62
+ ensure
63
+ cleanup_failed_send(thread_id) unless sent
64
+ end
65
+
66
+ def process_message_send(msg, thread_id)
67
+ text = msg.text
68
+ effective_channel = msg.channel_id || @services.config.channel_id
69
+ existing_session, session = prepare_session(thread_id, effective_channel, msg.sender_name)
70
+ prepare_response(session, thread_id, effective_channel)
71
+ message = existing_session ? text : build_contextual_message(thread_id, text)
72
+ send_and_touch(session, thread_id, message)
73
+ end
74
+
75
+ def send_and_touch(session, thread_id, message)
76
+ sent = session.send_message(message)
77
+ @services.session_manager.touch(thread_id) if sent
78
+ sent
79
+ end
80
+
81
+ def prepare_session(thread_id, channel_id, sender_name)
82
+ working_dir = resolve_working_dir(thread_id, channel_id)
83
+ manager = @services.session_manager
84
+ existing = manager.get(thread_id)
85
+ session_config = SessionManager::SessionConfig.new(
86
+ channel_id: channel_id, working_dir: working_dir, username: sender_name
87
+ )
88
+ session = manager.get_or_create(thread_id, session_config)
89
+ [existing, session]
90
+ end
91
+
92
+ def resolve_working_dir(thread_id, channel_id)
93
+ @services.command_executor.working_dir_for(thread_id) || @services.config.channels[channel_id] || Dir.pwd
94
+ end
95
+
96
+ def build_contextual_message(thread_id, text)
97
+ ThreadContextBuilder.new(mattermost: @services.mattermost).build(thread_id, text)
98
+ end
99
+
100
+ def process_next_queued(thread_id)
101
+ next_text = @app_state.message_queue.dequeue(thread_id)
102
+ return unless next_text
103
+
104
+ msg = UserMessage.new(thread_id: thread_id, text: next_text, channel_id: nil, sender_name: nil)
105
+ process_message(msg)
106
+ end
107
+
108
+ def allowed_user?(username)
109
+ allowed = @services.config.allowed_users
110
+ return true if allowed.empty?
111
+
112
+ unless allowed.include?(username)
113
+ log(:debug, "Ignoring message from non-allowed user: #{username}")
114
+ return false
115
+ end
116
+
117
+ true
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class Runner
5
+ # Emoji reaction handling: routes reactions to question handler or tmux monitor.
6
+ module ReactionHandling
7
+ private
8
+
9
+ def setup_reaction_handler
10
+ @services.mattermost.on_reaction do |user_id:, post_id:, emoji_name:|
11
+ handle_reaction(user_id: user_id, post_id: post_id, emoji_name: emoji_name)
12
+ end
13
+ end
14
+
15
+ def handle_reaction(user_id:, post_id:, emoji_name:)
16
+ return unless allowed_reactor?(user_id)
17
+
18
+ result = @services.question_handler.handle_reaction(post_id: post_id, emoji_name: emoji_name)
19
+ if result
20
+ thread_id = @responses.question_threads[result[:tool_use_id]]
21
+ return unless thread_id
22
+
23
+ session = @services.session_manager.get(thread_id)
24
+ session&.send_message(result[:answer_text])
25
+ return
26
+ end
27
+
28
+ @services.tmux_monitor.handle_reaction(post_id: post_id, emoji_name: emoji_name)
29
+ end
30
+
31
+ def allowed_reactor?(user_id)
32
+ allowed = @services.config.allowed_users
33
+ return true if allowed.empty?
34
+
35
+ username = @services.mattermost.get_user(user_id: user_id)["username"]
36
+ return false unless username
37
+
38
+ allowed.include?(username)
39
+ end
40
+ end
41
+ end
42
+ end