niahere 0.3.1 → 0.3.2
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/common/chat-session.ts +56 -0
- package/src/channels/phone/index.ts +13 -4
- package/src/channels/phone/tools.ts +2 -2
- package/src/channels/slack.ts +62 -82
- package/src/channels/sms.ts +25 -35
- package/src/channels/telegram.ts +46 -54
- package/src/channels/whatsapp.ts +90 -122
- package/src/chat/identity.ts +1 -2
- package/src/cli/phone.ts +9 -6
- package/src/commands/init.ts +17 -14
- package/src/commands/service.ts +26 -7
- package/src/mcp/tools.ts +17 -33
- package/src/types/channel.ts +35 -6
- package/src/types/index.ts +1 -1
- package/src/utils/attachment.ts +8 -2
- package/src/utils/config.ts +12 -27
package/package.json
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared chat-engine lifecycle helpers used by the message-driven
|
|
3
|
+
* channels (telegram, slack, sms, whatsapp). Each channel keeps its
|
|
4
|
+
* own `Map<senderKey, ChatState>`; these helpers cover the bits that
|
|
5
|
+
* were copy-pasted between them:
|
|
6
|
+
*
|
|
7
|
+
* - resolve the latest room index for a prefix and open a fresh engine
|
|
8
|
+
* - rotate to a new room (for `/reset` / `/new` / `/restart`), persisting
|
|
9
|
+
* a placeholder session so the new index survives daemon restarts
|
|
10
|
+
* - chain work onto a per-sender lock so messages from the same sender
|
|
11
|
+
* don't race
|
|
12
|
+
*
|
|
13
|
+
* The caller supplies a builder lambda for the EngineOptions so channels
|
|
14
|
+
* that need room-aware fields (e.g. Slack's per-room `mcpServers`) can
|
|
15
|
+
* compute them with the resolved room name. Channels with static options
|
|
16
|
+
* just ignore the `room` argument in their builder.
|
|
17
|
+
*/
|
|
18
|
+
import { createChatEngine } from "../../chat/engine";
|
|
19
|
+
import { Session } from "../../db/models";
|
|
20
|
+
import { log } from "../../utils/log";
|
|
21
|
+
import type { ChatState } from "../../types";
|
|
22
|
+
import type { EngineOptions } from "../../types/engine";
|
|
23
|
+
|
|
24
|
+
type EngineFactory = (room: string) => Omit<EngineOptions, "room" | "resume">;
|
|
25
|
+
|
|
26
|
+
/** Open (or resume) a chat engine for `prefix`. The resulting ChatState is the caller's to cache. */
|
|
27
|
+
export async function openChatEngine(prefix: string, buildOpts: EngineFactory): Promise<ChatState> {
|
|
28
|
+
const roomIndex = await Session.getLatestRoomIndex(prefix);
|
|
29
|
+
const room = `${prefix}-${roomIndex}`;
|
|
30
|
+
const opts = buildOpts(room);
|
|
31
|
+
log.info({ channel: opts.channel, room }, "chat-session: opening engine");
|
|
32
|
+
const engine = await createChatEngine({ ...opts, room, resume: true });
|
|
33
|
+
return { engine, roomIndex, lock: Promise.resolve() };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Rotate to a fresh room. Closes `prev` if supplied, persists a placeholder Session so the index survives restarts. */
|
|
37
|
+
export async function rotateRoom(
|
|
38
|
+
prefix: string,
|
|
39
|
+
prev: ChatState | undefined,
|
|
40
|
+
buildOpts: EngineFactory,
|
|
41
|
+
): Promise<ChatState> {
|
|
42
|
+
if (prev) prev.engine.close();
|
|
43
|
+
const prevIdx = await Session.getLatestRoomIndex(prefix);
|
|
44
|
+
const roomIndex = prevIdx + 1;
|
|
45
|
+
const room = `${prefix}-${roomIndex}`;
|
|
46
|
+
await Session.create(`placeholder-${room}`, room);
|
|
47
|
+
const opts = buildOpts(room);
|
|
48
|
+
log.info({ channel: opts.channel, room }, "chat-session: rotated room");
|
|
49
|
+
const engine = await createChatEngine({ ...opts, room, resume: false });
|
|
50
|
+
return { engine, roomIndex, lock: Promise.resolve() };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Serialize `fn` onto `state.lock`. Both success and failure forward so a thrown error doesn't poison the chain. */
|
|
54
|
+
export function chainLock(state: ChatState, fn: () => Promise<void>): void {
|
|
55
|
+
state.lock = state.lock.then(fn, fn);
|
|
56
|
+
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* - consult.ts — escape hatch to Claude for memory-aware reasoning
|
|
23
23
|
*/
|
|
24
24
|
import type { ServerWebSocket } from "bun";
|
|
25
|
-
import type { Channel, PhoneConfig, TwilioConfig } from "../../types";
|
|
25
|
+
import type { Channel, Outbound, PhoneConfig, TwilioConfig } from "../../types";
|
|
26
26
|
import { getConfig } from "../../utils/config";
|
|
27
27
|
import { log } from "../../utils/log";
|
|
28
28
|
import { getChannel } from "../registry";
|
|
@@ -66,7 +66,7 @@ const HARD_MAX_MINUTES = 30;
|
|
|
66
66
|
const WS_PATH = "/twilio/voice/stream";
|
|
67
67
|
|
|
68
68
|
class PhoneChannel implements Channel {
|
|
69
|
-
name = "phone";
|
|
69
|
+
name = "phone" as const;
|
|
70
70
|
private readonly phone: PhoneConfig;
|
|
71
71
|
private readonly twilio: TwilioConfig;
|
|
72
72
|
private readonly pending = new Map<string, PendingCall>();
|
|
@@ -92,7 +92,7 @@ class PhoneChannel implements Channel {
|
|
|
92
92
|
|
|
93
93
|
server.registerHttp("/twilio/voice/incoming", (req, ctx) => this.handleIncoming(req, ctx.params));
|
|
94
94
|
server.registerHttp("/twilio/voice/outbound", (req, ctx) => this.handleOutboundTwiml(req, ctx.params));
|
|
95
|
-
server.registerHttp("/twilio/voice/status", (
|
|
95
|
+
server.registerHttp("/twilio/voice/status", (_req, ctx) => this.handleStatus(ctx.params), {
|
|
96
96
|
dedupOn: "CallSid",
|
|
97
97
|
});
|
|
98
98
|
|
|
@@ -127,6 +127,15 @@ class PhoneChannel implements Channel {
|
|
|
127
127
|
// it running here would block SMS/WhatsApp if they're also bound to it.
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Phone is voice-only — agent-initiated text/media doesn't have a sensible
|
|
132
|
+
* delivery shape over Twilio Voice. Callers that want a text notification
|
|
133
|
+
* about a call should target a text channel (telegram, slack, whatsapp).
|
|
134
|
+
*/
|
|
135
|
+
async deliver(_out: Outbound): Promise<void> {
|
|
136
|
+
throw new Error("phone: text/media delivery is not supported — use a text channel or placeCall() for voice");
|
|
137
|
+
}
|
|
138
|
+
|
|
130
139
|
// --- Outbound entrypoint (used by the place_call MCP tool and CLI test) ---
|
|
131
140
|
|
|
132
141
|
async placeCall(opts: {
|
|
@@ -186,7 +195,7 @@ class PhoneChannel implements Channel {
|
|
|
186
195
|
if (!allowed) {
|
|
187
196
|
log.warn({ from, callSid }, "phone: rejecting unauthorized caller");
|
|
188
197
|
getChannel("telegram")
|
|
189
|
-
?.
|
|
198
|
+
?.deliver({ text: `Phone: rejected call from ${from} (CallSid ${callSid})` })
|
|
190
199
|
.catch(() => {});
|
|
191
200
|
return twimlResponse(sayAndHangupTwiML("Sorry, this line is not currently accepting calls. Goodbye."));
|
|
192
201
|
}
|
|
@@ -47,8 +47,8 @@ export function buildPhoneTools(ctx: ToolContextOpts): PhoneToolDefinition[] {
|
|
|
47
47
|
handler: async (args) => {
|
|
48
48
|
const text = String(args.text || "").slice(0, 1000);
|
|
49
49
|
const tg = getChannel("telegram");
|
|
50
|
-
if (!tg
|
|
51
|
-
await tg.
|
|
50
|
+
if (!tg) return "telegram unavailable";
|
|
51
|
+
await tg.deliver({ text: `[Phone] ${text}` });
|
|
52
52
|
return "sent";
|
|
53
53
|
},
|
|
54
54
|
},
|
package/src/channels/slack.ts
CHANGED
|
@@ -2,8 +2,7 @@ import { App } from "@slack/bolt";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { createHash } from "crypto";
|
|
5
|
-
import {
|
|
6
|
-
import type { Channel, ChatState, Attachment, AttachmentType } from "../types";
|
|
5
|
+
import type { Channel, ChatState, Attachment, AttachmentType, Outbound, Recipient } from "../types";
|
|
7
6
|
import { getConfig, updateRawConfig, resetConfig } from "../utils/config";
|
|
8
7
|
import { relativeTime } from "../utils/format";
|
|
9
8
|
import { runMigrations } from "../db/migrate";
|
|
@@ -13,6 +12,7 @@ import { getMcpServers } from "../mcp";
|
|
|
13
12
|
import { getNiaHome, getPaths } from "../utils/paths";
|
|
14
13
|
import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
|
|
15
14
|
import { resolveWatchBehavior } from "../utils/watches";
|
|
15
|
+
import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
|
|
16
16
|
|
|
17
17
|
/** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
|
|
18
18
|
function cleanSentinel(text: string): string {
|
|
@@ -20,47 +20,39 @@ function cleanSentinel(text: string): string {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
class SlackChannel implements Channel {
|
|
23
|
-
name = "slack";
|
|
23
|
+
name = "slack" as const;
|
|
24
24
|
private app: App | null = null;
|
|
25
25
|
private dmUserId: string | null = null;
|
|
26
26
|
/** Timestamps of messages Nia posted proactively (used to detect replies to our own messages) */
|
|
27
27
|
private outboundTs = new Set<string>();
|
|
28
28
|
|
|
29
|
-
async
|
|
29
|
+
async deliver(out: Outbound): Promise<void> {
|
|
30
30
|
if (!this.app) throw new Error("Slack not started");
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
const dest = this.resolveDest(out.to);
|
|
32
|
+
|
|
33
|
+
if (out.media) {
|
|
34
|
+
const buffer = Buffer.from(out.media.data);
|
|
35
|
+
const filename = out.media.filename || `file.${out.media.mimeType.split("/")[1] || "bin"}`;
|
|
36
|
+
await this.app.client.filesUploadV2({
|
|
37
|
+
channel_id: dest.channel,
|
|
38
|
+
file: buffer,
|
|
39
|
+
filename,
|
|
40
|
+
...(dest.threadTs ? { thread_ts: dest.threadTs } : {}),
|
|
41
|
+
} as any);
|
|
42
|
+
}
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
if (out.text) {
|
|
45
|
+
const opts: Record<string, unknown> = { channel: dest.channel, text: out.text };
|
|
46
|
+
if (dest.threadTs) opts.thread_ts = dest.threadTs;
|
|
47
|
+
const result = await this.app.client.chat.postMessage(opts as any);
|
|
48
|
+
if (result.ts) this.outboundTs.add(result.ts);
|
|
49
|
+
}
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
await this.app.client.filesUploadV2({
|
|
50
|
-
channel_id: target,
|
|
51
|
-
file: data,
|
|
52
|
-
filename: filename || `file.${mimeType.split("/")[1] || "bin"}`,
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async sendMediaToThread(channelId: string, data: Buffer, mimeType: string, filename?: string, threadTs?: string): Promise<void> {
|
|
57
|
-
if (!this.app) throw new Error("Slack not started");
|
|
58
|
-
await this.app.client.filesUploadV2({
|
|
59
|
-
channel_id: channelId,
|
|
60
|
-
file: data,
|
|
61
|
-
filename: filename || `file.${mimeType.split("/")[1] || "bin"}`,
|
|
62
|
-
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
63
|
-
} as any);
|
|
52
|
+
private resolveDest(to: Recipient | undefined): { channel: string; threadTs?: string } {
|
|
53
|
+
if (to?.kind === "thread") return { channel: to.channelId, threadTs: to.threadTs };
|
|
54
|
+
if (!this.dmUserId) throw new Error("No Slack recipient — set dm_user_id in config");
|
|
55
|
+
return { channel: this.dmUserId };
|
|
64
56
|
}
|
|
65
57
|
|
|
66
58
|
async start(): Promise<void> {
|
|
@@ -87,62 +79,46 @@ class SlackChannel implements Channel {
|
|
|
87
79
|
return name;
|
|
88
80
|
}
|
|
89
81
|
|
|
90
|
-
function roomPrefix(key: string): string {
|
|
91
|
-
return `slack-${key}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function roomName(key: string, index: number): string {
|
|
95
|
-
return `slack-${key}-${index}`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
82
|
interface SlackContext {
|
|
99
83
|
slackChannelId?: string;
|
|
100
84
|
slackThreadTs?: string;
|
|
101
85
|
}
|
|
102
86
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!state) {
|
|
106
|
-
const prefix = roomPrefix(key);
|
|
107
|
-
const idx = await Session.getLatestRoomIndex(prefix);
|
|
108
|
-
const room = roomName(key, idx);
|
|
109
|
-
const engine = await createChatEngine({
|
|
110
|
-
room,
|
|
111
|
-
channel: "slack",
|
|
112
|
-
resume: true,
|
|
113
|
-
mcpServers: getMcpServers({ channel: "slack", room, ...slackCtx }),
|
|
114
|
-
watchBehavior,
|
|
115
|
-
});
|
|
116
|
-
state = { engine, roomIndex: idx, lock: Promise.resolve() };
|
|
117
|
-
chats.set(key, state);
|
|
118
|
-
}
|
|
119
|
-
return state;
|
|
87
|
+
function roomPrefix(k: string): string {
|
|
88
|
+
return `slack-${k}`;
|
|
120
89
|
}
|
|
121
90
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const prefix = roomPrefix(key);
|
|
127
|
-
const prevIdx = await Session.getLatestRoomIndex(prefix);
|
|
128
|
-
const newIdx = prevIdx + 1;
|
|
129
|
-
const room = roomName(key, newIdx);
|
|
130
|
-
|
|
131
|
-
// Persist a placeholder session immediately so the room index survives
|
|
132
|
-
// daemon restarts (otherwise getState falls back to the old room).
|
|
133
|
-
await Session.create(`placeholder-${room}`, room);
|
|
91
|
+
function roomName(k: string, index: number): string {
|
|
92
|
+
return `slack-${k}-${index}`;
|
|
93
|
+
}
|
|
134
94
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
room,
|
|
95
|
+
function buildEngineOpts(watchBehavior?: { channel: string; behavior: string }, slackCtx?: SlackContext) {
|
|
96
|
+
return (room: string) => ({
|
|
138
97
|
channel: "slack",
|
|
139
|
-
resume: false,
|
|
140
98
|
mcpServers: getMcpServers({ channel: "slack", room, ...slackCtx }),
|
|
141
99
|
watchBehavior,
|
|
142
100
|
});
|
|
143
|
-
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function getState(
|
|
104
|
+
key: string,
|
|
105
|
+
watchBehavior?: { channel: string; behavior: string },
|
|
106
|
+
slackCtx?: SlackContext,
|
|
107
|
+
): Promise<ChatState> {
|
|
108
|
+
let state = chats.get(key);
|
|
109
|
+
if (state) return state;
|
|
110
|
+
state = await openChatEngine(roomPrefix(key), buildEngineOpts(watchBehavior, slackCtx));
|
|
111
|
+
chats.set(key, state);
|
|
112
|
+
return state;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function restartChat(
|
|
116
|
+
key: string,
|
|
117
|
+
watchBehavior?: { channel: string; behavior: string },
|
|
118
|
+
slackCtx?: SlackContext,
|
|
119
|
+
): Promise<ChatState> {
|
|
120
|
+
const state = await rotateRoom(roomPrefix(key), chats.get(key), buildEngineOpts(watchBehavior, slackCtx));
|
|
144
121
|
chats.set(key, state);
|
|
145
|
-
log.info({ key, room, activeSessions: chats.size }, "slack: engine ready");
|
|
146
122
|
return state;
|
|
147
123
|
}
|
|
148
124
|
|
|
@@ -152,9 +128,7 @@ class SlackChannel implements Channel {
|
|
|
152
128
|
fn().catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
|
|
153
129
|
return;
|
|
154
130
|
}
|
|
155
|
-
|
|
156
|
-
if (queued) log.debug({ key }, "slack: message queued behind active lock");
|
|
157
|
-
state.lock = state.lock.then(fn, fn).catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
|
|
131
|
+
chainLock(state, fn);
|
|
158
132
|
}
|
|
159
133
|
|
|
160
134
|
const self = this;
|
|
@@ -364,7 +338,7 @@ class SlackChannel implements Channel {
|
|
|
364
338
|
|
|
365
339
|
try {
|
|
366
340
|
const data = await downloadSlackFile(file.url_private_download);
|
|
367
|
-
const error = validateAttachment(data
|
|
341
|
+
const error = validateAttachment(data);
|
|
368
342
|
if (error) {
|
|
369
343
|
log.warn({ file: file.name, error }, "skipping slack attachment");
|
|
370
344
|
continue;
|
|
@@ -383,7 +357,13 @@ class SlackChannel implements Channel {
|
|
|
383
357
|
const entry: CachedFile = { path: diskPath, type: attType, mimeType: finalMime, filename: file.name };
|
|
384
358
|
fileIndex.set(indexedKey, entry);
|
|
385
359
|
|
|
386
|
-
attachments.push({
|
|
360
|
+
attachments.push({
|
|
361
|
+
type: attType,
|
|
362
|
+
data: finalData,
|
|
363
|
+
mimeType: finalMime,
|
|
364
|
+
filename: file.name,
|
|
365
|
+
sourcePath: diskPath,
|
|
366
|
+
});
|
|
387
367
|
} catch (err) {
|
|
388
368
|
log.warn({ err, file: file.name }, "failed to download slack file");
|
|
389
369
|
}
|
package/src/channels/sms.ts
CHANGED
|
@@ -14,29 +14,26 @@
|
|
|
14
14
|
* variable deliverability under TRAI scrubbing rules. Test empirically;
|
|
15
15
|
* if outbound fails, the inbound leg (Aman → Nia) is more reliable.
|
|
16
16
|
*/
|
|
17
|
-
import { createChatEngine } from "../chat/engine";
|
|
18
17
|
import { getMcpServers } from "../mcp";
|
|
19
|
-
import { Session } from "../db/models";
|
|
20
18
|
import { runMigrations } from "../db/migrate";
|
|
21
|
-
import type { Channel, ChatState,
|
|
19
|
+
import type { Channel, ChatState, Outbound, TwilioConfig } from "../types";
|
|
22
20
|
import { getConfig } from "../utils/config";
|
|
23
21
|
import { log } from "../utils/log";
|
|
24
22
|
import { sendMessage as twilioSendMessage } from "./twilio/rest";
|
|
25
23
|
import { getTwilioServer } from "./twilio/server";
|
|
24
|
+
import { chainLock, openChatEngine } from "./common/chat-session";
|
|
26
25
|
|
|
27
26
|
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
|
28
27
|
|
|
29
28
|
class SmsChannel implements Channel {
|
|
30
|
-
name = "sms";
|
|
29
|
+
name = "sms" as const;
|
|
31
30
|
private readonly twilio: TwilioConfig;
|
|
32
|
-
private readonly sms: SmsConfig;
|
|
33
31
|
/** Cached resolved "from" number: sms.from_number || phone.from_number */
|
|
34
32
|
private readonly fromNumber: string;
|
|
35
33
|
private readonly chats = new Map<string, ChatState>();
|
|
36
34
|
|
|
37
|
-
constructor(twilio: TwilioConfig,
|
|
35
|
+
constructor(twilio: TwilioConfig, fromNumber: string) {
|
|
38
36
|
this.twilio = twilio;
|
|
39
|
-
this.sms = sms;
|
|
40
37
|
this.fromNumber = fromNumber;
|
|
41
38
|
}
|
|
42
39
|
|
|
@@ -77,10 +74,16 @@ class SmsChannel implements Channel {
|
|
|
77
74
|
this.chats.clear();
|
|
78
75
|
}
|
|
79
76
|
|
|
80
|
-
/** Outbound
|
|
81
|
-
async
|
|
77
|
+
/** Outbound — used by send_message MCP tool. SMS is text-only; media is dropped with a warning. */
|
|
78
|
+
async deliver(out: Outbound): Promise<void> {
|
|
82
79
|
if (!this.twilio.owner_number) throw new Error("sms: owner_number not set");
|
|
83
|
-
|
|
80
|
+
// SMS has no threading; recipient kind is ignored.
|
|
81
|
+
if (out.media) {
|
|
82
|
+
log.warn({ filename: out.media.filename }, "sms: media payload dropped (channel is text-only)");
|
|
83
|
+
}
|
|
84
|
+
if (out.text) {
|
|
85
|
+
await this.sendTo(this.twilio.owner_number, out.text);
|
|
86
|
+
}
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
// --- Inbound webhook ---
|
|
@@ -97,19 +100,16 @@ class SmsChannel implements Channel {
|
|
|
97
100
|
const state = await this.getState(from);
|
|
98
101
|
// Ack the webhook immediately; reply via REST asynchronously to avoid
|
|
99
102
|
// Twilio's ~15s webhook timeout when the engine takes longer.
|
|
100
|
-
state
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
},
|
|
111
|
-
(err) => log.error({ err, from }, "sms: lock chain error"),
|
|
112
|
-
);
|
|
103
|
+
chainLock(state, async () => {
|
|
104
|
+
try {
|
|
105
|
+
const { result } = await state.engine.send(body);
|
|
106
|
+
const reply = result.trim() || "(no response)";
|
|
107
|
+
await this.sendTo(from, reply);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
log.error({ err, from }, "sms: engine error");
|
|
110
|
+
await this.sendTo(from, `[error] ${err instanceof Error ? err.message : String(err)}`).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
113
|
|
|
114
114
|
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
115
115
|
}
|
|
@@ -160,17 +160,7 @@ class SmsChannel implements Channel {
|
|
|
160
160
|
private async getState(remoteE164: string): Promise<ChatState> {
|
|
161
161
|
let state = this.chats.get(remoteE164);
|
|
162
162
|
if (state) return state;
|
|
163
|
-
|
|
164
|
-
const idx = await Session.getLatestRoomIndex(prefix);
|
|
165
|
-
const room = `${prefix}-${idx}`;
|
|
166
|
-
log.info({ remoteE164, room }, "sms: creating chat engine");
|
|
167
|
-
const engine = await createChatEngine({
|
|
168
|
-
room,
|
|
169
|
-
channel: "sms",
|
|
170
|
-
resume: true,
|
|
171
|
-
mcpServers: getMcpServers(),
|
|
172
|
-
});
|
|
173
|
-
state = { engine, roomIndex: idx, lock: Promise.resolve() };
|
|
163
|
+
state = await openChatEngine(`sms-${remoteE164}`, () => ({ channel: "sms", mcpServers: getMcpServers() }));
|
|
174
164
|
this.chats.set(remoteE164, state);
|
|
175
165
|
return state;
|
|
176
166
|
}
|
|
@@ -183,7 +173,7 @@ export function createSmsChannel(): SmsChannel | null {
|
|
|
183
173
|
// sms.from_number falls back to phone.from_number (same number for voice + SMS).
|
|
184
174
|
const fromNumber = sms.from_number ?? phone.from_number;
|
|
185
175
|
if (!fromNumber) return null;
|
|
186
|
-
return new SmsChannel(twilio,
|
|
176
|
+
return new SmsChannel(twilio, fromNumber);
|
|
187
177
|
}
|
|
188
178
|
|
|
189
179
|
export type { SmsChannel };
|
package/src/channels/telegram.ts
CHANGED
|
@@ -2,15 +2,15 @@ import { Bot, InputFile } from "grammy";
|
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
3
|
import { mkdirSync, writeFileSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
|
-
import {
|
|
6
|
-
import type { Channel, ChatState, Attachment } from "../types";
|
|
5
|
+
import type { Channel, ChatState, Attachment, Outbound } from "../types";
|
|
7
6
|
import { getConfig, updateRawConfig } from "../utils/config";
|
|
8
7
|
import { runMigrations } from "../db/migrate";
|
|
9
|
-
import {
|
|
8
|
+
import { Message } from "../db/models";
|
|
10
9
|
import { log } from "../utils/log";
|
|
11
10
|
import { getMcpServers } from "../mcp";
|
|
12
11
|
import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
|
|
13
12
|
import { getNiaHome } from "../utils/paths";
|
|
13
|
+
import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
|
|
14
14
|
|
|
15
15
|
function safeExtension(filename?: string): string {
|
|
16
16
|
const ext = filename?.split(".").pop();
|
|
@@ -23,27 +23,27 @@ function cacheExtension(filename: string | undefined, mimeType: string): string
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
class TelegramChannel implements Channel {
|
|
26
|
-
name = "telegram";
|
|
26
|
+
name = "telegram" as const;
|
|
27
27
|
private bot: Bot | null = null;
|
|
28
28
|
private outboundChatId: number | null = null;
|
|
29
29
|
|
|
30
|
-
async
|
|
30
|
+
async deliver(out: Outbound): Promise<void> {
|
|
31
31
|
if (!this.bot) throw new Error("Telegram not started");
|
|
32
32
|
const chatId = this.outboundChatId;
|
|
33
33
|
if (!chatId) throw new Error("No outbound chat ID registered");
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
await this.bot.api.
|
|
34
|
+
// Telegram has no native threading; thread recipients fall back to the
|
|
35
|
+
// configured DM chat (the same place we'd send to for `owner`).
|
|
36
|
+
|
|
37
|
+
if (out.media) {
|
|
38
|
+
const file = new InputFile(Buffer.from(out.media.data), out.media.filename);
|
|
39
|
+
if (out.media.mimeType.startsWith("image/")) {
|
|
40
|
+
await this.bot.api.sendPhoto(chatId, file);
|
|
41
|
+
} else {
|
|
42
|
+
await this.bot.api.sendDocument(chatId, file);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (out.text) {
|
|
46
|
+
await this.bot.api.sendMessage(chatId, out.text);
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -57,7 +57,13 @@ class TelegramChannel implements Channel {
|
|
|
57
57
|
return Buffer.from(await resp.arrayBuffer());
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
private cacheAttachment(
|
|
60
|
+
private cacheAttachment(
|
|
61
|
+
chatId: number,
|
|
62
|
+
roomIndex: number,
|
|
63
|
+
data: Buffer,
|
|
64
|
+
mimeType: string,
|
|
65
|
+
filename?: string,
|
|
66
|
+
): string {
|
|
61
67
|
const scope = `telegram-${chatId}-${roomIndex}`;
|
|
62
68
|
const dir = join(getNiaHome(), "tmp", "attachments", scope);
|
|
63
69
|
mkdirSync(dir, { recursive: true });
|
|
@@ -78,44 +84,23 @@ class TelegramChannel implements Channel {
|
|
|
78
84
|
|
|
79
85
|
const chats = new Map<number, ChatState>();
|
|
80
86
|
|
|
81
|
-
function
|
|
87
|
+
function keyOf(chatId: number): string {
|
|
82
88
|
return `tg-${chatId}`;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
|
-
function roomName(chatId: number, index: number): string {
|
|
86
|
-
return `tg-${chatId}-${index}`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
91
|
async function getState(chatId: number): Promise<ChatState> {
|
|
90
92
|
let state = chats.get(chatId);
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const room = roomName(chatId, idx);
|
|
95
|
-
log.info({ chatId, room }, "telegram: creating chat engine");
|
|
96
|
-
const engine = await createChatEngine({ room, channel: "telegram", resume: true, mcpServers: getMcpServers() });
|
|
97
|
-
state = { engine, roomIndex: idx, lock: Promise.resolve() };
|
|
98
|
-
chats.set(chatId, state);
|
|
99
|
-
log.info({ chatId, room, activeSessions: chats.size }, "telegram: engine ready");
|
|
100
|
-
}
|
|
93
|
+
if (state) return state;
|
|
94
|
+
state = await openChatEngine(keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
|
|
95
|
+
chats.set(chatId, state);
|
|
101
96
|
return state;
|
|
102
97
|
}
|
|
103
98
|
|
|
104
99
|
async function restartChat(chatId: number): Promise<ChatState> {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const prevIdx = await Session.getLatestRoomIndex(prefix);
|
|
110
|
-
const newIdx = prevIdx + 1;
|
|
111
|
-
const room = roomName(chatId, newIdx);
|
|
112
|
-
|
|
113
|
-
// Persist a placeholder session immediately so the room index survives
|
|
114
|
-
// daemon restarts (otherwise getState falls back to the old room).
|
|
115
|
-
await Session.create(`placeholder-${room}`, room);
|
|
116
|
-
|
|
117
|
-
const engine = await createChatEngine({ room, channel: "telegram", resume: false, mcpServers: getMcpServers() });
|
|
118
|
-
const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
|
|
100
|
+
const state = await rotateRoom(keyOf(chatId), chats.get(chatId), () => ({
|
|
101
|
+
channel: "telegram",
|
|
102
|
+
mcpServers: getMcpServers(),
|
|
103
|
+
}));
|
|
119
104
|
chats.set(chatId, state);
|
|
120
105
|
return state;
|
|
121
106
|
}
|
|
@@ -126,9 +111,7 @@ class TelegramChannel implements Channel {
|
|
|
126
111
|
fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
|
|
127
112
|
return;
|
|
128
113
|
}
|
|
129
|
-
|
|
130
|
-
if (queued) log.debug({ chatId }, "telegram: message queued behind active lock");
|
|
131
|
-
state.lock = state.lock.then(fn, fn);
|
|
114
|
+
chainLock(state, fn);
|
|
132
115
|
}
|
|
133
116
|
|
|
134
117
|
const isOpen = config.channels.telegram.open;
|
|
@@ -151,7 +134,10 @@ class TelegramChannel implements Channel {
|
|
|
151
134
|
|
|
152
135
|
async function processMessage(ctx: any, state: ChatState, text: string, attachments?: Attachment[]): Promise<void> {
|
|
153
136
|
const chatId = ctx.chatId;
|
|
154
|
-
log.info(
|
|
137
|
+
log.info(
|
|
138
|
+
{ chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 },
|
|
139
|
+
"telegram message received",
|
|
140
|
+
);
|
|
155
141
|
|
|
156
142
|
// Show typing indicator throughout
|
|
157
143
|
const typingInterval = setInterval(() => {
|
|
@@ -251,7 +237,7 @@ class TelegramChannel implements Channel {
|
|
|
251
237
|
const mime = doc.mime_type || "application/octet-stream";
|
|
252
238
|
const attType = classifyMime(mime) || "file";
|
|
253
239
|
let data = await self.downloadFile(doc.file_id);
|
|
254
|
-
const error = validateAttachment(data
|
|
240
|
+
const error = validateAttachment(data);
|
|
255
241
|
if (error) {
|
|
256
242
|
await ctx.reply(error);
|
|
257
243
|
return;
|
|
@@ -263,7 +249,13 @@ class TelegramChannel implements Channel {
|
|
|
263
249
|
finalMime = prepared.mimeType;
|
|
264
250
|
}
|
|
265
251
|
const sourcePath = self.cacheAttachment(ctx.chatId, state.roomIndex, data, finalMime, doc.file_name);
|
|
266
|
-
const attachment: Attachment = {
|
|
252
|
+
const attachment: Attachment = {
|
|
253
|
+
type: attType,
|
|
254
|
+
data,
|
|
255
|
+
mimeType: finalMime,
|
|
256
|
+
filename: doc.file_name,
|
|
257
|
+
sourcePath,
|
|
258
|
+
};
|
|
267
259
|
const caption = ctx.message.caption || (attType === "image" ? "What's in this image?" : "Here's a file.");
|
|
268
260
|
await processMessage(ctx, state, caption, [attachment]);
|
|
269
261
|
} catch (err) {
|