refactor!: rename chat providers to channels

This commit is contained in:
Peter Steinberger
2026-01-13 06:16:43 +00:00
parent 0cd632ba84
commit 90342a4f3a
393 changed files with 8004 additions and 6737 deletions

View File

@@ -0,0 +1,44 @@
import { sendMessageDiscord, sendPollDiscord } from "../../../discord/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const discordOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 2000,
pollMaxOptions: 10,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Discord requires --to <channelId|user:ID|channel:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "discord", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
mediaUrl,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "discord", ...result };
},
sendPoll: async ({ to, poll, accountId }) =>
await sendPollDiscord(to, poll, {
accountId: accountId ?? undefined,
}),
};

View File

@@ -0,0 +1,53 @@
import { chunkText } from "../../../auto-reply/chunk.js";
import { sendMessageIMessage } from "../../../imessage/send.js";
import { resolveChannelMediaMaxBytes } from "../media-limits.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const imessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to iMessage requires --to <handle|chat_id:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "imessage", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "imessage", ...result };
},
};

View File

@@ -0,0 +1,32 @@
import type { ChannelId, ChannelOutboundAdapter } from "../types.js";
type OutboundLoader = () => Promise<ChannelOutboundAdapter>;
// Channel docking: outbound sends should stay cheap to import.
//
// The full channel plugins (src/channels/plugins/*.ts) pull in status,
// onboarding, gateway monitors, etc. Outbound delivery only needs chunking +
// send primitives, so we keep a dedicated, lightweight loader here.
const LOADERS: Record<ChannelId, OutboundLoader> = {
telegram: async () => (await import("./telegram.js")).telegramOutbound,
whatsapp: async () => (await import("./whatsapp.js")).whatsappOutbound,
discord: async () => (await import("./discord.js")).discordOutbound,
slack: async () => (await import("./slack.js")).slackOutbound,
signal: async () => (await import("./signal.js")).signalOutbound,
imessage: async () => (await import("./imessage.js")).imessageOutbound,
msteams: async () => (await import("./msteams.js")).msteamsOutbound,
};
const cache = new Map<ChannelId, ChannelOutboundAdapter>();
export async function loadChannelOutboundAdapter(
id: ChannelId,
): Promise<ChannelOutboundAdapter | undefined> {
const cached = cache.get(id);
if (cached) return cached;
const loader = LOADERS[id];
if (!loader) return undefined;
const outbound = await loader();
cache.set(id, outbound);
return outbound;
}

View File

@@ -0,0 +1,60 @@
import { chunkMarkdownText } from "../../../auto-reply/chunk.js";
import { createMSTeamsPollStoreFs } from "../../../msteams/polls.js";
import { sendMessageMSTeams, sendPollMSTeams } from "../../../msteams/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkMarkdownText,
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to MS Teams requires --to <conversationId|user:ID|conversation:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, deps }) => {
const send =
deps?.sendMSTeams ??
((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text);
return { channel: "msteams", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => {
const send =
deps?.sendMSTeams ??
((to, text, opts) =>
sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }));
const result = await send(to, text, { mediaUrl });
return { channel: "msteams", ...result };
},
sendPoll: async ({ cfg, to, poll }) => {
const maxSelections = poll.maxSelections ?? 1;
const result = await sendPollMSTeams({
cfg,
to,
question: poll.question,
options: poll.options,
maxSelections,
});
const pollStore = createMSTeamsPollStoreFs();
await pollStore.createPoll({
id: result.pollId,
question: poll.question,
options: poll.options,
maxSelections,
createdAt: new Date().toISOString(),
conversationId: result.conversationId,
messageId: result.messageId,
votes: {},
});
return result;
},
};

View File

@@ -0,0 +1,53 @@
import { chunkText } from "../../../auto-reply/chunk.js";
import { sendMessageSignal } from "../../../signal/send.js";
import { resolveChannelMediaMaxBytes } from "../media-limits.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const signalOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Signal requires --to <E.164|group:ID|signal:group:ID|signal:+E.164>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "signal", ...result };
},
};

View File

@@ -0,0 +1,37 @@
import { sendMessageSlack } from "../../../slack/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const slackOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Slack requires --to <channelId|user:ID|channel:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const result = await send(to, text, {
threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "slack", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const result = await send(to, text, {
mediaUrl,
threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { channel: "slack", ...result };
},
};

View File

@@ -0,0 +1,56 @@
import { chunkMarkdownText } from "../../../auto-reply/chunk.js";
import { sendMessageTelegram } from "../../../telegram/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
function parseReplyToMessageId(replyToId?: string | null) {
if (!replyToId) return undefined;
const parsed = Number.parseInt(replyToId, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkMarkdownText,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to Telegram requires --to <chatId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendTelegram ?? sendMessageTelegram;
const replyToMessageId = parseReplyToMessageId(replyToId);
const result = await send(to, text, {
verbose: false,
messageThreadId: threadId ?? undefined,
replyToMessageId,
accountId: accountId ?? undefined,
});
return { channel: "telegram", ...result };
},
sendMedia: async ({
to,
text,
mediaUrl,
accountId,
deps,
replyToId,
threadId,
}) => {
const send = deps?.sendTelegram ?? sendMessageTelegram;
const replyToMessageId = parseReplyToMessageId(replyToId);
const result = await send(to, text, {
verbose: false,
mediaUrl,
messageThreadId: threadId ?? undefined,
replyToMessageId,
accountId: accountId ?? undefined,
});
return { channel: "telegram", ...result };
},
};

View File

@@ -0,0 +1,94 @@
import { chunkText } from "../../../auto-reply/chunk.js";
import { shouldLogVerbose } from "../../../globals.js";
import {
sendMessageWhatsApp,
sendPollWhatsApp,
} from "../../../web/outbound.js";
import {
isWhatsAppGroupJid,
normalizeWhatsAppTarget,
} from "../../../whatsapp/normalize.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "gateway",
chunker: chunkText,
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";
const allowListRaw = (allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean);
const hasWildcard = allowListRaw.includes("*");
const allowList = allowListRaw
.filter((entry) => entry !== "*")
.map((entry) => normalizeWhatsAppTarget(entry))
.filter((entry): entry is string => Boolean(entry));
if (trimmed) {
const normalizedTo = normalizeWhatsAppTarget(trimmed);
if (!normalizedTo) {
if (
(mode === "implicit" || mode === "heartbeat") &&
allowList.length > 0
) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: new Error(
"Delivering to WhatsApp requires --to <E.164|group JID> or channels.whatsapp.allowFrom[0]",
),
};
}
if (isWhatsAppGroupJid(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
if (mode === "implicit" || mode === "heartbeat") {
if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo };
}
if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
return { ok: true, to: allowList[0] };
}
return { ok: true, to: normalizedTo };
}
if (allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: new Error(
"Delivering to WhatsApp requires --to <E.164|group JID> or channels.whatsapp.allowFrom[0]",
),
};
},
sendText: async ({ to, text, accountId, deps, gifPlayback }) => {
const send = deps?.sendWhatsApp ?? sendMessageWhatsApp;
const result = await send(to, text, {
verbose: false,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, gifPlayback }) => {
const send = deps?.sendWhatsApp ?? sendMessageWhatsApp;
const result = await send(to, text, {
verbose: false,
mediaUrl,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendPoll: async ({ to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
}),
};