diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d63ff67c..078d5c6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - 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: `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 - 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`. diff --git a/src/agents/lanes.ts b/src/agents/lanes.ts new file mode 100644 index 000000000..1dba87a60 --- /dev/null +++ b/src/agents/lanes.ts @@ -0,0 +1,2 @@ +export const AGENT_LANE_NESTED = "nested" as const; +export const AGENT_LANE_SUBAGENT = "subagent" as const; diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index cc31af1fd..646d870f4 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -8,6 +8,8 @@ import { resolveStorePath, } from "../config/sessions.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 { resolveAnnounceTarget } from "./tools/sessions-announce-target.js"; import { isAnnounceSkip } from "./tools/sessions-send-helpers.js"; @@ -241,7 +243,8 @@ export async function runSubagentAnnounceFlow(params: { message: "Sub-agent announce step.", extraSystemPrompt: announcePrompt, timeoutMs: params.timeoutMs, - lane: "nested", + provider: INTERNAL_MESSAGE_PROVIDER, + lane: AGENT_LANE_NESTED, }); if ( diff --git a/src/agents/tools/agent-step.ts b/src/agents/tools/agent-step.ts index bcf1609e8..3af7c7407 100644 --- a/src/agents/tools/agent-step.ts +++ b/src/agents/tools/agent-step.ts @@ -1,6 +1,8 @@ import crypto from "node:crypto"; 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"; export async function readLatestAssistantReply(params: { @@ -34,8 +36,8 @@ export async function runAgentStep(params: { sessionKey: params.sessionKey, idempotencyKey: stepIdem, deliver: false, - provider: params.provider ?? "webchat", - lane: params.lane ?? "nested", + provider: params.provider ?? INTERNAL_MESSAGE_PROVIDER, + lane: params.lane ?? AGENT_LANE_NESTED, extraSystemPrompt: params.extraSystemPrompt, }, timeoutMs: 10_000, diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 6e3c285b3..55561e947 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -10,7 +10,11 @@ import { parseAgentSessionKey, } from "../../routing/session-key.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 type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; @@ -297,8 +301,8 @@ export function createSessionsSendTool(opts?: { sessionKey: resolvedKey, idempotencyKey, deliver: false, - provider: "webchat", - lane: "nested", + provider: INTERNAL_MESSAGE_PROVIDER, + lane: AGENT_LANE_NESTED, extraSystemPrompt: agentMessageContext, }; const requesterSessionKey = opts?.agentSessionKey; @@ -362,7 +366,7 @@ export function createSessionsSendTool(opts?: { message: incomingMessage, extraSystemPrompt: replyPrompt, timeoutMs: announceTimeoutMs, - lane: "nested", + lane: AGENT_LANE_NESTED, }); if (!replyText || isReplySkip(replyText)) { break; @@ -388,7 +392,7 @@ export function createSessionsSendTool(opts?: { message: "Agent-to-agent announce step.", extraSystemPrompt: announcePrompt, timeoutMs: announceTimeoutMs, - lane: "nested", + lane: AGENT_LANE_NESTED, }); if ( announceTarget && diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 204cb72b6..ab4ed9b9f 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -11,6 +11,7 @@ import { } from "../../routing/session-key.js"; import type { GatewayMessageProvider } from "../../utils/message-provider.js"; import { resolveAgentConfig } from "../agent-scope.js"; +import { AGENT_LANE_SUBAGENT } from "../lanes.js"; import { buildSubagentSystemPrompt } from "../subagent-announce.js"; import { registerSubagentRun } from "../subagent-registry.js"; import type { AnyAgentTool } from "./common.js"; @@ -174,7 +175,7 @@ export function createSessionsSpawnTool(opts?: { provider: opts?.agentProvider, idempotencyKey: childIdem, deliver: false, - lane: "subagent", + lane: AGENT_LANE_SUBAGENT, extraSystemPrompt: childSystemPrompt, timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined, label: label || undefined, diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 6dd8e8ba1..ae00cd130 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -24,6 +24,7 @@ import { resolveDefaultIMessageAccountId, resolveIMessageAccount, } from "../imessage/accounts.js"; +import { resolveMSTeamsCredentials } from "../msteams/token.js"; import { type ChatProviderId, 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; } @@ -584,6 +599,8 @@ function resolveDefaultAccountId( return resolveDefaultSignalAccountId(cfg) || DEFAULT_ACCOUNT_ID; case "imessage": return resolveDefaultIMessageAccountId(cfg) || DEFAULT_ACCOUNT_ID; + case "msteams": + return DEFAULT_ACCOUNT_ID; } } diff --git a/src/commands/providers/list.ts b/src/commands/providers/list.ts index 5f1c41cd3..777c6d36d 100644 --- a/src/commands/providers/list.ts +++ b/src/commands/providers/list.ts @@ -16,10 +16,12 @@ import { formatUsageReportLines, loadProviderUsageSummary, } from "../../infra/provider-usage.js"; +import { resolveMSTeamsCredentials } from "../../msteams/token.js"; import { type ChatProviderId, listChatProviders, } from "../../providers/registry.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { listSignalAccountIds, @@ -104,6 +106,7 @@ export async function providersListCommand( slack: listSlackAccountIds(cfg), signal: listSignalAccountIds(cfg), imessage: listIMessageAccountIds(cfg), + msteams: [DEFAULT_ACCOUNT_ID], }; const lineBuilders: Record< @@ -194,6 +197,19 @@ export async function providersListCommand( }); 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(); @@ -217,6 +233,7 @@ export async function providersListCommand( slack: accountIdsByProvider.slack, signal: accountIdsByProvider.signal, imessage: accountIdsByProvider.imessage, + msteams: accountIdsByProvider.msteams, }, auth: authProfiles, ...(usage ? { usage } : {}), diff --git a/src/commands/providers/remove.ts b/src/commands/providers/remove.ts index 2f47b9f8b..f6ecd25be 100644 --- a/src/commands/providers/remove.ts +++ b/src/commands/providers/remove.ts @@ -42,6 +42,8 @@ function listAccountIds(cfg: ClawdbotConfig, provider: ChatProvider): string[] { return listSignalAccountIds(cfg); case "imessage": return listIMessageAccountIds(cfg); + case "msteams": + return [DEFAULT_ACCOUNT_ID]; } } diff --git a/src/commands/providers/status.ts b/src/commands/providers/status.ts index 5039c53d9..1cec442f0 100644 --- a/src/commands/providers/status.ts +++ b/src/commands/providers/status.ts @@ -14,7 +14,9 @@ import { } from "../../imessage/accounts.js"; import { formatAge } from "../../infra/provider-summary.js"; import { collectProvidersStatusIssues } from "../../infra/providers-status-issues.js"; +import { resolveMSTeamsCredentials } from "../../msteams/token.js"; import { listChatProviders } from "../../providers/registry.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { listSignalAccountIds, @@ -340,6 +342,18 @@ async function formatConfigProvidersStatusLines( 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>>>; // WhatsApp linked info (config-only best-effort). diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index a76734337..e4515c157 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -1,5 +1,6 @@ import { type Static, type TSchema, Type } from "@sinclair/typebox"; 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 SessionLabelString = Type.String({ @@ -7,6 +8,10 @@ const SessionLabelString = Type.String({ maxLength: SESSION_LABEL_MAX_LENGTH, }); +const AgentProviderSchema = Type.Union( + GATEWAY_AGENT_PROVIDER_VALUES.map((provider) => Type.Literal(provider)), +); + export const PresenceEntrySchema = Type.Object( { host: Type.Optional(NonEmptyString), @@ -225,7 +230,7 @@ export const AgentParamsSchema = Type.Object( sessionKey: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), deliver: Type.Optional(Type.Boolean()), - provider: Type.Optional(Type.String()), + provider: Type.Optional(AgentProviderSchema), timeout: Type.Optional(Type.Integer({ minimum: 0 })), lane: Type.Optional(Type.String()), extraSystemPrompt: Type.Optional(Type.String()), diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 7f0bda404..d54023a3f 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -12,7 +12,12 @@ import { registerAgentRunContext } from "../../infra/agent-events.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.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 { type AgentWaitParams, @@ -161,21 +166,18 @@ export const agentHandlers: GatewayRequestHandlers = { if (requestedProvider === "last") { // WebChat is not a deliverable surface. Treat it as "unset" for routing, // so VoiceWake and CLI callers don't get stuck with deliver=false. - if (lastProvider && lastProvider !== "webchat") return lastProvider; - return wantsDelivery ? "whatsapp" : "webchat"; + if (lastProvider && lastProvider !== INTERNAL_MESSAGE_PROVIDER) { + return lastProvider; + } + return wantsDelivery ? "whatsapp" : INTERNAL_MESSAGE_PROVIDER; } - if ( - requestedProvider === "whatsapp" || - requestedProvider === "telegram" || - requestedProvider === "discord" || - requestedProvider === "signal" || - requestedProvider === "imessage" || - requestedProvider === "webchat" - ) { - return requestedProvider; + + if (isGatewayMessageProvider(requestedProvider)) return requestedProvider; + + if (lastProvider && lastProvider !== INTERNAL_MESSAGE_PROVIDER) { + return lastProvider; } - if (lastProvider && lastProvider !== "webchat") return lastProvider; - return wantsDelivery ? "whatsapp" : "webchat"; + return wantsDelivery ? "whatsapp" : INTERNAL_MESSAGE_PROVIDER; })(); const resolvedTo = (() => { @@ -184,13 +186,7 @@ export const agentHandlers: GatewayRequestHandlers = { ? request.to.trim() : undefined; if (explicit) return explicit; - if ( - resolvedProvider === "whatsapp" || - resolvedProvider === "telegram" || - resolvedProvider === "discord" || - resolvedProvider === "signal" || - resolvedProvider === "imessage" - ) { + if (isDeliverableMessageProvider(resolvedProvider)) { return lastTo || undefined; } return undefined; @@ -225,7 +221,9 @@ export const agentHandlers: GatewayRequestHandlers = { return allowFrom[0]; })(); - const deliver = request.deliver === true && resolvedProvider !== "webchat"; + const deliver = + request.deliver === true && + resolvedProvider !== INTERNAL_MESSAGE_PROVIDER; const accepted = { runId, diff --git a/src/gateway/server.agent.test.ts b/src/gateway/server.agent.test.ts index 0b2095b7c..1186b5435 100644 --- a/src/gateway/server.agent.test.ts +++ b/src/gateway/server.agent.test.ts @@ -286,6 +286,50 @@ describe("gateway server agent", () => { 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; + 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 () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); @@ -330,6 +374,125 @@ describe("gateway server agent", () => { 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; + 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; + 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 () => { testState.allowFrom = ["+1555"]; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); diff --git a/src/infra/outbound/provider-selection.ts b/src/infra/outbound/provider-selection.ts index b63d9c752..069e6ff86 100644 --- a/src/infra/outbound/provider-selection.ts +++ b/src/infra/outbound/provider-selection.ts @@ -5,34 +5,23 @@ import { resolveMSTeamsCredentials } from "../../msteams/token.js"; import { listEnabledSignalAccounts } from "../../signal/accounts.js"; import { listEnabledSlackAccounts } from "../../slack/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 { listEnabledWhatsAppAccounts, resolveWhatsAppAccount, } from "../../web/accounts.js"; import { webAuthExists } from "../../web/session.js"; -export type MessageProviderId = - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams"; +export type MessageProviderId = DeliverableMessageProvider; -const MESSAGE_PROVIDERS: MessageProviderId[] = [ - "whatsapp", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "msteams", -]; +const MESSAGE_PROVIDERS = [...DELIVERABLE_MESSAGE_PROVIDERS]; 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 { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 1d784e592..60cefaa73 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,16 +1,12 @@ import type { ClawdbotConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; +import type { + DeliverableMessageProvider, + GatewayMessageProvider, +} from "../../utils/message-provider.js"; import { normalizeE164 } from "../../utils.js"; -export type OutboundProvider = - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams" - | "none"; +export type OutboundProvider = DeliverableMessageProvider | "none"; export type HeartbeatTarget = OutboundProvider | "last"; @@ -25,15 +21,7 @@ export type OutboundTargetResolution = | { ok: false; error: Error }; export function resolveOutboundTarget(params: { - provider: - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams" - | "webchat"; + provider: GatewayMessageProvider; to?: string; allowFrom?: string[]; }): OutboundTargetResolution { diff --git a/src/providers/registry.test.ts b/src/providers/registry.test.ts index e47c58450..805232ad1 100644 --- a/src/providers/registry.test.ts +++ b/src/providers/registry.test.ts @@ -9,6 +9,7 @@ import { describe("provider registry", () => { it("normalizes aliases", () => { expect(normalizeChatProviderId("imsg")).toBe("imessage"); + expect(normalizeChatProviderId("teams")).toBe("msteams"); }); it("keeps Telegram first in the default order", () => { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 85cb33b90..d21f29269 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,3 +1,5 @@ +import { normalizeMessageProvider } from "../utils/message-provider.js"; + export const CHAT_PROVIDER_ORDER = [ "telegram", "whatsapp", @@ -5,6 +7,7 @@ export const CHAT_PROVIDER_ORDER = [ "slack", "signal", "imessage", + "msteams", ] as const; export type ChatProviderId = (typeof CHAT_PROVIDER_ORDER)[number]; @@ -69,10 +72,14 @@ const CHAT_PROVIDER_META: Record = { docsLabel: "imessage", blurb: "this is still a work in progress.", }, -}; - -const CHAT_PROVIDER_ALIASES: Record = { - imsg: "imessage", + msteams: { + id: "msteams", + label: "MS Teams", + selectionLabel: "Microsoft Teams (Bot Framework)", + docsPath: "/msteams", + docsLabel: "msteams", + blurb: "supported (Bot Framework).", + }, }; const WEBSITE_URL = "https://clawd.bot"; @@ -88,9 +95,8 @@ export function getChatProviderMeta(id: ChatProviderId): ChatProviderMeta { export function normalizeChatProviderId( raw?: string | null, ): ChatProviderId | null { - const trimmed = (raw ?? "").trim().toLowerCase(); - if (!trimmed) return null; - const normalized = CHAT_PROVIDER_ALIASES[trimmed] ?? trimmed; + const normalized = normalizeMessageProvider(raw); + if (!normalized) return null; return CHAT_PROVIDER_ORDER.includes(normalized as ChatProviderId) ? (normalized as ChatProviderId) : null; diff --git a/src/utils/message-provider.ts b/src/utils/message-provider.ts index c925e8a7a..6da32f764 100644 --- a/src/utils/message-provider.ts +++ b/src/utils/message-provider.ts @@ -8,17 +8,7 @@ export function normalizeMessageProvider( return normalized; } -export type GatewayMessageProvider = - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams" - | "webchat"; - -const GATEWAY_MESSAGE_PROVIDERS: GatewayMessageProvider[] = [ +export const DELIVERABLE_MESSAGE_PROVIDERS = [ "whatsapp", "telegram", "discord", @@ -26,13 +16,48 @@ const GATEWAY_MESSAGE_PROVIDERS: GatewayMessageProvider[] = [ "signal", "imessage", "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", -]; +] 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( value: string, ): 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(