@openclaw/msteams 2026.1.29
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/CHANGELOG.md +56 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +36 -0
- package/src/attachments/download.ts +206 -0
- package/src/attachments/graph.ts +319 -0
- package/src/attachments/html.ts +76 -0
- package/src/attachments/payload.ts +22 -0
- package/src/attachments/shared.ts +235 -0
- package/src/attachments/types.ts +37 -0
- package/src/attachments.test.ts +424 -0
- package/src/attachments.ts +18 -0
- package/src/channel.directory.test.ts +46 -0
- package/src/channel.ts +436 -0
- package/src/conversation-store-fs.test.ts +89 -0
- package/src/conversation-store-fs.ts +155 -0
- package/src/conversation-store-memory.ts +45 -0
- package/src/conversation-store.ts +41 -0
- package/src/directory-live.ts +179 -0
- package/src/errors.test.ts +46 -0
- package/src/errors.ts +158 -0
- package/src/file-consent-helpers.test.ts +234 -0
- package/src/file-consent-helpers.ts +73 -0
- package/src/file-consent.ts +122 -0
- package/src/graph-chat.ts +52 -0
- package/src/graph-upload.ts +445 -0
- package/src/inbound.test.ts +67 -0
- package/src/inbound.ts +38 -0
- package/src/index.ts +4 -0
- package/src/media-helpers.test.ts +186 -0
- package/src/media-helpers.ts +77 -0
- package/src/messenger.test.ts +245 -0
- package/src/messenger.ts +460 -0
- package/src/monitor-handler/inbound-media.ts +123 -0
- package/src/monitor-handler/message-handler.ts +629 -0
- package/src/monitor-handler.ts +166 -0
- package/src/monitor-types.ts +5 -0
- package/src/monitor.ts +290 -0
- package/src/onboarding.ts +432 -0
- package/src/outbound.ts +47 -0
- package/src/pending-uploads.ts +87 -0
- package/src/policy.test.ts +210 -0
- package/src/policy.ts +247 -0
- package/src/polls-store-memory.ts +30 -0
- package/src/polls-store.test.ts +40 -0
- package/src/polls.test.ts +73 -0
- package/src/polls.ts +300 -0
- package/src/probe.test.ts +57 -0
- package/src/probe.ts +99 -0
- package/src/reply-dispatcher.ts +128 -0
- package/src/resolve-allowlist.ts +277 -0
- package/src/runtime.ts +14 -0
- package/src/sdk-types.ts +19 -0
- package/src/sdk.ts +33 -0
- package/src/send-context.ts +156 -0
- package/src/send.ts +489 -0
- package/src/sent-message-cache.test.ts +16 -0
- package/src/sent-message-cache.ts +41 -0
- package/src/storage.ts +22 -0
- package/src/store-fs.ts +80 -0
- package/src/token.ts +19 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MSTeamsConversationStore,
|
|
3
|
+
MSTeamsConversationStoreEntry,
|
|
4
|
+
StoredConversationReference,
|
|
5
|
+
} from "./conversation-store.js";
|
|
6
|
+
|
|
7
|
+
export function createMSTeamsConversationStoreMemory(
|
|
8
|
+
initial: MSTeamsConversationStoreEntry[] = [],
|
|
9
|
+
): MSTeamsConversationStore {
|
|
10
|
+
const map = new Map<string, StoredConversationReference>();
|
|
11
|
+
for (const { conversationId, reference } of initial) {
|
|
12
|
+
map.set(conversationId, reference);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
upsert: async (conversationId, reference) => {
|
|
17
|
+
map.set(conversationId, reference);
|
|
18
|
+
},
|
|
19
|
+
get: async (conversationId) => {
|
|
20
|
+
return map.get(conversationId) ?? null;
|
|
21
|
+
},
|
|
22
|
+
list: async () => {
|
|
23
|
+
return Array.from(map.entries()).map(([conversationId, reference]) => ({
|
|
24
|
+
conversationId,
|
|
25
|
+
reference,
|
|
26
|
+
}));
|
|
27
|
+
},
|
|
28
|
+
remove: async (conversationId) => {
|
|
29
|
+
return map.delete(conversationId);
|
|
30
|
+
},
|
|
31
|
+
findByUserId: async (id) => {
|
|
32
|
+
const target = id.trim();
|
|
33
|
+
if (!target) return null;
|
|
34
|
+
for (const [conversationId, reference] of map.entries()) {
|
|
35
|
+
if (reference.user?.aadObjectId === target) {
|
|
36
|
+
return { conversationId, reference };
|
|
37
|
+
}
|
|
38
|
+
if (reference.user?.id === target) {
|
|
39
|
+
return { conversationId, reference };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation store for MS Teams proactive messaging.
|
|
3
|
+
*
|
|
4
|
+
* Stores ConversationReference-like objects keyed by conversation ID so we can
|
|
5
|
+
* send proactive messages later (after the webhook turn has completed).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Minimal ConversationReference shape for proactive messaging */
|
|
9
|
+
export type StoredConversationReference = {
|
|
10
|
+
/** Activity ID from the last message */
|
|
11
|
+
activityId?: string;
|
|
12
|
+
/** User who sent the message */
|
|
13
|
+
user?: { id?: string; name?: string; aadObjectId?: string };
|
|
14
|
+
/** Agent/bot that received the message */
|
|
15
|
+
agent?: { id?: string; name?: string; aadObjectId?: string } | null;
|
|
16
|
+
/** @deprecated legacy field (pre-Agents SDK). Prefer `agent`. */
|
|
17
|
+
bot?: { id?: string; name?: string };
|
|
18
|
+
/** Conversation details */
|
|
19
|
+
conversation?: { id?: string; conversationType?: string; tenantId?: string };
|
|
20
|
+
/** Team ID for channel messages (when available). */
|
|
21
|
+
teamId?: string;
|
|
22
|
+
/** Channel ID (usually "msteams") */
|
|
23
|
+
channelId?: string;
|
|
24
|
+
/** Service URL for sending messages back */
|
|
25
|
+
serviceUrl?: string;
|
|
26
|
+
/** Locale */
|
|
27
|
+
locale?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type MSTeamsConversationStoreEntry = {
|
|
31
|
+
conversationId: string;
|
|
32
|
+
reference: StoredConversationReference;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type MSTeamsConversationStore = {
|
|
36
|
+
upsert: (conversationId: string, reference: StoredConversationReference) => Promise<void>;
|
|
37
|
+
get: (conversationId: string) => Promise<StoredConversationReference | null>;
|
|
38
|
+
list: () => Promise<MSTeamsConversationStoreEntry[]>;
|
|
39
|
+
remove: (conversationId: string) => Promise<boolean>;
|
|
40
|
+
findByUserId: (id: string) => Promise<MSTeamsConversationStoreEntry | null>;
|
|
41
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
import { GRAPH_ROOT } from "./attachments/shared.js";
|
|
4
|
+
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
|
|
5
|
+
import { resolveMSTeamsCredentials } from "./token.js";
|
|
6
|
+
|
|
7
|
+
type GraphUser = {
|
|
8
|
+
id?: string;
|
|
9
|
+
displayName?: string;
|
|
10
|
+
userPrincipalName?: string;
|
|
11
|
+
mail?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type GraphGroup = {
|
|
15
|
+
id?: string;
|
|
16
|
+
displayName?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type GraphChannel = {
|
|
20
|
+
id?: string;
|
|
21
|
+
displayName?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type GraphResponse<T> = { value?: T[] };
|
|
25
|
+
|
|
26
|
+
function readAccessToken(value: unknown): string | null {
|
|
27
|
+
if (typeof value === "string") return value;
|
|
28
|
+
if (value && typeof value === "object") {
|
|
29
|
+
const token =
|
|
30
|
+
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
|
|
31
|
+
return typeof token === "string" ? token : null;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeQuery(value?: string | null): string {
|
|
37
|
+
return value?.trim() ?? "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function escapeOData(value: string): string {
|
|
41
|
+
return value.replace(/'/g, "''");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fetchGraphJson<T>(params: {
|
|
45
|
+
token: string;
|
|
46
|
+
path: string;
|
|
47
|
+
headers?: Record<string, string>;
|
|
48
|
+
}): Promise<T> {
|
|
49
|
+
const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
|
|
50
|
+
headers: {
|
|
51
|
+
Authorization: `Bearer ${params.token}`,
|
|
52
|
+
...(params.headers ?? {}),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
const text = await res.text().catch(() => "");
|
|
57
|
+
throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`);
|
|
58
|
+
}
|
|
59
|
+
return (await res.json()) as T;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function resolveGraphToken(cfg: unknown): Promise<string> {
|
|
63
|
+
const creds = resolveMSTeamsCredentials((cfg as { channels?: { msteams?: unknown } })?.channels?.msteams);
|
|
64
|
+
if (!creds) throw new Error("MS Teams credentials missing");
|
|
65
|
+
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
|
66
|
+
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
|
67
|
+
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
|
|
68
|
+
const accessToken = readAccessToken(token);
|
|
69
|
+
if (!accessToken) throw new Error("MS Teams graph token unavailable");
|
|
70
|
+
return accessToken;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
|
|
74
|
+
const escaped = escapeOData(query);
|
|
75
|
+
const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
|
|
76
|
+
const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
|
|
77
|
+
const res = await fetchGraphJson<GraphResponse<GraphGroup>>({ token, path });
|
|
78
|
+
return res.value ?? [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
|
|
82
|
+
const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
|
|
83
|
+
const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
|
|
84
|
+
return res.value ?? [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function listMSTeamsDirectoryPeersLive(params: {
|
|
88
|
+
cfg: unknown;
|
|
89
|
+
query?: string | null;
|
|
90
|
+
limit?: number | null;
|
|
91
|
+
}): Promise<ChannelDirectoryEntry[]> {
|
|
92
|
+
const query = normalizeQuery(params.query);
|
|
93
|
+
if (!query) return [];
|
|
94
|
+
const token = await resolveGraphToken(params.cfg);
|
|
95
|
+
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
|
|
96
|
+
|
|
97
|
+
let users: GraphUser[] = [];
|
|
98
|
+
if (query.includes("@")) {
|
|
99
|
+
const escaped = escapeOData(query);
|
|
100
|
+
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
|
|
101
|
+
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
|
|
102
|
+
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
|
|
103
|
+
users = res.value ?? [];
|
|
104
|
+
} else {
|
|
105
|
+
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`;
|
|
106
|
+
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
|
|
107
|
+
token,
|
|
108
|
+
path,
|
|
109
|
+
headers: { ConsistencyLevel: "eventual" },
|
|
110
|
+
});
|
|
111
|
+
users = res.value ?? [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return users
|
|
115
|
+
.map((user) => {
|
|
116
|
+
const id = user.id?.trim();
|
|
117
|
+
if (!id) return null;
|
|
118
|
+
const name = user.displayName?.trim();
|
|
119
|
+
const handle = user.userPrincipalName?.trim() || user.mail?.trim();
|
|
120
|
+
return {
|
|
121
|
+
kind: "user",
|
|
122
|
+
id: `user:${id}`,
|
|
123
|
+
name: name || undefined,
|
|
124
|
+
handle: handle ? `@${handle}` : undefined,
|
|
125
|
+
raw: user,
|
|
126
|
+
} satisfies ChannelDirectoryEntry;
|
|
127
|
+
})
|
|
128
|
+
.filter(Boolean) as ChannelDirectoryEntry[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function listMSTeamsDirectoryGroupsLive(params: {
|
|
132
|
+
cfg: unknown;
|
|
133
|
+
query?: string | null;
|
|
134
|
+
limit?: number | null;
|
|
135
|
+
}): Promise<ChannelDirectoryEntry[]> {
|
|
136
|
+
const rawQuery = normalizeQuery(params.query);
|
|
137
|
+
if (!rawQuery) return [];
|
|
138
|
+
const token = await resolveGraphToken(params.cfg);
|
|
139
|
+
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
|
|
140
|
+
const [teamQuery, channelQuery] = rawQuery.includes("/")
|
|
141
|
+
? rawQuery.split("/", 2).map((part) => part.trim()).filter(Boolean)
|
|
142
|
+
: [rawQuery, null];
|
|
143
|
+
|
|
144
|
+
const teams = await listTeamsByName(token, teamQuery);
|
|
145
|
+
const results: ChannelDirectoryEntry[] = [];
|
|
146
|
+
|
|
147
|
+
for (const team of teams) {
|
|
148
|
+
const teamId = team.id?.trim();
|
|
149
|
+
if (!teamId) continue;
|
|
150
|
+
const teamName = team.displayName?.trim() || teamQuery;
|
|
151
|
+
if (!channelQuery) {
|
|
152
|
+
results.push({
|
|
153
|
+
kind: "group",
|
|
154
|
+
id: `team:${teamId}`,
|
|
155
|
+
name: teamName,
|
|
156
|
+
handle: teamName ? `#${teamName}` : undefined,
|
|
157
|
+
raw: team,
|
|
158
|
+
});
|
|
159
|
+
if (results.length >= limit) return results;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const channels = await listChannelsForTeam(token, teamId);
|
|
163
|
+
for (const channel of channels) {
|
|
164
|
+
const name = channel.displayName?.trim();
|
|
165
|
+
if (!name) continue;
|
|
166
|
+
if (!name.toLowerCase().includes(channelQuery.toLowerCase())) continue;
|
|
167
|
+
results.push({
|
|
168
|
+
kind: "group",
|
|
169
|
+
id: `conversation:${channel.id}`,
|
|
170
|
+
name: `${teamName}/${name}`,
|
|
171
|
+
handle: `#${name}`,
|
|
172
|
+
raw: channel,
|
|
173
|
+
});
|
|
174
|
+
if (results.length >= limit) return results;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return results;
|
|
179
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
classifyMSTeamsSendError,
|
|
5
|
+
formatMSTeamsSendErrorHint,
|
|
6
|
+
formatUnknownError,
|
|
7
|
+
} from "./errors.js";
|
|
8
|
+
|
|
9
|
+
describe("msteams errors", () => {
|
|
10
|
+
it("formats unknown errors", () => {
|
|
11
|
+
expect(formatUnknownError("oops")).toBe("oops");
|
|
12
|
+
expect(formatUnknownError(null)).toBe("null");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("classifies auth errors", () => {
|
|
16
|
+
expect(classifyMSTeamsSendError({ statusCode: 401 }).kind).toBe("auth");
|
|
17
|
+
expect(classifyMSTeamsSendError({ statusCode: 403 }).kind).toBe("auth");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("classifies throttling errors and parses retry-after", () => {
|
|
21
|
+
expect(classifyMSTeamsSendError({ statusCode: 429, retryAfter: "1.5" })).toMatchObject({
|
|
22
|
+
kind: "throttled",
|
|
23
|
+
statusCode: 429,
|
|
24
|
+
retryAfterMs: 1500,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("classifies transient errors", () => {
|
|
29
|
+
expect(classifyMSTeamsSendError({ statusCode: 503 })).toMatchObject({
|
|
30
|
+
kind: "transient",
|
|
31
|
+
statusCode: 503,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("classifies permanent 4xx errors", () => {
|
|
36
|
+
expect(classifyMSTeamsSendError({ statusCode: 400 })).toMatchObject({
|
|
37
|
+
kind: "permanent",
|
|
38
|
+
statusCode: 400,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("provides actionable hints for common cases", () => {
|
|
43
|
+
expect(formatMSTeamsSendErrorHint({ kind: "auth" })).toContain("msteams");
|
|
44
|
+
expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain("throttled");
|
|
45
|
+
});
|
|
46
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
export function formatUnknownError(err: unknown): string {
|
|
2
|
+
if (err instanceof Error) return err.message;
|
|
3
|
+
if (typeof err === "string") return err;
|
|
4
|
+
if (err === null) return "null";
|
|
5
|
+
if (err === undefined) return "undefined";
|
|
6
|
+
if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
|
|
7
|
+
return String(err);
|
|
8
|
+
}
|
|
9
|
+
if (typeof err === "symbol") return err.description ?? err.toString();
|
|
10
|
+
if (typeof err === "function") {
|
|
11
|
+
return err.name ? `[function ${err.name}]` : "[function]";
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
return JSON.stringify(err) ?? "unknown error";
|
|
15
|
+
} catch {
|
|
16
|
+
return "unknown error";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extractStatusCode(err: unknown): number | null {
|
|
25
|
+
if (!isRecord(err)) return null;
|
|
26
|
+
const direct = err.statusCode ?? err.status;
|
|
27
|
+
if (typeof direct === "number" && Number.isFinite(direct)) return direct;
|
|
28
|
+
if (typeof direct === "string") {
|
|
29
|
+
const parsed = Number.parseInt(direct, 10);
|
|
30
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const response = err.response;
|
|
34
|
+
if (isRecord(response)) {
|
|
35
|
+
const status = response.status;
|
|
36
|
+
if (typeof status === "number" && Number.isFinite(status)) return status;
|
|
37
|
+
if (typeof status === "string") {
|
|
38
|
+
const parsed = Number.parseInt(status, 10);
|
|
39
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractRetryAfterMs(err: unknown): number | null {
|
|
47
|
+
if (!isRecord(err)) return null;
|
|
48
|
+
|
|
49
|
+
const direct = err.retryAfterMs ?? err.retry_after_ms;
|
|
50
|
+
if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) {
|
|
51
|
+
return direct;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const retryAfter = err.retryAfter ?? err.retry_after;
|
|
55
|
+
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
|
|
56
|
+
return retryAfter >= 0 ? retryAfter * 1000 : null;
|
|
57
|
+
}
|
|
58
|
+
if (typeof retryAfter === "string") {
|
|
59
|
+
const parsed = Number.parseFloat(retryAfter);
|
|
60
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const response = err.response;
|
|
64
|
+
if (!isRecord(response)) return null;
|
|
65
|
+
|
|
66
|
+
const headers = response.headers;
|
|
67
|
+
if (!headers) return null;
|
|
68
|
+
|
|
69
|
+
if (isRecord(headers)) {
|
|
70
|
+
const raw = headers["retry-after"] ?? headers["Retry-After"];
|
|
71
|
+
if (typeof raw === "string") {
|
|
72
|
+
const parsed = Number.parseFloat(raw);
|
|
73
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fetch Headers-like interface
|
|
78
|
+
if (
|
|
79
|
+
typeof headers === "object" &&
|
|
80
|
+
headers !== null &&
|
|
81
|
+
"get" in headers &&
|
|
82
|
+
typeof (headers as { get?: unknown }).get === "function"
|
|
83
|
+
) {
|
|
84
|
+
const raw = (headers as { get: (name: string) => string | null }).get("retry-after");
|
|
85
|
+
if (raw) {
|
|
86
|
+
const parsed = Number.parseFloat(raw);
|
|
87
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type MSTeamsSendErrorKind = "auth" | "throttled" | "transient" | "permanent" | "unknown";
|
|
95
|
+
|
|
96
|
+
export type MSTeamsSendErrorClassification = {
|
|
97
|
+
kind: MSTeamsSendErrorKind;
|
|
98
|
+
statusCode?: number;
|
|
99
|
+
retryAfterMs?: number;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Classify outbound send errors for safe retries and actionable logs.
|
|
104
|
+
*
|
|
105
|
+
* Important: We only mark errors as retryable when we have an explicit HTTP
|
|
106
|
+
* status code that indicates the message was not accepted (e.g. 429, 5xx).
|
|
107
|
+
* For transport-level errors where delivery is ambiguous, we prefer to avoid
|
|
108
|
+
* retries to reduce the chance of duplicate posts.
|
|
109
|
+
*/
|
|
110
|
+
export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassification {
|
|
111
|
+
const statusCode = extractStatusCode(err);
|
|
112
|
+
const retryAfterMs = extractRetryAfterMs(err);
|
|
113
|
+
|
|
114
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
115
|
+
return { kind: "auth", statusCode };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (statusCode === 429) {
|
|
119
|
+
return {
|
|
120
|
+
kind: "throttled",
|
|
121
|
+
statusCode,
|
|
122
|
+
retryAfterMs: retryAfterMs ?? undefined,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (statusCode === 408 || (statusCode != null && statusCode >= 500)) {
|
|
127
|
+
return {
|
|
128
|
+
kind: "transient",
|
|
129
|
+
statusCode,
|
|
130
|
+
retryAfterMs: retryAfterMs ?? undefined,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (statusCode != null && statusCode >= 400) {
|
|
135
|
+
return { kind: "permanent", statusCode };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
kind: "unknown",
|
|
140
|
+
statusCode: statusCode ?? undefined,
|
|
141
|
+
retryAfterMs: retryAfterMs ?? undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function formatMSTeamsSendErrorHint(
|
|
146
|
+
classification: MSTeamsSendErrorClassification,
|
|
147
|
+
): string | undefined {
|
|
148
|
+
if (classification.kind === "auth") {
|
|
149
|
+
return "check msteams appId/appPassword/tenantId (or env vars MSTEAMS_APP_ID/MSTEAMS_APP_PASSWORD/MSTEAMS_TENANT_ID)";
|
|
150
|
+
}
|
|
151
|
+
if (classification.kind === "throttled") {
|
|
152
|
+
return "Teams throttled the bot; backing off may help";
|
|
153
|
+
}
|
|
154
|
+
if (classification.kind === "transient") {
|
|
155
|
+
return "transient Teams/Bot Framework error; retry may succeed";
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
|
|
3
|
+
import * as pendingUploads from "./pending-uploads.js";
|
|
4
|
+
|
|
5
|
+
describe("requiresFileConsent", () => {
|
|
6
|
+
const thresholdBytes = 4 * 1024 * 1024; // 4MB
|
|
7
|
+
|
|
8
|
+
it("returns true for personal chat with non-image", () => {
|
|
9
|
+
expect(
|
|
10
|
+
requiresFileConsent({
|
|
11
|
+
conversationType: "personal",
|
|
12
|
+
contentType: "application/pdf",
|
|
13
|
+
bufferSize: 1000,
|
|
14
|
+
thresholdBytes,
|
|
15
|
+
}),
|
|
16
|
+
).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns true for personal chat with large image", () => {
|
|
20
|
+
expect(
|
|
21
|
+
requiresFileConsent({
|
|
22
|
+
conversationType: "personal",
|
|
23
|
+
contentType: "image/png",
|
|
24
|
+
bufferSize: 5 * 1024 * 1024, // 5MB
|
|
25
|
+
thresholdBytes,
|
|
26
|
+
}),
|
|
27
|
+
).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns false for personal chat with small image", () => {
|
|
31
|
+
expect(
|
|
32
|
+
requiresFileConsent({
|
|
33
|
+
conversationType: "personal",
|
|
34
|
+
contentType: "image/png",
|
|
35
|
+
bufferSize: 1000,
|
|
36
|
+
thresholdBytes,
|
|
37
|
+
}),
|
|
38
|
+
).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns false for group chat with large non-image", () => {
|
|
42
|
+
expect(
|
|
43
|
+
requiresFileConsent({
|
|
44
|
+
conversationType: "groupChat",
|
|
45
|
+
contentType: "application/pdf",
|
|
46
|
+
bufferSize: 5 * 1024 * 1024,
|
|
47
|
+
thresholdBytes,
|
|
48
|
+
}),
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns false for channel with large non-image", () => {
|
|
53
|
+
expect(
|
|
54
|
+
requiresFileConsent({
|
|
55
|
+
conversationType: "channel",
|
|
56
|
+
contentType: "application/pdf",
|
|
57
|
+
bufferSize: 5 * 1024 * 1024,
|
|
58
|
+
thresholdBytes,
|
|
59
|
+
}),
|
|
60
|
+
).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("handles case-insensitive conversation type", () => {
|
|
64
|
+
expect(
|
|
65
|
+
requiresFileConsent({
|
|
66
|
+
conversationType: "Personal",
|
|
67
|
+
contentType: "application/pdf",
|
|
68
|
+
bufferSize: 1000,
|
|
69
|
+
thresholdBytes,
|
|
70
|
+
}),
|
|
71
|
+
).toBe(true);
|
|
72
|
+
|
|
73
|
+
expect(
|
|
74
|
+
requiresFileConsent({
|
|
75
|
+
conversationType: "PERSONAL",
|
|
76
|
+
contentType: "application/pdf",
|
|
77
|
+
bufferSize: 1000,
|
|
78
|
+
thresholdBytes,
|
|
79
|
+
}),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns false when conversationType is undefined", () => {
|
|
84
|
+
expect(
|
|
85
|
+
requiresFileConsent({
|
|
86
|
+
conversationType: undefined,
|
|
87
|
+
contentType: "application/pdf",
|
|
88
|
+
bufferSize: 1000,
|
|
89
|
+
thresholdBytes,
|
|
90
|
+
}),
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns true for personal chat when contentType is undefined (non-image)", () => {
|
|
95
|
+
expect(
|
|
96
|
+
requiresFileConsent({
|
|
97
|
+
conversationType: "personal",
|
|
98
|
+
contentType: undefined,
|
|
99
|
+
bufferSize: 1000,
|
|
100
|
+
thresholdBytes,
|
|
101
|
+
}),
|
|
102
|
+
).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns true for personal chat with file exactly at threshold", () => {
|
|
106
|
+
expect(
|
|
107
|
+
requiresFileConsent({
|
|
108
|
+
conversationType: "personal",
|
|
109
|
+
contentType: "image/jpeg",
|
|
110
|
+
bufferSize: thresholdBytes, // exactly 4MB
|
|
111
|
+
thresholdBytes,
|
|
112
|
+
}),
|
|
113
|
+
).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns false for personal chat with file just below threshold", () => {
|
|
117
|
+
expect(
|
|
118
|
+
requiresFileConsent({
|
|
119
|
+
conversationType: "personal",
|
|
120
|
+
contentType: "image/jpeg",
|
|
121
|
+
bufferSize: thresholdBytes - 1, // 4MB - 1 byte
|
|
122
|
+
thresholdBytes,
|
|
123
|
+
}),
|
|
124
|
+
).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("prepareFileConsentActivity", () => {
|
|
129
|
+
const mockUploadId = "test-upload-id-123";
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
vi.spyOn(pendingUploads, "storePendingUpload").mockReturnValue(mockUploadId);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
vi.restoreAllMocks();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("creates activity with consent card attachment", () => {
|
|
140
|
+
const result = prepareFileConsentActivity({
|
|
141
|
+
media: {
|
|
142
|
+
buffer: Buffer.from("test content"),
|
|
143
|
+
filename: "test.pdf",
|
|
144
|
+
contentType: "application/pdf",
|
|
145
|
+
},
|
|
146
|
+
conversationId: "conv123",
|
|
147
|
+
description: "My file",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(result.uploadId).toBe(mockUploadId);
|
|
151
|
+
expect(result.activity.type).toBe("message");
|
|
152
|
+
expect(result.activity.attachments).toHaveLength(1);
|
|
153
|
+
|
|
154
|
+
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, unknown>;
|
|
155
|
+
expect(attachment.contentType).toBe("application/vnd.microsoft.teams.card.file.consent");
|
|
156
|
+
expect(attachment.name).toBe("test.pdf");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("stores pending upload with correct data", () => {
|
|
160
|
+
const buffer = Buffer.from("test content");
|
|
161
|
+
prepareFileConsentActivity({
|
|
162
|
+
media: {
|
|
163
|
+
buffer,
|
|
164
|
+
filename: "test.pdf",
|
|
165
|
+
contentType: "application/pdf",
|
|
166
|
+
},
|
|
167
|
+
conversationId: "conv123",
|
|
168
|
+
description: "My file",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(pendingUploads.storePendingUpload).toHaveBeenCalledWith({
|
|
172
|
+
buffer,
|
|
173
|
+
filename: "test.pdf",
|
|
174
|
+
contentType: "application/pdf",
|
|
175
|
+
conversationId: "conv123",
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("uses default description when not provided", () => {
|
|
180
|
+
const result = prepareFileConsentActivity({
|
|
181
|
+
media: {
|
|
182
|
+
buffer: Buffer.from("test"),
|
|
183
|
+
filename: "document.docx",
|
|
184
|
+
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
185
|
+
},
|
|
186
|
+
conversationId: "conv456",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
|
|
190
|
+
expect(attachment.content.description).toBe("File: document.docx");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("uses provided description", () => {
|
|
194
|
+
const result = prepareFileConsentActivity({
|
|
195
|
+
media: {
|
|
196
|
+
buffer: Buffer.from("test"),
|
|
197
|
+
filename: "report.pdf",
|
|
198
|
+
contentType: "application/pdf",
|
|
199
|
+
},
|
|
200
|
+
conversationId: "conv789",
|
|
201
|
+
description: "Q4 Financial Report",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
|
|
205
|
+
expect(attachment.content.description).toBe("Q4 Financial Report");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("includes uploadId in consent card context", () => {
|
|
209
|
+
const result = prepareFileConsentActivity({
|
|
210
|
+
media: {
|
|
211
|
+
buffer: Buffer.from("test"),
|
|
212
|
+
filename: "file.txt",
|
|
213
|
+
contentType: "text/plain",
|
|
214
|
+
},
|
|
215
|
+
conversationId: "conv000",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { acceptContext: { uploadId: string } }>;
|
|
219
|
+
expect(attachment.content.acceptContext.uploadId).toBe(mockUploadId);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("handles media without contentType", () => {
|
|
223
|
+
const result = prepareFileConsentActivity({
|
|
224
|
+
media: {
|
|
225
|
+
buffer: Buffer.from("binary data"),
|
|
226
|
+
filename: "unknown.bin",
|
|
227
|
+
},
|
|
228
|
+
conversationId: "conv111",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(result.uploadId).toBe(mockUploadId);
|
|
232
|
+
expect(result.activity.type).toBe("message");
|
|
233
|
+
});
|
|
234
|
+
});
|