@gakr-gakr/msteams 0.1.0

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.
Files changed (107) hide show
  1. package/api.ts +3 -0
  2. package/autobot.plugin.json +15 -0
  3. package/channel-config-api.ts +1 -0
  4. package/channel-plugin-api.ts +2 -0
  5. package/config-api.ts +4 -0
  6. package/contract-api.ts +4 -0
  7. package/index.ts +20 -0
  8. package/package.json +72 -0
  9. package/runtime-api.ts +66 -0
  10. package/secret-contract-api.ts +5 -0
  11. package/setup-entry.ts +13 -0
  12. package/setup-plugin-api.ts +3 -0
  13. package/src/ai-entity.ts +7 -0
  14. package/src/approval-auth.ts +44 -0
  15. package/src/attachments/bot-framework.ts +348 -0
  16. package/src/attachments/download.ts +328 -0
  17. package/src/attachments/graph.ts +489 -0
  18. package/src/attachments/html.ts +122 -0
  19. package/src/attachments/payload.ts +14 -0
  20. package/src/attachments/remote-media.ts +86 -0
  21. package/src/attachments/shared.ts +655 -0
  22. package/src/attachments/types.ts +47 -0
  23. package/src/attachments.ts +18 -0
  24. package/src/channel-api.ts +1 -0
  25. package/src/channel.runtime.ts +56 -0
  26. package/src/channel.setup.ts +77 -0
  27. package/src/channel.ts +1176 -0
  28. package/src/config-schema.ts +6 -0
  29. package/src/config-ui-hints.ts +40 -0
  30. package/src/conversation-store-fs.ts +149 -0
  31. package/src/conversation-store-helpers.ts +105 -0
  32. package/src/conversation-store-memory.ts +51 -0
  33. package/src/conversation-store.ts +71 -0
  34. package/src/directory-live.ts +111 -0
  35. package/src/doctor.ts +27 -0
  36. package/src/errors.ts +270 -0
  37. package/src/feedback-reflection-prompt.ts +117 -0
  38. package/src/feedback-reflection-store.ts +113 -0
  39. package/src/feedback-reflection.ts +271 -0
  40. package/src/file-consent-helpers.ts +115 -0
  41. package/src/file-consent-invoke.ts +150 -0
  42. package/src/file-consent.ts +223 -0
  43. package/src/graph-chat.ts +36 -0
  44. package/src/graph-group-management.ts +168 -0
  45. package/src/graph-members.ts +48 -0
  46. package/src/graph-messages.ts +534 -0
  47. package/src/graph-teams.ts +114 -0
  48. package/src/graph-thread.ts +146 -0
  49. package/src/graph-upload.ts +531 -0
  50. package/src/graph-users.ts +29 -0
  51. package/src/graph.ts +308 -0
  52. package/src/inbound.ts +148 -0
  53. package/src/index.ts +4 -0
  54. package/src/media-helpers.ts +105 -0
  55. package/src/mentions.ts +114 -0
  56. package/src/messenger.ts +608 -0
  57. package/src/monitor-handler/access.ts +136 -0
  58. package/src/monitor-handler/inbound-media.ts +180 -0
  59. package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
  60. package/src/monitor-handler/message-handler.test-support.ts +102 -0
  61. package/src/monitor-handler/message-handler.ts +1015 -0
  62. package/src/monitor-handler/reaction-handler.ts +124 -0
  63. package/src/monitor-handler/thread-session.ts +30 -0
  64. package/src/monitor-handler.ts +538 -0
  65. package/src/monitor-handler.types.ts +27 -0
  66. package/src/monitor-types.ts +6 -0
  67. package/src/monitor.ts +476 -0
  68. package/src/oauth.flow.ts +77 -0
  69. package/src/oauth.shared.ts +37 -0
  70. package/src/oauth.token.ts +162 -0
  71. package/src/oauth.ts +130 -0
  72. package/src/outbound.ts +198 -0
  73. package/src/pending-uploads-fs.ts +235 -0
  74. package/src/pending-uploads.ts +121 -0
  75. package/src/policy.ts +245 -0
  76. package/src/polls-store-memory.ts +32 -0
  77. package/src/polls.ts +312 -0
  78. package/src/presentation.ts +93 -0
  79. package/src/probe.ts +132 -0
  80. package/src/reply-dispatcher.ts +523 -0
  81. package/src/reply-stream-controller.ts +334 -0
  82. package/src/resolve-allowlist.ts +309 -0
  83. package/src/revoked-context.ts +17 -0
  84. package/src/runtime.ts +12 -0
  85. package/src/sdk-types.ts +59 -0
  86. package/src/sdk.ts +916 -0
  87. package/src/secret-contract.ts +49 -0
  88. package/src/secret-input.ts +7 -0
  89. package/src/send-context.ts +269 -0
  90. package/src/send.ts +697 -0
  91. package/src/sent-message-cache.ts +174 -0
  92. package/src/session-route.ts +40 -0
  93. package/src/setup-core.ts +162 -0
  94. package/src/setup-surface.ts +319 -0
  95. package/src/sso-token-store.ts +166 -0
  96. package/src/sso.ts +300 -0
  97. package/src/storage.ts +25 -0
  98. package/src/store-fs.ts +42 -0
  99. package/src/streaming-message.ts +327 -0
  100. package/src/thread-parent-context.ts +159 -0
  101. package/src/token-response.ts +11 -0
  102. package/src/token.ts +194 -0
  103. package/src/user-agent.ts +53 -0
  104. package/src/webhook-timeouts.ts +27 -0
  105. package/src/welcome-card.ts +57 -0
  106. package/test-api.ts +1 -0
  107. package/tsconfig.json +16 -0
