From 83c3796fbbeea5afef983218f482db078cbdd311 Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 5 Sep 2026 21:36:50 +0800 Subject: [PATCH] Add optional IM sink for key alarms (0.1.17). Settings choose DSH session and/or WhatsApp via botId+targetId; onAlarm soft-calls ctx.dshIm.send when im is installed. Co-authored-by: Cursor --- README.md | 2 +- cordis.patch.yml | 4 + docs/INSTALL.md | 6 +- lib/client.js | 184 ++++++++++++++++++++++++++++++++++++- lib/index.js | 55 +++++++++-- package.json | 2 +- src/client/NetxopsCard.tsx | 78 ++++++++++++++++ src/client/card-form.ts | 21 +++++ src/client/controller.ts | 24 ++++- src/client/locales.ts | 28 +++++- src/index.ts | 43 ++++++++- src/netx/alarm-im.ts | 58 ++++++++++++ 12 files changed, 482 insertions(+), 23 deletions(-) create mode 100644 src/netx/alarm-im.ts diff --git a/README.md b/README.md index 38da63b..a7b2775 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ dsh web Then: 1. Settings → **Plugins** → **Netx Ops** → API URL / token -2. Optional: enable **关键告警推送** — DSH dials out to netx and opens a sticky session on matched key alarms (WhatsApp / im not required). The card shows live WSS status (Connected / Reconnecting / Auth failed). +2. Optional: enable **关键告警推送** — DSH dials out to netx; choose **Deliver to DSH session** and/or **Deliver to WhatsApp / IM** (paste `botId`+`targetId` from IM 投递设置). The card shows live WSS status. 3. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate) 4. New session → choose **Netx Ops** diff --git a/cordis.patch.yml b/cordis.patch.yml index 79a92d9..2f3cbdb 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -14,3 +14,7 @@ toolCallTimeoutMs: 120000 installAgentPreset: true alarmPushEnabled: false + alarmDeliverDsh: true + alarmDeliverIm: false + imBotId: '' + imTargetId: '' diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 055307b..25ed50d 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -30,16 +30,16 @@ dsh web # or: pnpm dsh web 1. **Settings → Plugins → Netx Ops** → API URL (+ token if the field is enabled). Token fallback: `scripts/set-netx-token.ps1` / `.sh`. - Optional: enable **关键告警推送** so this DSH dials `ws(s):///v1/integrations/dsh-alarm/ws` and follows a sticky session when netx matches a key alert. The card header shows live WSS status (`Connected` / `Reconnecting` / `Auth failed`). + Optional: enable **关键告警推送** so this DSH dials `ws(s):///v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status. 2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once. 3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms. ## Key-alarm push - **netx** (fixed IP) hosts the subscribe hub and fans out matched key alerts. -- **netxops** (each DSH) reuses the same URL/token, connects outbound, and delivers into a DSH session. +- **netxops** (each DSH) reuses the same URL/token, connects outbound, and delivers into a DSH session and/or IM via `ctx.dshIm.send`. - Live status is exposed on the Plugins card via Connection RPC (`/netxops` → `alarm-push.status`); no curl required for day-to-day checks. -- WhatsApp / `dsh-im` is **not** required for this path. +- WhatsApp / `dsh-im` is **optional** for the DSH session path; required only when IM delivery is checked. ## Verify diff --git a/lib/client.js b/lib/client.js index 869326d..5f64dad 100644 --- a/lib/client.js +++ b/lib/client.js @@ -456,6 +456,140 @@ function NetxopsCard(props) { }) : null ] }), + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-field", + children: [ + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-fieldHead", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("label", { + className: "dsh-nx-label", + htmlFor: "netxops-alarm-dsh", + children: t("alarmDeliverDsh") + }), + state.alarmDeliverDsh.overridden ? /* @__PURE__ */ jsx_runtime.jsxs("span", { + className: "dsh-nx-badges", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("span", { + className: "dsh-nx-badge", + children: t("overridden") + }), + /* @__PURE__ */ jsx_runtime.jsx("button", { + type: "button", + className: "dsh-nx-reset", + disabled, + onClick: () => { + props.resetField("alarmDeliverDsh"); + }, + children: t("reset") + }) + ] + }) : null + ] + }), + /* @__PURE__ */ jsx_runtime.jsxs("label", { + className: "dsh-nx-checkRow", + htmlFor: "netxops-alarm-dsh", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("input", { + id: "netxops-alarm-dsh", + type: "checkbox", + checked: state.alarmDeliverDsh.text === "true", + disabled, + onChange: (event) => { + props.edit("alarmDeliverDsh", event.target.checked ? "true" : "false"); + } + }), + /* @__PURE__ */ jsx_runtime.jsx("span", { + children: t("alarmDeliverDshHint") + }) + ] + }) + ] + }), + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-field", + children: [ + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-fieldHead", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("label", { + className: "dsh-nx-label", + htmlFor: "netxops-alarm-im", + children: t("alarmDeliverIm") + }), + state.alarmDeliverIm.overridden ? /* @__PURE__ */ jsx_runtime.jsxs("span", { + className: "dsh-nx-badges", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("span", { + className: "dsh-nx-badge", + children: t("overridden") + }), + /* @__PURE__ */ jsx_runtime.jsx("button", { + type: "button", + className: "dsh-nx-reset", + disabled, + onClick: () => { + props.resetField("alarmDeliverIm"); + }, + children: t("reset") + }) + ] + }) : null + ] + }), + /* @__PURE__ */ jsx_runtime.jsxs("label", { + className: "dsh-nx-checkRow", + htmlFor: "netxops-alarm-im", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("input", { + id: "netxops-alarm-im", + type: "checkbox", + checked: state.alarmDeliverIm.text === "true", + disabled, + onChange: (event) => { + props.edit("alarmDeliverIm", event.target.checked ? "true" : "false"); + } + }), + /* @__PURE__ */ jsx_runtime.jsx("span", { + children: t("alarmDeliverImHint") + }) + ] + }) + ] + }), + /* @__PURE__ */ jsx_runtime.jsx(ValueField, { + id: "netxops-im-bot-id", + label: t("imBotId"), + hint: t("imBotIdHint"), + field: state.imBotId, + overriddenLabel: t("overridden"), + resetLabel: t("reset"), + invalidLabel: t("invalid"), + disabled, + onEdit: (text) => { + props.edit("imBotId", text); + }, + onReset: () => { + props.resetField("imBotId"); + } + }), + /* @__PURE__ */ jsx_runtime.jsx(ValueField, { + id: "netxops-im-target-id", + label: t("imTargetId"), + hint: t("imTargetIdHint"), + field: state.imTargetId, + overriddenLabel: t("overridden"), + resetLabel: t("reset"), + invalidLabel: t("invalid"), + disabled, + onEdit: (text) => { + props.edit("imTargetId", text); + }, + onReset: () => { + props.resetField("imTargetId"); + } + }), /* @__PURE__ */ jsx_runtime.jsxs("div", { className: "dsh-nx-footer", children: [ @@ -533,6 +667,22 @@ function booleanField(field) { } }; } +function booleanFieldPersistFalse(field) { + return { + field, + format: (value) => value === true ? "true" : "false", + parse: (text) => { + const normalized = text.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes") { + return { kind: "set", value: true }; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "") { + return { kind: "set", value: false }; + } + return; + } + }; +} class CardForm { scope; @@ -716,7 +866,15 @@ class NetxopsCardController { constructor(scope, ctx) { this.scope = scope; this.ctx = ctx; - this.form = new CardForm(scope, [textField("apiUrl"), textField("lang"), booleanField("alarmPushEnabled")], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]); + this.form = new CardForm(scope, [ + textField("apiUrl"), + textField("lang"), + booleanField("alarmPushEnabled"), + booleanFieldPersistFalse("alarmDeliverDsh"), + booleanField("alarmDeliverIm"), + textField("imBotId"), + textField("imTargetId") + ], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]); this.store = this.form.bind(() => this.projection()); scope.subscribe(() => { this.readCredential(); @@ -783,6 +941,10 @@ class NetxopsCardController { apiUrl: this.form.field("apiUrl"), lang: this.form.field("lang"), alarmPushEnabled: this.form.field("alarmPushEnabled"), + alarmDeliverDsh: this.form.field("alarmDeliverDsh"), + alarmDeliverIm: this.form.field("alarmDeliverIm"), + imBotId: this.form.field("imBotId"), + imTargetId: this.form.field("imTargetId"), apiToken: this.form.field(API_TOKEN_FIELD), apiTokenConfigured: this.credential.configured, apiTokenWritable: this.credential.remoteReady && this.credential.writable, @@ -858,7 +1020,7 @@ var en = { lang: "Language", langHint: "Response language hint (zh / en).", alarmPushEnabled: "Key-alarm push", - alarmPushEnabledHint: "Dial out to netx and open/follow a DSH session when a matched key alarm arrives. WhatsApp/im is optional and not required.", + alarmPushEnabledHint: "Dial out to netx when a matched key alarm arrives. Choose DSH and/or IM sinks below.", alarmPushStatus: "Push link", alarmPushPhaseDisabled: "Off", alarmPushPhaseIdle: "Idle", @@ -868,6 +1030,14 @@ var en = { alarmPushPhaseReconnecting: "Reconnecting…", alarmPushPhaseAuthFailed: "Auth failed", alarmPushPhaseError: "Error", + alarmDeliverDsh: "Deliver to DSH session", + alarmDeliverDshHint: "Open/follow the sticky「Netx 关键告警」session on this Host.", + alarmDeliverIm: "Deliver to WhatsApp / IM", + alarmDeliverImHint: "Requires dsh-im-ops. Paste botId + targetId from IM → 投递设置 → 复制调用参数.", + imBotId: "IM Bot ID", + imBotIdHint: "Opaque botId from IM delivery settings (not a phone number).", + imTargetId: "IM Target ID", + imTargetIdHint: "Opaque targetId alias (e.g. release-alerts). Create the target in IM first.", apiToken: "API token", apiTokenHint: "Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.", apiTokenUnavailable: "This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.", @@ -893,7 +1063,7 @@ var zh = { lang: "语言", langHint: "响应语言提示(zh / en)。", alarmPushEnabled: "关键告警推送", - alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后在本机打开/续写一条 DSH 会话。不必安装 WhatsApp / im。", + alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。", alarmPushStatus: "推送链路", alarmPushPhaseDisabled: "未开启", alarmPushPhaseIdle: "空闲", @@ -903,6 +1073,14 @@ var zh = { alarmPushPhaseReconnecting: "重连中…", alarmPushPhaseAuthFailed: "鉴权失败", alarmPushPhaseError: "异常", + alarmDeliverDsh: "投递到 DSH 会话", + alarmDeliverDshHint: "在本机打开/续写 sticky「Netx 关键告警」会话。", + alarmDeliverIm: "投递到 WhatsApp / IM", + alarmDeliverImHint: "需要已安装 dsh-im-ops。请先在 IM「投递设置」新建目标,再粘贴「复制调用参数」里的 botId / targetId。", + imBotId: "IM Bot ID", + imBotIdHint: "来自 IM 投递设置的不透明 botId(不是手机号)。", + imTargetId: "IM Target ID", + imTargetIdHint: "投递目标别名(如 release-alerts)。请先在 IM 侧创建目标。", apiToken: "API Token", apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。", apiTokenUnavailable: "当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。", diff --git a/lib/index.js b/lib/index.js index 8fb98dc..5811244 100644 --- a/lib/index.js +++ b/lib/index.js @@ -250,6 +250,30 @@ function startAlarmPushClient(options) { }; } +// src/netx/alarm-im.ts +async function deliverAlarmToIm(ctx, payload, options) { + if (!options.enabled) + return; + const botId = options.botId.trim(); + const targetId = options.targetId.trim(); + if (!botId || !targetId) { + ctx.logger.warn("netxops alarm-im: enabled but imBotId/imTargetId empty — skip IM delivery (create a target in IM 投递设置 and paste botId+targetId)"); + return; + } + const im = (typeof ctx.get === "function" ? ctx.get("dshIm") : undefined) ?? ctx.dshIm; + if (!im || typeof im.send !== "function") { + ctx.logger.warn("netxops alarm-im: ctx.dshIm unavailable — install/enable dsh-im-ops to deliver alarms to WhatsApp/IM"); + return; + } + const text = formatAlarmPrompt(payload, options.lang); + try { + await im.send(botId, targetId, text); + ctx.logger.info("netxops alarm-im: sent to botId=%s targetId=%s", botId, targetId); + } catch (error) { + ctx.logger.warn("netxops alarm-im: send failed: %s", error); + } +} + // src/netx/alarm-session.ts import { homedir } from "node:os"; import { join } from "node:path"; @@ -400,7 +424,11 @@ var Config = z.object({ tokenCredentialRef: z.string().role("credential-ref").default(DEFAULT_TOKEN_REF), toolCallTimeoutMs: z.number().step(1).min(1000).default(120000), installAgentPreset: z.boolean().default(true), - alarmPushEnabled: z.boolean().default(false) + alarmPushEnabled: z.boolean().default(false), + alarmDeliverDsh: z.boolean().default(true), + alarmDeliverIm: z.boolean().default(false), + imBotId: z.string().default(""), + imTargetId: z.string().default("") }); function packageRoot() { return join2(dirname(fileURLToPath(import.meta.url)), ".."); @@ -455,11 +483,11 @@ function apply(ctx, config = Config({})) { if (config.installAgentPreset) { ensureAgentPresetInstalled(ctx.logger); } - const restartAlarmPush = (apiUrl, token, enabled, lang) => { + const restartAlarmPush = (current, apiUrl, token) => { stopAlarmPush?.(); stopAlarmPush = undefined; resetAlarmSession(); - if (!enabled) { + if (!current.alarmPushEnabled) { resetAlarmPushStatus(); return; } @@ -474,11 +502,26 @@ function apply(ctx, config = Config({})) { }); return; } + const lang = current.lang; + const deliverDsh = current.alarmDeliverDsh !== false; + const deliverIm = current.alarmDeliverIm === true; + const imBotId = current.imBotId ?? ""; + const imTargetId = current.imTargetId ?? ""; stopAlarmPush = startAlarmPushClient({ apiUrl, token, logger: ctx.logger, - onAlarm: (payload) => deliverAlarmToSession(ctx, payload, lang) + onAlarm: async (payload) => { + if (deliverDsh) { + await deliverAlarmToSession(ctx, payload, lang); + } + await deliverAlarmToIm(ctx, payload, { + enabled: deliverIm, + botId: imBotId, + targetId: imTargetId, + lang + }); + } }); }; const publish = () => { @@ -496,11 +539,11 @@ function apply(ctx, config = Config({})) { lang: current.lang, toolCallTimeoutMs: current.toolCallTimeoutMs }); - restartAlarmPush(apiUrl, token, current.alarmPushEnabled === true, current.lang); + restartAlarmPush(current, apiUrl, token); if (!tokenConfigured) { ctx.logger.warn("netxops: published connection → %s tokenConfigured=false (set credential %s)", apiUrl, current.tokenCredentialRef); } else { - ctx.logger.info("netxops: published connection → %s tokenConfigured=true alarmPush=%s", apiUrl, current.alarmPushEnabled === true); + ctx.logger.info("netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s", apiUrl, current.alarmPushEnabled === true, current.alarmDeliverDsh !== false, current.alarmDeliverIm === true); } }).catch((error) => { ctx.logger.error("netxops: connection publish error: %s", error); diff --git a/package.json b/package.json index 3e3e13d..39651f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.16", + "version": "0.1.17", "description": "DeepSeek Harness Netx Ops: Ops-scoped netx__* REST tools + key-alarm push to DSH sessions + Plugins settings card + agent preset", "license": "MIT", "type": "module", diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index 99668e3..f2de017 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -236,6 +236,84 @@ export function NetxopsCard(props: NetxopsCardProps) { ?

{pushStatus.lastError}

: null} +
+
+ + {state.alarmDeliverDsh.overridden + ? ( + + {t('overridden')} + + + ) + : null} +
+ +
+
+
+ + {state.alarmDeliverIm.overridden + ? ( + + {t('overridden')} + + + ) + : null} +
+ +
+ { props.edit('imBotId', text) }} + onReset={() => { props.resetField('imBotId') }} + /> + { props.edit('imTargetId', text) }} + onReset={() => { props.resetField('imTargetId') }} + />
{state.failed ?

{t('saveFailed')}

: null}