@xmoxmo/bncr 0.2.7 → 0.2.9

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,24 @@
1
+ import { CHANNEL_ID } from '../core/accounts.ts';
2
+ import { applyOpenClawAccountNameToChannelSection } from '../openclaw/sdk-helpers.ts';
3
+
4
+ export const BNCR_SETUP_SURFACE = {
5
+ applyAccountName: ({ cfg, accountId, name }: any) =>
6
+ applyOpenClawAccountNameToChannelSection({
7
+ cfg,
8
+ channelKey: CHANNEL_ID,
9
+ accountId,
10
+ name,
11
+ alwaysUseAccounts: true,
12
+ }),
13
+ applyAccountConfig: ({ cfg, accountId }: any) => {
14
+ const next = { ...(cfg || {}) } as any;
15
+ next.channels = next.channels || {};
16
+ next.channels[CHANNEL_ID] = next.channels[CHANNEL_ID] || {};
17
+ next.channels[CHANNEL_ID].accounts = next.channels[CHANNEL_ID].accounts || {};
18
+ next.channels[CHANNEL_ID].accounts[accountId] = {
19
+ ...(next.channels[CHANNEL_ID].accounts[accountId] || {}),
20
+ enabled: true,
21
+ };
22
+ return next;
23
+ },
24
+ };
@@ -0,0 +1,38 @@
1
+ import { BNCR_DEFAULT_ACCOUNT_ID, resolveAccount, resolveDefaultDisplayName } from '../core/accounts.ts';
2
+ import { buildAccountStatusSnapshot } from '../core/status.ts';
3
+ import { createOpenClawDefaultChannelRuntimeState } from '../openclaw/sdk-helpers.ts';
4
+
5
+ export type BncrStatusBridge = {
6
+ getChannelSummary: (defaultAccountId: string) => unknown | Promise<unknown>;
7
+ getAccountRuntimeSnapshot: (accountId?: string) => unknown;
8
+ getStatusHeadline: (accountId?: string) => unknown;
9
+ };
10
+
11
+ export function createBncrStatusSurface(getBridge: () => BncrStatusBridge) {
12
+ return {
13
+ defaultRuntime: createOpenClawDefaultChannelRuntimeState(BNCR_DEFAULT_ACCOUNT_ID, {
14
+ mode: 'ws-offline',
15
+ }),
16
+ buildChannelSummary: async ({ defaultAccountId }: any) => {
17
+ return getBridge().getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
18
+ },
19
+ buildAccountSnapshot: async ({ account, runtime }: any) => {
20
+ const runtimeBridge = getBridge();
21
+ const rt = runtime || runtimeBridge.getAccountRuntimeSnapshot(account?.accountId);
22
+ return buildAccountStatusSnapshot({
23
+ account,
24
+ runtime: rt,
25
+ healthSummary: runtimeBridge.getStatusHeadline(account?.accountId),
26
+ // default 名不可隐藏时,统一展示稳定默认值
27
+ displayName: resolveDefaultDisplayName(account?.name, account?.accountId),
28
+ });
29
+ },
30
+ resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
31
+ if (!enabled) return 'disabled';
32
+ const resolved = resolveAccount(cfg, account?.accountId);
33
+ if (!(resolved.enabled && configured)) return 'not configured';
34
+ const rt = runtime || getBridge().getAccountRuntimeSnapshot(account?.accountId);
35
+ return (rt as any)?.connected ? 'linked' : 'configured';
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,56 @@
1
+ export type LogDedupeStateEntry = {
2
+ at: number;
3
+ sig: string;
4
+ };
5
+
6
+ export type LogDedupeState = Map<string, LogDedupeStateEntry>;
7
+
8
+ export const LOG_DEDUPE_STATE_TTL_MS = 10 * 60 * 1000;
9
+ export const LOG_DEDUPE_STATE_MAX_ENTRIES = 1_000;
10
+
11
+ export function pruneLogDedupeState(
12
+ state: LogDedupeState,
13
+ currentTime: number,
14
+ options?: {
15
+ ttlMs?: number;
16
+ maxEntries?: number;
17
+ },
18
+ ) {
19
+ const ttlMs = options?.ttlMs ?? LOG_DEDUPE_STATE_TTL_MS;
20
+ const maxEntries = options?.maxEntries ?? LOG_DEDUPE_STATE_MAX_ENTRIES;
21
+
22
+ for (const [key, entry] of state.entries()) {
23
+ if (currentTime - entry.at > ttlMs) {
24
+ state.delete(key);
25
+ }
26
+ }
27
+
28
+ while (state.size > maxEntries) {
29
+ const oldestKey = state.keys().next().value;
30
+ if (!oldestKey) break;
31
+ state.delete(oldestKey);
32
+ }
33
+ }
34
+
35
+ export function shouldEmitDedupLog(args: {
36
+ state: LogDedupeState;
37
+ key: string;
38
+ sig: string;
39
+ nowMs: number;
40
+ windowMs?: number;
41
+ ttlMs?: number;
42
+ maxEntries?: number;
43
+ }) {
44
+ const windowMs = args.windowMs ?? 5 * 60 * 1000;
45
+ const pruneOptions = {
46
+ ttlMs: args.ttlMs,
47
+ maxEntries: args.maxEntries,
48
+ };
49
+
50
+ pruneLogDedupeState(args.state, args.nowMs, pruneOptions);
51
+ const prev = args.state.get(args.key) || null;
52
+ if (prev && prev.sig === args.sig && args.nowMs - prev.at < windowMs) return false;
53
+ args.state.set(args.key, { at: args.nowMs, sig: args.sig });
54
+ pruneLogDedupeState(args.state, args.nowMs, pruneOptions);
55
+ return true;
56
+ }
@@ -0,0 +1,96 @@
1
+ type ComputeBncrRecommendedAckTimeoutArgs = {
2
+ lateAckOkCount: number;
3
+ recentAckTimeoutCount: number;
4
+ lastLateAckPushLatencyMs: number | null;
5
+ lastLateAckOkAt?: number | null;
6
+ adaptiveAckRecoveryOkCount?: number;
7
+ nowMs: number;
8
+ defaultAckTimeoutMs: number;
9
+ minAckTimeoutMs: number;
10
+ maxAckTimeoutMs: number;
11
+ lateAckObservationTtlMs: number;
12
+ recoveryOkThreshold: number;
13
+ };
14
+
15
+ type ComputeBncrRecommendedAckTimeoutReasonArgs = ComputeBncrRecommendedAckTimeoutArgs & {
16
+ recommendedAckTimeoutMs?: number;
17
+ };
18
+
19
+ function isLateAckObservationExpired(args: {
20
+ lastLateAckOkAt?: number | null;
21
+ nowMs: number;
22
+ lateAckObservationTtlMs: number;
23
+ }) {
24
+ const lastLateAckOkAt = typeof args.lastLateAckOkAt === 'number' ? args.lastLateAckOkAt : null;
25
+ return (
26
+ typeof lastLateAckOkAt === 'number' &&
27
+ lastLateAckOkAt > 0 &&
28
+ args.nowMs - lastLateAckOkAt > args.lateAckObservationTtlMs
29
+ );
30
+ }
31
+
32
+ function isAdaptiveAckRecovered(args: {
33
+ adaptiveAckRecoveryOkCount?: number;
34
+ recoveryOkThreshold: number;
35
+ }) {
36
+ return (
37
+ typeof args.adaptiveAckRecoveryOkCount === 'number' &&
38
+ args.adaptiveAckRecoveryOkCount >= args.recoveryOkThreshold
39
+ );
40
+ }
41
+
42
+ export function computeBncrRecommendedAckTimeoutReason(
43
+ args: ComputeBncrRecommendedAckTimeoutReasonArgs,
44
+ ) {
45
+ if (args.recentAckTimeoutCount <= 0) return 'no-timeout-evidence';
46
+ if (args.lateAckOkCount <= 0) return 'no-late-ack-evidence';
47
+ if (typeof args.lastLateAckPushLatencyMs !== 'number') return 'missing-latency';
48
+ if (isLateAckObservationExpired(args)) return 'late-ack-expired';
49
+ if (isAdaptiveAckRecovered(args)) return 'recovered';
50
+ if (args.recommendedAckTimeoutMs === args.maxAckTimeoutMs) return 'capped-max';
51
+ return 'late-ack-observed';
52
+ }
53
+
54
+ export function computeBncrRecommendedAckTimeoutMs(args: ComputeBncrRecommendedAckTimeoutArgs) {
55
+ if (
56
+ args.lateAckOkCount <= 0 ||
57
+ args.recentAckTimeoutCount <= 0 ||
58
+ typeof args.lastLateAckPushLatencyMs !== 'number' ||
59
+ isLateAckObservationExpired(args) ||
60
+ isAdaptiveAckRecovered(args)
61
+ ) {
62
+ return args.defaultAckTimeoutMs;
63
+ }
64
+ const recommended = Math.ceil(args.lastLateAckPushLatencyMs * 1.25);
65
+ return Math.min(args.maxAckTimeoutMs, Math.max(args.minAckTimeoutMs, recommended));
66
+ }
67
+
68
+ function finiteNumberOr(value: unknown, fallback: number) {
69
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
70
+ }
71
+
72
+ function asString(value: unknown, fallback = '') {
73
+ return typeof value === 'string' ? value : fallback;
74
+ }
75
+
76
+ export function buildBncrRuntimeAckStrategy(args: {
77
+ ackObservability: Record<string, any>;
78
+ defaultAckTimeoutMs: number;
79
+ maxAckTimeoutMs: number;
80
+ }) {
81
+ const { ackObservability } = args;
82
+ const currentMs = finiteNumberOr(ackObservability.currentAckTimeoutMs, args.defaultAckTimeoutMs);
83
+ const defaultMs = finiteNumberOr(ackObservability.defaultAckTimeoutMs, args.defaultAckTimeoutMs);
84
+ const reason = asString(ackObservability.recommendedAckTimeoutReason || 'unknown') || 'unknown';
85
+ return {
86
+ mode: ackObservability.adaptiveAckTimeoutEnabled === true ? 'adaptive' : 'fixed',
87
+ currentMs,
88
+ defaultMs,
89
+ maxMs: args.maxAckTimeoutMs,
90
+ reason,
91
+ active: currentMs > defaultMs,
92
+ lastLateAckAgeMs: ackObservability.lastLateAckAgeMs ?? null,
93
+ lateAckObservationTtlMs: ackObservability.lateAckObservationTtlMs ?? null,
94
+ recovered: ackObservability.adaptiveAckRecovered === true,
95
+ };
96
+ }
@@ -0,0 +1,81 @@
1
+ import { CHANNEL_ID, BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId } from '../core/accounts.ts';
2
+ import { getOpenClawRuntimeConfig } from '../openclaw/config-runtime.ts';
3
+
4
+ type RuntimeApiHolder = { api: unknown };
5
+
6
+ type ResolveOutboundAckRequiredArgs = RuntimeApiHolder & {
7
+ accountId?: string;
8
+ };
9
+
10
+ export function resolveBncrOutboundAckRequired(args: ResolveOutboundAckRequiredArgs) {
11
+ try {
12
+ const cfg = getOpenClawRuntimeConfig(args.api as any);
13
+ const channelCfg = (cfg as any)?.channels?.[CHANNEL_ID];
14
+ const accountCfg =
15
+ args.accountId && channelCfg?.accounts && typeof channelCfg.accounts === 'object'
16
+ ? (channelCfg.accounts as Record<string, any>)[normalizeAccountId(args.accountId)]
17
+ : null;
18
+ const scoped = accountCfg?.outboundRequireAck;
19
+ const global = channelCfg?.outboundRequireAck;
20
+ if (typeof scoped === 'boolean') return scoped;
21
+ if (typeof global === 'boolean') return global;
22
+ return true;
23
+ } catch {
24
+ return true;
25
+ }
26
+ }
27
+
28
+ type BuildBncrRuntimeFlagsArgs = RuntimeApiHolder & {
29
+ accountId?: string;
30
+ resolveMessageAckTimeoutMs: (accountId?: string) => number;
31
+ adaptiveAckTimeoutEnabled: boolean;
32
+ defaultMessageAckTimeoutMs: number;
33
+ fileAckTimeoutMs: number;
34
+ debugVerbose: boolean;
35
+ };
36
+
37
+ export function buildBncrRuntimeStatusInput(args: {
38
+ accountId: string;
39
+ connected: boolean;
40
+ queueSnapshot: Record<string, any>;
41
+ eventCounters: Record<string, any>;
42
+ activitySnapshot: Record<string, any>;
43
+ startedAt: number | null;
44
+ running?: boolean;
45
+ channelRoot: string;
46
+ }) {
47
+ return {
48
+ accountId: args.accountId,
49
+ connected: args.connected,
50
+ ...args.queueSnapshot,
51
+ ...args.eventCounters,
52
+ ...args.activitySnapshot,
53
+ startedAt: args.startedAt,
54
+ running: args.running,
55
+ channelRoot: args.channelRoot,
56
+ };
57
+ }
58
+
59
+ export function buildBncrRuntimeFlags(args: BuildBncrRuntimeFlagsArgs) {
60
+ let ackPolicySource: 'channel' | 'default' = 'default';
61
+ try {
62
+ const cfg = getOpenClawRuntimeConfig(args.api as any);
63
+ const global = (cfg as any)?.channels?.[CHANNEL_ID]?.outboundRequireAck;
64
+ if (typeof global === 'boolean') ackPolicySource = 'channel';
65
+ } catch {
66
+ // keep default source
67
+ }
68
+ const accountId = args.accountId ? normalizeAccountId(args.accountId) : BNCR_DEFAULT_ACCOUNT_ID;
69
+ return {
70
+ outboundRequireAck: resolveBncrOutboundAckRequired({
71
+ api: args.api,
72
+ accountId,
73
+ }),
74
+ ackPolicySource,
75
+ messageAckTimeoutMs: args.resolveMessageAckTimeoutMs(accountId),
76
+ adaptiveAckTimeoutEnabled: args.adaptiveAckTimeoutEnabled,
77
+ defaultMessageAckTimeoutMs: args.defaultMessageAckTimeoutMs,
78
+ fileAckTimeoutMs: args.fileAckTimeoutMs,
79
+ debugVerbose: args.debugVerbose,
80
+ };
81
+ }
@@ -0,0 +1,119 @@
1
+ import type { OutboxEntry } from '../core/types.ts';
2
+ import type {
3
+ PushFailureDecision,
4
+ RetryRerouteDecision,
5
+ } from '../messaging/outbound/retry-policy.ts';
6
+
7
+ export function applyBncrRetryRerouteDecisionToEntry(
8
+ entry: OutboxEntry,
9
+ decision: Extract<RetryRerouteDecision, { kind: 'retry' }>,
10
+ ): OutboxEntry {
11
+ return {
12
+ ...entry,
13
+ routeAttemptConnIds: decision.attemptedConnIds,
14
+ fastReroutePending: decision.fastReroutePending,
15
+ retryCount: decision.nextRetryCount,
16
+ lastAttemptAt: decision.lastAttemptAt,
17
+ nextAttemptAt: decision.nextAttemptAt,
18
+ lastError: decision.lastError,
19
+ routeAttemptRound: decision.routeAttemptRound,
20
+ };
21
+ }
22
+
23
+ export function applyBncrPushFailureDecisionToEntry(
24
+ entry: OutboxEntry,
25
+ decision: Extract<PushFailureDecision, { kind: 'retry' }>,
26
+ ): OutboxEntry {
27
+ return {
28
+ ...entry,
29
+ retryCount: decision.nextRetryCount,
30
+ lastAttemptAt: decision.lastAttemptAt,
31
+ nextAttemptAt: decision.nextAttemptAt,
32
+ lastError: decision.lastError,
33
+ };
34
+ }
35
+
36
+ export function buildBncrAckRetryEntryPatch(args: {
37
+ entry: OutboxEntry;
38
+ error: string;
39
+ nextAttemptAt: number;
40
+ }): OutboxEntry {
41
+ return {
42
+ ...args.entry,
43
+ nextAttemptAt: args.nextAttemptAt,
44
+ lastError: args.error,
45
+ awaitingRetryPush: true,
46
+ };
47
+ }
48
+
49
+ export function buildBncrOutboxFailureEntryPatch(args: {
50
+ entry: OutboxEntry;
51
+ lastError: string;
52
+ }): OutboxEntry {
53
+ return {
54
+ ...args.entry,
55
+ lastError: args.lastError,
56
+ };
57
+ }
58
+
59
+ export function buildBncrOutboxPushSuccessEntryPatch(args: {
60
+ entry: OutboxEntry;
61
+ connIds: Iterable<string>;
62
+ pushedAt: number;
63
+ ownerConnId?: string;
64
+ ownerClientId?: string;
65
+ clearLastError?: boolean;
66
+ }): OutboxEntry {
67
+ const connIds = Array.from(args.connIds);
68
+ const lastPushConnId = args.ownerConnId || (connIds.length === 1 ? connIds[0] : undefined);
69
+ const routeAttemptConnIds = Array.isArray(args.entry.routeAttemptConnIds)
70
+ ? [...args.entry.routeAttemptConnIds]
71
+ : [];
72
+ if (lastPushConnId && !routeAttemptConnIds.includes(lastPushConnId)) {
73
+ routeAttemptConnIds.push(lastPushConnId);
74
+ }
75
+ return {
76
+ ...args.entry,
77
+ lastPushAt: args.pushedAt,
78
+ lastPushConnId,
79
+ lastPushClientId: args.ownerClientId,
80
+ awaitingRetryPush: false,
81
+ routeAttemptConnIds,
82
+ lastError: args.clearLastError ? undefined : args.entry.lastError,
83
+ };
84
+ }
85
+
86
+ function finiteNumberOr(value: unknown, fallback: number): number {
87
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
88
+ }
89
+
90
+ export function buildBncrAckOkTelemetryPatch(args: {
91
+ entry: OutboxEntry;
92
+ ackAt: number;
93
+ defaultAckTimeoutMs: number;
94
+ }): {
95
+ ackAt: number;
96
+ ackQueueLatencyMs: number;
97
+ ackPushLatencyMs: number | null;
98
+ lateAccepted: boolean;
99
+ shouldResetAdaptiveAckRecovery: boolean;
100
+ shouldIncrementAdaptiveAckRecovery: boolean;
101
+ } {
102
+ const ackAt = finiteNumberOr(args.ackAt, 0);
103
+ const defaultAckTimeoutMs = Math.max(0, finiteNumberOr(args.defaultAckTimeoutMs, 0));
104
+ const ackQueueLatencyMs = Math.max(0, ackAt - finiteNumberOr(args.entry.createdAt, ackAt));
105
+ const ackPushLatencyMs =
106
+ typeof args.entry.lastPushAt === 'number'
107
+ ? Math.max(0, ackAt - args.entry.lastPushAt)
108
+ : null;
109
+ const lateAccepted = args.entry.awaitingRetryPush === true;
110
+ return {
111
+ ackAt,
112
+ ackQueueLatencyMs,
113
+ ackPushLatencyMs,
114
+ lateAccepted,
115
+ shouldResetAdaptiveAckRecovery: lateAccepted,
116
+ shouldIncrementAdaptiveAckRecovery:
117
+ !lateAccepted && typeof ackPushLatencyMs === 'number' && ackPushLatencyMs <= defaultAckTimeoutMs,
118
+ };
119
+ }
@@ -0,0 +1,108 @@
1
+ import { normalizeAccountId } from '../core/accounts.ts';
2
+ import type { OutboxEntry } from '../core/types.ts';
3
+
4
+ function getCounter(map: Map<string, number>, accountId: string): number {
5
+ return map.get(normalizeAccountId(accountId)) || 0;
6
+ }
7
+
8
+ export function buildRuntimeQueueSnapshot(args: {
9
+ accountId: string;
10
+ outboxEntries: Iterable<OutboxEntry>;
11
+ deadLetterEntries: Iterable<OutboxEntry>;
12
+ sessionRouteEntries: Iterable<{ accountId: string }>;
13
+ countInvalidOutboxSessionKeys: (accountId: string) => number;
14
+ countLegacyAccountResidue: (accountId: string) => number;
15
+ }) {
16
+ const accountId = normalizeAccountId(args.accountId);
17
+ const pending = Array.from(args.outboxEntries).filter((v) => v.accountId === accountId).length;
18
+ const deadLetter = Array.from(args.deadLetterEntries).filter((v) => v.accountId === accountId).length;
19
+ const sessionRoutesCount = Array.from(args.sessionRouteEntries).filter(
20
+ (v) => v.accountId === accountId,
21
+ ).length;
22
+ return {
23
+ pending,
24
+ deadLetter,
25
+ sessionRoutesCount,
26
+ invalidOutboxSessionKeys: args.countInvalidOutboxSessionKeys(accountId),
27
+ legacyAccountResidue: args.countLegacyAccountResidue(accountId),
28
+ };
29
+ }
30
+
31
+ export function buildRuntimeEventCounters(args: {
32
+ accountId: string;
33
+ connectEventsByAccount: Map<string, number>;
34
+ inboundEventsByAccount: Map<string, number>;
35
+ activityEventsByAccount: Map<string, number>;
36
+ ackEventsByAccount: Map<string, number>;
37
+ }) {
38
+ const accountId = normalizeAccountId(args.accountId);
39
+ return {
40
+ connectEvents: getCounter(args.connectEventsByAccount, accountId),
41
+ inboundEvents: getCounter(args.inboundEventsByAccount, accountId),
42
+ activityEvents: getCounter(args.activityEventsByAccount, accountId),
43
+ ackEvents: getCounter(args.ackEventsByAccount, accountId),
44
+ };
45
+ }
46
+
47
+ export function buildRuntimeActivitySnapshot(args: {
48
+ accountId: string;
49
+ activeConnectionCount: (accountId: string) => number;
50
+ lastSessionByAccount: Map<string, { sessionKey: string; scope: string; updatedAt: number }>;
51
+ lastActivityByAccount: Map<string, number>;
52
+ lastInboundByAccount: Map<string, number>;
53
+ lastOutboundByAccount: Map<string, number>;
54
+ }) {
55
+ const accountId = normalizeAccountId(args.accountId);
56
+ return {
57
+ activeConnections: args.activeConnectionCount(accountId),
58
+ lastSession: args.lastSessionByAccount.get(accountId) || null,
59
+ lastActivityAt: args.lastActivityByAccount.get(accountId) || null,
60
+ lastInboundAt: args.lastInboundByAccount.get(accountId) || null,
61
+ lastOutboundAt: args.lastOutboundByAccount.get(accountId) || null,
62
+ };
63
+ }
64
+
65
+ export function buildRuntimeStatusSnapshots(args: {
66
+ accountId: string;
67
+ outboxEntries: Iterable<OutboxEntry>;
68
+ deadLetterEntries: Iterable<OutboxEntry>;
69
+ sessionRouteEntries: Iterable<{ accountId: string }>;
70
+ countInvalidOutboxSessionKeys: (accountId: string) => number;
71
+ countLegacyAccountResidue: (accountId: string) => number;
72
+ connectEventsByAccount: Map<string, number>;
73
+ inboundEventsByAccount: Map<string, number>;
74
+ activityEventsByAccount: Map<string, number>;
75
+ ackEventsByAccount: Map<string, number>;
76
+ activeConnectionCount: (accountId: string) => number;
77
+ lastSessionByAccount: Map<string, { sessionKey: string; scope: string; updatedAt: number }>;
78
+ lastActivityByAccount: Map<string, number>;
79
+ lastInboundByAccount: Map<string, number>;
80
+ lastOutboundByAccount: Map<string, number>;
81
+ }) {
82
+ const accountId = normalizeAccountId(args.accountId);
83
+ return {
84
+ queueSnapshot: buildRuntimeQueueSnapshot({
85
+ accountId,
86
+ outboxEntries: args.outboxEntries,
87
+ deadLetterEntries: args.deadLetterEntries,
88
+ sessionRouteEntries: args.sessionRouteEntries,
89
+ countInvalidOutboxSessionKeys: args.countInvalidOutboxSessionKeys,
90
+ countLegacyAccountResidue: args.countLegacyAccountResidue,
91
+ }),
92
+ eventCounters: buildRuntimeEventCounters({
93
+ accountId,
94
+ connectEventsByAccount: args.connectEventsByAccount,
95
+ inboundEventsByAccount: args.inboundEventsByAccount,
96
+ activityEventsByAccount: args.activityEventsByAccount,
97
+ ackEventsByAccount: args.ackEventsByAccount,
98
+ }),
99
+ activitySnapshot: buildRuntimeActivitySnapshot({
100
+ accountId,
101
+ activeConnectionCount: args.activeConnectionCount,
102
+ lastSessionByAccount: args.lastSessionByAccount,
103
+ lastActivityByAccount: args.lastActivityByAccount,
104
+ lastInboundByAccount: args.lastInboundByAccount,
105
+ lastOutboundByAccount: args.lastOutboundByAccount,
106
+ }),
107
+ };
108
+ }
@@ -0,0 +1,172 @@
1
+ import { normalizeAccountId } from '../core/accounts.ts';
2
+
3
+ type StatusWorkerContext = {
4
+ accountId: string;
5
+ getStatus?: () => Record<string, any>;
6
+ setStatus?: (status: Record<string, any>) => void;
7
+ abortSignal?: {
8
+ aborted?: boolean;
9
+ addEventListener?: (event: 'abort', listener: () => void, options?: { once?: boolean }) => void;
10
+ removeEventListener?: (event: 'abort', listener: () => void) => void;
11
+ };
12
+ };
13
+
14
+ export type ChannelAccountWorkerHandle = {
15
+ timer: NodeJS.Timeout;
16
+ finish: (reason: string) => void;
17
+ cleanupAbortListener?: () => void;
18
+ };
19
+
20
+ type StatusWorkerHooks = {
21
+ isOnline: (accountId: string) => boolean;
22
+ hasRecentInboundReachability: (accountId: string) => boolean;
23
+ getLastActivityAt: (accountId: string, previous: Record<string, any>) => number | null;
24
+ getActiveConnectionKey: (accountId: string) => string | null;
25
+ getActiveConnections: (accountId: string) => Array<Record<string, unknown>>;
26
+ buildStatusMeta: (accountId: string) => Record<string, any>;
27
+ logInfo: (scope: string | undefined, message: string, options?: { debugOnly?: boolean }) => void;
28
+ logInfoDedup: (
29
+ scope: string | undefined,
30
+ message: string,
31
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
32
+ ) => void;
33
+ };
34
+
35
+ type StatusWorkerRuntime = {
36
+ workers: Map<string, ChannelAccountWorkerHandle>;
37
+ bridgeId: string;
38
+ hooks: StatusWorkerHooks;
39
+ };
40
+
41
+ export function clearBncrStatusWorker(
42
+ runtime: StatusWorkerRuntime,
43
+ accountId: string,
44
+ reason: string,
45
+ ) {
46
+ const worker = runtime.workers.get(accountId);
47
+ if (!worker) return false;
48
+ worker.finish(reason);
49
+ runtime.hooks.logInfo(
50
+ 'health',
51
+ `status-worker cleared ${JSON.stringify({ bridge: runtime.bridgeId, accountId, reason })}`,
52
+ { debugOnly: true },
53
+ );
54
+ return true;
55
+ }
56
+
57
+ export function clearAllBncrStatusWorkers(runtime: StatusWorkerRuntime, reason: string) {
58
+ for (const accountId of Array.from(runtime.workers.keys())) {
59
+ clearBncrStatusWorker(runtime, accountId, reason);
60
+ }
61
+ }
62
+
63
+ export async function startBncrStatusWorker(runtime: StatusWorkerRuntime, ctx: StatusWorkerContext) {
64
+ const accountId = normalizeAccountId(ctx.accountId);
65
+ clearBncrStatusWorker(runtime, accountId, 'start-replace');
66
+
67
+ const tick = () => {
68
+ const previous = ctx.getStatus?.() || {};
69
+ const onlineByConn = runtime.hooks.isOnline(accountId);
70
+ const recentInboundReachable = runtime.hooks.hasRecentInboundReachability(accountId);
71
+ const connected = onlineByConn || recentInboundReachable;
72
+ const lastActAt = runtime.hooks.getLastActivityAt(accountId, previous);
73
+ const activeConnections = runtime.hooks.getActiveConnections(accountId);
74
+ const healthSig = JSON.stringify({
75
+ bridge: runtime.bridgeId,
76
+ accountId,
77
+ connected,
78
+ onlineByConn,
79
+ recentInboundReachable,
80
+ activeConnectionKey: runtime.hooks.getActiveConnectionKey(accountId),
81
+ activeConnections,
82
+ });
83
+ const conns = activeConnections.length;
84
+ runtime.hooks.logInfoDedup(
85
+ 'health',
86
+ `status-tick ${accountId}|changed|${connected ? 'linked' : 'configured'}|onlineByConn=${onlineByConn}|recentInboundReachable=${recentInboundReachable}|conns=${conns}`,
87
+ {
88
+ key: `health-status-tick:${accountId}`,
89
+ sig: healthSig,
90
+ },
91
+ );
92
+ runtime.hooks.logInfoDedup('health', `status-tick ${healthSig}`, {
93
+ key: `health-status-tick-debug:${accountId}`,
94
+ sig: healthSig,
95
+ debugOnly: true,
96
+ });
97
+
98
+ ctx.setStatus?.({
99
+ ...previous,
100
+ accountId,
101
+ running: true,
102
+ connected,
103
+ lastEventAt: lastActAt,
104
+ // 状态映射:在线=linked,离线=configured
105
+ mode: connected ? 'linked' : 'configured',
106
+ lastError: previous?.lastError ?? null,
107
+ meta: runtime.hooks.buildStatusMeta(accountId),
108
+ });
109
+ };
110
+
111
+ tick();
112
+ const timer = setInterval(tick, 5_000);
113
+ let worker!: ChannelAccountWorkerHandle;
114
+ const done = new Promise<void>((resolve) => {
115
+ let settled = false;
116
+ const finish = (reason: string) => {
117
+ if (settled) return;
118
+ settled = true;
119
+ const activeWorker = runtime.workers.get(accountId);
120
+ if (activeWorker === worker) {
121
+ runtime.workers.delete(accountId);
122
+ }
123
+ clearInterval(timer);
124
+ worker.cleanupAbortListener?.();
125
+ worker.cleanupAbortListener = undefined;
126
+ runtime.hooks.logInfo(
127
+ 'health',
128
+ `status-worker finished ${JSON.stringify({ bridge: runtime.bridgeId, accountId, reason })}`,
129
+ { debugOnly: true },
130
+ );
131
+ runtime.hooks.logInfo('health', `status-worker finished ${accountId}|${reason}`);
132
+ resolve();
133
+ };
134
+
135
+ worker = { timer, finish };
136
+ runtime.workers.set(accountId, worker);
137
+
138
+ const onAbort = () => finish('abort');
139
+ const abortSignal = ctx.abortSignal;
140
+
141
+ if (abortSignal?.aborted) {
142
+ onAbort();
143
+ return;
144
+ }
145
+
146
+ abortSignal?.addEventListener?.('abort', onAbort, { once: true });
147
+ if (abortSignal?.removeEventListener) {
148
+ worker.cleanupAbortListener = () => abortSignal.removeEventListener?.('abort', onAbort);
149
+ }
150
+ });
151
+ await done;
152
+ }
153
+
154
+ export async function stopBncrStatusWorker(runtime: StatusWorkerRuntime, ctx: Partial<StatusWorkerContext>) {
155
+ const accountId = normalizeAccountId(ctx?.accountId);
156
+ const cleared = clearBncrStatusWorker(runtime, accountId, 'explicit-stop');
157
+ const previous = ctx?.getStatus?.() || {};
158
+ ctx?.setStatus?.({
159
+ ...previous,
160
+ accountId,
161
+ running: false,
162
+ restartPending: false,
163
+ lastStopAt: Date.now(),
164
+ meta: runtime.hooks.buildStatusMeta(accountId),
165
+ });
166
+ runtime.hooks.logInfo(
167
+ 'health',
168
+ `status-stop ${JSON.stringify({ bridge: runtime.bridgeId, accountId, cleared })}`,
169
+ { debugOnly: true },
170
+ );
171
+ runtime.hooks.logInfo('health', `status-stop ${accountId}|cleared=${cleared}`);
172
+ }