feat: add matrix channel plugin

This commit is contained in:
Peter Steinberger
2026-01-15 08:29:17 +00:00
parent f4bb5b381d
commit 725a6b71dc
51 changed files with 3986 additions and 24 deletions

View File

@@ -41,6 +41,7 @@ export function buildThreadingToolContext(params: {
To: threadingTo,
ReplyToId: sessionCtx.ReplyToId,
ThreadLabel: sessionCtx.ThreadLabel,
MessageThreadId: sessionCtx.MessageThreadId,
},
hasRepliedRef,
}) ?? {}

View File

@@ -216,9 +216,11 @@ export async function runReplyAgent(params: {
abortedLastRun: false,
};
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const topicId =
typeof sessionCtx.MessageThreadId === "number" ? sessionCtx.MessageThreadId : undefined;
const nextSessionFile = resolveSessionTranscriptPath(nextSessionId, agentId, topicId);
const nextSessionFile = resolveSessionTranscriptPath(
nextSessionId,
agentId,
sessionCtx.MessageThreadId,
);
nextEntry.sessionFile = nextSessionFile;
activeSessionStore[sessionKey] = nextEntry;
try {

View File

@@ -23,7 +23,10 @@ export function buildInboundDedupeKey(ctx: MsgContext): string | null {
if (!peerId) return null;
const sessionKey = ctx.SessionKey?.trim() ?? "";
const accountId = ctx.AccountId?.trim() ?? "";
const threadId = typeof ctx.MessageThreadId === "number" ? String(ctx.MessageThreadId) : "";
const threadId =
ctx.MessageThreadId !== undefined && ctx.MessageThreadId !== null
? String(ctx.MessageThreadId)
: "";
return [provider, accountId, sessionKey, peerId, threadId, messageId].filter(Boolean).join("|");
}

View File

@@ -36,8 +36,8 @@ export type FollowupRun = {
originatingTo?: string;
/** Provider account id (multi-account). */
originatingAccountId?: string;
/** Telegram forum topic thread id. */
originatingThreadId?: number;
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
originatingThreadId?: string | number;
run: {
agentId: string;
agentDir: string;

View File

@@ -27,8 +27,8 @@ export type RouteReplyParams = {
sessionKey?: string;
/** Provider account id (multi-account). */
accountId?: string;
/** Telegram message thread id (forum topics). */
threadId?: number;
/** Thread id for replies (Telegram topic id or Matrix thread event id). */
threadId?: string | number;
/** Config for provider-specific settings. */
cfg: ClawdbotConfig;
/** Optional abort signal for cooperative cancellation. */

View File

@@ -53,8 +53,8 @@ export type MsgContext = {
CommandAuthorized?: boolean;
CommandSource?: "text" | "native";
CommandTargetSessionKey?: string;
/** Telegram forum topic thread ID. */
MessageThreadId?: number;
/** Thread identifier (Telegram topic id or Matrix thread event id). */
MessageThreadId?: string | number;
/** Telegram forum supergroup marker. */
IsForum?: boolean;
/**

View File

@@ -103,11 +103,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
buildToolContext: ({ context, hasRepliedRef }) => {
const threadId = context.MessageThreadId ?? context.ReplyToId;
return {
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: threadId != null ? String(threadId) : undefined,
hasRepliedRef,
};
},
},
},
whatsapp: {

View File

@@ -6,10 +6,29 @@ export type ChannelPluginCatalogEntry = {
install: {
npmSpec: string;
localPath?: string;
defaultChoice?: "npm" | "local";
};
};
const CATALOG: ChannelPluginCatalogEntry[] = [
{
id: "matrix",
meta: {
id: "matrix",
label: "Matrix",
selectionLabel: "Matrix (plugin)",
docsPath: "/channels/matrix",
docsLabel: "matrix",
blurb: "open protocol; install the plugin to enable.",
order: 70,
quickstartAllowFrom: true,
},
install: {
npmSpec: "@clawdbot/matrix",
localPath: "extensions/matrix",
defaultChoice: "npm",
},
},
{
id: "zalo",
meta: {

View File

@@ -73,7 +73,7 @@ export type ChannelOutboundContext = {
mediaUrl?: string;
gifPlayback?: boolean;
replyToId?: string | null;
threadId?: number | null;
threadId?: string | number | null;
accountId?: string | null;
deps?: OutboundSendDeps;
};

View File

@@ -31,6 +31,12 @@ export type ChannelSetupInput = {
httpHost?: string;
httpPort?: string;
useEnv?: boolean;
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
};
export type ChannelStatusIssue = {
@@ -196,6 +202,7 @@ export type ChannelThreadingContext = {
To?: string;
ReplyToId?: string;
ThreadLabel?: string;
MessageThreadId?: string | number;
};
export type ChannelThreadingToolContext = {

View File

@@ -32,6 +32,12 @@ const optionNamesAdd = [
"httpHost",
"httpPort",
"useEnv",
"homeserver",
"userId",
"accessToken",
"password",
"deviceName",
"initialSyncLimit",
] as const;
const optionNamesRemove = ["channel", "account", "delete"] as const;
@@ -115,6 +121,12 @@ export function registerChannelsCli(program: Command) {
.option("--http-url <url>", "Signal HTTP daemon base URL")
.option("--http-host <host>", "Signal HTTP host")
.option("--http-port <port>", "Signal HTTP port")
.option("--homeserver <url>", "Matrix homeserver URL")
.option("--user-id <id>", "Matrix user ID")
.option("--access-token <token>", "Matrix access token")
.option("--password <password>", "Matrix password")
.option("--device-name <name>", "Matrix device name")
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
.option("--use-env", "Use env token (default account only)", false)
.action(async (opts, command) => {
try {

View File

@@ -36,6 +36,12 @@ export function applyChannelAccountConfig(params: {
httpHost?: string;
httpPort?: string;
useEnv?: boolean;
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
}): ClawdbotConfig {
const accountId = normalizeAccountId(params.accountId);
const plugin = getChannelPlugin(params.channel);
@@ -57,6 +63,12 @@ export function applyChannelAccountConfig(params: {
httpHost: params.httpHost,
httpPort: params.httpPort,
useEnv: params.useEnv,
homeserver: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
password: params.password,
deviceName: params.deviceName,
initialSyncLimit: params.initialSyncLimit,
};
return apply({ cfg: params.cfg, accountId, input });
}

View File

@@ -27,6 +27,12 @@ export type ChannelsAddOptions = {
httpHost?: string;
httpPort?: string;
useEnv?: boolean;
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number | string;
};
export async function channelsAddCommand(
@@ -88,9 +94,9 @@ export async function channelsAddCommand(
}
await writeConfigFile(nextConfig);
await prompter.outro("Channels updated.");
return;
}
await prompter.outro("Channels updated.");
return;
}
const channel = normalizeChannelId(opts.channel);
if (!channel) {
@@ -109,6 +115,12 @@ export async function channelsAddCommand(
plugin.setup.resolveAccountId?.({ cfg, accountId: opts.account }) ??
normalizeAccountId(opts.account);
const useEnv = opts.useEnv === true;
const initialSyncLimit =
typeof opts.initialSyncLimit === "number"
? opts.initialSyncLimit
: typeof opts.initialSyncLimit === "string" && opts.initialSyncLimit.trim()
? Number.parseInt(opts.initialSyncLimit, 10)
: undefined;
const validationError = plugin.setup.validateInput?.({
cfg,
accountId,
@@ -127,6 +139,12 @@ export async function channelsAddCommand(
httpUrl: opts.httpUrl,
httpHost: opts.httpHost,
httpPort: opts.httpPort,
homeserver: opts.homeserver,
userId: opts.userId,
accessToken: opts.accessToken,
password: opts.password,
deviceName: opts.deviceName,
initialSyncLimit,
useEnv,
},
});
@@ -154,6 +172,12 @@ export async function channelsAddCommand(
httpUrl: opts.httpUrl,
httpHost: opts.httpHost,
httpPort: opts.httpPort,
homeserver: opts.homeserver,
userId: opts.userId,
accessToken: opts.accessToken,
password: opts.password,
deviceName: opts.deviceName,
initialSyncLimit,
useEnv,
});

View File

@@ -36,10 +36,16 @@ export function resolveDefaultSessionStorePath(agentId?: string): string {
export function resolveSessionTranscriptPath(
sessionId: string,
agentId?: string,
topicId?: number,
topicId?: string | number,
): string {
const safeTopicId =
typeof topicId === "string"
? encodeURIComponent(topicId)
: typeof topicId === "number"
? String(topicId)
: undefined;
const fileName =
topicId !== undefined ? `${sessionId}-topic-${topicId}.jsonl` : `${sessionId}.jsonl`;
safeTopicId !== undefined ? `${sessionId}-topic-${safeTopicId}.jsonl` : `${sessionId}.jsonl`;
return path.join(resolveAgentSessionsDir(agentId), fileName);
}

View File

@@ -18,6 +18,12 @@ import type { OutboundChannel } from "./targets.js";
export type { NormalizedOutboundPayload } from "./payloads.js";
export { normalizeOutboundPayloads } from "./payloads.js";
type SendMatrixMessage = (
to: string,
text: string,
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number },
) => Promise<{ messageId: string; roomId: string }>;
export type OutboundSendDeps = {
sendWhatsApp?: typeof sendMessageWhatsApp;
sendTelegram?: typeof sendMessageTelegram;
@@ -25,6 +31,7 @@ export type OutboundSendDeps = {
sendSlack?: typeof sendMessageSlack;
sendSignal?: typeof sendMessageSignal;
sendIMessage?: typeof sendMessageIMessage;
sendMatrix?: SendMatrixMessage;
sendMSTeams?: (
to: string,
text: string,
@@ -37,6 +44,7 @@ export type OutboundDeliveryResult = {
messageId: string;
chatId?: string;
channelId?: string;
roomId?: string;
conversationId?: string;
timestamp?: number;
toJid?: string;
@@ -67,7 +75,7 @@ async function createChannelHandler(params: {
to: string;
accountId?: string;
replyToId?: string | null;
threadId?: number | null;
threadId?: string | number | null;
deps?: OutboundSendDeps;
gifPlayback?: boolean;
}): Promise<ChannelHandler> {
@@ -99,7 +107,7 @@ function createPluginHandler(params: {
to: string;
accountId?: string;
replyToId?: string | null;
threadId?: number | null;
threadId?: string | number | null;
deps?: OutboundSendDeps;
gifPlayback?: boolean;
}): ChannelHandler | null {
@@ -144,7 +152,7 @@ export async function deliverOutboundPayloads(params: {
accountId?: string;
payloads: ReplyPayload[];
replyToId?: string | null;
threadId?: number | null;
threadId?: string | number | null;
deps?: OutboundSendDeps;
gifPlayback?: boolean;
abortSignal?: AbortSignal;

View File

@@ -10,6 +10,7 @@ export type OutboundDeliveryJson = {
mediaUrl: string | null;
chatId?: string;
channelId?: string;
roomId?: string;
conversationId?: string;
timestamp?: number;
toJid?: string;
@@ -20,6 +21,7 @@ type OutboundDeliveryMeta = {
messageId?: string;
chatId?: string;
channelId?: string;
roomId?: string;
conversationId?: string;
timestamp?: number;
toJid?: string;
@@ -42,6 +44,7 @@ export function formatOutboundDeliverySummary(
if ("chatId" in result) return `${base} (chat ${result.chatId})`;
if ("channelId" in result) return `${base} (channel ${result.channelId})`;
if ("roomId" in result) return `${base} (room ${result.roomId})`;
if ("conversationId" in result) return `${base} (conversation ${result.conversationId})`;
return base;
}
@@ -69,6 +72,9 @@ export function buildOutboundDeliveryJson(params: {
if (result && "channelId" in result && result.channelId !== undefined) {
payload.channelId = result.channelId;
}
if (result && "roomId" in result && result.roomId !== undefined) {
payload.roomId = result.roomId;
}
if (result && "conversationId" in result && result.conversationId !== undefined) {
payload.conversationId = result.conversationId;
}