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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { appendFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { getPaths } from "../../utils/paths";
|
|
4
|
+
import { scanAgents } from "../../core/agents";
|
|
5
|
+
import { listEmployeesForMcp } from "../../core/employees";
|
|
6
|
+
import { readMemory as readMemoryUtil, addMemory as addMemoryUtil } from "../../utils/memory";
|
|
7
|
+
|
|
8
|
+
export function addRule(rule: string): string {
|
|
9
|
+
const { selfDir } = getPaths();
|
|
10
|
+
const rulesPath = join(selfDir, "rules.md");
|
|
11
|
+
const line = `\n- ${rule}\n`;
|
|
12
|
+
appendFileSync(rulesPath, line, "utf8");
|
|
13
|
+
return `Rule added to rules.md. Takes effect on next new session.`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const readMemory = readMemoryUtil;
|
|
17
|
+
export const addMemory = addMemoryUtil;
|
|
18
|
+
|
|
19
|
+
export function listAgents(): string {
|
|
20
|
+
const agents = scanAgents();
|
|
21
|
+
if (agents.length === 0) return "No agents found.";
|
|
22
|
+
return JSON.stringify(
|
|
23
|
+
agents.map((a) => ({
|
|
24
|
+
name: a.name,
|
|
25
|
+
description: a.description,
|
|
26
|
+
model: a.model,
|
|
27
|
+
source: a.source,
|
|
28
|
+
})),
|
|
29
|
+
null,
|
|
30
|
+
2,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function listEmployees(): string {
|
|
35
|
+
return listEmployeesForMcp();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function placeCall(args: {
|
|
39
|
+
number: string;
|
|
40
|
+
goal: string;
|
|
41
|
+
context?: string;
|
|
42
|
+
max_minutes?: number;
|
|
43
|
+
voice?: string;
|
|
44
|
+
}): Promise<string> {
|
|
45
|
+
// Dynamic import avoids a static cycle with channels/phone -> mcp/tools.
|
|
46
|
+
const { getPhoneChannel } = await import("../../channels/phone");
|
|
47
|
+
const phone = getPhoneChannel();
|
|
48
|
+
if (!phone) {
|
|
49
|
+
return "Phone channel is not configured. Add channels.twilio.{sid, secret, public_base_url} and channels.phone.{from_number, openai_api_key} to ~/.niahere/config.yaml (or set the matching env vars in .env), then restart the daemon.";
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const result = await phone.placeCall({
|
|
53
|
+
number: args.number,
|
|
54
|
+
goal: args.goal,
|
|
55
|
+
context: args.context,
|
|
56
|
+
maxMinutes: args.max_minutes,
|
|
57
|
+
voice: args.voice,
|
|
58
|
+
});
|
|
59
|
+
return `Call placed. callSid=${result.callSid} status=${result.status}. Transcript will land in messages once the call completes.`;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return `place_call failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { basename } from "path";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { Message, Session } from "../../db/models";
|
|
5
|
+
import { getConfig } from "../../utils/config";
|
|
6
|
+
import { getChannel } from "../../channels/registry";
|
|
7
|
+
import { log } from "../../utils/log";
|
|
8
|
+
import type { Recipient } from "../../types";
|
|
9
|
+
import type { McpSourceContext } from "../index";
|
|
10
|
+
|
|
11
|
+
/** Guess MIME type from file extension. */
|
|
12
|
+
export function guessMime(filePath: string): string {
|
|
13
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
14
|
+
const map: Record<string, string> = {
|
|
15
|
+
jpg: "image/jpeg",
|
|
16
|
+
jpeg: "image/jpeg",
|
|
17
|
+
png: "image/png",
|
|
18
|
+
gif: "image/gif",
|
|
19
|
+
webp: "image/webp",
|
|
20
|
+
txt: "text/plain",
|
|
21
|
+
md: "text/markdown",
|
|
22
|
+
csv: "text/csv",
|
|
23
|
+
json: "application/json",
|
|
24
|
+
pdf: "application/pdf",
|
|
25
|
+
html: "text/html",
|
|
26
|
+
};
|
|
27
|
+
return map[ext || ""] || "application/octet-stream";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Send directly via API when no started channel is available (e.g. CLI `nia send`). */
|
|
31
|
+
async function sendDirect(target: string, text: string): Promise<void> {
|
|
32
|
+
const config = getConfig();
|
|
33
|
+
|
|
34
|
+
if (target === "telegram") {
|
|
35
|
+
const token = config.channels.telegram.bot_token;
|
|
36
|
+
const chatId = config.channels.telegram.chat_id;
|
|
37
|
+
if (!token) throw new Error("Telegram not configured (no bot token)");
|
|
38
|
+
if (!chatId) throw new Error("No Telegram chat ID — send a message to the bot first");
|
|
39
|
+
const { Bot } = await import("grammy");
|
|
40
|
+
const bot = new Bot(token);
|
|
41
|
+
await bot.api.sendMessage(chatId, text);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (target === "slack") {
|
|
46
|
+
const token = config.channels.slack.bot_token;
|
|
47
|
+
const recipient = config.channels.slack.dm_user_id;
|
|
48
|
+
if (!token) throw new Error("Slack not configured (no bot token)");
|
|
49
|
+
if (!recipient) throw new Error("No Slack recipient — set dm_user_id in config");
|
|
50
|
+
const { App } = await import("@slack/bolt");
|
|
51
|
+
const app = new App({ token, signingSecret: "unused" });
|
|
52
|
+
await app.client.chat.postMessage({ token, channel: recipient, text });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw new Error(`Channel "${target}" not configured`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Send media directly via API (no started channel). */
|
|
60
|
+
async function sendMediaDirect(target: string, data: Buffer, mimeType: string, filename?: string): Promise<void> {
|
|
61
|
+
const config = getConfig();
|
|
62
|
+
|
|
63
|
+
if (target === "telegram") {
|
|
64
|
+
const token = config.channels.telegram.bot_token;
|
|
65
|
+
const chatId = config.channels.telegram.chat_id;
|
|
66
|
+
if (!token) throw new Error("Telegram not configured (no bot token)");
|
|
67
|
+
if (!chatId) throw new Error("No Telegram chat ID — send a message to the bot first");
|
|
68
|
+
const { Bot, InputFile } = await import("grammy");
|
|
69
|
+
const bot = new Bot(token);
|
|
70
|
+
const file = new InputFile(data, filename);
|
|
71
|
+
if (mimeType.startsWith("image/")) {
|
|
72
|
+
await bot.api.sendPhoto(chatId, file);
|
|
73
|
+
} else {
|
|
74
|
+
await bot.api.sendDocument(chatId, file);
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (target === "slack") {
|
|
80
|
+
const token = config.channels.slack.bot_token;
|
|
81
|
+
const recipient = config.channels.slack.dm_user_id;
|
|
82
|
+
if (!token) throw new Error("Slack not configured (no bot token)");
|
|
83
|
+
if (!recipient) throw new Error("No Slack recipient — set dm_user_id in config");
|
|
84
|
+
const { App } = await import("@slack/bolt");
|
|
85
|
+
const app = new App({ token, signingSecret: "unused" });
|
|
86
|
+
await app.client.filesUploadV2({
|
|
87
|
+
channel_id: recipient,
|
|
88
|
+
file: data,
|
|
89
|
+
filename: filename || `file.${mimeType.split("/")[1] || "bin"}`,
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error(`Channel "${target}" not configured`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function sendMessage(
|
|
98
|
+
text: string,
|
|
99
|
+
channelName?: string,
|
|
100
|
+
mediaPath?: string,
|
|
101
|
+
sourceCtx?: McpSourceContext,
|
|
102
|
+
target: "auto" | "dm" | "thread" = "auto",
|
|
103
|
+
): Promise<string> {
|
|
104
|
+
const config = getConfig();
|
|
105
|
+
const channelTarget = channelName || config.channels.default;
|
|
106
|
+
|
|
107
|
+
// Use started channel if available (daemon), otherwise call API directly (CLI)
|
|
108
|
+
const channel = getChannel(channelTarget);
|
|
109
|
+
|
|
110
|
+
// Resolve send target: thread reply vs DM
|
|
111
|
+
// "auto" = if we have thread context, reply there; otherwise DM
|
|
112
|
+
// "dm" = always DM the owner
|
|
113
|
+
// "thread" = reply in current thread (falls back to DM if no thread context)
|
|
114
|
+
const hasThreadCtx = sourceCtx?.slackChannelId && sourceCtx?.slackThreadTs;
|
|
115
|
+
const useThread = (target === "auto" && hasThreadCtx) || (target === "thread" && hasThreadCtx);
|
|
116
|
+
|
|
117
|
+
// Compute room prefix for DB storage BEFORE sending
|
|
118
|
+
let roomPrefix: string | undefined;
|
|
119
|
+
if (channelTarget === "telegram") {
|
|
120
|
+
const chatId = config.channels.telegram.chat_id;
|
|
121
|
+
if (chatId) roomPrefix = `tg-${chatId}`;
|
|
122
|
+
} else if (channelTarget === "slack") {
|
|
123
|
+
if (useThread && sourceCtx?.room) {
|
|
124
|
+
// Replying in-thread: use the source session's room prefix
|
|
125
|
+
roomPrefix = sourceCtx.room.replace(/-\d+$/, "");
|
|
126
|
+
} else {
|
|
127
|
+
const dmUserId = config.channels.slack.dm_user_id;
|
|
128
|
+
if (dmUserId) {
|
|
129
|
+
roomPrefix = `slack-dm-${dmUserId}`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Save pending notification to DB before sending (avoids race with fast replies)
|
|
135
|
+
let messageId: number | undefined;
|
|
136
|
+
if (roomPrefix) {
|
|
137
|
+
try {
|
|
138
|
+
const idx = await Session.getLatestRoomIndex(roomPrefix);
|
|
139
|
+
const fullRoom = `${roomPrefix}-${idx}`;
|
|
140
|
+
let sessionId = await Session.getLatest(fullRoom);
|
|
141
|
+
|
|
142
|
+
// Auto-create a backing session if none exists (e.g. first proactive DM)
|
|
143
|
+
if (!sessionId) {
|
|
144
|
+
sessionId = randomUUID();
|
|
145
|
+
await Session.create(sessionId, fullRoom);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const content = mediaPath ? `${text} [media: ${basename(mediaPath)}]` : text;
|
|
149
|
+
const source = sourceCtx?.jobName ? `job:${sourceCtx.jobName}` : sourceCtx?.channel || undefined;
|
|
150
|
+
const metadata: Record<string, unknown> = { kind: useThread ? "thread_reply" : "notification" };
|
|
151
|
+
if (source) metadata.source = source;
|
|
152
|
+
|
|
153
|
+
messageId = await Message.save({
|
|
154
|
+
sessionId,
|
|
155
|
+
room: fullRoom,
|
|
156
|
+
sender: "nia",
|
|
157
|
+
content,
|
|
158
|
+
isFromAgent: true,
|
|
159
|
+
deliveryStatus: "pending",
|
|
160
|
+
metadata,
|
|
161
|
+
});
|
|
162
|
+
} catch (err) {
|
|
163
|
+
log.warn({ err, channelTarget, roomPrefix }, "sendMessage: failed to save pending notification to DB");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
let media: { data: Uint8Array; mimeType: string; filename: string } | undefined;
|
|
169
|
+
if (mediaPath) {
|
|
170
|
+
if (!existsSync(mediaPath)) {
|
|
171
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
|
|
172
|
+
return `Failed to send: file not found: ${mediaPath}`;
|
|
173
|
+
}
|
|
174
|
+
const buf = readFileSync(mediaPath);
|
|
175
|
+
media = { data: new Uint8Array(buf), mimeType: guessMime(mediaPath), filename: basename(mediaPath) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const recipient: Recipient = useThread
|
|
179
|
+
? { kind: "thread", channelId: sourceCtx!.slackChannelId!, threadTs: sourceCtx!.slackThreadTs }
|
|
180
|
+
: { kind: "owner" };
|
|
181
|
+
|
|
182
|
+
if (channel) {
|
|
183
|
+
await channel.deliver({ text: text || undefined, media, to: recipient });
|
|
184
|
+
} else {
|
|
185
|
+
// No started channel in this process (e.g. CLI `nia send` outside the daemon).
|
|
186
|
+
// Fall back to API-direct send — text-only, no thread fan-out.
|
|
187
|
+
if (media) await sendMediaDirect(channelTarget, Buffer.from(media.data), media.mimeType, media.filename);
|
|
188
|
+
if (text) await sendDirect(channelTarget, text);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Mark as sent
|
|
192
|
+
if (messageId) {
|
|
193
|
+
await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return mediaPath ? "Message with media sent." : "Message sent.";
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
|
|
199
|
+
const errText = err instanceof Error ? err.message : String(err);
|
|
200
|
+
return `Failed to send: ${errText}`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readRawConfig, updateRawConfig, writeRawConfig } from "../../utils/config";
|
|
2
|
+
|
|
3
|
+
export function addWatchChannel(name: string, behavior?: string): string {
|
|
4
|
+
const raw = readRawConfig();
|
|
5
|
+
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
6
|
+
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
7
|
+
const entry: Record<string, unknown> = { enabled: true };
|
|
8
|
+
if (behavior !== undefined && behavior !== "") entry.behavior = behavior;
|
|
9
|
+
const watch = {
|
|
10
|
+
...((slack.watch || {}) as Record<string, unknown>),
|
|
11
|
+
[name]: entry,
|
|
12
|
+
};
|
|
13
|
+
updateRawConfig({ channels: { slack: { watch } } });
|
|
14
|
+
return `Watch channel "${name}" added (enabled). Takes effect on next message.`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function removeWatchChannel(name: string): string {
|
|
18
|
+
const raw = readRawConfig();
|
|
19
|
+
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
20
|
+
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
21
|
+
const watch = (slack.watch || {}) as Record<string, unknown>;
|
|
22
|
+
if (!watch[name]) return `Watch channel "${name}" not found.`;
|
|
23
|
+
delete watch[name];
|
|
24
|
+
writeRawConfig(raw);
|
|
25
|
+
return `Watch channel "${name}" removed. Takes effect on next message.`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function enableWatchChannel(name: string): string {
|
|
29
|
+
const raw = readRawConfig();
|
|
30
|
+
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
31
|
+
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
32
|
+
const watch = (slack.watch || {}) as Record<string, unknown>;
|
|
33
|
+
if (!watch[name]) return `Watch channel "${name}" not found.`;
|
|
34
|
+
const entry = watch[name] as Record<string, unknown>;
|
|
35
|
+
entry.enabled = true;
|
|
36
|
+
updateRawConfig({ channels: { slack: { watch } } });
|
|
37
|
+
return `Watch channel "${name}" enabled. Takes effect on next message.`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function disableWatchChannel(name: string): string {
|
|
41
|
+
const raw = readRawConfig();
|
|
42
|
+
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
43
|
+
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
44
|
+
const watch = (slack.watch || {}) as Record<string, unknown>;
|
|
45
|
+
if (!watch[name]) return `Watch channel "${name}" not found.`;
|
|
46
|
+
const entry = watch[name] as Record<string, unknown>;
|
|
47
|
+
entry.enabled = false;
|
|
48
|
+
updateRawConfig({ channels: { slack: { watch } } });
|
|
49
|
+
return `Watch channel "${name}" disabled. Takes effect on next message.`;
|
|
50
|
+
}
|
package/src/types/channel.ts
CHANGED
|
@@ -1,13 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an outbound payload is delivered.
|
|
3
|
+
*
|
|
4
|
+
* - `owner` → the channel's configured default recipient (DM user, owner
|
|
5
|
+
* phone number, etc.). Always supported.
|
|
6
|
+
* - `thread` → reply in a specific Slack thread. Channels that don't
|
|
7
|
+
* support threads fall back to `owner`.
|
|
8
|
+
*/
|
|
9
|
+
export type Recipient = { kind: "owner" } | { kind: "thread"; channelId: string; threadTs?: string };
|
|
10
|
+
|
|
11
|
+
export interface OutboundMedia {
|
|
12
|
+
data: Uint8Array;
|
|
13
|
+
mimeType: string;
|
|
14
|
+
filename?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Structured payload for agent-initiated outbound messages (`send_message`
|
|
19
|
+
* MCP tool, cross-channel notifications). Replaces the old optional
|
|
20
|
+
* sendMessage / sendMedia / sendToThread / sendMediaToThread surface.
|
|
21
|
+
*/
|
|
22
|
+
export interface Outbound {
|
|
23
|
+
text?: string;
|
|
24
|
+
media?: OutboundMedia;
|
|
25
|
+
/** Defaults to `{ kind: "owner" }`. */
|
|
26
|
+
to?: Recipient;
|
|
27
|
+
}
|
|
28
|
+
|
|
1
29
|
export interface Channel {
|
|
30
|
+
/** Channel identifier. Built-in channels use the `ChannelName` literals; test fixtures may use other strings. */
|
|
2
31
|
name: string;
|
|
3
32
|
start(): Promise<void>;
|
|
4
33
|
stop(): Promise<void>;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Deliver an outbound payload. Channels are expected to handle either
|
|
36
|
+
* a text-only, media-only, or text+media payload; format details (chunking,
|
|
37
|
+
* markdown, attachment shape) are channel-specific.
|
|
38
|
+
*/
|
|
39
|
+
deliver(out: Outbound): Promise<void>;
|
|
11
40
|
}
|
|
12
41
|
|
|
13
42
|
export type ChannelFactory = () => Channel | null;
|
package/src/types/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type { JobStatus, JobStateStatus, JobLifecycle, ScheduleType, Mode, Attac
|
|
|
3
3
|
export type { JobInput, JobPromptSource, JobResult, ResolvedJobPrompt } from "./job";
|
|
4
4
|
export type { SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatEngine, EngineOptions } from "./engine";
|
|
5
5
|
export type { AuditEntry, JobState, CronState } from "./audit";
|
|
6
|
-
export type { Channel, ChannelFactory } from "./channel";
|
|
6
|
+
export type { Channel, ChannelFactory, Outbound, OutboundMedia, Recipient } from "./channel";
|
|
7
7
|
export type { ChatState } from "./chat-state";
|
|
8
8
|
export type {
|
|
9
9
|
Config,
|
package/src/utils/attachment.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { AttachmentType } from "../types";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
IMAGE_MIMES,
|
|
4
|
+
DOCUMENT_MIMES,
|
|
5
|
+
MAX_ATTACHMENT_SIZE,
|
|
6
|
+
MAX_IMAGE_DIMENSION,
|
|
7
|
+
JPEG_QUALITY,
|
|
8
|
+
} from "../constants/attachment";
|
|
3
9
|
|
|
4
10
|
export function classifyMime(mimeType: string): AttachmentType | null {
|
|
5
11
|
if (IMAGE_MIMES.has(mimeType)) return "image";
|
|
@@ -8,7 +14,7 @@ export function classifyMime(mimeType: string): AttachmentType | null {
|
|
|
8
14
|
return "file";
|
|
9
15
|
}
|
|
10
16
|
|
|
11
|
-
export function validateAttachment(data: Buffer
|
|
17
|
+
export function validateAttachment(data: Buffer): string | null {
|
|
12
18
|
if (data.length > MAX_ATTACHMENT_SIZE) {
|
|
13
19
|
return `File too large (${(data.length / 1024 / 1024).toFixed(1)}MB, max ${MAX_ATTACHMENT_SIZE / 1024 / 1024}MB)`;
|
|
14
20
|
}
|
package/src/utils/config.ts
CHANGED
|
@@ -146,14 +146,10 @@ export function loadConfig(): Config {
|
|
|
146
146
|
const chSms = (ch.sms || {}) as Record<string, unknown>;
|
|
147
147
|
const chWa = (ch.whatsapp || {}) as Record<string, unknown>;
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
// legacy `channels.phone.<oldKey>` (the pre-refactor location), then env.
|
|
151
|
-
const twilioOrPhone = (newKey: string, oldKey: string, envKey: string | null): string | null => {
|
|
149
|
+
const twilioField = (key: string, envKey: string | null): string | null => {
|
|
152
150
|
const envVal = envKey ? process.env[envKey] : undefined;
|
|
153
151
|
if (envVal) return envVal;
|
|
154
|
-
|
|
155
|
-
if (typeof chPh[oldKey] === "string") return chPh[oldKey] as string;
|
|
156
|
-
return null;
|
|
152
|
+
return typeof chTw[key] === "string" ? (chTw[key] as string) : null;
|
|
157
153
|
};
|
|
158
154
|
|
|
159
155
|
const channelsEnabled = ch.enabled !== false;
|
|
@@ -174,11 +170,7 @@ export function loadConfig(): Config {
|
|
|
174
170
|
|
|
175
171
|
const slAppToken = process.env.SLACK_APP_TOKEN || (typeof chSl.app_token === "string" ? chSl.app_token : null);
|
|
176
172
|
|
|
177
|
-
|
|
178
|
-
const legacyChannelId =
|
|
179
|
-
process.env.SLACK_CHANNEL_ID || (typeof chSl.channel_id === "string" ? chSl.channel_id : null);
|
|
180
|
-
const slDmUserId =
|
|
181
|
-
process.env.SLACK_DM_USER_ID || (typeof chSl.dm_user_id === "string" ? chSl.dm_user_id : null) || legacyChannelId;
|
|
173
|
+
const slDmUserId = process.env.SLACK_DM_USER_ID || (typeof chSl.dm_user_id === "string" ? chSl.dm_user_id : null);
|
|
182
174
|
|
|
183
175
|
const slBotUserId = typeof chSl.bot_user_id === "string" ? chSl.bot_user_id : null;
|
|
184
176
|
const slBotName = typeof chSl.bot_name === "string" ? chSl.bot_name : null;
|
|
@@ -187,15 +179,12 @@ export function loadConfig(): Config {
|
|
|
187
179
|
const slWorkspaceUrl = typeof chSl.workspace_url === "string" ? chSl.workspace_url : null;
|
|
188
180
|
|
|
189
181
|
// --- Twilio shared config (used by phone, sms, whatsapp) ---
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
const twOwnerNumber = twilioOrPhone("owner_number", "owner_number", "PRIMARY_PHONE_USER");
|
|
197
|
-
const twPublicBaseUrl =
|
|
198
|
-
(twilioOrPhone("public_base_url", "public_base_url", "PUBLIC_BASE_URL") || "").replace(/\/$/, "") || null;
|
|
182
|
+
// Env vars take precedence over channels.twilio.* values.
|
|
183
|
+
const twSid = twilioField("sid", "TWILIO_SID");
|
|
184
|
+
const twSecret = twilioField("secret", "TWILIO_SECRET");
|
|
185
|
+
const twAuthToken = twilioField("auth_token", "TWILIO_AUTH_TOKEN");
|
|
186
|
+
const twOwnerNumber = twilioField("owner_number", "PRIMARY_PHONE_USER");
|
|
187
|
+
const twPublicBaseUrl = (twilioField("public_base_url", "PUBLIC_BASE_URL") || "").replace(/\/$/, "") || null;
|
|
199
188
|
|
|
200
189
|
const twPortRaw = process.env.PHONE_PORT ? Number(process.env.PHONE_PORT) : null;
|
|
201
190
|
const twPort =
|
|
@@ -203,23 +192,19 @@ export function loadConfig(): Config {
|
|
|
203
192
|
? twPortRaw
|
|
204
193
|
: typeof chTw.port === "number"
|
|
205
194
|
? chTw.port
|
|
206
|
-
:
|
|
207
|
-
? chPh.port
|
|
208
|
-
: DEFAULTS.channels.twilio.port;
|
|
195
|
+
: DEFAULTS.channels.twilio.port;
|
|
209
196
|
|
|
210
197
|
const twAllowlistRaw =
|
|
211
198
|
process.env.PHONE_ALLOWLIST ||
|
|
212
199
|
(Array.isArray(chTw.allowlist)
|
|
213
200
|
? (chTw.allowlist as unknown[]).filter((x): x is string => typeof x === "string").join(",")
|
|
214
|
-
:
|
|
215
|
-
? (chPh.allowlist as unknown[]).filter((x): x is string => typeof x === "string").join(",")
|
|
216
|
-
: "");
|
|
201
|
+
: "");
|
|
217
202
|
const twAllowlist = twAllowlistRaw
|
|
218
203
|
.split(",")
|
|
219
204
|
.map((s) => s.trim())
|
|
220
205
|
.filter((s) => s.length > 0);
|
|
221
206
|
|
|
222
|
-
// --- Phone (voice) — env vars
|
|
207
|
+
// --- Phone (voice) — env vars override config ---
|
|
223
208
|
const phFromNumber =
|
|
224
209
|
process.env.PHONE_FROM_NUMBER || (typeof chPh.from_number === "string" ? chPh.from_number : null);
|
|
225
210
|
const phOpenAiKey =
|