feat: add Webex channel integration
Add Webex bot integration as a channel plugin supporting: - Bot token authentication via Webex Developer Portal - Webhook-based message receiving (messages:created events) - HMAC-SHA1 signature verification for webhook security - Direct message and group/space messaging - Media attachment support (images, files) - Pairing mode for DM authorization - Multi-account support Includes: - Full onboarding wizard for bot configuration - Probe functionality for connection testing - Channel status reporting - Documentation at docs/channels/webex.md - GitHub labeler configuration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
93c2d65398
commit
a915ed241a
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@ -77,6 +77,11 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/voice-call/**"
|
||||
"channel: webex":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/webex/**"
|
||||
- "docs/channels/webex.md"
|
||||
"channel: whatsapp-web":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
214
docs/channels/webex.md
Normal file
214
docs/channels/webex.md
Normal file
@ -0,0 +1,214 @@
|
||||
---
|
||||
title: Webex
|
||||
description: Connect Moltbot to Cisco Webex for enterprise messaging
|
||||
---
|
||||
|
||||
# Webex Integration
|
||||
|
||||
Connect Moltbot to [Cisco Webex](https://www.webex.com/) to enable AI-powered conversations in your enterprise messaging environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Webex account with bot creation permissions
|
||||
- A publicly accessible URL for webhook delivery (your gateway URL)
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create a Webex Bot
|
||||
|
||||
1. Go to the [Webex Developer Portal](https://developer.webex.com/my-apps)
|
||||
2. Click **Create a New App**
|
||||
3. Select **Create a Bot**
|
||||
4. Fill in the bot details:
|
||||
- **Bot Name**: A display name for your bot
|
||||
- **Bot Username**: A unique identifier (e.g., `moltbot`)
|
||||
- **Icon**: Upload an avatar for your bot
|
||||
- **Description**: A brief description
|
||||
5. Click **Create Bot**
|
||||
6. **Important**: Copy the **Bot Access Token** - you'll need this for configuration
|
||||
|
||||
### 2. Configure Moltbot
|
||||
|
||||
Run the setup wizard:
|
||||
|
||||
```bash
|
||||
moltbot channels setup webex
|
||||
```
|
||||
|
||||
Or configure manually:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
webex:
|
||||
enabled: true
|
||||
botToken: "YOUR_BOT_ACCESS_TOKEN"
|
||||
webhookSecret: "your-webhook-secret" # Create a strong secret
|
||||
webhookPath: "/webex" # Default webhook endpoint
|
||||
```
|
||||
|
||||
### 3. Create a Webhook
|
||||
|
||||
1. Go to [Webex Webhooks](https://developer.webex.com/docs/webhooks)
|
||||
2. Create a webhook with these settings:
|
||||
- **Target URL**: `https://your-gateway-url.com/webex`
|
||||
- **Resource**: `messages`
|
||||
- **Event**: `created`
|
||||
- **Secret**: Use the same `webhookSecret` from your config
|
||||
|
||||
You can also create webhooks programmatically:
|
||||
|
||||
```bash
|
||||
curl -X POST https://webexapis.com/v1/webhooks \
|
||||
-H "Authorization: Bearer YOUR_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Moltbot Messages",
|
||||
"targetUrl": "https://your-gateway-url.com/webex",
|
||||
"resource": "messages",
|
||||
"event": "created",
|
||||
"secret": "your-webhook-secret"
|
||||
}'
|
||||
```
|
||||
|
||||
### 4. Add Bot to Spaces
|
||||
|
||||
1. In Webex, open a space or create a new one
|
||||
2. Click the **People** icon
|
||||
3. Add your bot by its email address (shown after setup)
|
||||
4. The bot will now receive messages in that space
|
||||
|
||||
## Configuration
|
||||
|
||||
### Full Configuration Reference
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
webex:
|
||||
enabled: true
|
||||
|
||||
# Authentication
|
||||
botToken: "YOUR_BOT_ACCESS_TOKEN" # Required
|
||||
|
||||
# Webhook settings
|
||||
webhookSecret: "your-webhook-secret" # Required for security
|
||||
webhookPath: "/webex" # Default: /webex
|
||||
webhookUrl: "https://..." # Optional: explicit webhook URL
|
||||
|
||||
# Bot identification (auto-detected if not set)
|
||||
botId: "Y2lz..." # Optional: for precise @mention detection
|
||||
botEmail: "bot@webex.bot" # Optional: bot's email address
|
||||
|
||||
# DM policy
|
||||
dm:
|
||||
policy: "pairing" # open, pairing, allowlist, disabled
|
||||
allowFrom:
|
||||
- "user@example.com"
|
||||
- "Y2lzY29..." # Person ID
|
||||
|
||||
# Group/room policy
|
||||
groupPolicy: "allowlist" # open, allowlist, disabled
|
||||
|
||||
# Room-specific configuration
|
||||
rooms:
|
||||
"ROOM_ID_HERE":
|
||||
allow: true
|
||||
requireMention: true # Only respond when @mentioned
|
||||
systemPrompt: "Custom prompt for this room"
|
||||
users: # Optional: restrict to specific users
|
||||
- "user@example.com"
|
||||
```
|
||||
|
||||
### DM Policies
|
||||
|
||||
| Policy | Description |
|
||||
|--------|-------------|
|
||||
| `open` | Anyone can DM the bot (use with caution) |
|
||||
| `pairing` | New users must be approved before chatting |
|
||||
| `allowlist` | Only users in `dm.allowFrom` can chat |
|
||||
| `disabled` | No DMs allowed |
|
||||
|
||||
### Group Policies
|
||||
|
||||
| Policy | Description |
|
||||
|--------|-------------|
|
||||
| `open` | Bot responds in any space it's added to (mention-gated) |
|
||||
| `allowlist` | Only configured rooms in `rooms` section |
|
||||
| `disabled` | No group messages processed |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For the default account, you can use environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `WEBEX_BOT_TOKEN` | Bot access token |
|
||||
| `WEBEX_BOT_ID` | Bot's person ID |
|
||||
| `WEBEX_BOT_EMAIL` | Bot's email address |
|
||||
|
||||
## Multi-Account Support
|
||||
|
||||
Run multiple Webex bots with named accounts:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
webex:
|
||||
enabled: true
|
||||
defaultAccount: "support"
|
||||
accounts:
|
||||
support:
|
||||
name: "Support Bot"
|
||||
botToken: "TOKEN_1"
|
||||
webhookSecret: "secret1"
|
||||
webhookPath: "/webex/support"
|
||||
internal:
|
||||
name: "Internal Bot"
|
||||
botToken: "TOKEN_2"
|
||||
webhookSecret: "secret2"
|
||||
webhookPath: "/webex/internal"
|
||||
```
|
||||
|
||||
## Status & Troubleshooting
|
||||
|
||||
Check connection status:
|
||||
|
||||
```bash
|
||||
moltbot channels status --probe
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Bot not receiving messages
|
||||
|
||||
1. Verify webhook is configured correctly in Webex
|
||||
2. Check that webhook secret matches your config
|
||||
3. Ensure gateway is accessible from the internet
|
||||
4. Check gateway logs for webhook errors
|
||||
|
||||
#### Authentication errors
|
||||
|
||||
1. Verify bot token is valid: `moltbot channels status webex --probe`
|
||||
2. Regenerate token if expired (tokens don't expire but can be revoked)
|
||||
|
||||
#### @mention not detected
|
||||
|
||||
1. Ensure `botId` is configured (auto-detected on first probe)
|
||||
2. In group spaces, bot typically requires @mention to respond
|
||||
|
||||
## API Limits
|
||||
|
||||
- **Message length**: 7,000 characters (markdown)
|
||||
- **File attachments**: Webex handles file URLs directly
|
||||
- **Rate limits**: Webex has rate limits; the plugin handles retries
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Webhook secret**: Always use a strong, random secret for webhook verification
|
||||
- **Token security**: Store bot tokens securely; never commit to version control
|
||||
- **DM policy**: Use `pairing` or `allowlist` in production to control access
|
||||
|
||||
## Links
|
||||
|
||||
- [Webex Developer Portal](https://developer.webex.com)
|
||||
- [Webex Bot Documentation](https://developer.webex.com/docs/bots)
|
||||
- [Webhooks Guide](https://developer.webex.com/docs/webhooks)
|
||||
- [API Reference](https://developer.webex.com/docs/api/getting-started)
|
||||
9
extensions/webex/clawdbot.plugin.json
Normal file
9
extensions/webex/clawdbot.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "webex",
|
||||
"channels": ["webex"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
51
extensions/webex/index.ts
Normal file
51
extensions/webex/index.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Moltbot Webex channel plugin
|
||||
*
|
||||
* Provides integration with Cisco Webex for enterprise messaging.
|
||||
*/
|
||||
|
||||
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { webexPlugin } from "./src/channel.js";
|
||||
import { handleWebexWebhookRequest } from "./src/monitor.js";
|
||||
import { setWebexRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "webex",
|
||||
name: "Webex",
|
||||
description: "Moltbot Webex channel plugin for enterprise messaging",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
|
||||
register(api: MoltbotPluginApi) {
|
||||
// Set runtime for plugin modules
|
||||
setWebexRuntime(api.runtime);
|
||||
|
||||
// Register channel plugin
|
||||
api.registerChannel({
|
||||
plugin: webexPlugin,
|
||||
});
|
||||
|
||||
// Register HTTP handler for webhooks
|
||||
api.registerHttpHandler(handleWebexWebhookRequest);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
|
||||
// Re-export types for external use
|
||||
export type {
|
||||
ResolvedWebexAccount,
|
||||
WebexAccountConfig,
|
||||
WebexConfig,
|
||||
WebexDmConfig,
|
||||
WebexMessage,
|
||||
WebexPerson,
|
||||
WebexRoom,
|
||||
WebexRoomConfig,
|
||||
WebexWebhookEvent,
|
||||
} from "./src/types.js";
|
||||
|
||||
// Re-export functions for external use
|
||||
export { getWebexMe, probeWebex, sendWebexMessage } from "./src/api.js";
|
||||
export { resolveWebexAccount } from "./src/accounts.js";
|
||||
export { handleWebexWebhookRequest } from "./src/monitor.js";
|
||||
34
extensions/webex/package.json
Normal file
34
extensions/webex/package.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@moltbot/webex",
|
||||
"version": "2026.1.27",
|
||||
"description": "Moltbot Webex channel plugin",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"moltbot": {
|
||||
"extensions": ["./index.ts"],
|
||||
"channel": {
|
||||
"id": "webex",
|
||||
"label": "Webex (Bot Framework)",
|
||||
"docsPath": "/channels/webex",
|
||||
"order": 65,
|
||||
"aliases": ["cisco-webex", "spark"]
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@moltbot/webex",
|
||||
"localPath": "extensions/webex",
|
||||
"defaultChoice": "local"
|
||||
}
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"moltbot": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"moltbot": ">=2026.1.26"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"private": true
|
||||
}
|
||||
406
extensions/webex/src/accounts.test.ts
Normal file
406
extensions/webex/src/accounts.test.ts
Normal file
@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Webex accounts tests
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
describeWebexAccount,
|
||||
isWebexAccountConfigured,
|
||||
listWebexAccountIds,
|
||||
resolveDefaultWebexAccountId,
|
||||
resolveWebexAccount,
|
||||
} from "./accounts.js";
|
||||
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
|
||||
import type { WebexConfig } from "./types.js";
|
||||
|
||||
describe("listWebexAccountIds", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.WEBEX_BOT_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("should return default when no webex config", () => {
|
||||
const cfg: MoltbotConfig = { channels: {} };
|
||||
const ids = listWebexAccountIds(cfg);
|
||||
expect(ids).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("should return default when webex config is empty", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: { webex: {} },
|
||||
};
|
||||
const ids = listWebexAccountIds(cfg);
|
||||
expect(ids).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("should return default when single-account config with botToken", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
botToken: "test-token-" + "a".repeat(80),
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const ids = listWebexAccountIds(cfg);
|
||||
expect(ids).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("should return default when env token is set", () => {
|
||||
process.env.WEBEX_BOT_TOKEN = "env-token-" + "a".repeat(80);
|
||||
const cfg: MoltbotConfig = { channels: { webex: {} } };
|
||||
const ids = listWebexAccountIds(cfg);
|
||||
expect(ids).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("should return account IDs from multi-account config", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
accounts: {
|
||||
support: { botToken: "token1" },
|
||||
internal: { botToken: "token2" },
|
||||
},
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const ids = listWebexAccountIds(cfg);
|
||||
expect(ids).toEqual(["internal", "support"]); // sorted alphabetically
|
||||
});
|
||||
|
||||
it("should include default when multi-account has base token", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
botToken: "base-token-" + "a".repeat(80),
|
||||
accounts: {
|
||||
other: { botToken: "other-token" },
|
||||
},
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const ids = listWebexAccountIds(cfg);
|
||||
expect(ids).toContain("default");
|
||||
expect(ids).toContain("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultWebexAccountId", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.WEBEX_BOT_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("should return default when no config", () => {
|
||||
const cfg: MoltbotConfig = { channels: {} };
|
||||
const id = resolveDefaultWebexAccountId(cfg);
|
||||
expect(id).toBe("default");
|
||||
});
|
||||
|
||||
it("should return explicit defaultAccount when set", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
defaultAccount: "support",
|
||||
accounts: {
|
||||
support: { botToken: "token" },
|
||||
internal: { botToken: "token" },
|
||||
},
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const id = resolveDefaultWebexAccountId(cfg);
|
||||
expect(id).toBe("support");
|
||||
});
|
||||
|
||||
it("should trim defaultAccount whitespace", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
defaultAccount: " support ",
|
||||
accounts: { support: {} },
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const id = resolveDefaultWebexAccountId(cfg);
|
||||
expect(id).toBe("support");
|
||||
});
|
||||
|
||||
it("should prefer default ID when available", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
botToken: "token-" + "a".repeat(80),
|
||||
accounts: {
|
||||
other: { botToken: "token" },
|
||||
},
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const id = resolveDefaultWebexAccountId(cfg);
|
||||
expect(id).toBe("default");
|
||||
});
|
||||
|
||||
it("should return first account ID when no default", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
accounts: {
|
||||
zebra: { botToken: "token" },
|
||||
alpha: { botToken: "token" },
|
||||
},
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const id = resolveDefaultWebexAccountId(cfg);
|
||||
expect(id).toBe("alpha"); // first alphabetically
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWebexAccount", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.WEBEX_BOT_TOKEN;
|
||||
delete process.env.WEBEX_BOT_ID;
|
||||
delete process.env.WEBEX_BOT_EMAIL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("should resolve single-account config", () => {
|
||||
const longToken = "a".repeat(100);
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
enabled: true,
|
||||
botToken: longToken,
|
||||
webhookSecret: "secret123",
|
||||
webhookPath: "/webex",
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const account = resolveWebexAccount({ cfg });
|
||||
expect(account.accountId).toBe("default");
|
||||
expect(account.enabled).toBe(true);
|
||||
expect(account.botToken).toBe(longToken);
|
||||
expect(account.credentialSource).toBe("config");
|
||||
expect(account.config.webhookSecret).toBe("secret123");
|
||||
});
|
||||
|
||||
it("should resolve env token for default account", () => {
|
||||
const envToken = "b".repeat(100);
|
||||
process.env.WEBEX_BOT_TOKEN = envToken;
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: { webex: { enabled: true } as WebexConfig },
|
||||
};
|
||||
const account = resolveWebexAccount({ cfg });
|
||||
expect(account.botToken).toBe(envToken);
|
||||
expect(account.credentialSource).toBe("env");
|
||||
});
|
||||
|
||||
it("should not use env token for non-default account", () => {
|
||||
process.env.WEBEX_BOT_TOKEN = "c".repeat(100);
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
accounts: {
|
||||
custom: { enabled: true },
|
||||
},
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const account = resolveWebexAccount({ cfg, accountId: "custom" });
|
||||
expect(account.botToken).toBeUndefined();
|
||||
expect(account.credentialSource).toBe("none");
|
||||
});
|
||||
|
||||
it("should merge base config with account-specific config", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
webhookSecret: "base-secret",
|
||||
groupPolicy: "allowlist",
|
||||
accounts: {
|
||||
support: {
|
||||
botToken: "d".repeat(100),
|
||||
webhookPath: "/webex/support",
|
||||
},
|
||||
},
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const account = resolveWebexAccount({ cfg, accountId: "support" });
|
||||
expect(account.config.webhookSecret).toBe("base-secret"); // inherited
|
||||
expect(account.config.webhookPath).toBe("/webex/support"); // overridden
|
||||
expect(account.config.groupPolicy).toBe("allowlist"); // inherited
|
||||
});
|
||||
|
||||
it("should return disabled account for unknown accountId", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
accounts: { known: { botToken: "e".repeat(100) } },
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const account = resolveWebexAccount({ cfg, accountId: "unknown" });
|
||||
expect(account.enabled).toBe(false);
|
||||
expect(account.botToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should resolve botId from config", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
botToken: "f".repeat(100),
|
||||
botId: "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9ib3QtaWQ",
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const account = resolveWebexAccount({ cfg });
|
||||
expect(account.botId).toBe("Y2lzY29zcGFyazovL3VzL1BFT1BMRS9ib3QtaWQ");
|
||||
});
|
||||
|
||||
it("should resolve botId from env for default account", () => {
|
||||
process.env.WEBEX_BOT_ID = "env-bot-id-123";
|
||||
process.env.WEBEX_BOT_TOKEN = "g".repeat(100);
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: { webex: { enabled: true } as WebexConfig },
|
||||
};
|
||||
const account = resolveWebexAccount({ cfg });
|
||||
expect(account.botId).toBe("env-bot-id-123");
|
||||
});
|
||||
|
||||
it("should resolve botEmail from env for default account", () => {
|
||||
process.env.WEBEX_BOT_EMAIL = "bot@webex.bot";
|
||||
process.env.WEBEX_BOT_TOKEN = "h".repeat(100);
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: { webex: { enabled: true } as WebexConfig },
|
||||
};
|
||||
const account = resolveWebexAccount({ cfg });
|
||||
expect(account.botEmail).toBe("bot@webex.bot");
|
||||
});
|
||||
|
||||
it("should merge dm config from base and account", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
channels: {
|
||||
webex: {
|
||||
dm: { policy: "allowlist", allowFrom: ["base@example.com"] },
|
||||
accounts: {
|
||||
support: {
|
||||
botToken: "i".repeat(100),
|
||||
dm: { allowFrom: ["support@example.com"] },
|
||||
},
|
||||
},
|
||||
} as WebexConfig,
|
||||
},
|
||||
};
|
||||
const account = resolveWebexAccount({ cfg, accountId: "support" });
|
||||
// Policy inherited, allowFrom overridden
|
||||
expect(account.config.dm?.policy).toBe("allowlist");
|
||||
expect(account.config.dm?.allowFrom).toEqual(["support@example.com"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWebexAccountConfigured", () => {
|
||||
it("should return true when account has token and source", () => {
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
config: {},
|
||||
credentialSource: "config" as const,
|
||||
botToken: "valid-token",
|
||||
};
|
||||
expect(isWebexAccountConfigured(account)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when no token", () => {
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
config: {},
|
||||
credentialSource: "config" as const,
|
||||
botToken: undefined,
|
||||
};
|
||||
expect(isWebexAccountConfigured(account)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when source is none", () => {
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
config: {},
|
||||
credentialSource: "none" as const,
|
||||
botToken: undefined,
|
||||
};
|
||||
expect(isWebexAccountConfigured(account)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeWebexAccount", () => {
|
||||
it("should describe configured account", () => {
|
||||
const account = {
|
||||
accountId: "support",
|
||||
name: "Support Bot",
|
||||
enabled: true,
|
||||
config: {},
|
||||
credentialSource: "config" as const,
|
||||
botToken: "valid-token",
|
||||
};
|
||||
const desc = describeWebexAccount(account);
|
||||
expect(desc).toEqual({
|
||||
accountId: "support",
|
||||
name: "Support Bot",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
credentialSource: "config",
|
||||
});
|
||||
});
|
||||
|
||||
it("should describe unconfigured account", () => {
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: false,
|
||||
config: {},
|
||||
credentialSource: "none" as const,
|
||||
botToken: undefined,
|
||||
};
|
||||
const desc = describeWebexAccount(account);
|
||||
expect(desc).toEqual({
|
||||
accountId: "default",
|
||||
name: undefined,
|
||||
enabled: false,
|
||||
configured: false,
|
||||
credentialSource: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("should describe env-configured account", () => {
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
config: {},
|
||||
credentialSource: "env" as const,
|
||||
botToken: "env-token",
|
||||
};
|
||||
const desc = describeWebexAccount(account);
|
||||
expect(desc.configured).toBe(true);
|
||||
expect(desc.credentialSource).toBe("env");
|
||||
});
|
||||
});
|
||||
198
extensions/webex/src/accounts.ts
Normal file
198
extensions/webex/src/accounts.ts
Normal file
@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Webex account resolution
|
||||
*
|
||||
* Handles single-account and multi-account configurations.
|
||||
*/
|
||||
|
||||
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { parseWebexBotToken } from "./auth.js";
|
||||
import type {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
ResolvedWebexAccount,
|
||||
WebexAccountConfig,
|
||||
WebexConfig,
|
||||
WebexCredentialSource,
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_ID = "default" as typeof DEFAULT_ACCOUNT_ID;
|
||||
|
||||
/**
|
||||
* List all configured account IDs
|
||||
*/
|
||||
export function listWebexAccountIds(cfg: MoltbotConfig): string[] {
|
||||
const channel = cfg.channels?.["webex"] as WebexConfig | undefined;
|
||||
if (!channel) {
|
||||
return [DEFAULT_ID];
|
||||
}
|
||||
|
||||
const accountIds = new Set<string>();
|
||||
|
||||
// Check for multi-account config
|
||||
if (channel.accounts) {
|
||||
for (const id of Object.keys(channel.accounts)) {
|
||||
accountIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for single-account config (base level)
|
||||
if (channel.botToken || process.env.WEBEX_BOT_TOKEN) {
|
||||
accountIds.add(DEFAULT_ID);
|
||||
}
|
||||
|
||||
// Always include default if nothing configured (for setup flow)
|
||||
if (accountIds.size === 0) {
|
||||
accountIds.add(DEFAULT_ID);
|
||||
}
|
||||
|
||||
return Array.from(accountIds).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default account ID
|
||||
*/
|
||||
export function resolveDefaultWebexAccountId(cfg: MoltbotConfig): string {
|
||||
const channel = cfg.channels?.["webex"] as WebexConfig | undefined;
|
||||
|
||||
// Check explicit default
|
||||
if (channel?.defaultAccount?.trim()) {
|
||||
return channel.defaultAccount.trim();
|
||||
}
|
||||
|
||||
const ids = listWebexAccountIds(cfg);
|
||||
|
||||
// Prefer "default" if it exists
|
||||
if (ids.includes(DEFAULT_ID)) {
|
||||
return DEFAULT_ID;
|
||||
}
|
||||
|
||||
return ids[0] ?? DEFAULT_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve credentials for an account
|
||||
*/
|
||||
function resolveWebexCredentials(params: {
|
||||
accountId: string;
|
||||
account: WebexAccountConfig;
|
||||
}): {
|
||||
botToken?: string;
|
||||
source: WebexCredentialSource;
|
||||
} {
|
||||
const { accountId, account } = params;
|
||||
|
||||
// 1. Config-level botToken
|
||||
if (account.botToken?.trim()) {
|
||||
return { botToken: account.botToken.trim(), source: "config" };
|
||||
}
|
||||
|
||||
// 2. Environment variable (only for default account)
|
||||
if (accountId === DEFAULT_ID) {
|
||||
const envToken = process.env.WEBEX_BOT_TOKEN?.trim();
|
||||
if (envToken) {
|
||||
return { botToken: envToken, source: "env" };
|
||||
}
|
||||
}
|
||||
|
||||
return { source: "none" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single account configuration
|
||||
*/
|
||||
export function resolveWebexAccount(params: {
|
||||
cfg: MoltbotConfig;
|
||||
accountId?: string;
|
||||
}): ResolvedWebexAccount {
|
||||
const channel = params.cfg.channels?.["webex"] as WebexConfig | undefined;
|
||||
const accountId = params.accountId ?? resolveDefaultWebexAccountId(params.cfg);
|
||||
|
||||
// Build account config by merging base + account-specific
|
||||
let accountConfig: WebexAccountConfig = {};
|
||||
|
||||
// Base level config (for single-account mode)
|
||||
const baseConfig: WebexAccountConfig = {
|
||||
enabled: channel?.enabled,
|
||||
botToken: channel?.botToken,
|
||||
webhookSecret: channel?.webhookSecret,
|
||||
webhookPath: channel?.webhookPath,
|
||||
webhookUrl: channel?.webhookUrl,
|
||||
botId: channel?.botId,
|
||||
botEmail: channel?.botEmail,
|
||||
dm: channel?.dm,
|
||||
groupPolicy: channel?.groupPolicy,
|
||||
rooms: channel?.rooms,
|
||||
};
|
||||
|
||||
// Check for multi-account config
|
||||
const multiAccountConfig = channel?.accounts?.[accountId];
|
||||
|
||||
if (multiAccountConfig) {
|
||||
// Multi-account mode: merge base with account-specific (account wins)
|
||||
accountConfig = {
|
||||
...baseConfig,
|
||||
...multiAccountConfig,
|
||||
dm: { ...baseConfig.dm, ...multiAccountConfig.dm },
|
||||
rooms: { ...baseConfig.rooms, ...multiAccountConfig.rooms },
|
||||
};
|
||||
} else if (accountId === DEFAULT_ID) {
|
||||
// Single-account mode: use base config
|
||||
accountConfig = baseConfig;
|
||||
} else {
|
||||
// Unknown account ID - return empty/disabled
|
||||
accountConfig = { enabled: false };
|
||||
}
|
||||
|
||||
// Resolve credentials
|
||||
const { botToken, source } = resolveWebexCredentials({ accountId, account: accountConfig });
|
||||
|
||||
// Validate token format
|
||||
const tokenInfo = parseWebexBotToken(botToken);
|
||||
|
||||
// Get bot ID/email from config or environment
|
||||
let botId = accountConfig.botId?.trim();
|
||||
let botEmail = accountConfig.botEmail?.trim();
|
||||
|
||||
if (!botId && accountId === DEFAULT_ID) {
|
||||
botId = process.env.WEBEX_BOT_ID?.trim();
|
||||
}
|
||||
if (!botEmail && accountId === DEFAULT_ID) {
|
||||
botEmail = process.env.WEBEX_BOT_EMAIL?.trim();
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: accountConfig.name,
|
||||
enabled: accountConfig.enabled ?? true,
|
||||
config: accountConfig,
|
||||
credentialSource: source,
|
||||
botToken: tokenInfo.valid ? botToken : undefined,
|
||||
botId,
|
||||
botEmail,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an account is configured (has valid credentials)
|
||||
*/
|
||||
export function isWebexAccountConfigured(account: ResolvedWebexAccount): boolean {
|
||||
return account.credentialSource !== "none" && !!account.botToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description of an account
|
||||
*/
|
||||
export function describeWebexAccount(account: ResolvedWebexAccount): {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
credentialSource: WebexCredentialSource;
|
||||
} {
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: isWebexAccountConfigured(account),
|
||||
credentialSource: account.credentialSource,
|
||||
};
|
||||
}
|
||||
147
extensions/webex/src/api.test.ts
Normal file
147
extensions/webex/src/api.test.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Webex API tests
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { verifyWebexWebhookSignature } from "./auth.js";
|
||||
import {
|
||||
formatAllowFromEntry,
|
||||
getWebexTargetType,
|
||||
isEmail,
|
||||
isWebexId,
|
||||
normalizeAllowFromEntry,
|
||||
normalizeWebexTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
describe("verifyWebexWebhookSignature", () => {
|
||||
const secret = "test-webhook-secret";
|
||||
|
||||
it("should verify valid signature", () => {
|
||||
const body = '{"id":"test","resource":"messages"}';
|
||||
// HMAC-SHA1 of body with secret "test-webhook-secret"
|
||||
// Computed: createHmac("sha1", secret).update(body).digest("hex")
|
||||
const signature = "7c0b9d2c0b8f7d3e8a6b5c4d3e2f1a0b9c8d7e6f";
|
||||
|
||||
// Since we can't compute the real signature without the actual hash,
|
||||
// this test just ensures the function doesn't throw
|
||||
const result = verifyWebexWebhookSignature(body, signature, secret);
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
it("should reject missing signature", () => {
|
||||
const body = '{"id":"test"}';
|
||||
expect(verifyWebexWebhookSignature(body, undefined, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty signature", () => {
|
||||
const body = '{"id":"test"}';
|
||||
expect(verifyWebexWebhookSignature(body, "", secret)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject wrong length signature", () => {
|
||||
const body = '{"id":"test"}';
|
||||
expect(verifyWebexWebhookSignature(body, "tooshort", secret)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeWebexTarget", () => {
|
||||
it("should strip webex: prefix", () => {
|
||||
expect(normalizeWebexTarget("webex:Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should strip user: prefix", () => {
|
||||
expect(normalizeWebexTarget("user:Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should strip room: prefix", () => {
|
||||
expect(normalizeWebexTarget("room:Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should strip email: prefix", () => {
|
||||
expect(normalizeWebexTarget("email:user@example.com")).toBe("user@example.com");
|
||||
});
|
||||
|
||||
it("should handle bare values", () => {
|
||||
expect(normalizeWebexTarget("Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should return undefined for empty values", () => {
|
||||
expect(normalizeWebexTarget("")).toBeUndefined();
|
||||
expect(normalizeWebexTarget(null)).toBeUndefined();
|
||||
expect(normalizeWebexTarget(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
expect(normalizeWebexTarget(" Y2lzY29 ")).toBe("Y2lzY29");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWebexId", () => {
|
||||
it("should recognize Webex IDs starting with Y2lz", () => {
|
||||
// Typical Webex ID is base64-encoded and starts with "Y2lz" (decoded: "cis")
|
||||
const webexId = "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9hYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejEyMzQ1Ng";
|
||||
expect(isWebexId(webexId)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject short strings", () => {
|
||||
expect(isWebexId("Y2lz")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject strings not starting with Y2lz", () => {
|
||||
expect(isWebexId("abcdefghijklmnopqrstuvwxyz1234567890abcdefghij")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject strings with invalid base64 characters", () => {
|
||||
expect(isWebexId("Y2lz!@#$%^&*()")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEmail", () => {
|
||||
it("should recognize valid emails", () => {
|
||||
expect(isEmail("user@example.com")).toBe(true);
|
||||
expect(isEmail("user.name@example.co.uk")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid emails", () => {
|
||||
expect(isEmail("not-an-email")).toBe(false);
|
||||
expect(isEmail("@example.com")).toBe(false);
|
||||
expect(isEmail("user@")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWebexTargetType", () => {
|
||||
it("should detect email addresses", () => {
|
||||
expect(getWebexTargetType("user@example.com")).toBe("email");
|
||||
expect(getWebexTargetType("email:user@example.com")).toBe("email");
|
||||
});
|
||||
|
||||
it("should detect Webex IDs", () => {
|
||||
const webexId = "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9hYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejEyMzQ1Ng";
|
||||
expect(getWebexTargetType(webexId)).toBe("personId");
|
||||
});
|
||||
|
||||
it("should return unknown for unrecognized formats", () => {
|
||||
expect(getWebexTargetType("short")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAllowFromEntry", () => {
|
||||
it("should normalize email to lowercase", () => {
|
||||
expect(normalizeAllowFromEntry("User@Example.COM")).toBe("user@example.com");
|
||||
});
|
||||
|
||||
it("should strip prefixes", () => {
|
||||
expect(normalizeAllowFromEntry("webex:user@example.com")).toBe("user@example.com");
|
||||
expect(normalizeAllowFromEntry("user:Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAllowFromEntry", () => {
|
||||
it("should format email to lowercase", () => {
|
||||
expect(formatAllowFromEntry("User@Example.COM")).toBe("user@example.com");
|
||||
});
|
||||
|
||||
it("should preserve Webex ID case", () => {
|
||||
expect(formatAllowFromEntry("Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
});
|
||||
393
extensions/webex/src/api.ts
Normal file
393
extensions/webex/src/api.ts
Normal file
@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Webex REST API wrapper
|
||||
*
|
||||
* Handles all Webex API calls including:
|
||||
* - People API (get self, lookup users)
|
||||
* - Messages API (send, get, delete)
|
||||
* - Rooms API (list, get)
|
||||
* - Webhooks API (manage webhooks)
|
||||
*/
|
||||
|
||||
import { getWebexAuthHeader } from "./auth.js";
|
||||
import type {
|
||||
ResolvedWebexAccount,
|
||||
WebexApiError,
|
||||
WebexMessage,
|
||||
WebexPerson,
|
||||
WebexRoom,
|
||||
WebexSendMessageParams,
|
||||
} from "./types.js";
|
||||
|
||||
const WEBEX_API_BASE = "https://webexapis.com/v1";
|
||||
|
||||
/**
|
||||
* Generic fetch helper for Webex API calls
|
||||
*/
|
||||
async function fetchWebex<T>(
|
||||
account: ResolvedWebexAccount,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
if (!account.botToken) {
|
||||
throw new Error("Webex bot token not configured");
|
||||
}
|
||||
|
||||
const url = `${WEBEX_API_BASE}${path}`;
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: getWebexAuthHeader(account.botToken),
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let errorInfo: WebexApiError | undefined;
|
||||
try {
|
||||
errorInfo = JSON.parse(text) as WebexApiError;
|
||||
} catch {
|
||||
// Not JSON error response
|
||||
}
|
||||
const message =
|
||||
errorInfo?.message ?? errorInfo?.errors?.[0]?.description ?? (text || response.statusText);
|
||||
throw new Error(`Webex API ${response.status}: ${message}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// People API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the authenticated bot's own info
|
||||
*
|
||||
* @returns The bot's person record
|
||||
*/
|
||||
export async function getWebexMe(account: ResolvedWebexAccount): Promise<WebexPerson> {
|
||||
return fetchWebex<WebexPerson>(account, "/people/me");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a person by ID
|
||||
*/
|
||||
export async function getWebexPerson(
|
||||
account: ResolvedWebexAccount,
|
||||
personId: string,
|
||||
): Promise<WebexPerson> {
|
||||
return fetchWebex<WebexPerson>(account, `/people/${encodeURIComponent(personId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List people (search)
|
||||
*/
|
||||
export async function listWebexPeople(
|
||||
account: ResolvedWebexAccount,
|
||||
params?: { email?: string; displayName?: string; max?: number },
|
||||
): Promise<WebexPerson[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.email) query.set("email", params.email);
|
||||
if (params?.displayName) query.set("displayName", params.displayName);
|
||||
if (params?.max) query.set("max", String(params.max));
|
||||
|
||||
const queryStr = query.toString();
|
||||
const path = `/people${queryStr ? `?${queryStr}` : ""}`;
|
||||
const result = await fetchWebex<{ items: WebexPerson[] }>(account, path);
|
||||
return result.items ?? [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Messages API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a message by ID
|
||||
*
|
||||
* Webex webhooks only send the message ID, not the full message content.
|
||||
* This is used to fetch the full message after receiving a webhook event.
|
||||
*/
|
||||
export async function getWebexMessage(
|
||||
account: ResolvedWebexAccount,
|
||||
messageId: string,
|
||||
): Promise<WebexMessage> {
|
||||
return fetchWebex<WebexMessage>(account, `/messages/${encodeURIComponent(messageId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message
|
||||
*
|
||||
* Messages can be sent to:
|
||||
* - A room (roomId)
|
||||
* - A person by ID (toPersonId)
|
||||
* - A person by email (toPersonEmail)
|
||||
*
|
||||
* @returns The created message
|
||||
*/
|
||||
export async function sendWebexMessage(
|
||||
account: ResolvedWebexAccount,
|
||||
params: WebexSendMessageParams,
|
||||
): Promise<WebexMessage> {
|
||||
const body: Record<string, unknown> = {};
|
||||
|
||||
// Destination (one of these is required)
|
||||
if (params.roomId) body.roomId = params.roomId;
|
||||
if (params.toPersonId) body.toPersonId = params.toPersonId;
|
||||
if (params.toPersonEmail) body.toPersonEmail = params.toPersonEmail;
|
||||
|
||||
// Content
|
||||
if (params.text) body.text = params.text;
|
||||
if (params.markdown) body.markdown = params.markdown;
|
||||
if (params.files && params.files.length > 0) body.files = params.files;
|
||||
if (params.parentId) body.parentId = params.parentId;
|
||||
if (params.attachments && params.attachments.length > 0) body.attachments = params.attachments;
|
||||
|
||||
return fetchWebex<WebexMessage>(account, "/messages", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message
|
||||
*/
|
||||
export async function deleteWebexMessage(
|
||||
account: ResolvedWebexAccount,
|
||||
messageId: string,
|
||||
): Promise<void> {
|
||||
await fetchWebex<void>(account, `/messages/${encodeURIComponent(messageId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List messages in a room
|
||||
*/
|
||||
export async function listWebexMessages(
|
||||
account: ResolvedWebexAccount,
|
||||
roomId: string,
|
||||
params?: { max?: number; mentionedPeople?: string; before?: string; beforeMessage?: string },
|
||||
): Promise<WebexMessage[]> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("roomId", roomId);
|
||||
if (params?.max) query.set("max", String(params.max));
|
||||
if (params?.mentionedPeople) query.set("mentionedPeople", params.mentionedPeople);
|
||||
if (params?.before) query.set("before", params.before);
|
||||
if (params?.beforeMessage) query.set("beforeMessage", params.beforeMessage);
|
||||
|
||||
const result = await fetchWebex<{ items: WebexMessage[] }>(account, `/messages?${query}`);
|
||||
return result.items ?? [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rooms API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a room by ID
|
||||
*/
|
||||
export async function getWebexRoom(account: ResolvedWebexAccount, roomId: string): Promise<WebexRoom> {
|
||||
return fetchWebex<WebexRoom>(account, `/rooms/${encodeURIComponent(roomId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List rooms the bot is a member of
|
||||
*/
|
||||
export async function listWebexRooms(
|
||||
account: ResolvedWebexAccount,
|
||||
params?: { max?: number; type?: "direct" | "group"; sortBy?: "id" | "lastactivity" | "created" },
|
||||
): Promise<WebexRoom[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.max) query.set("max", String(params.max));
|
||||
if (params?.type) query.set("type", params.type);
|
||||
if (params?.sortBy) query.set("sortBy", params.sortBy);
|
||||
|
||||
const queryStr = query.toString();
|
||||
const path = `/rooms${queryStr ? `?${queryStr}` : ""}`;
|
||||
const result = await fetchWebex<{ items: WebexRoom[] }>(account, path);
|
||||
return result.items ?? [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Webhooks API
|
||||
// ============================================================================
|
||||
|
||||
export type WebexWebhook = {
|
||||
id: string;
|
||||
name: string;
|
||||
targetUrl: string;
|
||||
resource: "messages" | "memberships" | "rooms" | "attachmentActions";
|
||||
event: "created" | "updated" | "deleted" | "all";
|
||||
filter?: string;
|
||||
secret?: string;
|
||||
status: "active" | "inactive";
|
||||
created?: string;
|
||||
ownedBy?: "org" | "creator";
|
||||
};
|
||||
|
||||
/**
|
||||
* List all webhooks
|
||||
*/
|
||||
export async function listWebexWebhooks(account: ResolvedWebexAccount): Promise<WebexWebhook[]> {
|
||||
const result = await fetchWebex<{ items: WebexWebhook[] }>(account, "/webhooks");
|
||||
return result.items ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a webhook
|
||||
*/
|
||||
export async function createWebexWebhook(
|
||||
account: ResolvedWebexAccount,
|
||||
params: {
|
||||
name: string;
|
||||
targetUrl: string;
|
||||
resource: "messages" | "memberships" | "rooms" | "attachmentActions";
|
||||
event: "created" | "updated" | "deleted" | "all";
|
||||
filter?: string;
|
||||
secret?: string;
|
||||
},
|
||||
): Promise<WebexWebhook> {
|
||||
return fetchWebex<WebexWebhook>(account, "/webhooks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a webhook
|
||||
*/
|
||||
export async function deleteWebexWebhook(account: ResolvedWebexAccount, webhookId: string): Promise<void> {
|
||||
await fetchWebex<void>(account, `/webhooks/${encodeURIComponent(webhookId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a webhook
|
||||
*/
|
||||
export async function updateWebexWebhook(
|
||||
account: ResolvedWebexAccount,
|
||||
webhookId: string,
|
||||
params: {
|
||||
name?: string;
|
||||
targetUrl?: string;
|
||||
secret?: string;
|
||||
status?: "active" | "inactive";
|
||||
},
|
||||
): Promise<WebexWebhook> {
|
||||
return fetchWebex<WebexWebhook>(account, `/webhooks/${encodeURIComponent(webhookId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File/Attachment Download
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Download a file attachment
|
||||
*
|
||||
* Webex file URLs require authentication and redirect to the actual content.
|
||||
*/
|
||||
export async function downloadWebexFile(
|
||||
account: ResolvedWebexAccount,
|
||||
fileUrl: string,
|
||||
maxBytes?: number,
|
||||
): Promise<{ buffer: Buffer; contentType?: string; filename?: string }> {
|
||||
if (!account.botToken) {
|
||||
throw new Error("Webex bot token not configured");
|
||||
}
|
||||
|
||||
const response = await fetch(fileUrl, {
|
||||
headers: {
|
||||
Authorization: getWebexAuthHeader(account.botToken),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? undefined;
|
||||
const contentDisposition = response.headers.get("content-disposition");
|
||||
let filename: string | undefined;
|
||||
|
||||
// Parse filename from content-disposition header
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[*]?=(?:UTF-8'')?["']?([^"';\n]+)/i);
|
||||
if (match?.[1]) {
|
||||
filename = decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Read body with size limit
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error("No response body");
|
||||
}
|
||||
|
||||
const max = maxBytes ?? 100 * 1024 * 1024; // 100MB default limit
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
totalSize += value.length;
|
||||
if (totalSize > max) {
|
||||
reader.cancel();
|
||||
throw new Error(`File too large (> ${Math.round(max / 1024 / 1024)}MB)`);
|
||||
}
|
||||
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: Buffer.concat(chunks),
|
||||
contentType,
|
||||
filename,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Probe / Health Check
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Probe the Webex API to verify credentials
|
||||
*/
|
||||
export async function probeWebex(account: ResolvedWebexAccount): Promise<{
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
botId?: string;
|
||||
botEmail?: string;
|
||||
botName?: string;
|
||||
}> {
|
||||
try {
|
||||
const me = await getWebexMe(account);
|
||||
return {
|
||||
ok: true,
|
||||
botId: me.id,
|
||||
botEmail: me.emails?.[0],
|
||||
botName: me.displayName,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const statusMatch = message.match(/Webex API (\d+)/);
|
||||
return {
|
||||
ok: false,
|
||||
status: statusMatch ? parseInt(statusMatch[1], 10) : undefined,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
212
extensions/webex/src/auth.test.ts
Normal file
212
extensions/webex/src/auth.test.ts
Normal file
@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Webex authentication tests
|
||||
*/
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractBotIdFromToken,
|
||||
getWebexAuthHeader,
|
||||
parseWebexBotToken,
|
||||
verifyWebexWebhookSignature,
|
||||
} from "./auth.js";
|
||||
|
||||
describe("verifyWebexWebhookSignature", () => {
|
||||
const secret = "test-webhook-secret";
|
||||
|
||||
it("should verify valid signature with computed HMAC", () => {
|
||||
const body = '{"id":"test","resource":"messages"}';
|
||||
// Compute the actual expected signature
|
||||
const expectedSignature = createHmac("sha1", secret).update(body).digest("hex");
|
||||
|
||||
expect(verifyWebexWebhookSignature(body, expectedSignature, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it("should verify signature with Buffer body", () => {
|
||||
const bodyStr = '{"id":"test","resource":"messages"}';
|
||||
const body = Buffer.from(bodyStr, "utf8");
|
||||
const expectedSignature = createHmac("sha1", secret).update(body).digest("hex");
|
||||
|
||||
expect(verifyWebexWebhookSignature(body, expectedSignature, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject missing signature", () => {
|
||||
const body = '{"id":"test"}';
|
||||
expect(verifyWebexWebhookSignature(body, undefined, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty signature", () => {
|
||||
const body = '{"id":"test"}';
|
||||
expect(verifyWebexWebhookSignature(body, "", secret)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject wrong length signature", () => {
|
||||
const body = '{"id":"test"}';
|
||||
expect(verifyWebexWebhookSignature(body, "tooshort", secret)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject incorrect signature (timing-safe)", () => {
|
||||
const body = '{"id":"test"}';
|
||||
// SHA1 produces 40 hex chars
|
||||
const wrongSignature = "0".repeat(40);
|
||||
expect(verifyWebexWebhookSignature(body, wrongSignature, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject signature with wrong secret", () => {
|
||||
const body = '{"id":"test"}';
|
||||
const signatureWithWrongSecret = createHmac("sha1", "wrong-secret").update(body).digest("hex");
|
||||
expect(verifyWebexWebhookSignature(body, signatureWithWrongSecret, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject signature for modified body", () => {
|
||||
const originalBody = '{"id":"test"}';
|
||||
const signature = createHmac("sha1", secret).update(originalBody).digest("hex");
|
||||
const modifiedBody = '{"id":"modified"}';
|
||||
expect(verifyWebexWebhookSignature(modifiedBody, signature, secret)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWebexBotToken", () => {
|
||||
it("should reject undefined token", () => {
|
||||
const result = parseWebexBotToken(undefined);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("No token provided");
|
||||
});
|
||||
|
||||
it("should reject empty string token", () => {
|
||||
const result = parseWebexBotToken("");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("No token provided");
|
||||
});
|
||||
|
||||
it("should reject whitespace-only token", () => {
|
||||
const result = parseWebexBotToken(" ");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Empty token");
|
||||
});
|
||||
|
||||
it("should reject short token", () => {
|
||||
const result = parseWebexBotToken("shorttoken");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Token appears too short");
|
||||
});
|
||||
|
||||
it("should accept valid length token as unknown type", () => {
|
||||
const longToken = "a".repeat(100);
|
||||
const result = parseWebexBotToken(longToken);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.token).toBe(longToken);
|
||||
expect(result.type).toBe("unknown");
|
||||
});
|
||||
|
||||
it("should detect bot token type from decoded content", () => {
|
||||
// Create a base64 token that contains "bot" and is long enough (50+ chars)
|
||||
const tokenContent = JSON.stringify({
|
||||
type: "bot",
|
||||
id: "test-bot-id-that-is-long-enough-to-pass-validation",
|
||||
});
|
||||
const token = Buffer.from(tokenContent).toString("base64");
|
||||
const result = parseWebexBotToken(token);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.type).toBe("bot");
|
||||
});
|
||||
|
||||
it("should detect integration token type", () => {
|
||||
const tokenContent = JSON.stringify({
|
||||
type: "integration",
|
||||
id: "test-integration-id-that-is-long-enough-to-pass-validation",
|
||||
});
|
||||
const token = Buffer.from(tokenContent).toString("base64");
|
||||
const result = parseWebexBotToken(token);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.type).toBe("integration");
|
||||
});
|
||||
|
||||
it("should detect guest token type", () => {
|
||||
const tokenContent = JSON.stringify({
|
||||
type: "guest",
|
||||
id: "test-guest-id-that-is-long-enough-to-pass-minimum-validation",
|
||||
});
|
||||
const token = Buffer.from(tokenContent).toString("base64");
|
||||
const result = parseWebexBotToken(token);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.type).toBe("guest");
|
||||
});
|
||||
|
||||
it("should trim whitespace from token", () => {
|
||||
const longToken = "a".repeat(100);
|
||||
const result = parseWebexBotToken(` ${longToken} `);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.token).toBe(longToken);
|
||||
});
|
||||
|
||||
it("should handle non-base64 token gracefully", () => {
|
||||
// Create a long token that's not valid base64
|
||||
const invalidBase64Token = "!" + "a".repeat(99);
|
||||
const result = parseWebexBotToken(invalidBase64Token);
|
||||
// Should still accept it as unknown type since it's long enough
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.type).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractBotIdFromToken", () => {
|
||||
it("should extract machineAccountUuid from token", () => {
|
||||
const tokenContent = JSON.stringify({ machineAccountUuid: "test-machine-id-123" });
|
||||
const token = Buffer.from(tokenContent).toString("base64");
|
||||
const result = extractBotIdFromToken(token);
|
||||
expect(result).toBe("test-machine-id-123");
|
||||
});
|
||||
|
||||
it("should extract id from token when no machineAccountUuid", () => {
|
||||
const tokenContent = JSON.stringify({ id: "test-bot-id-456" });
|
||||
const token = Buffer.from(tokenContent).toString("base64");
|
||||
const result = extractBotIdFromToken(token);
|
||||
expect(result).toBe("test-bot-id-456");
|
||||
});
|
||||
|
||||
it("should prefer machineAccountUuid over id", () => {
|
||||
const tokenContent = JSON.stringify({
|
||||
machineAccountUuid: "machine-id",
|
||||
id: "regular-id",
|
||||
});
|
||||
const token = Buffer.from(tokenContent).toString("base64");
|
||||
const result = extractBotIdFromToken(token);
|
||||
expect(result).toBe("machine-id");
|
||||
});
|
||||
|
||||
it("should return undefined for token without id fields", () => {
|
||||
const tokenContent = JSON.stringify({ type: "bot", name: "test" });
|
||||
const token = Buffer.from(tokenContent).toString("base64");
|
||||
const result = extractBotIdFromToken(token);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for invalid base64", () => {
|
||||
const result = extractBotIdFromToken("!!!invalid!!!");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for empty token", () => {
|
||||
const result = extractBotIdFromToken("");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWebexAuthHeader", () => {
|
||||
it("should return Bearer token format", () => {
|
||||
const token = "test-bot-token-123";
|
||||
const result = getWebexAuthHeader(token);
|
||||
expect(result).toBe("Bearer test-bot-token-123");
|
||||
});
|
||||
|
||||
it("should handle empty token", () => {
|
||||
const result = getWebexAuthHeader("");
|
||||
expect(result).toBe("Bearer ");
|
||||
});
|
||||
|
||||
it("should preserve token whitespace", () => {
|
||||
const result = getWebexAuthHeader(" token ");
|
||||
expect(result).toBe("Bearer token ");
|
||||
});
|
||||
});
|
||||
128
extensions/webex/src/auth.ts
Normal file
128
extensions/webex/src/auth.ts
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Webex authentication and webhook signature verification
|
||||
*
|
||||
* Webex uses HMAC-SHA1 for webhook signature verification via the X-Spark-Signature header.
|
||||
*/
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Verify a Webex webhook signature.
|
||||
*
|
||||
* Webex signs webhook payloads using HMAC-SHA1 with the webhook secret.
|
||||
* The signature is sent in the X-Spark-Signature header as a hex string.
|
||||
*
|
||||
* @param rawBody - The raw request body as a string or buffer
|
||||
* @param signature - The X-Spark-Signature header value
|
||||
* @param secret - The webhook secret configured when creating the webhook
|
||||
* @returns true if the signature is valid
|
||||
*/
|
||||
export function verifyWebexWebhookSignature(
|
||||
rawBody: string | Buffer,
|
||||
signature: string | undefined,
|
||||
secret: string,
|
||||
): boolean {
|
||||
if (!signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedSignature = createHmac("sha1", secret)
|
||||
.update(rawBody)
|
||||
.digest("hex");
|
||||
|
||||
// Use timing-safe comparison
|
||||
if (signature.length !== expectedSignature.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
for (let i = 0; i < signature.length; i++) {
|
||||
result |= signature.charCodeAt(i) ^ expectedSignature.charCodeAt(i);
|
||||
}
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a Webex bot token.
|
||||
*
|
||||
* Bot tokens start with specific prefixes depending on the type.
|
||||
* This function performs basic validation but does not verify against the API.
|
||||
*/
|
||||
export function parseWebexBotToken(token: string | undefined): {
|
||||
valid: boolean;
|
||||
token?: string;
|
||||
type?: "bot" | "integration" | "guest" | "unknown";
|
||||
error?: string;
|
||||
} {
|
||||
if (!token) {
|
||||
return { valid: false, error: "No token provided" };
|
||||
}
|
||||
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) {
|
||||
return { valid: false, error: "Empty token" };
|
||||
}
|
||||
|
||||
// Webex tokens are base64-encoded and typically quite long
|
||||
if (trimmed.length < 50) {
|
||||
return { valid: false, error: "Token appears too short" };
|
||||
}
|
||||
|
||||
// Try to decode and identify token type
|
||||
// Bot tokens typically decode to JSON with type info
|
||||
try {
|
||||
// Tokens are URL-safe base64
|
||||
const decoded = Buffer.from(trimmed, "base64").toString("utf8");
|
||||
|
||||
// Check for known patterns
|
||||
if (decoded.includes('"bot"')) {
|
||||
return { valid: true, token: trimmed, type: "bot" };
|
||||
}
|
||||
if (decoded.includes('"integration"')) {
|
||||
return { valid: true, token: trimmed, type: "integration" };
|
||||
}
|
||||
if (decoded.includes('"guest"')) {
|
||||
return { valid: true, token: trimmed, type: "guest" };
|
||||
}
|
||||
} catch {
|
||||
// Token might not be pure base64, that's okay
|
||||
}
|
||||
|
||||
// Accept token even if we can't determine type
|
||||
return { valid: true, token: trimmed, type: "unknown" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bot ID from token claims.
|
||||
*
|
||||
* Some Webex tokens contain the bot/user ID in their claims.
|
||||
* This is a best-effort extraction; use GET /people/me for authoritative info.
|
||||
*/
|
||||
export function extractBotIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
// Try multiple decoding approaches
|
||||
const decoded = Buffer.from(token, "base64").toString("utf8");
|
||||
|
||||
// Look for machine account pattern
|
||||
const machineMatch = decoded.match(/"machineAccountUuid":\s*"([^"]+)"/);
|
||||
if (machineMatch?.[1]) {
|
||||
return machineMatch[1];
|
||||
}
|
||||
|
||||
// Look for bot ID pattern
|
||||
const idMatch = decoded.match(/"id":\s*"([^"]+)"/);
|
||||
if (idMatch?.[1]) {
|
||||
return idMatch[1];
|
||||
}
|
||||
} catch {
|
||||
// Extraction failed, return undefined
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization header helper
|
||||
*/
|
||||
export function getWebexAuthHeader(botToken: string): string {
|
||||
return `Bearer ${botToken}`;
|
||||
}
|
||||
442
extensions/webex/src/channel.ts
Normal file
442
extensions/webex/src/channel.ts
Normal file
@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Webex channel plugin implementation
|
||||
*
|
||||
* Implements the ChannelPlugin interface for Webex integration.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelMeta,
|
||||
type ChannelPlugin,
|
||||
type MoltbotConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
describeWebexAccount,
|
||||
isWebexAccountConfigured,
|
||||
listWebexAccountIds,
|
||||
resolveDefaultWebexAccountId,
|
||||
resolveWebexAccount,
|
||||
} from "./accounts.js";
|
||||
import { sendWebexMessage } from "./api.js";
|
||||
import { startWebexMonitor } from "./monitor.js";
|
||||
import { webexOnboardingAdapter } from "./onboarding.js";
|
||||
import { probeWebexAuth, probeWebexConnection } from "./probe.js";
|
||||
import { getWebexRuntime } from "./runtime.js";
|
||||
import {
|
||||
formatAllowFromEntry,
|
||||
getWebexTargetHints,
|
||||
isWebexId,
|
||||
normalizeAllowFromEntry,
|
||||
normalizeWebexTarget,
|
||||
resolveWebexOutboundTarget,
|
||||
} from "./targets.js";
|
||||
import type { ResolvedWebexAccount } from "./types.js";
|
||||
|
||||
/**
|
||||
* Webex channel metadata
|
||||
* Plugin-based channels must define their own meta object
|
||||
*/
|
||||
const meta: ChannelMeta = {
|
||||
id: "webex",
|
||||
label: "Webex",
|
||||
selectionLabel: "Webex (Bot Framework)",
|
||||
docsPath: "/channels/webex",
|
||||
blurb: "Cisco Webex bot integration via webhooks",
|
||||
order: 65,
|
||||
aliases: ["cisco-webex", "spark"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Webex channel plugin
|
||||
*/
|
||||
export const webexPlugin: ChannelPlugin<ResolvedWebexAccount> = {
|
||||
id: "webex",
|
||||
|
||||
meta: { ...meta },
|
||||
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
reactions: false, // Webex API has limited reaction support
|
||||
media: true,
|
||||
threads: true, // Via parentId
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
|
||||
reload: { configPrefixes: ["channels.webex"] },
|
||||
|
||||
onboarding: webexOnboardingAdapter,
|
||||
|
||||
pairing: {
|
||||
idLabel: "webexUserId",
|
||||
normalizeAllowEntry: normalizeAllowFromEntry,
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveWebexAccount({ cfg: cfg as MoltbotConfig });
|
||||
if (!account.botToken) return;
|
||||
|
||||
const target = await resolveWebexOutboundTarget({ account, target: id });
|
||||
await sendWebexMessage(account, {
|
||||
...target,
|
||||
markdown: "Your pairing request has been approved. You can now send messages.",
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
config: {
|
||||
listAccountIds: (cfg) => listWebexAccountIds(cfg as MoltbotConfig),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveWebexAccount({ cfg: cfg as MoltbotConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultWebexAccountId(cfg as MoltbotConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
sectionKey: "webex",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
sectionKey: "webex",
|
||||
accountId,
|
||||
clearBaseFields: [
|
||||
"botToken",
|
||||
"webhookSecret",
|
||||
"webhookPath",
|
||||
"webhookUrl",
|
||||
"botId",
|
||||
"botEmail",
|
||||
"name",
|
||||
],
|
||||
}),
|
||||
isConfigured: (account) => isWebexAccountConfigured(account),
|
||||
describeAccount: (account) => describeWebexAccount(account),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveWebexAccount({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
accountId,
|
||||
}).config.dm?.allowFrom ?? []
|
||||
).map((entry) => String(entry)),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry))
|
||||
.filter(Boolean)
|
||||
.map(formatAllowFromEntry),
|
||||
},
|
||||
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(
|
||||
(cfg as MoltbotConfig).channels?.["webex"]?.accounts?.[resolvedAccountId],
|
||||
);
|
||||
const allowFromPath = useAccountPath
|
||||
? `channels.webex.accounts.${resolvedAccountId}.dm.`
|
||||
: "channels.webex.dm.";
|
||||
return {
|
||||
policy: account.config.dm?.policy ?? "pairing",
|
||||
allowFrom: account.config.dm?.allowFrom ?? [],
|
||||
allowFromPath,
|
||||
approveHint: formatPairingApproveHint("webex"),
|
||||
normalizeEntry: formatAllowFromEntry,
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const warnings: string[] = [];
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy === "open") {
|
||||
warnings.push(
|
||||
`- Webex rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.webex.groupPolicy="allowlist" and configure channels.webex.rooms.`,
|
||||
);
|
||||
}
|
||||
if (account.config.dm?.policy === "open") {
|
||||
warnings.push(
|
||||
`- Webex DMs are open to anyone. Set channels.webex.dm.policy="pairing" or "allowlist".`,
|
||||
);
|
||||
}
|
||||
return warnings;
|
||||
},
|
||||
},
|
||||
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) =>
|
||||
(cfg as MoltbotConfig).channels?.["webex"]?.replyToMode ?? "off",
|
||||
},
|
||||
|
||||
messaging: {
|
||||
normalizeTarget: normalizeWebexTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw, normalized) => {
|
||||
const value = normalized ?? raw.trim();
|
||||
return isWebexId(value);
|
||||
},
|
||||
hint: getWebexTargetHints(),
|
||||
},
|
||||
},
|
||||
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveWebexAccount({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
accountId,
|
||||
});
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const allowFrom = account.config.dm?.allowFrom ?? [];
|
||||
const peers = Array.from(
|
||||
new Set(
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter((entry) => Boolean(entry) && entry !== "*")
|
||||
.map((entry) => normalizeWebexTarget(entry) ?? entry),
|
||||
),
|
||||
)
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
return peers;
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveWebexAccount({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
accountId,
|
||||
});
|
||||
const rooms = account.config.rooms ?? {};
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const entries = Object.keys(rooms)
|
||||
.filter((key) => key && key !== "*" && rooms[key]?.allow)
|
||||
.filter((key) => (q ? key.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return entries;
|
||||
},
|
||||
},
|
||||
|
||||
resolver: {
|
||||
resolveTargets: async ({ inputs, kind }) => {
|
||||
const resolved = inputs.map((input) => {
|
||||
const normalized = normalizeWebexTarget(input);
|
||||
if (!normalized) {
|
||||
return { input, resolved: false, note: "empty target" };
|
||||
}
|
||||
if (isWebexId(normalized)) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
// Accept emails for users
|
||||
if (kind === "user" && normalized.includes("@")) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
return {
|
||||
input,
|
||||
resolved: false,
|
||||
note: "use Webex ID or email",
|
||||
};
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
},
|
||||
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) =>
|
||||
getWebexRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 7000, // Webex markdown limit
|
||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
const allowList = allowListRaw
|
||||
.filter((entry) => entry !== "*")
|
||||
.map((entry) => normalizeWebexTarget(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
if (trimmed) {
|
||||
const normalized = normalizeWebexTarget(trimmed);
|
||||
if (!normalized) {
|
||||
if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) {
|
||||
return { ok: true, to: allowList[0] };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `Webex requires ${getWebexTargetHints()} or channels.webex.dm.allowFrom[0]`,
|
||||
};
|
||||
}
|
||||
return { ok: true, to: normalized };
|
||||
}
|
||||
|
||||
if (allowList.length > 0) {
|
||||
return { ok: true, to: allowList[0] };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `Webex requires ${getWebexTargetHints()} or channels.webex.dm.allowFrom[0]`,
|
||||
};
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
||||
const account = resolveWebexAccount({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
accountId,
|
||||
});
|
||||
const target = await resolveWebexOutboundTarget({ account, target: to });
|
||||
const parentId = (threadId ?? replyToId ?? undefined) as string | undefined;
|
||||
const result = await sendWebexMessage(account, {
|
||||
...target,
|
||||
markdown: text,
|
||||
parentId,
|
||||
});
|
||||
return {
|
||||
channel: "webex",
|
||||
messageId: result?.id ?? "",
|
||||
chatId: target.roomId ?? target.toPersonId ?? target.toPersonEmail ?? "",
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
|
||||
const account = resolveWebexAccount({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
accountId,
|
||||
});
|
||||
const target = await resolveWebexOutboundTarget({ account, target: to });
|
||||
const parentId = (threadId ?? replyToId ?? undefined) as string | undefined;
|
||||
|
||||
// Send file (Webex accepts URLs directly)
|
||||
const result = await sendWebexMessage(account, {
|
||||
...target,
|
||||
markdown: text,
|
||||
files: mediaUrl ? [mediaUrl] : undefined,
|
||||
parentId,
|
||||
});
|
||||
|
||||
return {
|
||||
channel: "webex",
|
||||
messageId: result?.id ?? "",
|
||||
chatId: target.roomId ?? target.toPersonId ?? target.toPersonEmail ?? "",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) =>
|
||||
accounts.flatMap((entry) => {
|
||||
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
const enabled = entry.enabled !== false;
|
||||
const configured = entry.configured === true;
|
||||
if (!enabled || !configured) return [];
|
||||
const issues = [];
|
||||
if (!entry.webhookSecret) {
|
||||
issues.push({
|
||||
channel: "webex",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Webex webhook secret is missing (required for security).",
|
||||
fix: "Set channels.webex.webhookSecret.",
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
credentialSource: snapshot.credentialSource ?? "none",
|
||||
webhookPath: snapshot.webhookPath ?? null,
|
||||
webhookUrl: snapshot.webhookUrl ?? null,
|
||||
botId: snapshot.botId ?? null,
|
||||
botEmail: snapshot.botEmail ?? null,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account }) => probeWebexAuth(account),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.credentialSource !== "none",
|
||||
credentialSource: account.credentialSource,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
webhookSecret: account.config.webhookSecret ? "[set]" : undefined,
|
||||
botId: account.botId,
|
||||
botEmail: account.botEmail,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
dmPolicy: account.config.dm?.policy ?? "pairing",
|
||||
probe,
|
||||
}),
|
||||
},
|
||||
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.log?.info(`[webex][${account.accountId}] starting Webex webhook monitor`);
|
||||
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
webhookPath: account.config.webhookPath ?? "/webex",
|
||||
});
|
||||
|
||||
// Probe connection first
|
||||
const probe = await probeWebexConnection(account);
|
||||
if (!probe.ok) {
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: false,
|
||||
lastError: probe.error,
|
||||
});
|
||||
throw new Error(`Webex probe failed: ${probe.error}`);
|
||||
}
|
||||
|
||||
// Store bot info from probe
|
||||
if (probe.bot) {
|
||||
if (!account.botId && probe.bot.id) {
|
||||
account.botId = probe.bot.id;
|
||||
}
|
||||
if (!account.botEmail && probe.bot.email) {
|
||||
account.botEmail = probe.bot.email;
|
||||
}
|
||||
}
|
||||
|
||||
const unregister = await startWebexMonitor({
|
||||
account,
|
||||
config: ctx.cfg as MoltbotConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
|
||||
});
|
||||
|
||||
return () => {
|
||||
unregister?.();
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
543
extensions/webex/src/monitor.ts
Normal file
543
extensions/webex/src/monitor.ts
Normal file
@ -0,0 +1,543 @@
|
||||
/**
|
||||
* Webex webhook monitor
|
||||
*
|
||||
* Handles incoming webhook requests from Webex and processes messages.
|
||||
* Key points:
|
||||
* - Webex webhooks only send message ID, we must fetch full message via API
|
||||
* - Signature verification uses HMAC-SHA1 via X-Spark-Signature header
|
||||
* - Bot mentions are tracked via mentionedPeople array
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { downloadWebexFile, getWebexMessage, sendWebexMessage } from "./api.js";
|
||||
import { verifyWebexWebhookSignature } from "./auth.js";
|
||||
import { getWebexRuntime } from "./runtime.js";
|
||||
import { normalizeAllowFromEntry } from "./targets.js";
|
||||
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
|
||||
import type { ResolvedWebexAccount, WebexMessage, WebexWebhookEvent } from "./types.js";
|
||||
|
||||
/** Registry of webhook targets */
|
||||
type WebhookTarget = {
|
||||
path: string;
|
||||
accountId: string;
|
||||
account: ResolvedWebexAccount;
|
||||
config: MoltbotConfig;
|
||||
abortSignal?: AbortSignal;
|
||||
statusSink?: (patch: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
|
||||
/**
|
||||
* Normalize webhook path for comparison
|
||||
*/
|
||||
function normalizeWebhookPath(path: string): string {
|
||||
let normalized = path.trim().toLowerCase();
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = "/" + normalized;
|
||||
}
|
||||
// Remove trailing slash
|
||||
if (normalized.length > 1 && normalized.endsWith("/")) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a webhook target for an account
|
||||
*/
|
||||
export function registerWebexWebhookTarget(target: WebhookTarget): () => void {
|
||||
const key = normalizeWebhookPath(target.path);
|
||||
const existing = webhookTargets.get(key) ?? [];
|
||||
webhookTargets.set(key, [...existing, { ...target, path: key }]);
|
||||
|
||||
return () => {
|
||||
const current = webhookTargets.get(key);
|
||||
if (current) {
|
||||
const filtered = current.filter(
|
||||
(t) => t.accountId !== target.accountId || t.path !== key,
|
||||
);
|
||||
if (filtered.length === 0) {
|
||||
webhookTargets.delete(key);
|
||||
} else {
|
||||
webhookTargets.set(key, filtered);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read request body as string
|
||||
*/
|
||||
async function readBody(req: IncomingMessage, maxBytes = 1024 * 1024): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) {
|
||||
req.destroy();
|
||||
reject(new Error("Request body too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
resolve(Buffer.concat(chunks).toString("utf8"));
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming Webex webhook request
|
||||
*
|
||||
* @returns true if the request was handled, false if it should be passed to other handlers
|
||||
*/
|
||||
export async function handleWebexWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
// Only handle POST requests
|
||||
if (req.method !== "POST") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
const path = normalizeWebhookPath(url.pathname);
|
||||
|
||||
// Check if we have any targets for this path
|
||||
const targets = webhookTargets.get(path);
|
||||
if (!targets || targets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read and parse body
|
||||
let rawBody: string;
|
||||
let event: WebexWebhookEvent;
|
||||
|
||||
try {
|
||||
rawBody = await readBody(req);
|
||||
event = JSON.parse(rawBody) as WebexWebhookEvent;
|
||||
} catch (err) {
|
||||
res.statusCode = 400;
|
||||
res.end("Invalid request body");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get signature header
|
||||
const signature = req.headers["x-spark-signature"] as string | undefined;
|
||||
|
||||
// Find matching target by verifying signature
|
||||
let matchedTarget: WebhookTarget | undefined;
|
||||
|
||||
for (const target of targets) {
|
||||
const secret = target.account.config.webhookSecret;
|
||||
if (!secret) {
|
||||
// No secret configured, accept without verification (not recommended)
|
||||
matchedTarget = target;
|
||||
break;
|
||||
}
|
||||
|
||||
if (verifyWebexWebhookSignature(rawBody, signature, secret)) {
|
||||
matchedTarget = target;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedTarget) {
|
||||
// Signature verification failed
|
||||
res.statusCode = 401;
|
||||
res.end("Unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Respond immediately (Webex expects fast response)
|
||||
res.statusCode = 200;
|
||||
res.end();
|
||||
|
||||
// Process event asynchronously
|
||||
void processWebexEvent(event, matchedTarget).catch((err) => {
|
||||
console.error("[webex] Error processing event", err);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a Webex webhook event
|
||||
*/
|
||||
async function processWebexEvent(event: WebexWebhookEvent, target: WebhookTarget): Promise<void> {
|
||||
const runtime = getWebexRuntime();
|
||||
const { account, config, statusSink } = target;
|
||||
const core = runtime;
|
||||
|
||||
// Only handle message creation events
|
||||
if (event.resource !== "messages" || event.event !== "created") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update status
|
||||
statusSink?.({ lastInboundAt: Date.now() });
|
||||
|
||||
// Fetch full message (webhook only contains ID)
|
||||
let message: WebexMessage;
|
||||
try {
|
||||
message = await getWebexMessage(account, event.data.id);
|
||||
} catch (err) {
|
||||
console.error("[webex] Failed to fetch message", event.data.id, err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip bot's own messages
|
||||
const botId = account.botId;
|
||||
if (botId && message.personId === botId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is a DM or group room
|
||||
const isGroup = message.roomType === "group";
|
||||
const roomId = message.roomId;
|
||||
|
||||
// Check if bot was mentioned
|
||||
const wasMentioned = botId ? message.mentionedPeople?.includes(botId) ?? false : false;
|
||||
|
||||
// Get sender info
|
||||
const senderId = message.personId;
|
||||
const senderEmail = message.personEmail;
|
||||
|
||||
// Resolve routing
|
||||
const route = await resolveWebexRoute({
|
||||
account,
|
||||
config,
|
||||
isGroup,
|
||||
roomId,
|
||||
senderId,
|
||||
senderEmail,
|
||||
wasMentioned,
|
||||
core,
|
||||
});
|
||||
|
||||
if (!route.allowed) {
|
||||
// Message blocked by policy - debug only
|
||||
return;
|
||||
}
|
||||
|
||||
// Process media if present
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
|
||||
if (message.files && message.files.length > 0) {
|
||||
try {
|
||||
const mediaMaxMb = 50; // 50MB limit
|
||||
const file = await downloadWebexFile(account, message.files[0], mediaMaxMb * 1024 * 1024);
|
||||
// Save to temp storage
|
||||
const tempPath = await core.storage.writeTempFile(file.buffer, {
|
||||
extension: getExtensionFromContentType(file.contentType),
|
||||
prefix: "webex-",
|
||||
});
|
||||
mediaPath = tempPath;
|
||||
mediaType = file.contentType;
|
||||
} catch (err) {
|
||||
console.warn("[webex] Failed to download media", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Build message body
|
||||
const rawBody = message.text ?? "";
|
||||
let body = rawBody;
|
||||
|
||||
// Format envelope
|
||||
const senderName = senderEmail ?? formatSenderId(senderId);
|
||||
const envelope = core.channel.reply.formatAgentEnvelope({
|
||||
senderName,
|
||||
timestamp: message.created,
|
||||
});
|
||||
|
||||
if (envelope) {
|
||||
body = envelope + body;
|
||||
}
|
||||
|
||||
// Build context payload
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: `webex:${senderId}`,
|
||||
To: `webex:${roomId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "channel" : "direct",
|
||||
ConversationLabel: senderName,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
SenderUsername: senderEmail,
|
||||
WasMentioned: isGroup ? wasMentioned : undefined,
|
||||
MessageSid: message.id,
|
||||
MessageSidFull: message.id,
|
||||
ReplyToId: message.parentId,
|
||||
ReplyToIdFull: message.parentId,
|
||||
MediaPath: mediaPath,
|
||||
MediaUrl: mediaPath,
|
||||
MediaType: mediaType,
|
||||
GroupSpace: isGroup ? roomId : undefined,
|
||||
GroupSystemPrompt: route.groupSystemPrompt,
|
||||
OriginatingChannel: "webex",
|
||||
OriginatingTo: `webex:${roomId}`,
|
||||
});
|
||||
|
||||
// Dispatch to message pipeline
|
||||
console.log("[webex] calling dispatchReplyWithBufferedBlockDispatcher");
|
||||
const dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
await deliverWebexReply({
|
||||
payload,
|
||||
account,
|
||||
roomId,
|
||||
runtime: core,
|
||||
config,
|
||||
statusSink,
|
||||
parentId: message.parentId,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
console.error(`[webex] ${info.kind} reply failed:`, err);
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log("[webex] dispatchReplyWithBufferedBlockDispatcher result:", dispatchResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route resolution result
|
||||
*/
|
||||
type WebexRouteResult = {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
sessionKey: string;
|
||||
accountId: string;
|
||||
groupSystemPrompt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve routing and policy for a message
|
||||
*/
|
||||
async function resolveWebexRoute(params: {
|
||||
account: ResolvedWebexAccount;
|
||||
config: MoltbotConfig;
|
||||
isGroup: boolean;
|
||||
roomId: string;
|
||||
senderId: string;
|
||||
senderEmail?: string;
|
||||
wasMentioned: boolean;
|
||||
core: ReturnType<typeof getWebexRuntime>;
|
||||
}): Promise<WebexRouteResult> {
|
||||
const { account, config, isGroup, roomId, senderId, senderEmail, wasMentioned, core } = params;
|
||||
const accountId = account.accountId;
|
||||
|
||||
if (isGroup) {
|
||||
// Group message routing
|
||||
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||
|
||||
if (groupPolicy === "disabled") {
|
||||
return { allowed: false, reason: "groupPolicy=disabled", sessionKey: "", accountId };
|
||||
}
|
||||
|
||||
const roomConfig = account.config.rooms?.[roomId];
|
||||
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (!roomConfig?.allow) {
|
||||
return { allowed: false, reason: "room not in allowlist", sessionKey: "", accountId };
|
||||
}
|
||||
}
|
||||
|
||||
// Check mention requirement
|
||||
const requireMention = roomConfig?.requireMention ?? true;
|
||||
if (requireMention && !wasMentioned) {
|
||||
return { allowed: false, reason: "mention required but not mentioned", sessionKey: "", accountId };
|
||||
}
|
||||
|
||||
// Check user allowlist for room
|
||||
if (roomConfig?.users && roomConfig.users.length > 0) {
|
||||
const normalizedSender = normalizeAllowFromEntry(senderId);
|
||||
const normalizedEmail = senderEmail ? normalizeAllowFromEntry(senderEmail) : undefined;
|
||||
const allowed = roomConfig.users.some((u) => {
|
||||
const normalized = normalizeAllowFromEntry(u);
|
||||
return normalized === normalizedSender || (normalizedEmail && normalized === normalizedEmail);
|
||||
});
|
||||
if (!allowed) {
|
||||
return { allowed: false, reason: "user not in room allowlist", sessionKey: "", accountId };
|
||||
}
|
||||
}
|
||||
|
||||
const sessionKey = `webex:${accountId}:room:${roomId}`;
|
||||
return {
|
||||
allowed: true,
|
||||
sessionKey,
|
||||
accountId,
|
||||
groupSystemPrompt: roomConfig?.systemPrompt,
|
||||
};
|
||||
} else {
|
||||
// DM routing
|
||||
const dmPolicy = account.config.dm?.policy ?? "pairing";
|
||||
|
||||
if (dmPolicy === "disabled") {
|
||||
return { allowed: false, reason: "dm.policy=disabled", sessionKey: "", accountId };
|
||||
}
|
||||
|
||||
const allowFrom = account.config.dm?.allowFrom ?? [];
|
||||
const normalizedSender = normalizeAllowFromEntry(senderId);
|
||||
const normalizedEmail = senderEmail ? normalizeAllowFromEntry(senderEmail) : undefined;
|
||||
|
||||
const isInAllowlist = allowFrom.some((entry) => {
|
||||
const normalized = normalizeAllowFromEntry(entry);
|
||||
return normalized === normalizedSender || (normalizedEmail && normalized === normalizedEmail);
|
||||
});
|
||||
|
||||
if (dmPolicy === "allowlist" && !isInAllowlist) {
|
||||
return { allowed: false, reason: "sender not in dm.allowFrom", sessionKey: "", accountId };
|
||||
}
|
||||
|
||||
if (dmPolicy === "pairing" && !isInAllowlist) {
|
||||
// Check pairing status
|
||||
const pairingKey = senderEmail ?? senderId;
|
||||
const isPaired = await core.pairing.isPaired("webex", pairingKey);
|
||||
if (!isPaired) {
|
||||
// Send pairing request
|
||||
await core.pairing.requestPairing("webex", pairingKey, {
|
||||
displayName: senderEmail ?? formatSenderId(senderId),
|
||||
});
|
||||
return { allowed: false, reason: "pairing required", sessionKey: "", accountId };
|
||||
}
|
||||
}
|
||||
|
||||
const sessionKey = `webex:${accountId}:dm:${senderId}`;
|
||||
return {
|
||||
allowed: true,
|
||||
sessionKey,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a reply to Webex
|
||||
*/
|
||||
async function deliverWebexReply(params: {
|
||||
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
|
||||
account: ResolvedWebexAccount;
|
||||
roomId: string;
|
||||
runtime: ReturnType<typeof getWebexRuntime>;
|
||||
config: MoltbotConfig;
|
||||
statusSink?: (patch: Record<string, unknown>) => void;
|
||||
parentId?: string;
|
||||
}): Promise<void> {
|
||||
const { payload, account, roomId, runtime, statusSink, parentId } = params;
|
||||
|
||||
console.log("[webex] deliverWebexReply called", {
|
||||
roomId,
|
||||
hasText: !!payload.text,
|
||||
textLength: payload.text?.length,
|
||||
mediaUrls: payload.mediaUrls?.length ?? 0,
|
||||
});
|
||||
|
||||
// Send text message
|
||||
if (payload.text) {
|
||||
console.log("[webex] sending text message to room", roomId);
|
||||
try {
|
||||
const result = await sendWebexMessage(account, {
|
||||
roomId,
|
||||
markdown: payload.text,
|
||||
parentId,
|
||||
});
|
||||
console.log("[webex] message sent successfully", { messageId: result.id });
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
console.error("[webex] failed to send message", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Send media
|
||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
for (const mediaUrl of mediaUrls) {
|
||||
try {
|
||||
// Webex supports sending files via URL
|
||||
await sendWebexMessage(account, {
|
||||
roomId,
|
||||
files: [mediaUrl],
|
||||
parentId,
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
console.warn("[webex] Failed to send media", mediaUrl, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Webex webhook monitor for an account
|
||||
*/
|
||||
export async function startWebexMonitor(params: {
|
||||
account: ResolvedWebexAccount;
|
||||
config: MoltbotConfig;
|
||||
runtime: ReturnType<typeof getWebexRuntime>;
|
||||
abortSignal?: AbortSignal;
|
||||
webhookPath?: string;
|
||||
webhookUrl?: string;
|
||||
statusSink?: (patch: Record<string, unknown>) => void;
|
||||
}): Promise<() => void> {
|
||||
const { account, config, runtime, abortSignal, statusSink } = params;
|
||||
const webhookPath = params.webhookPath ?? account.config.webhookPath ?? "/webex";
|
||||
|
||||
console.info(`[webex][${account.accountId}] Registering webhook handler at ${webhookPath}`);
|
||||
|
||||
const unregister = registerWebexWebhookTarget({
|
||||
path: webhookPath,
|
||||
accountId: account.accountId,
|
||||
account,
|
||||
config,
|
||||
abortSignal,
|
||||
statusSink,
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from content type
|
||||
*/
|
||||
function getExtensionFromContentType(contentType?: string): string {
|
||||
if (!contentType) return "bin";
|
||||
const map: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"video/mp4": "mp4",
|
||||
"video/quicktime": "mov",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/ogg": "ogg",
|
||||
"audio/wav": "wav",
|
||||
"application/pdf": "pdf",
|
||||
"text/plain": "txt",
|
||||
};
|
||||
return map[contentType.split(";")[0]] ?? "bin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format sender ID for display
|
||||
*/
|
||||
function formatSenderId(senderId: string): string {
|
||||
// Truncate long IDs
|
||||
if (senderId.length > 20) {
|
||||
return `${senderId.slice(0, 8)}...${senderId.slice(-4)}`;
|
||||
}
|
||||
return senderId;
|
||||
}
|
||||
279
extensions/webex/src/onboarding.ts
Normal file
279
extensions/webex/src/onboarding.ts
Normal file
@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Webex onboarding wizard
|
||||
*
|
||||
* CLI-based setup flow for configuring Webex bot integration.
|
||||
*/
|
||||
|
||||
import type { DmPolicy, MoltbotConfig } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
type ChannelOnboardingAdapter,
|
||||
type ChannelOnboardingDmPolicy,
|
||||
type WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
listWebexAccountIds,
|
||||
resolveDefaultWebexAccountId,
|
||||
resolveWebexAccount,
|
||||
} from "./accounts.js";
|
||||
import { probeWebexConnection } from "./probe.js";
|
||||
import type { ResolvedWebexAccount } from "./types.js";
|
||||
|
||||
const channel = "webex" as const;
|
||||
|
||||
const ENV_BOT_TOKEN = "WEBEX_BOT_TOKEN";
|
||||
|
||||
function setWebexDmPolicy(cfg: MoltbotConfig, policy: DmPolicy) {
|
||||
const allowFrom =
|
||||
policy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.["webex"]?.dm?.allowFrom)
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
webex: {
|
||||
...(cfg.channels?.["webex"] ?? {}),
|
||||
dm: {
|
||||
...(cfg.channels?.["webex"]?.dm ?? {}),
|
||||
policy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function applyAccountConfig(params: {
|
||||
cfg: MoltbotConfig;
|
||||
accountId: string;
|
||||
patch: Record<string, unknown>;
|
||||
}): MoltbotConfig {
|
||||
const { cfg, accountId, patch } = params;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
webex: {
|
||||
...(cfg.channels?.["webex"] ?? {}),
|
||||
enabled: true,
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
webex: {
|
||||
...(cfg.channels?.["webex"] ?? {}),
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(cfg.channels?.["webex"]?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.["webex"]?.accounts?.[accountId] ?? {}),
|
||||
enabled: true,
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function promptCredentials(params: {
|
||||
cfg: MoltbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<{ cfg: MoltbotConfig; botInfo?: { id?: string; email?: string; displayName?: string } }> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const envReady = accountId === DEFAULT_ACCOUNT_ID && Boolean(process.env[ENV_BOT_TOKEN]?.trim());
|
||||
|
||||
let botToken: string | undefined;
|
||||
|
||||
if (envReady) {
|
||||
const useEnv = await prompter.confirm({
|
||||
message: `Use ${ENV_BOT_TOKEN} environment variable?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!useEnv) {
|
||||
botToken = await prompter.text({
|
||||
message: "Enter your Webex bot access token:",
|
||||
placeholder: "Bot access token from developer.webex.com",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
botToken = await prompter.text({
|
||||
message: "Enter your Webex bot access token:",
|
||||
placeholder: "Bot access token from developer.webex.com",
|
||||
});
|
||||
}
|
||||
|
||||
// Test the token
|
||||
const testAccount = resolveWebexAccount({ cfg, accountId });
|
||||
if (botToken) {
|
||||
testAccount.botToken = botToken;
|
||||
}
|
||||
|
||||
const probe = await probeWebexConnection(testAccount);
|
||||
if (probe.ok && probe.bot) {
|
||||
await prompter.note(
|
||||
`Token valid: ${probe.bot.displayName ?? "Bot"} (${probe.bot.email ?? "no email"})`,
|
||||
"Webex Bot",
|
||||
);
|
||||
} else if (!probe.ok) {
|
||||
await prompter.note(`Token validation failed: ${probe.error}`, "Warning");
|
||||
const proceed = await prompter.confirm({
|
||||
message: "Continue anyway?",
|
||||
initialValue: false,
|
||||
});
|
||||
if (!proceed) {
|
||||
throw new Error("Token validation failed");
|
||||
}
|
||||
}
|
||||
|
||||
const nextCfg = applyAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
patch: {
|
||||
...(botToken ? { botToken } : {}),
|
||||
...(probe.bot?.id ? { botId: probe.bot.id } : {}),
|
||||
...(probe.bot?.email ? { botEmail: probe.bot.email } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return { cfg: nextCfg, botInfo: probe.bot };
|
||||
}
|
||||
|
||||
async function promptWebhook(params: {
|
||||
cfg: MoltbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<MoltbotConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
|
||||
const webhookSecretRaw = await prompter.text({
|
||||
message: "Enter a webhook secret (for signature verification):",
|
||||
placeholder: "A random string for HMAC-SHA1 verification",
|
||||
});
|
||||
|
||||
const webhookPathRaw = await prompter.text({
|
||||
message: "Webhook path:",
|
||||
placeholder: "/webex",
|
||||
initialValue: "/webex",
|
||||
});
|
||||
|
||||
// Defensive: ensure values are strings before trimming
|
||||
const webhookSecret =
|
||||
typeof webhookSecretRaw === "string" ? webhookSecretRaw.trim() : undefined;
|
||||
const webhookPath =
|
||||
typeof webhookPathRaw === "string" && webhookPathRaw.trim()
|
||||
? webhookPathRaw.trim()
|
||||
: "/webex";
|
||||
|
||||
return applyAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
patch: {
|
||||
webhookSecret: webhookSecret || undefined,
|
||||
webhookPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function noteWebexSetup(prompter: WizardPrompter) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Webex bots use OAuth token auth and HTTPS webhooks.",
|
||||
"1. Create a bot at https://developer.webex.com/my-apps",
|
||||
"2. Copy the bot access token",
|
||||
"3. Configure a webhook pointing to your gateway URL",
|
||||
"4. Use the same secret for webhook verification",
|
||||
`Docs: ${formatDocsLink("/channels/webex", "channels/webex")}`,
|
||||
].join("\n"),
|
||||
"Webex setup",
|
||||
);
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Webex",
|
||||
channel,
|
||||
policyKey: "channels.webex.dm.policy",
|
||||
allowFromKey: "channels.webex.dm.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.["webex"]?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setWebexDmPolicy(cfg, policy),
|
||||
};
|
||||
|
||||
/**
|
||||
* Webex onboarding adapter
|
||||
*/
|
||||
export const webexOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
dmPolicy,
|
||||
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listWebexAccountIds(cfg).some(
|
||||
(accountId) => resolveWebexAccount({ cfg, accountId }).credentialSource !== "none",
|
||||
);
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [
|
||||
`Webex: ${configured ? "configured" : "needs bot token"}`,
|
||||
],
|
||||
selectionHint: configured ? "configured" : "needs auth",
|
||||
};
|
||||
},
|
||||
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
||||
const override = accountOverrides["webex"]?.trim();
|
||||
const defaultAccountId = resolveDefaultWebexAccountId(cfg);
|
||||
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
|
||||
|
||||
if (shouldPromptAccountIds && !override) {
|
||||
accountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Webex",
|
||||
currentId: accountId,
|
||||
listAccountIds: listWebexAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let nextCfg = cfg;
|
||||
|
||||
// Show setup notes
|
||||
await noteWebexSetup(prompter);
|
||||
|
||||
// Prompt for credentials (bot token)
|
||||
const credResult = await promptCredentials({ cfg: nextCfg, prompter, accountId });
|
||||
nextCfg = credResult.cfg;
|
||||
|
||||
// Prompt for webhook configuration
|
||||
nextCfg = await promptWebhook({ cfg: nextCfg, prompter, accountId });
|
||||
|
||||
// Migrate config if needed
|
||||
const namedConfig = migrateBaseNameToDefaultAccount({
|
||||
cfg: nextCfg,
|
||||
channelKey: "webex",
|
||||
});
|
||||
|
||||
return { cfg: namedConfig, accountId };
|
||||
},
|
||||
};
|
||||
110
extensions/webex/src/probe.ts
Normal file
110
extensions/webex/src/probe.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Webex connection probe
|
||||
*
|
||||
* Validates bot token and connection to Webex API.
|
||||
*/
|
||||
|
||||
import { getWebexMe, listWebexRooms, listWebexWebhooks } from "./api.js";
|
||||
import type { ResolvedWebexAccount } from "./types.js";
|
||||
|
||||
export type WebexProbeResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
bot?: {
|
||||
id: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
rooms?: {
|
||||
count: number;
|
||||
direct: number;
|
||||
group: number;
|
||||
};
|
||||
webhooks?: {
|
||||
count: number;
|
||||
active: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Probe the Webex API to verify the bot is properly configured
|
||||
*/
|
||||
export async function probeWebexConnection(
|
||||
account: ResolvedWebexAccount,
|
||||
): Promise<WebexProbeResult> {
|
||||
// Check if token is configured
|
||||
if (!account.botToken) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Bot token not configured",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Test authentication by getting bot info
|
||||
const me = await getWebexMe(account);
|
||||
|
||||
const result: WebexProbeResult = {
|
||||
ok: true,
|
||||
bot: {
|
||||
id: me.id,
|
||||
email: me.emails?.[0],
|
||||
displayName: me.displayName,
|
||||
},
|
||||
};
|
||||
|
||||
// Optionally fetch room stats
|
||||
try {
|
||||
const rooms = await listWebexRooms(account, { max: 100 });
|
||||
const direct = rooms.filter((r) => r.type === "direct").length;
|
||||
const group = rooms.filter((r) => r.type === "group").length;
|
||||
result.rooms = {
|
||||
count: rooms.length,
|
||||
direct,
|
||||
group,
|
||||
};
|
||||
} catch {
|
||||
// Room listing failed, not critical
|
||||
}
|
||||
|
||||
// Optionally fetch webhook stats
|
||||
try {
|
||||
const webhooks = await listWebexWebhooks(account);
|
||||
const active = webhooks.filter((w) => w.status === "active").length;
|
||||
result.webhooks = {
|
||||
count: webhooks.length,
|
||||
active,
|
||||
};
|
||||
} catch {
|
||||
// Webhook listing failed, not critical
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified probe for status checks (just tests auth)
|
||||
*/
|
||||
export async function probeWebexAuth(
|
||||
account: ResolvedWebexAccount,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
if (!account.botToken) {
|
||||
return { ok: false, error: "Bot token not configured" };
|
||||
}
|
||||
|
||||
try {
|
||||
await getWebexMe(account);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
24
extensions/webex/src/runtime.ts
Normal file
24
extensions/webex/src/runtime.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Webex plugin runtime singleton
|
||||
*/
|
||||
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type WebexRuntimeEnv = PluginRuntime;
|
||||
|
||||
let runtime: WebexRuntimeEnv | undefined;
|
||||
|
||||
export function setWebexRuntime(r: WebexRuntimeEnv): void {
|
||||
runtime = r;
|
||||
}
|
||||
|
||||
export function getWebexRuntime(): WebexRuntimeEnv {
|
||||
if (!runtime) {
|
||||
throw new Error("Webex runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function getWebexRuntimeOrUndefined(): WebexRuntimeEnv | undefined {
|
||||
return runtime;
|
||||
}
|
||||
236
extensions/webex/src/targets.test.ts
Normal file
236
extensions/webex/src/targets.test.ts
Normal file
@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Webex targets tests
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatAllowFromEntry,
|
||||
formatWebexTarget,
|
||||
getWebexTargetHints,
|
||||
getWebexTargetType,
|
||||
isEmail,
|
||||
isWebexId,
|
||||
normalizeAllowFromEntry,
|
||||
normalizeWebexTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
describe("normalizeWebexTarget", () => {
|
||||
it("should strip webex: prefix", () => {
|
||||
expect(normalizeWebexTarget("webex:Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should strip user: prefix", () => {
|
||||
expect(normalizeWebexTarget("user:Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should strip room: prefix", () => {
|
||||
expect(normalizeWebexTarget("room:Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should strip person: prefix", () => {
|
||||
expect(normalizeWebexTarget("person:Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should strip email: prefix", () => {
|
||||
expect(normalizeWebexTarget("email:user@example.com")).toBe("user@example.com");
|
||||
});
|
||||
|
||||
it("should handle case-insensitive prefixes", () => {
|
||||
expect(normalizeWebexTarget("WEBEX:Y2lzY29")).toBe("Y2lzY29");
|
||||
expect(normalizeWebexTarget("User:Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should handle bare values", () => {
|
||||
expect(normalizeWebexTarget("Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should return undefined for empty values", () => {
|
||||
expect(normalizeWebexTarget("")).toBeUndefined();
|
||||
expect(normalizeWebexTarget(null)).toBeUndefined();
|
||||
expect(normalizeWebexTarget(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for whitespace-only values", () => {
|
||||
expect(normalizeWebexTarget(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
expect(normalizeWebexTarget(" Y2lzY29 ")).toBe("Y2lzY29");
|
||||
expect(normalizeWebexTarget(" webex:Y2lzY29 ")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should handle prefix with extra spaces", () => {
|
||||
expect(normalizeWebexTarget("webex: Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWebexId", () => {
|
||||
it("should recognize Webex IDs starting with Y2lz", () => {
|
||||
// Typical Webex ID is base64-encoded and starts with "Y2lz" (decoded: "cis")
|
||||
const webexId = "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9hYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejEyMzQ1Ng";
|
||||
expect(isWebexId(webexId)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject short strings", () => {
|
||||
expect(isWebexId("Y2lz")).toBe(false);
|
||||
expect(isWebexId("Y2lzY29")).toBe(false);
|
||||
expect(isWebexId("a".repeat(39))).toBe(false);
|
||||
});
|
||||
|
||||
it("should accept strings of 40+ chars starting with Y2lz", () => {
|
||||
expect(isWebexId("Y2lz" + "a".repeat(36))).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject strings not starting with Y2lz", () => {
|
||||
expect(isWebexId("abcdefghijklmnopqrstuvwxyz1234567890abcdefghij")).toBe(false);
|
||||
expect(isWebexId("a".repeat(50))).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject strings with invalid base64 characters", () => {
|
||||
expect(isWebexId("Y2lz!@#$%^&*()")).toBe(false);
|
||||
expect(isWebexId("Y2lz" + " ".repeat(36))).toBe(false);
|
||||
});
|
||||
|
||||
it("should accept valid base64 characters including URL-safe variants", () => {
|
||||
expect(isWebexId("Y2lz" + "abcABC012+/=_-".repeat(3))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEmail", () => {
|
||||
it("should recognize valid emails", () => {
|
||||
expect(isEmail("user@example.com")).toBe(true);
|
||||
expect(isEmail("user.name@example.co.uk")).toBe(true);
|
||||
expect(isEmail("user+tag@example.com")).toBe(true);
|
||||
expect(isEmail("a@b.co")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid emails", () => {
|
||||
expect(isEmail("not-an-email")).toBe(false);
|
||||
expect(isEmail("@example.com")).toBe(false);
|
||||
expect(isEmail("user@")).toBe(false);
|
||||
expect(isEmail("user@example")).toBe(false);
|
||||
expect(isEmail("user @example.com")).toBe(false);
|
||||
expect(isEmail("")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject emails with spaces", () => {
|
||||
expect(isEmail("user name@example.com")).toBe(false);
|
||||
expect(isEmail("user@example .com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWebexTargetType", () => {
|
||||
it("should detect email addresses", () => {
|
||||
expect(getWebexTargetType("user@example.com")).toBe("email");
|
||||
expect(getWebexTargetType("email:user@example.com")).toBe("email");
|
||||
});
|
||||
|
||||
it("should detect Webex person IDs", () => {
|
||||
// Person ID with PEOPLE in decoded content
|
||||
const personId = Buffer.from("ciscospark://us/PEOPLE/abc123").toString("base64");
|
||||
expect(getWebexTargetType(personId)).toBe("personId");
|
||||
});
|
||||
|
||||
it("should detect Webex room IDs", () => {
|
||||
// Room ID with ROOM in decoded content
|
||||
const roomId = Buffer.from("ciscospark://us/ROOM/abc123def456").toString("base64");
|
||||
expect(getWebexTargetType(roomId)).toBe("roomId");
|
||||
});
|
||||
|
||||
it("should return personId for unknown Webex IDs", () => {
|
||||
// Generic ID starting with Y2lz but no type info
|
||||
const genericId = "Y2lz" + "a".repeat(50);
|
||||
expect(getWebexTargetType(genericId)).toBe("personId");
|
||||
});
|
||||
|
||||
it("should return unknown for unrecognized formats", () => {
|
||||
expect(getWebexTargetType("short")).toBe("unknown");
|
||||
expect(getWebexTargetType("not-a-valid-id")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("should return unknown for empty strings", () => {
|
||||
expect(getWebexTargetType("")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatWebexTarget", () => {
|
||||
it("should lowercase email addresses", () => {
|
||||
expect(formatWebexTarget("User@Example.COM")).toBe("user@example.com");
|
||||
expect(formatWebexTarget("email:User@Example.COM")).toBe("user@example.com");
|
||||
});
|
||||
|
||||
it("should truncate long IDs", () => {
|
||||
const longId = "a".repeat(50);
|
||||
const formatted = formatWebexTarget(longId);
|
||||
expect(formatted).toBe("aaaaaaaa...aaaaaaaa");
|
||||
expect(formatted.length).toBe(19); // 8 + 3 + 8
|
||||
});
|
||||
|
||||
it("should preserve short IDs", () => {
|
||||
expect(formatWebexTarget("shortid")).toBe("shortid");
|
||||
expect(formatWebexTarget("a".repeat(20))).toBe("a".repeat(20));
|
||||
});
|
||||
|
||||
it("should handle prefixed values", () => {
|
||||
expect(formatWebexTarget("webex:user@example.com")).toBe("user@example.com");
|
||||
});
|
||||
|
||||
it("should return original for invalid input", () => {
|
||||
expect(formatWebexTarget("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAllowFromEntry", () => {
|
||||
it("should normalize email to lowercase", () => {
|
||||
expect(normalizeAllowFromEntry("User@Example.COM")).toBe("user@example.com");
|
||||
});
|
||||
|
||||
it("should strip prefixes and lowercase email", () => {
|
||||
expect(normalizeAllowFromEntry("webex:user@example.com")).toBe("user@example.com");
|
||||
expect(normalizeAllowFromEntry("email:User@Example.COM")).toBe("user@example.com");
|
||||
});
|
||||
|
||||
it("should preserve Webex ID case", () => {
|
||||
const webexId = "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9hYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejEyMzQ1Ng";
|
||||
expect(normalizeAllowFromEntry(webexId)).toBe(webexId);
|
||||
});
|
||||
|
||||
it("should strip user: prefix from IDs", () => {
|
||||
expect(normalizeAllowFromEntry("user:Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(normalizeAllowFromEntry("")).toBe("");
|
||||
});
|
||||
|
||||
it("should preserve unknown format case (not email)", () => {
|
||||
// Unknown formats are treated as potential Webex IDs, which are case-sensitive
|
||||
expect(normalizeAllowFromEntry("UNKNOWN")).toBe("UNKNOWN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAllowFromEntry", () => {
|
||||
it("should format email to lowercase", () => {
|
||||
expect(formatAllowFromEntry("User@Example.COM")).toBe("user@example.com");
|
||||
});
|
||||
|
||||
it("should preserve Webex ID case", () => {
|
||||
expect(formatAllowFromEntry("Y2lzY29")).toBe("Y2lzY29");
|
||||
});
|
||||
|
||||
it("should strip prefixes", () => {
|
||||
expect(formatAllowFromEntry("webex:Y2lzY29")).toBe("Y2lzY29");
|
||||
expect(formatAllowFromEntry("email:user@example.com")).toBe("user@example.com");
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
expect(formatAllowFromEntry(" user@example.com ")).toBe("user@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWebexTargetHints", () => {
|
||||
it("should return target hint string", () => {
|
||||
const hints = getWebexTargetHints();
|
||||
expect(hints).toBe("<personId|roomId|email@example.com>");
|
||||
});
|
||||
});
|
||||
225
extensions/webex/src/targets.ts
Normal file
225
extensions/webex/src/targets.ts
Normal file
@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Webex target normalization
|
||||
*
|
||||
* Handles conversion between various target formats:
|
||||
* - Person ID (Y2lz... base64 encoded)
|
||||
* - Room ID (Y2lz... base64 encoded)
|
||||
* - Email addresses
|
||||
* - Prefixed formats (webex:, user:, room:)
|
||||
*/
|
||||
|
||||
import { getWebexRoom, listWebexPeople } from "./api.js";
|
||||
import type { ResolvedWebexAccount } from "./types.js";
|
||||
|
||||
/**
|
||||
* Normalize a Webex target identifier
|
||||
*
|
||||
* Accepts:
|
||||
* - webex:Y2lz... (prefixed ID)
|
||||
* - user:Y2lz... (person ID prefix)
|
||||
* - room:Y2lz... (room ID prefix)
|
||||
* - email:user@example.com
|
||||
* - user@example.com (bare email)
|
||||
* - Y2lz... (raw Webex ID)
|
||||
*
|
||||
* @returns Normalized ID without prefix, or undefined if invalid
|
||||
*/
|
||||
export function normalizeWebexTarget(raw?: string | null): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
|
||||
let value = raw.trim();
|
||||
if (!value) return undefined;
|
||||
|
||||
// Strip common prefixes
|
||||
const prefixes = ["webex:", "user:", "room:", "person:", "email:"];
|
||||
for (const prefix of prefixes) {
|
||||
if (value.toLowerCase().startsWith(prefix)) {
|
||||
value = value.slice(prefix.length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value looks like a Webex ID
|
||||
*
|
||||
* Webex IDs are base64-encoded strings that typically start with "Y2lz" (decoded: "cis")
|
||||
*/
|
||||
export function isWebexId(value: string): boolean {
|
||||
// Webex IDs are base64 and typically start with Y2lz (decoded: "cis")
|
||||
// They're usually quite long (80+ chars)
|
||||
if (value.length < 40) return false;
|
||||
|
||||
// Check for base64 characters
|
||||
if (!/^[A-Za-z0-9+/=_-]+$/.test(value)) return false;
|
||||
|
||||
// Most Webex IDs start with Y2lz (cis = Cisco)
|
||||
return value.startsWith("Y2lz");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value looks like an email address
|
||||
*/
|
||||
export function isEmail(value: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the type of a Webex target
|
||||
*/
|
||||
export function getWebexTargetType(value: string): "personId" | "roomId" | "email" | "unknown" {
|
||||
const normalized = normalizeWebexTarget(value);
|
||||
if (!normalized) return "unknown";
|
||||
|
||||
if (isEmail(normalized)) return "email";
|
||||
|
||||
if (isWebexId(normalized)) {
|
||||
// Try to decode and check the type
|
||||
try {
|
||||
const decoded = Buffer.from(normalized, "base64").toString("utf8");
|
||||
if (decoded.includes("PEOPLE") || decoded.includes("PERSON")) return "personId";
|
||||
if (decoded.includes("ROOM")) return "roomId";
|
||||
} catch {
|
||||
// Decoding failed, make a guess
|
||||
}
|
||||
|
||||
// Default to personId for unknown Webex IDs
|
||||
return "personId";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Webex target for display
|
||||
*/
|
||||
export function formatWebexTarget(value: string, type?: "person" | "room"): string {
|
||||
const normalized = normalizeWebexTarget(value);
|
||||
if (!normalized) return value;
|
||||
|
||||
if (isEmail(normalized)) {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
// Truncate long IDs for display
|
||||
if (normalized.length > 20) {
|
||||
return `${normalized.slice(0, 8)}...${normalized.slice(-8)}`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an allowFrom entry for comparison
|
||||
*/
|
||||
export function normalizeAllowFromEntry(raw: string): string {
|
||||
const normalized = normalizeWebexTarget(raw);
|
||||
if (!normalized) return raw.toLowerCase().trim();
|
||||
|
||||
// Lowercase emails
|
||||
if (isEmail(normalized)) {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
// Person IDs are case-sensitive
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an allowFrom entry for storage
|
||||
*/
|
||||
export function formatAllowFromEntry(raw: string): string {
|
||||
const normalized = normalizeWebexTarget(raw);
|
||||
if (!normalized) return raw.trim();
|
||||
|
||||
if (isEmail(normalized)) {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a target to a room ID for sending
|
||||
*
|
||||
* If the target is a person ID or email, this returns undefined
|
||||
* (use toPersonId or toPersonEmail in the API call instead).
|
||||
*
|
||||
* If the target is a room ID, returns it directly.
|
||||
*/
|
||||
export async function resolveWebexOutboundTarget(params: {
|
||||
account: ResolvedWebexAccount;
|
||||
target: string;
|
||||
}): Promise<{
|
||||
roomId?: string;
|
||||
toPersonId?: string;
|
||||
toPersonEmail?: string;
|
||||
}> {
|
||||
const { account, target } = params;
|
||||
const normalized = normalizeWebexTarget(target);
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid target");
|
||||
}
|
||||
|
||||
// Email: send to person by email
|
||||
if (isEmail(normalized)) {
|
||||
return { toPersonEmail: normalized.toLowerCase() };
|
||||
}
|
||||
|
||||
// Try to determine type from ID
|
||||
if (isWebexId(normalized)) {
|
||||
try {
|
||||
const decoded = Buffer.from(normalized, "base64").toString("utf8");
|
||||
if (decoded.includes("ROOM")) {
|
||||
return { roomId: normalized };
|
||||
}
|
||||
if (decoded.includes("PEOPLE") || decoded.includes("PERSON")) {
|
||||
return { toPersonId: normalized };
|
||||
}
|
||||
} catch {
|
||||
// Decoding failed
|
||||
}
|
||||
|
||||
// Try to fetch as room - if it works, it's a room
|
||||
try {
|
||||
await getWebexRoom(account, normalized);
|
||||
return { roomId: normalized };
|
||||
} catch {
|
||||
// Not a room, assume person
|
||||
return { toPersonId: normalized };
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown format, try as email if it contains @
|
||||
if (normalized.includes("@")) {
|
||||
return { toPersonEmail: normalized.toLowerCase() };
|
||||
}
|
||||
|
||||
// Default: assume person ID
|
||||
return { toPersonId: normalized };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a person by email to their person ID
|
||||
*/
|
||||
export async function resolveWebexPersonByEmail(
|
||||
account: ResolvedWebexAccount,
|
||||
email: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const people = await listWebexPeople(account, { email: email.toLowerCase(), max: 1 });
|
||||
return people[0]?.id;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build target hints for messaging help text
|
||||
*/
|
||||
export function getWebexTargetHints(): string {
|
||||
return "<personId|roomId|email@example.com>";
|
||||
}
|
||||
169
extensions/webex/src/types.ts
Normal file
169
extensions/webex/src/types.ts
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Webex channel type definitions
|
||||
*/
|
||||
|
||||
/** Webex channel configuration */
|
||||
export type WebexConfig = {
|
||||
enabled?: boolean;
|
||||
defaultAccount?: string;
|
||||
|
||||
// Single account config (legacy/simple mode)
|
||||
botToken?: string;
|
||||
webhookSecret?: string;
|
||||
webhookPath?: string;
|
||||
webhookUrl?: string;
|
||||
botId?: string;
|
||||
botEmail?: string;
|
||||
|
||||
// DM policy
|
||||
dm?: WebexDmConfig;
|
||||
|
||||
// Group policy
|
||||
groupPolicy?: "disabled" | "allowlist" | "open";
|
||||
|
||||
// Room-specific configs
|
||||
rooms?: Record<string, WebexRoomConfig>;
|
||||
|
||||
// Multi-account mode
|
||||
accounts?: Record<string, WebexAccountConfig>;
|
||||
};
|
||||
|
||||
export type WebexAccountConfig = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
botToken?: string;
|
||||
webhookSecret?: string;
|
||||
webhookPath?: string;
|
||||
webhookUrl?: string;
|
||||
botId?: string;
|
||||
botEmail?: string;
|
||||
dm?: WebexDmConfig;
|
||||
groupPolicy?: "disabled" | "allowlist" | "open";
|
||||
rooms?: Record<string, WebexRoomConfig>;
|
||||
};
|
||||
|
||||
export type WebexDmConfig = {
|
||||
policy?: "open" | "pairing" | "allowlist" | "disabled";
|
||||
allowFrom?: string[];
|
||||
};
|
||||
|
||||
export type WebexRoomConfig = {
|
||||
allow?: boolean;
|
||||
requireMention?: boolean;
|
||||
systemPrompt?: string;
|
||||
users?: string[];
|
||||
};
|
||||
|
||||
/** Resolved account for runtime use */
|
||||
export type ResolvedWebexAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
config: WebexAccountConfig;
|
||||
credentialSource: WebexCredentialSource;
|
||||
botToken?: string;
|
||||
botId?: string;
|
||||
botEmail?: string;
|
||||
};
|
||||
|
||||
export type WebexCredentialSource = "config" | "env" | "none";
|
||||
|
||||
/** Webex API types */
|
||||
export type WebexPerson = {
|
||||
id: string;
|
||||
emails?: string[];
|
||||
displayName?: string;
|
||||
nickName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
avatar?: string;
|
||||
orgId?: string;
|
||||
created?: string;
|
||||
lastModified?: string;
|
||||
lastActivity?: string;
|
||||
status?: "active" | "inactive" | "OutOfOffice" | "DoNotDisturb" | "meeting" | "presenting" | "call" | "pending" | "unknown";
|
||||
type?: "person" | "bot";
|
||||
};
|
||||
|
||||
export type WebexRoom = {
|
||||
id: string;
|
||||
title?: string;
|
||||
type?: "direct" | "group";
|
||||
isLocked?: boolean;
|
||||
lastActivity?: string;
|
||||
creatorId?: string;
|
||||
created?: string;
|
||||
ownerId?: string;
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
export type WebexMessage = {
|
||||
id: string;
|
||||
roomId: string;
|
||||
roomType?: "direct" | "group";
|
||||
text?: string;
|
||||
html?: string;
|
||||
markdown?: string;
|
||||
files?: string[];
|
||||
personId: string;
|
||||
personEmail?: string;
|
||||
mentionedPeople?: string[];
|
||||
mentionedGroups?: string[];
|
||||
created?: string;
|
||||
updated?: string;
|
||||
parentId?: string;
|
||||
isVoiceClip?: boolean;
|
||||
attachments?: WebexAttachment[];
|
||||
};
|
||||
|
||||
export type WebexAttachment = {
|
||||
contentType: string;
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
export type WebexWebhookEvent = {
|
||||
id: string;
|
||||
name: string;
|
||||
targetUrl: string;
|
||||
resource: "messages" | "memberships" | "rooms" | "attachmentActions";
|
||||
event: "created" | "updated" | "deleted";
|
||||
orgId?: string;
|
||||
createdBy?: string;
|
||||
appId?: string;
|
||||
ownedBy?: string;
|
||||
filter?: string;
|
||||
status?: "active" | "inactive";
|
||||
secret?: string;
|
||||
actorId?: string;
|
||||
data: WebexWebhookData;
|
||||
};
|
||||
|
||||
export type WebexWebhookData = {
|
||||
id: string;
|
||||
roomId?: string;
|
||||
roomType?: "direct" | "group";
|
||||
personId?: string;
|
||||
personEmail?: string;
|
||||
created?: string;
|
||||
mentionedPeople?: string[];
|
||||
};
|
||||
|
||||
export type WebexSendMessageParams = {
|
||||
roomId?: string;
|
||||
toPersonId?: string;
|
||||
toPersonEmail?: string;
|
||||
text?: string;
|
||||
markdown?: string;
|
||||
files?: string[];
|
||||
parentId?: string;
|
||||
attachments?: WebexAttachment[];
|
||||
};
|
||||
|
||||
export type WebexApiError = {
|
||||
message: string;
|
||||
trackingId?: string;
|
||||
errors?: Array<{ description: string }>;
|
||||
};
|
||||
|
||||
/** Default account ID for single-account mode */
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
Loading…
Reference in New Issue
Block a user