Compare commits
2 Commits
patch-1
...
landpr/157
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
618efd8a71 | ||
|
|
e405ddcc20 |
@@ -14,7 +14,6 @@ Docs: https://docs.clawd.bot
|
||||
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
|
||||
- Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.
|
||||
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
|
||||
- Docs: add emoji reaction guidance to AGENTS.md template. (#1591) Thanks @EnzeD.
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
|
||||
@@ -28,13 +27,10 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
### Fixes
|
||||
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
|
||||
- Routing/Cron: normalize agentId casing for bindings and cron payloads. (#1591)
|
||||
- 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.
|
||||
- BlueBubbles: normalize group session keys for outbound mirroring. (#1520)
|
||||
- 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: treat request-aborted AbortError timeouts as fallback triggers. (#1576) Thanks @lc0rp.
|
||||
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).
|
||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
||||
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
||||
|
||||
@@ -964,7 +964,6 @@ public struct SessionsPreviewParams: Codable, Sendable {
|
||||
|
||||
public struct SessionsResolveParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let sessionid: String?
|
||||
public let label: String?
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
@@ -973,7 +972,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
sessionid: String?,
|
||||
label: String?,
|
||||
agentid: String?,
|
||||
spawnedby: String?,
|
||||
@@ -981,7 +979,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
includeunknown: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.label = label
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
@@ -990,7 +987,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case label
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
|
||||
@@ -964,7 +964,6 @@ public struct SessionsPreviewParams: Codable, Sendable {
|
||||
|
||||
public struct SessionsResolveParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let sessionid: String?
|
||||
public let label: String?
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
@@ -973,7 +972,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
sessionid: String?,
|
||||
label: String?,
|
||||
agentid: String?,
|
||||
spawnedby: String?,
|
||||
@@ -981,7 +979,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
includeunknown: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.label = label
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
@@ -990,7 +987,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case label
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
|
||||
@@ -59,8 +59,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [How do I use Brave for browser control?](#how-do-i-use-brave-for-browser-control)
|
||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
||||
- [How can my agent access my computer if the Gateway is hosted remotely?](#how-can-my-agent-access-my-computer-if-the-gateway-is-hosted-remotely)
|
||||
- [Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?](#is-there-a-benefit-to-using-a-node-on-my-personal-laptop-instead-of-ssh-from-a-vps)
|
||||
- [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service)
|
||||
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
||||
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
||||
@@ -753,23 +751,6 @@ pair devices you trust, and review [Security](/gateway/security).
|
||||
|
||||
Docs: [Nodes](/nodes), [Gateway protocol](/gateway/protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
|
||||
|
||||
### Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?
|
||||
|
||||
Yes — nodes are the first‑class way to reach your laptop from a remote Gateway, and they
|
||||
unlock more than shell access. The Gateway runs on macOS/Linux (Windows via WSL2), so a common
|
||||
setup is an always‑on host (VPS/home box/Pi) plus your laptop as a node.
|
||||
|
||||
- **No inbound SSH required.** Nodes connect out to the Gateway WebSocket and use device pairing.
|
||||
- **Safer execution controls.** `system.run` is gated by node allowlists/approvals on that laptop.
|
||||
- **More device tools.** Nodes expose `canvas`, `camera`, and `screen` in addition to `system.run`.
|
||||
- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally and relay control
|
||||
with the Chrome extension + `clawdbot browser serve`.
|
||||
|
||||
SSH is fine for ad‑hoc shell access, but nodes are simpler for ongoing agent workflows and
|
||||
device automation.
|
||||
|
||||
Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Chrome extension](/tools/chrome-extension).
|
||||
|
||||
### Do nodes run a gateway service?
|
||||
|
||||
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
|
||||
|
||||
@@ -32,7 +32,7 @@ cd clawdbot
|
||||
fly apps create my-clawdbot
|
||||
|
||||
# Create a persistent volume (1GB is usually enough)
|
||||
fly volumes create clawdbot_data --size 1 --region iad
|
||||
fly volumes create clawdbot_data --size 1 --region lhr
|
||||
```
|
||||
|
||||
**Tip:** Choose a region close to you. Common options: `lhr` (London), `iad` (Virginia), `sjc` (San Jose).
|
||||
@@ -43,7 +43,7 @@ Edit `fly.toml` to match your app name and requirements:
|
||||
|
||||
```toml
|
||||
app = "my-clawdbot" # Your app name
|
||||
primary_region = "iad"
|
||||
primary_region = "lhr"
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
@@ -134,14 +134,14 @@ fly ssh console
|
||||
|
||||
Create the config directory and file:
|
||||
```bash
|
||||
mkdir -p /data
|
||||
cat > /data/clawdbot.json << 'EOF'
|
||||
mkdir -p /data/.clawdbot
|
||||
cat > /data/.clawdbot/clawdbot.json << 'EOF'
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "anthropic/claude-opus-4-5",
|
||||
"fallbacks": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o"]
|
||||
"failover": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o"]
|
||||
},
|
||||
"maxConcurrent": 4
|
||||
},
|
||||
@@ -187,8 +187,6 @@ cat > /data/clawdbot.json << 'EOF'
|
||||
EOF
|
||||
```
|
||||
|
||||
**Note:** With `CLAWDBOT_STATE_DIR=/data`, the config path is `/data/clawdbot.json`.
|
||||
|
||||
**Note:** The Discord token can come from either:
|
||||
- Environment variable: `DISCORD_BOT_TOKEN` (recommended for secrets)
|
||||
- Config file: `channels.discord.token`
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
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).
|
||||
- BlueBubbles group targets strip `chat_*` prefixes to match inbound session keys.
|
||||
|
||||
## 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`
|
||||
@@ -92,21 +92,6 @@ In group chats where you receive every message, be **smart about when to contrib
|
||||
|
||||
Participate, don't dominate.
|
||||
|
||||
### 😊 React Like a Human!
|
||||
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
|
||||
|
||||
**React when:**
|
||||
- You appreciate something but don't need to reply (👍, ❤️, 🙌)
|
||||
- Something made you laugh (😂, 💀)
|
||||
- You find it interesting or thought-provoking (🤔, 💡)
|
||||
- You want to acknowledge without interrupting the flow
|
||||
- It's a simple yes/no or approval situation (✅, 👀)
|
||||
|
||||
**Why it matters:**
|
||||
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
|
||||
|
||||
**Don't overdo it:** One reaction per message max. Pick the one that fits best.
|
||||
|
||||
## Tools
|
||||
|
||||
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
|
||||
|
||||
2
fly.toml
2
fly.toml
@@ -15,7 +15,7 @@ primary_region = "iad" # change to your closest region
|
||||
NODE_OPTIONS = "--max-old-space-size=1536"
|
||||
|
||||
[processes]
|
||||
app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
|
||||
app = "node dist/index.js gateway --port 3000 --bind lan"
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
|
||||
@@ -29,23 +29,12 @@ const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
|
||||
|
||||
const WARNING_SUPPRESSION_FLAGS = [
|
||||
"--disable-warning=ExperimentalWarning",
|
||||
"--disable-warning=DEP0040",
|
||||
"--disable-warning=DEP0060",
|
||||
];
|
||||
|
||||
const run = (entry) =>
|
||||
new Promise((resolve) => {
|
||||
const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers)] : entry.args;
|
||||
const nodeOptions = process.env.NODE_OPTIONS ?? "";
|
||||
const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce(
|
||||
(acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()),
|
||||
nodeOptions,
|
||||
);
|
||||
const child = spawn(pnpm, args, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, VITEST_GROUP: entry.name, NODE_OPTIONS: nextNodeOptions },
|
||||
env: { ...process.env, VITEST_GROUP: entry.name },
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
children.add(child);
|
||||
|
||||
@@ -70,8 +70,7 @@ export function resolveSessionAgentIds(params: { sessionKey?: string; config?: C
|
||||
} {
|
||||
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
const normalizedSessionKey = sessionKey ? sessionKey.toLowerCase() : undefined;
|
||||
const parsed = normalizedSessionKey ? parseAgentSessionKey(normalizedSessionKey) : null;
|
||||
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
|
||||
const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
|
||||
return { defaultAgentId, sessionAgentId };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { classifyFailoverReason, type FailoverReason } from "./pi-embedded-helpers.js";
|
||||
|
||||
const TIMEOUT_HINT_RE = /timeout|timed out|deadline exceeded|context deadline exceeded/i;
|
||||
const ABORT_TIMEOUT_RE = /request was aborted|request aborted/i;
|
||||
const ABORT_TIMEOUT_RE = /request(?:\s+was)?[- ]aborted/i;
|
||||
|
||||
export class FailoverError extends Error {
|
||||
readonly reason: FailoverReason;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 () => {
|
||||
@@ -20,9 +21,47 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe("message tool agent routing", () => {
|
||||
it("derives agentId from the session key", async () => {
|
||||
mocks.runMessageAction.mockClear();
|
||||
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();
|
||||
mocks.runMessageAction.mockResolvedValue({
|
||||
kind: "send",
|
||||
action: "send",
|
||||
@@ -33,7 +72,7 @@ describe("message tool agent routing", () => {
|
||||
} satisfies MessageActionRunResult);
|
||||
|
||||
const tool = createMessageTool({
|
||||
agentSessionKey: "agent:alpha:main",
|
||||
agentSessionKey: "agent:main:main",
|
||||
config: {} as never,
|
||||
});
|
||||
|
||||
@@ -43,9 +82,7 @@ describe("message tool agent routing", () => {
|
||||
message: "hi",
|
||||
});
|
||||
|
||||
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
||||
expect(call?.agentId).toBe("alpha");
|
||||
expect(call?.sessionKey).toBeUndefined();
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ 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";
|
||||
@@ -373,11 +377,36 @@ 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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -42,10 +42,11 @@ export async function persistSessionUsageUpdate(params: {
|
||||
systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const cliProvider = params.providerUsed ?? entry.modelProvider;
|
||||
if (params.cliSessionId && cliProvider) {
|
||||
if (params.cliSessionId) {
|
||||
const nextEntry = { ...entry, ...patch };
|
||||
setCliSessionId(nextEntry, cliProvider, params.cliSessionId);
|
||||
const provider = params.providerUsed ?? entry.modelProvider;
|
||||
if (!provider) return patch;
|
||||
setCliSessionId(nextEntry, provider, params.cliSessionId);
|
||||
return {
|
||||
...patch,
|
||||
cliSessionIds: nextEntry.cliSessionIds,
|
||||
@@ -74,10 +75,11 @@ export async function persistSessionUsageUpdate(params: {
|
||||
systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const cliProvider = params.providerUsed ?? entry.modelProvider;
|
||||
if (params.cliSessionId && cliProvider) {
|
||||
if (params.cliSessionId) {
|
||||
const nextEntry = { ...entry, ...patch };
|
||||
setCliSessionId(nextEntry, cliProvider, params.cliSessionId);
|
||||
const provider = params.providerUsed ?? entry.modelProvider;
|
||||
if (!provider) return patch;
|
||||
setCliSessionId(nextEntry, provider, params.cliSessionId);
|
||||
return {
|
||||
...patch,
|
||||
cliSessionIds: nextEntry.cliSessionIds,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("cron cli", () => {
|
||||
|
||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||
const patch = updateCall?.[2] as { patch?: { agentId?: unknown } };
|
||||
expect(patch?.patch?.agentId).toBe("ops");
|
||||
expect(patch?.patch?.agentId).toBe("Ops");
|
||||
|
||||
callGatewayFromCli.mockClear();
|
||||
await program.parseAsync(["cron", "edit", "job-2", "--clear-agent"], {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import {
|
||||
getCronChannelOptions,
|
||||
|
||||
@@ -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,27 +278,6 @@ 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 = {};
|
||||
|
||||
@@ -88,7 +88,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
|
||||
? parts.slice(2).join(":")
|
||||
: parts.slice(1).join(":")
|
||||
: from;
|
||||
const finalId = id.trim().toLowerCase();
|
||||
const finalId = id.trim();
|
||||
if (!finalId) return null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -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.toLowerCase();
|
||||
if (explicit) return explicit;
|
||||
const raw = deriveSessionKey(scope, ctx);
|
||||
if (scope === "global") return raw;
|
||||
const canonicalMainKey = normalizeMainKey(mainKey);
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("normalizeCronJobCreate", () => {
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
|
||||
expect(normalized.agentId).toBe("ops");
|
||||
expect(normalized.agentId).toBe("Ops");
|
||||
|
||||
const cleared = normalizeCronJobCreate({
|
||||
name: "agent-clear",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { parseAbsoluteTimeMs } from "./parse.js";
|
||||
import { migrateLegacyCronPayload } from "./payload-migration.js";
|
||||
import type { CronJobCreate, CronJobPatch } from "./types.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -73,45 +73,12 @@ async function deployDiscordCommands(params: {
|
||||
try {
|
||||
await runWithRetry(() => params.client.handleDeployRequest(), "command deploy");
|
||||
} catch (err) {
|
||||
const details = formatDiscordDeployErrorDetails(err);
|
||||
params.runtime.error?.(
|
||||
danger(`discord: failed to deploy native commands: ${formatErrorMessage(err)}${details}`),
|
||||
danger(`discord: failed to deploy native commands: ${formatErrorMessage(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDiscordDeployErrorDetails(err: unknown): string {
|
||||
if (!err || typeof err !== "object") return "";
|
||||
const status = (err as { status?: unknown }).status;
|
||||
const discordCode = (err as { discordCode?: unknown }).discordCode;
|
||||
const rawBody = (err as { rawBody?: unknown }).rawBody;
|
||||
const details: string[] = [];
|
||||
if (typeof status === "number") details.push(`status=${status}`);
|
||||
if (typeof discordCode === "number" || typeof discordCode === "string") {
|
||||
details.push(`code=${discordCode}`);
|
||||
}
|
||||
if (rawBody !== undefined) {
|
||||
let bodyText = "";
|
||||
try {
|
||||
bodyText = JSON.stringify(rawBody);
|
||||
} catch {
|
||||
if (typeof rawBody === "string") {
|
||||
bodyText = rawBody;
|
||||
} else if (rawBody instanceof Error) {
|
||||
bodyText = rawBody.message;
|
||||
} else {
|
||||
bodyText = "[unserializable]";
|
||||
}
|
||||
}
|
||||
if (bodyText) {
|
||||
const maxLen = 800;
|
||||
const trimmed = bodyText.length > maxLen ? `${bodyText.slice(0, maxLen)}...` : bodyText;
|
||||
details.push(`body=${trimmed}`);
|
||||
}
|
||||
}
|
||||
return details.length > 0 ? ` (${details.join(", ")})` : "";
|
||||
}
|
||||
|
||||
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account = resolveDiscordAccount({
|
||||
|
||||
@@ -6,7 +6,6 @@ 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 () => {
|
||||
@@ -38,7 +37,6 @@ vi.mock("../../config/sessions.js", async () => {
|
||||
return {
|
||||
...actual,
|
||||
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
|
||||
recordSessionMetaFromInbound: mocks.recordSessionMetaFromInbound,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -136,33 +134,4 @@ 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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,6 @@ 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";
|
||||
@@ -143,30 +139,6 @@ 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,
|
||||
@@ -175,17 +147,14 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
payloads: [{ text: message, mediaUrl: request.mediaUrl, mediaUrls }],
|
||||
gifPlayback: request.gifPlayback,
|
||||
deps: outboundDeps,
|
||||
mirror: providedSessionKey
|
||||
? {
|
||||
sessionKey: providedSessionKey,
|
||||
agentId: resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg }),
|
||||
text: mirrorText || message,
|
||||
mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined,
|
||||
}
|
||||
: derivedRoute
|
||||
mirror:
|
||||
typeof request.sessionKey === "string" && request.sessionKey.trim()
|
||||
? {
|
||||
sessionKey: derivedRoute.sessionKey,
|
||||
agentId: derivedAgentId,
|
||||
sessionKey: request.sessionKey.trim(),
|
||||
agentId: resolveSessionAgentId({
|
||||
sessionKey: request.sessionKey.trim(),
|
||||
config: cfg,
|
||||
}),
|
||||
text: mirrorText || message,
|
||||
mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined,
|
||||
}
|
||||
|
||||
@@ -501,7 +501,9 @@ export async function runHeartbeatOnce(opts: {
|
||||
return { status: "skipped", reason: "alerts-disabled" };
|
||||
}
|
||||
|
||||
const heartbeatOkText = responsePrefix ? `${responsePrefix} ${HEARTBEAT_TOKEN}` : HEARTBEAT_TOKEN;
|
||||
const heartbeatOkText = responsePrefix
|
||||
? `${responsePrefix} ${HEARTBEAT_TOKEN}`
|
||||
: HEARTBEAT_TOKEN;
|
||||
const canAttemptHeartbeatOk = Boolean(
|
||||
visibility.showOk && delivery.channel !== "none" && delivery.to,
|
||||
);
|
||||
@@ -557,7 +559,11 @@ export async function runHeartbeatOnce(opts: {
|
||||
}
|
||||
|
||||
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
|
||||
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
|
||||
const normalized = normalizeHeartbeatReply(
|
||||
replyPayload,
|
||||
responsePrefix,
|
||||
ackMaxChars,
|
||||
);
|
||||
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
|
||||
if (shouldSkipMain && reasoningPayloads.length === 0) {
|
||||
await restoreHeartbeatUpdatedAt({
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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,7 +7,6 @@ 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 {
|
||||
@@ -27,7 +26,6 @@ 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 {
|
||||
@@ -39,10 +37,9 @@ import {
|
||||
} from "./outbound-policy.js";
|
||||
import { executePollAction, executeSendAction } from "./outbound-send-service.js";
|
||||
import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js";
|
||||
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
|
||||
import { resolveChannelTarget } 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;
|
||||
@@ -207,21 +204,6 @@ 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;
|
||||
@@ -458,8 +440,7 @@ async function resolveActionTarget(params: {
|
||||
action: ChannelMessageActionName;
|
||||
args: Record<string, unknown>;
|
||||
accountId?: string | null;
|
||||
}): Promise<ResolvedMessagingTarget | undefined> {
|
||||
let resolvedTarget: ResolvedMessagingTarget | undefined;
|
||||
}): Promise<void> {
|
||||
const toRaw = typeof params.args.to === "string" ? params.args.to.trim() : "";
|
||||
if (toRaw) {
|
||||
const resolved = await resolveChannelTarget({
|
||||
@@ -470,7 +451,6 @@ async function resolveActionTarget(params: {
|
||||
});
|
||||
if (resolved.ok) {
|
||||
params.args.to = resolved.target.to;
|
||||
resolvedTarget = resolved.target;
|
||||
} else {
|
||||
throw resolved.error;
|
||||
}
|
||||
@@ -494,7 +474,6 @@ async function resolveActionTarget(params: {
|
||||
throw resolved.error;
|
||||
}
|
||||
}
|
||||
return resolvedTarget;
|
||||
}
|
||||
|
||||
type ResolvedActionContext = {
|
||||
@@ -505,8 +484,6 @@ type ResolvedActionContext = {
|
||||
dryRun: boolean;
|
||||
gateway?: MessageActionRunnerGateway;
|
||||
input: RunMessageActionParams;
|
||||
agentId?: string;
|
||||
resolvedTarget?: ResolvedMessagingTarget;
|
||||
};
|
||||
function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined {
|
||||
if (!input.gateway) return undefined;
|
||||
@@ -593,7 +570,7 @@ async function handleBroadcastAction(
|
||||
}
|
||||
|
||||
async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
|
||||
const { cfg, params, channel, accountId, dryRun, gateway, input, agentId, resolvedTarget } = ctx;
|
||||
const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx;
|
||||
const action: ChannelMessageActionName = "send";
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
// Support media, path, and filePath parameters for attachments
|
||||
@@ -644,38 +621,6 @@ 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 && agentId && !dryRun) {
|
||||
await ensureOutboundSessionEntry({
|
||||
cfg,
|
||||
agentId,
|
||||
channel,
|
||||
accountId,
|
||||
route: outboundRoute,
|
||||
});
|
||||
}
|
||||
const mirrorMediaUrls =
|
||||
mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined;
|
||||
const send = await executeSendAction({
|
||||
ctx: {
|
||||
cfg,
|
||||
@@ -687,12 +632,10 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
deps: input.deps,
|
||||
dryRun,
|
||||
mirror:
|
||||
outboundRoute && !dryRun
|
||||
input.sessionKey && !dryRun
|
||||
? {
|
||||
sessionKey: outboundRoute.sessionKey,
|
||||
agentId,
|
||||
text: message,
|
||||
mediaUrls: mirrorMediaUrls,
|
||||
sessionKey: input.sessionKey,
|
||||
agentId: input.agentId,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
@@ -819,11 +762,6 @@ 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);
|
||||
|
||||
@@ -901,7 +839,7 @@ export async function runMessageAction(
|
||||
dryRun,
|
||||
});
|
||||
|
||||
const resolvedTarget = await resolveActionTarget({
|
||||
await resolveActionTarget({
|
||||
cfg,
|
||||
channel,
|
||||
action,
|
||||
@@ -928,8 +866,6 @@ export async function runMessageAction(
|
||||
dryRun,
|
||||
gateway,
|
||||
input,
|
||||
agentId: resolvedAgentId,
|
||||
resolvedTarget,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,6 @@ type MessageSendParams = {
|
||||
mirror?: {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -29,8 +28,6 @@ export type OutboundSendContext = {
|
||||
mirror?: {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -82,19 +79,6 @@ 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),
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
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("strips chat_* prefixes for BlueBubbles group session keys", async () => {
|
||||
const route = await resolveOutboundSessionRoute({
|
||||
cfg: baseConfig,
|
||||
channel: "bluebubbles",
|
||||
agentId: "main",
|
||||
target: "chat_guid:ABC123",
|
||||
});
|
||||
|
||||
expect(route?.sessionKey).toBe("agent:main:bluebubbles:group:abc123");
|
||||
expect(route?.from).toBe("group:ABC123");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,838 +0,0 @@
|
||||
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 rawPeerId = isGroup
|
||||
? stripKindPrefix(stripped)
|
||||
: stripped.replace(/^(imessage|sms|auto):/i, "");
|
||||
// BlueBubbles inbound group ids omit chat_* prefixes; strip them to align sessions.
|
||||
const peerId = isGroup
|
||||
? rawPeerId.replace(/^(chat_id|chat_guid|chat_identifier):/i, "")
|
||||
: rawPeerId;
|
||||
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.toLowerCase() === "global" || raw.toLowerCase() === "unknown") return raw.toLowerCase();
|
||||
if (raw === "global" || raw === "unknown") return raw;
|
||||
|
||||
const canonicalMain = canonicalizeMainSessionAlias({
|
||||
cfg: { session: { scope: params.scope, mainKey: params.mainKey } },
|
||||
agentId,
|
||||
sessionKey: raw,
|
||||
});
|
||||
if (canonicalMain !== raw) return canonicalMain.toLowerCase();
|
||||
if (canonicalMain !== raw) return canonicalMain;
|
||||
|
||||
if (raw.toLowerCase().startsWith("agent:")) return raw.toLowerCase();
|
||||
if (raw.startsWith("agent:")) return raw;
|
||||
if (raw.toLowerCase().startsWith("subagent:")) {
|
||||
const rest = raw.slice("subagent:".length);
|
||||
return `agent:${agentId}:subagent:${rest}`.toLowerCase();
|
||||
return `agent:${agentId}:subagent:${rest}`;
|
||||
}
|
||||
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}`.toLowerCase();
|
||||
return `agent:${agentId}:${channel}:group:${id}`;
|
||||
}
|
||||
if (!raw.includes(":") && raw.toLowerCase().includes("@g.us")) {
|
||||
return `agent:${agentId}:whatsapp:group:${raw}`.toLowerCase();
|
||||
return `agent:${agentId}:whatsapp:group:${raw}`;
|
||||
}
|
||||
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}`.toLowerCase();
|
||||
return `agent:${agentId}:whatsapp:group:${cleaned}`;
|
||||
}
|
||||
}
|
||||
if (isSurfaceGroupKey(raw)) {
|
||||
return `agent:${agentId}:${raw}`.toLowerCase();
|
||||
return `agent:${agentId}:${raw}`;
|
||||
}
|
||||
return `agent:${agentId}:${raw}`.toLowerCase();
|
||||
return `agent:${agentId}:${raw}`;
|
||||
}
|
||||
|
||||
function pickLatestLegacyDirectEntry(
|
||||
|
||||
@@ -10,9 +10,6 @@ function shouldIgnoreWarning(warning: Warning): boolean {
|
||||
if (warning.code === "DEP0040" && warning.message?.includes("punycode")) {
|
||||
return true;
|
||||
}
|
||||
if (warning.code === "DEP0060" && warning.message?.includes("util._extend")) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
warning.name === "ExperimentalWarning" &&
|
||||
warning.message?.includes("SQLite is an experimental feature")
|
||||
|
||||
@@ -180,7 +180,7 @@ describe("resolveAgentRoute", () => {
|
||||
accountId: undefined,
|
||||
peer: { kind: "dm", id: "+1000" },
|
||||
});
|
||||
expect(defaultRoute.agentId).toBe("defaultacct");
|
||||
expect(defaultRoute.agentId).toBe("defaultAcct");
|
||||
expect(defaultRoute.matchedBy).toBe("binding.account");
|
||||
|
||||
const otherRoute = resolveAgentRoute({
|
||||
|
||||
@@ -92,13 +92,12 @@ function listAgents(cfg: ClawdbotConfig) {
|
||||
}
|
||||
|
||||
function pickFirstExistingAgentId(cfg: ClawdbotConfig, agentId: string): string {
|
||||
const trimmed = (agentId ?? "").trim();
|
||||
if (!trimmed) return normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
const normalized = normalizeAgentId(trimmed);
|
||||
const normalized = normalizeAgentId(agentId);
|
||||
const agents = listAgents(cfg);
|
||||
if (agents.length === 0) return normalized;
|
||||
const match = agents.find((agent) => normalizeAgentId(agent.id) === normalized);
|
||||
if (match?.id?.trim()) return normalizeAgentId(match.id);
|
||||
if (agents.some((agent) => normalizeAgentId(agent.id) === normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
}
|
||||
|
||||
@@ -156,23 +155,21 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
||||
|
||||
const choose = (agentId: string, matchedBy: ResolvedAgentRoute["matchedBy"]) => {
|
||||
const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId);
|
||||
const sessionKey = buildAgentSessionKey({
|
||||
agentId: resolvedAgentId,
|
||||
channel,
|
||||
peer,
|
||||
dmScope,
|
||||
identityLinks,
|
||||
}).toLowerCase();
|
||||
const mainSessionKey = buildAgentMainSessionKey({
|
||||
agentId: resolvedAgentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
}).toLowerCase();
|
||||
return {
|
||||
agentId: resolvedAgentId,
|
||||
channel,
|
||||
accountId,
|
||||
sessionKey,
|
||||
mainSessionKey,
|
||||
sessionKey: buildAgentSessionKey({
|
||||
agentId: resolvedAgentId,
|
||||
channel,
|
||||
peer,
|
||||
dmScope,
|
||||
identityLinks,
|
||||
}),
|
||||
mainSessionKey: buildAgentMainSessionKey({
|
||||
agentId: resolvedAgentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
}),
|
||||
matchedBy,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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.toLowerCase() : DEFAULT_MAIN_KEY;
|
||||
return trimmed ? trimmed : DEFAULT_MAIN_KEY;
|
||||
}
|
||||
|
||||
export function toAgentRequestSessionKey(storeKey: string | undefined | null): string | undefined {
|
||||
@@ -35,12 +35,8 @@ export function toAgentStoreSessionKey(params: {
|
||||
if (!raw || raw === DEFAULT_MAIN_KEY) {
|
||||
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey: params.mainKey });
|
||||
}
|
||||
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}`;
|
||||
if (raw.startsWith("agent:")) return raw;
|
||||
return `agent:${normalizeAgentId(params.agentId)}:${raw}`;
|
||||
}
|
||||
|
||||
export function resolveAgentIdFromSessionKey(sessionKey: string | undefined | null): string {
|
||||
@@ -52,7 +48,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.toLowerCase();
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
|
||||
// Best-effort fallback: collapse invalid characters to "-"
|
||||
return (
|
||||
trimmed
|
||||
@@ -67,7 +63,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.toLowerCase();
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
@@ -110,7 +106,6 @@ 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}`;
|
||||
@@ -124,7 +119,7 @@ export function buildAgentPeerSessionKey(params: {
|
||||
});
|
||||
}
|
||||
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
|
||||
const peerId = ((params.peerId ?? "").trim() || "unknown").toLowerCase();
|
||||
const peerId = (params.peerId ?? "").trim() || "unknown";
|
||||
return `agent:${normalizeAgentId(params.agentId)}:${channel}:${peerKind}:${peerId}`;
|
||||
}
|
||||
|
||||
@@ -168,7 +163,7 @@ export function buildGroupHistoryKey(params: {
|
||||
}): string {
|
||||
const channel = normalizeToken(params.channel) || "unknown";
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const peerId = params.peerId.trim().toLowerCase() || "unknown";
|
||||
const peerId = params.peerId.trim() || "unknown";
|
||||
return `${channel}:${accountId}:${params.peerKind}:${peerId}`;
|
||||
}
|
||||
|
||||
@@ -182,10 +177,9 @@ 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:${normalizedThreadId}`
|
||||
? `${params.baseSessionKey}:thread:${threadId}`
|
||||
: params.baseSessionKey;
|
||||
return { sessionKey, parentSessionKey: params.parentSessionKey };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -408,8 +408,7 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
WasMentioned: true,
|
||||
MessageSid: command.trigger_id,
|
||||
Timestamp: Date.now(),
|
||||
SessionKey:
|
||||
`agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`.toLowerCase(),
|
||||
SessionKey: `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`,
|
||||
CommandTargetSessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
CommandSource: "native" as const,
|
||||
|
||||
@@ -311,9 +311,6 @@ export const registerTelegramNativeCommands = ({
|
||||
CommandTargetSessionKey: route.sessionKey,
|
||||
MessageThreadId: resolvedThreadId,
|
||||
IsForum: isForum,
|
||||
// Originating context for sub-agent announce routing
|
||||
OriginatingChannel: "telegram" as const,
|
||||
OriginatingTo: `telegram:${chatId}`,
|
||||
});
|
||||
|
||||
const disableBlockStreaming =
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
@@ -126,7 +126,8 @@ describe("provider timeouts (e2e)", () => {
|
||||
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
||||
};
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchHolder = globalThis as unknown as { fetch?: typeof fetch };
|
||||
const originalFetch = fetchHolder.fetch;
|
||||
const primaryBaseUrl = "https://primary.example/v1";
|
||||
const fallbackBaseUrl = "https://fallback.example/v1";
|
||||
const counts = { primary: 0, fallback: 0 };
|
||||
@@ -149,97 +150,103 @@ describe("provider timeouts (e2e)", () => {
|
||||
if (!originalFetch) throw new Error(`fetch is not available (url=${url})`);
|
||||
return await originalFetch(input, init);
|
||||
};
|
||||
(globalThis as unknown as { fetch: unknown }).fetch = fetchImpl;
|
||||
fetchHolder.fetch = fetchImpl;
|
||||
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-timeout-e2e-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.CLAWDBOT_SKIP_CRON = "1";
|
||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
||||
|
||||
const token = `test-${randomUUID()}`;
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||
|
||||
const configDir = path.join(tempHome, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const configPath = path.join(configDir, "clawdbot.json");
|
||||
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "primary/gpt-5.2",
|
||||
fallbacks: ["fallback/gpt-5.2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "replace",
|
||||
providers: {
|
||||
primary: {
|
||||
baseUrl: primaryBaseUrl,
|
||||
apiKey: "test",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.2",
|
||||
name: "gpt-5.2",
|
||||
api: "openai-responses",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
fallback: {
|
||||
baseUrl: fallbackBaseUrl,
|
||||
apiKey: "test",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.2",
|
||||
name: "gpt-5.2",
|
||||
api: "openai-responses",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
gateway: { auth: { token } },
|
||||
};
|
||||
|
||||
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
||||
process.env.CLAWDBOT_CONFIG_PATH = configPath;
|
||||
|
||||
const port = await getFreeGatewayPort();
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
|
||||
const client = await connectClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
});
|
||||
let tempHome: string | undefined;
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
|
||||
let client: InstanceType<typeof GatewayClient> | undefined;
|
||||
|
||||
try {
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-timeout-e2e-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.CLAWDBOT_SKIP_CRON = "1";
|
||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
||||
|
||||
const token = `test-${randomUUID()}`;
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||
|
||||
const configDir = path.join(tempHome, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const configPath = path.join(configDir, "clawdbot.json");
|
||||
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "primary/gpt-5.2",
|
||||
fallbacks: ["fallback/gpt-5.2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "replace",
|
||||
providers: {
|
||||
primary: {
|
||||
baseUrl: primaryBaseUrl,
|
||||
apiKey: "test",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.2",
|
||||
name: "gpt-5.2",
|
||||
api: "openai-responses",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
fallback: {
|
||||
baseUrl: fallbackBaseUrl,
|
||||
apiKey: "test",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.2",
|
||||
name: "gpt-5.2",
|
||||
api: "openai-responses",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
gateway: { auth: { token } },
|
||||
};
|
||||
|
||||
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
||||
process.env.CLAWDBOT_CONFIG_PATH = configPath;
|
||||
|
||||
const port = await getFreeGatewayPort();
|
||||
const startedServer = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
server = startedServer;
|
||||
|
||||
const connectedClient = await connectClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
});
|
||||
client = connectedClient;
|
||||
|
||||
const sessionKey = "agent:dev:timeout-fallback";
|
||||
await client.request<Record<string, unknown>>("sessions.patch", {
|
||||
await connectedClient.request<Record<string, unknown>>("sessions.patch", {
|
||||
key: sessionKey,
|
||||
model: "primary/gpt-5.2",
|
||||
});
|
||||
|
||||
const runId = randomUUID();
|
||||
const payload = await client.request<{
|
||||
const payload = await connectedClient.request<{
|
||||
status?: unknown;
|
||||
result?: unknown;
|
||||
}>(
|
||||
@@ -259,10 +266,21 @@ describe("provider timeouts (e2e)", () => {
|
||||
expect(counts.primary).toBeGreaterThan(0);
|
||||
expect(counts.fallback).toBeGreaterThan(0);
|
||||
} finally {
|
||||
client.stop();
|
||||
await server.close({ reason: "timeout fallback test complete" });
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
(globalThis as unknown as { fetch: unknown }).fetch = originalFetch;
|
||||
try {
|
||||
client?.stop();
|
||||
} catch {}
|
||||
if (server) {
|
||||
try {
|
||||
await server.close({ reason: "timeout fallback test complete" });
|
||||
} catch {}
|
||||
}
|
||||
if (tempHome) {
|
||||
try {
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
if (originalFetch === undefined) delete fetchHolder.fetch;
|
||||
else fetchHolder.fetch = originalFetch;
|
||||
if (prev.home === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prev.home;
|
||||
if (prev.configPath === undefined) delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||
|
||||
Reference in New Issue
Block a user