niahere 0.5.11 → 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
CHANGED
|
@@ -37,9 +37,51 @@ export interface CoalescerOptions {
|
|
|
37
37
|
maxBatch?: number;
|
|
38
38
|
/** Defaults to serializing internally, which suits a caller with no lock. */
|
|
39
39
|
schedule?: Schedule;
|
|
40
|
+
/**
|
|
41
|
+
* Consecutive turns whose reply may be folded forward before one is sent
|
|
42
|
+
* regardless. A room that never goes quiet must still get an answer.
|
|
43
|
+
*/
|
|
44
|
+
maxDeferrals?: number;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
const DEFAULT_MAX_BATCH = 10;
|
|
48
|
+
const DEFAULT_MAX_DEFERRALS = 2;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What a running turn can ask about its own standing.
|
|
52
|
+
*
|
|
53
|
+
* Coalescing answers the question "which messages share a turn". It cannot
|
|
54
|
+
* answer "is this reply still worth sending", because the message that
|
|
55
|
+
* obsoletes a reply arrives while the turn that produces it is still running.
|
|
56
|
+
* Roughly one in eight messages to the Slack DM lands mid-turn, and the ones
|
|
57
|
+
* that do are corrections — "i meant browser" sat 49 seconds behind an answer
|
|
58
|
+
* to the question it replaced, and that answer went out first.
|
|
59
|
+
*/
|
|
60
|
+
export interface TurnControl {
|
|
61
|
+
/**
|
|
62
|
+
* True when a newer message for this room is already queued, so this reply
|
|
63
|
+
* answers a superseded question. Fold it forward instead of sending: the
|
|
64
|
+
* text stays in the session, so the next turn can restate whatever still
|
|
65
|
+
* matters and answer both in one reply.
|
|
66
|
+
*
|
|
67
|
+
* Decided on first call and stable thereafter — a message landing during
|
|
68
|
+
* delivery must not retroactively unsend a reply.
|
|
69
|
+
*/
|
|
70
|
+
superseded(): boolean;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* True when the turn before this one folded its reply forward. That text is
|
|
74
|
+
* in the session, so without being told otherwise the model reads its own
|
|
75
|
+
* undelivered answer and writes "as I said above" about something nobody
|
|
76
|
+
* saw. This turn's reply has to cover both.
|
|
77
|
+
*/
|
|
78
|
+
readonly previousReplyWithheld: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface TurnControlInternal extends TurnControl {
|
|
82
|
+
/** Whether `superseded()` was both asked and answered yes. */
|
|
83
|
+
readonly deferred: boolean;
|
|
84
|
+
}
|
|
43
85
|
|
|
44
86
|
export interface Coalescer {
|
|
45
87
|
/** Offer a message. Runs now, or joins the next batch. */
|
|
@@ -49,12 +91,28 @@ export interface Coalescer {
|
|
|
49
91
|
}
|
|
50
92
|
|
|
51
93
|
export function createCoalescer(
|
|
52
|
-
process: (batch: Pending[]) => Promise<void>,
|
|
94
|
+
process: (batch: Pending[], turn: TurnControl) => Promise<void>,
|
|
53
95
|
options: CoalescerOptions = {},
|
|
54
96
|
): Coalescer {
|
|
55
97
|
const maxBatch = Math.max(1, options.maxBatch ?? DEFAULT_MAX_BATCH);
|
|
98
|
+
const maxDeferrals = Math.max(0, options.maxDeferrals ?? DEFAULT_MAX_DEFERRALS);
|
|
56
99
|
const queue: Pending[] = [];
|
|
57
100
|
let scheduled = false;
|
|
101
|
+
let deferrals = 0;
|
|
102
|
+
|
|
103
|
+
function control(canDefer: boolean, previousReplyWithheld: boolean): TurnControlInternal {
|
|
104
|
+
let decided: boolean | null = null;
|
|
105
|
+
return {
|
|
106
|
+
previousReplyWithheld,
|
|
107
|
+
superseded() {
|
|
108
|
+
if (decided === null) decided = canDefer && queue.length > 0;
|
|
109
|
+
return decided;
|
|
110
|
+
},
|
|
111
|
+
get deferred() {
|
|
112
|
+
return decided === true;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
58
116
|
|
|
59
117
|
// Two chains, deliberately separate. `lockChain` is the fallback scheduler for
|
|
60
118
|
// a caller with no lock of its own; `idleChain` only tracks completion so
|
|
@@ -78,8 +136,14 @@ export function createCoalescer(
|
|
|
78
136
|
scheduled = false;
|
|
79
137
|
const batch = queue.splice(0, maxBatch);
|
|
80
138
|
if (batch.length === 0) return;
|
|
139
|
+
// `deferrals` resets whenever a turn replies, so a non-zero count means
|
|
140
|
+
// the turn immediately before this one withheld its reply.
|
|
141
|
+
const turn = control(deferrals < maxDeferrals, deferrals > 0);
|
|
81
142
|
// A turn that throws must not strand the messages queued behind it.
|
|
82
|
-
await process(batch).catch(() => {});
|
|
143
|
+
await process(batch, turn).catch(() => {});
|
|
144
|
+
// A turn that replied clears the run; only folded-forward ones count
|
|
145
|
+
// toward the cap.
|
|
146
|
+
deferrals = turn.deferred ? deferrals + 1 : 0;
|
|
83
147
|
if (queue.length > 0) kick();
|
|
84
148
|
} finally {
|
|
85
149
|
settle();
|
|
@@ -108,18 +172,27 @@ export function createCoalescer(
|
|
|
108
172
|
* three redundant replies into one *incomplete* reply would be worse than the
|
|
109
173
|
* behaviour it replaces.
|
|
110
174
|
*/
|
|
111
|
-
export function mergeMessages(items: Pending[]): Pending {
|
|
175
|
+
export function mergeMessages(items: Pending[], previousReplyWithheld = false): Pending {
|
|
112
176
|
const attachments = items.flatMap((i) => i.attachments ?? []);
|
|
113
177
|
const parts = items.map((i) => i.text.trim()).filter((t) => t.length > 0);
|
|
114
178
|
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
if (parts.length
|
|
179
|
+
const notes: string[] = [];
|
|
180
|
+
if (previousReplyWithheld) notes.push(WITHHELD_NOTE);
|
|
181
|
+
if (parts.length > 1) {
|
|
182
|
+
notes.push(`[${parts.length} messages arrived together while you were working. Answer all of them.]`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const body =
|
|
186
|
+
items.length === 1 ? items[0]!.text : parts.length === 0 ? "" : parts.length === 1 ? parts[0]! : parts.join("\n\n");
|
|
118
187
|
|
|
119
|
-
|
|
120
|
-
return { text: `${
|
|
188
|
+
if (notes.length === 0) return { text: body, attachments };
|
|
189
|
+
return { text: `${notes.join("\n")}\n\n${body}`, attachments };
|
|
121
190
|
}
|
|
122
191
|
|
|
192
|
+
const WITHHELD_NOTE =
|
|
193
|
+
"[Your last reply was not sent — this arrived before it went out, so the user has never seen it. " +
|
|
194
|
+
"Cover anything from it that still matters.]";
|
|
195
|
+
|
|
123
196
|
/** An inbound message plus whatever the channel needs to answer it. */
|
|
124
197
|
export interface Inbound<C> extends Pending {
|
|
125
198
|
ctx: C;
|
|
@@ -145,7 +218,7 @@ export interface TurnPump<K, C> {
|
|
|
145
218
|
*/
|
|
146
219
|
export function createTurnPump<K, C>(
|
|
147
220
|
lockFor: (key: K) => Schedule,
|
|
148
|
-
run: (key: K, batch: Inbound<C>[], merged: Pending) => Promise<void>,
|
|
221
|
+
run: (key: K, batch: Inbound<C>[], merged: Pending, turn: TurnControl) => Promise<void>,
|
|
149
222
|
options: CoalescerOptions = {},
|
|
150
223
|
): TurnPump<K, C> {
|
|
151
224
|
const queues = new Map<K, Coalescer>();
|
|
@@ -154,8 +227,8 @@ export function createTurnPump<K, C>(
|
|
|
154
227
|
let q = queues.get(key);
|
|
155
228
|
if (!q) {
|
|
156
229
|
q = createCoalescer(
|
|
157
|
-
async (batch) => {
|
|
158
|
-
await run(key, batch as Inbound<C>[], mergeMessages(batch));
|
|
230
|
+
async (batch, turn) => {
|
|
231
|
+
await run(key, batch as Inbound<C>[], mergeMessages(batch, turn.previousReplyWithheld), turn);
|
|
159
232
|
},
|
|
160
233
|
{ ...options, schedule: lockFor(key) },
|
|
161
234
|
);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TurnControl } from "./coalesce";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* What counts as a reply, and what is just the model talking to itself.
|
|
3
5
|
*
|
|
@@ -77,3 +79,39 @@ export function decideWatchReply(structured: unknown, raw: string): WatchDecisio
|
|
|
77
79
|
}
|
|
78
80
|
return { send: true, text: trimmed, source: "sentinel" };
|
|
79
81
|
}
|
|
82
|
+
|
|
83
|
+
export interface Delivery {
|
|
84
|
+
/** Whether to put the text in front of the reader now. */
|
|
85
|
+
post: boolean;
|
|
86
|
+
/** The reply text. Kept even when held back, so the caller can log it. */
|
|
87
|
+
text: string;
|
|
88
|
+
/** Which path judged the reply. */
|
|
89
|
+
source: WatchDecision["source"];
|
|
90
|
+
/** Why it is not being posted. */
|
|
91
|
+
reason?: "silent" | "ambiguous" | "superseded";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The whole judgement on a finished turn's reply: is there anything to say,
|
|
96
|
+
* and is it still worth saying.
|
|
97
|
+
*
|
|
98
|
+
* Order matters. A turn that chose silence is never asked whether it was
|
|
99
|
+
* superseded — it withheld nothing, and most watch-channel turns are silent,
|
|
100
|
+
* so charging them a deferral would exhaust the cap on rooms that never
|
|
101
|
+
* deferred anything.
|
|
102
|
+
*/
|
|
103
|
+
export function decideDelivery(structured: unknown, raw: string, turn: Pick<TurnControl, "superseded">): Delivery {
|
|
104
|
+
const decision = decideWatchReply(structured, raw);
|
|
105
|
+
if (!decision.send) {
|
|
106
|
+
return {
|
|
107
|
+
post: false,
|
|
108
|
+
text: decision.text,
|
|
109
|
+
source: decision.source,
|
|
110
|
+
reason: decision.ambiguous ? "ambiguous" : "silent",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (turn.superseded()) {
|
|
114
|
+
return { post: false, text: decision.text, source: decision.source, reason: "superseded" };
|
|
115
|
+
}
|
|
116
|
+
return { post: true, text: decision.text, source: decision.source };
|
|
117
|
+
}
|
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";
|
|
@@ -12,11 +12,50 @@ import { ChatSessions, chainLock } from "./common/chat-session";
|
|
|
12
12
|
import { SlackAttachmentCache } from "./slack/attachments";
|
|
13
13
|
import { SlackWatchReloader } from "./slack/watch";
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { decideDelivery, shouldSuppressReply } from "./common/reply";
|
|
16
16
|
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");
|
|
@@ -155,7 +199,7 @@ class SlackChannel implements Channel {
|
|
|
155
199
|
*/
|
|
156
200
|
const slackPump = createTurnPump<string, SlackTurnCtx>(
|
|
157
201
|
(key) => (fn) => withLock(key, fn),
|
|
158
|
-
async (key, batch, merged) => {
|
|
202
|
+
async (key, batch, merged, turn) => {
|
|
159
203
|
// The newest message decides where the reply goes; every message in the
|
|
160
204
|
// batch needs its own reaction cleared.
|
|
161
205
|
const last = batch[batch.length - 1]!.ctx;
|
|
@@ -222,17 +266,23 @@ class SlackChannel implements Channel {
|
|
|
222
266
|
return;
|
|
223
267
|
}
|
|
224
268
|
|
|
225
|
-
const
|
|
226
|
-
const reply =
|
|
269
|
+
const delivery = decideDelivery(structured, result, turn);
|
|
270
|
+
const reply = delivery.text;
|
|
227
271
|
|
|
228
|
-
if (!
|
|
229
|
-
if (
|
|
272
|
+
if (!delivery.post) {
|
|
273
|
+
if (delivery.reason === "ambiguous") {
|
|
230
274
|
log.warn(
|
|
231
|
-
{ channel: msg.channel, key, reply: result.trim(), source:
|
|
275
|
+
{ channel: msg.channel, key, reply: result.trim(), source: delivery.source },
|
|
232
276
|
"slack: [NO_REPLY] sentinel mixed with content; suppressing send",
|
|
233
277
|
);
|
|
278
|
+
} else if (delivery.reason === "superseded") {
|
|
279
|
+
// The text stays in the session, so the next turn answers both.
|
|
280
|
+
log.info(
|
|
281
|
+
{ channel: msg.channel, key, chars: reply.length },
|
|
282
|
+
"slack: reply superseded by a newer message; folding into the next turn",
|
|
283
|
+
);
|
|
234
284
|
} else {
|
|
235
|
-
log.info({ channel: msg.channel, key, source:
|
|
285
|
+
log.info({ channel: msg.channel, key, source: delivery.source }, "slack: agent chose not to reply");
|
|
236
286
|
}
|
|
237
287
|
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
|
|
238
288
|
return;
|
|
@@ -269,10 +319,14 @@ class SlackChannel implements Channel {
|
|
|
269
319
|
|
|
270
320
|
const self = this;
|
|
271
321
|
|
|
322
|
+
const receiver = new SocketModeReceiver({ appToken });
|
|
323
|
+
this.liveness = new SocketLiveness(receiver.client);
|
|
324
|
+
|
|
272
325
|
const app = new App({
|
|
273
326
|
token: botToken,
|
|
274
327
|
appToken,
|
|
275
328
|
socketMode: true,
|
|
329
|
+
receiver,
|
|
276
330
|
});
|
|
277
331
|
|
|
278
332
|
let botUserId: string | undefined;
|
|
@@ -566,6 +620,7 @@ class SlackChannel implements Channel {
|
|
|
566
620
|
if (this.app) {
|
|
567
621
|
await this.app.stop();
|
|
568
622
|
this.app = null;
|
|
623
|
+
this.liveness = null;
|
|
569
624
|
}
|
|
570
625
|
}
|
|
571
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,
|