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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +1166 -0
- data/Rakefile +12 -0
- data/bin/zillacore +1521 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/user_registry.rb +159 -0
- data/lib/zillacore/agents.rb +203 -0
- data/lib/zillacore/brain.rb +197 -0
- data/lib/zillacore/card_index.rb +389 -0
- data/lib/zillacore/config.rb +263 -0
- data/lib/zillacore/cron.rb +629 -0
- data/lib/zillacore/deployments.rb +258 -0
- data/lib/zillacore/handlers/discord.rb +1643 -0
- data/lib/zillacore/handlers/fizzy.rb +1249 -0
- data/lib/zillacore/handlers/github.rb +598 -0
- data/lib/zillacore/handlers/zoho.rb +487 -0
- data/lib/zillacore/helpers.rb +760 -0
- data/lib/zillacore/planning.rb +237 -0
- data/lib/zillacore/prompts.rb +620 -0
- data/lib/zillacore/sessions.rb +282 -0
- data/lib/zillacore/skills.rb +276 -0
- data/lib/zillacore/users.rb +76 -0
- data/lib/zillacore/version.rb +6 -0
- data/lib/zillacore/zoho_mail_api.rb +109 -0
- data/lib/zillacore.rb +10 -0
- data/monitor/daemon.rb +99 -0
- data/monitor/deploy-env-macos.rb +131 -0
- data/monitor/menubar.rb +295 -0
- data/monitor/open-action.sh +15 -0
- data/monitor/setup-menubar.rb +78 -0
- data/monitor/setup-waybar-deploy-envs.rb +121 -0
- data/monitor/setup-waybar-deployments.rb +96 -0
- data/monitor/setup-waybar-module.rb +113 -0
- data/monitor/setup-xbar-plugin.rb +35 -0
- data/monitor/view-logs-macos.rb +210 -0
- data/monitor/view-logs-rofi.rb +194 -0
- data/monitor/view-logs.rb +119 -0
- data/monitor/waybar-config-updater.rb +56 -0
- data/monitor/waybar-deploy-env.rb +206 -0
- data/monitor/waybar-deployments.rb +239 -0
- data/monitor/waybar.rb +146 -0
- data/monitor/xbar.3s.rb +179 -0
- data/receiver.rb +956 -0
- data/templates/agents.json.example +10 -0
- data/templates/discord.json.example +17 -0
- data/templates/fizzy.json.example +24 -0
- data/templates/github.json.example +4 -0
- data/templates/testflight.json.example +8 -0
- data/templates/users.json.example +121 -0
- data/templates/zoho.json.example +27 -0
- data/views/dashboard.erb +437 -0
- data/zillacore.gemspec +30 -0
- data.tar.gz.sig +2 -0
- metadata +235 -0
- 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(/ /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
|