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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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
- if (missing.length === 0 && extra.length === 0) return { started: [], failed: [] };
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
- log.warn({ missing, extra: extra.map((ch) => ch.name) }, "channels out of sync, reconciling");
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
- // Only additions: start just the missing channels so healthy ones stay
97
- // connected and one persistently-failing channel can't thrash the rest.
98
- return startChannels(missing);
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[] {
@@ -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
  }
@@ -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
  }
@@ -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(
@@ -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,