Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/manage-sandboxes/messaging-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Refer to [Commands](../reference/commands) for details.
| Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | `SLACK_ALLOWED_USERS` for DM and channel `@mention` user allowlisting, `SLACK_ALLOWED_CHANNELS` for channel ID allowlisting |
| WeChat (experimental) | None. Captured through host-side QR scan during `$$nemoclaw onboard` | `WECHAT_ALLOWED_IDS` for DM allowlisting |
| WhatsApp (experimental) | None. Pair through QR after rebuild | None |
| Microsoft Teams (experimental) | `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, `MSTEAMS_TENANT_ID` | `TEAMS_ALLOWED_USERS` for Microsoft Entra ID object ID allowlisting, `MSTEAMS_PORT` for the local webhook port, `TEAMS_REQUIRE_MENTION` for OpenClaw group and channel mention mode |
| Microsoft Teams (experimental) | `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, `MSTEAMS_TENANT_ID` | `TEAMS_ALLOWED_USERS` for direct-message Microsoft Entra ID object ID allowlisting, `TEAMS_GROUP_POLICY` and `TEAMS_GROUP_ALLOWED_USERS` for OpenClaw group and channel sender allowlisting, `MSTEAMS_PORT` for the local webhook port, `TEAMS_REQUIRE_MENTION` for OpenClaw group and channel mention mode |

