zillacore 0.0.1

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 (60) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +126 -0
  6. data/README.md +1166 -0
  7. data/Rakefile +12 -0
  8. data/bin/zillacore +1521 -0
  9. data/certs/stowzilla.pem +26 -0
  10. data/docs/waybar-config.md +96 -0
  11. data/lib/user_registry.rb +159 -0
  12. data/lib/zillacore/agents.rb +203 -0
  13. data/lib/zillacore/brain.rb +197 -0
  14. data/lib/zillacore/card_index.rb +389 -0
  15. data/lib/zillacore/config.rb +263 -0
  16. data/lib/zillacore/cron.rb +629 -0
  17. data/lib/zillacore/deployments.rb +258 -0
  18. data/lib/zillacore/handlers/discord.rb +1643 -0
  19. data/lib/zillacore/handlers/fizzy.rb +1249 -0
  20. data/lib/zillacore/handlers/github.rb +598 -0
  21. data/lib/zillacore/handlers/zoho.rb +487 -0
  22. data/lib/zillacore/helpers.rb +760 -0
  23. data/lib/zillacore/planning.rb +237 -0
  24. data/lib/zillacore/prompts.rb +620 -0
  25. data/lib/zillacore/sessions.rb +282 -0
  26. data/lib/zillacore/skills.rb +276 -0
  27. data/lib/zillacore/users.rb +76 -0
  28. data/lib/zillacore/version.rb +6 -0
  29. data/lib/zillacore/zoho_mail_api.rb +109 -0
  30. data/lib/zillacore.rb +10 -0
  31. data/monitor/daemon.rb +99 -0
  32. data/monitor/deploy-env-macos.rb +131 -0
  33. data/monitor/menubar.rb +295 -0
  34. data/monitor/open-action.sh +15 -0
  35. data/monitor/setup-menubar.rb +78 -0
  36. data/monitor/setup-waybar-deploy-envs.rb +121 -0
  37. data/monitor/setup-waybar-deployments.rb +96 -0
  38. data/monitor/setup-waybar-module.rb +113 -0
  39. data/monitor/setup-xbar-plugin.rb +35 -0
  40. data/monitor/view-logs-macos.rb +210 -0
  41. data/monitor/view-logs-rofi.rb +194 -0
  42. data/monitor/view-logs.rb +119 -0
  43. data/monitor/waybar-config-updater.rb +56 -0
  44. data/monitor/waybar-deploy-env.rb +206 -0
  45. data/monitor/waybar-deployments.rb +239 -0
  46. data/monitor/waybar.rb +146 -0
  47. data/monitor/xbar.3s.rb +179 -0
  48. data/receiver.rb +956 -0
  49. data/templates/agents.json.example +10 -0
  50. data/templates/discord.json.example +17 -0
  51. data/templates/fizzy.json.example +24 -0
  52. data/templates/github.json.example +4 -0
  53. data/templates/testflight.json.example +8 -0
  54. data/templates/users.json.example +121 -0
  55. data/templates/zoho.json.example +27 -0
  56. data/views/dashboard.erb +437 -0
  57. data/zillacore.gemspec +30 -0
  58. data.tar.gz.sig +2 -0
  59. metadata +235 -0
  60. metadata.gz.sig +0 -0