@@ -0,0 +1,174 @@
1
+ import { getOptionalMSTeamsRuntime } from "./runtime.js";
2
+
3
+ const TTL_MS = 24 * 60 * 60 * 1000;
4
+ const PERSISTENT_MAX_ENTRIES = 1000;
5
+ const PERSISTENT_NAMESPACE = "msteams.sent-messages";
6
+ const MSTEAMS_SENT_MESSAGES_KEY = Symbol.for("autobot.msteamsSentMessages");
7
+
8
+ type MSTeamsSentMessageRecord = {
9
+ sentAt: number;
10
+ };
11
+
12
+ type MSTeamsSentMessageStore = {
13
+ register(key: string, value: MSTeamsSentMessageRecord, opts?: { ttlMs?: number }): Promise<void>;
14
+ lookup(key: string): Promise<MSTeamsSentMessageRecord | undefined>;
15
+ };
16
+
17
+ let sentMessageCache: Map<string, Map<string, number>> | undefined;
18
+ let persistentStore: MSTeamsSentMessageStore | undefined;
19
+ let persistentStoreDisabled = false;
20
+
21
+ function getSentMessageCache(): Map<string, Map<string, number>> {
22
+ if (!sentMessageCache) {
23
+ const globalStore = globalThis as Record<PropertyKey, unknown>;
24
+ sentMessageCache =
25
+ (globalStore[MSTEAMS_SENT_MESSAGES_KEY] as Map<string, Map<string, number>> | undefined) ??
26
+ new Map<string, Map<string, number>>();
27
+ globalStore[MSTEAMS_SENT_MESSAGES_KEY] = sentMessageCache;
28
+ }
29
+ return sentMessageCache;
30
+ }
31
+
32
+ function makePersistentKey(conversationId: string, messageId: string): string {
33
+ return `${conversationId}:${messageId}`;
34
+ }
35
+
36
+ function reportPersistentSentMessageError(error: unknown): void {
37
+ try {
38
+ getOptionalMSTeamsRuntime()
39
+ ?.logging.getChildLogger({ plugin: "msteams", feature: "sent-message-state" })
40
+ .warn("Microsoft Teams persistent sent-message state failed", { error: String(error) });
41
+ } catch {
42
+ // Best effort only: persistent state must never break Teams routing.
43
+ }
44
+ }
45
+
46
+ function disablePersistentSentMessageStore(error: unknown): void {
47
+ persistentStoreDisabled = true;
48
+ persistentStore = undefined;
49
+ reportPersistentSentMessageError(error);
50
+ }
51
+
52
+ function getPersistentSentMessageStore(): MSTeamsSentMessageStore | undefined {
53
+ if (persistentStoreDisabled) {
54
+ return undefined;
55
+ }
56
+ if (persistentStore) {
57
+ return persistentStore;
58
+ }
59
+ const runtime = getOptionalMSTeamsRuntime();
60
+ if (!runtime) {
61
+ return undefined;
62
+ }
63
+ try {
64
+ persistentStore = runtime.state.openKeyedStore<MSTeamsSentMessageRecord>({
65
+ namespace: PERSISTENT_NAMESPACE,
66
+ maxEntries: PERSISTENT_MAX_ENTRIES,
67
+ defaultTtlMs: TTL_MS,
68
+ });
69
+ return persistentStore;
70
+ } catch (error) {
71
+ disablePersistentSentMessageStore(error);
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ function cleanupExpired(scopeKey: string, entry: Map<string, number>, now: number): void {
77
+ for (const [id, timestamp] of entry) {
78
+ if (now - timestamp > TTL_MS) {
79
+ entry.delete(id);
80
+ }
81
+ }
82
+ if (entry.size === 0) {
83
+ getSentMessageCache().delete(scopeKey);
84
+ }
85
+ }
86
+
87
+ function rememberSentMessageInMemory(
88
+ conversationId: string,
89
+ messageId: string,
90
+ sentAt: number,
91
+ ): void {
92
+ const store = getSentMessageCache();
93
+ let entry = store.get(conversationId);
94
+ if (!entry) {
95
+ entry = new Map<string, number>();
96
+ store.set(conversationId, entry);
97
+ }
98
+ entry.set(messageId, sentAt);
99
+ if (entry.size > 200) {
100
+ cleanupExpired(conversationId, entry, sentAt);
101
+ }
102
+ }
103
+
104
+ function rememberPersistentSentMessage(params: {
105
+ conversationId: string;
106
+ messageId: string;
107
+ sentAt: number;
108
+ }): void {
109
+ const store = getPersistentSentMessageStore();
110
+ if (!store) {
111
+ return;
112
+ }
113
+ void store
114
+ .register(makePersistentKey(params.conversationId, params.messageId), { sentAt: params.sentAt })
115
+ .catch(disablePersistentSentMessageStore);
116
+ }
117
+
118
+ async function lookupPersistentSentMessage(params: {
119
+ conversationId: string;
120
+ messageId: string;
121
+ }): Promise<number | undefined> {
122
+ const store = getPersistentSentMessageStore();
123
+ if (!store) {
124
+ return undefined;
125
+ }
126
+ try {
127
+ return (await store.lookup(makePersistentKey(params.conversationId, params.messageId)))?.sentAt;
128
+ } catch (error) {
129
+ disablePersistentSentMessageStore(error);
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ export function recordMSTeamsSentMessage(conversationId: string, messageId: string): void {
135
+ if (!conversationId || !messageId) {
136
+ return;
137
+ }
138
+ const now = Date.now();
139
+ rememberSentMessageInMemory(conversationId, messageId, now);
140
+ rememberPersistentSentMessage({ conversationId, messageId, sentAt: now });
141
+ }
142
+
143
+ export function wasMSTeamsMessageSent(conversationId: string, messageId: string): boolean {
144
+ const entry = getSentMessageCache().get(conversationId);
145
+ if (!entry) {
146
+ return false;
147
+ }
148
+ cleanupExpired(conversationId, entry, Date.now());
149
+ return entry.has(messageId);
150
+ }
151
+
152
+ export async function wasMSTeamsMessageSentWithPersistence(params: {
153
+ conversationId: string;
154
+ messageId: string;
155
+ }): Promise<boolean> {
156
+ if (!params.conversationId || !params.messageId) {
157
+ return false;
158
+ }
159
+ if (wasMSTeamsMessageSent(params.conversationId, params.messageId)) {
160
+ return true;
161
+ }
162
+ const sentAt = await lookupPersistentSentMessage(params);
163
+ if (sentAt == null) {
164
+ return false;
165
+ }
166
+ rememberSentMessageInMemory(params.conversationId, params.messageId, sentAt);
167
+ return wasMSTeamsMessageSent(params.conversationId, params.messageId);
168
+ }
169
+
170
+ export function clearMSTeamsSentMessageCache(): void {
171
+ getSentMessageCache().clear();
172
+ persistentStore = undefined;
173
+ persistentStoreDisabled = false;
174
+ }
@@ -0,0 +1,40 @@
1
+ import {
2
+ buildChannelOutboundSessionRoute,
3
+ stripChannelTargetPrefix,
4
+ stripTargetKindPrefix,
5
+ type ChannelOutboundSessionRouteParams,
6
+ } from "autobot/plugin-sdk/channel-core";
7
+ import { normalizeLowercaseStringOrEmpty } from "autobot/plugin-sdk/string-coerce-runtime";
8
+
9
+ export function resolveMSTeamsOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
10
+ let trimmed = stripChannelTargetPrefix(params.target, "msteams", "teams");
11
+ if (!trimmed) {
12
+ return null;
13
+ }
14
+
15
+ const lower = normalizeLowercaseStringOrEmpty(trimmed);
16
+ const isUser = lower.startsWith("user:");
17
+ const rawId = stripTargetKindPrefix(trimmed);
18
+ if (!rawId) {
19
+ return null;
20
+ }
21
+ const conversationId = rawId.split(";")[0] ?? rawId;
22
+ const isChannel = !isUser && /@thread\.tacv2/i.test(conversationId);
23
+ return buildChannelOutboundSessionRoute({
24
+ cfg: params.cfg,
25
+ agentId: params.agentId,
26
+ channel: "msteams",
27
+ accountId: params.accountId,
28
+ peer: {
29
+ kind: isUser ? "direct" : isChannel ? "channel" : "group",
30
+ id: conversationId,
31
+ },
32
+ chatType: isUser ? "direct" : isChannel ? "channel" : "group",
33
+ from: isUser
34
+ ? `msteams:${conversationId}`
35
+ : isChannel
36
+ ? `msteams:channel:${conversationId}`
37
+ : `msteams:group:${conversationId}`,
38
+ to: isUser ? `user:${conversationId}` : `conversation:${conversationId}`,
39
+ });
40
+ }
@@ -0,0 +1,162 @@
1
+ import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
2
+ import {
3
+ createStandardChannelSetupStatus,
4
+ DEFAULT_ACCOUNT_ID,
5
+ createSetupTranslator,
6
+ type ChannelSetupAdapter,
7
+ type ChannelSetupWizard,
8
+ type WizardPrompter,
9
+ } from "autobot/plugin-sdk/setup";
10
+ import { formatDocsLink } from "autobot/plugin-sdk/setup-tools";
11
+ import { normalizeSecretInputString } from "./secret-input.js";
12
+ import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
13
+
14
+ const t = createSetupTranslator();
15
+
16
+ export const msteamsSetupAdapter: ChannelSetupAdapter = {
17
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
18
+ applyAccountConfig: ({ cfg }) => ({
19
+ ...cfg,
20
+ channels: {
21
+ ...cfg.channels,
22
+ msteams: {
23
+ ...cfg.channels?.msteams,
24
+ enabled: true,
25
+ },
26
+ },
27
+ }),
28
+ };
29
+
30
+ const channel = "msteams" as const;
31
+
32
+ async function promptMSTeamsCredentials(prompter: WizardPrompter): Promise<{
33
+ appId: string;
34
+ appPassword: string;
35
+ tenantId: string;
36
+ }> {
37
+ const appId = (
38
+ await prompter.text({
39
+ message: t("wizard.msteams.appIdPrompt"),
40
+ validate: (value) => (value?.trim() ? undefined : t("common.required")),
41
+ })
42
+ ).trim();
43
+ const appPassword = (
44
+ await prompter.text({
45
+ message: t("wizard.msteams.appPasswordPrompt"),
46
+ validate: (value) => (value?.trim() ? undefined : t("common.required")),
47
+ })
48
+ ).trim();
49
+ const tenantId = (
50
+ await prompter.text({
51
+ message: t("wizard.msteams.tenantIdPrompt"),
52
+ validate: (value) => (value?.trim() ? undefined : t("common.required")),
53
+ })
54
+ ).trim();
55
+ return { appId, appPassword, tenantId };
56
+ }
57
+
58
+ async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> {
59
+ await prompter.note(
60
+ [
61
+ t("wizard.msteams.helpAzureBot"),
62
+ t("wizard.msteams.helpClientSecret"),
63
+ t("wizard.msteams.helpWebhook"),
64
+ t("wizard.msteams.helpEnvTip"),
65
+ t("wizard.channels.docs", { link: formatDocsLink("/channels/msteams", "msteams") }),
66
+ ].join("\n"),
67
+ t("wizard.msteams.credentialsTitle"),
68
+ );
69
+ }
70
+
71
+ export function createMSTeamsSetupWizardBase(): Pick<
72
+ ChannelSetupWizard,
73
+ | "channel"
74
+ | "resolveAccountIdForConfigure"
75
+ | "resolveShouldPromptAccountIds"
76
+ | "status"
77
+ | "credentials"
78
+ | "finalize"
79
+ > {
80
+ return {
81
+ channel,
82
+ resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
83
+ resolveShouldPromptAccountIds: () => false,
84
+ status: createStandardChannelSetupStatus({
85
+ channelLabel: "MS Teams",
86
+ configuredLabel: t("wizard.channels.statusConfigured"),
87
+ unconfiguredLabel: t("wizard.channels.statusNeedsAppCredentials"),
88
+ configuredHint: t("wizard.channels.statusConfigured"),
89
+ unconfiguredHint: t("wizard.channels.statusNeedsAppCreds"),
90
+ configuredScore: 2,
91
+ unconfiguredScore: 0,
92
+ includeStatusLine: true,
93
+ resolveConfigured: ({ cfg }) =>
94
+ Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) ||
95
+ hasConfiguredMSTeamsCredentials(cfg.channels?.msteams),
96
+ }),
97
+ credentials: [],
98
+ finalize: async ({ cfg, prompter }) => {
99
+ const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams);
100
+ const hasConfigCreds = hasConfiguredMSTeamsCredentials(cfg.channels?.msteams);
101
+ const canUseEnv = Boolean(
102
+ !hasConfigCreds &&
103
+ normalizeSecretInputString(process.env.MSTEAMS_APP_ID) &&
104
+ normalizeSecretInputString(process.env.MSTEAMS_APP_PASSWORD) &&
105
+ normalizeSecretInputString(process.env.MSTEAMS_TENANT_ID),
106
+ );
107
+
108
+ let next: AutoBotConfig = cfg;
109
+ let appId: string | null = null;
110
+ let appPassword: string | null = null;
111
+ let tenantId: string | null = null;
112
+
113
+ if (!resolved && !hasConfigCreds) {
114
+ await noteMSTeamsCredentialHelp(prompter);
115
+ }
116
+
117
+ if (canUseEnv) {
118
+ const keepEnv = await prompter.confirm({
119
+ message: t("wizard.msteams.envPrompt"),
120
+ initialValue: true,
121
+ });
122
+ if (keepEnv) {
123
+ next = msteamsSetupAdapter.applyAccountConfig({
124
+ cfg: next,
125
+ accountId: DEFAULT_ACCOUNT_ID,
126
+ input: {},
127
+ });
128
+ } else {
129
+ ({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter));
130
+ }
131
+ } else if (hasConfigCreds) {
132
+ const keep = await prompter.confirm({
133
+ message: t("wizard.msteams.credentialsKeep"),
134
+ initialValue: true,
135
+ });
136
+ if (!keep) {
137
+ ({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter));
138
+ }
139
+ } else {
140
+ ({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter));
141
+ }
142
+
143
+ if (appId && appPassword && tenantId) {
144
+ next = {
145
+ ...next,
146
+ channels: {
147
+ ...next.channels,
148
+ msteams: {
149
+ ...next.channels?.msteams,
150
+ enabled: true,
151
+ appId,
152
+ appPassword,
153
+ tenantId,
154
+ },
155
+ },
156
+ };
157
+ }
158
+
159
+ return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
160
+ },
161
+ };
162
+ }
@@ -0,0 +1,319 @@
1
+ import {
2
+ createTopLevelChannelAllowFromSetter,
3
+ createTopLevelChannelDmPolicy,
4
+ createTopLevelChannelGroupPolicySetter,
5
+ mergeAllowFromEntries,
6
+ splitSetupEntries,
7
+ createSetupTranslator,
8
+ type ChannelSetupDmPolicy,
9
+ type ChannelSetupWizard,
10
+ type AutoBotConfig,
11
+ type WizardPrompter,
12
+ } from "autobot/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 t = createSetupTranslator();
24
+
25
+ const channel = "msteams" as const;
26
+ const setMSTeamsAllowFrom = createTopLevelChannelAllowFromSetter({
27
+ channel,
28
+ });
29
+ const setMSTeamsGroupPolicy = createTopLevelChannelGroupPolicySetter({
30
+ channel,
31
+ enabled: true,
32
+ });
33
+
34
+ export function openDelegatedOAuthUrl(url: string): Promise<void> {
35
+ return Promise.reject(
36
+ new Error(`Automatic browser launch is not available. Open this URL manually: ${url}`),
37
+ );
38
+ }
39
+
40
+ function looksLikeGuid(value: string): boolean {
41
+ return /^[0-9a-fA-F-]{16,}$/.test(value);
42
+ }
43
+
44
+ async function promptMSTeamsAllowFrom(params: {
45
+ cfg: AutoBotConfig;
46
+ prompter: WizardPrompter;
47
+ }): Promise<AutoBotConfig> {
48
+ const existing = params.cfg.channels?.msteams?.allowFrom ?? [];
49
+ await params.prompter.note(
50
+ [
51
+ t("wizard.msteams.allowlistIntro"),
52
+ t("wizard.msteams.allowlistResolve"),
53
+ t("wizard.msteams.examples"),
54
+ "- alex@example.com",
55
+ "- Alex Johnson",
56
+ "- 00000000-0000-0000-0000-000000000000",
57
+ ].join("\n"),
58
+ t("wizard.msteams.allowlistTitle"),
59
+ );
60
+
61
+ while (true) {
62
+ const entry = await params.prompter.text({
63
+ message: t("wizard.msteams.allowFromPrompt"),
64
+ placeholder: "alex@example.com, Alex Johnson",
65
+ initialValue: existing[0] ? existing[0] : undefined,
66
+ validate: (value) => (value.trim() ? undefined : t("common.required")),
67
+ });
68
+ const parts = splitSetupEntries(entry);
69
+ if (parts.length === 0) {
70
+ await params.prompter.note(
71
+ t("wizard.msteams.enterAtLeastOneUser"),
72
+ t("wizard.msteams.allowlistTitle"),
73
+ );
74
+ continue;
75
+ }
76
+
77
+ const resolved = await resolveMSTeamsUserAllowlist({
78
+ cfg: params.cfg,
79
+ entries: parts,
80
+ }).catch(() => null);
81
+
82
+ if (!resolved) {
83
+ const ids = parts.filter((part) => looksLikeGuid(part));
84
+ if (ids.length !== parts.length) {
85
+ await params.prompter.note(
86
+ t("wizard.msteams.graphLookupUnavailable"),
87
+ t("wizard.msteams.allowlistTitle"),
88
+ );
89
+ continue;
90
+ }
91
+ const unique = mergeAllowFromEntries(existing, ids);
92
+ return setMSTeamsAllowFrom(params.cfg, unique);
93
+ }
94
+
95
+ const unresolved = resolved.filter((item) => !item.resolved || !item.id);
96
+ if (unresolved.length > 0) {
97
+ await params.prompter.note(
98
+ t("wizard.msteams.couldNotResolve", {
99
+ entries: unresolved.map((item) => item.input).join(", "),
100
+ }),
101
+ t("wizard.msteams.allowlistTitle"),
102
+ );
103
+ continue;
104
+ }
105
+
106
+ const ids = resolved.map((item) => item.id as string);
107
+ const unique = mergeAllowFromEntries(existing, ids);
108
+ return setMSTeamsAllowFrom(params.cfg, unique);
109
+ }
110
+ }
111
+
112
+ function setMSTeamsTeamsAllowlist(
113
+ cfg: AutoBotConfig,
114
+ entries: Array<{ teamKey: string; channelKey?: string }>,
115
+ ): AutoBotConfig {
116
+ const baseTeams = cfg.channels?.msteams?.teams ?? {};
117
+ const teams: Record<string, { channels?: Record<string, unknown> }> = { ...baseTeams };
118
+ for (const entry of entries) {
119
+ const teamKey = entry.teamKey;
120
+ if (!teamKey) {
121
+ continue;
122
+ }
123
+ const existing = teams[teamKey] ?? {};
124
+ if (entry.channelKey) {
125
+ const channels = { ...existing.channels };
126
+ channels[entry.channelKey] = channels[entry.channelKey] ?? {};
127
+ teams[teamKey] = { ...existing, channels };
128
+ } else {
129
+ teams[teamKey] = existing;
130
+ }
131
+ }
132
+ return {
133
+ ...cfg,
134
+ channels: {
135
+ ...cfg.channels,
136
+ msteams: {
137
+ ...cfg.channels?.msteams,
138
+ enabled: true,
139
+ teams: teams as Record<string, MSTeamsTeamConfig>,
140
+ },
141
+ },
142
+ };
143
+ }
144
+
145
+ function listMSTeamsGroupEntries(cfg: AutoBotConfig): string[] {
146
+ return Object.entries(cfg.channels?.msteams?.teams ?? {}).flatMap(([teamKey, value]) => {
147
+ const channels = value?.channels ?? {};
148
+ const channelKeys = Object.keys(channels);
149
+ if (channelKeys.length === 0) {
150
+ return [teamKey];
151
+ }
152
+ return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`);
153
+ });
154
+ }
155
+
156
+ async function resolveMSTeamsGroupAllowlist(params: {
157
+ cfg: AutoBotConfig;
158
+ entries: string[];
159
+ prompter: Pick<WizardPrompter, "note">;
160
+ }): Promise<Array<{ teamKey: string; channelKey?: string }>> {
161
+ let resolvedEntries = params.entries
162
+ .map((entry) => parseMSTeamsTeamEntry(entry))
163
+ .filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>;
164
+ if (params.entries.length === 0 || !resolveMSTeamsCredentials(params.cfg.channels?.msteams)) {
165
+ return resolvedEntries;
166
+ }
167
+ try {
168
+ const lookups = await resolveMSTeamsChannelAllowlist({
169
+ cfg: params.cfg,
170
+ entries: params.entries,
171
+ });
172
+ const resolvedChannels = lookups.filter(
173
+ (entry) => entry.resolved && entry.teamId && entry.channelId,
174
+ );
175
+ const resolvedTeams = lookups.filter(
176
+ (entry) => entry.resolved && entry.teamId && !entry.channelId,
177
+ );
178
+ const unresolved = lookups.filter((entry) => !entry.resolved).map((entry) => entry.input);
179
+ resolvedEntries = [
180
+ ...resolvedChannels.map((entry) => ({
181
+ teamKey: entry.teamId as string,
182
+ channelKey: entry.channelId as string,
183
+ })),
184
+ ...resolvedTeams.map((entry) => ({
185
+ teamKey: entry.teamId as string,
186
+ })),
187
+ ...unresolved.map((entry) => parseMSTeamsTeamEntry(entry)).filter(Boolean),
188
+ ] as Array<{ teamKey: string; channelKey?: string }>;
189
+ const summary: string[] = [];
190
+ if (resolvedChannels.length > 0) {
191
+ summary.push(
192
+ t("wizard.msteams.resolvedChannels", {
193
+ entries: resolvedChannels
194
+ .map((entry) => entry.channelId)
195
+ .filter(Boolean)
196
+ .join(", "),
197
+ }),
198
+ );
199
+ }
200
+ if (resolvedTeams.length > 0) {
201
+ summary.push(
202
+ t("wizard.msteams.resolvedTeams", {
203
+ entries: resolvedTeams
204
+ .map((entry) => entry.teamId)
205
+ .filter(Boolean)
206
+ .join(", "),
207
+ }),
208
+ );
209
+ }
210
+ if (unresolved.length > 0) {
211
+ summary.push(t("wizard.msteams.unresolvedKept", { entries: unresolved.join(", ") }));
212
+ }
213
+ if (summary.length > 0) {
214
+ await params.prompter.note(summary.join("\n"), t("wizard.msteams.channelsLabel"));
215
+ }
216
+ return resolvedEntries;
217
+ } catch (err) {
218
+ await params.prompter.note(
219
+ t("wizard.msteams.channelLookupFailed", { error: formatUnknownError(err) }),
220
+ t("wizard.msteams.channelsLabel"),
221
+ );
222
+ return resolvedEntries;
223
+ }
224
+ }
225
+
226
+ const msteamsGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
227
+ label: t("wizard.msteams.channelsLabel"),
228
+ placeholder: "Team Name/Channel Name, teamId/conversationId",
229
+ currentPolicy: ({ cfg }) => cfg.channels?.msteams?.groupPolicy ?? "allowlist",
230
+ currentEntries: ({ cfg }) => listMSTeamsGroupEntries(cfg),
231
+ updatePrompt: ({ cfg }) => Boolean(cfg.channels?.msteams?.teams),
232
+ setPolicy: ({ cfg, policy }) => setMSTeamsGroupPolicy(cfg, policy),
233
+ resolveAllowlist: async ({ cfg, entries, prompter }) =>
234
+ await resolveMSTeamsGroupAllowlist({ cfg, entries, prompter }),
235
+ applyAllowlist: ({ cfg, resolved }) =>
236
+ setMSTeamsTeamsAllowlist(cfg, resolved as Array<{ teamKey: string; channelKey?: string }>),
237
+ };
238
+
239
+ const msteamsDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
240
+ label: "MS Teams",
241
+ channel,
242
+ policyKey: "channels.msteams.dmPolicy",
243
+ allowFromKey: "channels.msteams.allowFrom",
244
+ getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
245
+ promptAllowFrom: promptMSTeamsAllowFrom,
246
+ });
247
+
248
+ const msteamsSetupWizardBase = createMSTeamsSetupWizardBase();
249
+
250
+ export const msteamsSetupWizard: ChannelSetupWizard = {
251
+ ...msteamsSetupWizardBase,
252
+ // Override finalize to layer on the optional delegated-auth bootstrap after
253
+ // the base wizard collects app credentials. This preserves main's shared
254
+ // setup-core flow while keeping the delegated OAuth step from this PR.
255
+ finalize: async (params) => {
256
+ // setup-core always provides a finalize; the type is optional only because
257
+ // ChannelSetupWizard.finalize is generally optional. Fall back to the
258
+ // incoming cfg if the base ever returns void for forward-compat.
259
+ const baseFinalize = msteamsSetupWizardBase.finalize;
260
+ const baseResult = baseFinalize ? await baseFinalize(params) : undefined;
261
+ let next = baseResult?.cfg ?? params.cfg;
262
+ const finalCreds = resolveMSTeamsCredentials(next.channels?.msteams);
263
+ if (finalCreds?.type === "secret") {
264
+ const enableDelegated = await params.prompter.confirm({
265
+ message: t("wizard.msteams.delegatedAuthPrompt"),
266
+ initialValue: false,
267
+ });
268
+ if (enableDelegated) {
269
+ next = {
270
+ ...next,
271
+ channels: {
272
+ ...next.channels,
273
+ msteams: {
274
+ ...next.channels?.msteams,
275
+ delegatedAuth: { enabled: true },
276
+ },
277
+ },
278
+ };
279
+ try {
280
+ const { loginMSTeamsDelegated } = await import("./oauth.js");
281
+ const progress = params.prompter.progress(t("wizard.msteams.delegatedOAuthProgress"));
282
+ const tokens = await loginMSTeamsDelegated(
283
+ {
284
+ isRemote: true,
285
+ openUrl: openDelegatedOAuthUrl,
286
+ log: (msg) => params.prompter.note(msg),
287
+ note: (msg, title) => params.prompter.note(msg, title),
288
+ prompt: (msg) => params.prompter.text({ message: msg }),
289
+ progress,
290
+ },
291
+ {
292
+ tenantId: finalCreds.tenantId,
293
+ clientId: finalCreds.appId,
294
+ clientSecret: finalCreds.appPassword,
295
+ },
296
+ );
297
+ saveDelegatedTokens(tokens);
298
+ progress.stop(t("wizard.msteams.delegatedAuthConfigured"));
299
+ } catch (err) {
300
+ await params.prompter.note(
301
+ `Delegated auth setup failed: ${formatUnknownError(err)}\n` +
302
+ t("wizard.msteams.delegatedAuthRetry"),
303
+ t("wizard.msteams.delegatedAuthTitle"),
304
+ );
305
+ }
306
+ }
307
+ }
308
+ return { ...baseResult, cfg: next };
309
+ },
310
+ dmPolicy: msteamsDmPolicy,
311
+ groupAccess: msteamsGroupAccess,
312
+ disable: (cfg) => ({
313
+ ...cfg,
314
+ channels: {
315
+ ...cfg.channels,
316
+ msteams: { ...cfg.channels?.msteams, enabled: false },
317
+ },
318
+ }),
319
+ };