fix: normalize session keys and outbound mirroring

This commit is contained in:
Peter Steinberger
2026-01-24 11:57:04 +00:00
parent eaeb52f70a
commit 4b6cdd1d3c
33 changed files with 1357 additions and 145 deletions

View File

@@ -28,6 +28,8 @@ Docs: https://docs.clawd.bot
### Fixes
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
- Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)
- Sessions: normalize session key casing to lowercase for consistent routing.
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).

View File

@@ -0,0 +1,72 @@
---
title: Outbound Session Mirroring Refactor (Issue #1520)
description: Track outbound session mirroring refactor notes, decisions, tests, and open items.
---
# Outbound Session Mirroring Refactor (Issue #1520)
## Status
- In progress.
- Core + plugin channel routing updated for outbound mirroring.
- Gateway send now derives target session when sessionKey is omitted.
## Context
Outbound sends were mirrored into the *current* agent session (tool session key) rather than the target channel session. Inbound routing uses channel/peer session keys, so outbound responses landed in the wrong session and first-contact targets often lacked session entries.
## Goals
- Mirror outbound messages into the target channel session key.
- Create session entries on outbound when missing.
- Keep thread/topic scoping aligned with inbound session keys.
- Cover core channels plus bundled extensions.
## Implementation Summary
- New outbound session routing helper:
- `src/infra/outbound/outbound-session.ts`
- `resolveOutboundSessionRoute` builds target sessionKey using `buildAgentSessionKey` (dmScope + identityLinks).
- `ensureOutboundSessionEntry` writes minimal `MsgContext` via `recordSessionMetaFromInbound`.
- `runMessageAction` (send) derives target sessionKey and passes it to `executeSendAction` for mirroring.
- `message-tool` no longer mirrors directly; it only resolves agentId from the current session key.
- Plugin send path mirrors via `appendAssistantMessageToSessionTranscript` using the derived sessionKey.
- Gateway send derives a target session key when none is provided (default agent), and ensures a session entry.
## Thread/Topic Handling
- Slack: replyTo/threadId -> `resolveThreadSessionKeys` (suffix).
- Discord: threadId/replyTo -> `resolveThreadSessionKeys` with `useSuffix=false` to match inbound (thread channel id already scopes session).
- Telegram: topic IDs map to `chatId:topic:<id>` via `buildTelegramGroupPeerId`.
## Extensions Covered
- Matrix, MS Teams, Mattermost, BlueBubbles, Nextcloud Talk, Zalo, Zalo Personal, Nostr, Tlon.
- Notes:
- Mattermost targets now strip `@` for DM session key routing.
- Zalo Personal uses DM peer kind for 1:1 targets (group only when `group:` is present).
## Decisions
- **Gateway send session derivation**: if `sessionKey` is provided, use it. If omitted, derive a sessionKey from target + default agent and mirror there.
- **Session entry creation**: always use `recordSessionMetaFromInbound` with `Provider/From/To/ChatType/AccountId/Originating*` aligned to inbound formats.
- **Target normalization**: outbound routing uses resolved targets (post `resolveChannelTarget`) when available.
- **Session key casing**: canonicalize session keys to lowercase on write and during migrations.
## Tests Added/Updated
- `src/infra/outbound/outbound-session.test.ts`
- Slack thread session key.
- Telegram topic session key.
- dmScope identityLinks with Discord.
- `src/agents/tools/message-tool.test.ts`
- Derives agentId from session key (no sessionKey passed through).
- `src/gateway/server-methods/send.test.ts`
- Derives session key when omitted and creates session entry.
## Open Items / Follow-ups
- Voice-call plugin uses custom `voice:<phone>` session keys. Outbound mapping is not standardized here; if message-tool should support voice-call sends, add explicit mapping.
- Confirm if any external plugin uses non-standard `From/To` formats beyond the bundled set.
## Files Touched
- `src/infra/outbound/outbound-session.ts`
- `src/infra/outbound/outbound-send-service.ts`
- `src/infra/outbound/message-action-runner.ts`
- `src/agents/tools/message-tool.ts`
- `src/gateway/server-methods/send.ts`
- Tests in:
- `src/infra/outbound/outbound-session.test.ts`
- `src/agents/tools/message-tool.test.ts`
- `src/gateway/server-methods/send.test.ts`

View File

@@ -70,7 +70,8 @@ export function resolveSessionAgentIds(params: { sessionKey?: string; config?: C
} {
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
const sessionKey = params.sessionKey?.trim();
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
const normalizedSessionKey = sessionKey ? sessionKey.toLowerCase() : undefined;
const parsed = normalizedSessionKey ? parseAgentSessionKey(normalizedSessionKey) : null;
const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
return { defaultAgentId, sessionAgentId };
}

View File

@@ -128,7 +128,7 @@ describe("getDmHistoryLimitFromSessionKey", () => {
slack: { dmHistoryLimit: 10 },
},
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:C1", config)).toBeUndefined();
expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:c1", config)).toBeUndefined();
expect(getDmHistoryLimitFromSessionKey("telegram:slash:123", config)).toBeUndefined();
});
it("returns undefined for unknown provider", () => {

View File

@@ -126,7 +126,7 @@ describe("resolveSessionAgentIds", () => {
});
it("keeps the agent id for provider-qualified agent sessions", () => {
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: "agent:beta:slack:channel:C1",
sessionKey: "agent:beta:slack:channel:c1",
config: cfg,
});
expect(sessionAgentId).toBe("beta");

View File

@@ -106,7 +106,7 @@ describe("sandbox explain helpers", () => {
const msg = formatSandboxToolPolicyBlockedMessage({
cfg,
sessionKey: "agent:main:whatsapp:group:G1",
sessionKey: "agent:main:whatsapp:group:g1",
toolName: "browser",
});
expect(msg).toBeTruthy();

View File

@@ -8,7 +8,6 @@ import { createMessageTool } from "./message-tool.js";
const mocks = vi.hoisted(() => ({
runMessageAction: vi.fn(),
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
}));
vi.mock("../../infra/outbound/message-action-runner.js", async () => {
@@ -21,47 +20,9 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => {
};
});
vi.mock("../../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
"../../config/sessions.js",
);
return {
...actual,
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
};
});
describe("message tool mirroring", () => {
it("mirrors media filename for plugin-handled sends", async () => {
mocks.appendAssistantMessageToSessionTranscript.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
handledBy: "plugin",
payload: {},
dryRun: false,
} satisfies MessageActionRunResult);
const tool = createMessageTool({
agentSessionKey: "agent:main:main",
config: {} as never,
});
await tool.execute("1", {
action: "send",
target: "telegram:123",
message: "",
media: "https://example.com/files/report.pdf?sig=1",
});
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith(
expect.objectContaining({ text: "report.pdf" }),
);
});
it("does not mirror on dry-run", async () => {
mocks.appendAssistantMessageToSessionTranscript.mockClear();
describe("message tool agent routing", () => {
it("derives agentId from the session key", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
@@ -72,7 +33,7 @@ describe("message tool mirroring", () => {
} satisfies MessageActionRunResult);
const tool = createMessageTool({
agentSessionKey: "agent:main:main",
agentSessionKey: "agent:alpha:main",
config: {} as never,
});
@@ -82,7 +43,9 @@ describe("message tool mirroring", () => {
message: "hi",
});
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.agentId).toBe("alpha");
expect(call?.sessionKey).toBeUndefined();
});
});

View File

@@ -11,10 +11,6 @@ import {
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
appendAssistantMessageToSessionTranscript,
resolveMirroredTranscriptText,
} from "../../config/sessions.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
@@ -377,36 +373,11 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
defaultAccountId: accountId ?? undefined,
gateway,
toolContext,
sessionKey: options?.agentSessionKey,
agentId: options?.agentSessionKey
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
: undefined,
});
if (
action === "send" &&
options?.agentSessionKey &&
!result.dryRun &&
result.handledBy === "plugin"
) {
const mediaUrl = typeof params.media === "string" ? params.media : undefined;
const mirrorText = resolveMirroredTranscriptText({
text: typeof params.message === "string" ? params.message : undefined,
mediaUrls: mediaUrl ? [mediaUrl] : undefined,
});
if (mirrorText) {
const agentId = resolveSessionAgentId({
sessionKey: options.agentSessionKey,
config: cfg,
});
await appendAssistantMessageToSessionTranscript({
agentId,
sessionKey: options.agentSessionKey,
text: mirrorText,
});
}
}
const toolResult = getToolResult(result);
if (toolResult) return toolResult;
return jsonResult(result.payload);

View File

@@ -159,7 +159,7 @@ describe("RawBody directive parsing", () => {
ChatType: "group",
From: "+1222",
To: "+1222",
SessionKey: "agent:main:whatsapp:group:G1",
SessionKey: "agent:main:whatsapp:group:g1",
Provider: "whatsapp",
Surface: "whatsapp",
SenderE164: "+1222",
@@ -182,7 +182,7 @@ describe("RawBody directive parsing", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Session: agent:main:whatsapp:group:G1");
expect(text).toContain("Session: agent:main:whatsapp:group:g1");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});

View File

@@ -37,7 +37,7 @@ describe("abort detection", () => {
Body: `[Context]\nJake: /stop\n[from: Jake]`,
RawBody: "/stop",
ChatType: "group",
SessionKey: "agent:main:whatsapp:group:G1",
SessionKey: "agent:main:whatsapp:group:g1",
};
const result = await initSessionState({

View File

@@ -235,8 +235,8 @@ describe("handleCommands subagents", () => {
addSubagentRunForTests({
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:slack:slash:U1",
requesterDisplayKey: "agent:main:slack:slash:U1",
requesterSessionKey: "agent:main:slack:slash:u1",
requesterDisplayKey: "agent:main:slack:slash:u1",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
@@ -250,7 +250,7 @@ describe("handleCommands subagents", () => {
CommandSource: "native",
CommandTargetSessionKey: "agent:main:main",
});
params.sessionKey = "agent:main:slack:slash:U1";
params.sessionKey = "agent:main:slack:slash:u1";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Subagents (current session)");

View File

@@ -45,8 +45,8 @@ async function resolveState(params: {
describe("createModelSelectionState parent inheritance", () => {
it("inherits parent override from explicit parentSessionKey", async () => {
const cfg = {} as ClawdbotConfig;
const parentKey = "agent:main:discord:channel:C1";
const sessionKey = "agent:main:discord:channel:C1:thread:123";
const parentKey = "agent:main:discord:channel:c1";
const sessionKey = "agent:main:discord:channel:c1:thread:123";
const parentEntry = makeEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
@@ -132,8 +132,8 @@ describe("createModelSelectionState parent inheritance", () => {
},
},
} as ClawdbotConfig;
const parentKey = "agent:main:slack:channel:C1";
const sessionKey = "agent:main:slack:channel:C1:thread:123";
const parentKey = "agent:main:slack:channel:c1";
const sessionKey = "agent:main:slack:channel:c1:thread:123";
const parentEntry = makeEntry({
providerOverride: "anthropic",
modelOverride: "claude-opus-4-5",

View File

@@ -136,7 +136,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
it("Reset trigger works when RawBody is clean but Body has wrapped context", async () => {
const storePath = await createStorePath("clawdbot-group-rawbody-");
const sessionKey = "agent:main:whatsapp:group:G1";
const sessionKey = "agent:main:whatsapp:group:g1";
const existingSessionId = "existing-session-123";
await seedSessionStore({
storePath,

View File

@@ -37,7 +37,7 @@ describe("initSessionState thread forking", () => {
);
const storePath = path.join(root, "sessions.json");
const parentSessionKey = "agent:main:slack:channel:C1";
const parentSessionKey = "agent:main:slack:channel:c1";
await saveSessionStore(storePath, {
[parentSessionKey]: {
sessionId: parentSessionId,
@@ -50,7 +50,7 @@ describe("initSessionState thread forking", () => {
session: { store: storePath },
} as ClawdbotConfig;
const threadSessionKey = "agent:main:slack:channel:C1:thread:123";
const threadSessionKey = "agent:main:slack:channel:c1:thread:123";
const threadLabel = "Slack thread #general: starter";
const result = await initSessionState({
ctx: {
@@ -117,7 +117,7 @@ describe("initSessionState RawBody", () => {
Body: `[Chat messages since your last reply - for context]\n[WhatsApp ...] Someone: hello\n\n[Current message - respond to this]\n[WhatsApp ...] Jake: /status\n[from: Jake McInteer (+6421807830)]`,
RawBody: "/status",
ChatType: "group",
SessionKey: "agent:main:whatsapp:group:G1",
SessionKey: "agent:main:whatsapp:group:g1",
};
const result = await initSessionState({
@@ -138,7 +138,7 @@ describe("initSessionState RawBody", () => {
Body: `[Context]\nJake: /new\n[from: Jake]`,
RawBody: "/new",
ChatType: "group",
SessionKey: "agent:main:whatsapp:group:G1",
SessionKey: "agent:main:whatsapp:group:g1",
};
const result = await initSessionState({
@@ -165,7 +165,7 @@ describe("initSessionState RawBody", () => {
const ctx = {
RawBody: "/NEW KeepThisCase",
ChatType: "direct",
SessionKey: "agent:main:whatsapp:dm:S1",
SessionKey: "agent:main:whatsapp:dm:s1",
};
const result = await initSessionState({
@@ -186,7 +186,7 @@ describe("initSessionState RawBody", () => {
const ctx = {
Body: "/status",
SessionKey: "agent:main:whatsapp:dm:S1",
SessionKey: "agent:main:whatsapp:dm:s1",
};
const result = await initSessionState({
@@ -206,7 +206,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S1";
const sessionKey = "agent:main:whatsapp:dm:s1";
const existingSessionId = "daily-session-id";
await saveSessionStore(storePath, {
@@ -236,7 +236,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-edge-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S-edge";
const sessionKey = "agent:main:whatsapp:dm:s-edge";
const existingSessionId = "daily-edge-session";
await saveSessionStore(storePath, {
@@ -266,7 +266,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-idle-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S2";
const sessionKey = "agent:main:whatsapp:dm:s2";
const existingSessionId = "idle-session-id";
await saveSessionStore(storePath, {
@@ -301,7 +301,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:slack:channel:C1:thread:123";
const sessionKey = "agent:main:slack:channel:c1:thread:123";
const existingSessionId = "thread-session-id";
await saveSessionStore(storePath, {
@@ -337,7 +337,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-nosuffix-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:discord:channel:C1";
const sessionKey = "agent:main:discord:channel:c1";
const existingSessionId = "thread-nosuffix";
await saveSessionStore(storePath, {
@@ -372,7 +372,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-type-default-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S4";
const sessionKey = "agent:main:whatsapp:dm:s4";
const existingSessionId = "type-default-session";
await saveSessionStore(storePath, {
@@ -407,7 +407,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-legacy-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S3";
const sessionKey = "agent:main:whatsapp:dm:s3";
const existingSessionId = "legacy-session-id";
await saveSessionStore(storePath, {

View File

@@ -72,7 +72,7 @@ describe("doctor legacy state migrations", () => {
expect(store["agent:main:+1666"]?.sessionId).toBe("b");
expect(store["+1555"]).toBeUndefined();
expect(store["+1666"]).toBeUndefined();
expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c");
expect(store["agent:main:slack:channel:c123"]?.sessionId).toBe("c");
expect(store["agent:main:unknown:group:abc"]?.sessionId).toBe("d");
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
});
@@ -278,6 +278,27 @@ describe("doctor legacy state migrations", () => {
expect(store["agent:main:main"]).toBeUndefined();
});
it("lowercases agent session keys during canonicalization", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};
const targetDir = path.join(root, "agents", "main", "sessions");
writeJson5(path.join(targetDir, "sessions.json"), {
"agent:main:slack:channel:C123": { sessionId: "legacy", updatedAt: 10 },
});
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["agent:main:slack:channel:c123"]?.sessionId).toBe("legacy");
expect(store["agent:main:slack:channel:C123"]).toBeUndefined();
});
it("auto-migrates when only target sessions contain legacy keys", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};

View File

@@ -88,7 +88,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
? parts.slice(2).join(":")
: parts.slice(1).join(":")
: from;
const finalId = id.trim();
const finalId = id.trim().toLowerCase();
if (!finalId) return null;
return {

View File

@@ -23,7 +23,7 @@ export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
*/
export function resolveSessionKey(scope: SessionScope, ctx: MsgContext, mainKey?: string) {
const explicit = ctx.SessionKey?.trim();
if (explicit) return explicit;
if (explicit) return explicit.toLowerCase();
const raw = deriveSessionKey(scope, ctx);
if (scope === "global") return raw;
const canonicalMainKey = normalizeMainKey(mainKey);

View File

@@ -80,12 +80,12 @@ describe("discord processDiscordMessage inbound contract", () => {
guildInfo: null,
guildSlug: "",
channelConfig: null,
baseSessionKey: "agent:main:discord:dm:U1",
baseSessionKey: "agent:main:discord:dm:u1",
route: {
agentId: "main",
channel: "discord",
accountId: "default",
sessionKey: "agent:main:discord:dm:U1",
sessionKey: "agent:main:discord:dm:u1",
mainSessionKey: "agent:main:main",
} as any,
} as any);

View File

@@ -6,6 +6,7 @@ import { sendHandlers } from "./send.js";
const mocks = vi.hoisted(() => ({
deliverOutboundPayloads: vi.fn(),
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })),
}));
vi.mock("../../config/config.js", async () => {
@@ -37,6 +38,7 @@ vi.mock("../../config/sessions.js", async () => {
return {
...actual,
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
recordSessionMetaFromInbound: mocks.recordSessionMetaFromInbound,
};
});
@@ -134,4 +136,33 @@ describe("gateway send mirroring", () => {
}),
);
});
it("derives a target session key when none is provided", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m3", channel: "slack" }]);
const respond = vi.fn();
await sendHandlers.send({
params: {
to: "channel:C1",
message: "hello",
channel: "slack",
idempotencyKey: "idem-4",
},
respond,
context: makeContext(),
req: { type: "req", id: "1", method: "send" },
client: null,
isWebchatConnect: () => false,
});
expect(mocks.recordSessionMetaFromInbound).toHaveBeenCalled();
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
mirror: expect.objectContaining({
sessionKey: "agent:main:slack:channel:resolved",
agentId: "main",
}),
}),
);
});
});

View File

@@ -5,6 +5,10 @@ import { loadConfig } from "../../config/config.js";
import { createOutboundSendDeps } from "../../cli/deps.js";
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads.js";
import {
ensureOutboundSessionEntry,
resolveOutboundSessionRoute,
} from "../../infra/outbound/outbound-session.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { OutboundChannel } from "../../infra/outbound/targets.js";
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
@@ -139,6 +143,30 @@ export const sendHandlers: GatewayRequestHandlers = {
const mirrorMediaUrls = mirrorPayloads.flatMap(
(payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
);
const providedSessionKey =
typeof request.sessionKey === "string" && request.sessionKey.trim()
? request.sessionKey.trim()
: undefined;
const derivedAgentId = resolveSessionAgentId({ config: cfg });
// If callers omit sessionKey, derive a target session key from the outbound route.
const derivedRoute = !providedSessionKey
? await resolveOutboundSessionRoute({
cfg,
channel,
agentId: derivedAgentId,
accountId,
target: resolved.to,
})
: null;
if (derivedRoute) {
await ensureOutboundSessionEntry({
cfg,
agentId: derivedAgentId,
channel,
accountId,
route: derivedRoute,
});
}
const results = await deliverOutboundPayloads({
cfg,
channel: outboundChannel,
@@ -147,14 +175,17 @@ export const sendHandlers: GatewayRequestHandlers = {
payloads: [{ text: message, mediaUrl: request.mediaUrl, mediaUrls }],
gifPlayback: request.gifPlayback,
deps: outboundDeps,
mirror:
typeof request.sessionKey === "string" && request.sessionKey.trim()
mirror: providedSessionKey
? {
sessionKey: providedSessionKey,
agentId: resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg }),
text: mirrorText || message,
mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined,
}
: derivedRoute
? {
sessionKey: request.sessionKey.trim(),
agentId: resolveSessionAgentId({
sessionKey: request.sessionKey.trim(),
config: cfg,
}),
sessionKey: derivedRoute.sessionKey,
agentId: derivedAgentId,
text: mirrorText || message,
mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined,
}

View 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");
});
});

View File

@@ -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,
});
}

View File

@@ -47,6 +47,8 @@ type MessageSendParams = {
mirror?: {
sessionKey: string;
agentId?: string;
text?: string;
mediaUrls?: string[];
};
};

View File

@@ -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),

View 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");
});
});

View 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.
}
}

View File

@@ -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(

View File

@@ -17,7 +17,7 @@ function normalizeToken(value: string | undefined | null): string {
export function normalizeMainKey(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
return trimmed ? trimmed : DEFAULT_MAIN_KEY;
return trimmed ? trimmed.toLowerCase() : DEFAULT_MAIN_KEY;
}
export function toAgentRequestSessionKey(storeKey: string | undefined | null): string | undefined {
@@ -35,8 +35,12 @@ export function toAgentStoreSessionKey(params: {
if (!raw || raw === DEFAULT_MAIN_KEY) {
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey: params.mainKey });
}
if (raw.startsWith("agent:")) return raw;
return `agent:${normalizeAgentId(params.agentId)}:${raw}`;
const lowered = raw.toLowerCase();
if (lowered.startsWith("agent:")) return lowered;
if (lowered.startsWith("subagent:")) {
return `agent:${normalizeAgentId(params.agentId)}:${lowered}`;
}
return `agent:${normalizeAgentId(params.agentId)}:${lowered}`;
}
export function resolveAgentIdFromSessionKey(sessionKey: string | undefined | null): string {
@@ -48,7 +52,7 @@ export function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_AGENT_ID;
// Keep it path-safe + shell-friendly.
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase();
// Best-effort fallback: collapse invalid characters to "-"
return (
trimmed
@@ -63,7 +67,7 @@ export function normalizeAgentId(value: string | undefined | null): string {
export function normalizeAccountId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_ACCOUNT_ID;
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase();
return (
trimmed
.toLowerCase()
@@ -106,6 +110,7 @@ export function buildAgentPeerSessionKey(params: {
peerId,
});
if (linkedPeerId) peerId = linkedPeerId;
peerId = peerId.toLowerCase();
if (dmScope === "per-channel-peer" && peerId) {
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`;
@@ -119,7 +124,7 @@ export function buildAgentPeerSessionKey(params: {
});
}
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
const peerId = (params.peerId ?? "").trim() || "unknown";
const peerId = ((params.peerId ?? "").trim() || "unknown").toLowerCase();
return `agent:${normalizeAgentId(params.agentId)}:${channel}:${peerKind}:${peerId}`;
}
@@ -163,7 +168,7 @@ export function buildGroupHistoryKey(params: {
}): string {
const channel = normalizeToken(params.channel) || "unknown";
const accountId = normalizeAccountId(params.accountId);
const peerId = params.peerId.trim() || "unknown";
const peerId = params.peerId.trim().toLowerCase() || "unknown";
return `${channel}:${accountId}:${params.peerKind}:${peerId}`;
}
@@ -177,9 +182,10 @@ export function resolveThreadSessionKeys(params: {
if (!threadId) {
return { sessionKey: params.baseSessionKey, parentSessionKey: undefined };
}
const normalizedThreadId = threadId.toLowerCase();
const useSuffix = params.useSuffix ?? true;
const sessionKey = useSuffix
? `${params.baseSessionKey}:thread:${threadId}`
? `${params.baseSessionKey}:thread:${normalizedThreadId}`
: params.baseSessionKey;
return { sessionKey, parentSessionKey: params.parentSessionKey };
}

View File

@@ -153,8 +153,8 @@ describe("monitorSlackProvider tool results", () => {
SessionKey?: string;
ParentSessionKey?: string;
};
expect(ctx.SessionKey).toBe("agent:main:slack:channel:C1:thread:111.222");
expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:C1");
expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222");
expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:c1");
});
it("injects starter context for thread replies", async () => {
@@ -216,7 +216,7 @@ describe("monitorSlackProvider tool results", () => {
ThreadStarterBody?: string;
ThreadLabel?: string;
};
expect(ctx.SessionKey).toBe("agent:main:slack:channel:C1:thread:111.222");
expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222");
expect(ctx.ParentSessionKey).toBeUndefined();
expect(ctx.ThreadStarterBody).toContain("starter message");
expect(ctx.ThreadLabel).toContain("Slack thread #general");
@@ -280,7 +280,7 @@ describe("monitorSlackProvider tool results", () => {
SessionKey?: string;
ParentSessionKey?: string;
};
expect(ctx.SessionKey).toBe("agent:support:slack:channel:C1:thread:111.222");
expect(ctx.SessionKey).toBe("agent:support:slack:channel:c1:thread:111.222");
expect(ctx.ParentSessionKey).toBeUndefined();
});

View File

@@ -56,7 +56,7 @@ describe("resolveSlackSystemEventSessionKey", () => {
it("defaults missing channel_type to channel sessions", () => {
const ctx = createSlackMonitorContext(baseParams());
expect(ctx.resolveSlackSystemEventSessionKey({ channelId: "C123" })).toBe(
"agent:main:slack:channel:C123",
"agent:main:slack:channel:c123",
);
});
});

View File

@@ -48,7 +48,7 @@ describe("prepareSlackMessage sender prefix", () => {
logger: { info: vi.fn() },
markMessageSeen: () => false,
shouldDropMismatchedSlackEvent: () => false,
resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:C1",
resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:c1",
isChannelAllowed: () => true,
resolveChannelName: async () => ({
name: "general",

View File

@@ -408,7 +408,8 @@ export function registerSlackMonitorSlashCommands(params: {
WasMentioned: true,
MessageSid: command.trigger_id,
Timestamp: Date.now(),
SessionKey: `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`,
SessionKey:
`agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`.toLowerCase(),
CommandTargetSessionKey: route.sessionKey,
AccountId: route.accountId,
CommandSource: "native" as const,

View File

@@ -14,7 +14,7 @@ describe("getSessionSnapshot", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-snapshot-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S1";
const sessionKey = "agent:main:whatsapp:dm:s1";
await saveSessionStore(storePath, {
[sessionKey]: {