Module: Brainiac::Plugins::Basecamp::CommentResponder

Defined in:
lib/brainiac/plugins/basecamp/comment_responder.rb

Overview

Handles inbound Basecamp comments on epic todolists/todos.

Routing:

1. If a bot account is @mentioned in the comment → dispatch that agent
2. If no mention → dispatch the last agent who responded on this epic
3. If no prior responder → dispatch the epic's default agent

The dispatched agent receives the comment content as a prompt with epic context, and posts its reply back via Client.add_comment.

Constant Summary collapse

MENTION_TAG_OPEN =
"<bc-attachment"
MENTION_TAG_CLOSE =
"</bc-attachment>"

Class Method Summary collapse

Class Method Details

.handle(payload, recording) ⇒ Array(Integer, String)

Process a comment_created webhook and dispatch the appropriate agent.

Parameters:

  • payload (Hash)

    Full webhook payload

  • recording (Hash)

    The comment recording from the payload

Returns:

  • (Array(Integer, String))

    HTTP status code and response body



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/brainiac/plugins/basecamp/comment_responder.rb', line 27

def handle(payload, recording)
  content = recording["content"] || ""
  creator = payload["creator"] || {}
  creator_id = creator["id"]&.to_s
  parent = recording["parent"] || {}
  parent_type = parent["type"]
  parent_id = parent["id"]
  parent_title = parent["title"] || ""
  project_id = recording.dig("bucket", "id")&.to_s

  # Ignore comments posted by our own bot accounts (prevent loops)
  if Config.(creator_id)
    LOG.debug "[Basecamp:Comment] Ignoring comment from our own bot (person #{creator_id})" if defined?(LOG)
    return [200, { status: "ignored", reason: "self_comment" }.to_json]
  end

  # Determine if this comment is on an epic todolist or a todo within one
  epic = resolve_epic_for_comment(parent_type, parent_id, parent_title, project_id)
  unless epic
    LOG.debug "[Basecamp:Comment] Comment not on an epic recording — ignoring" if defined?(LOG)
    return [200, { status: "ignored", reason: "not_epic" }.to_json]
  end

  # Determine which agent to dispatch
  agent_name = resolve_target_agent(content, epic)

  LOG.info "[Basecamp:Comment] Dispatching #{agent_name} to respond to comment on '#{epic['title']}'" if defined?(LOG)

  # Strip HTML tags for a clean text prompt, preserve @mentions as names
  clean_content = strip_html_preserve_mentions(content)
  commenter_name = creator["name"] || "Someone"

  # Dispatch the agent in a background thread
  Thread.new do
    dispatch_comment_response(
      epic: epic,
      agent_name: agent_name,
      comment_text: clean_content,
      commenter_name: commenter_name,
      recording_id: parent_id,
      project_id: project_id
    )
  rescue StandardError => e
    LOG.error "[Basecamp:Comment] Dispatch failed: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
  end

  # Track last responding agent on the epic
  epic["last_responding_agent"] = agent_name
  epic["updated_at"] = Time.now.iso8601
  Hooks.send(:save_epic_state, epic)

  [200, { status: "dispatched", agent: agent_name, epic_id: epic["id"] }.to_json]
end

.resolve_person_ids(agent_names) ⇒ Hash<String, String>

Resolve Basecamp person IDs for known agent names using the basecamp CLI. Used during setup to auto-map bot accounts.

Parameters:

  • agent_names (Array<String>)

    Agent names to look up (e.g. ["Galen", "Kaylee"])

  • project_id (String, nil)

    Optional project/bucket ID for scoping

Returns:

  • (Hash<String, String>)

    agent_name => person_id mapping



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/brainiac/plugins/basecamp/comment_responder.rb', line 87

def resolve_person_ids(agent_names)
  results = {}

  agent_names.each do |name|
    # Use jq to filter people by name
    output, status = Open3.capture2(
      "basecamp", "people", "list", "--jq",
      ".data[] | select(.name | ascii_downcase | contains(\"#{name.downcase}\")) | {id, name}"
    )

    next unless status.success?

    # Parse each JSON line (could be multiple matches)
    output.each_line do |line|
      person = JSON.parse(line.strip)
      # Exact match preferred, otherwise first contains-match
      if person["name"]&.downcase == name.downcase
        results[name] = person["id"].to_s
        break
      elsif !results.key?(name)
        results[name] = person["id"].to_s
      end
    rescue JSON::ParserError
      next
    end
  end

  results
end