niahere 0.5.0 → 0.5.1
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/agent/backends/claude.ts +2 -1
- package/src/agent/backends/codex.ts +2 -1
- package/src/agent/mcp-endpoint.ts +3 -2
- package/src/channels/common/chat-session.ts +60 -0
- package/src/channels/phone/consult.ts +2 -1
- package/src/channels/phone/index.ts +5 -3
- package/src/channels/phone/relay.ts +15 -5
- package/src/channels/slack.ts +24 -34
- package/src/channels/sms.ts +16 -36
- package/src/channels/telegram.ts +22 -34
- package/src/channels/twilio/media-cache.ts +4 -3
- package/src/channels/twilio/rest.ts +0 -31
- package/src/channels/twilio/server.ts +0 -5
- package/src/channels/twilio/shared.ts +36 -0
- package/src/channels/whatsapp.ts +24 -58
- package/src/chat/engine.ts +17 -8
- package/src/chat/gap-marker.ts +63 -0
- package/src/chat/repl.ts +5 -5
- package/src/cli/config.ts +71 -0
- package/src/cli/index.ts +23 -288
- package/src/cli/job.ts +1 -2
- package/src/cli/logs.ts +55 -0
- package/src/cli/run.ts +74 -0
- package/src/cli/skills.ts +13 -0
- package/src/cli/status.ts +0 -1
- package/src/cli/test.ts +32 -0
- package/src/cli/update.ts +79 -0
- package/src/commands/backup.ts +1 -2
- package/src/commands/init.ts +1 -2
- package/src/core/alive.ts +4 -3
- package/src/core/consolidator.ts +78 -31
- package/src/core/daemon.ts +3 -2
- package/src/core/finalizer.ts +9 -5
- package/src/core/runner.ts +7 -6
- package/src/core/scheduler.ts +19 -4
- package/src/core/skills.ts +0 -4
- package/src/db/migrations/017_sessions_consolidated_count.ts +9 -0
- package/src/db/models/active_engine.ts +0 -7
- package/src/db/models/job.ts +3 -2
- package/src/db/models/message.ts +10 -0
- package/src/db/models/session.ts +12 -0
- package/src/mcp/tools/misc.ts +2 -1
- package/src/mcp/tools/send.ts +5 -4
- package/src/utils/errors.ts +26 -0
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { asError } from "../../utils/errors";
|
|
2
3
|
import { randomUUID } from "crypto";
|
|
3
4
|
import { existsSync } from "fs";
|
|
4
5
|
import { join } from "path";
|
|
@@ -126,7 +127,7 @@ class ClaudeSession implements AgentSession {
|
|
|
126
127
|
res = await this.iterator!.next();
|
|
127
128
|
} catch (err) {
|
|
128
129
|
if (this.aborted) throw new Error(this.aborted);
|
|
129
|
-
throw
|
|
130
|
+
throw asError(err);
|
|
130
131
|
}
|
|
131
132
|
if (this.aborted) throw new Error(this.aborted);
|
|
132
133
|
if (res.done) {
|
|
@@ -7,6 +7,7 @@ import type { McpSourceContext } from "../../mcp";
|
|
|
7
7
|
import { CodexNormalizer } from "./codex-normalize";
|
|
8
8
|
import { mintRun, revokeRun } from "../mcp-endpoint";
|
|
9
9
|
import { scopeOf, parseFailure } from "../failure";
|
|
10
|
+
import { ignore } from "../../utils/errors";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Resolve the codex binary's absolute path. The daemon runs under launchd with a
|
|
@@ -256,7 +257,7 @@ class CodexSession implements AgentSession {
|
|
|
256
257
|
};
|
|
257
258
|
}
|
|
258
259
|
} finally {
|
|
259
|
-
await stdout.cancel()
|
|
260
|
+
await ignore(stdout.cancel(), "cancel codex stdout reader");
|
|
260
261
|
revokeRun(token);
|
|
261
262
|
this.proc = null;
|
|
262
263
|
}
|
|
@@ -4,6 +4,7 @@ import { randomBytes, randomUUID } from "crypto";
|
|
|
4
4
|
import type { NiaTool } from "../mcp/tools/types";
|
|
5
5
|
import type { McpSourceContext } from "../mcp";
|
|
6
6
|
import { log } from "../utils/log";
|
|
7
|
+
import { ignore } from "../utils/errors";
|
|
7
8
|
import { gateSideEffects } from "../mcp/gate";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -93,8 +94,8 @@ export function revokeRun(token: string): void {
|
|
|
93
94
|
const entry = runs.get(token);
|
|
94
95
|
if (!entry) return;
|
|
95
96
|
runs.delete(token);
|
|
96
|
-
entry.transport.close()
|
|
97
|
-
entry.server.close()
|
|
97
|
+
void ignore(entry.transport.close(), "close run transport");
|
|
98
|
+
void ignore(entry.server.close(), "close run server");
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
/** Test/diagnostic: number of live runs. */
|
|
@@ -54,3 +54,63 @@ export async function rotateRoom(
|
|
|
54
54
|
export function chainLock(state: ChatState, fn: () => Promise<void>): void {
|
|
55
55
|
state.lock = state.lock.then(fn, fn);
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/** Seam so the registry is testable without a database. */
|
|
59
|
+
export interface SessionOpener {
|
|
60
|
+
open(prefix: string, build: EngineFactory): Promise<ChatState>;
|
|
61
|
+
rotate(prefix: string, prev: ChatState | undefined, build: EngineFactory): Promise<ChatState>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const defaultOpener: SessionOpener = { open: openChatEngine, rotate: rotateRoom };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A channel's per-sender chat sessions. Every message-driven channel keeps one
|
|
68
|
+
* of these keyed by sender, and they all want the same four things: open on
|
|
69
|
+
* first use, reuse after, rotate on `/reset`, close everything on shutdown.
|
|
70
|
+
*/
|
|
71
|
+
export class ChatSessions<K> {
|
|
72
|
+
private readonly states = new Map<K, ChatState>();
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly prefixFor: (key: K) => string,
|
|
76
|
+
private readonly build: EngineFactory,
|
|
77
|
+
private readonly opener: SessionOpener = defaultOpener,
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
/** `build` overrides the default options for this call — Slack needs it to
|
|
81
|
+
* thread watch behavior and thread context into a newly opened engine. */
|
|
82
|
+
async get(key: K, build: EngineFactory = this.build): Promise<ChatState> {
|
|
83
|
+
const existing = this.states.get(key);
|
|
84
|
+
if (existing) return existing;
|
|
85
|
+
const state = await this.opener.open(this.prefixFor(key), build);
|
|
86
|
+
this.states.set(key, state);
|
|
87
|
+
return state;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Start a fresh room for this sender, closing the previous one. */
|
|
91
|
+
async rotate(key: K, build: EngineFactory = this.build): Promise<ChatState> {
|
|
92
|
+
const state = await this.opener.rotate(this.prefixFor(key), this.states.get(key), build);
|
|
93
|
+
this.states.set(key, state);
|
|
94
|
+
return state;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The cached session, without opening one. */
|
|
98
|
+
peek(key: K): ChatState | undefined {
|
|
99
|
+
return this.states.get(key);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Whether a session is already open — Slack uses it to tell a live thread
|
|
103
|
+
* from one it should ignore. */
|
|
104
|
+
has(key: K): boolean {
|
|
105
|
+
return this.states.has(key);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
keys(): Iterable<K> {
|
|
109
|
+
return this.states.keys();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
closeAll(): void {
|
|
113
|
+
for (const state of this.states.values()) state.engine.close();
|
|
114
|
+
this.states.clear();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Heavyweight (multi-second latency) by design — keep usage selective.
|
|
7
7
|
*/
|
|
8
8
|
import Anthropic from "@anthropic-ai/sdk";
|
|
9
|
+
import { errMsg } from "../../utils/errors";
|
|
9
10
|
import { loadIdentity } from "../../chat/identity";
|
|
10
11
|
import { log } from "../../utils/log";
|
|
11
12
|
|
|
@@ -38,6 +39,6 @@ export async function consultClaude(question: string, callerLabel: string): Prom
|
|
|
38
39
|
return "(no answer)";
|
|
39
40
|
} catch (err) {
|
|
40
41
|
log.error({ err }, "phone: consult_claude failed");
|
|
41
|
-
return `error consulting Claude: ${
|
|
42
|
+
return `error consulting Claude: ${errMsg(err)}`;
|
|
42
43
|
}
|
|
43
44
|
}
|
|
@@ -25,6 +25,7 @@ import type { ServerWebSocket } from "bun";
|
|
|
25
25
|
import type { Channel, Outbound, PhoneConfig, TwilioConfig } from "../../types";
|
|
26
26
|
import { getConfig } from "../../utils/config";
|
|
27
27
|
import { log } from "../../utils/log";
|
|
28
|
+
import { ignore } from "../../utils/errors";
|
|
28
29
|
import { getChannel } from "../registry";
|
|
29
30
|
import { Session, Message } from "../../db/models";
|
|
30
31
|
import { runMigrations } from "../../db/migrate";
|
|
@@ -194,9 +195,10 @@ class PhoneChannel implements Channel {
|
|
|
194
195
|
|
|
195
196
|
if (!allowed) {
|
|
196
197
|
log.warn({ from, callSid }, "phone: rejecting unauthorized caller");
|
|
197
|
-
getChannel("telegram")
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
const notice = getChannel("telegram")?.deliver({
|
|
199
|
+
text: `Phone: rejected call from ${from} (CallSid ${callSid})`,
|
|
200
|
+
});
|
|
201
|
+
if (notice) void ignore(notice, "notify rejected caller");
|
|
200
202
|
return twimlResponse(sayAndHangupTwiML("Sorry, this line is not currently accepting calls. Goodbye."));
|
|
201
203
|
}
|
|
202
204
|
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* const result = await relay.completion;
|
|
13
13
|
*/
|
|
14
14
|
import { log } from "../../utils/log";
|
|
15
|
+
import { errMsg } from "../../utils/errors";
|
|
15
16
|
|
|
16
17
|
export interface PhoneToolDefinition {
|
|
17
18
|
name: string;
|
|
@@ -57,6 +58,17 @@ export interface RelayOpts {
|
|
|
57
58
|
model: string;
|
|
58
59
|
voice: string;
|
|
59
60
|
context: CallContext;
|
|
61
|
+
/** Injectable so the bridge is testable without dialing OpenAI. */
|
|
62
|
+
openAiWsFactory?: (url: string, key: string) => OpenAiSocket;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The slice of the realtime socket the relay drives. */
|
|
66
|
+
export interface OpenAiSocket extends WebSocketLike {
|
|
67
|
+
addEventListener(type: string, listener: (ev: any) => void): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function connectOpenAi(url: string, key: string): OpenAiSocket {
|
|
71
|
+
return new WebSocket(url, { headers: { Authorization: `Bearer ${key}` } } as any) as unknown as OpenAiSocket;
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
export interface WebSocketLike {
|
|
@@ -76,7 +88,7 @@ export interface RelayHandle {
|
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
export function createRelay(opts: RelayOpts): RelayHandle {
|
|
79
|
-
const { twilioWs, openAiKey, model, voice, context } = opts;
|
|
91
|
+
const { twilioWs, openAiKey, model, voice, context, openAiWsFactory = connectOpenAi } = opts;
|
|
80
92
|
const transcript: TranscriptTurn[] = [];
|
|
81
93
|
let endedReason: RelayEndReason = "twilio_stop";
|
|
82
94
|
let errorMsg: string | undefined;
|
|
@@ -84,9 +96,7 @@ export function createRelay(opts: RelayOpts): RelayHandle {
|
|
|
84
96
|
let pendingAssistantText = "";
|
|
85
97
|
|
|
86
98
|
const openAiUrl = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`;
|
|
87
|
-
const openAiWs =
|
|
88
|
-
headers: { Authorization: `Bearer ${openAiKey}` },
|
|
89
|
-
} as any);
|
|
99
|
+
const openAiWs = openAiWsFactory(openAiUrl, openAiKey);
|
|
90
100
|
|
|
91
101
|
/** Whether a response is currently in flight on the OpenAI side. */
|
|
92
102
|
let responseActive = false;
|
|
@@ -268,7 +278,7 @@ export function createRelay(opts: RelayOpts): RelayHandle {
|
|
|
268
278
|
try {
|
|
269
279
|
output = await tool.handler(args);
|
|
270
280
|
} catch (err) {
|
|
271
|
-
output = `error: ${
|
|
281
|
+
output = `error: ${errMsg(err)}`;
|
|
272
282
|
log.error({ err, callSid: context.callSid, toolName }, "phone tool handler failed");
|
|
273
283
|
}
|
|
274
284
|
}
|
package/src/channels/slack.ts
CHANGED
|
@@ -5,11 +5,14 @@ import { relativeTime } from "../utils/format";
|
|
|
5
5
|
import { runMigrations } from "../db/migrate";
|
|
6
6
|
import { Session, Message } from "../db/models";
|
|
7
7
|
import { log } from "../utils/log";
|
|
8
|
+
import { errMsg, ignore } from "../utils/errors";
|
|
8
9
|
import { getMcpServers } from "../mcp";
|
|
9
|
-
import {
|
|
10
|
+
import { ChatSessions, chainLock } from "./common/chat-session";
|
|
10
11
|
import { SlackAttachmentCache } from "./slack/attachments";
|
|
11
12
|
import { SlackWatchReloader } from "./slack/watch";
|
|
12
13
|
|
|
14
|
+
const logActivity = (status: string) => log.debug({ status }, "slack engine activity");
|
|
15
|
+
|
|
13
16
|
/** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
|
|
14
17
|
function cleanSentinel(text: string): string {
|
|
15
18
|
return text.replace(/`/g, "").trim();
|
|
@@ -75,7 +78,7 @@ class SlackChannel implements Channel {
|
|
|
75
78
|
|
|
76
79
|
this.dmUserId = config.channels.slack.dm_user_id;
|
|
77
80
|
|
|
78
|
-
const chats = new
|
|
81
|
+
const chats = new ChatSessions<string>(roomPrefix, buildEngineOpts());
|
|
79
82
|
const channelNames = new Map<string, string>();
|
|
80
83
|
|
|
81
84
|
async function resolveChannelName(app: App, channelId: string): Promise<string> {
|
|
@@ -111,30 +114,20 @@ class SlackChannel implements Channel {
|
|
|
111
114
|
});
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
|
|
117
|
+
const getState = (
|
|
115
118
|
key: string,
|
|
116
119
|
watchBehavior?: { channel: string; behavior: string },
|
|
117
120
|
slackCtx?: SlackContext,
|
|
118
|
-
): Promise<ChatState>
|
|
119
|
-
let state = chats.get(key);
|
|
120
|
-
if (state) return state;
|
|
121
|
-
state = await openChatEngine(roomPrefix(key), buildEngineOpts(watchBehavior, slackCtx));
|
|
122
|
-
chats.set(key, state);
|
|
123
|
-
return state;
|
|
124
|
-
}
|
|
121
|
+
): Promise<ChatState> => chats.get(key, buildEngineOpts(watchBehavior, slackCtx));
|
|
125
122
|
|
|
126
|
-
|
|
123
|
+
const restartChat = (
|
|
127
124
|
key: string,
|
|
128
125
|
watchBehavior?: { channel: string; behavior: string },
|
|
129
126
|
slackCtx?: SlackContext,
|
|
130
|
-
): Promise<ChatState>
|
|
131
|
-
const state = await rotateRoom(roomPrefix(key), chats.get(key), buildEngineOpts(watchBehavior, slackCtx));
|
|
132
|
-
chats.set(key, state);
|
|
133
|
-
return state;
|
|
134
|
-
}
|
|
127
|
+
): Promise<ChatState> => chats.rotate(key, buildEngineOpts(watchBehavior, slackCtx));
|
|
135
128
|
|
|
136
129
|
function withLock(key: string, fn: () => Promise<void>): void {
|
|
137
|
-
const state = chats.
|
|
130
|
+
const state = chats.peek(key);
|
|
138
131
|
if (!state) {
|
|
139
132
|
fn().catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
|
|
140
133
|
return;
|
|
@@ -177,13 +170,11 @@ class SlackChannel implements Channel {
|
|
|
177
170
|
withLock(key, async () => {
|
|
178
171
|
try {
|
|
179
172
|
const { result } = await state.engine.send(subcommand, {
|
|
180
|
-
onActivity
|
|
181
|
-
log.debug({ status }, "slack engine activity");
|
|
182
|
-
},
|
|
173
|
+
onActivity: logActivity,
|
|
183
174
|
});
|
|
184
175
|
await respond(result.trim() || "(no response)");
|
|
185
176
|
} catch (err) {
|
|
186
|
-
const errText =
|
|
177
|
+
const errText = errMsg(err);
|
|
187
178
|
await respond(`[error] ${errText}`);
|
|
188
179
|
}
|
|
189
180
|
});
|
|
@@ -437,9 +428,7 @@ class SlackChannel implements Channel {
|
|
|
437
428
|
const { result, messageId, signal } = await state.engine.send(
|
|
438
429
|
text,
|
|
439
430
|
{
|
|
440
|
-
onActivity
|
|
441
|
-
log.debug({ status }, "slack engine activity");
|
|
442
|
-
},
|
|
431
|
+
onActivity: logActivity,
|
|
443
432
|
},
|
|
444
433
|
attachments,
|
|
445
434
|
);
|
|
@@ -448,7 +437,7 @@ class SlackChannel implements Channel {
|
|
|
448
437
|
await reactToSlackMessage(client, msg.channel, msg.ts, "skull").catch((err) =>
|
|
449
438
|
log.debug({ err, channel: msg.channel }, "slack: failed to add provider-down reaction"),
|
|
450
439
|
);
|
|
451
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "sent")
|
|
440
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
|
|
452
441
|
log.info({ channel: msg.channel, key, reaction: "skull" }, "slack provider failure sent as reaction");
|
|
453
442
|
return;
|
|
454
443
|
}
|
|
@@ -468,7 +457,7 @@ class SlackChannel implements Channel {
|
|
|
468
457
|
"slack: [NO_REPLY] sentinel mixed with content; suppressing send",
|
|
469
458
|
);
|
|
470
459
|
}
|
|
471
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "sent")
|
|
460
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
|
|
472
461
|
return;
|
|
473
462
|
}
|
|
474
463
|
|
|
@@ -482,26 +471,27 @@ class SlackChannel implements Channel {
|
|
|
482
471
|
} else {
|
|
483
472
|
await say(reply);
|
|
484
473
|
}
|
|
485
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "sent")
|
|
474
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
|
|
486
475
|
log.info({ channel: msg.channel, key, chars: reply.length }, "slack reply sent");
|
|
487
476
|
} catch (sendErr) {
|
|
488
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "failed")
|
|
477
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "failed"), "record failed delivery status");
|
|
489
478
|
throw sendErr;
|
|
490
479
|
}
|
|
491
480
|
} catch (err) {
|
|
492
|
-
const errText =
|
|
481
|
+
const errText = errMsg(err);
|
|
493
482
|
log.error({ err, channel: msg.channel }, "slack message processing failed");
|
|
494
483
|
|
|
495
484
|
if (replyThreadTs) {
|
|
496
|
-
await
|
|
497
|
-
.postMessage({
|
|
485
|
+
await ignore(
|
|
486
|
+
client.chat.postMessage({
|
|
498
487
|
channel: msg.channel,
|
|
499
488
|
text: `[error] ${errText}`,
|
|
500
489
|
thread_ts: replyThreadTs,
|
|
501
|
-
})
|
|
502
|
-
|
|
490
|
+
}),
|
|
491
|
+
"reply engine error in thread",
|
|
492
|
+
);
|
|
503
493
|
} else {
|
|
504
|
-
await say(`[error] ${errText}`)
|
|
494
|
+
await ignore(say(`[error] ${errText}`), "reply engine error");
|
|
505
495
|
}
|
|
506
496
|
} finally {
|
|
507
497
|
await client.reactions
|
package/src/channels/sms.ts
CHANGED
|
@@ -16,25 +16,29 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { getMcpServers } from "../mcp";
|
|
18
18
|
import { runMigrations } from "../db/migrate";
|
|
19
|
-
import type { Channel,
|
|
19
|
+
import type { Channel, Outbound, TwilioConfig } from "../types";
|
|
20
20
|
import { getConfig } from "../utils/config";
|
|
21
21
|
import { log } from "../utils/log";
|
|
22
|
+
import { errMsg, ignore } from "../utils/errors";
|
|
22
23
|
import { sendMessage as twilioSendMessage } from "./twilio/rest";
|
|
23
24
|
import { getTwilioServer } from "./twilio/server";
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
|
25
|
+
import { ChatSessions, chainLock } from "./common/chat-session";
|
|
26
|
+
import { ackTwiml, deliveryStatusAck, isAllowedSender } from "./twilio/shared";
|
|
27
27
|
|
|
28
28
|
class SmsChannel implements Channel {
|
|
29
29
|
name = "sms" as const;
|
|
30
30
|
private readonly twilio: TwilioConfig;
|
|
31
31
|
/** Cached resolved "from" number: sms.from_number || phone.from_number */
|
|
32
32
|
private readonly fromNumber: string;
|
|
33
|
-
private readonly chats
|
|
33
|
+
private readonly chats: ChatSessions<string>;
|
|
34
34
|
|
|
35
35
|
constructor(twilio: TwilioConfig, fromNumber: string) {
|
|
36
36
|
this.twilio = twilio;
|
|
37
37
|
this.fromNumber = fromNumber;
|
|
38
|
+
this.chats = new ChatSessions((remote) => `sms-${remote}`, () => ({
|
|
39
|
+
channel: "sms",
|
|
40
|
+
mcpServers: getMcpServers(),
|
|
41
|
+
}));
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
async start(): Promise<void> {
|
|
@@ -70,8 +74,7 @@ class SmsChannel implements Channel {
|
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
async stop(): Promise<void> {
|
|
73
|
-
|
|
74
|
-
this.chats.clear();
|
|
77
|
+
this.chats.closeAll();
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
/** Outbound — used by send_message MCP tool. SMS is text-only; media is dropped with a warning. */
|
|
@@ -92,12 +95,12 @@ class SmsChannel implements Channel {
|
|
|
92
95
|
const from = params.From || "";
|
|
93
96
|
const body = params.Body || "";
|
|
94
97
|
|
|
95
|
-
if (!this.
|
|
98
|
+
if (!isAllowedSender(this.twilio, from)) {
|
|
96
99
|
log.warn({ from }, "sms: rejecting non-allowlisted sender");
|
|
97
|
-
return
|
|
100
|
+
return ackTwiml();
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
const state = await this.
|
|
103
|
+
const state = await this.chats.get(from);
|
|
101
104
|
// Ack the webhook immediately; reply via REST asynchronously to avoid
|
|
102
105
|
// Twilio's ~15s webhook timeout when the engine takes longer.
|
|
103
106
|
chainLock(state, async () => {
|
|
@@ -107,24 +110,15 @@ class SmsChannel implements Channel {
|
|
|
107
110
|
await this.sendTo(from, reply);
|
|
108
111
|
} catch (err) {
|
|
109
112
|
log.error({ err, from }, "sms: engine error");
|
|
110
|
-
await this.sendTo(from, `[error] ${
|
|
113
|
+
await ignore(this.sendTo(from, `[error] ${errMsg(err)}`), "reply engine error");
|
|
111
114
|
}
|
|
112
115
|
});
|
|
113
116
|
|
|
114
|
-
return
|
|
117
|
+
return ackTwiml();
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
private handleStatus(params: Record<string, string>): Response {
|
|
118
|
-
|
|
119
|
-
{
|
|
120
|
-
messageSid: params.MessageSid,
|
|
121
|
-
status: params.MessageStatus,
|
|
122
|
-
errorCode: params.ErrorCode,
|
|
123
|
-
to: params.To,
|
|
124
|
-
},
|
|
125
|
-
"sms: delivery status",
|
|
126
|
-
);
|
|
127
|
-
return new Response("", { status: 204 });
|
|
121
|
+
return deliveryStatusAck("sms", params);
|
|
128
122
|
}
|
|
129
123
|
|
|
130
124
|
// --- Outbound ---
|
|
@@ -153,20 +147,6 @@ class SmsChannel implements Channel {
|
|
|
153
147
|
}
|
|
154
148
|
}
|
|
155
149
|
|
|
156
|
-
// --- Helpers ---
|
|
157
|
-
|
|
158
|
-
private isAllowed(remoteE164: string): boolean {
|
|
159
|
-
if (this.twilio.owner_number && remoteE164 === this.twilio.owner_number) return true;
|
|
160
|
-
return this.twilio.allowlist.includes(remoteE164);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
private async getState(remoteE164: string): Promise<ChatState> {
|
|
164
|
-
let state = this.chats.get(remoteE164);
|
|
165
|
-
if (state) return state;
|
|
166
|
-
state = await openChatEngine(`sms-${remoteE164}`, () => ({ channel: "sms", mcpServers: getMcpServers() }));
|
|
167
|
-
this.chats.set(remoteE164, state);
|
|
168
|
-
return state;
|
|
169
|
-
}
|
|
170
150
|
}
|
|
171
151
|
|
|
172
152
|
export function createSmsChannel(): SmsChannel | null {
|
package/src/channels/telegram.ts
CHANGED
|
@@ -7,10 +7,11 @@ import { getConfig, updateRawConfig } from "../utils/config";
|
|
|
7
7
|
import { runMigrations } from "../db/migrate";
|
|
8
8
|
import { Message } from "../db/models";
|
|
9
9
|
import { log } from "../utils/log";
|
|
10
|
+
import { errMsg, ignore } from "../utils/errors";
|
|
10
11
|
import { getMcpServers } from "../mcp";
|
|
11
12
|
import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
|
|
12
13
|
import { getNiaHome } from "../utils/paths";
|
|
13
|
-
import {
|
|
14
|
+
import { ChatSessions, chainLock } from "./common/chat-session";
|
|
14
15
|
|
|
15
16
|
function safeExtension(filename?: string): string {
|
|
16
17
|
const ext = filename?.split(".").pop();
|
|
@@ -27,7 +28,10 @@ class TelegramChannel implements Channel {
|
|
|
27
28
|
private bot: Bot | null = null;
|
|
28
29
|
private outboundChatId: number | null = null;
|
|
29
30
|
private isOpen = false;
|
|
30
|
-
private readonly chats = new
|
|
31
|
+
private readonly chats = new ChatSessions<number>(
|
|
32
|
+
(chatId) => this.keyOf(chatId),
|
|
33
|
+
() => ({ channel: "telegram", mcpServers: getMcpServers() }),
|
|
34
|
+
);
|
|
31
35
|
|
|
32
36
|
async start(): Promise<void> {
|
|
33
37
|
const config = getConfig();
|
|
@@ -51,6 +55,7 @@ class TelegramChannel implements Channel {
|
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
async stop(): Promise<void> {
|
|
58
|
+
this.chats.closeAll();
|
|
54
59
|
if (this.bot) {
|
|
55
60
|
this.bot.stop();
|
|
56
61
|
this.bot = null;
|
|
@@ -82,14 +87,14 @@ class TelegramChannel implements Channel {
|
|
|
82
87
|
private async handleStart(ctx: Context): Promise<void> {
|
|
83
88
|
if (!ctx.chatId || !this.gate(ctx)) return;
|
|
84
89
|
this.registerOutbound(ctx.chatId);
|
|
85
|
-
const state = await this.
|
|
90
|
+
const state = await this.chats.get(ctx.chatId);
|
|
86
91
|
this.withLock(ctx.chatId, () => this.processMessage(ctx, state, "hi"));
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
private async handleRestart(ctx: Context): Promise<void> {
|
|
90
95
|
if (!ctx.chatId || !this.gate(ctx)) return;
|
|
91
96
|
this.registerOutbound(ctx.chatId);
|
|
92
|
-
const state = await this.
|
|
97
|
+
const state = await this.chats.rotate(ctx.chatId);
|
|
93
98
|
log.info({ chatId: ctx.chatId, room: `tg-${ctx.chatId}-${state.roomIndex}` }, "new telegram conversation");
|
|
94
99
|
await ctx.reply("New conversation started.");
|
|
95
100
|
}
|
|
@@ -97,7 +102,7 @@ class TelegramChannel implements Channel {
|
|
|
97
102
|
private async handleText(ctx: Context): Promise<void> {
|
|
98
103
|
if (!ctx.chatId || !ctx.message?.text || !this.gate(ctx)) return;
|
|
99
104
|
this.registerOutbound(ctx.chatId);
|
|
100
|
-
const state = await this.
|
|
105
|
+
const state = await this.chats.get(ctx.chatId);
|
|
101
106
|
const text = ctx.message.text;
|
|
102
107
|
this.withLock(ctx.chatId, () => this.processMessage(ctx, state, text));
|
|
103
108
|
}
|
|
@@ -105,7 +110,7 @@ class TelegramChannel implements Channel {
|
|
|
105
110
|
private async handlePhoto(ctx: Context): Promise<void> {
|
|
106
111
|
if (!ctx.chatId || !ctx.message?.photo || !this.gate(ctx)) return;
|
|
107
112
|
this.registerOutbound(ctx.chatId);
|
|
108
|
-
const state = await this.
|
|
113
|
+
const state = await this.chats.get(ctx.chatId);
|
|
109
114
|
const chatId = ctx.chatId;
|
|
110
115
|
this.withLock(chatId, async () => {
|
|
111
116
|
try {
|
|
@@ -118,7 +123,7 @@ class TelegramChannel implements Channel {
|
|
|
118
123
|
await this.processMessage(ctx, state, caption, [attachment]);
|
|
119
124
|
} catch (err) {
|
|
120
125
|
log.error({ err, chatId }, "failed to process photo");
|
|
121
|
-
await ctx.reply("Failed to process image.")
|
|
126
|
+
await ignore(ctx.reply("Failed to process image."), "reply image failure");
|
|
122
127
|
}
|
|
123
128
|
});
|
|
124
129
|
}
|
|
@@ -126,7 +131,7 @@ class TelegramChannel implements Channel {
|
|
|
126
131
|
private async handleDocument(ctx: Context): Promise<void> {
|
|
127
132
|
if (!ctx.chatId || !ctx.message?.document || !this.gate(ctx)) return;
|
|
128
133
|
this.registerOutbound(ctx.chatId);
|
|
129
|
-
const state = await this.
|
|
134
|
+
const state = await this.chats.get(ctx.chatId);
|
|
130
135
|
const chatId = ctx.chatId;
|
|
131
136
|
this.withLock(chatId, async () => {
|
|
132
137
|
try {
|
|
@@ -157,7 +162,7 @@ class TelegramChannel implements Channel {
|
|
|
157
162
|
await this.processMessage(ctx, state, caption, [attachment]);
|
|
158
163
|
} catch (err) {
|
|
159
164
|
log.error({ err, chatId }, "failed to process document");
|
|
160
|
-
await ctx.reply("Failed to process document.")
|
|
165
|
+
await ignore(ctx.reply("Failed to process document."), "reply document failure");
|
|
161
166
|
}
|
|
162
167
|
});
|
|
163
168
|
}
|
|
@@ -176,25 +181,25 @@ class TelegramChannel implements Channel {
|
|
|
176
181
|
log.info({ chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 }, "telegram message received");
|
|
177
182
|
|
|
178
183
|
const typingInterval = setInterval(() => {
|
|
179
|
-
bot.api.sendChatAction(chatId, "typing")
|
|
184
|
+
void ignore(bot.api.sendChatAction(chatId, "typing"), "send typing indicator");
|
|
180
185
|
}, 4000);
|
|
181
|
-
bot.api.sendChatAction(chatId, "typing")
|
|
186
|
+
void ignore(bot.api.sendChatAction(chatId, "typing"), "send typing indicator");
|
|
182
187
|
|
|
183
188
|
try {
|
|
184
189
|
const { result, messageId } = await state.engine.send(text, {}, attachments);
|
|
185
190
|
const reply = result.trim() || "(no response)";
|
|
186
191
|
try {
|
|
187
192
|
await bot.api.sendMessage(chatId, reply);
|
|
188
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "sent")
|
|
193
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
|
|
189
194
|
log.info({ chatId, chars: result.length }, "telegram reply sent");
|
|
190
195
|
} catch (sendErr) {
|
|
191
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "failed")
|
|
196
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "failed"), "record failed delivery status");
|
|
192
197
|
throw sendErr;
|
|
193
198
|
}
|
|
194
199
|
} catch (err) {
|
|
195
|
-
const errText =
|
|
200
|
+
const errText = errMsg(err);
|
|
196
201
|
log.error({ err, chatId }, "telegram message processing failed");
|
|
197
|
-
await bot.api.sendMessage(chatId, `[error] ${errText}`)
|
|
202
|
+
await ignore(bot.api.sendMessage(chatId, `[error] ${errText}`), "reply engine error");
|
|
198
203
|
} finally {
|
|
199
204
|
clearInterval(typingInterval);
|
|
200
205
|
}
|
|
@@ -202,25 +207,8 @@ class TelegramChannel implements Channel {
|
|
|
202
207
|
|
|
203
208
|
// --- Session / state helpers ---
|
|
204
209
|
|
|
205
|
-
private async getState(chatId: number): Promise<ChatState> {
|
|
206
|
-
let state = this.chats.get(chatId);
|
|
207
|
-
if (state) return state;
|
|
208
|
-
state = await openChatEngine(this.keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
|
|
209
|
-
this.chats.set(chatId, state);
|
|
210
|
-
return state;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
private async restartChat(chatId: number): Promise<ChatState> {
|
|
214
|
-
const state = await rotateRoom(this.keyOf(chatId), this.chats.get(chatId), () => ({
|
|
215
|
-
channel: "telegram",
|
|
216
|
-
mcpServers: getMcpServers(),
|
|
217
|
-
}));
|
|
218
|
-
this.chats.set(chatId, state);
|
|
219
|
-
return state;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
210
|
private withLock(chatId: number, fn: () => Promise<void>): void {
|
|
223
|
-
const state = this.chats.
|
|
211
|
+
const state = this.chats.peek(chatId);
|
|
224
212
|
if (!state) {
|
|
225
213
|
fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
|
|
226
214
|
return;
|
|
@@ -238,7 +226,7 @@ class TelegramChannel implements Channel {
|
|
|
238
226
|
private gate(ctx: Context): boolean {
|
|
239
227
|
if (!ctx.chatId) return false;
|
|
240
228
|
if (this.isAllowed(ctx.chatId)) return true;
|
|
241
|
-
ctx.reply("Unauthorized.")
|
|
229
|
+
void ignore(ctx.reply("Unauthorized."), "reply unauthorized");
|
|
242
230
|
return false;
|
|
243
231
|
}
|
|
244
232
|
|
|
@@ -16,6 +16,7 @@ import { createHash } from "crypto";
|
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
import { getNiaHome } from "../../utils/paths";
|
|
18
18
|
import { log } from "../../utils/log";
|
|
19
|
+
import { ignore } from "../../utils/errors";
|
|
19
20
|
|
|
20
21
|
const MAX_FILES = 100;
|
|
21
22
|
const MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -85,7 +86,7 @@ async function evict(): Promise<void> {
|
|
|
85
86
|
let alive: typeof stats = [];
|
|
86
87
|
for (const s of stats) {
|
|
87
88
|
if (now - s.mtime > MAX_AGE_MS) {
|
|
88
|
-
await unlink(s.path)
|
|
89
|
+
await ignore(unlink(s.path), "prune cached media");
|
|
89
90
|
} else {
|
|
90
91
|
alive.push(s);
|
|
91
92
|
}
|
|
@@ -95,13 +96,13 @@ async function evict(): Promise<void> {
|
|
|
95
96
|
|
|
96
97
|
while (alive.length > MAX_FILES) {
|
|
97
98
|
const victim = alive.shift()!;
|
|
98
|
-
await unlink(victim.path)
|
|
99
|
+
await ignore(unlink(victim.path), "prune cached media");
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
let totalBytes = alive.reduce((sum, f) => sum + f.size, 0);
|
|
102
103
|
while (totalBytes > MAX_BYTES && alive.length > 0) {
|
|
103
104
|
const victim = alive.shift()!;
|
|
104
105
|
totalBytes -= victim.size;
|
|
105
|
-
await unlink(victim.path)
|
|
106
|
+
await ignore(unlink(victim.path), "prune cached media");
|
|
106
107
|
}
|
|
107
108
|
}
|