@zibby/workflow-templates 0.4.1 → 0.4.2
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/index.js +99 -0
- package/notify-lark/nodes/notify-lark-node.js +14 -1
- package/notify-lark/state.js +12 -2
- package/notify-notion/README.md +71 -0
- package/notify-notion/graph.mjs +64 -0
- package/notify-notion/icon.png +0 -0
- package/notify-notion/nodes/notify-notion-node.js +342 -0
- package/notify-notion/package.json +19 -0
- package/notify-notion/state.js +110 -0
- package/notify-slack/nodes/notify-slack-node.js +35 -5
- package/notify-slack/state.js +21 -2
- package/package.json +7 -2
package/index.js
CHANGED
|
@@ -255,6 +255,57 @@ export const TEMPLATES = {
|
|
|
255
255
|
},
|
|
256
256
|
},
|
|
257
257
|
|
|
258
|
+
// ── notify-notion: same as notify-slack/-lark, Notion variant ────
|
|
259
|
+
// Two write modes: create a new page in a database, or append blocks
|
|
260
|
+
// to an existing page. Rich-mode delegates to
|
|
261
|
+
// @zibby/skills/report's reportToNotionBlocks. See notify-notion/README.md.
|
|
262
|
+
'notify-notion': {
|
|
263
|
+
name: 'notify-notion',
|
|
264
|
+
displayName: 'Notify Notion',
|
|
265
|
+
description: 'Reusable child workflow — creates a Notion page in a database OR appends blocks to an existing page. Dispatched by other workflows (digest, incident archives, weekly reports) via sub-graph.',
|
|
266
|
+
path: join(__dirname, 'notify-notion'),
|
|
267
|
+
defaultSlug: 'archive-notion',
|
|
268
|
+
deps: { zod: '^3.23.0', '@zibby/skills': '^0.1.25' },
|
|
269
|
+
features: [
|
|
270
|
+
'Single-node, no LLM — deterministic ~1-2s Notion REST call',
|
|
271
|
+
'Two write modes: create page in database OR append children to existing page',
|
|
272
|
+
'Rich-mode renders report objects via @zibby/skills/report (heading, callouts, tables, trend code blocks)',
|
|
273
|
+
'Legacy-mode renders simple severity / title / body alerts (sentry-triage et al.)',
|
|
274
|
+
'Severity-mapped page-icon emoji (low/medium/high/critical or rich-mode delta severity)',
|
|
275
|
+
'Surfaces typed errors for 401 (token revoked), 404 (target not shared with bot), 429 (rate-limited)',
|
|
276
|
+
'Returns pageId + pageUrl for downstream linkage from notify-slack / notify-lark messages',
|
|
277
|
+
],
|
|
278
|
+
marketplace: {
|
|
279
|
+
slug: 'notify-notion',
|
|
280
|
+
tagline: 'Reusable Notion archiver — durable record for any workflow.',
|
|
281
|
+
iconPrompt: [
|
|
282
|
+
'Hand-painted gouache illustration with soft brushwork and gentle painterly texture, in the same family as the sentry-triage and generate-test-cases marketplace icons but with its own distinct character.',
|
|
283
|
+
'Subject: a friendly anthropomorphic notebook-document mascot — a small rounded notebook character with two big smiling eyes and a rosy blush, its open pages showing three painted horizontal ink-lines and a tiny checkmark in the corner. A soft halo of two or three little pastel page-flutter sparkles dance around it, suggesting a freshly-written entry being archived.',
|
|
284
|
+
'Background: a pale neutral Notion-flavored off-white gradient — warm cream at the top blending into a soft dove-grey at the base (#F7F3EC → #E8E4DA), with a single faint paper-grain texture and a couple of small floating pastel ink-spot dots for friendliness.',
|
|
285
|
+
'Centered composition with the notebook character as the focal point in the lower-center, sparkles arcing across the upper third; plenty of breathing room so the silhouette reads at 64×64 in the marketplace grid.',
|
|
286
|
+
'Mood is calm, archival, gently studious — the friendly notebook companion that keeps a tidy record, NOT corporate productivity or wall-of-text database.',
|
|
287
|
+
'Soft rounded square 1024×1024 canvas with a subtle paper-grain texture.',
|
|
288
|
+
'NO text, NO logo or trademarked marks, NO photo-realism, NO sleek 3D render, NO literal Notion trademark.',
|
|
289
|
+
].join('\n'),
|
|
290
|
+
category: 'Operations',
|
|
291
|
+
tags: ['notion', 'docs', 'reporting', 'knowledge-base', 'archive'],
|
|
292
|
+
capabilities: [
|
|
293
|
+
'Create a new page in a Notion database (POST /v1/pages)',
|
|
294
|
+
'Append blocks to an existing page (PATCH /v1/blocks/{pageId}/children)',
|
|
295
|
+
'Renders rich report-objects to native Notion blocks (headings, callouts, tables, code, embeds)',
|
|
296
|
+
'Severity-mapped page-icon emoji + colored callout backgrounds',
|
|
297
|
+
'Sub-graph dispatchable from any parent workflow',
|
|
298
|
+
'Maps 401 / 404 / 429 to typed errors so callers can react cleanly',
|
|
299
|
+
],
|
|
300
|
+
conversationStarters: [
|
|
301
|
+
'Archive the weekly digest to our Notion reports database',
|
|
302
|
+
'Append today\'s incident summary to the on-call runbook page',
|
|
303
|
+
'Create a Notion page for every new Sentry CRITICAL',
|
|
304
|
+
'Drop the deploy-summary into our engineering Notion every Friday',
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
|
|
258
309
|
// ── sentry-triage: parent workflow that uses notify-slack/-lark ──
|
|
259
310
|
'sentry-triage': {
|
|
260
311
|
name: 'sentry-triage',
|
|
@@ -301,6 +352,54 @@ export const TEMPLATES = {
|
|
|
301
352
|
'Page on-call when a CRITICAL error appears in checkout',
|
|
302
353
|
],
|
|
303
354
|
},
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
// ── ai-spend-weekly-digest: cross-silo billing digest ─────────────
|
|
358
|
+
'ai-spend-weekly-digest': {
|
|
359
|
+
name: 'ai-spend-weekly-digest',
|
|
360
|
+
displayName: 'AI Spend Weekly Digest',
|
|
361
|
+
description: 'Weekly digest of OpenAI / Anthropic / Cursor admin billing — pulls trailing-28d cost+usage across all three providers, detects per-project anomalies vs 3-week baseline, and posts a rich report card to Lark and/or Slack via in-process sub-graph dispatch.',
|
|
362
|
+
path: join(__dirname, 'ai-spend-weekly-digest'),
|
|
363
|
+
defaultSlug: 'ai-spend-weekly-digest',
|
|
364
|
+
deps: { zod: '^3.23.0', '@zibby/skills': '^0.1.25' },
|
|
365
|
+
features: [
|
|
366
|
+
'3-node graph: fetch_spending → analyze (LLM narrative) → dispatch_digest',
|
|
367
|
+
'Parallel admin-API fetch via Promise.allSettled — one provider down doesn\'t kill the run',
|
|
368
|
+
'Customer attribution via OpenAI project / Anthropic workspace / Cursor user metadata (no manual mapping required)',
|
|
369
|
+
'Anomaly detection over per-key σ + ratio fallback — flags spend spikes vs 3-week baseline',
|
|
370
|
+
'Renders to native Slack Block-Kit / Lark Card via reportToBlockKit / reportToLarkCard — zero chart-image dependency',
|
|
371
|
+
'Parallel sub-graph dispatch to notify-slack + notify-lark (~5ms in-process overhead)',
|
|
372
|
+
'Cron-friendly: weekly schedule, default Monday 08:00 local',
|
|
373
|
+
],
|
|
374
|
+
marketplace: {
|
|
375
|
+
slug: 'ai-spend-weekly-digest',
|
|
376
|
+
tagline: 'Track and explain your OpenAI / Anthropic / Cursor spending — every Monday morning, in Lark or Slack.',
|
|
377
|
+
iconPrompt: [
|
|
378
|
+
'Hand-painted gouache illustration with soft brushwork and gentle painterly texture, featuring a friendly chubby pastel-pink piggy-bank character with two big smiling eyes and rosy blush, its body marked with a soft glowing dollar-sign sigil; the piggy is gently cradling a small stack of three coloured coins floating just above its back — a sky-blue coin for OpenAI-ish, a warm violet coin for Anthropic-ish, and a soft mint-green coin for Cursor-ish — each coin painted in the same loose gouache style with no logos or text on them, just abstract round chips.',
|
|
379
|
+
'A thin painterly trend-line ribbon arcs gently upward behind the piggy from lower-left to upper-right, suggesting "money over time", rendered as a soft watercolor ribbon in dusty rose with a tiny gentle peak near the top right.',
|
|
380
|
+
'Background is a calm sunrise gradient of pale buttercream at the top blending through soft peach into a gentle wash of pale mint at the base, with a few small pastel clouds for friendliness.',
|
|
381
|
+
'Centered composition with the piggy as the immediate focal point in the lower-center, the floating coins arcing across the upper third, the trend ribbon as background scaffolding; plenty of breathing room so the silhouette reads at 64×64.',
|
|
382
|
+
'Mood is warm, optimistic, gently informative — feels like a thoughtful finance friend, NOT corporate spreadsheet, NOT alarmist red.',
|
|
383
|
+
'Soft rounded square 1024×1024 canvas with a subtle paper-grain texture.',
|
|
384
|
+
'NO text, NO letters, NO numbers, NO photo-realism, NO sleek 3D render, NO chart axes or grid lines, NO dark navy or near-black backgrounds, NO literal OpenAI / Anthropic / Cursor logos or wordmarks, NO bar charts.',
|
|
385
|
+
].join('\n'),
|
|
386
|
+
category: 'Operations',
|
|
387
|
+
tags: ['cost', 'finance', 'reporting', 'openai', 'anthropic', 'cursor', 'digest', 'weekly'],
|
|
388
|
+
capabilities: [
|
|
389
|
+
'Pulls org-wide cost+usage from OpenAI, Anthropic, and Cursor admin APIs in parallel',
|
|
390
|
+
'Joins customer attribution from provider-native project / workspace / member metadata',
|
|
391
|
+
'Detects per-project anomalies (σ + ratio) against a 3-week rolling baseline',
|
|
392
|
+
'Drafts the leadership-grade narrative with an LLM, falls back to deterministic copy if model is unavailable',
|
|
393
|
+
'Posts a rich Block-Kit / Lark Card report (trend bars, top spenders table, anomalies, provider breakdown)',
|
|
394
|
+
'Fan-out to Lark + Slack in parallel — partial-failure resilient',
|
|
395
|
+
],
|
|
396
|
+
conversationStarters: [
|
|
397
|
+
'Run a weekly AI spend digest every Monday morning',
|
|
398
|
+
'Tell me which OpenAI projects spiked spending this week',
|
|
399
|
+
'Compare Anthropic spend vs the 3-week baseline',
|
|
400
|
+
'Post the digest to our #leadership Lark group + #eng Slack channel',
|
|
401
|
+
],
|
|
402
|
+
},
|
|
304
403
|
}
|
|
305
404
|
};
|
|
306
405
|
|
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
import { z } from 'zod';
|
|
32
32
|
import { SKILLS } from '@zibby/core';
|
|
33
33
|
import { resolveIntegrationToken } from '@zibby/core/backend-client.js';
|
|
34
|
+
// Universal renderer — see notify-slack-node.js for the design note;
|
|
35
|
+
// when state.report is present the legacy buildLarkCard path is bypassed.
|
|
36
|
+
import { reportToLarkCard } from '@zibby/skills/report';
|
|
34
37
|
|
|
35
38
|
const SEVERITY_TEMPLATE = Object.freeze({
|
|
36
39
|
low: 'grey',
|
|
@@ -268,10 +271,20 @@ export const notifyLarkNode = {
|
|
|
268
271
|
if (!receiveId) {
|
|
269
272
|
throw new Error('notify-lark: input.receiveId is required');
|
|
270
273
|
}
|
|
274
|
+
if (!state.report && !state.title) {
|
|
275
|
+
throw new Error('notify-lark: input.title is required when input.report is absent');
|
|
276
|
+
}
|
|
271
277
|
const receiveIdType = inferReceiveIdType(receiveId);
|
|
272
278
|
|
|
273
279
|
const { token, host } = await getTenantAccessToken();
|
|
274
|
-
|
|
280
|
+
// Two rendering paths (symmetric with notify-slack):
|
|
281
|
+
// 1. state.report present → reportToLarkCard renders the full
|
|
282
|
+
// structured digest. Legacy fields are ignored.
|
|
283
|
+
// 2. Legacy severity/title/body shape → buildLarkCard renders
|
|
284
|
+
// the simple alert (sentry-triage et al.).
|
|
285
|
+
const card = (state.report && typeof state.report === 'object')
|
|
286
|
+
? reportToLarkCard(state.report)
|
|
287
|
+
: buildLarkCard(state);
|
|
275
288
|
const { messageId } = await postToLark({
|
|
276
289
|
token,
|
|
277
290
|
host,
|
package/notify-lark/state.js
CHANGED
|
@@ -27,8 +27,11 @@ export const notifyLarkInputSchema = z.object({
|
|
|
27
27
|
.default('medium')
|
|
28
28
|
.describe('Alert severity. Drives header color + mention strategy.'),
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// Required in legacy-mode; rich-mode (when `report` is set) sources
|
|
31
|
+
// the title from `report.title` instead. See notify-slack/state.js
|
|
32
|
+
// for the symmetric design.
|
|
33
|
+
title: z.string().min(1).max(300).optional()
|
|
34
|
+
.describe('Card header text. Required when `report` is absent (rich-mode sources from report.title).'),
|
|
32
35
|
|
|
33
36
|
body: z.string().max(3000).optional()
|
|
34
37
|
.describe('Body text. Supports Lark lark_md (e.g. **bold**, `code`, [text](url)).'),
|
|
@@ -60,6 +63,13 @@ export const notifyLarkInputSchema = z.object({
|
|
|
60
63
|
.describe('Lark @-mentions, e.g. [\'<at user_id="ou_alice">@Alice</at>\'].'),
|
|
61
64
|
|
|
62
65
|
idempotencyKey: z.string().max(128).optional(),
|
|
66
|
+
|
|
67
|
+
// ── Rich-report mode ──────────────────────────────────────────────
|
|
68
|
+
// When `report` is present, the node renders the report-object via
|
|
69
|
+
// reportToLarkCard() and IGNORES the legacy severity/title/body
|
|
70
|
+
// fields. See notify-slack/state.js for the symmetric design.
|
|
71
|
+
report: z.record(z.any()).optional()
|
|
72
|
+
.describe('Rich report-object (see @zibby/skills/report). When set, supersedes severity/title/body — the node renders a full Lark Card.'),
|
|
63
73
|
});
|
|
64
74
|
|
|
65
75
|
export const notifyLarkContextSchema = z.object({
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# notify-notion
|
|
2
|
+
|
|
3
|
+
A reusable **child workflow** that posts a structured page (or appends blocks) to a Notion workspace.
|
|
4
|
+
|
|
5
|
+
Designed to be dispatched via sub-graph from any parent workflow — most commonly digest workflows (`ai-spend-weekly-digest`) that want to archive a weekly report to a Notion database, or alert workflows (`sentry-triage`) that want a durable record of each incident.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Takes a provider-neutral payload (severity, title, body, target, optional Sentry-flavored fields)
|
|
10
|
+
- Builds a Notion page (or block-children array) — including rich-report mode that delegates to `@zibby/skills/report`'s `reportToNotionBlocks`
|
|
11
|
+
- Posts via `POST /v1/pages` (when `databaseId` is supplied) or `PATCH /v1/blocks/{pageId}/children` (when `pageId` is supplied)
|
|
12
|
+
- Returns the page id + URL so the parent can link to it from downstream notifications
|
|
13
|
+
|
|
14
|
+
No LLM call — single deterministic API request, typically ~1-2s depending on block count.
|
|
15
|
+
|
|
16
|
+
## Dispatch shape (parent workflow)
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { WorkflowGraph, SKILLS } from '@zibby/core';
|
|
20
|
+
|
|
21
|
+
const g = new WorkflowGraph();
|
|
22
|
+
g.addNode('archive_to_notion', {
|
|
23
|
+
workflow: 'notify-notion',
|
|
24
|
+
input: (state) => ({
|
|
25
|
+
// Either databaseId OR pageId — not both:
|
|
26
|
+
databaseId: process.env.NOTION_INCIDENT_DB,
|
|
27
|
+
severity: 'critical',
|
|
28
|
+
title: 'Checkout: TypeError on session.user.id',
|
|
29
|
+
body: '12 users affected in the last 1h.',
|
|
30
|
+
sentryLink: 'https://sentry.io/.../1234567890/',
|
|
31
|
+
affectedUsers: 12,
|
|
32
|
+
events: 47,
|
|
33
|
+
release: '1.42.0',
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For digest workflows, pass a `report` object instead of severity/title/body — the node renders the full structured page via `reportToNotionBlocks`:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
g.addNode('archive_digest', {
|
|
42
|
+
workflow: 'notify-notion',
|
|
43
|
+
input: (state) => ({
|
|
44
|
+
databaseId: process.env.NOTION_REPORTS_DB,
|
|
45
|
+
report: state.analyze.report, // produced by an upstream digest node
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Output
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
{
|
|
54
|
+
delivered: boolean,
|
|
55
|
+
pageId: string,
|
|
56
|
+
pageUrl?: string, // only set on the create branch (Notion returns it)
|
|
57
|
+
blocksCount?: number // diagnostic — how many blocks were posted
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Auth
|
|
62
|
+
|
|
63
|
+
The bot's OAuth token is resolved via `resolveIntegrationToken('notion')`. Connect your workspace in **Settings → Integrations → Notion** in the Zibby dashboard before running.
|
|
64
|
+
|
|
65
|
+
## Failure modes
|
|
66
|
+
|
|
67
|
+
- **401** — Notion rejected the token (revoked, or wrong workspace).
|
|
68
|
+
- **404** — Page/database not found, OR the Notion integration isn't added to the target page (Notion's quirk — share the page with the integration explicitly).
|
|
69
|
+
- **429** — Rate-limited. The parent should backoff + retry.
|
|
70
|
+
|
|
71
|
+
All three surface as typed errors with `.status` set; the parent's `onComplete` sees a failed execution row and can re-dispatch.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-notion — single-node child workflow.
|
|
3
|
+
*
|
|
4
|
+
* Companion to notify-slack / notify-lark; same provider-neutral input
|
|
5
|
+
* shape so a parent can dispatch the same payload to all three (just
|
|
6
|
+
* swap the destination field: `channel` → `receiveId` → `databaseId`
|
|
7
|
+
* or `pageId`).
|
|
8
|
+
*
|
|
9
|
+
* Two write modes:
|
|
10
|
+
* - Create a NEW page in a database (`input.databaseId` set)
|
|
11
|
+
* - APPEND blocks to an existing page (`input.pageId` set)
|
|
12
|
+
* Exactly one must be supplied — the node throws clearly if neither
|
|
13
|
+
* or both are present.
|
|
14
|
+
*
|
|
15
|
+
* Returns `{ delivered, pageId, pageUrl, blocksCount }`. The `pageUrl`
|
|
16
|
+
* is only populated on the create-page branch (Notion returns the URL
|
|
17
|
+
* in the response); appended pages reuse the caller-supplied pageId.
|
|
18
|
+
*
|
|
19
|
+
* Example dispatch shape (parent workflow):
|
|
20
|
+
*
|
|
21
|
+
* g.addNode('notify', { workflow: 'notify-notion',
|
|
22
|
+
* input: (state) => ({
|
|
23
|
+
* severity: 'critical',
|
|
24
|
+
* title: 'Checkout: TypeError',
|
|
25
|
+
* body: '12 users affected in last 1h',
|
|
26
|
+
* databaseId: 'abc123...', // create new page in this DB
|
|
27
|
+
* sentryLink: 'https://sentry.io/.../1234567890/',
|
|
28
|
+
* }),
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* For digest / report workflows, drop the legacy severity/title/body
|
|
32
|
+
* fields and pass a `report` object instead — the node delegates to
|
|
33
|
+
* @zibby/skills's reportToNotionBlocks() for the full structured page.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { WorkflowAgent, WorkflowGraph } from '@zibby/core';
|
|
37
|
+
import { notifyNotionNode } from './nodes/notify-notion-node.js';
|
|
38
|
+
import {
|
|
39
|
+
notifyNotionInputSchema,
|
|
40
|
+
notifyNotionContextSchema,
|
|
41
|
+
} from './state.js';
|
|
42
|
+
|
|
43
|
+
export class NotifyNotionAgent extends WorkflowAgent {
|
|
44
|
+
buildGraph() {
|
|
45
|
+
const graph = new WorkflowGraph();
|
|
46
|
+
graph
|
|
47
|
+
.setInputSchema(notifyNotionInputSchema)
|
|
48
|
+
.setContextSchema(notifyNotionContextSchema);
|
|
49
|
+
|
|
50
|
+
graph.addNode('notify_notion', notifyNotionNode);
|
|
51
|
+
graph.setEntryPoint('notify_notion');
|
|
52
|
+
graph.addEdge('notify_notion', 'END');
|
|
53
|
+
|
|
54
|
+
return graph;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async onComplete(result) {
|
|
58
|
+
const delivered = !!result?.state?.notify_notion?.delivered;
|
|
59
|
+
const pageId = result?.state?.notify_notion?.pageId || '?';
|
|
60
|
+
console.log(`[notify-notion] ${delivered ? 'delivered' : 'failed'} (pageId=${pageId})`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default NotifyNotionAgent;
|
|
Binary file
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-notion node — deterministic, no LLM.
|
|
3
|
+
*
|
|
4
|
+
* Posts a single page (or appends blocks to an existing page) to a
|
|
5
|
+
* Notion workspace via the official Notion REST API. Same design
|
|
6
|
+
* philosophy as notify-slack and notify-lark: parent makes all the
|
|
7
|
+
* decisions (severity, title, body, where), this node just renders
|
|
8
|
+
* the blocks and dispatches the API call.
|
|
9
|
+
*
|
|
10
|
+
* Auth: pulls the Notion OAuth bearer token via @zibby/core's
|
|
11
|
+
* `resolveIntegrationToken('notion')`. Backend's notion handler
|
|
12
|
+
* returns `{ token, workspaceId }` — we only need the token, but
|
|
13
|
+
* tests / debugging consume the workspaceId too.
|
|
14
|
+
*
|
|
15
|
+
* Two write paths:
|
|
16
|
+
* - databaseId → POST /v1/pages (creates a new page in a database)
|
|
17
|
+
* - pageId → PATCH /v1/blocks/{pageId}/children
|
|
18
|
+
* (appends blocks to an existing page)
|
|
19
|
+
*
|
|
20
|
+
* Mutual exclusion is enforced at runtime — see state.js for why we
|
|
21
|
+
* don't do it in the zod schema.
|
|
22
|
+
*
|
|
23
|
+
* Failure modes (no retry — sub-graph caller handles retries):
|
|
24
|
+
* - Token missing / wrong scope → resolveIntegrationToken throws
|
|
25
|
+
* "notion is not connected".
|
|
26
|
+
* - 401 → Notion rejected token (token revoked or wrong workspace).
|
|
27
|
+
* - 404 → page/database not found (or bot not added to it).
|
|
28
|
+
* - 429 → Notion rate-limited (rare; caller should backoff + retry).
|
|
29
|
+
* - 5xx → typed error with .status set; caller can re-dispatch.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { z } from 'zod';
|
|
33
|
+
import { SKILLS } from '@zibby/core';
|
|
34
|
+
import { resolveIntegrationToken } from '@zibby/core/backend-client.js';
|
|
35
|
+
// Universal renderer — see notify-slack-node.js / notify-lark-node.js
|
|
36
|
+
// for the design note. When state.report is present the legacy
|
|
37
|
+
// buildNotionPayload path is bypassed entirely.
|
|
38
|
+
import { reportToNotionBlocks } from '@zibby/skills/report';
|
|
39
|
+
|
|
40
|
+
// Notion REST API root. Hardcoded — there's no on-prem variant and
|
|
41
|
+
// the backend doesn't return a host.
|
|
42
|
+
const NOTION_API_BASE = 'https://api.notion.com/v1';
|
|
43
|
+
|
|
44
|
+
// Notion-Version pin. As of 2026-05 this is the latest stable version.
|
|
45
|
+
// Notion's API is versioned at the request level (header), not the
|
|
46
|
+
// URL path, so we MUST send this to get a deterministic schema.
|
|
47
|
+
const NOTION_VERSION = '2022-06-28';
|
|
48
|
+
|
|
49
|
+
const SEVERITY_ICON = Object.freeze({
|
|
50
|
+
low: '⚪',
|
|
51
|
+
medium: '🟡',
|
|
52
|
+
high: '🟠',
|
|
53
|
+
critical: '🚨',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const NotifyNotionOutputSchema = z.object({
|
|
57
|
+
delivered: z.boolean().describe('true if the Notion API request returned 2xx'),
|
|
58
|
+
pageId: z.string().describe('ID of the page that was created (databaseId branch) or appended to (pageId branch)'),
|
|
59
|
+
pageUrl: z.string().optional().describe('Notion URL for the page — set only on the create branch (Notion returns it in the response). Empty on append.'),
|
|
60
|
+
blocksCount: z.number().int().optional().describe('Block count posted — diagnostic only.'),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the Notion page-creation OR block-append request body from
|
|
65
|
+
* the input. Exposed for unit tests so we can pin the rendered shape
|
|
66
|
+
* without going through fetch.
|
|
67
|
+
*
|
|
68
|
+
* Returns one of two shapes:
|
|
69
|
+
* - `{ kind: 'page', url: '<api>/pages', body: {...} }`
|
|
70
|
+
* - `{ kind: 'append', url: '<api>/blocks/{pageId}/children', body: {...} }`
|
|
71
|
+
*
|
|
72
|
+
* The shape is determined by which of databaseId / pageId is supplied;
|
|
73
|
+
* exactly one must be present (the caller's runtime guard catches the
|
|
74
|
+
* "neither/both" case before this is called).
|
|
75
|
+
*
|
|
76
|
+
* @param {Object} input
|
|
77
|
+
* @returns {{ kind: 'page'|'append', url: string, body: Object, blocksCount: number, title: string, icon?: string }}
|
|
78
|
+
*/
|
|
79
|
+
export function buildNotionPayload(input) {
|
|
80
|
+
const {
|
|
81
|
+
severity = 'medium',
|
|
82
|
+
title: inputTitle,
|
|
83
|
+
body,
|
|
84
|
+
databaseId,
|
|
85
|
+
pageId,
|
|
86
|
+
sentryLink,
|
|
87
|
+
affectedUsers,
|
|
88
|
+
events,
|
|
89
|
+
release,
|
|
90
|
+
firstSeen,
|
|
91
|
+
codeSnippet,
|
|
92
|
+
actionUrl,
|
|
93
|
+
actionLabel,
|
|
94
|
+
mentions,
|
|
95
|
+
report,
|
|
96
|
+
} = input;
|
|
97
|
+
|
|
98
|
+
// ── Rich-mode: defer entirely to @zibby/skills's renderer ─────────
|
|
99
|
+
if (report && typeof report === 'object') {
|
|
100
|
+
const rendered = reportToNotionBlocks(report);
|
|
101
|
+
if (databaseId) {
|
|
102
|
+
const pageBody = {
|
|
103
|
+
parent: { database_id: databaseId },
|
|
104
|
+
properties: {
|
|
105
|
+
Name: {
|
|
106
|
+
title: [{ text: { content: rendered.title } }],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
children: rendered.blocks,
|
|
110
|
+
};
|
|
111
|
+
if (rendered.icon) {
|
|
112
|
+
pageBody.icon = { type: 'emoji', emoji: rendered.icon };
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
kind: 'page',
|
|
116
|
+
url: `${NOTION_API_BASE}/pages`,
|
|
117
|
+
body: pageBody,
|
|
118
|
+
blocksCount: rendered.blocks.length,
|
|
119
|
+
title: rendered.title,
|
|
120
|
+
icon: rendered.icon,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// pageId branch — append children (no title/icon — those are page
|
|
124
|
+
// properties and can't be updated via the children endpoint).
|
|
125
|
+
return {
|
|
126
|
+
kind: 'append',
|
|
127
|
+
url: `${NOTION_API_BASE}/blocks/${encodeURIComponent(pageId)}/children`,
|
|
128
|
+
body: { children: rendered.blocks },
|
|
129
|
+
blocksCount: rendered.blocks.length,
|
|
130
|
+
title: rendered.title,
|
|
131
|
+
icon: rendered.icon,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Legacy-mode: render severity/title/body into a simple Notion page ─
|
|
136
|
+
const titleText = `${severity.toUpperCase()}: ${inputTitle || ''}`.trim();
|
|
137
|
+
const icon = SEVERITY_ICON[severity] || SEVERITY_ICON.medium;
|
|
138
|
+
const children = [];
|
|
139
|
+
|
|
140
|
+
if (body && body.trim().length > 0) {
|
|
141
|
+
children.push({
|
|
142
|
+
object: 'block',
|
|
143
|
+
type: 'paragraph',
|
|
144
|
+
paragraph: { rich_text: [{ type: 'text', text: { content: body.slice(0, 2000) } }] },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Sentry-flavored metadata — rendered as a bulleted list when at
|
|
149
|
+
// least one field is present. Mirrors notify-slack's "fields" block.
|
|
150
|
+
const metaLines = [];
|
|
151
|
+
if (typeof affectedUsers === 'number') metaLines.push(`Users affected: ${affectedUsers}`);
|
|
152
|
+
if (typeof events === 'number') metaLines.push(`Events: ${events}`);
|
|
153
|
+
if (release) metaLines.push(`Release: ${release}`);
|
|
154
|
+
if (firstSeen) metaLines.push(`First seen: ${firstSeen}`);
|
|
155
|
+
for (const line of metaLines) {
|
|
156
|
+
children.push({
|
|
157
|
+
object: 'block',
|
|
158
|
+
type: 'bulleted_list_item',
|
|
159
|
+
bulleted_list_item: { rich_text: [{ type: 'text', text: { content: line } }] },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (codeSnippet) {
|
|
164
|
+
children.push({
|
|
165
|
+
object: 'block',
|
|
166
|
+
type: 'code',
|
|
167
|
+
code: {
|
|
168
|
+
rich_text: [{ type: 'text', text: { content: codeSnippet.slice(0, 2000) } }],
|
|
169
|
+
language: 'plain text',
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Action links — embeds (Notion's closest equivalent to buttons).
|
|
175
|
+
if (sentryLink) {
|
|
176
|
+
children.push({
|
|
177
|
+
object: 'block',
|
|
178
|
+
type: 'embed',
|
|
179
|
+
embed: { url: sentryLink },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (actionUrl && actionLabel) {
|
|
183
|
+
children.push({
|
|
184
|
+
object: 'block',
|
|
185
|
+
type: 'embed',
|
|
186
|
+
embed: { url: actionUrl },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Mentions — plain-text bulleted list at the bottom.
|
|
191
|
+
if (Array.isArray(mentions) && mentions.length > 0) {
|
|
192
|
+
children.push({
|
|
193
|
+
object: 'block',
|
|
194
|
+
type: 'heading_3',
|
|
195
|
+
heading_3: { rich_text: [{ type: 'text', text: { content: 'Notify' } }] },
|
|
196
|
+
});
|
|
197
|
+
for (const m of mentions) {
|
|
198
|
+
children.push({
|
|
199
|
+
object: 'block',
|
|
200
|
+
type: 'bulleted_list_item',
|
|
201
|
+
bulleted_list_item: { rich_text: [{ type: 'text', text: { content: m } }] },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (databaseId) {
|
|
207
|
+
return {
|
|
208
|
+
kind: 'page',
|
|
209
|
+
url: `${NOTION_API_BASE}/pages`,
|
|
210
|
+
body: {
|
|
211
|
+
parent: { database_id: databaseId },
|
|
212
|
+
icon: { type: 'emoji', emoji: icon },
|
|
213
|
+
properties: {
|
|
214
|
+
Name: {
|
|
215
|
+
title: [{ text: { content: titleText.slice(0, 200) } }],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
children,
|
|
219
|
+
},
|
|
220
|
+
blocksCount: children.length,
|
|
221
|
+
title: titleText,
|
|
222
|
+
icon,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// pageId branch — append children only.
|
|
226
|
+
return {
|
|
227
|
+
kind: 'append',
|
|
228
|
+
url: `${NOTION_API_BASE}/blocks/${encodeURIComponent(pageId)}/children`,
|
|
229
|
+
body: { children },
|
|
230
|
+
blocksCount: children.length,
|
|
231
|
+
title: titleText,
|
|
232
|
+
icon,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Post the request to Notion. Exposed for unit tests so we can stub
|
|
238
|
+
* fetch + assert payload shape without going through token resolution.
|
|
239
|
+
*
|
|
240
|
+
* Notion's API uses POST for page creation and PATCH for children
|
|
241
|
+
* append — the `kind` field of the buildNotionPayload result tells us
|
|
242
|
+
* which method to use.
|
|
243
|
+
*/
|
|
244
|
+
export async function postToNotion({ token, kind, url, body }) {
|
|
245
|
+
const method = kind === 'append' ? 'PATCH' : 'POST';
|
|
246
|
+
const res = await fetch(url, {
|
|
247
|
+
method,
|
|
248
|
+
headers: {
|
|
249
|
+
Authorization: `Bearer ${token}`,
|
|
250
|
+
'Notion-Version': NOTION_VERSION,
|
|
251
|
+
'Content-Type': 'application/json',
|
|
252
|
+
},
|
|
253
|
+
body: JSON.stringify(body),
|
|
254
|
+
});
|
|
255
|
+
// Notion returns JSON for both success and error responses; the
|
|
256
|
+
// status code distinguishes. We parse defensively because a network
|
|
257
|
+
// proxy might return non-JSON on a 5xx.
|
|
258
|
+
const data = await res.json().catch(() => ({}));
|
|
259
|
+
if (res.status < 200 || res.status >= 300) {
|
|
260
|
+
// Map common status codes to friendly messages. The mapping below
|
|
261
|
+
// mirrors the Notion API docs:
|
|
262
|
+
// 401 unauthorized — token revoked / wrong workspace
|
|
263
|
+
// 404 object_not_found — page/database not found OR bot not added
|
|
264
|
+
// 429 rate_limited
|
|
265
|
+
let msg;
|
|
266
|
+
if (res.status === 401) msg = 'Notion rejected token';
|
|
267
|
+
else if (res.status === 404) msg = 'Notion page/database not found';
|
|
268
|
+
else if (res.status === 429) msg = 'Notion rate-limited';
|
|
269
|
+
else msg = data.message || `http ${res.status}`;
|
|
270
|
+
const err = new Error(`Notion API failed: ${msg}`);
|
|
271
|
+
err.code = data.code || `http_${res.status}`;
|
|
272
|
+
err.status = res.status;
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
return data;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const notifyNotionNode = {
|
|
279
|
+
name: 'notify_notion',
|
|
280
|
+
// Declares Notion as a hard requirement for marketplace gating —
|
|
281
|
+
// same pattern as notify-slack / notify-lark. See those for the
|
|
282
|
+
// rationale. The MCP notion tool isn't actually invoked here
|
|
283
|
+
// (custom-execute path); the declaration wires the gate only.
|
|
284
|
+
skills: [SKILLS.NOTION],
|
|
285
|
+
outputSchema: NotifyNotionOutputSchema,
|
|
286
|
+
// 20s — Notion's page-create can be slower than Slack/Lark when
|
|
287
|
+
// appending many children (each child block adds ~10ms server-side).
|
|
288
|
+
timeout: 20 * 1000,
|
|
289
|
+
execute: async (context) => {
|
|
290
|
+
// Custom-execute gets either the merged state object or the new
|
|
291
|
+
// {state, agent, ...} context wrapper. Normalize to a plain state.
|
|
292
|
+
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
293
|
+
? context.state.getAll()
|
|
294
|
+
: context;
|
|
295
|
+
|
|
296
|
+
const { databaseId, pageId } = state;
|
|
297
|
+
// Mutual exclusion: exactly one destination must be supplied. We
|
|
298
|
+
// check both directions explicitly so the error message points at
|
|
299
|
+
// the actual misconfiguration (rather than "Invalid input" from a
|
|
300
|
+
// zod refinement).
|
|
301
|
+
if (!databaseId && !pageId) {
|
|
302
|
+
throw new Error('notify-notion: must supply exactly one of input.databaseId or input.pageId');
|
|
303
|
+
}
|
|
304
|
+
if (databaseId && pageId) {
|
|
305
|
+
throw new Error('notify-notion: databaseId and pageId are mutually exclusive — pick one');
|
|
306
|
+
}
|
|
307
|
+
if (!state.report && !state.title) {
|
|
308
|
+
throw new Error('notify-notion: input.title is required when input.report is absent');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Resolve the bot token. The cache inside resolveIntegrationToken
|
|
312
|
+
// means this is a single HTTP RTT the first time per process; later
|
|
313
|
+
// dispatches in the same Fargate task are zero-cost.
|
|
314
|
+
const { token } = await resolveIntegrationToken('notion');
|
|
315
|
+
|
|
316
|
+
const payload = buildNotionPayload(state);
|
|
317
|
+
const response = await postToNotion({
|
|
318
|
+
token,
|
|
319
|
+
kind: payload.kind,
|
|
320
|
+
url: payload.url,
|
|
321
|
+
body: payload.body,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// For the create-page branch, Notion returns the new page object
|
|
325
|
+
// (with `id` + `url`). For the append-children branch, the
|
|
326
|
+
// response is `{ object: 'list', results: [...blocks] }` and we
|
|
327
|
+
// already know the page id (the caller supplied it).
|
|
328
|
+
const resultPageId = payload.kind === 'page'
|
|
329
|
+
? response.id
|
|
330
|
+
: pageId;
|
|
331
|
+
const resultPageUrl = payload.kind === 'page'
|
|
332
|
+
? response.url
|
|
333
|
+
: undefined;
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
delivered: true,
|
|
337
|
+
pageId: resultPageId,
|
|
338
|
+
pageUrl: resultPageUrl,
|
|
339
|
+
blocksCount: payload.blocksCount,
|
|
340
|
+
};
|
|
341
|
+
},
|
|
342
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "notify-notion",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Post a structured page (or append blocks) to Notion — 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
|
+
"@zibby/skills": "^0.1.25",
|
|
14
|
+
"zod": "^3.23.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"vitest": "^2.1.5"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-notion — three-schema state model.
|
|
3
|
+
*
|
|
4
|
+
* Posts to Notion as either:
|
|
5
|
+
* - a NEW page inside a database (`databaseId`), or
|
|
6
|
+
* - APPENDED blocks on an existing page (`pageId`)
|
|
7
|
+
* EXACTLY ONE of these must be supplied. The execute path enforces
|
|
8
|
+
* mutual exclusion at runtime — we keep both fields optional at the
|
|
9
|
+
* schema level so the wire format degrades gracefully (e.g. when a
|
|
10
|
+
* parent workflow forgets to set one, the error message is clearer
|
|
11
|
+
* than a zod refinement failure on an unfamiliar field name).
|
|
12
|
+
*
|
|
13
|
+
* Mirrors notify-slack / notify-lark's provider-neutral envelope so a
|
|
14
|
+
* parent can fan-out the same alert to all three notifiers with a
|
|
15
|
+
* single `input: (state) => ({...})` block (swapping the target field
|
|
16
|
+
* per provider: `channel`, `receiveId`, or `pageId`/`databaseId`).
|
|
17
|
+
*
|
|
18
|
+
* Three schemas:
|
|
19
|
+
* - notifyNotionInputSchema — payload from the parent's
|
|
20
|
+
* `dispatchSubgraph('notify-notion', { input: … })`
|
|
21
|
+
* - notifyNotionContextSchema — runner-injected fields + node outputs
|
|
22
|
+
* - notifyNotionStateSchema — merge of the two (tests + tooling)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
|
|
27
|
+
export const SEVERITIES = /** @type {const} */ (['low', 'medium', 'high', 'critical']);
|
|
28
|
+
|
|
29
|
+
export const notifyNotionInputSchema = z.object({
|
|
30
|
+
severity: z.enum(SEVERITIES)
|
|
31
|
+
.default('medium')
|
|
32
|
+
.describe('Alert severity. Drives legacy-mode page-icon emoji + decoration.'),
|
|
33
|
+
|
|
34
|
+
// Required in legacy-mode (severity/title/body alerts); rich-mode
|
|
35
|
+
// (when `report` is set) sources the title from `report.title`. The
|
|
36
|
+
// execute path enforces "at least one source of title" at runtime —
|
|
37
|
+
// see notify-slack/state.js for the symmetric design.
|
|
38
|
+
title: z.string().min(1).max(300).optional()
|
|
39
|
+
.describe('Page title. Required when `report` is absent (rich-mode sources from report.title).'),
|
|
40
|
+
|
|
41
|
+
body: z.string().max(3000).optional()
|
|
42
|
+
.describe('Body text. Rendered as a paragraph block in the page.'),
|
|
43
|
+
|
|
44
|
+
// ── Destination — EXACTLY ONE must be set ─────────────────────────
|
|
45
|
+
// We don't enforce mutual exclusion via zod refinement because the
|
|
46
|
+
// resulting error message ("Invalid input") is less helpful than the
|
|
47
|
+
// execute-path's typed runtime checks ("notify-notion: must supply
|
|
48
|
+
// exactly one of databaseId or pageId"). Schema-level validation is
|
|
49
|
+
// intentionally permissive on the wire.
|
|
50
|
+
databaseId: z.string().min(1).max(100).optional()
|
|
51
|
+
.describe('Notion database id to create a new page in (e.g. "abc123def...32hex"). Mutually exclusive with pageId.'),
|
|
52
|
+
|
|
53
|
+
pageId: z.string().min(1).max(100).optional()
|
|
54
|
+
.describe('Existing Notion page id to append blocks to. Mutually exclusive with databaseId.'),
|
|
55
|
+
|
|
56
|
+
// ── Sentry-flavored optional context (legacy-mode) ────────────────
|
|
57
|
+
// Same fields as notify-slack so a parent can dispatch a single
|
|
58
|
+
// payload to all three notifiers. notify-notion renders these as
|
|
59
|
+
// bulleted-list items in the page body when present.
|
|
60
|
+
sentryLink: z.string().url().optional()
|
|
61
|
+
.describe('Sentry issue URL — rendered as an embed/link in the page.'),
|
|
62
|
+
affectedUsers: z.number().int().min(0).optional(),
|
|
63
|
+
events: z.number().int().min(0).optional(),
|
|
64
|
+
release: z.string().max(120).optional(),
|
|
65
|
+
firstSeen: z.string().optional(),
|
|
66
|
+
codeSnippet: z.string().max(2000).optional()
|
|
67
|
+
.describe('Optional code snippet — rendered as a Notion `code` block.'),
|
|
68
|
+
|
|
69
|
+
actionUrl: z.string().url().optional(),
|
|
70
|
+
actionLabel: z.string().max(40).optional(),
|
|
71
|
+
|
|
72
|
+
// Mentions — Notion's mention syntax (`@user`) requires user UUIDs
|
|
73
|
+
// that don't survive serialization to a generic alert payload, so we
|
|
74
|
+
// pass through caller-supplied strings as plain text in a bulleted
|
|
75
|
+
// list. Useful for "cc'ing" team names without requiring the caller
|
|
76
|
+
// to resolve user IDs upfront.
|
|
77
|
+
mentions: z.array(z.string().max(200))
|
|
78
|
+
.max(20)
|
|
79
|
+
.optional()
|
|
80
|
+
.describe('Plain-text mentions, rendered as a "Notify" bulleted list at the bottom of the page.'),
|
|
81
|
+
|
|
82
|
+
idempotencyKey: z.string().max(128).optional(),
|
|
83
|
+
|
|
84
|
+
// ── Rich-report mode ──────────────────────────────────────────────
|
|
85
|
+
// When `report` is present, the node renders the report-object via
|
|
86
|
+
// reportToNotionBlocks() and IGNORES severity/title/body/sentryLink/etc.
|
|
87
|
+
// (those legacy fields are exclusively for the simple-alert path).
|
|
88
|
+
//
|
|
89
|
+
// The contract lives in @zibby/skills's reportObjectSchema. We
|
|
90
|
+
// declare it as record(any) on the wire so old runtimes that don't
|
|
91
|
+
// know about the field don't fail validation, and the actual
|
|
92
|
+
// structure is validated by reportToNotionBlocks at render time.
|
|
93
|
+
// New consumers should import reportObjectSchema from @zibby/skills
|
|
94
|
+
// for tighter typing.
|
|
95
|
+
report: z.record(z.any()).optional()
|
|
96
|
+
.describe('Rich report-object (see @zibby/skills/report). When set, supersedes severity/title/body — the node renders a full Notion blocks payload.'),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const notifyNotionContextSchema = z.object({
|
|
100
|
+
notify_notion: z.object({
|
|
101
|
+
delivered: z.boolean(),
|
|
102
|
+
pageId: z.string(),
|
|
103
|
+
pageUrl: z.string().optional(),
|
|
104
|
+
blocksCount: z.number().int().optional(),
|
|
105
|
+
}).optional()
|
|
106
|
+
.describe('Output of the notify_notion node — set after dispatch completes.'),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const notifyNotionStateSchema =
|
|
110
|
+
notifyNotionInputSchema.merge(notifyNotionContextSchema);
|
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
import { z } from 'zod';
|
|
38
38
|
import { SKILLS } from '@zibby/core';
|
|
39
39
|
import { resolveIntegrationToken } from '@zibby/core/backend-client.js';
|
|
40
|
+
// reportToBlockKit lives in @zibby/skills (universal renderer for any
|
|
41
|
+
// destination). When the parent passes a `report` object, we delegate
|
|
42
|
+
// rendering entirely; the legacy severity/title/body path is unchanged
|
|
43
|
+
// for sentry-triage and other callers still on the simple shape.
|
|
44
|
+
import { reportToBlockKit } from '@zibby/skills/report';
|
|
40
45
|
|
|
41
46
|
const SEVERITY_COLORS = Object.freeze({
|
|
42
47
|
low: '#7f8c8d',
|
|
@@ -215,17 +220,42 @@ export const notifySlackNode = {
|
|
|
215
220
|
if (!channel) {
|
|
216
221
|
throw new Error('notify-slack: input.channel is required');
|
|
217
222
|
}
|
|
223
|
+
if (!state.report && !state.title) {
|
|
224
|
+
throw new Error('notify-slack: input.title is required when input.report is absent');
|
|
225
|
+
}
|
|
218
226
|
|
|
219
227
|
// Resolve the bot token. The cache inside resolveIntegrationToken
|
|
220
228
|
// means this is a single HTTP RTT the first time per process; later
|
|
221
229
|
// dispatches in the same Fargate task are zero-cost.
|
|
222
230
|
const { token } = await resolveIntegrationToken('slack');
|
|
223
231
|
|
|
224
|
-
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
232
|
+
// Two rendering paths:
|
|
233
|
+
// 1. Rich `report` object (digest workflows like
|
|
234
|
+
// ai-spend-weekly-digest) → reportToBlockKit produces the full
|
|
235
|
+
// structured card. The plain-text fallback is sourced from
|
|
236
|
+
// report.title (best summary for mobile push).
|
|
237
|
+
// 2. Legacy severity/title/body shape (sentry-triage et al.) →
|
|
238
|
+
// buildSlackBlocks renders the simple alert card.
|
|
239
|
+
//
|
|
240
|
+
// The two paths are mutually exclusive at runtime; `report` wins
|
|
241
|
+
// if both are present (a misconfiguration the parent shouldn't do,
|
|
242
|
+
// but we resolve it predictably rather than mixing the outputs).
|
|
243
|
+
let blocks;
|
|
244
|
+
let text;
|
|
245
|
+
if (state.report && typeof state.report === 'object') {
|
|
246
|
+
blocks = reportToBlockKit(state.report);
|
|
247
|
+
const title = state.report.title || 'Report';
|
|
248
|
+
const headline = state.report.headline?.primary
|
|
249
|
+
? `: ${state.report.headline.primary}`
|
|
250
|
+
: '';
|
|
251
|
+
text = `${title}${headline}`.slice(0, 200);
|
|
252
|
+
} else {
|
|
253
|
+
blocks = buildSlackBlocks(state);
|
|
254
|
+
// Plain-text fallback — Slack renders this in mobile push
|
|
255
|
+
// notifications when blocks are absent. Severity + title is the
|
|
256
|
+
// most useful summary in <100 chars.
|
|
257
|
+
text = `${(state.severity || 'medium').toUpperCase()}: ${state.title}`.slice(0, 200);
|
|
258
|
+
}
|
|
229
259
|
|
|
230
260
|
const result = await postToSlack({ token, channel, blocks, text });
|
|
231
261
|
return {
|
package/notify-slack/state.js
CHANGED
|
@@ -27,8 +27,12 @@ export const notifySlackInputSchema = z.object({
|
|
|
27
27
|
.default('medium')
|
|
28
28
|
.describe('Alert severity. Drives color + mention strategy.'),
|
|
29
29
|
|
|
30
|
-
title
|
|
31
|
-
|
|
30
|
+
// Required in legacy-mode (severity/title/body alerts from sentry-triage
|
|
31
|
+
// and friends). In rich-mode (when `report` is set), title is sourced
|
|
32
|
+
// from `report.title` so the top-level field is optional. The execute
|
|
33
|
+
// path enforces "at least one source of title" at runtime.
|
|
34
|
+
title: z.string().min(1).max(300).optional()
|
|
35
|
+
.describe('Short one-line headline (renders in the Slack header). Required when `report` is absent.'),
|
|
32
36
|
|
|
33
37
|
body: z.string().max(3000).optional()
|
|
34
38
|
.describe('Body text. Supports Slack mrkdwn (e.g. *bold*, `code`, <url|text>).'),
|
|
@@ -76,6 +80,21 @@ export const notifySlackInputSchema = z.object({
|
|
|
76
80
|
// the output for thread replies later.
|
|
77
81
|
idempotencyKey: z.string().max(128).optional()
|
|
78
82
|
.describe('Caller-supplied dedup key. The node still posts; idempotency is the parent\'s job today.'),
|
|
83
|
+
|
|
84
|
+
// ── Rich-report mode ──────────────────────────────────────────────
|
|
85
|
+
// When `report` is present, the node renders the report-object via
|
|
86
|
+
// reportToBlockKit() and IGNORES severity/title/body/sentryLink/etc.
|
|
87
|
+
// (those legacy fields are exclusively for the simple-alert path
|
|
88
|
+
// used by sentry-triage et al.).
|
|
89
|
+
//
|
|
90
|
+
// Schema validation is intentionally permissive here — the contract
|
|
91
|
+
// lives in @zibby/skills's reportObjectSchema. We declare it as a
|
|
92
|
+
// free-form object on the wire so old runtimes that don't know about
|
|
93
|
+
// the field don't fail validation, and the actual structure is
|
|
94
|
+
// validated by reportToBlockKit at render time. New consumers should
|
|
95
|
+
// import reportObjectSchema from @zibby/skills for tighter typing.
|
|
96
|
+
report: z.record(z.any()).optional()
|
|
97
|
+
.describe('Rich report-object (see @zibby/skills/report). When set, supersedes severity/title/body — the node renders a full Block-Kit card.'),
|
|
79
98
|
});
|
|
80
99
|
|
|
81
100
|
export const notifySlackContextSchema = z.object({
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zibby/workflow-templates",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Built-in workflow templates for Zibby — browser-test-automation, code-analysis, generate-test-cases, notify-slack, notify-lark, sentry-triage.",
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"description": "Built-in workflow templates for Zibby — browser-test-automation, code-analysis, generate-test-cases, notify-slack, notify-lark, notify-notion, sentry-triage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
@@ -19,8 +19,12 @@
|
|
|
19
19
|
"./notify-slack/*": "./notify-slack/*",
|
|
20
20
|
"./notify-lark": "./notify-lark/graph.mjs",
|
|
21
21
|
"./notify-lark/*": "./notify-lark/*",
|
|
22
|
+
"./notify-notion": "./notify-notion/graph.mjs",
|
|
23
|
+
"./notify-notion/*": "./notify-notion/*",
|
|
22
24
|
"./sentry-triage": "./sentry-triage/graph.mjs",
|
|
23
25
|
"./sentry-triage/*": "./sentry-triage/*",
|
|
26
|
+
"./ai-spend-weekly-digest": "./ai-spend-weekly-digest/graph.mjs",
|
|
27
|
+
"./ai-spend-weekly-digest/*": "./ai-spend-weekly-digest/*",
|
|
24
28
|
"./package.json": "./package.json"
|
|
25
29
|
},
|
|
26
30
|
"scripts": {
|
|
@@ -50,6 +54,7 @@
|
|
|
50
54
|
"generate-test-cases/",
|
|
51
55
|
"notify-slack/",
|
|
52
56
|
"notify-lark/",
|
|
57
|
+
"notify-notion/",
|
|
53
58
|
"sentry-triage/",
|
|
54
59
|
"index.js",
|
|
55
60
|
"register-nodes.js",
|