@zibby/workflow-templates 0.3.0 ā 0.4.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.
- package/browser-test-automation/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/browser-test-automation/package.json +1 -0
- package/code-analysis/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/code-analysis/nodes/generate-code-node.js +12 -2
- package/index.js +136 -0
- package/notify-lark/README.md +88 -0
- package/notify-lark/graph.mjs +43 -0
- package/notify-lark/icon.png +0 -0
- package/notify-lark/nodes/notify-lark-node.js +290 -0
- package/notify-lark/package.json +18 -0
- package/notify-lark/state.js +75 -0
- package/notify-slack/README.md +94 -0
- package/notify-slack/graph.mjs +51 -0
- package/notify-slack/icon.png +0 -0
- package/notify-slack/nodes/notify-slack-node.js +238 -0
- package/notify-slack/package.json +18 -0
- package/notify-slack/state.js +93 -0
- package/package.json +12 -3
- package/sentry-triage/graph.mjs +81 -0
- package/sentry-triage/icon.png +0 -0
- package/sentry-triage/nodes/classify-node.js +38 -0
- package/sentry-triage/nodes/dispatch-alerts-node.js +191 -0
- package/sentry-triage/nodes/fetch-issues-node.js +52 -0
- package/sentry-triage/nodes/filter-noise-node.js +112 -0
- package/sentry-triage/package.json +18 -0
- package/sentry-triage/prompts/classify.md +76 -0
- package/sentry-triage/prompts/fetch-issues.md +66 -0
- package/sentry-triage/state.js +134 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"4.1.5","results":[[":__tests__/preflight-early-exit.test.mjs",{"duration":6.5747499999999945,"failed":false}]]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"4.1.5","results":[[":nodes/__tests__/middleware.integration.test.js",{"duration":0,"failed":true}],[":nodes/__tests__/finalizeNode.test.js",{"duration":8.396791000000007,"failed":false}]]}
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { spawn } from 'child_process';
|
|
8
|
-
import { join, resolve } from 'path';
|
|
8
|
+
import { dirname, join, resolve } from 'path';
|
|
9
9
|
import { existsSync, readFileSync } from 'fs';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
10
11
|
import Handlebars from 'handlebars';
|
|
11
12
|
import { invokeAgent } from '@zibby/core';
|
|
12
13
|
import { generatePRMeta } from './services/prMetaService.js';
|
|
@@ -14,6 +15,14 @@ import { adfToText } from '@zibby/core/utils/adf-converter.js';
|
|
|
14
15
|
import { getRepoPath } from './utils/get-repo-path.js';
|
|
15
16
|
import { z } from 'zod';
|
|
16
17
|
|
|
18
|
+
// Prompts ship inside the workflow bundle at `<template>/prompts/`.
|
|
19
|
+
// state.promptsDir is the runner-injection slot for callers that want
|
|
20
|
+
// to override (legacy analyze-graph CLI set it explicitly); when unset,
|
|
21
|
+
// resolve relative to this node file so the template is self-contained
|
|
22
|
+
// and works for any runner.
|
|
23
|
+
const __nodeDir = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const DEFAULT_PROMPTS_DIR = join(__nodeDir, '..', 'prompts');
|
|
25
|
+
|
|
17
26
|
const CodeImplementationOutputSchema = z.object({
|
|
18
27
|
success: z.boolean(),
|
|
19
28
|
codeImplementation: z.object({
|
|
@@ -60,7 +69,8 @@ export function createCodeGenerationNode(options = {}) {
|
|
|
60
69
|
const mode = commitAndPush ? 'implementing' : 'generating preview of';
|
|
61
70
|
console.log(`\nš» ${commitAndPush ? 'Implementing' : 'Generating'} code implementation...`);
|
|
62
71
|
|
|
63
|
-
const { workspace, ticketContext, repos,
|
|
72
|
+
const { workspace, ticketContext, repos, model, nodeConfigs = {} } = state;
|
|
73
|
+
const promptsDir = state.promptsDir || DEFAULT_PROMPTS_DIR;
|
|
64
74
|
const aiModel = model || ticketContext.model || 'auto';
|
|
65
75
|
const _nodeConfig = nodeConfigs[nodeName] || {};
|
|
66
76
|
const analysis = state.analyze_ticket?.analysis;
|
package/index.js
CHANGED
|
@@ -165,6 +165,142 @@ export const TEMPLATES = {
|
|
|
165
165
|
'Produce specs that an AI agent can execute end-to-end',
|
|
166
166
|
],
|
|
167
167
|
},
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// āā notify-slack: reusable notifier child workflow āāāāāāāāāāāāāāāā
|
|
171
|
+
// Dispatched as a sub-graph from any parent that wants Slack alerts.
|
|
172
|
+
// Single-node graph, no LLM, deterministic API call.
|
|
173
|
+
'notify-slack': {
|
|
174
|
+
name: 'notify-slack',
|
|
175
|
+
displayName: 'Notify Slack',
|
|
176
|
+
description: 'Reusable child workflow ā posts a structured Block Kit alert to a Slack channel. Dispatched by other workflows (Sentry triage, autofix, incident) via sub-graph.',
|
|
177
|
+
path: join(__dirname, 'notify-slack'),
|
|
178
|
+
defaultSlug: 'alert-slack',
|
|
179
|
+
deps: { zod: '^3.23.0' },
|
|
180
|
+
features: [
|
|
181
|
+
'Single-node, no LLM ā deterministic ~500ms post',
|
|
182
|
+
'Block Kit message with severity-coded color + emoji',
|
|
183
|
+
'Optional Sentry-flavored fields (users affected, events, release)',
|
|
184
|
+
'Action buttons + caller-supplied @-mentions',
|
|
185
|
+
'Returns messageTs so parent can thread follow-ups',
|
|
186
|
+
],
|
|
187
|
+
marketplace: {
|
|
188
|
+
slug: 'notify-slack',
|
|
189
|
+
tagline: 'Reusable Slack alert worker ā dispatch from any workflow.',
|
|
190
|
+
iconPrompt: [
|
|
191
|
+
'Flat geometric vector illustration with subtle clean gradients, in the spirit of Linear / Notion / Stripe iconography ā crisp, no painterly textures.',
|
|
192
|
+
'Subject: the iconic Slack pinwheel mark ā four chunky rounded-rectangle "petals" arranged in a plus / asterisk configuration, colored with Slack\'s signature palette (top-left red #E01E5A, top-right yellow #ECB22E, bottom-right green #2EB67D, bottom-left blue #36C5F0), painted as if it\'s the focal brand mark of the icon. A small bright magenta notification dot floats in the upper-right corner of the pinwheel suggesting an incoming alert/ping.',
|
|
193
|
+
'Background: deep navy (#0B0F1A) rounded square (1024Ć1024) with a faint radial glow centered behind the pinwheel so the colors pop without becoming oversaturated.',
|
|
194
|
+
'Centered composition with the pinwheel as the dominant focal element, the notification dot as a small secondary accent in the upper-right; plenty of breathing room so the silhouette reads at 64Ć64 in the marketplace grid.',
|
|
195
|
+
'Mood: focused, energetic, signal-not-noise ā the canonical Slack-flavored notification worker.',
|
|
196
|
+
'NO text, NO letters, NO photo-realism, NO sleek 3D render, NO literal Slack wordmark ā the colored pinwheel shape is allowed as the brand reference.',
|
|
197
|
+
].join('\n'),
|
|
198
|
+
category: 'Notifications',
|
|
199
|
+
tags: ['slack', 'notification', 'alert', 'child-workflow'],
|
|
200
|
+
capabilities: [
|
|
201
|
+
'Severity-coded Block Kit message (low/medium/high/critical)',
|
|
202
|
+
'Code snippet + action button + caller mentions',
|
|
203
|
+
'Sub-graph dispatchable from any parent workflow',
|
|
204
|
+
'Returns messageTs for thread replies',
|
|
205
|
+
],
|
|
206
|
+
conversationStarters: [
|
|
207
|
+
'Post a CRITICAL alert to #incidents from this workflow',
|
|
208
|
+
'Send a daily summary to #dev-updates',
|
|
209
|
+
'Notify @oncall when a Sentry issue exceeds threshold',
|
|
210
|
+
'Forward the deploy-fail message to #ops',
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
// āā notify-lark: same as notify-slack, Lark / Feishu variant āāāāā
|
|
216
|
+
'notify-lark': {
|
|
217
|
+
name: 'notify-lark',
|
|
218
|
+
displayName: 'Notify Lark',
|
|
219
|
+
description: 'Reusable child workflow ā posts a structured Interactive Card to a Lark / Feishu chat. Dispatched by other workflows via sub-graph.',
|
|
220
|
+
path: join(__dirname, 'notify-lark'),
|
|
221
|
+
defaultSlug: 'alert-lark',
|
|
222
|
+
deps: { zod: '^3.23.0' },
|
|
223
|
+
features: [
|
|
224
|
+
'Single-node, no LLM',
|
|
225
|
+
'Lark Interactive Card with severity template (red/orange/yellow/grey)',
|
|
226
|
+
'Optional Sentry-flavored metadata fields',
|
|
227
|
+
'Token cache across multiple sends in one task',
|
|
228
|
+
'Returns messageId for threaded replies',
|
|
229
|
+
],
|
|
230
|
+
marketplace: {
|
|
231
|
+
slug: 'notify-lark',
|
|
232
|
+
tagline: 'Reusable Lark / Feishu alert worker ā dispatch from any workflow.',
|
|
233
|
+
iconPrompt: [
|
|
234
|
+
'A clean, modern app icon for a Lark / Feishu notification worker.',
|
|
235
|
+
'',
|
|
236
|
+
'Visual style: flat geometric vector with subtle gradient, complementary to the notify-slack icon (same family).',
|
|
237
|
+
'Subject: a stylized speech-bubble silhouette in Lark-cyan-to-blue gradient (#00D6B9 ā #1664FF) with a checkmark inside. Gentle motion lines behind it.',
|
|
238
|
+
'Background: deep navy (#0B0F1A) rounded square (1024Ć1024).',
|
|
239
|
+
'Mood: focused, professional, signal-not-noise.',
|
|
240
|
+
'NO Lark / Feishu logo trademark, NO text, NO photo-realism.',
|
|
241
|
+
].join('\n'),
|
|
242
|
+
category: 'Notifications',
|
|
243
|
+
tags: ['lark', 'feishu', 'notification', 'alert', 'child-workflow'],
|
|
244
|
+
capabilities: [
|
|
245
|
+
'Severity-coded Lark Interactive Card',
|
|
246
|
+
'Auto-detects receive_id_type from id prefix (chat_id / open_id / email)',
|
|
247
|
+
'Sub-graph dispatchable from any parent workflow',
|
|
248
|
+
'Per-process token cache for fan-out efficiency',
|
|
249
|
+
],
|
|
250
|
+
conversationStarters: [
|
|
251
|
+
'Send a CRITICAL alert to the engineering Lark group',
|
|
252
|
+
'Notify the on-call group chat when Sentry issue spikes',
|
|
253
|
+
'Forward deploy notifications to our Lark channel',
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
// āā sentry-triage: parent workflow that uses notify-slack/-lark āā
|
|
259
|
+
'sentry-triage': {
|
|
260
|
+
name: 'sentry-triage',
|
|
261
|
+
displayName: 'Sentry Triage Bot',
|
|
262
|
+
description: 'Hourly Sentry triage ā pulls new issues, drops obvious noise with a regex pre-filter, classifies survivors with LLM (CRITICAL/HIGH/MEDIUM/LOW/NOISE), and fans out alerts to a notify-slack OR notify-lark child workflow.',
|
|
263
|
+
path: join(__dirname, 'sentry-triage'),
|
|
264
|
+
defaultSlug: 'sentry-triage',
|
|
265
|
+
deps: { zod: '^3.23.0' },
|
|
266
|
+
features: [
|
|
267
|
+
'4-node graph: fetch ā filter_noise ā classify ā dispatch_alerts',
|
|
268
|
+
'Regex noise filter before LLM cuts ~80% of classification cost',
|
|
269
|
+
'LLM severity classifier with explicit rubric (rules 1-5)',
|
|
270
|
+
'Sub-graph fan-out to notify-slack OR notify-lark (choose at deploy)',
|
|
271
|
+
'Per-issue failure isolation ā one Slack hiccup doesn\'t stall the run',
|
|
272
|
+
'Configurable severityThreshold (don\'t notify on LOW noise)',
|
|
273
|
+
'Cron-friendly: hourly schedule, default sinceMinutes=60',
|
|
274
|
+
],
|
|
275
|
+
marketplace: {
|
|
276
|
+
slug: 'sentry-triage',
|
|
277
|
+
tagline: 'Filter noise, classify severity, ping the right channel ā every hour.',
|
|
278
|
+
iconPrompt: [
|
|
279
|
+
'Hand-painted storybook illustration in a warm gouache style with soft brushwork and gentle painterly texture, featuring the friendly round lighthouse mascot character with two big smiling eyes and a rosy blush on its white-and-coral-striped tower body, perched on a tiny mint-green island and clutching a small glowing purple SHIELD BADGE in front of its body ā the badge is a rounded geometric emblem in Sentry\'s signature deep violet (#362D59 / #7553FF) with a stylized white "S"-mark inside it formed from overlapping rounded parallelogram shapes, painted with the same soft gouache brushstrokes as the rest of the scene so it feels integrated rather than corporate.',
|
|
280
|
+
'The lighthouse lantern emits a soft golden beam that catches one glowing amber alert orb while three faded grey noise specks drift harmlessly past, reinforcing the "filter the signal, calm the noise" idea.',
|
|
281
|
+
'Background is a soft sunrise gradient of pale peach at the top blending through buttercream into a gentle wash of dusty lavender at the base, tying the warm scene to the violet of the badge; a few small fluffy pastel clouds float in for friendliness.',
|
|
282
|
+
'Centered composition with the purple shield badge as the immediate focal point in the lower-center, the lighthouse rising behind and slightly above it, beam angled diagonally; plenty of breathing room so the silhouette reads at 64Ć64 with the violet badge clearly visible at a glance.',
|
|
283
|
+
'Mood is warm, reassuring, optimistic ā the friendly Sentry-flavored night-watch character, NOT tactical or corporate or alarming.',
|
|
284
|
+
'Soft rounded square 1024Ć1024 canvas with a subtle paper-grain texture.',
|
|
285
|
+
'NO text, NO letters, NO photo-realism, NO sleek 3D render, NO magnifying glass, NO speech bubbles, NO dark navy or near-black backgrounds, NO bug or insect imagery, NO literal Sentry wordmark.',
|
|
286
|
+
].join('\n'),
|
|
287
|
+
category: 'Operations',
|
|
288
|
+
tags: ['sentry', 'observability', 'on-call', 'triage', 'alerting'],
|
|
289
|
+
capabilities: [
|
|
290
|
+
'Hourly scheduled triage of new Sentry issues',
|
|
291
|
+
'Deterministic regex filter drops Script error / ResizeObserver / extension noise',
|
|
292
|
+
'LLM severity classifier with auditable rubric',
|
|
293
|
+
'Dispatches to notify-slack or notify-lark (sub-graph, ~5ms in-process)',
|
|
294
|
+
'CRITICAL alerts get caller-supplied @-mentions; lower severities don\'t',
|
|
295
|
+
'Configurable severity threshold per deploy',
|
|
296
|
+
],
|
|
297
|
+
conversationStarters: [
|
|
298
|
+
'Triage all new Sentry issues from the last hour',
|
|
299
|
+
'Notify #sentry-alerts when severity is HIGH or above',
|
|
300
|
+
'Run hourly and post a summary to our team Slack',
|
|
301
|
+
'Page on-call when a CRITICAL error appears in checkout',
|
|
302
|
+
],
|
|
303
|
+
},
|
|
168
304
|
}
|
|
169
305
|
};
|
|
170
306
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# notify-lark
|
|
2
|
+
|
|
3
|
+
A reusable **child workflow** that posts an Interactive Card alert to a Lark / Feishu chat.
|
|
4
|
+
|
|
5
|
+
Companion to `notify-slack` ā same provider-neutral input shape, so a parent workflow can fan out to BOTH Slack and Lark with the same `input` block (just swap `channel` ā `receiveId`).
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Takes a provider-neutral payload (severity, title, body, optional Sentry-flavored context)
|
|
10
|
+
- Builds a Lark Interactive Card with severity-coded header template + emoji + action buttons
|
|
11
|
+
- Resolves `tenant_access_token` from the app id/secret stored in your Lark integration
|
|
12
|
+
- POSTs to `/open-apis/im/v1/messages` with the right `receive_id_type` inferred from the id prefix
|
|
13
|
+
|
|
14
|
+
No LLM call ā single deterministic API request, typically <500ms (or <1s on cold token cache).
|
|
15
|
+
|
|
16
|
+
## Dispatch shape (parent workflow)
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
graph.addNode('alert', {
|
|
20
|
+
workflow: 'notify-lark',
|
|
21
|
+
async: false,
|
|
22
|
+
input: (state) => ({
|
|
23
|
+
severity: 'critical',
|
|
24
|
+
title: 'Checkout: TypeError on session.user.id',
|
|
25
|
+
body: '**12 users** affected in the last 1h. Likely regression from `1.42.0`.',
|
|
26
|
+
receiveId: 'oc_abc123def456...', // chat id, open id, or email
|
|
27
|
+
sentryLink: 'https://sentry.io/.../1234567890/',
|
|
28
|
+
affectedUsers: 12,
|
|
29
|
+
events: 47,
|
|
30
|
+
release: '1.42.0',
|
|
31
|
+
firstSeen: '8 min ago',
|
|
32
|
+
codeSnippet:'src/handlers/checkout.ts:142\nconst userId = session.user.id;',
|
|
33
|
+
mentions: ['<at user_id="ou_oncall_group">@backend-oncall</at>'],
|
|
34
|
+
}),
|
|
35
|
+
output: 'notify_lark.messageId',
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## receiveId formats
|
|
40
|
+
|
|
41
|
+
The `receive_id_type` query param is inferred from the prefix:
|
|
42
|
+
|
|
43
|
+
| Prefix | Type | Description |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `oc_` | `chat_id` | Group chat or DM (most common) |
|
|
46
|
+
| `ou_` | `open_id` | Direct message to a specific user |
|
|
47
|
+
| `on_` | `union_id` | Cross-app stable user id |
|
|
48
|
+
| `cli_` | `app_id` | App-to-app message |
|
|
49
|
+
| `<email>@ā¦` | `email` | Send to user by email |
|
|
50
|
+
|
|
51
|
+
If you don't know your chat id, DM the bot `whoami` and it'll respond with the caller's `open_id`. Group chat ids appear in the URL when you open the chat in browser.
|
|
52
|
+
|
|
53
|
+
## Output
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
{ delivered: true, receiveId: 'oc_abc...', receiveIdType: 'chat_id', messageId: 'om_xxxxx' }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The `messageId` lets the parent post threaded replies later (e.g. incident-commander progress updates).
|
|
60
|
+
|
|
61
|
+
## Prerequisites
|
|
62
|
+
|
|
63
|
+
Project must have the **Lark** integration connected with:
|
|
64
|
+
- `im:message` scope (send messages)
|
|
65
|
+
- `im:message.group_msg` scope (send to group chats)
|
|
66
|
+
|
|
67
|
+
## Severity ā header template
|
|
68
|
+
|
|
69
|
+
| Severity | Lark template | Emoji |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| low | `grey` | āŖ |
|
|
72
|
+
| medium | `yellow` | š” |
|
|
73
|
+
| high | `orange` | š |
|
|
74
|
+
| critical | `red` | šØ |
|
|
75
|
+
|
|
76
|
+
## Tests
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
cd packages/workflow-templates/notify-lark
|
|
80
|
+
npm test
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Tests cover:
|
|
84
|
+
- Card rendering for each severity
|
|
85
|
+
- Conditional sections (fields/body/code/actions only when input present)
|
|
86
|
+
- receive_id_type inference per prefix
|
|
87
|
+
- Tenant token caching across multiple sends
|
|
88
|
+
- Error mapping (invalid receive_id, scope missing, network blip)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-lark ā single-node child workflow.
|
|
3
|
+
*
|
|
4
|
+
* Companion to notify-slack; same provider-neutral input shape so a
|
|
5
|
+
* parent can `dispatchSubgraph('notify-slack')` AND
|
|
6
|
+
* `dispatchSubgraph('notify-lark')` with the same input block, just
|
|
7
|
+
* swapping `channel` ā `receiveId`. (See notify-slack/graph.mjs for the
|
|
8
|
+
* Slack-flavored example.)
|
|
9
|
+
*
|
|
10
|
+
* Returns `{ delivered, receiveId, receiveIdType, messageId }`. The
|
|
11
|
+
* `messageId` can be used for thread replies via Lark's reply API in
|
|
12
|
+
* follow-up dispatches.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { WorkflowAgent, WorkflowGraph } from '@zibby/core';
|
|
16
|
+
import { notifyLarkNode } from './nodes/notify-lark-node.js';
|
|
17
|
+
import {
|
|
18
|
+
notifyLarkInputSchema,
|
|
19
|
+
notifyLarkContextSchema,
|
|
20
|
+
} from './state.js';
|
|
21
|
+
|
|
22
|
+
export class NotifyLarkAgent extends WorkflowAgent {
|
|
23
|
+
buildGraph() {
|
|
24
|
+
const graph = new WorkflowGraph();
|
|
25
|
+
graph
|
|
26
|
+
.setInputSchema(notifyLarkInputSchema)
|
|
27
|
+
.setContextSchema(notifyLarkContextSchema);
|
|
28
|
+
|
|
29
|
+
graph.addNode('notify_lark', notifyLarkNode);
|
|
30
|
+
graph.setEntryPoint('notify_lark');
|
|
31
|
+
graph.addEdge('notify_lark', 'END');
|
|
32
|
+
|
|
33
|
+
return graph;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async onComplete(result) {
|
|
37
|
+
const delivered = !!result?.state?.notify_lark?.delivered;
|
|
38
|
+
const mid = result?.state?.notify_lark?.messageId || '?';
|
|
39
|
+
console.log(`[notify-lark] ${delivered ? 'delivered' : 'failed'} (messageId=${mid})`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default NotifyLarkAgent;
|
|
Binary file
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-lark node ā deterministic, no LLM.
|
|
3
|
+
*
|
|
4
|
+
* Sends a Lark Interactive Card via /open-apis/im/v1/messages. Same
|
|
5
|
+
* design philosophy as notify-slack: parent makes all the decisions
|
|
6
|
+
* (severity, title, body, who to mention), this node just renders the
|
|
7
|
+
* card and POSTs it.
|
|
8
|
+
*
|
|
9
|
+
* Auth differs from Slack:
|
|
10
|
+
* - resolveIntegrationToken('lark') returns { appId, appSecret, host }
|
|
11
|
+
* not a ready-to-use bearer token.
|
|
12
|
+
* - We exchange app credentials for a `tenant_access_token` via
|
|
13
|
+
* /open-apis/auth/v3/tenant_access_token/internal, then use that as
|
|
14
|
+
* Bearer auth on the message send. Same flow @zibby/skills/lark.js
|
|
15
|
+
* uses; we reimplement here so the node stays decoupled from the
|
|
16
|
+
* MCP-tool path (which only the LLM nodes use).
|
|
17
|
+
*
|
|
18
|
+
* The token has ~2h TTL ā we cache per-process so a parent dispatching
|
|
19
|
+
* many alerts only pays the auth round-trip once.
|
|
20
|
+
*
|
|
21
|
+
* Severity ā Lark header template (named themes, not hex):
|
|
22
|
+
* low ā grey
|
|
23
|
+
* medium ā yellow
|
|
24
|
+
* high ā orange
|
|
25
|
+
* critical ā red
|
|
26
|
+
*
|
|
27
|
+
* The Lark API quirk: `content` on the message body must be a
|
|
28
|
+
* JSON-encoded STRING (not the object itself). Easy to miss.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
import { SKILLS } from '@zibby/core';
|
|
33
|
+
import { resolveIntegrationToken } from '@zibby/core/backend-client.js';
|
|
34
|
+
|
|
35
|
+
const SEVERITY_TEMPLATE = Object.freeze({
|
|
36
|
+
low: 'grey',
|
|
37
|
+
medium: 'yellow',
|
|
38
|
+
high: 'orange',
|
|
39
|
+
critical: 'red',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const SEVERITY_EMOJI = Object.freeze({
|
|
43
|
+
low: 'āŖ',
|
|
44
|
+
medium: 'š”',
|
|
45
|
+
high: 'š ',
|
|
46
|
+
critical: 'šØ',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const NotifyLarkOutputSchema = z.object({
|
|
50
|
+
delivered: z.boolean(),
|
|
51
|
+
receiveId: z.string(),
|
|
52
|
+
receiveIdType: z.string(),
|
|
53
|
+
messageId: z.string().optional(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the Lark Interactive Card body. Returns the card OBJECT (not
|
|
58
|
+
* the JSON-stringified form the API expects ā callers stringify it).
|
|
59
|
+
* Exposed for unit tests so we can pin the rendered shape.
|
|
60
|
+
*/
|
|
61
|
+
export function buildLarkCard(input) {
|
|
62
|
+
const {
|
|
63
|
+
severity = 'medium',
|
|
64
|
+
title,
|
|
65
|
+
body,
|
|
66
|
+
sentryLink,
|
|
67
|
+
affectedUsers,
|
|
68
|
+
events,
|
|
69
|
+
release,
|
|
70
|
+
firstSeen,
|
|
71
|
+
codeSnippet,
|
|
72
|
+
actionUrl,
|
|
73
|
+
actionLabel,
|
|
74
|
+
mentions,
|
|
75
|
+
} = input;
|
|
76
|
+
|
|
77
|
+
const elements = [];
|
|
78
|
+
|
|
79
|
+
// Metadata grid ā 2-column layout via `is_short: true` on each field.
|
|
80
|
+
const fields = [];
|
|
81
|
+
if (typeof affectedUsers === 'number') {
|
|
82
|
+
fields.push({
|
|
83
|
+
is_short: true,
|
|
84
|
+
text: { tag: 'lark_md', content: `**Users affected**\n${affectedUsers}` },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (typeof events === 'number') {
|
|
88
|
+
fields.push({
|
|
89
|
+
is_short: true,
|
|
90
|
+
text: { tag: 'lark_md', content: `**Events**\n${events}` },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (release) {
|
|
94
|
+
fields.push({
|
|
95
|
+
is_short: true,
|
|
96
|
+
text: { tag: 'lark_md', content: `**Release**\n\`${release}\`` },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (firstSeen) {
|
|
100
|
+
fields.push({
|
|
101
|
+
is_short: true,
|
|
102
|
+
text: { tag: 'lark_md', content: `**First seen**\n${firstSeen}` },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (fields.length > 0) {
|
|
106
|
+
elements.push({ tag: 'div', fields });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Body ā rendered as lark_md (Lark's markdown variant).
|
|
110
|
+
if (body && body.trim().length > 0) {
|
|
111
|
+
elements.push({
|
|
112
|
+
tag: 'div',
|
|
113
|
+
text: { tag: 'lark_md', content: body },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Code snippet ā fenced triple-backtick block.
|
|
118
|
+
if (codeSnippet) {
|
|
119
|
+
elements.push({
|
|
120
|
+
tag: 'div',
|
|
121
|
+
text: { tag: 'lark_md', content: '```\n' + codeSnippet + '\n```' },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Action buttons ā same logic as Slack: primary "Open in Sentry"
|
|
126
|
+
// when sentryLink set, plus secondary actionUrl/Label pair if both
|
|
127
|
+
// supplied.
|
|
128
|
+
const actions = [];
|
|
129
|
+
if (sentryLink) {
|
|
130
|
+
actions.push({
|
|
131
|
+
tag: 'button',
|
|
132
|
+
text: { tag: 'plain_text', content: 'Open in Sentry' },
|
|
133
|
+
type: 'primary',
|
|
134
|
+
url: sentryLink,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (actionUrl && actionLabel) {
|
|
138
|
+
actions.push({
|
|
139
|
+
tag: 'button',
|
|
140
|
+
text: { tag: 'plain_text', content: actionLabel.slice(0, 40) },
|
|
141
|
+
type: 'default',
|
|
142
|
+
url: actionUrl,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (actions.length > 0) {
|
|
146
|
+
elements.push({ tag: 'action', actions });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Mentions ā small-text note at bottom. Caller pre-formatted as
|
|
150
|
+
// `<at user_id="ou_xxx">@name</at>` strings.
|
|
151
|
+
if (Array.isArray(mentions) && mentions.length > 0) {
|
|
152
|
+
elements.push({
|
|
153
|
+
tag: 'note',
|
|
154
|
+
elements: [{ tag: 'lark_md', content: mentions.join(' ') }],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const emoji = SEVERITY_EMOJI[severity] || SEVERITY_EMOJI.medium;
|
|
159
|
+
const template = SEVERITY_TEMPLATE[severity] || SEVERITY_TEMPLATE.medium;
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
config: { wide_screen_mode: true },
|
|
163
|
+
header: {
|
|
164
|
+
template,
|
|
165
|
+
title: {
|
|
166
|
+
tag: 'plain_text',
|
|
167
|
+
content: `${emoji} ${severity.toUpperCase()} ${title}`.slice(0, 150),
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
elements,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Infer the Lark receive_id_type query param from the id prefix.
|
|
176
|
+
* Mirrors @zibby/skills/lark.js's inferReceiveIdType ā duplicated to
|
|
177
|
+
* keep the node self-contained.
|
|
178
|
+
*/
|
|
179
|
+
export function inferReceiveIdType(id) {
|
|
180
|
+
if (!id || typeof id !== 'string') return 'chat_id';
|
|
181
|
+
if (id.startsWith('oc_')) return 'chat_id';
|
|
182
|
+
if (id.startsWith('ou_')) return 'open_id';
|
|
183
|
+
if (id.startsWith('on_')) return 'union_id';
|
|
184
|
+
if (id.startsWith('cli_')) return 'app_id';
|
|
185
|
+
if (id.includes('@')) return 'email';
|
|
186
|
+
return 'chat_id';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Per-process cache. Tenant access tokens last ~2h; we cache 100m to
|
|
190
|
+
// avoid the boundary case where a 119-min-old token is rejected mid-call.
|
|
191
|
+
const TOKEN_TTL_MS = 100 * 60 * 1000;
|
|
192
|
+
let _tenantTokenCache = null;
|
|
193
|
+
|
|
194
|
+
async function getTenantAccessToken() {
|
|
195
|
+
const { appId, appSecret, host } = await resolveIntegrationToken('lark');
|
|
196
|
+
if (
|
|
197
|
+
_tenantTokenCache
|
|
198
|
+
&& _tenantTokenCache.appId === appId
|
|
199
|
+
&& _tenantTokenCache.expiresAt > Date.now()
|
|
200
|
+
) {
|
|
201
|
+
return { token: _tenantTokenCache.token, host };
|
|
202
|
+
}
|
|
203
|
+
const url = `${host}/open-apis/auth/v3/tenant_access_token/internal`;
|
|
204
|
+
const res = await fetch(url, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: { 'Content-Type': 'application/json' },
|
|
207
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
208
|
+
});
|
|
209
|
+
const data = await res.json().catch(() => ({}));
|
|
210
|
+
if (data.code !== 0) {
|
|
211
|
+
const e = new Error(`Lark tenant_access_token failed: ${data.msg || data.code}`);
|
|
212
|
+
e.code = data.code;
|
|
213
|
+
throw e;
|
|
214
|
+
}
|
|
215
|
+
_tenantTokenCache = {
|
|
216
|
+
appId,
|
|
217
|
+
token: data.tenant_access_token,
|
|
218
|
+
expiresAt: Date.now() + TOKEN_TTL_MS,
|
|
219
|
+
};
|
|
220
|
+
return { token: data.tenant_access_token, host };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Test hook ā resets the per-process token cache. */
|
|
224
|
+
export function _resetTokenCache() { _tenantTokenCache = null; }
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Send the Lark card. Exposed for unit tests so we can stub fetch +
|
|
228
|
+
* assert payload shape without going through token resolution.
|
|
229
|
+
*/
|
|
230
|
+
export async function postToLark({ token, host, receiveId, receiveIdType, card }) {
|
|
231
|
+
const url = `${host}/open-apis/im/v1/messages?receive_id_type=${encodeURIComponent(receiveIdType)}`;
|
|
232
|
+
const res = await fetch(url, {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: {
|
|
235
|
+
Authorization: `Bearer ${token}`,
|
|
236
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
receive_id: receiveId,
|
|
240
|
+
msg_type: 'interactive',
|
|
241
|
+
// Lark wants `content` as a JSON-encoded STRING, not an object.
|
|
242
|
+
content: JSON.stringify(card),
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
const data = await res.json().catch(() => ({}));
|
|
246
|
+
if (data.code !== 0) {
|
|
247
|
+
const e = new Error(`Lark send message failed: ${data.msg || `code ${data.code} / http ${res.status}`}`);
|
|
248
|
+
e.code = data.code || 'unknown';
|
|
249
|
+
e.status = res.status;
|
|
250
|
+
throw e;
|
|
251
|
+
}
|
|
252
|
+
return { messageId: data.data?.message_id };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const notifyLarkNode = {
|
|
256
|
+
name: 'notify_lark',
|
|
257
|
+
// Declares Lark as a hard requirement for marketplace gating (same
|
|
258
|
+
// pattern as notify-slack ā see that file's comment for rationale).
|
|
259
|
+
skills: [SKILLS.LARK],
|
|
260
|
+
outputSchema: NotifyLarkOutputSchema,
|
|
261
|
+
timeout: 15 * 1000,
|
|
262
|
+
execute: async (context) => {
|
|
263
|
+
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
264
|
+
? context.state.getAll()
|
|
265
|
+
: context;
|
|
266
|
+
|
|
267
|
+
const receiveId = state.receiveId;
|
|
268
|
+
if (!receiveId) {
|
|
269
|
+
throw new Error('notify-lark: input.receiveId is required');
|
|
270
|
+
}
|
|
271
|
+
const receiveIdType = inferReceiveIdType(receiveId);
|
|
272
|
+
|
|
273
|
+
const { token, host } = await getTenantAccessToken();
|
|
274
|
+
const card = buildLarkCard(state);
|
|
275
|
+
const { messageId } = await postToLark({
|
|
276
|
+
token,
|
|
277
|
+
host,
|
|
278
|
+
receiveId,
|
|
279
|
+
receiveIdType,
|
|
280
|
+
card,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
delivered: true,
|
|
285
|
+
receiveId,
|
|
286
|
+
receiveIdType,
|
|
287
|
+
messageId,
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "notify-lark",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Post a structured alert card to a Lark / Feishu chat ā child workflow, dispatched by parent workflows via sub-graph.",
|
|
7
|
+
"main": "graph.mjs",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@zibby/core": "^0.5.1",
|
|
13
|
+
"zod": "^3.23.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"vitest": "^2.1.5"
|
|
17
|
+
}
|
|
18
|
+
}
|