refactor!: rename chat providers to channels
This commit is contained in:
332
src/channels/dock.ts
Normal file
332
src/channels/dock.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
||||
import { resolveSignalAccount } from "../signal/accounts.js";
|
||||
import { resolveSlackAccount } from "../slack/accounts.js";
|
||||
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||
import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveSlackGroupRequireMention,
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveWhatsAppGroupRequireMention,
|
||||
} from "./plugins/group-mentions.js";
|
||||
import type {
|
||||
ChannelCapabilities,
|
||||
ChannelCommandAdapter,
|
||||
ChannelElevatedAdapter,
|
||||
ChannelGroupAdapter,
|
||||
ChannelId,
|
||||
ChannelMentionAdapter,
|
||||
ChannelThreadingAdapter,
|
||||
} from "./plugins/types.js";
|
||||
import { CHAT_CHANNEL_ORDER } from "./registry.js";
|
||||
|
||||
export type ChannelDock = {
|
||||
id: ChannelId;
|
||||
capabilities: ChannelCapabilities;
|
||||
commands?: ChannelCommandAdapter;
|
||||
outbound?: {
|
||||
textChunkLimit?: number;
|
||||
};
|
||||
streaming?: ChannelDockStreaming;
|
||||
elevated?: ChannelElevatedAdapter;
|
||||
config?: {
|
||||
resolveAllowFrom?: (params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}) => Array<string | number> | undefined;
|
||||
formatAllowFrom?: (params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
allowFrom: Array<string | number>;
|
||||
}) => string[];
|
||||
};
|
||||
groups?: ChannelGroupAdapter;
|
||||
mentions?: ChannelMentionAdapter;
|
||||
threading?: ChannelThreadingAdapter;
|
||||
};
|
||||
|
||||
type ChannelDockStreaming = {
|
||||
blockStreamingCoalesceDefaults?: {
|
||||
minChars?: number;
|
||||
idleMs?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const formatLower = (allowFrom: Array<string | number>) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.toLowerCase());
|
||||
|
||||
const escapeRegExp = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
// Channel docks: lightweight channel metadata/behavior for shared code paths.
|
||||
//
|
||||
// Rules:
|
||||
// - keep this module *light* (no monitors, probes, puppeteer/web login, etc)
|
||||
// - OK: config readers, allowFrom formatting, mention stripping patterns, threading defaults
|
||||
// - shared code should import from here (and from `src/channels/registry.ts`), not from the plugins registry
|
||||
//
|
||||
// Adding a channel:
|
||||
// - add a new entry to `DOCKS`
|
||||
// - keep it cheap; push heavy logic into `src/channels/plugins/<id>.ts` or channel modules
|
||||
const DOCKS: Record<ChannelId, ChannelDock> = {
|
||||
telegram: {
|
||||
id: "telegram",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "channel", "thread"],
|
||||
nativeCommands: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 4000 },
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.replace(/^(telegram|tg):/i, ""))
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveTelegramGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) =>
|
||||
cfg.channels?.telegram?.replyToMode ?? "first",
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
id: "whatsapp",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
polls: true,
|
||||
reactions: true,
|
||||
media: true,
|
||||
},
|
||||
commands: {
|
||||
enforceOwnerForCommands: true,
|
||||
skipWhenConfigEmpty: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 4000 },
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [],
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.map((entry) =>
|
||||
entry === "*" ? entry : normalizeWhatsAppTarget(entry),
|
||||
)
|
||||
.filter((entry): entry is string => Boolean(entry)),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveWhatsAppGroupRequireMention,
|
||||
resolveGroupIntroHint: () =>
|
||||
"WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).",
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: ({ ctx }) => {
|
||||
const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
if (!selfE164) return [];
|
||||
const escaped = escapeRegExp(selfE164);
|
||||
return [escaped, `@${escaped}`];
|
||||
},
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
id: "discord",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "thread"],
|
||||
polls: true,
|
||||
reactions: true,
|
||||
media: true,
|
||||
nativeCommands: true,
|
||||
threads: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 2000 },
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
elevated: {
|
||||
allowFromFallback: ({ cfg }) => cfg.channels?.discord?.dm?.allowFrom,
|
||||
},
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(
|
||||
resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []
|
||||
).map((entry) => String(entry)),
|
||||
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveDiscordGroupRequireMention,
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ["<@!?\\d+>"],
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) =>
|
||||
cfg.channels?.discord?.replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
id: "slack",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "thread"],
|
||||
reactions: true,
|
||||
media: true,
|
||||
nativeCommands: true,
|
||||
threads: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 4000 },
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveSlackAccount({ cfg, accountId }).dm?.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||
allowTagsWhenOff: true,
|
||||
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
|
||||
const configuredReplyToMode =
|
||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off";
|
||||
const effectiveReplyToMode = context.ThreadLabel
|
||||
? "all"
|
||||
: configuredReplyToMode;
|
||||
return {
|
||||
currentChannelId: context.To?.startsWith("channel:")
|
||||
? context.To.slice("channel:".length)
|
||||
: undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
replyToMode: effectiveReplyToMode,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
signal: {
|
||||
id: "signal",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: true,
|
||||
media: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 4000 },
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) =>
|
||||
entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")),
|
||||
)
|
||||
.filter(Boolean),
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
},
|
||||
imessage: {
|
||||
id: "imessage",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: true,
|
||||
media: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 4000 },
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
},
|
||||
msteams: {
|
||||
id: "msteams",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "thread"],
|
||||
polls: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 4000 },
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [],
|
||||
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function listChannelDocks(): ChannelDock[] {
|
||||
return CHAT_CHANNEL_ORDER.map((id) => DOCKS[id]);
|
||||
}
|
||||
|
||||
export function getChannelDock(id: ChannelId): ChannelDock | undefined {
|
||||
return DOCKS[id];
|
||||
}
|
||||
Reference in New Issue
Block a user