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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 21:36:50 +08:00
parent e8b7d7331f
commit 83c3796fbb
12 changed files with 482 additions and 23 deletions

View file

@ -236,6 +236,84 @@ export function NetxopsCard(props: NetxopsCardProps) {
? <p className="dsh-nx-invalid" role="status">{pushStatus.lastError}</p>
: null}
</div>
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<label className="dsh-nx-label" htmlFor="netxops-alarm-dsh">{t('alarmDeliverDsh')}</label>
{state.alarmDeliverDsh.overridden
? (
<span className="dsh-nx-badges">
<span className="dsh-nx-badge">{t('overridden')}</span>
<button type="button" className="dsh-nx-reset" disabled={disabled} onClick={() => { props.resetField('alarmDeliverDsh') }}>
{t('reset')}
</button>
</span>
)
: null}
</div>
<label className="dsh-nx-checkRow" htmlFor="netxops-alarm-dsh">
<input
id="netxops-alarm-dsh"
type="checkbox"
checked={state.alarmDeliverDsh.text === 'true'}
disabled={disabled}
onChange={(event) => {
props.edit('alarmDeliverDsh', event.target.checked ? 'true' : 'false')
}}
/>
<span>{t('alarmDeliverDshHint')}</span>
</label>
</div>
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<label className="dsh-nx-label" htmlFor="netxops-alarm-im">{t('alarmDeliverIm')}</label>
{state.alarmDeliverIm.overridden
? (
<span className="dsh-nx-badges">
<span className="dsh-nx-badge">{t('overridden')}</span>
<button type="button" className="dsh-nx-reset" disabled={disabled} onClick={() => { props.resetField('alarmDeliverIm') }}>
{t('reset')}
</button>
</span>
)
: null}
</div>
<label className="dsh-nx-checkRow" htmlFor="netxops-alarm-im">
<input
id="netxops-alarm-im"
type="checkbox"
checked={state.alarmDeliverIm.text === 'true'}
disabled={disabled}
onChange={(event) => {
props.edit('alarmDeliverIm', event.target.checked ? 'true' : 'false')
}}
/>
<span>{t('alarmDeliverImHint')}</span>
</label>
</div>
<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={disabled}
onEdit={(text) => { props.edit('imBotId', text) }}
onReset={() => { props.resetField('imBotId') }}
/>
<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={disabled}
onEdit={(text) => { props.edit('imTargetId', text) }}
onReset={() => { props.resetField('imTargetId') }}
/>
<div className="dsh-nx-footer">
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
<button

View file

