niahere 0.5.12 → 0.5.13
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/package.json +1 -1
- package/src/channels/index.ts +36 -6
- package/src/channels/registry.ts +8 -0
- package/src/channels/slack.ts +50 -1
- package/src/types/channel.ts +7 -0
package/package.json
CHANGED
package/src/channels/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Channel } from "../types";
|
|
2
|
-
import { registerChannel, getFactories, trackStarted, clearStarted, getStarted } from "./registry";
|
|
2
|
+
import { registerChannel, getFactories, trackStarted, clearStarted, getStarted, untrackStarted } from "./registry";
|
|
3
3
|
import { log } from "../utils/log";
|
|
4
|
+
import { ignore } from "../utils/errors";
|
|
4
5
|
import { getConfig } from "../utils/config";
|
|
5
6
|
import { createTelegramChannel } from "./telegram";
|
|
6
7
|
import { createSlackChannel } from "./slack";
|
|
@@ -20,6 +21,8 @@ export function registerAllChannels(): void {
|
|
|
20
21
|
registerChannel(() => createWhatsAppChannel());
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
let reconciling = false;
|
|
25
|
+
|
|
23
26
|
export interface StartResult {
|
|
24
27
|
started: Channel[];
|
|
25
28
|
failed: string[];
|
|
@@ -72,8 +75,26 @@ export async function startChannels(only?: readonly string[]): Promise<StartResu
|
|
|
72
75
|
* `startChannels` abandons the failed channels with no retry, so without
|
|
73
76
|
* reconciliation Nia stays alive but deaf on every channel until a manual
|
|
74
77
|
* restart. The alive monitor calls this every healthy heartbeat.
|
|
78
|
+
*
|
|
79
|
+
* It also covers the reverse case, where a channel is tracked as running but
|
|
80
|
+
* its transport has died underneath it. Being in the registry was the only
|
|
81
|
+
* liveness anyone checked, so such a channel was never retried; `healthy()`
|
|
82
|
+
* lets a channel say otherwise and get rebuilt.
|
|
75
83
|
*/
|
|
76
84
|
export async function reconcileChannels(): Promise<StartResult> {
|
|
85
|
+
// Bringing a channel up can block for as long as its transport takes to
|
|
86
|
+
// connect. Without this, a slow start would let the next heartbeat reconcile
|
|
87
|
+
// the same channel again and leave two of them running.
|
|
88
|
+
if (reconciling) return { started: [], failed: [] };
|
|
89
|
+
reconciling = true;
|
|
90
|
+
try {
|
|
91
|
+
return await reconcile();
|
|
92
|
+
} finally {
|
|
93
|
+
reconciling = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function reconcile(): Promise<StartResult> {
|
|
77
98
|
const wanted = getConfiguredChannelNames();
|
|
78
99
|
const running = getStarted();
|
|
79
100
|
const runningNames = new Set(running.map((ch) => ch.name));
|
|
@@ -81,9 +102,11 @@ export async function reconcileChannels(): Promise<StartResult> {
|
|
|
81
102
|
|
|
82
103
|
const missing = wanted.filter((name) => !runningNames.has(name));
|
|
83
104
|
const extra = running.filter((ch) => !wantedSet.has(ch.name));
|
|
84
|
-
|
|
105
|
+
const dead = running.filter((ch) => wantedSet.has(ch.name) && ch.healthy?.() === false);
|
|
106
|
+
if (missing.length === 0 && extra.length === 0 && dead.length === 0) return { started: [], failed: [] };
|
|
85
107
|
|
|
86
|
-
|
|
108
|
+
const deadNames = dead.map((ch) => ch.name);
|
|
109
|
+
log.warn({ missing, extra: extra.map((ch) => ch.name), dead: deadNames }, "channels out of sync, reconciling");
|
|
87
110
|
|
|
88
111
|
// A channel was removed from config. stopChannels() tears down the whole
|
|
89
112
|
// registry (it stops the shared Twilio server and clears all tracking), so a
|
|
@@ -93,9 +116,16 @@ export async function reconcileChannels(): Promise<StartResult> {
|
|
|
93
116
|
return wanted.length > 0 ? startChannels() : { started: [], failed: [] };
|
|
94
117
|
}
|
|
95
118
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
119
|
+
// Tear the dead ones down individually so healthy channels stay connected. A
|
|
120
|
+
// stop that fails must not block the restart — the point is to replace them.
|
|
121
|
+
for (const channel of dead) {
|
|
122
|
+
await ignore(channel.stop(), `stopping dead channel ${channel.name}`);
|
|
123
|
+
untrackStarted(channel.name);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Start just the channels that need it so healthy ones stay connected and one
|
|
127
|
+
// persistently-failing channel can't thrash the rest.
|
|
128
|
+
return startChannels([...missing, ...deadNames]);
|
|
99
129
|
}
|
|
100
130
|
|
|
101
131
|
export function getConfiguredChannelNames(): string[] {
|
package/src/channels/registry.ts
CHANGED
|
@@ -11,6 +11,10 @@ export function getFactories(): readonly ChannelFactory[] {
|
|
|
11
11
|
return factories;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export function clearFactories(): void {
|
|
15
|
+
factories.length = 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
14
18
|
export function trackStarted(channel: Channel): void {
|
|
15
19
|
started.set(channel.name, channel);
|
|
16
20
|
}
|
|
@@ -23,6 +27,10 @@ export function getStarted(): Channel[] {
|
|
|
23
27
|
return [...started.values()];
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
export function untrackStarted(name: string): void {
|
|
31
|
+
started.delete(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
export function clearStarted(): void {
|
|
27
35
|
started.clear();
|
|
28
36
|
}
|
package/src/channels/slack.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { App } from "@slack/bolt";
|
|
1
|
+
import { App, SocketModeReceiver } from "@slack/bolt";
|
|
2
2
|
import type { WebClient } from "@slack/web-api";
|
|
3
3
|
import type { Channel, ChatState, Attachment, Outbound, Recipient } from "../types";
|
|
4
4
|
import { getConfig, updateRawConfig } from "../utils/config";
|
|
@@ -17,6 +17,45 @@ import { createTurnPump } from "./common/coalesce";
|
|
|
17
17
|
|
|
18
18
|
const logActivity = (status: string) => log.debug({ status }, "slack engine activity");
|
|
19
19
|
|
|
20
|
+
/** A healthy reconnect lands in seconds, so a gap this long is not one in progress. */
|
|
21
|
+
const SOCKET_STALL_MS = 3 * 60_000;
|
|
22
|
+
|
|
23
|
+
/** Every Socket Mode state that is not "carrying events right now". */
|
|
24
|
+
const DISCONNECTED_STATES = ["connecting", "reconnecting", "disconnecting", "disconnected"] as const;
|
|
25
|
+
|
|
26
|
+
interface SocketStateEmitter {
|
|
27
|
+
on(event: string, listener: () => void): unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether Socket Mode is actually carrying events, as opposed to merely having
|
|
32
|
+
* been started.
|
|
33
|
+
*
|
|
34
|
+
* Socket Mode can stop trying without saying so: a transport-level failure of
|
|
35
|
+
* `apps.connections.open` is classed unrecoverable, and the resulting rejection
|
|
36
|
+
* escapes an unawaited reconnect chain. The process stays up and the tokens stay
|
|
37
|
+
* valid, so nothing outside the socket can tell it is never coming back.
|
|
38
|
+
*/
|
|
39
|
+
export class SocketLiveness {
|
|
40
|
+
private downSince: number | null = null;
|
|
41
|
+
|
|
42
|
+
constructor(client: SocketStateEmitter) {
|
|
43
|
+
client.on("connected", () => {
|
|
44
|
+
if (this.downSince !== null) log.info("slack: socket connected");
|
|
45
|
+
this.downSince = null;
|
|
46
|
+
});
|
|
47
|
+
for (const state of DISCONNECTED_STATES) {
|
|
48
|
+
client.on(state, () => {
|
|
49
|
+
this.downSince ??= Date.now();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
stalled(now = Date.now()): boolean {
|
|
55
|
+
return this.downSince !== null && now - this.downSince >= SOCKET_STALL_MS;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
/** What answering a Slack message needs, carried per message so a coalesced
|
|
21
60
|
* turn can still reply in the right thread and clear every reaction. */
|
|
22
61
|
interface SlackTurnCtx {
|
|
@@ -51,6 +90,11 @@ class SlackChannel implements Channel {
|
|
|
51
90
|
private dmUserId: string | null = null;
|
|
52
91
|
/** Timestamps of messages Nia posted proactively (used to detect replies to our own messages) */
|
|
53
92
|
private outboundTs = new Set<string>();
|
|
93
|
+
private liveness: SocketLiveness | null = null;
|
|
94
|
+
|
|
95
|
+
healthy(): boolean {
|
|
96
|
+
return !this.liveness?.stalled();
|
|
97
|
+
}
|
|
54
98
|
|
|
55
99
|
async deliver(out: Outbound): Promise<void> {
|
|
56
100
|
if (!this.app) throw new Error("Slack not started");
|
|
@@ -275,10 +319,14 @@ class SlackChannel implements Channel {
|
|
|
275
319
|
|
|
276
320
|
const self = this;
|
|
277
321
|
|
|
322
|
+
const receiver = new SocketModeReceiver({ appToken });
|
|
323
|
+
this.liveness = new SocketLiveness(receiver.client);
|
|
324
|
+
|
|
278
325
|
const app = new App({
|
|
279
326
|
token: botToken,
|
|
280
327
|
appToken,
|
|
281
328
|
socketMode: true,
|
|
329
|
+
receiver,
|
|
282
330
|
});
|
|
283
331
|
|
|
284
332
|
let botUserId: string | undefined;
|
|
@@ -572,6 +620,7 @@ class SlackChannel implements Channel {
|
|
|
572
620
|
if (this.app) {
|
|
573
621
|
await this.app.stop();
|
|
574
622
|
this.app = null;
|
|
623
|
+
this.liveness = null;
|
|
575
624
|
}
|
|
576
625
|
}
|
|
577
626
|
}
|
package/src/types/channel.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface Channel {
|
|
|
31
31
|
name: string;
|
|
32
32
|
start(): Promise<void>;
|
|
33
33
|
stop(): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Whether the channel can still receive. Implement it wherever having started
|
|
36
|
+
* is not the same as being connected — a transport can die under a process
|
|
37
|
+
* that stays up, and no caller above this seam can tell. Omit it and the
|
|
38
|
+
* channel is assumed healthy for as long as it is running.
|
|
39
|
+
*/
|
|
40
|
+
healthy?(): boolean;
|
|
34
41
|
/**
|
|
35
42
|
* Deliver an outbound payload. Channels are expected to handle either
|
|
36
43
|
* a text-only, media-only, or text+media payload; format details (chunking,
|