fix(gateway): harden agent provider routing
This commit is contained in:
@@ -23,6 +23,7 @@
|
|||||||
- Models: centralize model override validation + hooks Gmail warnings in doctor. (#602) — thanks @steipete
|
- Models: centralize model override validation + hooks Gmail warnings in doctor. (#602) — thanks @steipete
|
||||||
- Agents: avoid base-to-string error stringification in model fallback. (#604) — thanks @steipete
|
- Agents: avoid base-to-string error stringification in model fallback. (#604) — thanks @steipete
|
||||||
- Agents: `sessions_spawn` inherits the requester's provider for child runs (avoid WhatsApp fallback). (#528) — thanks @rlmestre
|
- Agents: `sessions_spawn` inherits the requester's provider for child runs (avoid WhatsApp fallback). (#528) — thanks @rlmestre
|
||||||
|
- Gateway/CLI: harden agent provider routing + validation (Slack/MS Teams + aliases). (follow-up #528) — thanks @steipete
|
||||||
- Agents: treat billing/insufficient-credits errors as failover-worthy so model fallbacks kick in. (#486) — thanks @steipete
|
- Agents: treat billing/insufficient-credits errors as failover-worthy so model fallbacks kick in. (#486) — thanks @steipete
|
||||||
- Auth: default billing disable backoff to 5h (doubling, 24h cap) and surface disabled/cooldown profiles in `models list` + doctor. (#486) — thanks @steipete
|
- Auth: default billing disable backoff to 5h (doubling, 24h cap) and surface disabled/cooldown profiles in `models list` + doctor. (#486) — thanks @steipete
|
||||||
- Commands: harden slash command registry and list text-only commands in `/commands`.
|
- Commands: harden slash command registry and list text-only commands in `/commands`.
|
||||||
|
|||||||
2
src/agents/lanes.ts
Normal file
2
src/agents/lanes.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const AGENT_LANE_NESTED = "nested" as const;
|
||||||
|
export const AGENT_LANE_SUBAGENT = "subagent" as const;
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
|
import { INTERNAL_MESSAGE_PROVIDER } from "../utils/message-provider.js";
|
||||||
|
import { AGENT_LANE_NESTED } from "./lanes.js";
|
||||||
import { readLatestAssistantReply, runAgentStep } from "./tools/agent-step.js";
|
import { readLatestAssistantReply, runAgentStep } from "./tools/agent-step.js";
|
||||||
import { resolveAnnounceTarget } from "./tools/sessions-announce-target.js";
|
import { resolveAnnounceTarget } from "./tools/sessions-announce-target.js";
|
||||||
import { isAnnounceSkip } from "./tools/sessions-send-helpers.js";
|
import { isAnnounceSkip } from "./tools/sessions-send-helpers.js";
|
||||||
@@ -241,7 +243,8 @@ export async function runSubagentAnnounceFlow(params: {
|
|||||||
message: "Sub-agent announce step.",
|
message: "Sub-agent announce step.",
|
||||||
extraSystemPrompt: announcePrompt,
|
extraSystemPrompt: announcePrompt,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
lane: "nested",
|
provider: INTERNAL_MESSAGE_PROVIDER,
|
||||||
|
lane: AGENT_LANE_NESTED,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import { callGateway } from "../../gateway/call.js";
|
import { callGateway } from "../../gateway/call.js";
|
||||||
|
import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js";
|
||||||
|
import { AGENT_LANE_NESTED } from "../lanes.js";
|
||||||
import { extractAssistantText, stripToolMessages } from "./sessions-helpers.js";
|
import { extractAssistantText, stripToolMessages } from "./sessions-helpers.js";
|
||||||
|
|
||||||
export async function readLatestAssistantReply(params: {
|
export async function readLatestAssistantReply(params: {
|
||||||
@@ -34,8 +36,8 @@ export async function runAgentStep(params: {
|
|||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
idempotencyKey: stepIdem,
|
idempotencyKey: stepIdem,
|
||||||
deliver: false,
|
deliver: false,
|
||||||
provider: params.provider ?? "webchat",
|
provider: params.provider ?? INTERNAL_MESSAGE_PROVIDER,
|
||||||
lane: params.lane ?? "nested",
|
lane: params.lane ?? AGENT_LANE_NESTED,
|
||||||
extraSystemPrompt: params.extraSystemPrompt,
|
extraSystemPrompt: params.extraSystemPrompt,
|
||||||
},
|
},
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
parseAgentSessionKey,
|
parseAgentSessionKey,
|
||||||
} from "../../routing/session-key.js";
|
} from "../../routing/session-key.js";
|
||||||
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
|
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
|
||||||
import type { GatewayMessageProvider } from "../../utils/message-provider.js";
|
import {
|
||||||
|
type GatewayMessageProvider,
|
||||||
|
INTERNAL_MESSAGE_PROVIDER,
|
||||||
|
} from "../../utils/message-provider.js";
|
||||||
|
import { AGENT_LANE_NESTED } from "../lanes.js";
|
||||||
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
|
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { jsonResult, readStringParam } from "./common.js";
|
import { jsonResult, readStringParam } from "./common.js";
|
||||||
@@ -297,8 +301,8 @@ export function createSessionsSendTool(opts?: {
|
|||||||
sessionKey: resolvedKey,
|
sessionKey: resolvedKey,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
deliver: false,
|
deliver: false,
|
||||||
provider: "webchat",
|
provider: INTERNAL_MESSAGE_PROVIDER,
|
||||||
lane: "nested",
|
lane: AGENT_LANE_NESTED,
|
||||||
extraSystemPrompt: agentMessageContext,
|
extraSystemPrompt: agentMessageContext,
|
||||||
};
|
};
|
||||||
const requesterSessionKey = opts?.agentSessionKey;
|
const requesterSessionKey = opts?.agentSessionKey;
|
||||||
@@ -362,7 +366,7 @@ export function createSessionsSendTool(opts?: {
|
|||||||
message: incomingMessage,
|
message: incomingMessage,
|
||||||
extraSystemPrompt: replyPrompt,
|
extraSystemPrompt: replyPrompt,
|
||||||
timeoutMs: announceTimeoutMs,
|
timeoutMs: announceTimeoutMs,
|
||||||
lane: "nested",
|
lane: AGENT_LANE_NESTED,
|
||||||
});
|
});
|
||||||
if (!replyText || isReplySkip(replyText)) {
|
if (!replyText || isReplySkip(replyText)) {
|
||||||
break;
|
break;
|
||||||
@@ -388,7 +392,7 @@ export function createSessionsSendTool(opts?: {
|
|||||||
message: "Agent-to-agent announce step.",
|
message: "Agent-to-agent announce step.",
|
||||||
extraSystemPrompt: announcePrompt,
|
extraSystemPrompt: announcePrompt,
|
||||||
timeoutMs: announceTimeoutMs,
|
timeoutMs: announceTimeoutMs,
|
||||||
lane: "nested",
|
lane: AGENT_LANE_NESTED,
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
announceTarget &&
|
announceTarget &&
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "../../routing/session-key.js";
|
} from "../../routing/session-key.js";
|
||||||
import type { GatewayMessageProvider } from "../../utils/message-provider.js";
|
import type { GatewayMessageProvider } from "../../utils/message-provider.js";
|
||||||
import { resolveAgentConfig } from "../agent-scope.js";
|
import { resolveAgentConfig } from "../agent-scope.js";
|
||||||
|
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
|
||||||
import { buildSubagentSystemPrompt } from "../subagent-announce.js";
|
import { buildSubagentSystemPrompt } from "../subagent-announce.js";
|
||||||
import { registerSubagentRun } from "../subagent-registry.js";
|
import { registerSubagentRun } from "../subagent-registry.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
@@ -174,7 +175,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
provider: opts?.agentProvider,
|
provider: opts?.agentProvider,
|
||||||
idempotencyKey: childIdem,
|
idempotencyKey: childIdem,
|
||||||
deliver: false,
|
deliver: false,
|
||||||
lane: "subagent",
|
lane: AGENT_LANE_SUBAGENT,
|
||||||
extraSystemPrompt: childSystemPrompt,
|
extraSystemPrompt: childSystemPrompt,
|
||||||
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
|
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
|
||||||
label: label || undefined,
|
label: label || undefined,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
resolveDefaultIMessageAccountId,
|
resolveDefaultIMessageAccountId,
|
||||||
resolveIMessageAccount,
|
resolveIMessageAccount,
|
||||||
} from "../imessage/accounts.js";
|
} from "../imessage/accounts.js";
|
||||||
|
import { resolveMSTeamsCredentials } from "../msteams/token.js";
|
||||||
import {
|
import {
|
||||||
type ChatProviderId,
|
type ChatProviderId,
|
||||||
getChatProviderMeta,
|
getChatProviderMeta,
|
||||||
@@ -564,6 +565,20 @@ async function buildProviderStatusIndex(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const accountId = DEFAULT_ACCOUNT_ID;
|
||||||
|
const hasCreds = Boolean(resolveMSTeamsCredentials(cfg.msteams));
|
||||||
|
const hasConfig = Boolean(cfg.msteams);
|
||||||
|
const enabled = cfg.msteams?.enabled !== false;
|
||||||
|
map.set(providerAccountKey("msteams", accountId), {
|
||||||
|
provider: "msteams",
|
||||||
|
accountId,
|
||||||
|
state: hasCreds ? "configured" : "not configured",
|
||||||
|
enabled,
|
||||||
|
configured: hasCreds || hasConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +599,8 @@ function resolveDefaultAccountId(
|
|||||||
return resolveDefaultSignalAccountId(cfg) || DEFAULT_ACCOUNT_ID;
|
return resolveDefaultSignalAccountId(cfg) || DEFAULT_ACCOUNT_ID;
|
||||||
case "imessage":
|
case "imessage":
|
||||||
return resolveDefaultIMessageAccountId(cfg) || DEFAULT_ACCOUNT_ID;
|
return resolveDefaultIMessageAccountId(cfg) || DEFAULT_ACCOUNT_ID;
|
||||||
|
case "msteams":
|
||||||
|
return DEFAULT_ACCOUNT_ID;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import {
|
|||||||
formatUsageReportLines,
|
formatUsageReportLines,
|
||||||
loadProviderUsageSummary,
|
loadProviderUsageSummary,
|
||||||
} from "../../infra/provider-usage.js";
|
} from "../../infra/provider-usage.js";
|
||||||
|
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
|
||||||
import {
|
import {
|
||||||
type ChatProviderId,
|
type ChatProviderId,
|
||||||
listChatProviders,
|
listChatProviders,
|
||||||
} from "../../providers/registry.js";
|
} from "../../providers/registry.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||||
import {
|
import {
|
||||||
listSignalAccountIds,
|
listSignalAccountIds,
|
||||||
@@ -104,6 +106,7 @@ export async function providersListCommand(
|
|||||||
slack: listSlackAccountIds(cfg),
|
slack: listSlackAccountIds(cfg),
|
||||||
signal: listSignalAccountIds(cfg),
|
signal: listSignalAccountIds(cfg),
|
||||||
imessage: listIMessageAccountIds(cfg),
|
imessage: listIMessageAccountIds(cfg),
|
||||||
|
msteams: [DEFAULT_ACCOUNT_ID],
|
||||||
};
|
};
|
||||||
|
|
||||||
const lineBuilders: Record<
|
const lineBuilders: Record<
|
||||||
@@ -194,6 +197,19 @@ export async function providersListCommand(
|
|||||||
});
|
});
|
||||||
return `- ${label}: ${formatEnabled(account.enabled)}`;
|
return `- ${label}: ${formatEnabled(account.enabled)}`;
|
||||||
},
|
},
|
||||||
|
msteams: async (accountId) => {
|
||||||
|
const label = formatProviderAccountLabel({
|
||||||
|
provider: "msteams",
|
||||||
|
accountId,
|
||||||
|
providerStyle: theme.accent,
|
||||||
|
accountStyle: theme.heading,
|
||||||
|
});
|
||||||
|
const configured = Boolean(resolveMSTeamsCredentials(cfg.msteams));
|
||||||
|
const enabled = cfg.msteams?.enabled !== false;
|
||||||
|
return `- ${label}: ${formatConfigured(configured)}, ${formatEnabled(
|
||||||
|
enabled,
|
||||||
|
)}`;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const authStore = loadAuthProfileStore();
|
const authStore = loadAuthProfileStore();
|
||||||
@@ -217,6 +233,7 @@ export async function providersListCommand(
|
|||||||
slack: accountIdsByProvider.slack,
|
slack: accountIdsByProvider.slack,
|
||||||
signal: accountIdsByProvider.signal,
|
signal: accountIdsByProvider.signal,
|
||||||
imessage: accountIdsByProvider.imessage,
|
imessage: accountIdsByProvider.imessage,
|
||||||
|
msteams: accountIdsByProvider.msteams,
|
||||||
},
|
},
|
||||||
auth: authProfiles,
|
auth: authProfiles,
|
||||||
...(usage ? { usage } : {}),
|
...(usage ? { usage } : {}),
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ function listAccountIds(cfg: ClawdbotConfig, provider: ChatProvider): string[] {
|
|||||||
return listSignalAccountIds(cfg);
|
return listSignalAccountIds(cfg);
|
||||||
case "imessage":
|
case "imessage":
|
||||||
return listIMessageAccountIds(cfg);
|
return listIMessageAccountIds(cfg);
|
||||||
|
case "msteams":
|
||||||
|
return [DEFAULT_ACCOUNT_ID];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
} from "../../imessage/accounts.js";
|
} from "../../imessage/accounts.js";
|
||||||
import { formatAge } from "../../infra/provider-summary.js";
|
import { formatAge } from "../../infra/provider-summary.js";
|
||||||
import { collectProvidersStatusIssues } from "../../infra/providers-status-issues.js";
|
import { collectProvidersStatusIssues } from "../../infra/providers-status-issues.js";
|
||||||
|
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
|
||||||
import { listChatProviders } from "../../providers/registry.js";
|
import { listChatProviders } from "../../providers/registry.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||||
import {
|
import {
|
||||||
listSignalAccountIds,
|
listSignalAccountIds,
|
||||||
@@ -340,6 +342,18 @@ async function formatConfigProvidersStatusLines(
|
|||||||
configured: imsgConfigured,
|
configured: imsgConfigured,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
msteams: [
|
||||||
|
{
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
enabled: cfg.msteams?.enabled !== false,
|
||||||
|
configured: Boolean(resolveMSTeamsCredentials(cfg.msteams)),
|
||||||
|
dmPolicy: cfg.msteams?.dmPolicy ?? "pairing",
|
||||||
|
allowFrom: (cfg.msteams?.allowFrom ?? [])
|
||||||
|
.map((value) => String(value ?? "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
} satisfies Partial<Record<ChatProvider, Array<Record<string, unknown>>>>;
|
} satisfies Partial<Record<ChatProvider, Array<Record<string, unknown>>>>;
|
||||||
|
|
||||||
// WhatsApp linked info (config-only best-effort).
|
// WhatsApp linked info (config-only best-effort).
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type Static, type TSchema, Type } from "@sinclair/typebox";
|
import { type Static, type TSchema, Type } from "@sinclair/typebox";
|
||||||
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
|
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
|
||||||
|
import { GATEWAY_AGENT_PROVIDER_VALUES } from "../../utils/message-provider.js";
|
||||||
|
|
||||||
const NonEmptyString = Type.String({ minLength: 1 });
|
const NonEmptyString = Type.String({ minLength: 1 });
|
||||||
const SessionLabelString = Type.String({
|
const SessionLabelString = Type.String({
|
||||||
@@ -7,6 +8,10 @@ const SessionLabelString = Type.String({
|
|||||||
maxLength: SESSION_LABEL_MAX_LENGTH,
|
maxLength: SESSION_LABEL_MAX_LENGTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const AgentProviderSchema = Type.Union(
|
||||||
|
GATEWAY_AGENT_PROVIDER_VALUES.map((provider) => Type.Literal(provider)),
|
||||||
|
);
|
||||||
|
|
||||||
export const PresenceEntrySchema = Type.Object(
|
export const PresenceEntrySchema = Type.Object(
|
||||||
{
|
{
|
||||||
host: Type.Optional(NonEmptyString),
|
host: Type.Optional(NonEmptyString),
|
||||||
@@ -225,7 +230,7 @@ export const AgentParamsSchema = Type.Object(
|
|||||||
sessionKey: Type.Optional(Type.String()),
|
sessionKey: Type.Optional(Type.String()),
|
||||||
thinking: Type.Optional(Type.String()),
|
thinking: Type.Optional(Type.String()),
|
||||||
deliver: Type.Optional(Type.Boolean()),
|
deliver: Type.Optional(Type.Boolean()),
|
||||||
provider: Type.Optional(Type.String()),
|
provider: Type.Optional(AgentProviderSchema),
|
||||||
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
lane: Type.Optional(Type.String()),
|
lane: Type.Optional(Type.String()),
|
||||||
extraSystemPrompt: Type.Optional(Type.String()),
|
extraSystemPrompt: Type.Optional(Type.String()),
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import { registerAgentRunContext } from "../../infra/agent-events.js";
|
|||||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||||
import { normalizeMessageProvider } from "../../utils/message-provider.js";
|
import {
|
||||||
|
INTERNAL_MESSAGE_PROVIDER,
|
||||||
|
isDeliverableMessageProvider,
|
||||||
|
isGatewayMessageProvider,
|
||||||
|
normalizeMessageProvider,
|
||||||
|
} from "../../utils/message-provider.js";
|
||||||
import { normalizeE164 } from "../../utils.js";
|
import { normalizeE164 } from "../../utils.js";
|
||||||
import {
|
import {
|
||||||
type AgentWaitParams,
|
type AgentWaitParams,
|
||||||
@@ -161,21 +166,18 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
if (requestedProvider === "last") {
|
if (requestedProvider === "last") {
|
||||||
// WebChat is not a deliverable surface. Treat it as "unset" for routing,
|
// WebChat is not a deliverable surface. Treat it as "unset" for routing,
|
||||||
// so VoiceWake and CLI callers don't get stuck with deliver=false.
|
// so VoiceWake and CLI callers don't get stuck with deliver=false.
|
||||||
if (lastProvider && lastProvider !== "webchat") return lastProvider;
|
if (lastProvider && lastProvider !== INTERNAL_MESSAGE_PROVIDER) {
|
||||||
return wantsDelivery ? "whatsapp" : "webchat";
|
return lastProvider;
|
||||||
|
}
|
||||||
|
return wantsDelivery ? "whatsapp" : INTERNAL_MESSAGE_PROVIDER;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
requestedProvider === "whatsapp" ||
|
if (isGatewayMessageProvider(requestedProvider)) return requestedProvider;
|
||||||
requestedProvider === "telegram" ||
|
|
||||||
requestedProvider === "discord" ||
|
if (lastProvider && lastProvider !== INTERNAL_MESSAGE_PROVIDER) {
|
||||||
requestedProvider === "signal" ||
|
return lastProvider;
|
||||||
requestedProvider === "imessage" ||
|
|
||||||
requestedProvider === "webchat"
|
|
||||||
) {
|
|
||||||
return requestedProvider;
|
|
||||||
}
|
}
|
||||||
if (lastProvider && lastProvider !== "webchat") return lastProvider;
|
return wantsDelivery ? "whatsapp" : INTERNAL_MESSAGE_PROVIDER;
|
||||||
return wantsDelivery ? "whatsapp" : "webchat";
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const resolvedTo = (() => {
|
const resolvedTo = (() => {
|
||||||
@@ -184,13 +186,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
? request.to.trim()
|
? request.to.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
if (explicit) return explicit;
|
if (explicit) return explicit;
|
||||||
if (
|
if (isDeliverableMessageProvider(resolvedProvider)) {
|
||||||
resolvedProvider === "whatsapp" ||
|
|
||||||
resolvedProvider === "telegram" ||
|
|
||||||
resolvedProvider === "discord" ||
|
|
||||||
resolvedProvider === "signal" ||
|
|
||||||
resolvedProvider === "imessage"
|
|
||||||
) {
|
|
||||||
return lastTo || undefined;
|
return lastTo || undefined;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -225,7 +221,9 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
return allowFrom[0];
|
return allowFrom[0];
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const deliver = request.deliver === true && resolvedProvider !== "webchat";
|
const deliver =
|
||||||
|
request.deliver === true &&
|
||||||
|
resolvedProvider !== INTERNAL_MESSAGE_PROVIDER;
|
||||||
|
|
||||||
const accepted = {
|
const accepted = {
|
||||||
runId,
|
runId,
|
||||||
|
|||||||
@@ -286,6 +286,50 @@ describe("gateway server agent", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("agent routes main last-channel slack", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
|
await fs.writeFile(
|
||||||
|
testState.sessionStorePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
main: {
|
||||||
|
sessionId: "sess-slack",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastProvider: "slack",
|
||||||
|
lastTo: "channel:slack-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "agent", {
|
||||||
|
message: "hi",
|
||||||
|
sessionKey: "main",
|
||||||
|
provider: "last",
|
||||||
|
deliver: true,
|
||||||
|
idempotencyKey: "idem-agent-last-slack",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
|
const spy = vi.mocked(agentCommand);
|
||||||
|
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||||
|
expectProviders(call, "slack");
|
||||||
|
expect(call.to).toBe("channel:slack-123");
|
||||||
|
expect(call.deliver).toBe(true);
|
||||||
|
expect(call.bestEffortDeliver).toBe(true);
|
||||||
|
expect(call.sessionId).toBe("sess-slack");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("agent routes main last-channel signal", async () => {
|
test("agent routes main last-channel signal", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
@@ -330,6 +374,125 @@ describe("gateway server agent", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("agent routes main last-channel msteams", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
|
await fs.writeFile(
|
||||||
|
testState.sessionStorePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
main: {
|
||||||
|
sessionId: "sess-teams",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastProvider: "msteams",
|
||||||
|
lastTo: "conversation:teams-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "agent", {
|
||||||
|
message: "hi",
|
||||||
|
sessionKey: "main",
|
||||||
|
provider: "last",
|
||||||
|
deliver: true,
|
||||||
|
idempotencyKey: "idem-agent-last-msteams",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
|
const spy = vi.mocked(agentCommand);
|
||||||
|
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||||
|
expectProviders(call, "msteams");
|
||||||
|
expect(call.to).toBe("conversation:teams-123");
|
||||||
|
expect(call.deliver).toBe(true);
|
||||||
|
expect(call.bestEffortDeliver).toBe(true);
|
||||||
|
expect(call.sessionId).toBe("sess-teams");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agent accepts provider aliases (imsg/teams)", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
|
await fs.writeFile(
|
||||||
|
testState.sessionStorePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
main: {
|
||||||
|
sessionId: "sess-alias",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastProvider: "imessage",
|
||||||
|
lastTo: "chat_id:123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const resIMessage = await rpcReq(ws, "agent", {
|
||||||
|
message: "hi",
|
||||||
|
sessionKey: "main",
|
||||||
|
provider: "imsg",
|
||||||
|
deliver: true,
|
||||||
|
idempotencyKey: "idem-agent-imsg",
|
||||||
|
});
|
||||||
|
expect(resIMessage.ok).toBe(true);
|
||||||
|
|
||||||
|
const resTeams = await rpcReq(ws, "agent", {
|
||||||
|
message: "hi",
|
||||||
|
sessionKey: "main",
|
||||||
|
provider: "teams",
|
||||||
|
to: "conversation:teams-abc",
|
||||||
|
deliver: false,
|
||||||
|
idempotencyKey: "idem-agent-teams",
|
||||||
|
});
|
||||||
|
expect(resTeams.ok).toBe(true);
|
||||||
|
|
||||||
|
const spy = vi.mocked(agentCommand);
|
||||||
|
const lastIMessageCall = spy.mock.calls.at(-2)?.[0] as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expectProviders(lastIMessageCall, "imessage");
|
||||||
|
expect(lastIMessageCall.to).toBe("chat_id:123");
|
||||||
|
|
||||||
|
const lastTeamsCall = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||||
|
expectProviders(lastTeamsCall, "msteams");
|
||||||
|
expect(lastTeamsCall.to).toBe("conversation:teams-abc");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agent rejects unknown provider", async () => {
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "agent", {
|
||||||
|
message: "hi",
|
||||||
|
sessionKey: "main",
|
||||||
|
provider: "sms",
|
||||||
|
idempotencyKey: "idem-agent-bad-provider",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error?.code).toBe("INVALID_REQUEST");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("agent ignores webchat last-channel for routing", async () => {
|
test("agent ignores webchat last-channel for routing", async () => {
|
||||||
testState.allowFrom = ["+1555"];
|
testState.allowFrom = ["+1555"];
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
|
|||||||
@@ -5,34 +5,23 @@ import { resolveMSTeamsCredentials } from "../../msteams/token.js";
|
|||||||
import { listEnabledSignalAccounts } from "../../signal/accounts.js";
|
import { listEnabledSignalAccounts } from "../../signal/accounts.js";
|
||||||
import { listEnabledSlackAccounts } from "../../slack/accounts.js";
|
import { listEnabledSlackAccounts } from "../../slack/accounts.js";
|
||||||
import { listEnabledTelegramAccounts } from "../../telegram/accounts.js";
|
import { listEnabledTelegramAccounts } from "../../telegram/accounts.js";
|
||||||
import { normalizeMessageProvider } from "../../utils/message-provider.js";
|
import {
|
||||||
|
DELIVERABLE_MESSAGE_PROVIDERS,
|
||||||
|
type DeliverableMessageProvider,
|
||||||
|
normalizeMessageProvider,
|
||||||
|
} from "../../utils/message-provider.js";
|
||||||
import {
|
import {
|
||||||
listEnabledWhatsAppAccounts,
|
listEnabledWhatsAppAccounts,
|
||||||
resolveWhatsAppAccount,
|
resolveWhatsAppAccount,
|
||||||
} from "../../web/accounts.js";
|
} from "../../web/accounts.js";
|
||||||
import { webAuthExists } from "../../web/session.js";
|
import { webAuthExists } from "../../web/session.js";
|
||||||
|
|
||||||
export type MessageProviderId =
|
export type MessageProviderId = DeliverableMessageProvider;
|
||||||
| "whatsapp"
|
|
||||||
| "telegram"
|
|
||||||
| "discord"
|
|
||||||
| "slack"
|
|
||||||
| "signal"
|
|
||||||
| "imessage"
|
|
||||||
| "msteams";
|
|
||||||
|
|
||||||
const MESSAGE_PROVIDERS: MessageProviderId[] = [
|
const MESSAGE_PROVIDERS = [...DELIVERABLE_MESSAGE_PROVIDERS];
|
||||||
"whatsapp",
|
|
||||||
"telegram",
|
|
||||||
"discord",
|
|
||||||
"slack",
|
|
||||||
"signal",
|
|
||||||
"imessage",
|
|
||||||
"msteams",
|
|
||||||
];
|
|
||||||
|
|
||||||
function isKnownProvider(value: string): value is MessageProviderId {
|
function isKnownProvider(value: string): value is MessageProviderId {
|
||||||
return (MESSAGE_PROVIDERS as string[]).includes(value);
|
return (MESSAGE_PROVIDERS as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isWhatsAppConfigured(cfg: ClawdbotConfig): Promise<boolean> {
|
async function isWhatsAppConfigured(cfg: ClawdbotConfig): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { SessionEntry } from "../../config/sessions.js";
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
|
import type {
|
||||||
|
DeliverableMessageProvider,
|
||||||
|
GatewayMessageProvider,
|
||||||
|
} from "../../utils/message-provider.js";
|
||||||
import { normalizeE164 } from "../../utils.js";
|
import { normalizeE164 } from "../../utils.js";
|
||||||
|
|
||||||
export type OutboundProvider =
|
export type OutboundProvider = DeliverableMessageProvider | "none";
|
||||||
| "whatsapp"
|
|
||||||
| "telegram"
|
|
||||||
| "discord"
|
|
||||||
| "slack"
|
|
||||||
| "signal"
|
|
||||||
| "imessage"
|
|
||||||
| "msteams"
|
|
||||||
| "none";
|
|
||||||
|
|
||||||
export type HeartbeatTarget = OutboundProvider | "last";
|
export type HeartbeatTarget = OutboundProvider | "last";
|
||||||
|
|
||||||
@@ -25,15 +21,7 @@ export type OutboundTargetResolution =
|
|||||||
| { ok: false; error: Error };
|
| { ok: false; error: Error };
|
||||||
|
|
||||||
export function resolveOutboundTarget(params: {
|
export function resolveOutboundTarget(params: {
|
||||||
provider:
|
provider: GatewayMessageProvider;
|
||||||
| "whatsapp"
|
|
||||||
| "telegram"
|
|
||||||
| "discord"
|
|
||||||
| "slack"
|
|
||||||
| "signal"
|
|
||||||
| "imessage"
|
|
||||||
| "msteams"
|
|
||||||
| "webchat";
|
|
||||||
to?: string;
|
to?: string;
|
||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
}): OutboundTargetResolution {
|
}): OutboundTargetResolution {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
describe("provider registry", () => {
|
describe("provider registry", () => {
|
||||||
it("normalizes aliases", () => {
|
it("normalizes aliases", () => {
|
||||||
expect(normalizeChatProviderId("imsg")).toBe("imessage");
|
expect(normalizeChatProviderId("imsg")).toBe("imessage");
|
||||||
|
expect(normalizeChatProviderId("teams")).toBe("msteams");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Telegram first in the default order", () => {
|
it("keeps Telegram first in the default order", () => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { normalizeMessageProvider } from "../utils/message-provider.js";
|
||||||
|
|
||||||
export const CHAT_PROVIDER_ORDER = [
|
export const CHAT_PROVIDER_ORDER = [
|
||||||
"telegram",
|
"telegram",
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
@@ -5,6 +7,7 @@ export const CHAT_PROVIDER_ORDER = [
|
|||||||
"slack",
|
"slack",
|
||||||
"signal",
|
"signal",
|
||||||
"imessage",
|
"imessage",
|
||||||
|
"msteams",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ChatProviderId = (typeof CHAT_PROVIDER_ORDER)[number];
|
export type ChatProviderId = (typeof CHAT_PROVIDER_ORDER)[number];
|
||||||
@@ -69,10 +72,14 @@ const CHAT_PROVIDER_META: Record<ChatProviderId, ChatProviderMeta> = {
|
|||||||
docsLabel: "imessage",
|
docsLabel: "imessage",
|
||||||
blurb: "this is still a work in progress.",
|
blurb: "this is still a work in progress.",
|
||||||
},
|
},
|
||||||
};
|
msteams: {
|
||||||
|
id: "msteams",
|
||||||
const CHAT_PROVIDER_ALIASES: Record<string, ChatProviderId> = {
|
label: "MS Teams",
|
||||||
imsg: "imessage",
|
selectionLabel: "Microsoft Teams (Bot Framework)",
|
||||||
|
docsPath: "/msteams",
|
||||||
|
docsLabel: "msteams",
|
||||||
|
blurb: "supported (Bot Framework).",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const WEBSITE_URL = "https://clawd.bot";
|
const WEBSITE_URL = "https://clawd.bot";
|
||||||
@@ -88,9 +95,8 @@ export function getChatProviderMeta(id: ChatProviderId): ChatProviderMeta {
|
|||||||
export function normalizeChatProviderId(
|
export function normalizeChatProviderId(
|
||||||
raw?: string | null,
|
raw?: string | null,
|
||||||
): ChatProviderId | null {
|
): ChatProviderId | null {
|
||||||
const trimmed = (raw ?? "").trim().toLowerCase();
|
const normalized = normalizeMessageProvider(raw);
|
||||||
if (!trimmed) return null;
|
if (!normalized) return null;
|
||||||
const normalized = CHAT_PROVIDER_ALIASES[trimmed] ?? trimmed;
|
|
||||||
return CHAT_PROVIDER_ORDER.includes(normalized as ChatProviderId)
|
return CHAT_PROVIDER_ORDER.includes(normalized as ChatProviderId)
|
||||||
? (normalized as ChatProviderId)
|
? (normalized as ChatProviderId)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -8,17 +8,7 @@ export function normalizeMessageProvider(
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GatewayMessageProvider =
|
export const DELIVERABLE_MESSAGE_PROVIDERS = [
|
||||||
| "whatsapp"
|
|
||||||
| "telegram"
|
|
||||||
| "discord"
|
|
||||||
| "slack"
|
|
||||||
| "signal"
|
|
||||||
| "imessage"
|
|
||||||
| "msteams"
|
|
||||||
| "webchat";
|
|
||||||
|
|
||||||
const GATEWAY_MESSAGE_PROVIDERS: GatewayMessageProvider[] = [
|
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
"telegram",
|
"telegram",
|
||||||
"discord",
|
"discord",
|
||||||
@@ -26,13 +16,48 @@ const GATEWAY_MESSAGE_PROVIDERS: GatewayMessageProvider[] = [
|
|||||||
"signal",
|
"signal",
|
||||||
"imessage",
|
"imessage",
|
||||||
"msteams",
|
"msteams",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DeliverableMessageProvider =
|
||||||
|
(typeof DELIVERABLE_MESSAGE_PROVIDERS)[number];
|
||||||
|
|
||||||
|
export const INTERNAL_MESSAGE_PROVIDER = "webchat" as const;
|
||||||
|
export type InternalMessageProvider = typeof INTERNAL_MESSAGE_PROVIDER;
|
||||||
|
|
||||||
|
export type GatewayMessageProvider =
|
||||||
|
| DeliverableMessageProvider
|
||||||
|
| InternalMessageProvider;
|
||||||
|
|
||||||
|
export const GATEWAY_MESSAGE_PROVIDERS = [
|
||||||
|
...DELIVERABLE_MESSAGE_PROVIDERS,
|
||||||
"webchat",
|
"webchat",
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
|
export const GATEWAY_AGENT_PROVIDER_ALIASES = ["imsg", "teams"] as const;
|
||||||
|
export type GatewayAgentProviderAlias =
|
||||||
|
(typeof GATEWAY_AGENT_PROVIDER_ALIASES)[number];
|
||||||
|
|
||||||
|
export type GatewayAgentProviderHint =
|
||||||
|
| GatewayMessageProvider
|
||||||
|
| "last"
|
||||||
|
| GatewayAgentProviderAlias;
|
||||||
|
|
||||||
|
export const GATEWAY_AGENT_PROVIDER_VALUES = [
|
||||||
|
...GATEWAY_MESSAGE_PROVIDERS,
|
||||||
|
"last",
|
||||||
|
...GATEWAY_AGENT_PROVIDER_ALIASES,
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function isGatewayMessageProvider(
|
export function isGatewayMessageProvider(
|
||||||
value: string,
|
value: string,
|
||||||
): value is GatewayMessageProvider {
|
): value is GatewayMessageProvider {
|
||||||
return (GATEWAY_MESSAGE_PROVIDERS as string[]).includes(value);
|
return (GATEWAY_MESSAGE_PROVIDERS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDeliverableMessageProvider(
|
||||||
|
value: string,
|
||||||
|
): value is DeliverableMessageProvider {
|
||||||
|
return (DELIVERABLE_MESSAGE_PROVIDERS as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveGatewayMessageProvider(
|
export function resolveGatewayMessageProvider(
|
||||||
|
|||||||
Reference in New Issue
Block a user