niahere 0.5.12 → 0.5.14
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/channels/telegram.ts +48 -0
- 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/channels/telegram.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { classifyMime, validateAttachment, prepareImage } from "../utils/attachm
|
|
|
13
13
|
import { getNiaHome } from "../utils/paths";
|
|
14
14
|
import { ChatSessions, chainLock } from "./common/chat-session";
|
|
15
15
|
import { shouldSuppressReply } from "./common/reply";
|
|
16
|
+
import { transcribeAudio } from "./twilio/transcribe";
|
|
16
17
|
|
|
17
18
|
function safeExtension(filename?: string): string {
|
|
18
19
|
const ext = filename?.split(".").pop();
|
|
@@ -51,10 +52,24 @@ class TelegramChannel implements Channel {
|
|
|
51
52
|
bot.on("message:text", (ctx) => this.handleText(ctx));
|
|
52
53
|
bot.on("message:photo", (ctx) => this.handlePhoto(ctx));
|
|
53
54
|
bot.on("message:document", (ctx) => this.handleDocument(ctx));
|
|
55
|
+
bot.on(["message:voice", "message:audio"], (ctx) => this.handleVoice(ctx));
|
|
56
|
+
|
|
57
|
+
// Without a handler grammY rethrows, and an error thrown while polling
|
|
58
|
+
// takes the whole loop down rather than the one update that caused it.
|
|
59
|
+
bot.catch((err) => log.error({ err: err.error, chatId: err.ctx.chatId }, "telegram handler failed"));
|
|
54
60
|
|
|
55
61
|
bot.start({ onStart: () => log.info("telegram bot polling started") });
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
/**
|
|
65
|
+
* grammY stops polling for good if the loop crashes, and the process carries
|
|
66
|
+
* on regardless. `isRunning()` is its own account of whether that has
|
|
67
|
+
* happened, so ask it rather than inferring from the outside.
|
|
68
|
+
*/
|
|
69
|
+
healthy(): boolean {
|
|
70
|
+
return !this.bot || this.bot.isRunning();
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
async stop(): Promise<void> {
|
|
59
74
|
this.chats.closeAll();
|
|
60
75
|
if (this.bot) {
|
|
@@ -168,6 +183,39 @@ class TelegramChannel implements Channel {
|
|
|
168
183
|
});
|
|
169
184
|
}
|
|
170
185
|
|
|
186
|
+
private async handleVoice(ctx: Context): Promise<void> {
|
|
187
|
+
const media = ctx.message?.voice ?? ctx.message?.audio;
|
|
188
|
+
if (!ctx.chatId || !media || !this.gate(ctx)) return;
|
|
189
|
+
this.registerOutbound(ctx.chatId);
|
|
190
|
+
const state = await this.chats.get(ctx.chatId);
|
|
191
|
+
const chatId = ctx.chatId;
|
|
192
|
+
this.withLock(chatId, async () => {
|
|
193
|
+
try {
|
|
194
|
+
const apiKey = getConfig().channels.phone.openai_api_key;
|
|
195
|
+
if (!apiKey) {
|
|
196
|
+
await ctx.reply("Can't transcribe voice notes — channels.phone.openai_api_key isn't set.");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const data = await this.downloadFile(media.file_id);
|
|
200
|
+
const error = validateAttachment(data);
|
|
201
|
+
if (error) {
|
|
202
|
+
await ctx.reply(error);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const transcript = await transcribeAudio({ apiKey, data, mime: media.mime_type || "audio/ogg" });
|
|
206
|
+
if (!transcript) {
|
|
207
|
+
await ctx.reply("Couldn't make out anything in that voice note.");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const caption = ctx.message!.caption;
|
|
211
|
+
await this.processMessage(ctx, state, caption ? `${caption}\n\n${transcript}` : transcript);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
log.error({ err, chatId }, "failed to process voice note");
|
|
214
|
+
await ignore(ctx.reply(`Failed to transcribe that voice note — ${errMsg(err)}`), "reply voice failure");
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
171
219
|
// --- Core message loop ---
|
|
172
220
|
|
|
173
221
|
private async processMessage(
|
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,
|