@@ -0,0 +1,487 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Zoho Mail outgoing webhook handler.
4
+ #
5
+ # Zoho Mail fires outgoing webhooks when emails match configured conditions.
6
+ # This handler receives those webhooks, verifies the HMAC-SHA256 signature,
7
+ # and dispatches notifications to Discord.
8
+ #
9
+ # Config: ~/.zillacore/zoho.json
10
+ # Docs: https://www.zoho.com/mail/help/dev-platform/webhook.html
11
+
12
+ require "English"
13
+ require_relative "../zoho_mail_api"
14
+
15
+ ZOHO_CONFIG_FILE = File.join(ZILLACORE_DIR, "zoho.json")
16
+
17
+ def load_zoho_config
18
+ return {} unless File.exist?(ZOHO_CONFIG_FILE)
19
+
20
+ JSON.parse(File.read(ZOHO_CONFIG_FILE))
21
+ rescue JSON::ParserError => e
22
+ LOG.error "[Zoho] Failed to parse config: #{e.message}"
23
+ {}
24
+ end
25
+
26
+ ZOHO_CONFIG = load_zoho_config
27
+
28
+ def reload_zoho_config!(force: false)
29
+ return unless file_changed?(ZOHO_CONFIG_FILE, force: force)
30
+
31
+ ZOHO_CONFIG.replace(load_zoho_config)
32
+ LOG.info "[Zoho] Reloaded configuration"
33
+ end
34
+
35
+ def default_project_config
36
+ key = default_project_key
37
+ key ? PROJECTS[key] : PROJECTS.values.first
38
+ end
39
+
40
+ def zoho_triage_project_tags
41
+ tags = ZOHO_CONFIG["triage_project_tags"]
42
+ return "Use your best judgement to identify the relevant project." unless tags&.any?
43
+
44
+ tags.map { |t| " - `#{t["tag"]}` — #{t["description"]}" }.join("\n")
45
+ end
46
+
47
+ def zoho_triage_agent_assignment
48
+ rules = ZOHO_CONFIG["triage_agent_assignment"]
49
+ return "Assign to the default agent." unless rules&.any?
50
+
51
+ rules.map { |r| " - #{r}" }.join("\n")
52
+ end
53
+
54
+ # Zoho sends the signing secret in the X-Hook-Secret header on the very first
55
+ # request. We store it in the config file so subsequent requests can be verified.
56
+ # If the secret is already in the config, we use that.
57
+ def zoho_hook_secret
58
+ ZOHO_CONFIG["hook_secret"]
59
+ end
60
+
61
+ def save_zoho_hook_secret(secret)
62
+ ZOHO_CONFIG["hook_secret"] = secret
63
+ File.write(ZOHO_CONFIG_FILE, JSON.pretty_generate(ZOHO_CONFIG))
64
+ LOG.info "[Zoho] Saved hook_secret to #{ZOHO_CONFIG_FILE}"
65
+ end
66
+
67
+ # Verify the X-Hook-Signature header (base64 HMAC-SHA256 of the raw body).
68
+ def verify_zoho_signature!(request, payload_body)
69
+ signature = request.env["HTTP_X_HOOK_SIGNATURE"]
70
+ return unless signature # First request won't have a signature, just the secret
71
+
72
+ secret = zoho_hook_secret
73
+ halt 500, { error: "No hook_secret configured — waiting for initial Zoho handshake" }.to_json unless secret
74
+
75
+ computed = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", secret, payload_body))
76
+ halt 403, { error: "Invalid Zoho signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
77
+ end
78
+
79
+ # Check if an email contains any of the exclude words (checked against subject, from, and body).
80
+ def zoho_email_excluded?(email, exclude_words)
81
+ return false if exclude_words.nil? || exclude_words.empty?
82
+
83
+ searchable = [email["subject"], email["fromAddress"], email["toAddress"], email["summary"], email["html"]].join(" ").downcase
84
+ Array(exclude_words).any? { |word| searchable.include?(word.downcase) }
85
+ end
86
+
87
+ # Match an email against configured rules. Returns the first matching rule or nil.
88
+ # If no rules match, returns a fallback rule (if configured) so nothing is missed.
89
+ def match_zoho_rule(email)
90
+ rules = ZOHO_CONFIG["rules"] || []
91
+ rules.each do |rule|
92
+ next if rule["enabled"] == false
93
+
94
+ matches = true
95
+ if rule["from_contains"] && !rule["from_contains"].empty? && !email["fromAddress"].to_s.downcase.include?(rule["from_contains"].downcase)
96
+ matches = false
97
+ end
98
+ matches = false if rule["to_contains"] && !rule["to_contains"].empty? && !email["toAddress"].to_s.downcase.include?(rule["to_contains"].downcase)
99
+ if rule["subject_contains"] && !rule["subject_contains"].empty? && !email["subject"].to_s.downcase.include?(rule["subject_contains"].downcase)
100
+ matches = false
101
+ end
102
+ if rule["body_contains"] && !rule["body_contains"].empty?
103
+ body = email["summary"].to_s + email["html"].to_s
104
+ matches = false unless body.downcase.include?(rule["body_contains"].downcase)
105
+ end
106
+ matches = false if matches && zoho_email_excluded?(email, rule["exclude_words"])
107
+
108
+ return rule if matches
109
+ end
110
+
111
+ # Fallback: post unmatched emails so nothing slips through
112
+ fallback = zoho_fallback_rule
113
+ return nil if fallback && zoho_email_excluded?(email, ZOHO_CONFIG.dig("fallback", "exclude_words"))
114
+
115
+ fallback
116
+ end
117
+
118
+ # Returns the fallback rule config, or nil if not configured.
119
+ def zoho_fallback_rule
120
+ fallback = ZOHO_CONFIG["fallback"]
121
+ return nil unless fallback && fallback["enabled"] != false
122
+
123
+ { "label" => fallback["label"] || "Unmatched Email",
124
+ "emoji" => fallback["emoji"] || "📬",
125
+ "discord_channel_id" => fallback["discord_channel_id"],
126
+ "notify_as" => fallback["notify_as"] }
127
+ end
128
+
129
+ # Format a Discord notification for a matched email.
130
+ def format_zoho_notification(email, rule)
131
+ label = rule["label"] || "Zoho Mail"
132
+ emoji = rule["emoji"] || "📧"
133
+ parts = ["#{emoji} **#{label}**"]
134
+ parts << "**Subject:** #{email["subject"]}" if email["subject"]
135
+ parts << "**From:** #{email["fromAddress"]}" if email["fromAddress"]
136
+ parts << "**To:** #{email["toAddress"]}" if email["toAddress"]
137
+
138
+ # Include body content — try webhook payload first, then fetch via API
139
+ body_text = email["summary"].to_s.strip
140
+ if body_text.empty?
141
+ raw_html = (email["html"] || email["content"] || email["body"] || "").to_s
142
+ body_text = raw_html.gsub(/<[^>]+>/, " ").gsub(/&nbsp;/i, " ").gsub(/\s+/, " ").strip
143
+ end
144
+
145
+ # If still empty and show_body requested, fetch via Zoho Mail API
146
+ body_text = fetch_zoho_email_content(email["messageId"]).to_s if body_text.empty? && rule["show_body"] && email["messageId"]
147
+
148
+ if !body_text.empty? && rule["show_body"]
149
+ body_text = "#{body_text[0..1800]}..." if body_text.length > 1800
150
+ parts << "```\n#{body_text}\n```"
151
+ elsif !body_text.empty?
152
+ body_text = "#{body_text[0..500]}..." if body_text.length > 500
153
+ parts << "```\n#{body_text}\n```"
154
+ end
155
+
156
+ parts.join("\n")
157
+ end
158
+
159
+ # Send the notification to the configured Discord channel.
160
+ def notify_zoho_match(email, rule)
161
+ channel_id = rule["discord_channel_id"] || ZOHO_CONFIG["default_discord_channel_id"]
162
+ unless channel_id
163
+ LOG.warn "[Zoho] No discord_channel_id configured for rule '#{rule["label"]}' and no default set"
164
+ return
165
+ end
166
+
167
+ message = format_zoho_notification(email, rule)
168
+
169
+ tokens = discord_bot_tokens
170
+ bot_name = rule["notify_as"] || ZOHO_CONFIG["notify_as"] || tokens.keys.first
171
+ token = tokens[bot_name&.downcase] || tokens.values.first
172
+
173
+ unless token
174
+ LOG.warn "[Zoho] No Discord bot token available to send notification"
175
+ return
176
+ end
177
+
178
+ LOG.info "[Zoho] Sending notification to channel #{channel_id} (bot: #{bot_name})"
179
+ send_discord_message(channel_id, message, token: token)
180
+ end
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Zoho Email Triage — dispatch an agent to decide if a support email needs a card
184
+ # ---------------------------------------------------------------------------
185
+
186
+ ZOHO_TRIAGE_DIR = File.join(ZILLACORE_DIR, "tmp", "zoho", "triage")
187
+ FileUtils.mkdir_p(ZOHO_TRIAGE_DIR)
188
+
189
+ ZOHO_TRIAGE_PROMPT = <<~PROMPT
190
+ You are triaging a support email. Decide whether this email needs a Fizzy card or not.
191
+
192
+ ## Email
193
+ **From:** {{FROM}}
194
+ **To:** {{TO}}
195
+ **Subject:** {{SUBJECT}}
196
+ **Body:**
197
+ ```
198
+ {{BODY}}
199
+ ```
200
+
201
+ ## Decision Criteria
202
+ **Needs a card** (something is broken, a bug report, a feature request, a workflow issue):
203
+ - Create a card with a clear title summarizing the issue
204
+ - Tag with `support` plus a project tag if you can identify the relevant project
205
+ - Assign to the appropriate agent
206
+
207
+ **Does NOT need a card** (account questions, password resets, general inquiries, spam, marketing):
208
+ - Just explain why briefly
209
+
210
+ **Borderline** (you're not sure):
211
+ - Mark as borderline and explain why — a human will decide
212
+
213
+ ## Project Tags (use the tag name, not the ID)
214
+ {{PROJECT_TAGS}}
215
+
216
+ ## Agent Assignment
217
+ {{AGENT_ASSIGNMENT}}
218
+
219
+ ## Response Format
220
+ Write ONLY valid JSON to stdout (no markdown, no explanation outside the JSON):
221
+
222
+ For "needs a card":
223
+ ```json
224
+ {
225
+ "decision": "create_card",
226
+ "title": "Brief descriptive title for the card",
227
+ "description": "HTML description with relevant details from the email",
228
+ "project_tag": "project-tag-name or null",
229
+ "assign_to": "Galen|Avon|Sheogorath"
230
+ }
231
+ ```
232
+
233
+ For "does not need a card":
234
+ ```json
235
+ {
236
+ "decision": "skip",
237
+ "reason": "Brief explanation of why no card is needed"
238
+ }
239
+ ```
240
+
241
+ For "borderline":
242
+ ```json
243
+ {
244
+ "decision": "borderline",
245
+ "reason": "Why you're unsure — what makes this ambiguous"
246
+ }
247
+ ```
248
+ PROMPT
249
+
250
+ # Dispatch an agent to triage a support email. The agent decides whether to create
251
+ # a Fizzy card or just notify Discord.
252
+ def dispatch_zoho_triage(email, rule)
253
+ agent_name = rule["dispatch_agent"]
254
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
255
+ response_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{timestamp}.json")
256
+ log_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{timestamp}.log")
257
+
258
+ body = (email["summary"] || email["html"] || "").to_s.gsub(/\s+/, " ").strip
259
+ body = body[0..2000] if body.length > 2000
260
+
261
+ prompt = ZOHO_TRIAGE_PROMPT
262
+ .gsub("{{FROM}}", email["fromAddress"].to_s)
263
+ .gsub("{{TO}}", email["toAddress"].to_s)
264
+ .gsub("{{SUBJECT}}", email["subject"].to_s)
265
+ .gsub("{{BODY}}", body)
266
+ .gsub("{{PROJECT_TAGS}}", zoho_triage_project_tags)
267
+ .gsub("{{AGENT_ASSIGNMENT}}", zoho_triage_agent_assignment)
268
+
269
+ prompt += "\n\nWrite your JSON response to: #{response_file}\n"
270
+
271
+ prompt_file = File.join(ZOHO_TRIAGE_DIR, "triage-prompt-#{timestamp}.md")
272
+ File.write(prompt_file, prompt)
273
+
274
+ agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
275
+ project_config = default_project_config
276
+
277
+ resolved = project_config ? resolve_project_cli_config(project_config) : {}
278
+ agent_cli = resolved["agent_cli"] || "kiro-cli"
279
+ agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive"
280
+ resolved["agent_model_flag"] || "--model"
281
+
282
+ cmd = [agent_cli]
283
+ cmd.push("--agent", agent_key)
284
+ cmd.concat(agent_cli_args.split)
285
+ add_trust_tools!(cmd, agent_cli_args)
286
+
287
+ spawn_env = {}
288
+ agent_env = agent_env_for(agent_name)
289
+ spawn_env.merge!(agent_env) unless agent_env.empty?
290
+
291
+ work_dir = project_config ? project_config["repo_path"] : Dir.pwd
292
+
293
+ LOG.info "[Zoho:Triage] Dispatching #{agent_name} for: #{email["subject"]}"
294
+ LOG.info "[Zoho:Triage] Command: #{cmd.join(" ")}"
295
+
296
+ pid = spawn(spawn_env, *cmd,
297
+ chdir: work_dir,
298
+ in: prompt_file,
299
+ out: [log_file, "w"],
300
+ err: %i[child out])
301
+
302
+ # Monitor in background — process the triage decision when agent finishes
303
+ Thread.new do
304
+ Process.wait(pid)
305
+ exit_status = $CHILD_STATUS
306
+ LOG.info "[Zoho:Triage] Agent finished (exit: #{exit_status.exitstatus})"
307
+
308
+ decision = read_zoho_triage_response(response_file, log_file)
309
+ if decision
310
+ execute_zoho_triage_decision(decision, email, rule)
311
+ else
312
+ LOG.warn "[Zoho:Triage] No valid decision from agent — falling back to Discord notification"
313
+ notify_zoho_match(email, rule)
314
+ end
315
+
316
+ # Cleanup prompt file after a delay
317
+ Thread.new do
318
+ sleep 300
319
+ FileUtils.rm_f(prompt_file)
320
+ end
321
+ end
322
+
323
+ pid
324
+ end
325
+
326
+ # Read the triage response — try the response file first, then extract from log
327
+ def read_zoho_triage_response(response_file, log_file)
328
+ # Try response file
329
+ if File.exist?(response_file) && !File.empty?(response_file)
330
+ content = File.read(response_file).strip
331
+ return parse_triage_json(content)
332
+ end
333
+
334
+ # Fallback: extract JSON from log output
335
+ if File.exist?(log_file)
336
+ log_content = File.read(log_file)
337
+ # Look for JSON block in the log
338
+ if (match = log_content.match(/\{[^{}]*"decision"\s*:\s*"[^"]+?"[^{}]*\}/m))
339
+ return parse_triage_json(match[0])
340
+ end
341
+ end
342
+
343
+ nil
344
+ end
345
+
346
+ def parse_triage_json(content)
347
+ # Strip markdown code fences if present
348
+ content = content.gsub(/```json\s*/, "").gsub(/```\s*/, "").strip
349
+ JSON.parse(content)
350
+ rescue JSON::ParserError => e
351
+ LOG.warn "[Zoho:Triage] Failed to parse response JSON: #{e.message}"
352
+ nil
353
+ end
354
+
355
+ # Act on the triage decision
356
+ def execute_zoho_triage_decision(decision, email, rule)
357
+ channel_id = rule["discord_channel_id"] || ZOHO_CONFIG["default_discord_channel_id"]
358
+ tokens = discord_bot_tokens
359
+ bot_name = rule["notify_as"] || ZOHO_CONFIG["notify_as"] || tokens.keys.first
360
+ token = tokens[bot_name&.downcase] || tokens.values.first
361
+
362
+ case decision["decision"]
363
+ when "create_card"
364
+ create_zoho_triage_card(decision, email, channel_id, token)
365
+ when "skip"
366
+ msg = "📧 **Support Email — No Card Needed**\n"
367
+ msg += "**Subject:** #{email["subject"]}\n"
368
+ msg += "**From:** #{email["fromAddress"]}\n"
369
+ msg += "**Reason:** #{decision["reason"]}"
370
+ send_discord_message(channel_id, msg, token: token) if channel_id && token
371
+ LOG.info "[Zoho:Triage] Skipped card: #{decision["reason"]}"
372
+ when "borderline"
373
+ msg = "⚠️ **Support Email — Needs Human Decision**\n"
374
+ msg += "**Subject:** #{email["subject"]}\n"
375
+ msg += "**From:** #{email["fromAddress"]}\n"
376
+ msg += "**Why borderline:** #{decision["reason"]}\n"
377
+ summary = (email["summary"] || "").to_s[0..300]
378
+ msg += "```\n#{summary}\n```" unless summary.empty?
379
+ send_discord_message(channel_id, msg, token: token) if channel_id && token
380
+ LOG.info "[Zoho:Triage] Borderline — posted to Discord for human decision"
381
+ else
382
+ LOG.warn "[Zoho:Triage] Unknown decision: #{decision["decision"]}"
383
+ notify_zoho_match(email, rule)
384
+ end
385
+ end
386
+
387
+ # Create a Fizzy card from the triage decision
388
+ def create_zoho_triage_card(decision, email, channel_id, token)
389
+ board_id = ZOHO_CONFIG["triage_board_id"]
390
+ unless board_id
391
+ LOG.error "[Zoho:Triage] No triage_board_id configured in zoho.json"
392
+ return
393
+ end
394
+ title = decision["title"] || email["subject"]
395
+ description = decision["description"] || "<p>Support email from #{email["fromAddress"]}: #{email["subject"]}</p>"
396
+
397
+ # Build tag list — always include 'support', plus project tag if identified
398
+ tags = ["support"]
399
+ tags << decision["project_tag"] if decision["project_tag"]
400
+
401
+ # Resolve tag IDs
402
+ tag_ids = resolve_zoho_triage_tags(tags)
403
+
404
+ # Create the card
405
+ cmd = ["fizzy", "card", "create", "--board", board_id, "--title", title, "--description", description]
406
+ cmd.push("--tag-ids", tag_ids.join(",")) unless tag_ids.empty?
407
+
408
+ # Use the triage agent's env for fizzy token
409
+ agent_name = "Threepio"
410
+ agent_env = agent_env_for(agent_name)
411
+ spawn_env = agent_env.empty? ? {} : agent_env
412
+
413
+ output, status = Open3.capture2e(spawn_env, *cmd)
414
+ unless status.success?
415
+ LOG.error "[Zoho:Triage] Failed to create card: #{output}"
416
+ notify_zoho_match(email, { "label" => "Support Email (card creation failed)", "emoji" => "❌" }.merge(rule_defaults(nil)))
417
+ return
418
+ end
419
+
420
+ # Parse card number from response
421
+ card_data = JSON.parse(output)
422
+ card_number = card_data.dig("data", "number")
423
+ card_url = card_data.dig("data", "url")
424
+ LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}"
425
+
426
+ # Assign to the appropriate agent
427
+ assign_zoho_triage_card(card_number, decision["assign_to"], spawn_env) if card_number && decision["assign_to"]
428
+
429
+ # Notify Discord
430
+ if channel_id && token
431
+ msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
432
+ msg += "**Title:** #{title}\n"
433
+ msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
434
+ msg += "**Tags:** #{tags.join(", ")}\n"
435
+ msg += "**From:** #{email["fromAddress"]}"
436
+ send_discord_message(channel_id, msg, token: token)
437
+ end
438
+ rescue StandardError => e
439
+ LOG.error "[Zoho:Triage] Error creating card: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
440
+ notify_zoho_match(email, { "label" => "Support Email", "emoji" => "🆘" }.merge(rule_defaults(nil)))
441
+ end
442
+
443
+ # Resolve tag names to IDs by querying Fizzy
444
+ def resolve_zoho_triage_tags(tag_names)
445
+ agent_env = agent_env_for("Threepio")
446
+ spawn_env = agent_env.empty? ? {} : agent_env
447
+
448
+ output, status = Open3.capture2e(spawn_env, "fizzy", "tag", "list", "--all")
449
+ return [] unless status.success?
450
+
451
+ all_tags = JSON.parse(output)["data"] || []
452
+ tag_names.filter_map do |name|
453
+ tag = all_tags.find { |t| t["title"].downcase == name.downcase }
454
+ tag&.dig("id")
455
+ end
456
+ rescue StandardError => e
457
+ LOG.warn "[Zoho:Triage] Failed to resolve tags: #{e.message}"
458
+ []
459
+ end
460
+
461
+ # Assign a card to the appropriate agent
462
+ def assign_zoho_triage_card(card_number, agent_name, spawn_env)
463
+ # Map agent names to Fizzy user IDs
464
+ agent_user_ids = {
465
+ "Galen" => "03fja52opiykf0mua7aeqv8uk",
466
+ "Avon" => "03fnwe6kl4g2t8xw0djbfkv96",
467
+ "Sheogorath" => "03fnwjyt6gighy98ld46u2hni"
468
+ }
469
+
470
+ user_id = agent_user_ids[agent_name]
471
+ unless user_id
472
+ LOG.warn "[Zoho:Triage] Unknown agent for assignment: #{agent_name}"
473
+ return
474
+ end
475
+
476
+ output, status = Open3.capture2e(spawn_env, "fizzy", "card", "assign", card_number.to_s, "--user", user_id)
477
+ if status.success?
478
+ LOG.info "[Zoho:Triage] Assigned card ##{card_number} to #{agent_name}"
479
+ else
480
+ LOG.warn "[Zoho:Triage] Failed to assign card ##{card_number}: #{output}"
481
+ end
482
+ end
483
+
484
+ def rule_defaults(rule)
485
+ { "discord_channel_id" => rule&.dig("discord_channel_id") || ZOHO_CONFIG["default_discord_channel_id"],
486
+ "notify_as" => rule&.dig("notify_as") || ZOHO_CONFIG["notify_as"] }
487
+ end