@openclaw/msteams 2026.3.13 → 2026.5.2-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.ts +3 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +2 -0
- package/config-api.ts +4 -0
- package/contract-api.ts +4 -0
- package/index.ts +15 -12
- package/openclaw.plugin.json +553 -1
- package/package.json +46 -12
- package/runtime-api.ts +73 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/src/ai-entity.ts +7 -0
- package/src/approval-auth.ts +44 -0
- package/src/attachments/bot-framework.test.ts +461 -0
- package/src/attachments/bot-framework.ts +362 -0
- package/src/attachments/download.ts +63 -19
- package/src/attachments/graph.test.ts +416 -0
- package/src/attachments/graph.ts +163 -72
- package/src/attachments/html.ts +33 -1
- package/src/attachments/payload.ts +1 -1
- package/src/attachments/remote-media.test.ts +137 -0
- package/src/attachments/remote-media.ts +75 -8
- package/src/attachments/shared.test.ts +138 -1
- package/src/attachments/shared.ts +193 -26
- package/src/attachments/types.ts +10 -0
- package/src/attachments.graph.test.ts +342 -0
- package/src/attachments.helpers.test.ts +246 -0
- package/src/attachments.test-helpers.ts +17 -0
- package/src/attachments.test.ts +163 -418
- package/src/attachments.ts +5 -5
- package/src/block-streaming-config.test.ts +61 -0
- package/src/channel-api.ts +1 -0
- package/src/channel.actions.test.ts +742 -0
- package/src/channel.directory.test.ts +145 -4
- package/src/channel.runtime.ts +56 -0
- package/src/channel.setup.ts +77 -0
- package/src/channel.test.ts +128 -0
- package/src/channel.ts +1077 -395
- package/src/config-schema.ts +6 -0
- package/src/config-ui-hints.ts +12 -0
- package/src/conversation-store-fs.test.ts +4 -5
- package/src/conversation-store-fs.ts +35 -51
- package/src/conversation-store-helpers.test.ts +202 -0
- package/src/conversation-store-helpers.ts +105 -0
- package/src/conversation-store-memory.ts +27 -23
- package/src/conversation-store.shared.test.ts +225 -0
- package/src/conversation-store.ts +30 -0
- package/src/directory-live.test.ts +156 -0
- package/src/directory-live.ts +7 -4
- package/src/doctor.ts +27 -0
- package/src/errors.test.ts +64 -1
- package/src/errors.ts +50 -9
- package/src/feedback-reflection-prompt.ts +117 -0
- package/src/feedback-reflection-store.ts +114 -0
- package/src/feedback-reflection.test.ts +237 -0
- package/src/feedback-reflection.ts +283 -0
- package/src/file-consent-helpers.test.ts +83 -0
- package/src/file-consent-helpers.ts +64 -11
- package/src/file-consent-invoke.ts +150 -0
- package/src/file-consent.test.ts +363 -0
- package/src/file-consent.ts +165 -4
- package/src/graph-chat.ts +5 -3
- package/src/graph-group-management.test.ts +318 -0
- package/src/graph-group-management.ts +168 -0
- package/src/graph-members.test.ts +89 -0
- package/src/graph-members.ts +48 -0
- package/src/graph-messages.actions.test.ts +243 -0
- package/src/graph-messages.read.test.ts +391 -0
- package/src/graph-messages.search.test.ts +213 -0
- package/src/graph-messages.test-helpers.ts +50 -0
- package/src/graph-messages.ts +534 -0
- package/src/graph-teams.test.ts +215 -0
- package/src/graph-teams.ts +114 -0
- package/src/graph-thread.test.ts +246 -0
- package/src/graph-thread.ts +146 -0
- package/src/graph-upload.test.ts +161 -4
- package/src/graph-upload.ts +147 -56
- package/src/graph.test.ts +516 -0
- package/src/graph.ts +233 -21
- package/src/inbound.test.ts +156 -1
- package/src/inbound.ts +101 -1
- package/src/media-helpers.ts +1 -1
- package/src/mentions.test.ts +27 -18
- package/src/mentions.ts +2 -2
- package/src/messenger.test.ts +504 -23
- package/src/messenger.ts +133 -52
- package/src/monitor-handler/access.ts +125 -0
- package/src/monitor-handler/inbound-media.test.ts +289 -0
- package/src/monitor-handler/inbound-media.ts +57 -5
- package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
- package/src/monitor-handler/message-handler.authz.test.ts +588 -74
- package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
- package/src/monitor-handler/message-handler.test-support.ts +100 -0
- package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
- package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
- package/src/monitor-handler/message-handler.ts +470 -164
- package/src/monitor-handler/reaction-handler.test.ts +267 -0
- package/src/monitor-handler/reaction-handler.ts +210 -0
- package/src/monitor-handler/thread-session.ts +17 -0
- package/src/monitor-handler.adaptive-card.test.ts +162 -0
- package/src/monitor-handler.feedback-authz.test.ts +314 -0
- package/src/monitor-handler.file-consent.test.ts +281 -79
- package/src/monitor-handler.sso.test.ts +563 -0
- package/src/monitor-handler.test-helpers.ts +180 -0
- package/src/monitor-handler.ts +459 -115
- package/src/monitor-handler.types.ts +27 -0
- package/src/monitor-types.ts +1 -0
- package/src/monitor.lifecycle.test.ts +74 -10
- package/src/monitor.test.ts +35 -1
- package/src/monitor.ts +143 -46
- package/src/oauth.flow.ts +77 -0
- package/src/oauth.shared.ts +37 -0
- package/src/oauth.test.ts +305 -0
- package/src/oauth.token.ts +158 -0
- package/src/oauth.ts +130 -0
- package/src/outbound.test.ts +10 -11
- package/src/outbound.ts +62 -44
- package/src/pending-uploads-fs.test.ts +246 -0
- package/src/pending-uploads-fs.ts +235 -0
- package/src/pending-uploads.test.ts +173 -0
- package/src/pending-uploads.ts +34 -2
- package/src/policy.test.ts +11 -5
- package/src/policy.ts +5 -5
- package/src/polls.test.ts +106 -5
- package/src/polls.ts +15 -7
- package/src/presentation.ts +68 -0
- package/src/probe.test.ts +27 -8
- package/src/probe.ts +43 -9
- package/src/reply-dispatcher.test.ts +437 -0
- package/src/reply-dispatcher.ts +259 -73
- package/src/reply-stream-controller.test.ts +235 -0
- package/src/reply-stream-controller.ts +147 -0
- package/src/resolve-allowlist.test.ts +105 -1
- package/src/resolve-allowlist.ts +112 -7
- package/src/runtime.ts +6 -3
- package/src/sdk-types.ts +43 -3
- package/src/sdk.test.ts +666 -0
- package/src/sdk.ts +867 -16
- package/src/secret-contract.ts +49 -0
- package/src/secret-input.ts +1 -1
- package/src/send-context.ts +76 -9
- package/src/send.test.ts +389 -5
- package/src/send.ts +140 -32
- package/src/sent-message-cache.ts +30 -18
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +160 -0
- package/src/setup-surface.test.ts +202 -0
- package/src/setup-surface.ts +320 -0
- package/src/sso-token-store.test.ts +72 -0
- package/src/sso-token-store.ts +166 -0
- package/src/sso.ts +300 -0
- package/src/storage.ts +1 -1
- package/src/store-fs.ts +2 -2
- package/src/streaming-message.test.ts +262 -0
- package/src/streaming-message.ts +297 -0
- package/src/test-runtime.ts +1 -1
- package/src/thread-parent-context.test.ts +224 -0
- package/src/thread-parent-context.ts +159 -0
- package/src/token.test.ts +237 -50
- package/src/token.ts +162 -7
- package/src/user-agent.test.ts +86 -0
- package/src/user-agent.ts +53 -0
- package/src/webhook-timeouts.ts +27 -0
- package/src/welcome-card.test.ts +81 -0
- package/src/welcome-card.ts +57 -0
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -107
- package/src/file-lock.ts +0 -1
- package/src/graph-users.test.ts +0 -66
- package/src/onboarding.ts +0 -381
- package/src/polls-store.test.ts +0 -38
- package/src/revoked-context.test.ts +0 -39
- package/src/token-response.test.ts +0 -23
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createMSTeamsSetupWizardBase, msteamsSetupAdapter } from "./setup-core.js";
|
|
5
|
+
import { openDelegatedOAuthUrl } from "./setup-surface.js";
|
|
6
|
+
|
|
7
|
+
const spawn = vi.hoisted(() => vi.fn());
|
|
8
|
+
const resolveMSTeamsUserAllowlist = vi.hoisted(() => vi.fn());
|
|
9
|
+
const resolveMSTeamsChannelAllowlist = vi.hoisted(() => vi.fn());
|
|
10
|
+
const normalizeSecretInputString = vi.hoisted(() =>
|
|
11
|
+
vi.fn((value: unknown) => (typeof value === "string" ? value.trim() || undefined : undefined)),
|
|
12
|
+
);
|
|
13
|
+
const hasConfiguredMSTeamsCredentials = vi.hoisted(() => vi.fn());
|
|
14
|
+
const resolveMSTeamsCredentials = vi.hoisted(() => vi.fn());
|
|
15
|
+
|
|
16
|
+
vi.mock("./resolve-allowlist.js", () => ({
|
|
17
|
+
parseMSTeamsTeamEntry: vi.fn(),
|
|
18
|
+
resolveMSTeamsChannelAllowlist,
|
|
19
|
+
resolveMSTeamsUserAllowlist,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("./secret-input.js", () => ({
|
|
23
|
+
normalizeSecretInputString,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("./token.js", () => ({
|
|
27
|
+
hasConfiguredMSTeamsCredentials,
|
|
28
|
+
resolveMSTeamsCredentials,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
32
|
+
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
33
|
+
return {
|
|
34
|
+
...actual,
|
|
35
|
+
spawn,
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("msteams setup surface", () => {
|
|
40
|
+
const msteamsSetupWizard = createMSTeamsSetupWizardBase();
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
spawn.mockReset();
|
|
44
|
+
resolveMSTeamsUserAllowlist.mockReset();
|
|
45
|
+
resolveMSTeamsChannelAllowlist.mockReset();
|
|
46
|
+
normalizeSecretInputString.mockClear();
|
|
47
|
+
hasConfiguredMSTeamsCredentials.mockReset();
|
|
48
|
+
resolveMSTeamsCredentials.mockReset();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.unstubAllEnvs();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("always resolves to the default account", () => {
|
|
56
|
+
expect(msteamsSetupAdapter.resolveAccountId?.({ accountId: "work" } as never)).toBe(
|
|
57
|
+
DEFAULT_ACCOUNT_ID,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("opens delegated OAuth URLs without invoking a shell", async () => {
|
|
62
|
+
const url = "https://login.microsoftonline.com/auth?state=$(touch pwned)";
|
|
63
|
+
const child = new EventEmitter();
|
|
64
|
+
spawn.mockReturnValue(child);
|
|
65
|
+
|
|
66
|
+
const result = openDelegatedOAuthUrl(url);
|
|
67
|
+
child.emit("exit", 0, null);
|
|
68
|
+
|
|
69
|
+
await expect(result).resolves.toBeUndefined();
|
|
70
|
+
expect(spawn).toHaveBeenCalledWith(process.platform === "darwin" ? "open" : "xdg-open", [url], {
|
|
71
|
+
stdio: "ignore",
|
|
72
|
+
shell: false,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("enables the msteams channel without dropping existing config", () => {
|
|
77
|
+
expect(
|
|
78
|
+
msteamsSetupAdapter.applyAccountConfig?.({
|
|
79
|
+
cfg: {
|
|
80
|
+
channels: {
|
|
81
|
+
msteams: {
|
|
82
|
+
appId: "existing-app",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
87
|
+
input: {},
|
|
88
|
+
} as never),
|
|
89
|
+
).toEqual({
|
|
90
|
+
channels: {
|
|
91
|
+
msteams: {
|
|
92
|
+
appId: "existing-app",
|
|
93
|
+
enabled: true,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reports configured status from resolved credentials", async () => {
|
|
100
|
+
resolveMSTeamsCredentials.mockReturnValue({
|
|
101
|
+
appId: "app",
|
|
102
|
+
});
|
|
103
|
+
hasConfiguredMSTeamsCredentials.mockReturnValue(false);
|
|
104
|
+
|
|
105
|
+
expect(
|
|
106
|
+
msteamsSetupWizard.status.resolveConfigured({
|
|
107
|
+
cfg: { channels: { msteams: {} } },
|
|
108
|
+
} as never),
|
|
109
|
+
).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("reports configured status from configured credentials and renders status lines", async () => {
|
|
113
|
+
resolveMSTeamsCredentials.mockReturnValue(null);
|
|
114
|
+
hasConfiguredMSTeamsCredentials.mockReturnValue(true);
|
|
115
|
+
|
|
116
|
+
expect(
|
|
117
|
+
msteamsSetupWizard.status.resolveConfigured({
|
|
118
|
+
cfg: { channels: { msteams: {} } },
|
|
119
|
+
} as never),
|
|
120
|
+
).toBe(true);
|
|
121
|
+
|
|
122
|
+
hasConfiguredMSTeamsCredentials.mockReturnValue(false);
|
|
123
|
+
expect(msteamsSetupWizard.status.resolveStatusLines).toBeTypeOf("function");
|
|
124
|
+
await expect(
|
|
125
|
+
msteamsSetupWizard.status.resolveStatusLines?.({
|
|
126
|
+
cfg: { channels: { msteams: {} } },
|
|
127
|
+
} as never),
|
|
128
|
+
).resolves.toEqual(["MS Teams: needs app credentials"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("finalize keeps env credentials when available and accepted", async () => {
|
|
132
|
+
vi.stubEnv("MSTEAMS_APP_ID", "env-app");
|
|
133
|
+
vi.stubEnv("MSTEAMS_APP_PASSWORD", "env-secret");
|
|
134
|
+
vi.stubEnv("MSTEAMS_TENANT_ID", "env-tenant");
|
|
135
|
+
resolveMSTeamsCredentials.mockReturnValue(null);
|
|
136
|
+
hasConfiguredMSTeamsCredentials.mockReturnValue(false);
|
|
137
|
+
|
|
138
|
+
const result = await msteamsSetupWizard.finalize?.({
|
|
139
|
+
cfg: { channels: { msteams: { existing: true } } },
|
|
140
|
+
prompter: {
|
|
141
|
+
confirm: vi.fn(async () => true),
|
|
142
|
+
note: vi.fn(async () => {}),
|
|
143
|
+
text: vi.fn(),
|
|
144
|
+
},
|
|
145
|
+
} as never);
|
|
146
|
+
|
|
147
|
+
expect(result).toEqual({
|
|
148
|
+
accountId: "default",
|
|
149
|
+
cfg: {
|
|
150
|
+
channels: {
|
|
151
|
+
msteams: {
|
|
152
|
+
existing: true,
|
|
153
|
+
enabled: true,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("finalize prompts for manual credentials when env/config creds are unavailable", async () => {
|
|
161
|
+
resolveMSTeamsCredentials.mockReturnValue(null);
|
|
162
|
+
hasConfiguredMSTeamsCredentials.mockReturnValue(false);
|
|
163
|
+
const note = vi.fn(async () => {});
|
|
164
|
+
const confirm = vi.fn(async () => false);
|
|
165
|
+
const text = vi.fn(async ({ message }: { message: string }) => {
|
|
166
|
+
if (message === "Enter MS Teams App ID") {
|
|
167
|
+
return "app-id";
|
|
168
|
+
}
|
|
169
|
+
if (message === "Enter MS Teams App Password") {
|
|
170
|
+
return "app-password";
|
|
171
|
+
}
|
|
172
|
+
if (message === "Enter MS Teams Tenant ID") {
|
|
173
|
+
return "tenant-id";
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`Unexpected prompt: ${message}`);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const result = await msteamsSetupWizard.finalize?.({
|
|
179
|
+
cfg: { channels: { msteams: {} } },
|
|
180
|
+
prompter: {
|
|
181
|
+
confirm,
|
|
182
|
+
note,
|
|
183
|
+
text,
|
|
184
|
+
},
|
|
185
|
+
} as never);
|
|
186
|
+
|
|
187
|
+
expect(note).toHaveBeenCalled();
|
|
188
|
+
expect(result).toEqual({
|
|
189
|
+
accountId: "default",
|
|
190
|
+
cfg: {
|
|
191
|
+
channels: {
|
|
192
|
+
msteams: {
|
|
193
|
+
enabled: true,
|
|
194
|
+
appId: "app-id",
|
|
195
|
+
appPassword: "app-password",
|
|
196
|
+
tenantId: "tenant-id",
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
createTopLevelChannelAllowFromSetter,
|
|
4
|
+
createTopLevelChannelDmPolicy,
|
|
5
|
+
createTopLevelChannelGroupPolicySetter,
|
|
6
|
+
mergeAllowFromEntries,
|
|
7
|
+
splitSetupEntries,
|
|
8
|
+
type ChannelSetupDmPolicy,
|
|
9
|
+
type ChannelSetupWizard,
|
|
10
|
+
type OpenClawConfig,
|
|
11
|
+
type WizardPrompter,
|
|
12
|
+
} from "openclaw/plugin-sdk/setup";
|
|
13
|
+
import type { MSTeamsTeamConfig } from "../runtime-api.js";
|
|
14
|
+
import { formatUnknownError } from "./errors.js";
|
|
15
|
+
import {
|
|
16
|
+
parseMSTeamsTeamEntry,
|
|
17
|
+
resolveMSTeamsChannelAllowlist,
|
|
18
|
+
resolveMSTeamsUserAllowlist,
|
|
19
|
+
} from "./resolve-allowlist.js";
|
|
20
|
+
import { createMSTeamsSetupWizardBase } from "./setup-core.js";
|
|
21
|
+
import { resolveMSTeamsCredentials, saveDelegatedTokens } from "./token.js";
|
|
22
|
+
|
|
23
|
+
const channel = "msteams" as const;
|
|
24
|
+
const setMSTeamsAllowFrom = createTopLevelChannelAllowFromSetter({
|
|
25
|
+
channel,
|
|
26
|
+
});
|
|
27
|
+
const setMSTeamsGroupPolicy = createTopLevelChannelGroupPolicySetter({
|
|
28
|
+
channel,
|
|
29
|
+
enabled: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export function openDelegatedOAuthUrl(url: string): Promise<void> {
|
|
33
|
+
return new Promise<void>((resolve, reject) => {
|
|
34
|
+
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
35
|
+
const child = spawn(cmd, [url], { stdio: "ignore", shell: false });
|
|
36
|
+
child.once("error", reject);
|
|
37
|
+
child.once("exit", (code, signal) => {
|
|
38
|
+
if (code === 0) {
|
|
39
|
+
resolve();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const reason = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`;
|
|
43
|
+
reject(new Error(`${cmd} failed with ${reason}`));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function looksLikeGuid(value: string): boolean {
|
|
49
|
+
return /^[0-9a-fA-F-]{16,}$/.test(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function promptMSTeamsAllowFrom(params: {
|
|
53
|
+
cfg: OpenClawConfig;
|
|
54
|
+
prompter: WizardPrompter;
|
|
55
|
+
}): Promise<OpenClawConfig> {
|
|
56
|
+
const existing = params.cfg.channels?.msteams?.allowFrom ?? [];
|
|
57
|
+
await params.prompter.note(
|
|
58
|
+
[
|
|
59
|
+
"Allowlist MS Teams DMs by display name, UPN/email, or user id.",
|
|
60
|
+
"We resolve names to user IDs via Microsoft Graph when credentials allow.",
|
|
61
|
+
"Examples:",
|
|
62
|
+
"- alex@example.com",
|
|
63
|
+
"- Alex Johnson",
|
|
64
|
+
"- 00000000-0000-0000-0000-000000000000",
|
|
65
|
+
].join("\n"),
|
|
66
|
+
"MS Teams allowlist",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
while (true) {
|
|
70
|
+
const entry = await params.prompter.text({
|
|
71
|
+
message: "MS Teams allowFrom (usernames or ids)",
|
|
72
|
+
placeholder: "alex@example.com, Alex Johnson",
|
|
73
|
+
initialValue: existing[0] ? existing[0] : undefined,
|
|
74
|
+
validate: (value) => (value.trim() ? undefined : "Required"),
|
|
75
|
+
});
|
|
76
|
+
const parts = splitSetupEntries(entry);
|
|
77
|
+
if (parts.length === 0) {
|
|
78
|
+
await params.prompter.note("Enter at least one user.", "MS Teams allowlist");
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const resolved = await resolveMSTeamsUserAllowlist({
|
|
83
|
+
cfg: params.cfg,
|
|
84
|
+
entries: parts,
|
|
85
|
+
}).catch(() => null);
|
|
86
|
+
|
|
87
|
+
if (!resolved) {
|
|
88
|
+
const ids = parts.filter((part) => looksLikeGuid(part));
|
|
89
|
+
if (ids.length !== parts.length) {
|
|
90
|
+
await params.prompter.note(
|
|
91
|
+
"Graph lookup unavailable. Use user IDs only.",
|
|
92
|
+
"MS Teams allowlist",
|
|
93
|
+
);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const unique = mergeAllowFromEntries(existing, ids);
|
|
97
|
+
return setMSTeamsAllowFrom(params.cfg, unique);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const unresolved = resolved.filter((item) => !item.resolved || !item.id);
|
|
101
|
+
if (unresolved.length > 0) {
|
|
102
|
+
await params.prompter.note(
|
|
103
|
+
`Could not resolve: ${unresolved.map((item) => item.input).join(", ")}`,
|
|
104
|
+
"MS Teams allowlist",
|
|
105
|
+
);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ids = resolved.map((item) => item.id as string);
|
|
110
|
+
const unique = mergeAllowFromEntries(existing, ids);
|
|
111
|
+
return setMSTeamsAllowFrom(params.cfg, unique);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function setMSTeamsTeamsAllowlist(
|
|
116
|
+
cfg: OpenClawConfig,
|
|
117
|
+
entries: Array<{ teamKey: string; channelKey?: string }>,
|
|
118
|
+
): OpenClawConfig {
|
|
119
|
+
const baseTeams = cfg.channels?.msteams?.teams ?? {};
|
|
120
|
+
const teams: Record<string, { channels?: Record<string, unknown> }> = { ...baseTeams };
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const teamKey = entry.teamKey;
|
|
123
|
+
if (!teamKey) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const existing = teams[teamKey] ?? {};
|
|
127
|
+
if (entry.channelKey) {
|
|
128
|
+
const channels = { ...existing.channels };
|
|
129
|
+
channels[entry.channelKey] = channels[entry.channelKey] ?? {};
|
|
130
|
+
teams[teamKey] = { ...existing, channels };
|
|
131
|
+
} else {
|
|
132
|
+
teams[teamKey] = existing;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
...cfg,
|
|
137
|
+
channels: {
|
|
138
|
+
...cfg.channels,
|
|
139
|
+
msteams: {
|
|
140
|
+
...cfg.channels?.msteams,
|
|
141
|
+
enabled: true,
|
|
142
|
+
teams: teams as Record<string, MSTeamsTeamConfig>,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function listMSTeamsGroupEntries(cfg: OpenClawConfig): string[] {
|
|
149
|
+
return Object.entries(cfg.channels?.msteams?.teams ?? {}).flatMap(([teamKey, value]) => {
|
|
150
|
+
const channels = value?.channels ?? {};
|
|
151
|
+
const channelKeys = Object.keys(channels);
|
|
152
|
+
if (channelKeys.length === 0) {
|
|
153
|
+
return [teamKey];
|
|
154
|
+
}
|
|
155
|
+
return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function resolveMSTeamsGroupAllowlist(params: {
|
|
160
|
+
cfg: OpenClawConfig;
|
|
161
|
+
entries: string[];
|
|
162
|
+
prompter: Pick<WizardPrompter, "note">;
|
|
163
|
+
}): Promise<Array<{ teamKey: string; channelKey?: string }>> {
|
|
164
|
+
let resolvedEntries = params.entries
|
|
165
|
+
.map((entry) => parseMSTeamsTeamEntry(entry))
|
|
166
|
+
.filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>;
|
|
167
|
+
if (params.entries.length === 0 || !resolveMSTeamsCredentials(params.cfg.channels?.msteams)) {
|
|
168
|
+
return resolvedEntries;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const lookups = await resolveMSTeamsChannelAllowlist({
|
|
172
|
+
cfg: params.cfg,
|
|
173
|
+
entries: params.entries,
|
|
174
|
+
});
|
|
175
|
+
const resolvedChannels = lookups.filter(
|
|
176
|
+
(entry) => entry.resolved && entry.teamId && entry.channelId,
|
|
177
|
+
);
|
|
178
|
+
const resolvedTeams = lookups.filter(
|
|
179
|
+
(entry) => entry.resolved && entry.teamId && !entry.channelId,
|
|
180
|
+
);
|
|
181
|
+
const unresolved = lookups.filter((entry) => !entry.resolved).map((entry) => entry.input);
|
|
182
|
+
resolvedEntries = [
|
|
183
|
+
...resolvedChannels.map((entry) => ({
|
|
184
|
+
teamKey: entry.teamId as string,
|
|
185
|
+
channelKey: entry.channelId as string,
|
|
186
|
+
})),
|
|
187
|
+
...resolvedTeams.map((entry) => ({
|
|
188
|
+
teamKey: entry.teamId as string,
|
|
189
|
+
})),
|
|
190
|
+
...unresolved.map((entry) => parseMSTeamsTeamEntry(entry)).filter(Boolean),
|
|
191
|
+
] as Array<{ teamKey: string; channelKey?: string }>;
|
|
192
|
+
const summary: string[] = [];
|
|
193
|
+
if (resolvedChannels.length > 0) {
|
|
194
|
+
summary.push(
|
|
195
|
+
`Resolved channels: ${resolvedChannels
|
|
196
|
+
.map((entry) => entry.channelId)
|
|
197
|
+
.filter(Boolean)
|
|
198
|
+
.join(", ")}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (resolvedTeams.length > 0) {
|
|
202
|
+
summary.push(
|
|
203
|
+
`Resolved teams: ${resolvedTeams
|
|
204
|
+
.map((entry) => entry.teamId)
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.join(", ")}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (unresolved.length > 0) {
|
|
210
|
+
summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`);
|
|
211
|
+
}
|
|
212
|
+
if (summary.length > 0) {
|
|
213
|
+
await params.prompter.note(summary.join("\n"), "MS Teams channels");
|
|
214
|
+
}
|
|
215
|
+
return resolvedEntries;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
await params.prompter.note(
|
|
218
|
+
`Channel lookup failed; keeping entries as typed. ${formatUnknownError(err)}`,
|
|
219
|
+
"MS Teams channels",
|
|
220
|
+
);
|
|
221
|
+
return resolvedEntries;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const msteamsGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
|
|
226
|
+
label: "MS Teams channels",
|
|
227
|
+
placeholder: "Team Name/Channel Name, teamId/conversationId",
|
|
228
|
+
currentPolicy: ({ cfg }) => cfg.channels?.msteams?.groupPolicy ?? "allowlist",
|
|
229
|
+
currentEntries: ({ cfg }) => listMSTeamsGroupEntries(cfg),
|
|
230
|
+
updatePrompt: ({ cfg }) => Boolean(cfg.channels?.msteams?.teams),
|
|
231
|
+
setPolicy: ({ cfg, policy }) => setMSTeamsGroupPolicy(cfg, policy),
|
|
232
|
+
resolveAllowlist: async ({ cfg, entries, prompter }) =>
|
|
233
|
+
await resolveMSTeamsGroupAllowlist({ cfg, entries, prompter }),
|
|
234
|
+
applyAllowlist: ({ cfg, resolved }) =>
|
|
235
|
+
setMSTeamsTeamsAllowlist(cfg, resolved as Array<{ teamKey: string; channelKey?: string }>),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const msteamsDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
|
|
239
|
+
label: "MS Teams",
|
|
240
|
+
channel,
|
|
241
|
+
policyKey: "channels.msteams.dmPolicy",
|
|
242
|
+
allowFromKey: "channels.msteams.allowFrom",
|
|
243
|
+
getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
|
|
244
|
+
promptAllowFrom: promptMSTeamsAllowFrom,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const msteamsSetupWizardBase = createMSTeamsSetupWizardBase();
|
|
248
|
+
|
|
249
|
+
export const msteamsSetupWizard: ChannelSetupWizard = {
|
|
250
|
+
...msteamsSetupWizardBase,
|
|
251
|
+
// Override finalize to layer on the optional delegated-auth bootstrap after
|
|
252
|
+
// the base wizard collects app credentials. This preserves main's shared
|
|
253
|
+
// setup-core flow while keeping the delegated OAuth step from this PR.
|
|
254
|
+
finalize: async (params) => {
|
|
255
|
+
// setup-core always provides a finalize; the type is optional only because
|
|
256
|
+
// ChannelSetupWizard.finalize is generally optional. Fall back to the
|
|
257
|
+
// incoming cfg if the base ever returns void for forward-compat.
|
|
258
|
+
const baseFinalize = msteamsSetupWizardBase.finalize;
|
|
259
|
+
const baseResult = baseFinalize ? await baseFinalize(params) : undefined;
|
|
260
|
+
let next = baseResult?.cfg ?? params.cfg;
|
|
261
|
+
const finalCreds = resolveMSTeamsCredentials(next.channels?.msteams);
|
|
262
|
+
if (finalCreds?.type === "secret") {
|
|
263
|
+
const enableDelegated = await params.prompter.confirm({
|
|
264
|
+
message: "Enable delegated auth? (required for reactions and write operations)",
|
|
265
|
+
initialValue: false,
|
|
266
|
+
});
|
|
267
|
+
if (enableDelegated) {
|
|
268
|
+
next = {
|
|
269
|
+
...next,
|
|
270
|
+
channels: {
|
|
271
|
+
...next.channels,
|
|
272
|
+
msteams: {
|
|
273
|
+
...next.channels?.msteams,
|
|
274
|
+
delegatedAuth: { enabled: true },
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
try {
|
|
279
|
+
const { loginMSTeamsDelegated } = await import("./oauth.js");
|
|
280
|
+
const { shouldUseManualOAuthFlow } = await import("./oauth.flow.js");
|
|
281
|
+
const isRemote = Boolean(process.env.SSH_TTY || process.env.SSH_CONNECTION);
|
|
282
|
+
const progress = params.prompter.progress("MSTeams Delegated OAuth");
|
|
283
|
+
const tokens = await loginMSTeamsDelegated(
|
|
284
|
+
{
|
|
285
|
+
isRemote: shouldUseManualOAuthFlow(isRemote),
|
|
286
|
+
openUrl: openDelegatedOAuthUrl,
|
|
287
|
+
log: (msg) => params.prompter.note(msg),
|
|
288
|
+
note: (msg, title) => params.prompter.note(msg, title),
|
|
289
|
+
prompt: (msg) => params.prompter.text({ message: msg }),
|
|
290
|
+
progress,
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
tenantId: finalCreds.tenantId,
|
|
294
|
+
clientId: finalCreds.appId,
|
|
295
|
+
clientSecret: finalCreds.appPassword,
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
saveDelegatedTokens(tokens);
|
|
299
|
+
progress.stop("Delegated auth configured");
|
|
300
|
+
} catch (err) {
|
|
301
|
+
await params.prompter.note(
|
|
302
|
+
`Delegated auth setup failed: ${formatUnknownError(err)}\n` +
|
|
303
|
+
"You can retry later via the setup wizard.",
|
|
304
|
+
"MS Teams delegated auth",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { ...baseResult, cfg: next };
|
|
310
|
+
},
|
|
311
|
+
dmPolicy: msteamsDmPolicy,
|
|
312
|
+
groupAccess: msteamsGroupAccess,
|
|
313
|
+
disable: (cfg) => ({
|
|
314
|
+
...cfg,
|
|
315
|
+
channels: {
|
|
316
|
+
...cfg.channels,
|
|
317
|
+
msteams: { ...cfg.channels?.msteams, enabled: false },
|
|
318
|
+
},
|
|
319
|
+
}),
|
|
320
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { createMSTeamsSsoTokenStoreFs } from "./sso-token-store.js";
|
|
6
|
+
|
|
7
|
+
describe("msteams sso token store (fs)", () => {
|
|
8
|
+
it("keeps distinct tokens when connectionName and userId contain the legacy delimiter", async () => {
|
|
9
|
+
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-sso-"));
|
|
10
|
+
const storePath = path.join(stateDir, "msteams-sso-tokens.json");
|
|
11
|
+
const store = createMSTeamsSsoTokenStoreFs({ storePath });
|
|
12
|
+
|
|
13
|
+
const first = {
|
|
14
|
+
connectionName: "conn::alpha",
|
|
15
|
+
userId: "user",
|
|
16
|
+
token: "token-a",
|
|
17
|
+
updatedAt: "2026-04-10T00:00:00.000Z",
|
|
18
|
+
} as const;
|
|
19
|
+
const second = {
|
|
20
|
+
connectionName: "conn",
|
|
21
|
+
userId: "alpha::user",
|
|
22
|
+
token: "token-b",
|
|
23
|
+
updatedAt: "2026-04-10T00:00:01.000Z",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
await store.save(first);
|
|
27
|
+
await store.save(second);
|
|
28
|
+
|
|
29
|
+
expect(await store.get(first)).toEqual(first);
|
|
30
|
+
expect(await store.get(second)).toEqual(second);
|
|
31
|
+
|
|
32
|
+
const raw = JSON.parse(await fs.readFile(storePath, "utf8")) as {
|
|
33
|
+
tokens: Record<string, unknown>;
|
|
34
|
+
};
|
|
35
|
+
expect(Object.keys(raw.tokens)).toHaveLength(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("loads legacy flat-key files by rebuilding keys from stored token payloads", async () => {
|
|
39
|
+
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-sso-legacy-"));
|
|
40
|
+
const storePath = path.join(stateDir, "msteams-sso-tokens.json");
|
|
41
|
+
await fs.writeFile(
|
|
42
|
+
storePath,
|
|
43
|
+
`${JSON.stringify(
|
|
44
|
+
{
|
|
45
|
+
version: 1,
|
|
46
|
+
tokens: {
|
|
47
|
+
"legacy::wrong-key": {
|
|
48
|
+
connectionName: "conn",
|
|
49
|
+
userId: "user-1",
|
|
50
|
+
token: "token-1",
|
|
51
|
+
updatedAt: "2026-04-10T00:00:00.000Z",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
null,
|
|
56
|
+
2,
|
|
57
|
+
)}\n`,
|
|
58
|
+
"utf8",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const store = createMSTeamsSsoTokenStoreFs({ storePath });
|
|
62
|
+
expect(
|
|
63
|
+
await store.get({
|
|
64
|
+
connectionName: "conn",
|
|
65
|
+
userId: "user-1",
|
|
66
|
+
}),
|
|
67
|
+
).toMatchObject({
|
|
68
|
+
token: "token-1",
|
|
69
|
+
updatedAt: "2026-04-10T00:00:00.000Z",
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|