Telegram uses a bot token from [BotFather](https://t.me/BotFather).
Open Telegram, send `/newbot` to [@BotFather](https://t.me/BotFather), follow the prompts, and copy the token.
Expand Down Expand Up @@ -113,7 +113,11 @@ No two active Teams sandboxes can share the same local webhook port, so NemoClaw
Teams requires an app ID, client secret, and tenant ID.
Set `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, and `MSTEAMS_TENANT_ID`, or use the aliases `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, and `TEAMS_TENANT_ID`.
The client secret is stored as the `<sandbox>-teams-bridge` OpenShell provider and reaches the sandbox as an OpenShell placeholder, not as a raw value baked into the image.
Use `TEAMS_ALLOWED_USERS` or `MSTEAMS_ALLOWED_USERS` to list comma-separated Microsoft Entra ID object IDs that may interact with the bot.
Use `TEAMS_ALLOWED_USERS` or `MSTEAMS_ALLOWED_USERS` to list comma-separated Microsoft Entra ID object IDs that may direct-message the bot.
This direct-message allowlist does not restrict OpenClaw group or channel senders.
OpenClaw group and channel messages stay open by default, subject to mention mode.
Set `TEAMS_GROUP_POLICY=allowlist` and `TEAMS_GROUP_ALLOWED_USERS=<entra-object-id>[,<entra-object-id>]` to render `channels.msteams.groupPolicy="allowlist"` and `channels.msteams.groupAllowFrom` in `openclaw.json`.
If `TEAMS_GROUP_POLICY=allowlist` is set without `TEAMS_GROUP_ALLOWED_USERS`, NemoClaw keeps the rendered group policy open to preserve the default mention-gated group behavior.
For OpenClaw group and channel messages, `TEAMS_REQUIRE_MENTION` defaults to `1`, so the bot replies only when mentioned.
Direct messages are unaffected by mention mode.

Expand Down Expand Up @@ -182,6 +186,8 @@ export MSTEAMS_APP_ID=<your-teams-app-id>
export MSTEAMS_APP_PASSWORD=<your-teams-client-secret>
export MSTEAMS_TENANT_ID=<your-teams-tenant-id>
export TEAMS_ALLOWED_USERS=<your-entra-object-id>
export TEAMS_GROUP_POLICY=allowlist
export TEAMS_GROUP_ALLOWED_USERS=<your-entra-object-id>
export MSTEAMS_PORT=3978
```

Expand Down Expand Up @@ -233,7 +239,7 @@ Verify the gateway bridge before relying on the channel.
Restore the preset YAML and re-run `$$nemoclaw <sandbox> channels add <channel>`.
Choose the rebuild so the running sandbox image picks up the new channel.
For Telegram, Discord, Slack, and Microsoft Teams, `channels add` also checks the rebuilt runtime for the selected bridge and reports startup, credential, or missing-plugin warnings before returning.
If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `TELEGRAM_GROUP_POLICY`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, `SLACK_ALLOWED_CHANNELS`, `TEAMS_ALLOWED_USERS`, `MSTEAMS_PORT`, or `TEAMS_REQUIRE_MENTION`, export them before the rebuild starts.
If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `TELEGRAM_GROUP_POLICY`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, `SLACK_ALLOWED_CHANNELS`, `TEAMS_ALLOWED_USERS`, `TEAMS_GROUP_POLICY`, `TEAMS_GROUP_ALLOWED_USERS`, `MSTEAMS_PORT`, or `TEAMS_REQUIRE_MENTION`, export them before the rebuild starts.
You can omit `TELEGRAM_REQUIRE_MENTION` and `DISCORD_REQUIRE_MENTION` when you want the default mention-only mode.
You can omit `TELEGRAM_GROUP_POLICY` when you want OpenClaw Telegram group access to stay open.
Telegram Bot API `sendMessage` calls prove outbound delivery from the bot; to test inbound agent replies, send a message from the Telegram client as an allowed user.
Expand Down Expand Up @@ -271,6 +277,8 @@ MSTEAMS_APP_ID=<your-teams-app-id> \
MSTEAMS_APP_PASSWORD=<your-teams-client-secret> \
MSTEAMS_TENANT_ID=<your-teams-tenant-id> \
TEAMS_ALLOWED_USERS=<your-entra-object-id> \
TEAMS_GROUP_POLICY=allowlist \
TEAMS_GROUP_ALLOWED_USERS=<your-entra-object-id> \
MSTEAMS_PORT=3978 \
$$nemoclaw my-assistant channels add teams
```
Expand Down
46 changes: 40 additions & 6 deletions src/lib/messaging/channels/manifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,12 +663,11 @@ describe("built-in channel manifests", () => {
const clientSecret = findInput(teamsManifest, "clientSecret");
const tenantId = findInput(teamsManifest, "tenantId");
const allowedUsers = findInput(teamsManifest, "allowedUsers");
const groupPolicy = findInput(teamsManifest, "groupPolicy");
const groupAllowFrom = findInput(teamsManifest, "groupAllowFrom");
const webhookPort = findInput(teamsManifest, "webhookPort");
const requireMention = findInput(teamsManifest, "requireMention");

expect(() => findInput(teamsManifest, "groupPolicy")).toThrow(
/missing input teams\.groupPolicy/,
);
expect(getChannelTokenKeys(KNOWN_CHANNELS.teams)).toEqual(["MSTEAMS_APP_PASSWORD"]);
expect(teamsManifest.description).toContain("experimental");
expect(appId.envKey).toBe("MSTEAMS_APP_ID");
Expand All @@ -681,6 +680,16 @@ describe("built-in channel manifests", () => {
expect(allowedUsers.envKey).toBe("TEAMS_ALLOWED_USERS");
expect(allowedUsers.envAliases).toEqual(["MSTEAMS_ALLOWED_USERS"]);
expect(allowedUsers.required).toBe(false);
expect(allowedUsers.prompt?.help).toContain("Direct-message allowlist only");
expect(groupPolicy.envKey).toBe("TEAMS_GROUP_POLICY");
expect(groupPolicy.validValues).toEqual(["open", "allowlist", "disabled"]);
expect(groupPolicy).toMatchObject({ kind: "config", defaultValue: "open" });
expect(groupAllowFrom.envKey).toBe("TEAMS_GROUP_ALLOWED_USERS");
expect(groupAllowFrom.envAliases).toEqual([
"MSTEAMS_GROUP_ALLOWED_USERS",
"TEAMS_GROUP_ALLOW_FROM",
]);
expect(groupAllowFrom.prompt?.help).toContain("TEAMS_GROUP_POLICY=allowlist");
expect(webhookPort.envKey).toBe("MSTEAMS_PORT");
expect(webhookPort.envAliases).toEqual(["TEAMS_PORT"]);
expect(webhookPort).toMatchObject({ kind: "config", defaultValue: "3978" });
Expand Down Expand Up @@ -727,8 +736,10 @@ describe("built-in channel manifests", () => {
expect(renderJson(teamsManifest)).toContain('"path":"platforms.teams"');
expect(renderJson(teamsManifest)).toContain("credential.teamsClientSecret.placeholder");
expect(renderJson(teamsManifest)).toContain("teamsConfig.webhookPort");
expect(renderJson(teamsManifest)).toContain('"groupPolicy":"open"');
expect(renderJson(teamsManifest)).not.toContain("groupAllowFrom");
expect(renderJson(teamsManifest)).toContain('"groupPolicy":"{{teamsConfig.groupPolicy}}"');
expect(renderJson(teamsManifest)).toContain(
'"groupAllowFrom":"{{teamsConfig.groupAllowFrom}}"',
);
expectTokenPasteEnrollHook(teamsManifest, ["clientSecret"]);
expect(findHook(teamsManifest, "teams-config-prompt")).toMatchObject({
phase: "enroll",
Expand All @@ -748,6 +759,14 @@ describe("built-in channel manifests", () => {
id: "allowedUsers",
kind: "config",
},
{
id: "groupPolicy",
kind: "config",
},
{
id: "groupAllowFrom",
kind: "config",
},
{
id: "webhookPort",
kind: "config",
Expand Down Expand Up @@ -783,7 +802,14 @@ describe("built-in channel manifests", () => {
});
expect(teamsManifest.state).toEqual({
persist: {
teamsConfig: ["appId", "tenantId", "webhookPort", "requireMention"],
teamsConfig: [
"appId",
"tenantId",
"groupPolicy",
"groupAllowFrom",
"webhookPort",
"requireMention",
],
allowedIds: ["allowedUsers"],
},
rebuildHydration: [
Expand All @@ -799,6 +825,14 @@ describe("built-in channel manifests", () => {
statePath: "allowedIds.teams",
env: "TEAMS_ALLOWED_USERS",
},
{
statePath: "teamsConfig.groupPolicy",
env: "TEAMS_GROUP_POLICY",
},
{
statePath: "teamsConfig.groupAllowFrom",
env: "TEAMS_GROUP_ALLOWED_USERS",
},
{
statePath: "teamsConfig.webhookPort",
env: "MSTEAMS_PORT",
Expand Down
60 changes: 55 additions & 5 deletions src/lib/messaging/channels/teams/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const teamsManifest = {
description: "Microsoft Teams bot messaging (experimental)",
enrollmentNotes: [
"Microsoft Teams requires a public HTTPS webhook endpoint at /api/messages; expose the configured Teams webhook port before installing the Teams app.",
"Use Azure AD object IDs in TEAMS_ALLOWED_USERS so only authorized users can interact with the bot.",
"Use Azure AD object IDs in TEAMS_ALLOWED_USERS for direct-message access. Restrict OpenClaw group and channel senders separately with TEAMS_GROUP_ALLOWED_USERS.",
],
supportedAgents: ["openclaw", "hermes"],
auth: {
Expand Down Expand Up @@ -60,8 +60,34 @@ export const teamsManifest = {
envAliases: ["MSTEAMS_ALLOWED_USERS"],
statePath: "allowedIds.teams",
prompt: {
label: "Microsoft Teams AAD Object IDs (comma-separated allowlist)",
help: "Recommended: run `teams status --verbose` and enter the Azure AD object IDs allowed to use the bot.",
label: "Microsoft Teams AAD Object IDs (DM allowlist)",
help: "Direct-message allowlist only. Run `teams status --verbose` and enter comma-separated Azure AD object IDs allowed to DM the bot.",
},
},
{
id: "groupPolicy",
kind: "config",
required: false,
envKey: "TEAMS_GROUP_POLICY",
statePath: "teamsConfig.groupPolicy",
validValues: ["open", "allowlist", "disabled"],
defaultValue: "open",
prompt: {
label: "Microsoft Teams group policy",
help: "Controls OpenClaw group and channel access. Use allowlist with Teams group sender IDs, or keep open for mention-gated group access.",
},
},
{
id: "groupAllowFrom",
kind: "config",
required: false,
envKey: "TEAMS_GROUP_ALLOWED_USERS",
envAliases: ["MSTEAMS_GROUP_ALLOWED_USERS", "TEAMS_GROUP_ALLOW_FROM"],
statePath: "teamsConfig.groupAllowFrom",
prompt: {
label: "Microsoft Teams group sender IDs (comma-separated allowlist)",
help: "OpenClaw group and channel allowlist. Set this with TEAMS_GROUP_POLICY=allowlist to restrict group replies to these Entra object IDs.",
emptyValueMessage: "group and channel messages stay open when group policy is open",
},
},
{
Expand Down Expand Up @@ -128,7 +154,8 @@ export const teamsManifest = {
},
dmPolicy: "{{allowedIds.teams.dmPolicy}}",
allowFrom: "{{allowedIds.teams.values}}",
groupPolicy: "open",
groupPolicy: "{{teamsConfig.groupPolicy}}",
groupAllowFrom: "{{teamsConfig.groupAllowFrom}}",
requireMention: "{{teamsConfig.requireMention}}",
},
},
Expand Down Expand Up @@ -206,7 +233,14 @@ export const teamsManifest = {
],
state: {
persist: {
teamsConfig: ["appId", "tenantId", "webhookPort", "requireMention"],
teamsConfig: [
"appId",
"tenantId",
"groupPolicy",
"groupAllowFrom",
"webhookPort",
"requireMention",
],
allowedIds: ["allowedUsers"],
},
rebuildHydration: [
Expand All @@ -222,6 +256,14 @@ export const teamsManifest = {
statePath: "allowedIds.teams",
env: "TEAMS_ALLOWED_USERS",
},
{
statePath: "teamsConfig.groupPolicy",
env: "TEAMS_GROUP_POLICY",
},
{
statePath: "teamsConfig.groupAllowFrom",
env: "TEAMS_GROUP_ALLOWED_USERS",
},
{
statePath: "teamsConfig.webhookPort",
env: "MSTEAMS_PORT",
Expand Down Expand Up @@ -283,6 +325,14 @@ export const teamsManifest = {
id: "allowedUsers",
kind: "config",
},
{
id: "groupPolicy",
kind: "config",
},
{
id: "groupAllowFrom",
kind: "config",
},
{
id: "webhookPort",
kind: "config",
Expand Down
22 changes: 22 additions & 0 deletions src/lib/messaging/channels/teams/template-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
nonEmptyArray,
nonEmptyCsv,
nonEmptyString,
parseList,
parseBoolean,
resolvedRenderTemplateReference,
stateValue,
Expand All @@ -34,6 +35,10 @@ export const resolveTeamsTemplateReference: BuiltInRenderTemplateResolver = (
return resolvedRenderTemplateReference(
parseBoolean(stateValue(context, "teamsConfig.requireMention")),
);
case "teamsConfig.groupPolicy":
return resolvedRenderTemplateReference(teamsGroupPolicy(context));
case "teamsConfig.groupAllowFrom":
return resolvedRenderTemplateReference(nonEmptyArray(teamsGroupAllowFrom(context)));
default:
break;
}
Expand Down Expand Up @@ -64,3 +69,20 @@ function teamsWebhookPort(context: RenderTemplateContext): number {
}
return port;
}

function teamsGroupAllowFrom(context: RenderTemplateContext): string[] {
if (rawTeamsGroupPolicy(context) !== "allowlist") return [];
return parseList(stateValue(context, "teamsConfig.groupAllowFrom"));
}

function teamsGroupPolicy(context: RenderTemplateContext): string {
const raw = rawTeamsGroupPolicy(context);
if (raw === "allowlist") {
return teamsGroupAllowFrom(context).length > 0 ? "allowlist" : "open";
}
return raw;
}

function rawTeamsGroupPolicy(context: RenderTemplateContext): string {
return nonEmptyString(stateValue(context, "teamsConfig.groupPolicy")) ?? "open";
}
32 changes: 32 additions & 0 deletions src/lib/messaging/compiler/manifest-compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,8 @@ describe("ManifestCompiler", () => {
TEAMS_ALLOWED_USERS: "00000000-0000-0000-0000-000000000001",
MSTEAMS_PORT: undefined,
TEAMS_PORT: undefined,
TEAMS_GROUP_POLICY: undefined,
TEAMS_GROUP_ALLOWED_USERS: undefined,
TEAMS_REQUIRE_MENTION: undefined,
},
() =>
Expand Down Expand Up @@ -569,12 +571,42 @@ describe("ManifestCompiler", () => {
expect(JSON.stringify(plan.agentRender)).toContain('"requireMention":true');
});

it("renders Microsoft Teams OpenClaw group allowlist when configured", async () => {
const plan = await withEnv(
{
...TEST_TEAMS_ENV,
TEAMS_GROUP_POLICY: "allowlist",
TEAMS_GROUP_ALLOWED_USERS:
"10000000-0000-0000-0000-000000000001,20000000-0000-0000-0000-000000000002",
},
() =>
compiler().compile({
sandboxName: "demo",
agent: "openclaw",
workflow: "rebuild",
isInteractive: false,
configuredChannels: ["teams"],
credentialAvailability: {
MSTEAMS_APP_PASSWORD: true,
},
}),
);

const rendered = JSON.stringify(plan.agentRender);
expect(rendered).toContain('"groupPolicy":"allowlist"');
expect(rendered).toContain(
'"groupAllowFrom":["10000000-0000-0000-0000-000000000001","20000000-0000-0000-0000-000000000002"]',
);
});

it("keeps Microsoft Teams active when no explicit user allowlist is provided", async () => {
const plan = await withEnv(
{
MSTEAMS_APP_ID: "test-teams-app-id",
MSTEAMS_TENANT_ID: "test-teams-tenant-id",
TEAMS_ALLOWED_USERS: undefined,
TEAMS_GROUP_POLICY: undefined,
TEAMS_GROUP_ALLOWED_USERS: undefined,
},
() =>
compiler().compile({
Expand Down
Loading