fix: normalize session keys and outbound mirroring
This commit is contained in:
92
src/infra/outbound/message-action-runner.threading.test.ts
Normal file
92
src/infra/outbound/message-action-runner.threading.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeSendAction: vi.fn(),
|
||||
recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock("./outbound-send-service.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./outbound-send-service.js")>(
|
||||
"./outbound-send-service.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
executeSendAction: mocks.executeSendAction,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||
"../../config/sessions.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
recordSessionMetaFromInbound: mocks.recordSessionMetaFromInbound,
|
||||
};
|
||||
});
|
||||
|
||||
import { runMessageAction } from "./message-action-runner.js";
|
||||
|
||||
const slackConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
describe("runMessageAction Slack threading", () => {
|
||||
beforeEach(async () => {
|
||||
const { createPluginRuntime } = await import("../../plugins/runtime/index.js");
|
||||
const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js");
|
||||
const runtime = createPluginRuntime();
|
||||
setSlackRuntime(runtime);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
source: "test",
|
||||
plugin: slackPlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
mocks.executeSendAction.mockReset();
|
||||
mocks.recordSessionMetaFromInbound.mockReset();
|
||||
});
|
||||
|
||||
it("uses toolContext thread when auto-threading is active", async () => {
|
||||
mocks.executeSendAction.mockResolvedValue({
|
||||
handledBy: "plugin",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
await runMessageAction({
|
||||
cfg: slackConfig,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "slack",
|
||||
target: "channel:C123",
|
||||
message: "hi",
|
||||
},
|
||||
toolContext: {
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "111.222",
|
||||
replyToMode: "all",
|
||||
},
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
const call = mocks.executeSendAction.mock.calls[0]?.[0];
|
||||
expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:111.222");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "../../agents/tools/common.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
|
||||
import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js";
|
||||
import type {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
resolveMessageChannelSelection,
|
||||
} from "./channel-selection.js";
|
||||
import { applyTargetToParams } from "./channel-target.js";
|
||||
import { ensureOutboundSessionEntry, resolveOutboundSessionRoute } from "./outbound-session.js";
|
||||
import type { OutboundSendDeps } from "./deliver.js";
|
||||
import type { MessagePollResult, MessageSendResult } from "./message.js";
|
||||
import {
|
||||
@@ -37,9 +39,10 @@ import {
|
||||
} from "./outbound-policy.js";
|
||||
import { executePollAction, executeSendAction } from "./outbound-send-service.js";
|
||||
import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js";
|
||||
import { resolveChannelTarget } from "./target-resolver.js";
|
||||
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { extensionForMime } from "../../media/mime.js";
|
||||
import { parseSlackTarget } from "../../slack/targets.js";
|
||||
|
||||
export type MessageActionRunnerGateway = {
|
||||
url?: string;
|
||||
@@ -204,6 +207,21 @@ function readBooleanParam(params: Record<string, unknown>, key: string): boolean
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveSlackAutoThreadId(params: {
|
||||
to: string;
|
||||
toolContext?: ChannelThreadingToolContext;
|
||||
}): string | undefined {
|
||||
const context = params.toolContext;
|
||||
if (!context?.currentThreadTs || !context.currentChannelId) return undefined;
|
||||
// Only mirror auto-threading when Slack would reply in the active thread for this channel.
|
||||
if (context.replyToMode !== "all" && context.replyToMode !== "first") return undefined;
|
||||
const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" });
|
||||
if (!parsedTarget || parsedTarget.kind !== "channel") return undefined;
|
||||
if (parsedTarget.id !== context.currentChannelId) return undefined;
|
||||
if (context.replyToMode === "first" && context.hasRepliedRef?.value) return undefined;
|
||||
return context.currentThreadTs;
|
||||
}
|
||||
|
||||
function resolveAttachmentMaxBytes(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: ChannelId;
|
||||
@@ -440,7 +458,8 @@ async function resolveActionTarget(params: {
|
||||
action: ChannelMessageActionName;
|
||||
args: Record<string, unknown>;
|
||||
accountId?: string | null;
|
||||
}): Promise<void> {
|
||||
}): Promise<ResolvedMessagingTarget | undefined> {
|
||||
let resolvedTarget: ResolvedMessagingTarget | undefined;
|
||||
const toRaw = typeof params.args.to === "string" ? params.args.to.trim() : "";
|
||||
if (toRaw) {
|
||||
const resolved = await resolveChannelTarget({
|
||||
@@ -451,6 +470,7 @@ async function resolveActionTarget(params: {
|
||||
});
|
||||
if (resolved.ok) {
|
||||
params.args.to = resolved.target.to;
|
||||
resolvedTarget = resolved.target;
|
||||
} else {
|
||||
throw resolved.error;
|
||||
}
|
||||
@@ -474,6 +494,7 @@ async function resolveActionTarget(params: {
|
||||
throw resolved.error;
|
||||
}
|
||||
}
|
||||
return resolvedTarget;
|
||||
}
|
||||
|
||||
type ResolvedActionContext = {
|
||||
@@ -484,6 +505,8 @@ type ResolvedActionContext = {
|
||||
dryRun: boolean;
|
||||
gateway?: MessageActionRunnerGateway;
|
||||
input: RunMessageActionParams;
|
||||
agentId?: string;
|
||||
resolvedTarget?: ResolvedMessagingTarget;
|
||||
};
|
||||
function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined {
|
||||
if (!input.gateway) return undefined;
|
||||
@@ -570,7 +593,7 @@ async function handleBroadcastAction(
|
||||
}
|
||||
|
||||
async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
|
||||
const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx;
|
||||
const { cfg, params, channel, accountId, dryRun, gateway, input, agentId, resolvedTarget } = ctx;
|
||||
const action: ChannelMessageActionName = "send";
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
// Support media, path, and filePath parameters for attachments
|
||||
@@ -621,6 +644,38 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false;
|
||||
const bestEffort = readBooleanParam(params, "bestEffort");
|
||||
|
||||
const replyToId = readStringParam(params, "replyTo");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
// Slack auto-threading can inject threadTs without explicit params; mirror to that session key.
|
||||
const slackAutoThreadId =
|
||||
channel === "slack" && !replyToId && !threadId
|
||||
? resolveSlackAutoThreadId({ to, toolContext: input.toolContext })
|
||||
: undefined;
|
||||
const outboundRoute =
|
||||
agentId && !dryRun
|
||||
? await resolveOutboundSessionRoute({
|
||||
cfg,
|
||||
channel,
|
||||
agentId,
|
||||
accountId,
|
||||
target: to,
|
||||
resolvedTarget,
|
||||
replyToId,
|
||||
threadId: threadId ?? slackAutoThreadId,
|
||||
})
|
||||
: null;
|
||||
if (outboundRoute && !dryRun) {
|
||||
await ensureOutboundSessionEntry({
|
||||
cfg,
|
||||
agentId,
|
||||
channel,
|
||||
accountId,
|
||||
route: outboundRoute,
|
||||
});
|
||||
}
|
||||
const mirrorMediaUrls =
|
||||
mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined;
|
||||
const send = await executeSendAction({
|
||||
ctx: {
|
||||
cfg,
|
||||
@@ -632,10 +687,12 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
deps: input.deps,
|
||||
dryRun,
|
||||
mirror:
|
||||
input.sessionKey && !dryRun
|
||||
outboundRoute && !dryRun
|
||||
? {
|
||||
sessionKey: input.sessionKey,
|
||||
agentId: input.agentId,
|
||||
sessionKey: outboundRoute.sessionKey,
|
||||
agentId,
|
||||
text: message,
|
||||
mediaUrls: mirrorMediaUrls,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
@@ -762,6 +819,11 @@ export async function runMessageAction(
|
||||
): Promise<MessageActionRunResult> {
|
||||
const cfg = input.cfg;
|
||||
const params = { ...input.params };
|
||||
const resolvedAgentId =
|
||||
input.agentId ??
|
||||
(input.sessionKey
|
||||
? resolveSessionAgentId({ sessionKey: input.sessionKey, config: cfg })
|
||||
: undefined);
|
||||
parseButtonsParam(params);
|
||||
parseCardParam(params);
|
||||
|
||||
@@ -839,7 +901,7 @@ export async function runMessageAction(
|
||||
dryRun,
|
||||
});
|
||||
|
||||
await resolveActionTarget({
|
||||
const resolvedTarget = await resolveActionTarget({
|
||||
cfg,
|
||||
channel,
|
||||
action,
|
||||
@@ -866,6 +928,8 @@ export async function runMessageAction(
|
||||
dryRun,
|
||||
gateway,
|
||||
input,
|
||||
agentId: resolvedAgentId,
|
||||
resolvedTarget,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ type MessageSendParams = {
|
||||
mirror?: {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js";
|
||||
import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js";
|
||||
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
|
||||
import type { OutboundSendDeps } from "./deliver.js";
|
||||
import type { MessagePollResult, MessageSendResult } from "./message.js";
|
||||
@@ -28,6 +29,8 @@ export type OutboundSendContext = {
|
||||
mirror?: {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -79,6 +82,19 @@ export async function executeSendAction(params: {
|
||||
dryRun: params.ctx.dryRun,
|
||||
});
|
||||
if (handled) {
|
||||
if (params.ctx.mirror) {
|
||||
const mirrorText = params.ctx.mirror.text ?? params.message;
|
||||
const mirrorMediaUrls =
|
||||
params.ctx.mirror.mediaUrls ??
|
||||
params.mediaUrls ??
|
||||
(params.mediaUrl ? [params.mediaUrl] : undefined);
|
||||
await appendAssistantMessageToSessionTranscript({
|
||||
agentId: params.ctx.mirror.agentId,
|
||||
sessionKey: params.ctx.mirror.sessionKey,
|
||||
text: mirrorText,
|
||||
mediaUrls: mirrorMediaUrls,
|
||||
});
|
||||
}
|
||||
return {
|
||||
handledBy: "plugin",
|
||||
payload: extractToolPayload(handled),
|
||||
|
||||
105
src/infra/outbound/outbound-session.test.ts
Normal file
105
src/infra/outbound/outbound-session.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveOutboundSessionRoute } from "./outbound-session.js";
|
||||
|
||||
const baseConfig = {} as ClawdbotConfig;
|
||||
|
||||
describe("resolveOutboundSessionRoute", () => {
|
||||
it("builds Slack thread session keys", async () => {
|
||||
const route = await resolveOutboundSessionRoute({
|
||||
cfg: baseConfig,
|
||||
channel: "slack",
|
||||
agentId: "main",
|
||||
target: "channel:C123",
|
||||
replyToId: "456",
|
||||
});
|
||||
|
||||
expect(route?.sessionKey).toBe("agent:main:slack:channel:c123:thread:456");
|
||||
expect(route?.from).toBe("slack:channel:C123");
|
||||
expect(route?.to).toBe("channel:C123");
|
||||
expect(route?.threadId).toBe("456");
|
||||
});
|
||||
|
||||
it("uses Telegram topic ids in group session keys", async () => {
|
||||
const route = await resolveOutboundSessionRoute({
|
||||
cfg: baseConfig,
|
||||
channel: "telegram",
|
||||
agentId: "main",
|
||||
target: "-100123456:topic:42",
|
||||
});
|
||||
|
||||
expect(route?.sessionKey).toBe("agent:main:telegram:group:-100123456:topic:42");
|
||||
expect(route?.from).toBe("telegram:group:-100123456:topic:42");
|
||||
expect(route?.to).toBe("telegram:-100123456");
|
||||
expect(route?.threadId).toBe(42);
|
||||
});
|
||||
|
||||
it("treats Telegram usernames as DMs when unresolved", async () => {
|
||||
const cfg = { session: { dmScope: "per-channel-peer" } } as ClawdbotConfig;
|
||||
const route = await resolveOutboundSessionRoute({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
agentId: "main",
|
||||
target: "@alice",
|
||||
});
|
||||
|
||||
expect(route?.sessionKey).toBe("agent:main:telegram:dm:@alice");
|
||||
expect(route?.chatType).toBe("direct");
|
||||
});
|
||||
|
||||
it("honors dmScope identity links", async () => {
|
||||
const cfg = {
|
||||
session: {
|
||||
dmScope: "per-peer",
|
||||
identityLinks: {
|
||||
alice: ["discord:123"],
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const route = await resolveOutboundSessionRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
agentId: "main",
|
||||
target: "user:123",
|
||||
});
|
||||
|
||||
expect(route?.sessionKey).toBe("agent:main:dm:alice");
|
||||
});
|
||||
|
||||
it("treats Zalo Personal DM targets as direct sessions", async () => {
|
||||
const cfg = { session: { dmScope: "per-channel-peer" } } as ClawdbotConfig;
|
||||
const route = await resolveOutboundSessionRoute({
|
||||
cfg,
|
||||
channel: "zalouser",
|
||||
agentId: "main",
|
||||
target: "123456",
|
||||
});
|
||||
|
||||
expect(route?.sessionKey).toBe("agent:main:zalouser:dm:123456");
|
||||
expect(route?.chatType).toBe("direct");
|
||||
});
|
||||
|
||||
it("uses group session keys for Slack mpim allowlist entries", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
dm: {
|
||||
groupChannels: ["G123"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const route = await resolveOutboundSessionRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
agentId: "main",
|
||||
target: "channel:G123",
|
||||
});
|
||||
|
||||
expect(route?.sessionKey).toBe("agent:main:slack:group:g123");
|
||||
expect(route?.from).toBe("slack:group:G123");
|
||||
});
|
||||
});
|
||||
834
src/infra/outbound/outbound-session.ts
Normal file
834
src/infra/outbound/outbound-session.ts
Normal file
@@ -0,0 +1,834 @@
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { recordSessionMetaFromInbound, resolveStorePath } from "../../config/sessions.js";
|
||||
import { parseDiscordTarget } from "../../discord/targets.js";
|
||||
import { parseIMessageTarget, normalizeIMessageHandle } from "../../imessage/targets.js";
|
||||
import {
|
||||
buildAgentSessionKey,
|
||||
type RoutePeer,
|
||||
type RoutePeerKind,
|
||||
} from "../../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../../routing/session-key.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
import { createSlackWebClient } from "../../slack/client.js";
|
||||
import { normalizeAllowListLower } from "../../slack/monitor/allow-list.js";
|
||||
import {
|
||||
resolveSignalPeerId,
|
||||
resolveSignalRecipient,
|
||||
resolveSignalSender,
|
||||
} from "../../signal/identity.js";
|
||||
import { parseSlackTarget } from "../../slack/targets.js";
|
||||
import { buildTelegramGroupPeerId } from "../../telegram/bot/helpers.js";
|
||||
import { resolveTelegramTargetChatType } from "../../telegram/inline-buttons.js";
|
||||
import { parseTelegramTarget } from "../../telegram/targets.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
|
||||
import type { ResolvedMessagingTarget } from "./target-resolver.js";
|
||||
|
||||
export type OutboundSessionRoute = {
|
||||
sessionKey: string;
|
||||
baseSessionKey: string;
|
||||
peer: RoutePeer;
|
||||
chatType: "direct" | "group" | "channel";
|
||||
from: string;
|
||||
to: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
|
||||
export type ResolveOutboundSessionRouteParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: ChannelId;
|
||||
agentId: string;
|
||||
accountId?: string | null;
|
||||
target: string;
|
||||
resolvedTarget?: ResolvedMessagingTarget;
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
};
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UUID_COMPACT_RE = /^[0-9a-f]{32}$/i;
|
||||
// Cache Slack channel type lookups to avoid repeated API calls.
|
||||
const SLACK_CHANNEL_TYPE_CACHE = new Map<string, "channel" | "group" | "dm" | "unknown">();
|
||||
|
||||
function looksLikeUuid(value: string): boolean {
|
||||
if (UUID_RE.test(value) || UUID_COMPACT_RE.test(value)) return true;
|
||||
const compact = value.replace(/-/g, "");
|
||||
if (!/^[0-9a-f]+$/i.test(compact)) return false;
|
||||
return /[a-f]/i.test(compact);
|
||||
}
|
||||
|
||||
function normalizeThreadId(value?: string | number | null): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) return undefined;
|
||||
return String(Math.trunc(value));
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function stripProviderPrefix(raw: string, channel: string): string {
|
||||
const trimmed = raw.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
const prefix = `${channel.toLowerCase()}:`;
|
||||
if (lower.startsWith(prefix)) return trimmed.slice(prefix.length).trim();
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function stripKindPrefix(raw: string): string {
|
||||
return raw.replace(/^(user|channel|group|conversation|room|dm):/i, "").trim();
|
||||
}
|
||||
|
||||
function inferPeerKind(params: {
|
||||
channel: ChannelId;
|
||||
resolvedTarget?: ResolvedMessagingTarget;
|
||||
}): RoutePeerKind {
|
||||
const resolvedKind = params.resolvedTarget?.kind;
|
||||
if (resolvedKind === "user") return "dm";
|
||||
if (resolvedKind === "channel") return "channel";
|
||||
if (resolvedKind === "group") {
|
||||
const plugin = getChannelPlugin(params.channel);
|
||||
const chatTypes = plugin?.capabilities?.chatTypes ?? [];
|
||||
const supportsChannel = chatTypes.includes("channel");
|
||||
const supportsGroup = chatTypes.includes("group");
|
||||
if (supportsChannel && !supportsGroup) return "channel";
|
||||
return "group";
|
||||
}
|
||||
return "dm";
|
||||
}
|
||||
|
||||
function buildBaseSessionKey(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
channel: ChannelId;
|
||||
peer: RoutePeer;
|
||||
}): string {
|
||||
return buildAgentSessionKey({
|
||||
agentId: params.agentId,
|
||||
channel: params.channel,
|
||||
peer: params.peer,
|
||||
dmScope: params.cfg.session?.dmScope ?? "main",
|
||||
identityLinks: params.cfg.session?.identityLinks,
|
||||
});
|
||||
}
|
||||
|
||||
// Best-effort mpim detection: allowlist/config, then Slack API (if token available).
|
||||
async function resolveSlackChannelType(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
channelId: string;
|
||||
}): Promise<"channel" | "group" | "dm" | "unknown"> {
|
||||
const channelId = params.channelId.trim();
|
||||
if (!channelId) return "unknown";
|
||||
const cached = SLACK_CHANNEL_TYPE_CACHE.get(`${params.accountId ?? "default"}:${channelId}`);
|
||||
if (cached) return cached;
|
||||
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const groupChannels = normalizeAllowListLower(account.dm?.groupChannels);
|
||||
const channelIdLower = channelId.toLowerCase();
|
||||
if (
|
||||
groupChannels.includes(channelIdLower) ||
|
||||
groupChannels.includes(`slack:${channelIdLower}`) ||
|
||||
groupChannels.includes(`channel:${channelIdLower}`) ||
|
||||
groupChannels.includes(`group:${channelIdLower}`) ||
|
||||
groupChannels.includes(`mpim:${channelIdLower}`)
|
||||
) {
|
||||
SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "group");
|
||||
return "group";
|
||||
}
|
||||
|
||||
const channelKeys = Object.keys(account.channels ?? {});
|
||||
if (
|
||||
channelKeys.some((key) => {
|
||||
const normalized = key.trim().toLowerCase();
|
||||
return (
|
||||
normalized === channelIdLower ||
|
||||
normalized === `channel:${channelIdLower}` ||
|
||||
normalized.replace(/^#/, "") === channelIdLower
|
||||
);
|
||||
})
|
||||
) {
|
||||
SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "channel");
|
||||
return "channel";
|
||||
}
|
||||
|
||||
const token =
|
||||
account.botToken?.trim() ||
|
||||
(typeof account.config.userToken === "string" ? account.config.userToken.trim() : "");
|
||||
if (!token) {
|
||||
SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown");
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createSlackWebClient(token);
|
||||
const info = await client.conversations.info({ channel: channelId });
|
||||
const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined;
|
||||
const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel";
|
||||
SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, type);
|
||||
return type;
|
||||
} catch {
|
||||
SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown");
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSlackSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): Promise<OutboundSessionRoute | null> {
|
||||
const parsed = parseSlackTarget(params.target, { defaultKind: "channel" });
|
||||
if (!parsed) return null;
|
||||
const isDm = parsed.kind === "user";
|
||||
let peerKind: RoutePeerKind = isDm ? "dm" : "channel";
|
||||
if (!isDm && /^G/i.test(parsed.id)) {
|
||||
// Slack mpim/group DMs share the G-prefix; detect to align session keys with inbound.
|
||||
const channelType = await resolveSlackChannelType({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
channelId: parsed.id,
|
||||
});
|
||||
if (channelType === "group") peerKind = "group";
|
||||
if (channelType === "dm") peerKind = "dm";
|
||||
}
|
||||
const peer: RoutePeer = {
|
||||
kind: peerKind,
|
||||
id: parsed.id,
|
||||
};
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "slack",
|
||||
peer,
|
||||
});
|
||||
const threadId = normalizeThreadId(params.threadId ?? params.replyToId);
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId,
|
||||
});
|
||||
return {
|
||||
sessionKey: threadKeys.sessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: peerKind === "dm" ? "direct" : "channel",
|
||||
from:
|
||||
peerKind === "dm"
|
||||
? `slack:${parsed.id}`
|
||||
: peerKind === "group"
|
||||
? `slack:group:${parsed.id}`
|
||||
: `slack:channel:${parsed.id}`,
|
||||
to: peerKind === "dm" ? `user:${parsed.id}` : `channel:${parsed.id}`,
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDiscordSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const parsed = parseDiscordTarget(params.target, { defaultKind: "channel" });
|
||||
if (!parsed) return null;
|
||||
const isDm = parsed.kind === "user";
|
||||
const peer: RoutePeer = {
|
||||
kind: isDm ? "dm" : "channel",
|
||||
id: parsed.id,
|
||||
};
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "discord",
|
||||
peer,
|
||||
});
|
||||
const explicitThreadId = normalizeThreadId(params.threadId);
|
||||
const threadCandidate = explicitThreadId ?? normalizeThreadId(params.replyToId);
|
||||
// Discord threads use their own channel id; avoid adding a :thread suffix.
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId: threadCandidate,
|
||||
useSuffix: false,
|
||||
});
|
||||
return {
|
||||
sessionKey: threadKeys.sessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isDm ? "direct" : "channel",
|
||||
from: isDm ? `discord:${parsed.id}` : `discord:channel:${parsed.id}`,
|
||||
to: isDm ? `user:${parsed.id}` : `channel:${parsed.id}`,
|
||||
threadId: explicitThreadId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTelegramSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const parsed = parseTelegramTarget(params.target);
|
||||
const chatId = parsed.chatId.trim();
|
||||
if (!chatId) return null;
|
||||
const parsedThreadId = parsed.messageThreadId;
|
||||
const fallbackThreadId = normalizeThreadId(params.threadId);
|
||||
const resolvedThreadId =
|
||||
parsedThreadId ?? (fallbackThreadId ? Number.parseInt(fallbackThreadId, 10) : undefined);
|
||||
// Telegram topics are encoded in the peer id (chatId:topic:<id>).
|
||||
const chatType = resolveTelegramTargetChatType(params.target);
|
||||
// If the target is a username and we lack a resolvedTarget, default to DM to avoid group keys.
|
||||
const isGroup =
|
||||
chatType === "group" ||
|
||||
(chatType === "unknown" &&
|
||||
params.resolvedTarget?.kind &&
|
||||
params.resolvedTarget.kind !== "user");
|
||||
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : chatId;
|
||||
const peer: RoutePeer = {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: peerId,
|
||||
};
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "telegram",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `telegram:group:${peerId}` : `telegram:${chatId}`,
|
||||
to: `telegram:${chatId}`,
|
||||
threadId: resolvedThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWhatsAppSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const normalized = normalizeWhatsAppTarget(params.target);
|
||||
if (!normalized) return null;
|
||||
const isGroup = isWhatsAppGroupJid(normalized);
|
||||
const peer: RoutePeer = {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: normalized,
|
||||
};
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "whatsapp",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: normalized,
|
||||
to: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSignalSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const stripped = stripProviderPrefix(params.target, "signal");
|
||||
const lowered = stripped.toLowerCase();
|
||||
if (lowered.startsWith("group:")) {
|
||||
const groupId = stripped.slice("group:".length).trim();
|
||||
if (!groupId) return null;
|
||||
const peer: RoutePeer = { kind: "group", id: groupId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "signal",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: "group",
|
||||
from: `group:${groupId}`,
|
||||
to: `group:${groupId}`,
|
||||
};
|
||||
}
|
||||
|
||||
let recipient = stripped.trim();
|
||||
if (lowered.startsWith("username:")) {
|
||||
recipient = stripped.slice("username:".length).trim();
|
||||
} else if (lowered.startsWith("u:")) {
|
||||
recipient = stripped.slice("u:".length).trim();
|
||||
}
|
||||
if (!recipient) return null;
|
||||
|
||||
const uuidCandidate = recipient.toLowerCase().startsWith("uuid:")
|
||||
? recipient.slice("uuid:".length)
|
||||
: recipient;
|
||||
const sender = resolveSignalSender({
|
||||
sourceUuid: looksLikeUuid(uuidCandidate) ? uuidCandidate : null,
|
||||
sourceNumber: looksLikeUuid(uuidCandidate) ? null : recipient,
|
||||
});
|
||||
const peerId = sender ? resolveSignalPeerId(sender) : recipient;
|
||||
const displayRecipient = sender ? resolveSignalRecipient(sender) : recipient;
|
||||
const peer: RoutePeer = { kind: "dm", id: peerId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "signal",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: "direct",
|
||||
from: `signal:${displayRecipient}`,
|
||||
to: `signal:${displayRecipient}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveIMessageSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const parsed = parseIMessageTarget(params.target);
|
||||
if (parsed.kind === "handle") {
|
||||
const handle = normalizeIMessageHandle(parsed.to);
|
||||
if (!handle) return null;
|
||||
const peer: RoutePeer = { kind: "dm", id: handle };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "imessage",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: "direct",
|
||||
from: `imessage:${handle}`,
|
||||
to: `imessage:${handle}`,
|
||||
};
|
||||
}
|
||||
|
||||
const peerId =
|
||||
parsed.kind === "chat_id"
|
||||
? String(parsed.chatId)
|
||||
: parsed.kind === "chat_guid"
|
||||
? parsed.chatGuid
|
||||
: parsed.chatIdentifier;
|
||||
if (!peerId) return null;
|
||||
const peer: RoutePeer = { kind: "group", id: peerId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "imessage",
|
||||
peer,
|
||||
});
|
||||
const toPrefix =
|
||||
parsed.kind === "chat_id"
|
||||
? "chat_id"
|
||||
: parsed.kind === "chat_guid"
|
||||
? "chat_guid"
|
||||
: "chat_identifier";
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: "group",
|
||||
from: `imessage:group:${peerId}`,
|
||||
to: `${toPrefix}:${peerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatrixSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const stripped = stripProviderPrefix(params.target, "matrix");
|
||||
const isUser =
|
||||
params.resolvedTarget?.kind === "user" || stripped.startsWith("@") || /^user:/i.test(stripped);
|
||||
const rawId = stripKindPrefix(stripped);
|
||||
if (!rawId) return null;
|
||||
const peer: RoutePeer = { kind: isUser ? "dm" : "channel", id: rawId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "matrix",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isUser ? "direct" : "channel",
|
||||
from: isUser ? `matrix:${rawId}` : `matrix:channel:${rawId}`,
|
||||
to: `room:${rawId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMSTeamsSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
let trimmed = params.target.trim();
|
||||
if (!trimmed) return null;
|
||||
trimmed = trimmed.replace(/^(msteams|teams):/i, "").trim();
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
const isUser = lower.startsWith("user:");
|
||||
const rawId = stripKindPrefix(trimmed);
|
||||
if (!rawId) return null;
|
||||
const conversationId = rawId.split(";")[0] ?? rawId;
|
||||
const isChannel = !isUser && /@thread\.tacv2/i.test(conversationId);
|
||||
const peer: RoutePeer = {
|
||||
kind: isUser ? "dm" : isChannel ? "channel" : "group",
|
||||
id: conversationId,
|
||||
};
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "msteams",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isUser ? "direct" : isChannel ? "channel" : "group",
|
||||
from: isUser
|
||||
? `msteams:${conversationId}`
|
||||
: isChannel
|
||||
? `msteams:channel:${conversationId}`
|
||||
: `msteams:group:${conversationId}`,
|
||||
to: isUser ? `user:${conversationId}` : `conversation:${conversationId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMattermostSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
let trimmed = params.target.trim();
|
||||
if (!trimmed) return null;
|
||||
trimmed = trimmed.replace(/^mattermost:/i, "").trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
const isUser = lower.startsWith("user:") || trimmed.startsWith("@");
|
||||
if (trimmed.startsWith("@")) {
|
||||
trimmed = trimmed.slice(1).trim();
|
||||
}
|
||||
const rawId = stripKindPrefix(trimmed);
|
||||
if (!rawId) return null;
|
||||
const peer: RoutePeer = { kind: isUser ? "dm" : "channel", id: rawId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "mattermost",
|
||||
peer,
|
||||
});
|
||||
const threadId = normalizeThreadId(params.replyToId ?? params.threadId);
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId,
|
||||
});
|
||||
return {
|
||||
sessionKey: threadKeys.sessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isUser ? "direct" : "channel",
|
||||
from: isUser ? `mattermost:${rawId}` : `mattermost:channel:${rawId}`,
|
||||
to: isUser ? `user:${rawId}` : `channel:${rawId}`,
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBlueBubblesSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const stripped = stripProviderPrefix(params.target, "bluebubbles");
|
||||
const lower = stripped.toLowerCase();
|
||||
const isGroup =
|
||||
lower.startsWith("chat_id:") ||
|
||||
lower.startsWith("chat_guid:") ||
|
||||
lower.startsWith("chat_identifier:") ||
|
||||
lower.startsWith("group:");
|
||||
const peerId = isGroup
|
||||
? stripKindPrefix(stripped)
|
||||
: stripped.replace(/^(imessage|sms|auto):/i, "");
|
||||
if (!peerId) return null;
|
||||
const peer: RoutePeer = {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: peerId,
|
||||
};
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "bluebubbles",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `group:${peerId}` : `bluebubbles:${peerId}`,
|
||||
to: `bluebubbles:${stripped}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNextcloudTalkSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
let trimmed = params.target.trim();
|
||||
if (!trimmed) return null;
|
||||
trimmed = trimmed.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").trim();
|
||||
trimmed = trimmed.replace(/^room:/i, "").trim();
|
||||
if (!trimmed) return null;
|
||||
const peer: RoutePeer = { kind: "group", id: trimmed };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "nextcloud-talk",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: "group",
|
||||
from: `nextcloud-talk:room:${trimmed}`,
|
||||
to: `nextcloud-talk:${trimmed}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveZaloSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const trimmed = stripProviderPrefix(params.target, "zalo")
|
||||
.replace(/^(zl):/i, "")
|
||||
.trim();
|
||||
if (!trimmed) return null;
|
||||
const isGroup = trimmed.toLowerCase().startsWith("group:");
|
||||
const peerId = stripKindPrefix(trimmed);
|
||||
const peer: RoutePeer = { kind: isGroup ? "group" : "dm", id: peerId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "zalo",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `zalo:group:${peerId}` : `zalo:${peerId}`,
|
||||
to: `zalo:${peerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveZalouserSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const trimmed = stripProviderPrefix(params.target, "zalouser")
|
||||
.replace(/^(zlu):/i, "")
|
||||
.trim();
|
||||
if (!trimmed) return null;
|
||||
const isGroup = trimmed.toLowerCase().startsWith("group:");
|
||||
const peerId = stripKindPrefix(trimmed);
|
||||
// Keep DM vs group aligned with inbound sessions for Zalo Personal.
|
||||
const peer: RoutePeer = { kind: isGroup ? "group" : "dm", id: peerId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "zalouser",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `zalouser:group:${peerId}` : `zalouser:${peerId}`,
|
||||
to: `zalouser:${peerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNostrSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const trimmed = stripProviderPrefix(params.target, "nostr").trim();
|
||||
if (!trimmed) return null;
|
||||
const peer: RoutePeer = { kind: "dm", id: trimmed };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "nostr",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: "direct",
|
||||
from: `nostr:${trimmed}`,
|
||||
to: `nostr:${trimmed}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTlonShip(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
return trimmed.startsWith("~") ? trimmed : `~${trimmed}`;
|
||||
}
|
||||
|
||||
function resolveTlonSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
let trimmed = stripProviderPrefix(params.target, "tlon");
|
||||
trimmed = trimmed.trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
let isGroup =
|
||||
lower.startsWith("group:") || lower.startsWith("room:") || lower.startsWith("chat/");
|
||||
let peerId = trimmed;
|
||||
if (lower.startsWith("group:") || lower.startsWith("room:")) {
|
||||
peerId = trimmed.replace(/^(group|room):/i, "").trim();
|
||||
if (!peerId.startsWith("chat/")) {
|
||||
const parts = peerId.split("/").filter(Boolean);
|
||||
if (parts.length === 2) {
|
||||
peerId = `chat/${normalizeTlonShip(parts[0])}/${parts[1]}`;
|
||||
}
|
||||
}
|
||||
isGroup = true;
|
||||
} else if (lower.startsWith("dm:")) {
|
||||
peerId = normalizeTlonShip(trimmed.slice("dm:".length));
|
||||
isGroup = false;
|
||||
} else if (lower.startsWith("chat/")) {
|
||||
peerId = trimmed;
|
||||
isGroup = true;
|
||||
} else if (trimmed.includes("/")) {
|
||||
const parts = trimmed.split("/").filter(Boolean);
|
||||
if (parts.length === 2) {
|
||||
peerId = `chat/${normalizeTlonShip(parts[0])}/${parts[1]}`;
|
||||
isGroup = true;
|
||||
}
|
||||
} else {
|
||||
peerId = normalizeTlonShip(trimmed);
|
||||
}
|
||||
|
||||
const peer: RoutePeer = { kind: isGroup ? "group" : "dm", id: peerId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "tlon",
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `tlon:group:${peerId}` : `tlon:${peerId}`,
|
||||
to: `tlon:${peerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFallbackSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const trimmed = stripProviderPrefix(params.target, params.channel).trim();
|
||||
if (!trimmed) return null;
|
||||
const peerKind = inferPeerKind({
|
||||
channel: params.channel,
|
||||
resolvedTarget: params.resolvedTarget,
|
||||
});
|
||||
const peerId = stripKindPrefix(trimmed);
|
||||
if (!peerId) return null;
|
||||
const peer: RoutePeer = { kind: peerKind, id: peerId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: params.channel,
|
||||
peer,
|
||||
});
|
||||
const chatType = peerKind === "dm" ? "direct" : peerKind === "channel" ? "channel" : "group";
|
||||
const from =
|
||||
peerKind === "dm" ? `${params.channel}:${peerId}` : `${params.channel}:${peerKind}:${peerId}`;
|
||||
const toPrefix = peerKind === "dm" ? "user" : "channel";
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType,
|
||||
from,
|
||||
to: `${toPrefix}:${peerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveOutboundSessionRoute(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): Promise<OutboundSessionRoute | null> {
|
||||
const target = params.target.trim();
|
||||
if (!target) return null;
|
||||
switch (params.channel) {
|
||||
case "slack":
|
||||
return await resolveSlackSession({ ...params, target });
|
||||
case "discord":
|
||||
return resolveDiscordSession({ ...params, target });
|
||||
case "telegram":
|
||||
return resolveTelegramSession({ ...params, target });
|
||||
case "whatsapp":
|
||||
return resolveWhatsAppSession({ ...params, target });
|
||||
case "signal":
|
||||
return resolveSignalSession({ ...params, target });
|
||||
case "imessage":
|
||||
return resolveIMessageSession({ ...params, target });
|
||||
case "matrix":
|
||||
return resolveMatrixSession({ ...params, target });
|
||||
case "msteams":
|
||||
return resolveMSTeamsSession({ ...params, target });
|
||||
case "mattermost":
|
||||
return resolveMattermostSession({ ...params, target });
|
||||
case "bluebubbles":
|
||||
return resolveBlueBubblesSession({ ...params, target });
|
||||
case "nextcloud-talk":
|
||||
return resolveNextcloudTalkSession({ ...params, target });
|
||||
case "zalo":
|
||||
return resolveZaloSession({ ...params, target });
|
||||
case "zalouser":
|
||||
return resolveZalouserSession({ ...params, target });
|
||||
case "nostr":
|
||||
return resolveNostrSession({ ...params, target });
|
||||
case "tlon":
|
||||
return resolveTlonSession({ ...params, target });
|
||||
default:
|
||||
return resolveFallbackSession({ ...params, target });
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureOutboundSessionEntry(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
channel: ChannelId;
|
||||
accountId?: string | null;
|
||||
route: OutboundSessionRoute;
|
||||
}): Promise<void> {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const ctx: MsgContext = {
|
||||
From: params.route.from,
|
||||
To: params.route.to,
|
||||
SessionKey: params.route.sessionKey,
|
||||
AccountId: params.accountId ?? undefined,
|
||||
ChatType: params.route.chatType,
|
||||
Provider: params.channel,
|
||||
Surface: params.channel,
|
||||
MessageThreadId: params.route.threadId,
|
||||
OriginatingChannel: params.channel,
|
||||
OriginatingTo: params.route.to,
|
||||
};
|
||||
try {
|
||||
await recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: params.route.sessionKey,
|
||||
ctx,
|
||||
});
|
||||
} catch {
|
||||
// Do not block outbound sends on session meta writes.
|
||||
}
|
||||
}
|
||||
@@ -85,40 +85,40 @@ function canonicalizeSessionKeyForAgent(params: {
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
const raw = params.key.trim();
|
||||
if (!raw) return raw;
|
||||
if (raw === "global" || raw === "unknown") return raw;
|
||||
if (raw.toLowerCase() === "global" || raw.toLowerCase() === "unknown") return raw.toLowerCase();
|
||||
|
||||
const canonicalMain = canonicalizeMainSessionAlias({
|
||||
cfg: { session: { scope: params.scope, mainKey: params.mainKey } },
|
||||
agentId,
|
||||
sessionKey: raw,
|
||||
});
|
||||
if (canonicalMain !== raw) return canonicalMain;
|
||||
if (canonicalMain !== raw) return canonicalMain.toLowerCase();
|
||||
|
||||
if (raw.startsWith("agent:")) return raw;
|
||||
if (raw.toLowerCase().startsWith("agent:")) return raw.toLowerCase();
|
||||
if (raw.toLowerCase().startsWith("subagent:")) {
|
||||
const rest = raw.slice("subagent:".length);
|
||||
return `agent:${agentId}:subagent:${rest}`;
|
||||
return `agent:${agentId}:subagent:${rest}`.toLowerCase();
|
||||
}
|
||||
if (raw.startsWith("group:")) {
|
||||
const id = raw.slice("group:".length).trim();
|
||||
if (!id) return raw;
|
||||
const channel = id.toLowerCase().includes("@g.us") ? "whatsapp" : "unknown";
|
||||
return `agent:${agentId}:${channel}:group:${id}`;
|
||||
return `agent:${agentId}:${channel}:group:${id}`.toLowerCase();
|
||||
}
|
||||
if (!raw.includes(":") && raw.toLowerCase().includes("@g.us")) {
|
||||
return `agent:${agentId}:whatsapp:group:${raw}`;
|
||||
return `agent:${agentId}:whatsapp:group:${raw}`.toLowerCase();
|
||||
}
|
||||
if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) {
|
||||
const remainder = raw.slice("whatsapp:".length).trim();
|
||||
const cleaned = remainder.replace(/^group:/i, "").trim();
|
||||
if (cleaned && !isSurfaceGroupKey(raw)) {
|
||||
return `agent:${agentId}:whatsapp:group:${cleaned}`;
|
||||
return `agent:${agentId}:whatsapp:group:${cleaned}`.toLowerCase();
|
||||
}
|
||||
}
|
||||
if (isSurfaceGroupKey(raw)) {
|
||||
return `agent:${agentId}:${raw}`;
|
||||
return `agent:${agentId}:${raw}`.toLowerCase();
|
||||
}
|
||||
return `agent:${agentId}:${raw}`;
|
||||
return `agent:${agentId}:${raw}`.toLowerCase();
|
||||
}
|
||||
|
||||
function pickLatestLegacyDirectEntry(
|
||||
|
||||
Reference in New Issue
Block a user