@ -80,6 +80,27 @@ export function booleanField(field: string): CardFieldSpec {
}
}
/**
* Boolean that persists `false` explicitly (use when schema default is `true`).
* Clearing would restore the default and make "off" impossible.
*/
export function booleanFieldPersistFalse(field: string): CardFieldSpec {
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 undefined
},
}
}
export class CardForm<T> {
private readonly specs: Map<string, CardFieldSpec>
private readonly secretSpecs: Map<string, CardSecretSpec>

View file

@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SnapshotStore } from './snapshot-store.ts'
import {
CardForm, textField, booleanField,
CardForm, textField, booleanField, booleanFieldPersistFalse,
type CardActions, type CardFieldState, type CardShell,
} from './card-form.ts'
import {
@ -26,6 +26,10 @@ export interface NetxopsSettings {
lang?: string
tokenCredentialRef?: string
alarmPushEnabled?: boolean
alarmDeliverDsh?: boolean
alarmDeliverIm?: boolean
imBotId?: string
imTargetId?: string
}
interface CredentialState {
@ -39,6 +43,10 @@ export interface NetxopsCardState extends CardShell {
apiUrl: CardFieldState
lang: CardFieldState
alarmPushEnabled: CardFieldState
alarmDeliverDsh: CardFieldState
alarmDeliverIm: CardFieldState
imBotId: CardFieldState
imTargetId: CardFieldState
apiToken: CardFieldState
apiTokenConfigured: boolean
apiTokenWritable: boolean
@ -78,7 +86,15 @@ export class NetxopsCardController {
) {
this.form = new CardForm(
scope,
[textField('apiUrl'), textField('lang'), booleanField('alarmPushEnabled')],
[
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())
@ -158,6 +174,10 @@ export 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,

View file

@ -18,6 +18,14 @@ export type NetxopsLocaleKey =
| 'alarmPushPhaseReconnecting'
| 'alarmPushPhaseAuthFailed'
| 'alarmPushPhaseError'
| 'alarmDeliverDsh'
| 'alarmDeliverDshHint'
| 'alarmDeliverIm'
| 'alarmDeliverImHint'
| 'imBotId'
| 'imBotIdHint'
| 'imTargetId'
| 'imTargetIdHint'
| 'apiToken'
| 'apiTokenHint'
| 'apiTokenUnavailable'
@ -43,7 +51,7 @@ export const en: Record<NetxopsLocaleKey, string> = {
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',
@ -53,6 +61,14 @@ export const en: Record<NetxopsLocaleKey, string> = {
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.',
@ -79,7 +95,7 @@ export const zh: Record<NetxopsLocaleKey, string> = {
lang: '语言',
langHint: '响应语言提示(zh / en)。',
alarmPushEnabled: '关键告警推送',
alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后在本机打开/续写一条 DSH 会话。不必安装 WhatsApp / im。',
alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。',
alarmPushStatus: '推送链路',
alarmPushPhaseDisabled: '未开启',
alarmPushPhaseIdle: '空闲',
@ -89,6 +105,14 @@ export const zh: Record<NetxopsLocaleKey, string> = {
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 在监视凭据文件则无需重启。',

View file

@ -25,6 +25,7 @@ import {
publishAlarmPushStatus,
resetAlarmPushStatus,
} from './netx/alarm-push-status.ts'
import { deliverAlarmToIm } from './netx/alarm-im.ts'
import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts'
import { publishNetxConnection } from './netx/runtime.ts'
@ -66,6 +67,17 @@ export interface Config {
* alarms into a sticky DSH session.
*/
alarmPushEnabled: boolean
/** When push is on, also followup the sticky Netx Ops DSH session (default). */
alarmDeliverDsh: boolean
/**
* When push is on, also send via `ctx.dshIm.send(imBotId, imTargetId, …)`.
* Requires dsh-im-ops and a saved delivery target.
*/
alarmDeliverIm: boolean
/** Opaque bot id from IM「投递设置」→ 复制调用参数. */
imBotId: string
/** Opaque target id from the same copy payload. */
imTargetId: string
}
export const Config: z<Config> = z.object({
@ -75,6 +87,10 @@ export const Config: z<Config> = z.object({
toolCallTimeoutMs: z.number().step(1).min(1000).default(120_000),
installAgentPreset: z.boolean().default(true),
alarmPushEnabled: z.boolean().default(false),
alarmDeliverDsh: z.boolean().default(true),
alarmDeliverIm: z.boolean().default(false),
imBotId: z.string().default(''),
imTargetId: z.string().default(''),
})
/** Package root (parent of `lib/` or `src/` depending on launch). */
@ -171,11 +187,11 @@ export function apply(ctx: Context, config: Config = Config({})): void {
ensureAgentPresetInstalled(ctx.logger)
}
const restartAlarmPush = (apiUrl: string, token: string, enabled: boolean, lang: string): void => {
const restartAlarmPush = (current: Config, apiUrl: string, token: string): void => {
stopAlarmPush?.()
stopAlarmPush = undefined
resetAlarmSession()
if (!enabled) {
if (!current.alarmPushEnabled) {
resetAlarmPushStatus()
return
}
@ -190,11 +206,26 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})
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,
})
},
})
}
@ -213,7 +244,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
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)',
@ -222,9 +253,11 @@ export function apply(ctx: Context, config: Config = Config({})): void {
)
} else {
ctx.logger.info(
'netxops: published connection → %s tokenConfigured=true alarmPush=%s',
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s',
apiUrl,
current.alarmPushEnabled === true,
current.alarmDeliverDsh !== false,
current.alarmDeliverIm === true,
)
}
}).catch((error) => {

58
src/netx/alarm-im.ts Normal file
View file

@ -0,0 +1,58 @@
/**
* Optional IM sink for key alarms (soft-depends on dsh-im `ctx.dshIm`).
*/
import type { Context } from '@deepseek-ai/cordis'
import { formatAlarmPrompt, type KeyAlarmPayload } from './alarm-push.ts'
export interface AlarmImDeliveryOptions {
enabled: boolean
botId: string
targetId: string
lang: string
}
type DshIm = {
send: (botId: string, targetId: string, text: string, opts?: { signal?: AbortSignal }) => Promise<unknown>
}
/**
* Send a formatted key-alarm text through proactive IM delivery when configured.
* @param ctx - host cordis context (may lack `dshIm`).
* @param payload - matched alarm.
* @param options - settings snapshot for IM sink.
*/
export async function deliverAlarmToIm(
ctx: Context,
payload: KeyAlarmPayload,
options: AlarmImDeliveryOptions,
): Promise<void> {
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
}
// Soft inject: im plugin may be absent on this profile.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const im = (typeof (ctx as any).get === 'function' ? (ctx as any).get('dshIm') : undefined) as DshIm | undefined
?? ((ctx as { dshIm?: DshIm }).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)
}
}