-
Notifications
You must be signed in to change notification settings - Fork 77
Expand file tree
/
Copy pathsummarizer_format_telegram.go
More file actions
310 lines (278 loc) · 9.09 KB
/
summarizer_format_telegram.go
File metadata and controls
310 lines (278 loc) · 9.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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
80
81
82
83
84
85
86
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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
package taskengine
import (
"fmt"
"html"
"strings"
)
// formatTelegramFromStructured formats Summary into Telegram HTML using structured data
// Uses the PRD format: emoji + subject, network, time, executions list, footer
// All user-controlled content is HTML-escaped to prevent XSS attacks
func formatTelegramFromStructured(s Summary) string {
var sb strings.Builder
// Get status emoji - API returns subject WITHOUT emoji, aggregator prepends it
statusEmoji := getStatusEmoji(s)
// Subject as header with emoji prefix - only bold the workflow name
if s.Subject != "" {
sb.WriteString(statusEmoji)
if statusEmoji != "" {
sb.WriteString(" ")
}
sb.WriteString(formatSubjectWithBoldName(s.Subject))
sb.WriteString("\n")
}
// Skipped note (success runs with branch-skipped steps) — surfaces why the
// header shows ⚠️ instead of ✅.
if s.SkippedNote != "" {
sb.WriteString("<i>")
sb.WriteString(html.EscapeString(s.SkippedNote))
sb.WriteString("</i>\n")
}
// Network: use body.network field, fallback to workflow.chain or derive from chainID
network := s.Network
if network == "" && s.Workflow != nil {
network = s.Workflow.Chain
if network == "" && s.Workflow.ChainID > 0 {
network = getChainDisplayName(s.Workflow.ChainID)
}
}
// Time / Runner / Cost — metadata block. Network is folded into the Runner
// line ("Runner: 0x… on Sepolia") to save a line, since chain is contextual
// to the wallet that ran the workflow. When Runner is absent we keep
// Network as a standalone line so chain context isn't lost.
// Cost follows Runner so the "who and what it cost" pair stays adjacent.
// For simulations the Cost line is a placeholder ("⛽ (cost estimated at
// deploy)") — actual gas numbers only appear for deployed runs with real
// receipts. Runner addresses are intentionally NOT <code>-wrapped (hex
// addresses don't trigger Telegram auto-linking; the wrap adds noise).
hasRunner := s.Runner != nil && s.Runner.SmartWallet != ""
costLine := formatTelegramCostLine(s)
if network != "" || s.TriggeredAt != "" || hasRunner || costLine != "" {
sb.WriteString("\n")
if s.TriggeredAt != "" {
sb.WriteString("<b>Time:</b> ")
sb.WriteString(html.EscapeString(formatTimestampHumanReadable(s.TriggeredAt)))
sb.WriteString("\n")
}
switch {
case hasRunner && network != "":
sb.WriteString("<b>Runner:</b> ")
sb.WriteString(html.EscapeString(truncateAddress(s.Runner.SmartWallet)))
sb.WriteString(" on ")
sb.WriteString(html.EscapeString(network))
sb.WriteString("\n")
case hasRunner:
sb.WriteString("<b>Runner:</b> ")
sb.WriteString(html.EscapeString(truncateAddress(s.Runner.SmartWallet)))
sb.WriteString("\n")
case network != "":
sb.WriteString("<b>Network:</b> ")
sb.WriteString(html.EscapeString(network))
sb.WriteString("\n")
}
if costLine != "" {
sb.WriteString(costLine)
}
}
// Trigger section
if s.Trigger != "" {
sb.WriteString("\n<b>Trigger:</b> ")
sb.WriteString(html.EscapeString(s.Trigger))
sb.WriteString("\n")
}
// Display executions with "Executed:" header (PRD format)
if len(s.Executions) > 0 {
sb.WriteString("\n<b>Executed:</b>\n")
for _, exec := range s.Executions {
sb.WriteString("• ")
sb.WriteString(formatBackticksForChannel(exec.Description, "telegram"))
sb.WriteString("\n")
if exec.TxHash != "" {
if explorerURL := buildTxExplorerURL(s, exec.TxHash); explorerURL != "" {
sb.WriteString(" Transaction: <a href=\"")
sb.WriteString(html.EscapeString(explorerURL))
sb.WriteString("\">")
sb.WriteString(html.EscapeString(truncateTxHash(exec.TxHash)))
sb.WriteString("</a>\n")
}
}
}
}
// "What Went Wrong" section — consistent with email
if len(s.Errors) > 0 {
sb.WriteString("\n<b>What Went Wrong:</b>\n")
for _, err := range s.Errors {
sb.WriteString("• ")
sb.WriteString(formatBackticksForChannel(err, "telegram"))
sb.WriteString("\n")
}
}
if s.Annotation != "" {
sb.WriteString("\n<i>")
sb.WriteString(html.EscapeString(s.Annotation))
sb.WriteString("</i>")
}
return strings.TrimSpace(sb.String())
}
// getStatusEmoji returns the emoji for the summary's status.
// A success run with branch-skipped steps renders ⚠️ (warn) rather than ✅,
// matching the yellow badge on email.
func getStatusEmoji(s Summary) string {
switch s.Status {
case "success":
if s.SkippedSteps > 0 {
return "⚠️"
}
return "✅"
case "failed", "error":
return "❌"
default:
return ""
}
}
// formatSubjectWithBoldName formats the subject with <code> tags around the
// prefix + workflow name (everything except the trailing status suffix).
// Subject patterns:
// - "Simulation: {name} successfully completed"
// - "Run Node: {name} succeeded"
// - "Run #N: {name} successfully completed"
// - "{name} successfully completed"
// - And similar patterns for "failed to execute" and "partially executed"
func formatSubjectWithBoldName(subject string) string {
// Suffixes to look for (ordered from most specific to least)
suffixes := []string{
" successfully completed",
" failed to execute",
" partially executed",
" succeeded",
}
// Prefixes to look for
prefixes := []string{
"Simulation: ",
"Run Node: ",
}
// Check for "Run #N: " prefix pattern
runPrefix := ""
if strings.HasPrefix(subject, "Run #") {
// Find the ": " after "Run #N"
if idx := strings.Index(subject, ": "); idx > 0 {
runPrefix = subject[:idx+2]
}
}
// Find which suffix matches
var suffix string
var nameEnd int
for _, s := range suffixes {
if strings.HasSuffix(subject, s) {
suffix = s
nameEnd = len(subject) - len(s)
break
}
}
// Check for deployed workflow format: "{name}: succeeded (...)" or "{name}: failed at ..."
// Must be checked before the generic " failed at " pattern to avoid splitting at the wrong point.
if suffix == "" {
for _, marker := range []string{": succeeded (", ": failed at "} {
if idx := strings.Index(subject, marker); idx > 0 {
suffix = subject[idx:]
nameEnd = idx
break
}
}
}
// Check for "failed at <stepName>" suffix (Run Node failure format)
if suffix == "" && strings.Contains(subject, " failed at ") {
idx := strings.LastIndex(subject, " failed at ")
if idx > 0 {
suffix = subject[idx:]
nameEnd = idx
}
}
// If no suffix found, just escape and return
if suffix == "" {
return html.EscapeString(subject)
}
// Find the prefix and extract the name
var prefix string
nameStart := 0
if runPrefix != "" {
prefix = runPrefix
nameStart = len(runPrefix)
} else {
for _, p := range prefixes {
if strings.HasPrefix(subject, p) {
prefix = p
nameStart = len(p)
break
}
}
}
// Extract the workflow name
name := subject[nameStart:nameEnd]
// Build the formatted string: <code>prefix + name</code> + suffix
// Using <code> prevents Telegram from auto-linking names that contain dots
var sb strings.Builder
sb.WriteString("<code>")
if prefix != "" {
sb.WriteString(html.EscapeString(prefix))
}
sb.WriteString(html.EscapeString(name))
sb.WriteString("</code>")
sb.WriteString(html.EscapeString(suffix))
return sb.String()
}
// formatTelegramCostLine renders a single Cost line from Summary.Fees.Total.
// Format: "⛽ <b>Cost:</b> 0.000003 ETH ($0.01), 1.2 USDC ($1.20)" — native
// token first, comma-separated, USD parenthetical per token. Unpriceable
// tokens render as "$?". For simulations the line collapses to the static
// "⛽ (see cost estimate before deploy)" placeholder. Returns "" when there's
// nothing to render.
func formatTelegramCostLine(s Summary) string {
if s.Workflow != nil && s.Workflow.IsSimulation {
return "⛽ <i>(see cost estimate before deploy)</i>\n"
}
if s.Fees == nil || len(s.Fees.Total) == 0 {
return ""
}
parts := make([]string, 0, len(s.Fees.Total))
for _, t := range s.Fees.Total {
if t == nil || t.Amount == "" || t.Amount == "0" {
continue
}
// USD-unit entries are the platform fee — render as "$X platform fee"
// (the dollar amount is already canonical; no need for the parenthetical).
if t.Unit == "USD" {
parts = append(parts, fmt.Sprintf("$%s platform fee", html.EscapeString(t.Amount)))
continue
}
usd := "$?"
if t.USD != "" {
usd = "$" + t.USD
}
parts = append(parts, fmt.Sprintf("%s %s (%s)", html.EscapeString(t.Amount), html.EscapeString(t.Unit), usd))
}
if len(parts) == 0 {
return ""
}
return "⛽ <b>Cost:</b> " + strings.Join(parts, ", ") + "\n"
}
func formatTelegramExampleMessage(workflowName, chainName string) string {
var sb strings.Builder
// Status line with emoji and workflow name (code-wrapped to prevent auto-linking)
sb.WriteString("✅ <code>")
sb.WriteString(html.EscapeString(workflowName))
sb.WriteString("</code> completed\n\n")
// Network
sb.WriteString("<b>Network:</b> ")
sb.WriteString(html.EscapeString(chainName))
sb.WriteString("\n\n")
// Executed section with example
sb.WriteString("<b>Executed:</b>\n")
sb.WriteString("• (Simulated) ")
sb.WriteString(ExampleExecutionMessage)
sb.WriteString("\n\n")
// Example notice
sb.WriteString("<i>")
sb.WriteString(ExampleExecutionAnnotation)
sb.WriteString("</i>")
return sb.String()
}