@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
package/src/graph.ts ADDED
@@ -0,0 +1,308 @@
1
+ import { readProviderJsonResponse } from "autobot/plugin-sdk/provider-http";
2
+ import { fetchWithSsrFGuard, type MSTeamsConfig } from "../runtime-api.js";
3
+ import { GRAPH_ROOT } from "./attachments/shared.js";
4
+
5
+ const GRAPH_BETA = "https://graph.microsoft.com/beta";
6
+ const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
7
+ import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
8
+ import { readAccessToken } from "./token-response.js";
9
+ import { resolveDelegatedAccessToken, resolveMSTeamsCredentials } from "./token.js";
10
+ import { buildUserAgent } from "./user-agent.js";
11
+
12
+ export type GraphUser = {
13
+ id?: string;
14
+ displayName?: string;
15
+ userPrincipalName?: string;
16
+ mail?: string;
17
+ };
18
+
19
+ type GraphGroup = {
20
+ id?: string;
21
+ displayName?: string;
22
+ };
23
+
24
+ type GraphChannel = {
25
+ id?: string;
26
+ displayName?: string;
27
+ };
28
+
29
+ export type GraphResponse<T> = { value?: T[] };
30
+
31
+ export function normalizeQuery(value?: string | null): string {
32
+ return value?.trim() ?? "";
33
+ }
34
+
35
+ export function escapeOData(value: string): string {
36
+ return value.replace(/'/g, "''");
37
+ }
38
+
39
+ async function requestGraph(params: {
40
+ token: string;
41
+ path: string;
42
+ method?: "GET" | "POST" | "PATCH" | "DELETE";
43
+ root?: string;
44
+ headers?: Record<string, string>;
45
+ body?: unknown;
46
+ errorPrefix?: string;
47
+ }): Promise<Response> {
48
+ const hasBody = params.body !== undefined;
49
+ const url = `${params.root ?? GRAPH_ROOT}${params.path}`;
50
+ const currentFetch = globalThis.fetch;
51
+ const { response, release } = await fetchWithSsrFGuard({
52
+ url,
53
+ fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
54
+ init: {
55
+ method: params.method,
56
+ headers: {
57
+ "User-Agent": buildUserAgent(),
58
+ Authorization: `Bearer ${params.token}`,
59
+ ...(hasBody ? { "Content-Type": "application/json" } : {}),
60
+ ...params.headers,
61
+ },
62
+ body: hasBody ? JSON.stringify(params.body) : undefined,
63
+ },
64
+ auditContext: "msteams.graph",
65
+ });
66
+ try {
67
+ if (!response.ok) {
68
+ const text = await response.text().catch(() => "");
69
+ throw new Error(
70
+ `${params.errorPrefix ?? "Graph"} ${params.path} failed (${response.status}): ${text || "unknown error"}`,
71
+ );
72
+ }
73
+ const body = NULL_BODY_STATUSES.has(response.status) ? null : await response.arrayBuffer();
74
+ return new Response(body, {
75
+ status: response.status,
76
+ statusText: response.statusText,
77
+ headers: new Headers(response.headers),
78
+ });
79
+ } finally {
80
+ await release();
81
+ }
82
+ }
83
+
84
+ async function readOptionalGraphJson<T>(res: Response, label: string): Promise<T> {
85
+ // Use optional chaining to stay resilient to partial test mocks that do not
86
+ // provide a status or Headers instance (they only shim `ok` + `json()`).
87
+ if (res.status === 204 || res.headers?.get?.("content-length") === "0") {
88
+ return undefined as T;
89
+ }
90
+ return await readProviderJsonResponse<T>(res, label);
91
+ }
92
+
93
+ export async function fetchGraphJson<T>(params: {
94
+ token: string;
95
+ path: string;
96
+ headers?: Record<string, string>;
97
+ /** HTTP method; defaults to "GET" */
98
+ method?: string;
99
+ /** Request body (serialized as JSON). Only used for non-GET methods. */
100
+ body?: unknown;
101
+ }): Promise<T> {
102
+ const res = await requestGraph({
103
+ token: params.token,
104
+ path: params.path,
105
+ method: params.method as "GET" | "POST" | "DELETE" | undefined,
106
+ body: params.body,
107
+ headers: params.headers,
108
+ });
109
+ return await readOptionalGraphJson<T>(res, `Graph ${params.path} failed`);
110
+ }
111
+
112
+ /**
113
+ * Fetch JSON from an absolute Graph API URL (for example @odata.nextLink
114
+ * pagination URLs) without prepending GRAPH_ROOT.
115
+ */
116
+ export async function fetchGraphAbsoluteUrl<T>(params: {
117
+ token: string;
118
+ url: string;
119
+ headers?: Record<string, string>;
120
+ }): Promise<T> {
121
+ const { response, release } = await fetchWithSsrFGuard({
122
+ url: params.url,
123
+ init: {
124
+ headers: {
125
+ "User-Agent": buildUserAgent(),
126
+ Authorization: `Bearer ${params.token}`,
127
+ ...params.headers,
128
+ },
129
+ },
130
+ auditContext: "msteams.graph.absolute",
131
+ });
132
+ try {
133
+ if (!response.ok) {
134
+ const text = await response.text().catch(() => "");
135
+ throw new Error(
136
+ `Graph ${params.url} failed (${response.status}): ${text || "unknown error"}`,
137
+ );
138
+ }
139
+ return await readProviderJsonResponse<T>(response, `Graph ${params.url} failed`);
140
+ } finally {
141
+ await release();
142
+ }
143
+ }
144
+
145
+ /** Graph collection response with optional pagination link. */
146
+ type GraphPagedResponse<T> = {
147
+ value?: T[];
148
+ "@odata.nextLink"?: string;
149
+ };
150
+
151
+ /** Result of a paginated Graph API fetch. */
152
+ type PaginatedResult<T> = {
153
+ items: T[];
154
+ truncated: boolean;
155
+ found?: T;
156
+ };
157
+
158
+ /**
159
+ * Fetch all pages of a Graph API collection, following @odata.nextLink.
160
+ * Optionally stop early when `findOne` matches an item.
161
+ */
162
+ export async function fetchAllGraphPages<T>(params: {
163
+ token: string;
164
+ path: string;
165
+ headers?: Record<string, string>;
166
+ /** Max pages to fetch before stopping. Default: 50. */
167
+ maxPages?: number;
168
+ /** Stop pagination early when this predicate returns true. */
169
+ findOne?: (item: T) => boolean;
170
+ }): Promise<PaginatedResult<T>> {
171
+ const maxPages = params.maxPages ?? 50;
172
+ const items: T[] = [];
173
+ let nextPath: string | undefined = params.path;
174
+
175
+ for (let page = 0; page < maxPages && nextPath; page++) {
176
+ const res: GraphPagedResponse<T> = await fetchGraphJson<GraphPagedResponse<T>>({
177
+ token: params.token,
178
+ path: nextPath,
179
+ headers: params.headers,
180
+ });
181
+
182
+ const pageItems = res.value ?? [];
183
+
184
+ if (params.findOne) {
185
+ const match = pageItems.find(params.findOne);
186
+ if (match) {
187
+ items.push(...pageItems);
188
+ return { items, truncated: false, found: match };
189
+ }
190
+ }
191
+
192
+ items.push(...pageItems);
193
+
194
+ // @odata.nextLink is an absolute URL; strip the Graph root to get a relative path
195
+ const rawNext: string | undefined = res["@odata.nextLink"];
196
+ if (rawNext) {
197
+ nextPath = rawNext
198
+ .replace("https://graph.microsoft.com/v1.0", "")
199
+ .replace("https://graph.microsoft.com/beta", "");
200
+ } else {
201
+ nextPath = undefined;
202
+ }
203
+ }
204
+
205
+ return { items, truncated: Boolean(nextPath) };
206
+ }
207
+
208
+ export async function resolveGraphToken(
209
+ cfg: unknown,
210
+ options?: { preferDelegated?: boolean },
211
+ ): Promise<string> {
212
+ const msteamsCfg = (cfg as { channels?: { msteams?: MSTeamsConfig } })?.channels?.msteams;
213
+ const creds = resolveMSTeamsCredentials(msteamsCfg);
214
+ if (!creds) {
215
+ throw new Error("MS Teams credentials missing");
216
+ }
217
+
218
+ // Try delegated token if requested and configured
219
+ if (options?.preferDelegated && msteamsCfg?.delegatedAuth?.enabled && creds.type === "secret") {
220
+ const delegated = await resolveDelegatedAccessToken({
221
+ tenantId: creds.tenantId,
222
+ clientId: creds.appId,
223
+ clientSecret: creds.appPassword,
224
+ });
225
+ if (delegated) {
226
+ return delegated;
227
+ }
228
+ // Fall through to app-only token
229
+ }
230
+
231
+ const { app } = await loadMSTeamsSdkWithAuth(creds);
232
+ const tokenProvider = createMSTeamsTokenProvider(app);
233
+ const graphTokenValue = await tokenProvider.getAccessToken("https://graph.microsoft.com");
234
+ const accessToken = readAccessToken(graphTokenValue);
235
+ if (!accessToken) {
236
+ throw new Error("MS Teams graph token unavailable");
237
+ }
238
+ return accessToken;
239
+ }
240
+
241
+ export async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
242
+ const escaped = escapeOData(query);
243
+ const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
244
+ const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
245
+ const { items } = await fetchAllGraphPages<GraphGroup>({ token, path, maxPages: 5 });
246
+ return items;
247
+ }
248
+
249
+ export async function postGraphJson<T>(params: {
250
+ token: string;
251
+ path: string;
252
+ body?: unknown;
253
+ }): Promise<T> {
254
+ const res = await requestGraph({
255
+ token: params.token,
256
+ path: params.path,
257
+ method: "POST",
258
+ body: params.body,
259
+ errorPrefix: "Graph POST",
260
+ });
261
+ return readOptionalGraphJson<T>(res, `Graph POST ${params.path} failed`);
262
+ }
263
+
264
+ export async function postGraphBetaJson<T>(params: {
265
+ token: string;
266
+ path: string;
267
+ body?: unknown;
268
+ }): Promise<T> {
269
+ const res = await requestGraph({
270
+ token: params.token,
271
+ path: params.path,
272
+ method: "POST",
273
+ root: GRAPH_BETA,
274
+ body: params.body,
275
+ errorPrefix: "Graph beta POST",
276
+ });
277
+ return readOptionalGraphJson<T>(res, `Graph beta POST ${params.path} failed`);
278
+ }
279
+
280
+ export async function deleteGraphRequest(params: { token: string; path: string }): Promise<void> {
281
+ await requestGraph({
282
+ token: params.token,
283
+ path: params.path,
284
+ method: "DELETE",
285
+ errorPrefix: "Graph DELETE",
286
+ });
287
+ }
288
+
289
+ export async function patchGraphJson<T>(params: {
290
+ token: string;
291
+ path: string;
292
+ body?: unknown;
293
+ }): Promise<T> {
294
+ const res = await requestGraph({
295
+ token: params.token,
296
+ path: params.path,
297
+ method: "PATCH",
298
+ body: params.body,
299
+ errorPrefix: "Graph PATCH",
300
+ });
301
+ return readOptionalGraphJson<T>(res, `Graph PATCH ${params.path} failed`);
302
+ }
303
+
304
+ export async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
305
+ const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
306
+ const { items } = await fetchAllGraphPages<GraphChannel>({ token, path, maxPages: 10 });
307
+ return items;
308
+ }
package/src/inbound.ts ADDED
@@ -0,0 +1,148 @@
1
+ type MSTeamsQuoteInfo = {
2
+ sender: string;
3
+ body: string;
4
+ };
5
+
6
+ /**
7
+ * Decode common HTML entities to plain text.
8
+ */
9
+ export function decodeHtmlEntities(html: string): string {
10
+ return html
11
+ .replace(/&lt;/g, "<")
12
+ .replace(/&gt;/g, ">")
13
+ .replace(/&quot;/g, '"')
14
+ .replace(/&#39;/g, "'")
15
+ .replace(/&#x27;/g, "'")
16
+ .replace(/&nbsp;/g, " ")
17
+ .replace(/&amp;/g, "&"); // must be last to prevent double-decoding (e.g. &amp;lt; → &lt; not <)
18
+ }
19
+
20
+ /**
21
+ * Strip HTML tags, preserving text content.
22
+ */
23
+ export function htmlToPlainText(html: string): string {
24
+ return decodeHtmlEntities(
25
+ html
26
+ .replace(/<[^>]*>/g, " ")
27
+ .replace(/\s+/g, " ")
28
+ .trim(),
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Extract quote info from MS Teams HTML reply attachments.
34
+ * Teams wraps quoted content in a blockquote with itemtype="http://schema.skype.com/Reply".
35
+ */
36
+ export function extractMSTeamsQuoteInfo(
37
+ attachments: Array<{ contentType?: string | null; content?: unknown }>,
38
+ ): MSTeamsQuoteInfo | undefined {
39
+ for (const att of attachments) {
40
+ // Content may be a plain string or an object with .text/.body (e.g. Adaptive Card payloads).
41
+ let content = "";
42
+ if (typeof att.content === "string") {
43
+ content = att.content;
44
+ } else if (typeof att.content === "object" && att.content !== null) {
45
+ const record = att.content as Record<string, unknown>;
46
+ content =
47
+ typeof record.text === "string"
48
+ ? record.text
49
+ : typeof record.body === "string"
50
+ ? record.body
51
+ : "";
52
+ }
53
+ if (!content) {
54
+ continue;
55
+ }
56
+
57
+ // Look for the Skype Reply schema blockquote.
58
+ if (!content.includes("http://schema.skype.com/Reply")) {
59
+ continue;
60
+ }
61
+
62
+ // Extract sender from <strong itemprop="mri">.
63
+ const senderMatch = /<strong[^>]*itemprop=["']mri["'][^>]*>(.*?)<\/strong>/i.exec(content);
64
+ const sender = senderMatch?.[1] ? htmlToPlainText(senderMatch[1]) : undefined;
65
+
66
+ // Extract body from <p itemprop="copy">.
67
+ const bodyMatch = /<p[^>]*itemprop=["']copy["'][^>]*>(.*?)<\/p>/is.exec(content);
68
+ const body = bodyMatch?.[1] ? htmlToPlainText(bodyMatch[1]) : undefined;
69
+
70
+ if (body) {
71
+ return { sender: sender ?? "unknown", body };
72
+ }
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ type MentionableActivity = {
78
+ recipient?: { id?: string } | null;
79
+ entities?: Array<{
80
+ type?: string;
81
+ mentioned?: { id?: string };
82
+ }> | null;
83
+ };
84
+
85
+ export function normalizeMSTeamsConversationId(raw: string): string {
86
+ return raw.split(";")[0] ?? raw;
87
+ }
88
+
89
+ export function extractMSTeamsConversationMessageId(raw: string): string | undefined {
90
+ if (!raw) {
91
+ return undefined;
92
+ }
93
+ const match = /(?:^|;)messageid=([^;]+)/i.exec(raw);
94
+ const value = match?.[1]?.trim() ?? "";
95
+ return value || undefined;
96
+ }
97
+
98
+ export function parseMSTeamsActivityTimestamp(value: unknown): Date | undefined {
99
+ if (!value) {
100
+ return undefined;
101
+ }
102
+ if (value instanceof Date) {
103
+ return value;
104
+ }
105
+ if (typeof value !== "string") {
106
+ return undefined;
107
+ }
108
+ const date = new Date(value);
109
+ return Number.isNaN(date.getTime()) ? undefined : date;
110
+ }
111
+
112
+ export function stripMSTeamsMentionTags(text: string): string {
113
+ // Teams wraps mentions in <at>...</at> tags
114
+ return text.replace(/<at[^>]*>.*?<\/at>/gi, "").trim();
115
+ }
116
+
117
+ /**
118
+ * Bot Framework uses 'a:xxx' conversation IDs for personal chats, but Graph API
119
+ * requires the '19:{userId}_{botAppId}@unq.gbl.spaces' format.
120
+ *
121
+ * This is the documented Graph API format for 1:1 chat thread IDs between a user
122
+ * and a bot/app. See Microsoft docs "Get chat between user and app":
123
+ * https://learn.microsoft.com/en-us/graph/api/userscopeteamsappinstallation-get-chat
124
+ *
125
+ * The format is only synthesized when the Bot Framework conversation ID starts with
126
+ * 'a:' (the opaque format used by BF but not recognized by Graph). If the ID already
127
+ * has the '19:...' Graph format, it is passed through unchanged.
128
+ */
129
+ export function translateMSTeamsDmConversationIdForGraph(params: {
130
+ isDirectMessage: boolean;
131
+ conversationId: string;
132
+ aadObjectId?: string | null;
133
+ appId?: string | null;
134
+ }): string {
135
+ const { isDirectMessage, conversationId, aadObjectId, appId } = params;
136
+ return isDirectMessage && conversationId.startsWith("a:") && aadObjectId && appId
137
+ ? `19:${aadObjectId}_${appId}@unq.gbl.spaces`
138
+ : conversationId;
139
+ }
140
+
141
+ export function wasMSTeamsBotMentioned(activity: MentionableActivity): boolean {
142
+ const botId = activity.recipient?.id;
143
+ if (!botId) {
144
+ return false;
145
+ }
146
+ const entities = activity.entities ?? [];
147
+ return entities.some((e) => e.type === "mention" && e.mentioned?.id === botId);
148
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { monitorMSTeamsProvider } from "./monitor.js";
2
+ export { probeMSTeams } from "./probe.js";
3
+ export { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
4
+ export { type MSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
@@ -0,0 +1,105 @@
1
+ /**
2
+ * MIME type detection and filename extraction for MSTeams media attachments.
3
+ */
4
+
5
+ import path from "node:path";
6
+ import {
7
+ detectMime,
8
+ extensionForMime,
9
+ extractOriginalFilename,
10
+ getFileExtension,
11
+ } from "../runtime-api.js";
12
+
13
+ /**
14
+ * Detect MIME type from URL extension or data URL.
15
+ * Uses shared MIME detection for consistency with core handling.
16
+ */
17
+ export async function getMimeType(url: string): Promise<string> {
18
+ // Handle data URLs: data:image/png;base64,...
19
+ if (url.startsWith("data:")) {
20
+ const match = url.match(/^data:([^;,]+)/);
21
+ if (match?.[1]) {
22
+ return match[1];
23
+ }
24
+ }
25
+
26
+ // Use shared MIME detection (extension-based for URLs)
27
+ const detected = await detectMime({ filePath: url });
28
+ return detected ?? "application/octet-stream";
29
+ }
30
+
31
+ /**
32
+ * Extract filename from URL or local path.
33
+ * For local paths, extracts original filename if stored with embedded name pattern.
34
+ * Falls back to deriving the extension from MIME type when no extension present.
35
+ */
36
+ export async function extractFilename(url: string): Promise<string> {
37
+ // Handle data URLs: derive extension from MIME
38
+ if (url.startsWith("data:")) {
39
+ const mime = await getMimeType(url);
40
+ const ext = extensionForMime(mime) ?? ".bin";
41
+ const prefix = mime.startsWith("image/") ? "image" : "file";
42
+ return `${prefix}${ext}`;
43
+ }
44
+
45
+ // Try to extract from URL pathname
46
+ try {
47
+ const pathname = new URL(url).pathname;
48
+ const basename = path.basename(pathname);
49
+ const existingExt = getFileExtension(pathname);
50
+ if (basename && existingExt) {
51
+ return basename;
52
+ }
53
+ // No extension in URL, derive from MIME
54
+ const mime = await getMimeType(url);
55
+ const ext = extensionForMime(mime) ?? ".bin";
56
+ const prefix = mime.startsWith("image/") ? "image" : "file";
57
+ return basename ? `${basename}${ext}` : `${prefix}${ext}`;
58
+ } catch {
59
+ // Local paths - use extractOriginalFilename to extract embedded original name
60
+ return extractOriginalFilename(url);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Check if a URL refers to a local file path.
66
+ */
67
+ export function isLocalPath(url: string): boolean {
68
+ if (url.startsWith("file://") || url.startsWith("/") || url.startsWith("~")) {
69
+ return true;
70
+ }
71
+
72
+ // Windows rooted path on current drive (e.g. \tmp\file.txt)
73
+ if (url.startsWith("\\") && !url.startsWith("\\\\")) {
74
+ return true;
75
+ }
76
+
77
+ // Windows drive-letter absolute path (e.g. C:\foo\bar.txt or C:/foo/bar.txt)
78
+ if (/^[a-zA-Z]:[\\/]/.test(url)) {
79
+ return true;
80
+ }
81
+
82
+ // Windows UNC path (e.g. \\server\share\file.txt)
83
+ if (url.startsWith("\\\\")) {
84
+ return true;
85
+ }
86
+
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * Extract the message ID from a Bot Framework response.
92
+ */
93
+ export function extractMessageId(response: unknown): string | null {
94
+ if (!response || typeof response !== "object") {
95
+ return null;
96
+ }
97
+ if (!("id" in response)) {
98
+ return null;
99
+ }
100
+ const { id } = response as { id?: unknown };
101
+ if (typeof id !== "string" || !id) {
102
+ return null;
103
+ }
104
+ return id;
105
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * MS Teams mention handling utilities.
3
+ *
4
+ * Mentions in Teams require:
5
+ * 1. Text containing <at>Name</at> tags
6
+ * 2. entities array with mention metadata
7
+ */
8
+
9
+ type MentionEntity = {
10
+ type: "mention";
11
+ text: string;
12
+ mentioned: {
13
+ id: string;
14
+ name: string;
15
+ };
16
+ };
17
+
18
+ type MentionInfo = {
19
+ /** User/bot ID (e.g., "28:xxx" or AAD object ID) */
20
+ id: string;
21
+ /** Display name */
22
+ name: string;
23
+ };
24
+
25
+ /**
26
+ * Check whether an ID looks like a valid Teams user/bot identifier.
27
+ * Accepts:
28
+ * - Bot Framework IDs: "28:xxx..." / "29:xxx..." / "8:orgid:..."
29
+ * - AAD object IDs (UUIDs): "d5318c29-33ac-4e6b-bd42-57b8b793908f"
30
+ *
31
+ * Keep this permissive enough for real Teams IDs while still rejecting
32
+ * documentation placeholders like `@[表示名](ユーザーID)`.
33
+ */
34
+ const TEAMS_BOT_ID_PATTERN = /^\d+:[a-z0-9._=-]+(?::[a-z0-9._=-]+)*$/i;
35
+ const AAD_OBJECT_ID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
36
+
37
+ function isValidTeamsId(id: string): boolean {
38
+ return TEAMS_BOT_ID_PATTERN.test(id) || AAD_OBJECT_ID_PATTERN.test(id);
39
+ }
40
+
41
+ /**
42
+ * Parse mentions from text in the format @[Name](id).
43
+ * Example: "Hello @[John Doe](28:xxx-yyy-zzz)!"
44
+ *
45
+ * Only matches where the id looks like a real Teams user/bot ID are treated
46
+ * as mentions. This avoids false positives from documentation or code samples
47
+ * embedded in the message (e.g. `@[表示名](ユーザーID)` in backticks).
48
+ *
49
+ * Returns both the formatted text with <at> tags and the entities array.
50
+ */
51
+ export function parseMentions(text: string): {
52
+ text: string;
53
+ entities: MentionEntity[];
54
+ } {
55
+ const mentionPattern = /@\[([^\]]+)\]\(([^)]+)\)/g;
56
+ const entities: MentionEntity[] = [];
57
+
58
+ // Replace @[Name](id) with <at>Name</at> only for valid Teams IDs
59
+ const formattedText = text.replace(mentionPattern, (match, name, id) => {
60
+ const trimmedId = id.trim();
61
+
62
+ // Skip matches where the id doesn't look like a real Teams identifier
63
+ if (!isValidTeamsId(trimmedId)) {
64
+ return match;
65
+ }
66
+
67
+ const trimmedName = name.trim();
68
+ const mentionTag = `<at>${trimmedName}</at>`;
69
+ entities.push({
70
+ type: "mention",
71
+ text: mentionTag,
72
+ mentioned: {
73
+ id: trimmedId,
74
+ name: trimmedName,
75
+ },
76
+ });
77
+ return mentionTag;
78
+ });
79
+
80
+ return {
81
+ text: formattedText,
82
+ entities,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Build mention entities array from a list of mentions.
88
+ * Use this when you already have the mention info and formatted text.
89
+ */
90
+ export function buildMentionEntities(mentions: MentionInfo[]): MentionEntity[] {
91
+ return mentions.map((mention) => ({
92
+ type: "mention",
93
+ text: `<at>${mention.name}</at>`,
94
+ mentioned: {
95
+ id: mention.id,
96
+ name: mention.name,
97
+ },
98
+ }));
99
+ }
100
+
101
+ /**
102
+ * Format text with mentions using <at> tags.
103
+ * This is a convenience function when you want to manually format mentions.
104
+ */
105
+ export function formatMentionText(text: string, mentions: MentionInfo[]): string {
106
+ let formatted = text;
107
+ for (const mention of mentions) {
108
+ // Replace @Name or @name with <at>Name</at>
109
+ const escapedName = mention.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
110
+ const namePattern = new RegExp(`@${escapedName}`, "gi");
111
+ formatted = formatted.replace(namePattern, `<at>${mention.name}</at>`);
112
+ }
113
+ return formatted;
114
+ }