niahere 0.3.1 → 0.3.3
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/attachments.ts +142 -0
- package/src/channels/slack/watch.ts +73 -0
- package/src/channels/slack.ts +63 -267
- package/src/channels/sms.ts +25 -35
- package/src/channels/telegram.ts +224 -223
- 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/index.ts +9 -0
- package/src/mcp/tools/jobs.ts +145 -0
- package/src/mcp/tools/messages.ts +25 -0
- package/src/mcp/tools/misc.ts +63 -0
- package/src/mcp/tools/send.ts +202 -0
- package/src/mcp/tools/watch.ts +50 -0
- 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/src/mcp/tools.ts +0 -497
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
|
},
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk-backed cache for Slack file attachments. Slack file URLs expire and
|
|
3
|
+
* require Authorization on download, so we fetch once per (scope, url),
|
|
4
|
+
* write the bytes + metadata to `~/.niahere/tmp/attachments/<scope>/`, and
|
|
5
|
+
* read from disk on subsequent references. Survives daemon restarts via
|
|
6
|
+
* the metadata sidecar.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "crypto";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import type { Attachment, AttachmentType } from "../../types";
|
|
12
|
+
import { classifyMime, prepareImage, validateAttachment } from "../../utils/attachment";
|
|
13
|
+
import { getNiaHome } from "../../utils/paths";
|
|
14
|
+
import { log } from "../../utils/log";
|
|
15
|
+
|
|
16
|
+
interface CachedFile {
|
|
17
|
+
path: string;
|
|
18
|
+
type: AttachmentType;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
filename?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function urlHash(url: string): string {
|
|
24
|
+
return createHash("sha256").update(url).digest("hex").slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeExtension(filename?: string): string {
|
|
28
|
+
const ext = filename?.split(".").pop();
|
|
29
|
+
return ext && /^[a-zA-Z0-9]{1,16}$/.test(ext) ? ext : "bin";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cacheExtension(filename: string | undefined, mime: string, attType: AttachmentType): string {
|
|
33
|
+
if (attType === "image" && mime !== "image/gif") return "jpg";
|
|
34
|
+
return safeExtension(filename);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadCached(entry: CachedFile): Attachment {
|
|
38
|
+
return {
|
|
39
|
+
type: entry.type,
|
|
40
|
+
data: readFileSync(entry.path),
|
|
41
|
+
mimeType: entry.mimeType,
|
|
42
|
+
filename: entry.filename,
|
|
43
|
+
sourcePath: entry.path,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class SlackAttachmentCache {
|
|
48
|
+
private readonly attachRoot: string;
|
|
49
|
+
private readonly fileIndex = new Map<string, CachedFile>();
|
|
50
|
+
|
|
51
|
+
constructor(private readonly botToken: string) {
|
|
52
|
+
this.attachRoot = join(getNiaHome(), "tmp", "attachments");
|
|
53
|
+
mkdirSync(this.attachRoot, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async extract(files: any[], scope: string): Promise<Attachment[]> {
|
|
57
|
+
const attachments: Attachment[] = [];
|
|
58
|
+
const scopedDir = this.dirForScope(scope);
|
|
59
|
+
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
const mime = file.mimetype || "application/octet-stream";
|
|
62
|
+
const attType = classifyMime(mime);
|
|
63
|
+
if (!attType) continue;
|
|
64
|
+
if (!file.url_private_download) continue;
|
|
65
|
+
|
|
66
|
+
const indexedKey = `${scope}:${file.url_private_download}`;
|
|
67
|
+
const cached = this.fileIndex.get(indexedKey);
|
|
68
|
+
if (cached && existsSync(cached.path)) {
|
|
69
|
+
attachments.push(loadCached(cached));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const hash = urlHash(file.url_private_download);
|
|
74
|
+
const ext = cacheExtension(file.name, mime, attType);
|
|
75
|
+
const diskPath = join(scopedDir, `${hash}.${ext}`);
|
|
76
|
+
const metaPath = join(scopedDir, `${hash}.meta.json`);
|
|
77
|
+
|
|
78
|
+
// Re-load from disk if a prior daemon run already wrote this file.
|
|
79
|
+
if (existsSync(diskPath) && existsSync(metaPath)) {
|
|
80
|
+
try {
|
|
81
|
+
const meta = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
82
|
+
const entry: CachedFile = {
|
|
83
|
+
path: diskPath,
|
|
84
|
+
type: meta.type || attType,
|
|
85
|
+
mimeType: meta.mimeType || mime,
|
|
86
|
+
filename: meta.filename || file.name,
|
|
87
|
+
};
|
|
88
|
+
this.fileIndex.set(indexedKey, entry);
|
|
89
|
+
attachments.push(loadCached(entry));
|
|
90
|
+
continue;
|
|
91
|
+
} catch {
|
|
92
|
+
// Corrupt meta — re-download.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const raw = await this.download(file.url_private_download);
|
|
98
|
+
const error = validateAttachment(raw);
|
|
99
|
+
if (error) {
|
|
100
|
+
log.warn({ file: file.name, error }, "skipping slack attachment");
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let data = raw;
|
|
104
|
+
let finalMime = mime;
|
|
105
|
+
if (attType === "image") {
|
|
106
|
+
const prepared = await prepareImage(raw, mime);
|
|
107
|
+
data = prepared.data;
|
|
108
|
+
finalMime = prepared.mimeType;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
writeFileSync(diskPath, data);
|
|
112
|
+
writeFileSync(metaPath, JSON.stringify({ type: attType, mimeType: finalMime, filename: file.name }));
|
|
113
|
+
const entry: CachedFile = { path: diskPath, type: attType, mimeType: finalMime, filename: file.name };
|
|
114
|
+
this.fileIndex.set(indexedKey, entry);
|
|
115
|
+
|
|
116
|
+
attachments.push({
|
|
117
|
+
type: attType,
|
|
118
|
+
data,
|
|
119
|
+
mimeType: finalMime,
|
|
120
|
+
filename: file.name,
|
|
121
|
+
sourcePath: diskPath,
|
|
122
|
+
});
|
|
123
|
+
} catch (err) {
|
|
124
|
+
log.warn({ err, file: file.name }, "failed to download slack file");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return attachments;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private dirForScope(scope: string): string {
|
|
131
|
+
const safeScope = scope.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
132
|
+
const dir = join(this.attachRoot, safeScope);
|
|
133
|
+
mkdirSync(dir, { recursive: true });
|
|
134
|
+
return dir;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async download(url: string): Promise<Buffer> {
|
|
138
|
+
const resp = await fetch(url, { headers: { Authorization: `Bearer ${this.botToken}` } });
|
|
139
|
+
if (!resp.ok) throw new Error(`Slack file download failed: ${resp.status}`);
|
|
140
|
+
return Buffer.from(await resp.arrayBuffer());
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack watch channels: hot-reloads `channels.slack.watch` entries from
|
|
3
|
+
* config.yaml (plus any behavior files they reference) on each inbound
|
|
4
|
+
* message, gated by an mtime check so we don't re-parse config on every
|
|
5
|
+
* call. Keyed by `channel_id` (the part before `#` in the config key).
|
|
6
|
+
*/
|
|
7
|
+
import { statSync } from "fs";
|
|
8
|
+
import { getConfig, resetConfig } from "../../utils/config";
|
|
9
|
+
import { getPaths } from "../../utils/paths";
|
|
10
|
+
import { log } from "../../utils/log";
|
|
11
|
+
import { resolveWatchBehavior } from "../../utils/watches";
|
|
12
|
+
|
|
13
|
+
export interface WatchEntry {
|
|
14
|
+
name: string;
|
|
15
|
+
behavior: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function maxMtime(paths: string[]): number {
|
|
19
|
+
let max = 0;
|
|
20
|
+
for (const p of paths) {
|
|
21
|
+
try {
|
|
22
|
+
const m = statSync(p).mtimeMs;
|
|
23
|
+
if (m > max) max = m;
|
|
24
|
+
} catch {
|
|
25
|
+
// missing file — ignore
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return max;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class SlackWatchReloader {
|
|
32
|
+
private cache = new Map<string, WatchEntry>();
|
|
33
|
+
private filePaths: string[] = [];
|
|
34
|
+
private lastReloadMtime = 0;
|
|
35
|
+
|
|
36
|
+
/** Re-parse config + behavior files if any have been modified since the last read. */
|
|
37
|
+
reload(): Map<string, WatchEntry> {
|
|
38
|
+
const configPath = getPaths().config;
|
|
39
|
+
const mtime = maxMtime([configPath, ...this.filePaths]);
|
|
40
|
+
if (mtime === 0) return this.cache;
|
|
41
|
+
if (mtime === this.lastReloadMtime) return this.cache;
|
|
42
|
+
|
|
43
|
+
resetConfig();
|
|
44
|
+
const watch = getConfig().channels.slack.watch;
|
|
45
|
+
const fresh = new Map<string, WatchEntry>();
|
|
46
|
+
const freshFiles: string[] = [];
|
|
47
|
+
|
|
48
|
+
if (watch) {
|
|
49
|
+
for (const [key, entry] of Object.entries(watch)) {
|
|
50
|
+
if (!entry.enabled) continue;
|
|
51
|
+
const hashIdx = key.indexOf("#");
|
|
52
|
+
if (hashIdx === -1) {
|
|
53
|
+
log.warn({ channel: key }, "slack: watch key must use channel_id#name format, skipping");
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const id = key.slice(0, hashIdx);
|
|
57
|
+
const name = key.slice(hashIdx + 1);
|
|
58
|
+
const resolved = resolveWatchBehavior(entry.behavior, name);
|
|
59
|
+
if (resolved.filePath) freshFiles.push(resolved.filePath);
|
|
60
|
+
fresh.set(id, { name, behavior: resolved.behavior });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (fresh.size !== this.cache.size) {
|
|
65
|
+
log.info({ count: fresh.size }, "slack: watch channels reloaded");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.cache = fresh;
|
|
69
|
+
this.filePaths = freshFiles;
|
|
70
|
+
this.lastReloadMtime = maxMtime([configPath, ...freshFiles]);
|
|
71
|
+
return this.cache;
|
|
72
|
+
}
|
|
73
|
+
}
|