chainlesschain 0.45.4 → 0.45.6

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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * NotificationManager — unified multi-channel notification dispatcher.
3
+ *
4
+ * Supported channels:
5
+ * telegram — Telegram Bot API
6
+ * wecom — 企业微信群机器人 Webhook
7
+ * dingtalk — 钉钉群机器人 Webhook (支持加签)
8
+ * feishu — 飞书群机器人 Webhook (支持签名校验)
9
+ * websocket — Real-time push back over the triggering WS connection
10
+ *
11
+ * Supported incoming command sources (via HTTP Webhook receiver):
12
+ * wecom — 企业微信 回调 (XML format)
13
+ * dingtalk — 钉钉 outgoing webhook
14
+ * feishu — 飞书 event subscription
15
+ *
16
+ * Configuration (any combination via env vars or options):
17
+ * TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID
18
+ * WECOM_WEBHOOK_URL
19
+ * DINGTALK_WEBHOOK_URL [+ DINGTALK_SECRET]
20
+ * FEISHU_WEBHOOK_URL [+ FEISHU_SECRET]
21
+ *
22
+ * Usage:
23
+ * const nm = NotificationManager.fromEnv();
24
+ * await nm.notifySuccess({ taskId, description, ... });
25
+ *
26
+ * // With WebSocket channel:
27
+ * nm.addWebSocketChannel({ send: (data) => ws._send(ws, data), requestId });
28
+ */
29
+
30
+ import { TelegramNotifier } from "./telegram.js";
31
+ import { WeComNotifier } from "./wecom.js";
32
+ import { DingTalkNotifier } from "./dingtalk.js";
33
+ import { FeishuNotifier } from "./feishu.js";
34
+ import { WebSocketNotifier } from "./websocket.js";
35
+
36
+ // ─── Incoming command parsers ─────────────────────────────────────
37
+
38
+ /**
39
+ * Parse a 钉钉 outgoing webhook payload into a command string.
40
+ * DingTalk sends: { msgtype: "text", text: { content: "..." }, ... }
41
+ */
42
+ export function parseDingTalkIncoming(body) {
43
+ if (body?.msgtype === "text") {
44
+ return body.text?.content?.trim() || null;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Parse a 飞书 event subscription payload into a command string.
51
+ * Feishu sends: { event: { message: { content: '{"text":"..."}' } } }
52
+ */
53
+ export function parseFeishuIncoming(body) {
54
+ try {
55
+ const content = body?.event?.message?.content;
56
+ if (content) {
57
+ const parsed = JSON.parse(content);
58
+ return parsed.text?.trim() || null;
59
+ }
60
+ } catch (_err) {
61
+ // ignore
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Parse a 企业微信 回调 XML body into a command string.
68
+ * WeCom sends XML; this extracts the <Content> field using simple regex
69
+ * (no heavy XML parser needed for plain text messages).
70
+ */
71
+ export function parseWeComIncoming(xmlBody) {
72
+ const match = String(xmlBody).match(
73
+ /<Content><!\[CDATA\[([^\]]*)\]\]><\/Content>/,
74
+ );
75
+ return match ? match[1].trim() : null;
76
+ }
77
+
78
+ // ─── Notification Manager ─────────────────────────────────────────
79
+
80
+ export class NotificationManager {
81
+ constructor() {
82
+ /** @type {Array<object>} registered notifier instances */
83
+ this._channels = [];
84
+ }
85
+
86
+ /**
87
+ * Build a NotificationManager from environment variables.
88
+ * Any configured channel is automatically added.
89
+ */
90
+ static fromEnv(options = {}) {
91
+ const nm = new NotificationManager();
92
+
93
+ const telegram = new TelegramNotifier(options.telegram || {});
94
+ if (telegram.isConfigured) nm.add("telegram", telegram);
95
+
96
+ const wecom = new WeComNotifier(options.wecom || {});
97
+ if (wecom.isConfigured) nm.add("wecom", wecom);
98
+
99
+ const dingtalk = new DingTalkNotifier(options.dingtalk || {});
100
+ if (dingtalk.isConfigured) nm.add("dingtalk", dingtalk);
101
+
102
+ const feishu = new FeishuNotifier(options.feishu || {});
103
+ if (feishu.isConfigured) nm.add("feishu", feishu);
104
+
105
+ return nm;
106
+ }
107
+
108
+ /**
109
+ * Register a notifier under a name.
110
+ * @param {string} name
111
+ * @param {object} notifier - must implement notifySuccess/notifyFailure/notifyStart
112
+ */
113
+ add(name, notifier) {
114
+ // Remove existing channel with same name before re-adding
115
+ this._channels = this._channels.filter((c) => c.name !== name);
116
+ this._channels.push({ name, notifier });
117
+ return this;
118
+ }
119
+
120
+ /**
121
+ * Add (or replace) the WebSocket channel.
122
+ * Call this when an orchestration task is triggered from a WS connection.
123
+ *
124
+ * @param {object} options
125
+ * @param {Function} options.send - (data: object) => void
126
+ * @param {string} [options.requestId] - correlating request ID
127
+ */
128
+ addWebSocketChannel(options) {
129
+ const notifier = new WebSocketNotifier(options);
130
+ this.add("websocket", notifier);
131
+ return notifier; // caller may use directly for agent output streaming
132
+ }
133
+
134
+ /** Remove a channel by name. */
135
+ remove(name) {
136
+ this._channels = this._channels.filter((c) => c.name !== name);
137
+ return this;
138
+ }
139
+
140
+ /** List active channel names. */
141
+ get activeChannels() {
142
+ return this._channels.map((c) => c.name);
143
+ }
144
+
145
+ get isConfigured() {
146
+ return this._channels.length > 0;
147
+ }
148
+
149
+ // ─── Broadcast methods ──────────────────────────────────────────
150
+
151
+ /** Fan-out to all channels; collect results. */
152
+ async _broadcast(method, payload) {
153
+ const results = await Promise.allSettled(
154
+ this._channels.map(({ name, notifier }) =>
155
+ notifier[method]?.(payload)
156
+ .then((r) => ({ channel: name, ...r }))
157
+ .catch((err) => ({ channel: name, ok: false, reason: err.message })),
158
+ ),
159
+ );
160
+ return results.map((r) =>
161
+ r.status === "fulfilled" ? r.value : { ok: false, ...r.reason },
162
+ );
163
+ }
164
+
165
+ async notifyStart(summary) {
166
+ return this._broadcast("notifyStart", summary);
167
+ }
168
+
169
+ async notifySuccess(summary) {
170
+ return this._broadcast("notifySuccess", summary);
171
+ }
172
+
173
+ async notifyFailure(summary) {
174
+ return this._broadcast("notifyFailure", summary);
175
+ }
176
+ }
177
+
178
+ // Re-export individual notifiers for direct use
179
+ export { TelegramNotifier } from "./telegram.js";
180
+ export { WeComNotifier } from "./wecom.js";
181
+ export { DingTalkNotifier } from "./dingtalk.js";
182
+ export { FeishuNotifier } from "./feishu.js";
183
+ export { WebSocketNotifier } from "./websocket.js";
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Telegram Notifier — sends CI pass/fail notifications via Telegram Bot API.
3
+ *
4
+ * Configure via .chainlesschain/config.json:
5
+ * { "telegramBotToken": "...", "telegramChatId": "..." }
6
+ * or environment variables:
7
+ * TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
8
+ */
9
+
10
+ const TELEGRAM_API = "https://api.telegram.org";
11
+
12
+ export class TelegramNotifier {
13
+ /**
14
+ * @param {object} options
15
+ * @param {string} options.token - Bot token (from BotFather)
16
+ * @param {string} options.chatId - Chat/group/channel ID
17
+ */
18
+ constructor(options = {}) {
19
+ this.token = options.token || process.env.TELEGRAM_BOT_TOKEN || "";
20
+ this.chatId = options.chatId || process.env.TELEGRAM_CHAT_ID || "";
21
+ }
22
+
23
+ get isConfigured() {
24
+ return Boolean(this.token && this.chatId);
25
+ }
26
+
27
+ /**
28
+ * Send a raw text message (Markdown V2).
29
+ * @param {string} text
30
+ */
31
+ async send(text) {
32
+ if (!this.isConfigured) return { ok: false, reason: "not configured" };
33
+
34
+ try {
35
+ const res = await fetch(`${TELEGRAM_API}/bot${this.token}/sendMessage`, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({
39
+ chat_id: this.chatId,
40
+ text,
41
+ parse_mode: "HTML",
42
+ }),
43
+ });
44
+ const data = await res.json();
45
+ return { ok: data.ok, data };
46
+ } catch (err) {
47
+ return { ok: false, reason: err.message };
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Notify that a task's CI pipeline passed.
53
+ * @param {object} summary
54
+ * @param {string} summary.taskId
55
+ * @param {string} summary.description - Short task description
56
+ * @param {number} summary.agentCount - How many agents worked on it
57
+ * @param {number} summary.duration - Total ms
58
+ * @param {string[]} summary.filesChanged
59
+ */
60
+ async notifySuccess(summary) {
61
+ const {
62
+ taskId,
63
+ description,
64
+ agentCount = 1,
65
+ duration,
66
+ filesChanged = [],
67
+ } = summary;
68
+ const mins = duration ? Math.round(duration / 60_000) : "?";
69
+ const files = filesChanged.length
70
+ ? `\n📁 <b>Files:</b> ${filesChanged.slice(0, 5).join(", ")}${filesChanged.length > 5 ? ` +${filesChanged.length - 5} more` : ""}`
71
+ : "";
72
+
73
+ const text =
74
+ `✅ <b>CI Passed</b>\n` +
75
+ `📝 <b>Task:</b> ${_escape(description)}\n` +
76
+ `🤖 <b>Agents:</b> ${agentCount} ⏱ <b>Time:</b> ${mins}m` +
77
+ files +
78
+ `\n🔑 <code>${taskId}</code>`;
79
+
80
+ return this.send(text);
81
+ }
82
+
83
+ /**
84
+ * Notify that CI failed and agents are being retried.
85
+ * @param {object} summary
86
+ * @param {string} summary.taskId
87
+ * @param {string} summary.description
88
+ * @param {string[]} summary.errors - CI error messages
89
+ * @param {number} summary.retryNumber - Which retry attempt
90
+ */
91
+ async notifyFailure(summary) {
92
+ const { taskId, description, errors = [], retryNumber = 1 } = summary;
93
+ const errPreview = errors
94
+ .slice(0, 3)
95
+ .map((e) => ` • ${_escape(e.slice(0, 120))}`)
96
+ .join("\n");
97
+
98
+ const text =
99
+ `❌ <b>CI Failed</b> (retry #${retryNumber})\n` +
100
+ `📝 <b>Task:</b> ${_escape(description)}\n` +
101
+ (errPreview ? `\n<b>Errors:</b>\n${errPreview}\n` : "") +
102
+ `🔄 Dispatching fix to agents…\n` +
103
+ `🔑 <code>${taskId}</code>`;
104
+
105
+ return this.send(text);
106
+ }
107
+
108
+ /**
109
+ * Notify that orchestration is starting.
110
+ * @param {object} summary
111
+ * @param {string} summary.taskId
112
+ * @param {string} summary.description
113
+ * @param {number} summary.subtaskCount
114
+ */
115
+ async notifyStart(summary) {
116
+ const { taskId, description, subtaskCount = 1 } = summary;
117
+ const text =
118
+ `🚀 <b>Orchestration Started</b>\n` +
119
+ `📝 <b>Task:</b> ${_escape(description)}\n` +
120
+ `🤖 <b>Subtasks:</b> ${subtaskCount}\n` +
121
+ `🔑 <code>${taskId}</code>`;
122
+ return this.send(text);
123
+ }
124
+ }
125
+
126
+ function _escape(str) {
127
+ return String(str)
128
+ .replace(/&/g, "&amp;")
129
+ .replace(/</g, "&lt;")
130
+ .replace(/>/g, "&gt;");
131
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * WebSocket Notifier — pushes orchestration events back over the active WS connection.
3
+ *
4
+ * When `cc orchestrate` is triggered via the WebSocket interface, notifications
5
+ * are sent to the originating client in real-time instead of (or in addition to)
6
+ * external channels.
7
+ *
8
+ * The caller passes a `sendFn` bound to the specific WS client:
9
+ * const wsNotifier = new WebSocketNotifier({ send: (data) => _send(ws, data) });
10
+ */
11
+
12
+ export class WebSocketNotifier {
13
+ /**
14
+ * @param {object} options
15
+ * @param {Function} options.send - (data: object) => void — bound to one WS client
16
+ * @param {string} [options.taskId] - correlate events to the originating request
17
+ */
18
+ constructor(options = {}) {
19
+ this._send = options.send || (() => {});
20
+ this.requestId = options.requestId || null;
21
+ }
22
+
23
+ get isConfigured() {
24
+ return typeof this._send === "function";
25
+ }
26
+
27
+ /** Send a typed orchestration event to the WebSocket client. */
28
+ send(event, payload = {}) {
29
+ this._send({
30
+ type: "orchestrate:event",
31
+ event,
32
+ requestId: this.requestId,
33
+ ...payload,
34
+ ts: Date.now(),
35
+ });
36
+ }
37
+
38
+ async notifyStart(summary) {
39
+ this.send("start", summary);
40
+ return { ok: true };
41
+ }
42
+
43
+ async notifySuccess(summary) {
44
+ this.send("ci:pass", summary);
45
+ return { ok: true };
46
+ }
47
+
48
+ async notifyFailure(summary) {
49
+ this.send("ci:fail", summary);
50
+ return { ok: true };
51
+ }
52
+
53
+ /** Forward real-time agent output chunk to the client. */
54
+ sendAgentOutput({ agentId, taskId, chunk }) {
55
+ this.send("agent:output", { agentId, taskId, chunk });
56
+ }
57
+
58
+ /** Forward task status update. */
59
+ sendStatus(task) {
60
+ this.send("task:status", {
61
+ taskId: task.id,
62
+ status: task.status,
63
+ retries: task.retries,
64
+ subtasks: task.subtasks?.length,
65
+ });
66
+ }
67
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * WeCom (企业微信) Notifier — sends notifications via 企业微信群机器人 Webhook.
3
+ *
4
+ * Configure via env:
5
+ * WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
6
+ * or options.webhookUrl
7
+ */
8
+
9
+ export class WeComNotifier {
10
+ constructor(options = {}) {
11
+ this.webhookUrl = options.webhookUrl || process.env.WECOM_WEBHOOK_URL || "";
12
+ }
13
+
14
+ get isConfigured() {
15
+ return Boolean(this.webhookUrl);
16
+ }
17
+
18
+ /**
19
+ * Send a markdown message to the 企业微信 group bot.
20
+ * @param {string} content - Markdown content (企业微信 subset)
21
+ */
22
+ async send(content) {
23
+ if (!this.isConfigured) return { ok: false, reason: "not configured" };
24
+ try {
25
+ const res = await fetch(this.webhookUrl, {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify({
29
+ msgtype: "markdown",
30
+ markdown: { content },
31
+ }),
32
+ });
33
+ const data = await res.json();
34
+ return { ok: data.errcode === 0, data };
35
+ } catch (err) {
36
+ return { ok: false, reason: err.message };
37
+ }
38
+ }
39
+
40
+ async notifySuccess(summary) {
41
+ const { taskId, description, agentCount = 1, duration } = summary;
42
+ const mins = duration ? Math.round(duration / 60_000) : "?";
43
+ const content =
44
+ `## ✅ CI 通过\n` +
45
+ `**任务**: ${description.slice(0, 100)}\n` +
46
+ `**Agent 数**: ${agentCount} **耗时**: ${mins}m\n` +
47
+ `**ID**: \`${taskId}\``;
48
+ return this.send(content);
49
+ }
50
+
51
+ async notifyFailure(summary) {
52
+ const { taskId, description, errors = [], retryNumber = 1 } = summary;
53
+ const errLines = errors
54
+ .slice(0, 3)
55
+ .map((e) => `> ${e.slice(0, 100)}`)
56
+ .join("\n");
57
+ const content =
58
+ `## ❌ CI 失败 (第 ${retryNumber} 次重试)\n` +
59
+ `**任务**: ${description.slice(0, 100)}\n` +
60
+ (errLines ? `**错误**:\n${errLines}\n` : "") +
61
+ `**ID**: \`${taskId}\``;
62
+ return this.send(content);
63
+ }
64
+
65
+ async notifyStart(summary) {
66
+ const { taskId, description, subtaskCount = 1 } = summary;
67
+ const content =
68
+ `## 🚀 开始编排\n` +
69
+ `**任务**: ${description.slice(0, 100)}\n` +
70
+ `**子任务**: ${subtaskCount}\n` +
71
+ `**ID**: \`${taskId}\``;
72
+ return this.send(content);
73
+ }
74
+ }