@cogitator-ai/channels 0.17.0 → 0.18.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.
- package/README.md +37 -14
- package/dist/approvals.d.ts +3 -0
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +1 -0
- package/dist/approvals.js.map +1 -1
- package/dist/assistant-config.d.ts +20 -0
- package/dist/assistant-config.d.ts.map +1 -1
- package/dist/assistant-config.js +36 -0
- package/dist/assistant-config.js.map +1 -1
- package/dist/channels/teams/activity.d.ts +108 -0
- package/dist/channels/teams/activity.d.ts.map +1 -0
- package/dist/channels/teams/activity.js +206 -0
- package/dist/channels/teams/activity.js.map +1 -0
- package/dist/channels/teams/auth.d.ts +65 -0
- package/dist/channels/teams/auth.d.ts.map +1 -0
- package/dist/channels/teams/auth.js +213 -0
- package/dist/channels/teams/auth.js.map +1 -0
- package/dist/channels/teams/cards.d.ts +23 -0
- package/dist/channels/teams/cards.d.ts.map +1 -0
- package/dist/channels/teams/cards.js +97 -0
- package/dist/channels/teams/cards.js.map +1 -0
- package/dist/channels/teams/channel.d.ts +303 -0
- package/dist/channels/teams/channel.d.ts.map +1 -0
- package/dist/channels/teams/channel.js +1071 -0
- package/dist/channels/teams/channel.js.map +1 -0
- package/dist/channels/teams/client.d.ts +72 -0
- package/dist/channels/teams/client.d.ts.map +1 -0
- package/dist/channels/teams/client.js +161 -0
- package/dist/channels/teams/client.js.map +1 -0
- package/dist/channels/teams/index.d.ts +7 -0
- package/dist/channels/teams/index.d.ts.map +1 -0
- package/dist/channels/teams/index.js +6 -0
- package/dist/channels/teams/index.js.map +1 -0
- package/dist/channels/teams/pacer.d.ts +47 -0
- package/dist/channels/teams/pacer.d.ts.map +1 -0
- package/dist/channels/teams/pacer.js +174 -0
- package/dist/channels/teams/pacer.js.map +1 -0
- package/dist/channels/teams/token.d.ts +47 -0
- package/dist/channels/teams/token.d.ts.map +1 -0
- package/dist/channels/teams/token.js +107 -0
- package/dist/channels/teams/token.js.map +1 -0
- package/dist/channels/telegram.d.ts.map +1 -1
- package/dist/channels/telegram.js +3 -0
- package/dist/channels/telegram.js.map +1 -1
- package/dist/formatters/markdown-images.d.ts +8 -0
- package/dist/formatters/markdown-images.d.ts.map +1 -0
- package/dist/formatters/markdown-images.js +181 -0
- package/dist/formatters/markdown-images.js.map +1 -0
- package/dist/formatters/markdown.d.ts.map +1 -1
- package/dist/formatters/markdown.js +21 -3
- package/dist/formatters/markdown.js.map +1 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +9 -2
- package/dist/gateway.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime-builder.d.ts.map +1 -1
- package/dist/runtime-builder.js +35 -1
- package/dist/runtime-builder.js.map +1 -1
- package/package.json +7 -7
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { chunkMessage } from '../../formatters/markdown.js';
|
|
5
|
+
import { imagesAsLinks } from '../../formatters/markdown-images.js';
|
|
6
|
+
import { attachmentTypeOf, attachmentsOf, conversationTypeOf, downloadInfoOf, fileConsentOf, joinConversation, mentionsBot, mimeTypeOf, parseActivity, splitConversation, teamOf, tenantOf, tenantsDisagree, textWithoutMention, } from './activity.js';
|
|
7
|
+
import { TeamsAuthError, TeamsTokenVerifier } from './auth.js';
|
|
8
|
+
import { ADAPTIVE_CARD, FILE_CONSENT_CARD, FILE_INFO_CARD, buttonsCard, executeDataOf, submitDataOf, } from './cards.js';
|
|
9
|
+
import { TEAMS_REQUEST_TIMEOUT, TeamsApiError, TeamsClient, } from './client.js';
|
|
10
|
+
import { ConversationPacer, pause } from './pacer.js';
|
|
11
|
+
import { BOT_FRAMEWORK_TENANT, TeamsCredentials } from './token.js';
|
|
12
|
+
/** The largest message Teams is advised to take, in bytes of the activity JSON. */
|
|
13
|
+
export const TEAMS_MAX_MESSAGE_BYTES = 80 * 1024;
|
|
14
|
+
/**
|
|
15
|
+
* The longest text sent as one message, in characters: JSON takes at most six bytes for one
|
|
16
|
+
* character (`\u0001`), so this many stay under {@link TEAMS_MAX_MESSAGE_BYTES} whatever they are.
|
|
17
|
+
*/
|
|
18
|
+
export const TEAMS_MESSAGE_CHARS = Math.floor(TEAMS_MAX_MESSAGE_BYTES / 6);
|
|
19
|
+
/** The port of the channel's own server by default, the port Bot Framework samples use. */
|
|
20
|
+
export const TEAMS_DEFAULT_PORT = 3978;
|
|
21
|
+
/** The path of the channel's own server by default, the messaging endpoint of Azure Bot Service. */
|
|
22
|
+
export const TEAMS_DEFAULT_PATH = '/api/messages';
|
|
23
|
+
/** The Bot Connector of the commercial cloud, for conversations no activity came from yet. */
|
|
24
|
+
export const TEAMS_GLOBAL_SERVICE_URL = 'https://smba.trafficmanager.net/teams/';
|
|
25
|
+
/** The largest webhook body the channel's own server takes by default, in bytes. */
|
|
26
|
+
export const TEAMS_DEFAULT_MAX_BODY = 1024 * 1024;
|
|
27
|
+
/**
|
|
28
|
+
* The shortest time between two updates of one message, in ms, so a streamed answer takes at
|
|
29
|
+
* most one of the 60 requests a conversation may have in 30 seconds every second, and new
|
|
30
|
+
* messages always find room.
|
|
31
|
+
*/
|
|
32
|
+
export const TEAMS_MIN_EDIT_INTERVAL = 1000;
|
|
33
|
+
const MAX_TRACKED = 1000;
|
|
34
|
+
const MAX_CONVERSATIONS = 10_000;
|
|
35
|
+
const MAX_PENDING_UPLOADS = 100;
|
|
36
|
+
/** How long a file waits for its consent card to be answered, in ms. */
|
|
37
|
+
const UPLOAD_TTL = 60 * 60 * 1000;
|
|
38
|
+
/** The size of one upload request, a multiple of 320 KiB as OneDrive upload sessions want. */
|
|
39
|
+
const UPLOAD_FRAGMENT = 32 * 320 * 1024;
|
|
40
|
+
/** The largest file a user sends that is handed to the agent, in bytes. */
|
|
41
|
+
const MAX_INBOUND_FILE = 20 * 1024 * 1024;
|
|
42
|
+
/**
|
|
43
|
+
* How long a webhook request waits for the gateway to take its message, in ms. A gateway that
|
|
44
|
+
* queues takes it at once, one that answers before it returns is answered `202` after this long.
|
|
45
|
+
*/
|
|
46
|
+
const HANDOVER_TIMEOUT = 3000;
|
|
47
|
+
/** How long an `Action.Execute` press waits for its answer, under the time Teams waits, in ms. */
|
|
48
|
+
const INVOKE_TIMEOUT = 4000;
|
|
49
|
+
/** What a button press is answered with when nothing else is said and its card is not known. */
|
|
50
|
+
const ACTION_RECEIVED = 'Done';
|
|
51
|
+
/** How long `stop()` waits for requests under way before it closes their connections, in ms. */
|
|
52
|
+
const STOP_GRACE = 3000;
|
|
53
|
+
/** Answers to a file consent card the bot can no longer act on, or that was declined. */
|
|
54
|
+
const UPLOAD_GONE = 'That file is no longer available, ask for it again.';
|
|
55
|
+
const UPLOAD_DECLINED = 'The file was not sent.';
|
|
56
|
+
const ACTIVITY_MESSAGE = 'application/vnd.microsoft.activity.message';
|
|
57
|
+
const ResourceSchema = z.object({ id: z.string().optional() });
|
|
58
|
+
function textResponse(status, body) {
|
|
59
|
+
return { status, body, contentType: 'text/plain' };
|
|
60
|
+
}
|
|
61
|
+
function jsonResponse(status, value) {
|
|
62
|
+
return { status, body: JSON.stringify(value), contentType: 'application/json' };
|
|
63
|
+
}
|
|
64
|
+
function header(headers, name) {
|
|
65
|
+
const wanted = name.toLowerCase();
|
|
66
|
+
const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === wanted);
|
|
67
|
+
const value = key === undefined ? undefined : headers[key];
|
|
68
|
+
return Array.isArray(value) ? value[0] : value;
|
|
69
|
+
}
|
|
70
|
+
function bodyText(raw) {
|
|
71
|
+
return typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf-8');
|
|
72
|
+
}
|
|
73
|
+
function textFormatOf(format) {
|
|
74
|
+
if (format === 'plain')
|
|
75
|
+
return 'plain';
|
|
76
|
+
if (format === 'html')
|
|
77
|
+
return 'xml';
|
|
78
|
+
return 'extendedmarkdown';
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Markdown with its images turned into links (`` becomes `[alt](url)`) and raw
|
|
82
|
+
* `<img>` tags into text, outside code: Teams loads an image as soon as it shows the message,
|
|
83
|
+
* so an answer steered by injected text could send what it read to any address in an image URL.
|
|
84
|
+
*/
|
|
85
|
+
export function withoutImages(text) {
|
|
86
|
+
return imagesAsLinks(text);
|
|
87
|
+
}
|
|
88
|
+
function bodyFor(text, textFormat) {
|
|
89
|
+
return textFormat === 'extendedmarkdown' ? withoutImages(text) : text;
|
|
90
|
+
}
|
|
91
|
+
/** Resolves with `fallback` when `promise` takes longer than `ms`. */
|
|
92
|
+
function within(promise, ms, fallback) {
|
|
93
|
+
let timer;
|
|
94
|
+
const timeout = new Promise((resolve) => {
|
|
95
|
+
timer = setTimeout(() => resolve(fallback), ms);
|
|
96
|
+
});
|
|
97
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
98
|
+
}
|
|
99
|
+
function forgetOldest(collection, max = MAX_TRACKED) {
|
|
100
|
+
while (collection.size > max) {
|
|
101
|
+
const oldest = collection.keys().next().value;
|
|
102
|
+
if (oldest === undefined)
|
|
103
|
+
return;
|
|
104
|
+
collection.delete(oldest);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function remember(map, key, value, max = MAX_TRACKED) {
|
|
108
|
+
map.delete(key);
|
|
109
|
+
map.set(key, value);
|
|
110
|
+
forgetOldest(map, max);
|
|
111
|
+
}
|
|
112
|
+
/** The conversation whose limits a request counts against: a channel with all its threads. */
|
|
113
|
+
function paceKeyOf(conversationId) {
|
|
114
|
+
return splitConversation(conversationId).channelId;
|
|
115
|
+
}
|
|
116
|
+
function activitiesPath(conversationId, activityId) {
|
|
117
|
+
const base = `/v3/conversations/${encodeURIComponent(conversationId)}/activities`;
|
|
118
|
+
return activityId === undefined ? base : `${base}/${encodeURIComponent(activityId)}`;
|
|
119
|
+
}
|
|
120
|
+
/** Splits text into the messages Teams takes: at most {@link TEAMS_MESSAGE_CHARS} characters each. */
|
|
121
|
+
export function splitTeamsText(text) {
|
|
122
|
+
return chunkMessage(text, TEAMS_MESSAGE_CHARS).filter((part) => part.trim());
|
|
123
|
+
}
|
|
124
|
+
class BodyTooLarge extends Error {
|
|
125
|
+
}
|
|
126
|
+
function readBody(req, limit) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const chunks = [];
|
|
129
|
+
let size = 0;
|
|
130
|
+
req.on('data', (chunk) => {
|
|
131
|
+
size += chunk.length;
|
|
132
|
+
if (size > limit) {
|
|
133
|
+
reject(new BodyTooLarge('Webhook body too large'));
|
|
134
|
+
req.pause();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
chunks.push(chunk);
|
|
138
|
+
});
|
|
139
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
140
|
+
req.on('error', reject);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* A Microsoft Teams channel on the Bot Framework protocol, with no SDK: Bot Framework posts
|
|
145
|
+
* activities to a webhook, each checked against its signed JWT, and answers go back through the
|
|
146
|
+
* Bot Connector REST API with a client credentials token of the app registration.
|
|
147
|
+
*
|
|
148
|
+
* Each chat is a conversation: `channelId` is the Teams conversation id of a personal or group
|
|
149
|
+
* chat, or of a team channel, whose threads are topics (`topicId` is the thread's root message
|
|
150
|
+
* id), so each channel thread is a conversation of its own. In group chats and channels the bot
|
|
151
|
+
* answers @mentions, without the mention in the text. Buttons are Adaptive Cards, answers are
|
|
152
|
+
* Markdown and can be edited, so they stream.
|
|
153
|
+
*/
|
|
154
|
+
export class TeamsChannel {
|
|
155
|
+
type = 'teams';
|
|
156
|
+
editable = true;
|
|
157
|
+
nativeMarkdown = true;
|
|
158
|
+
maxMessageChars = TEAMS_MESSAGE_CHARS;
|
|
159
|
+
config;
|
|
160
|
+
tenantId;
|
|
161
|
+
verifier;
|
|
162
|
+
credentials;
|
|
163
|
+
client;
|
|
164
|
+
fetchImpl;
|
|
165
|
+
handler = null;
|
|
166
|
+
actionHandler = null;
|
|
167
|
+
/** Activities taken, by `conversation|id`, so one sent again is handled once */
|
|
168
|
+
delivered = new Set();
|
|
169
|
+
/** Messages handed to the gateway whose handler has not resolved yet, by delivery key */
|
|
170
|
+
inFlight = new Set();
|
|
171
|
+
/** Messages of `inFlight` the gateway handed back while they were being taken */
|
|
172
|
+
refused = new Set();
|
|
173
|
+
/** The delivery key of each message handed over, by message id */
|
|
174
|
+
deliveryKeys = new Map();
|
|
175
|
+
conversations = new Map();
|
|
176
|
+
sent = new Map();
|
|
177
|
+
updates = new Map();
|
|
178
|
+
pacer = new ConversationPacer();
|
|
179
|
+
/** Conversations whose messages Teams blocks (`MessageWritesBlocked`), by pacing key */
|
|
180
|
+
blocked = new Set();
|
|
181
|
+
/** Messages whose webhook request was answered before the gateway took them, by delivery key */
|
|
182
|
+
answeredEarly = new Set();
|
|
183
|
+
activeRequests = new Set();
|
|
184
|
+
/** Waits that end at once when the channel stops */
|
|
185
|
+
onHalt = new Set();
|
|
186
|
+
/** Aborts every request and wait under way when the channel stops */
|
|
187
|
+
halt = new AbortController();
|
|
188
|
+
answers = new Map();
|
|
189
|
+
/** The conversation of each button press, to answer it with a message once its request is over */
|
|
190
|
+
actionConversations = new Map();
|
|
191
|
+
uploads = new Map();
|
|
192
|
+
server;
|
|
193
|
+
running = false;
|
|
194
|
+
stopped = false;
|
|
195
|
+
constructor(config) {
|
|
196
|
+
if (!config.appId)
|
|
197
|
+
throw new Error('[teams] appId is required: the Microsoft App ID of the bot');
|
|
198
|
+
const appType = config.appType ?? 'SingleTenant';
|
|
199
|
+
if (appType !== 'MultiTenant' && !config.tenantId) {
|
|
200
|
+
throw new Error(`[teams] tenantId is required for a ${appType} bot: the tenant of its app registration`);
|
|
201
|
+
}
|
|
202
|
+
if (appType === 'UserAssignedMSI' && !config.tokenProvider) {
|
|
203
|
+
throw new Error('[teams] tokenProvider is required for a UserAssignedMSI bot: a function that returns a token of the managed identity');
|
|
204
|
+
}
|
|
205
|
+
if (!config.appPassword && !config.tokenProvider) {
|
|
206
|
+
throw new Error('[teams] appPassword (a client secret) or tokenProvider is required');
|
|
207
|
+
}
|
|
208
|
+
this.config = config;
|
|
209
|
+
if (config.tenantId)
|
|
210
|
+
this.tenantId = config.tenantId;
|
|
211
|
+
this.fetchImpl = config.fetch ?? fetch;
|
|
212
|
+
this.verifier = new TeamsTokenVerifier({
|
|
213
|
+
appId: config.appId,
|
|
214
|
+
...(config.openIdMetadataUrl && { metadataUrl: config.openIdMetadataUrl }),
|
|
215
|
+
...(config.fetch && { fetch: config.fetch }),
|
|
216
|
+
});
|
|
217
|
+
this.credentials = new TeamsCredentials({
|
|
218
|
+
appId: config.appId,
|
|
219
|
+
...(config.appPassword && { appPassword: config.appPassword }),
|
|
220
|
+
tenant: appType === 'MultiTenant' ? BOT_FRAMEWORK_TENANT : (config.tenantId ?? ''),
|
|
221
|
+
...(config.tokenProvider && { provider: config.tokenProvider }),
|
|
222
|
+
...(config.loginUrl && { loginUrl: config.loginUrl }),
|
|
223
|
+
...(config.fetch && { fetch: config.fetch }),
|
|
224
|
+
});
|
|
225
|
+
this.client = new TeamsClient({
|
|
226
|
+
credentials: this.credentials,
|
|
227
|
+
pacer: this.pacer,
|
|
228
|
+
...(config.retry && { retry: config.retry }),
|
|
229
|
+
...(config.requestTimeout !== undefined && { timeout: config.requestTimeout }),
|
|
230
|
+
...(config.fetch && { fetch: config.fetch }),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
onMessage(handler) {
|
|
234
|
+
this.handler = handler;
|
|
235
|
+
}
|
|
236
|
+
onAction(handler) {
|
|
237
|
+
this.actionHandler = handler;
|
|
238
|
+
}
|
|
239
|
+
async start() {
|
|
240
|
+
if (this.running)
|
|
241
|
+
return;
|
|
242
|
+
if (this.config.tenants === '*') {
|
|
243
|
+
console.warn('[teams] tenants is "*": the bot answers users of every tenant its app is installed in');
|
|
244
|
+
}
|
|
245
|
+
else if (this.allowedTenants().length === 0) {
|
|
246
|
+
console.warn('[teams] No tenants set: the bot answers nobody. Set tenants or tenantId');
|
|
247
|
+
}
|
|
248
|
+
this.running = true;
|
|
249
|
+
this.stopped = false;
|
|
250
|
+
if (this.halt.signal.aborted)
|
|
251
|
+
this.halt = new AbortController();
|
|
252
|
+
const webhook = this.config.webhook;
|
|
253
|
+
if (webhook?.port !== undefined) {
|
|
254
|
+
try {
|
|
255
|
+
await this.listen(webhook.port, webhook.path ?? TEAMS_DEFAULT_PATH, webhook.host);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
this.running = false;
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async stop() {
|
|
264
|
+
this.running = false;
|
|
265
|
+
this.stopped = true;
|
|
266
|
+
for (const answer of this.answers.values())
|
|
267
|
+
answer.resolve({});
|
|
268
|
+
this.answers.clear();
|
|
269
|
+
for (const halt of this.onHalt)
|
|
270
|
+
halt();
|
|
271
|
+
this.onHalt.clear();
|
|
272
|
+
this.halt.abort(new Error('[teams] The channel stopped'));
|
|
273
|
+
const server = this.server;
|
|
274
|
+
this.server = undefined;
|
|
275
|
+
if (!server)
|
|
276
|
+
return;
|
|
277
|
+
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
278
|
+
server.closeIdleConnections();
|
|
279
|
+
let timer;
|
|
280
|
+
await Promise.race([
|
|
281
|
+
Promise.allSettled([...this.activeRequests]),
|
|
282
|
+
new Promise((resolve) => {
|
|
283
|
+
timer = setTimeout(resolve, STOP_GRACE);
|
|
284
|
+
}),
|
|
285
|
+
]);
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
server.closeAllConnections();
|
|
288
|
+
await closed;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Takes back a message the gateway does not answer because it stops. One it refuses while it
|
|
292
|
+
* is being handed over gets its webhook request answered `503`. One it dropped after its
|
|
293
|
+
* request was answered cannot come again, since Bot Framework does not send an activity twice
|
|
294
|
+
* once it was taken, so it is logged.
|
|
295
|
+
*/
|
|
296
|
+
async redeliver(msg) {
|
|
297
|
+
const key = this.deliveryKeys.get(msg.id);
|
|
298
|
+
if (key && this.inFlight.has(key) && !this.answeredEarly.has(key)) {
|
|
299
|
+
this.refused.add(key);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (key)
|
|
303
|
+
this.delivered.delete(key);
|
|
304
|
+
console.warn(`[teams] The message ${msg.id} in ${msg.channelId} was dropped unanswered after its request was answered, so Bot Framework will not send it again`);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Answers a request Bot Framework posts: checks its token, answers at once and hands the
|
|
308
|
+
* activity to the agent after. A button press of an Adaptive Card (`Action.Execute`) is
|
|
309
|
+
* answered with the press's answer in the response, within a few seconds. Mount it in any HTTP
|
|
310
|
+
* server with the raw body. It works before `start()` too, answers a message `503` while no
|
|
311
|
+
* `onMessage` handler is set, so Bot Framework sends it again, and answers `503` once stopped.
|
|
312
|
+
*/
|
|
313
|
+
async handleWebhook(request) {
|
|
314
|
+
if (this.stopped)
|
|
315
|
+
return textResponse(503, 'The channel is stopped');
|
|
316
|
+
if (request.method !== 'POST')
|
|
317
|
+
return textResponse(405, 'Method not allowed');
|
|
318
|
+
const authorization = header(request.headers, 'authorization') ?? '';
|
|
319
|
+
if (authorization.slice(0, 7).toLowerCase() !== 'bearer ') {
|
|
320
|
+
return textResponse(401, 'Unauthorized');
|
|
321
|
+
}
|
|
322
|
+
let verified;
|
|
323
|
+
try {
|
|
324
|
+
verified = await this.verifier.verify(authorization.slice(7).trim());
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (!(error instanceof TeamsAuthError))
|
|
328
|
+
throw error;
|
|
329
|
+
if (error.status === 503)
|
|
330
|
+
console.error(error.message, error.cause);
|
|
331
|
+
return textResponse(error.status, error.status === 503 ? 'Unavailable' : 'Unauthorized');
|
|
332
|
+
}
|
|
333
|
+
let activity;
|
|
334
|
+
try {
|
|
335
|
+
activity = parseActivity(JSON.parse(bodyText(request.rawBody ?? '')));
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return textResponse(400, 'Invalid activity');
|
|
339
|
+
}
|
|
340
|
+
if (verified.serviceUrl !== activity.serviceUrl) {
|
|
341
|
+
return textResponse(401, 'Unauthorized');
|
|
342
|
+
}
|
|
343
|
+
if (!verified.endorsements.includes(activity.channelId)) {
|
|
344
|
+
return textResponse(403, 'Forbidden');
|
|
345
|
+
}
|
|
346
|
+
if (!this.allowsServiceUrl(activity.serviceUrl))
|
|
347
|
+
return textResponse(403, 'Forbidden');
|
|
348
|
+
if (activity.channelId !== 'msteams')
|
|
349
|
+
return textResponse(202, 'Ignored');
|
|
350
|
+
this.rememberConversation(activity);
|
|
351
|
+
if (activity.from.id === activity.recipient?.id) {
|
|
352
|
+
return textResponse(202, 'Ignored');
|
|
353
|
+
}
|
|
354
|
+
const pressed = activity.type === 'message' ? submitDataOf(activity.value) : undefined;
|
|
355
|
+
if (activity.type === 'message' && pressed === undefined && !this.handler) {
|
|
356
|
+
return textResponse(503, 'Not ready, send it again later');
|
|
357
|
+
}
|
|
358
|
+
const key = activity.id ? `${activity.conversation.id}|${activity.id}` : undefined;
|
|
359
|
+
if (key !== undefined) {
|
|
360
|
+
if (this.delivered.has(key)) {
|
|
361
|
+
return activity.type === 'invoke'
|
|
362
|
+
? jsonResponse(200, { statusCode: 200, type: ACTIVITY_MESSAGE, value: ACTION_RECEIVED })
|
|
363
|
+
: textResponse(200, 'Already received');
|
|
364
|
+
}
|
|
365
|
+
this.delivered.add(key);
|
|
366
|
+
forgetOldest(this.delivered);
|
|
367
|
+
}
|
|
368
|
+
if (activity.type === 'invoke')
|
|
369
|
+
return this.invoke(activity);
|
|
370
|
+
if (activity.type !== 'message')
|
|
371
|
+
return textResponse(202, 'Accepted');
|
|
372
|
+
if (pressed !== undefined) {
|
|
373
|
+
if (this.admits(activity)) {
|
|
374
|
+
void this.press(activity, pressed).catch((error) => console.error('[teams] Button press failed:', error));
|
|
375
|
+
}
|
|
376
|
+
return textResponse(202, 'Accepted');
|
|
377
|
+
}
|
|
378
|
+
return this.handOver(activity, key);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* A Node request listener that answers Bot Framework's requests on any path it is mounted at.
|
|
382
|
+
* Bodies over `webhook.maxBody` (1 MB by default) are refused with `413`.
|
|
383
|
+
*/
|
|
384
|
+
webhookListener() {
|
|
385
|
+
const limit = this.config.webhook?.maxBody ?? TEAMS_DEFAULT_MAX_BODY;
|
|
386
|
+
return (req, res) => {
|
|
387
|
+
readBody(req, limit)
|
|
388
|
+
.then((rawBody) => this.handleWebhook({ method: req.method ?? 'GET', headers: req.headers, rawBody }))
|
|
389
|
+
.then((response) => res
|
|
390
|
+
.writeHead(response.status, { 'content-type': response.contentType })
|
|
391
|
+
.end(response.body))
|
|
392
|
+
.catch((error) => {
|
|
393
|
+
if (error instanceof BodyTooLarge) {
|
|
394
|
+
res.writeHead(413, { 'content-type': 'text/plain', connection: 'close' });
|
|
395
|
+
res.end('Payload too large');
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
console.error('[teams] Webhook request failed:', error);
|
|
399
|
+
if (!res.headersSent)
|
|
400
|
+
res.writeHead(500);
|
|
401
|
+
res.end();
|
|
402
|
+
});
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Sends `text` to the conversation `channelId`, in the channel thread of `topicId`, as several
|
|
407
|
+
* messages when it is longer than one holds. With `replyTo` the first one replies to that
|
|
408
|
+
* message, and `buttons` go under the last as an Adaptive Card. Resolves with the id of the
|
|
409
|
+
* first message.
|
|
410
|
+
*/
|
|
411
|
+
async sendText(channelId, text, options) {
|
|
412
|
+
const conversationId = joinConversation(channelId, options?.topicId);
|
|
413
|
+
const parts = splitTeamsText(text);
|
|
414
|
+
if (parts.length === 0)
|
|
415
|
+
throw new Error('[teams] Nothing to send');
|
|
416
|
+
const textFormat = textFormatOf(options?.format);
|
|
417
|
+
const card = options?.buttons?.length ? buttonsCard(options.buttons) : undefined;
|
|
418
|
+
const ids = [];
|
|
419
|
+
for (const [index, part] of parts.entries()) {
|
|
420
|
+
const last = index === parts.length - 1;
|
|
421
|
+
ids.push(await this.post(conversationId, { text: part, textFormat, ...(last && card && { card }) }, index === 0 ? options?.replyTo : undefined));
|
|
422
|
+
}
|
|
423
|
+
return ids[0] ?? '';
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Replaces the text of a message the bot sent, keeping its buttons as they are now, also when
|
|
427
|
+
* a change of them is still on its way, unless `buttons` are given. Updates of one message go
|
|
428
|
+
* out one at a time, at least {@link TEAMS_MIN_EDIT_INTERVAL} apart and behind new messages
|
|
429
|
+
* when the conversation nears its limits, and those made meanwhile are folded into the next,
|
|
430
|
+
* the latest text winning, so a streamed answer never queues more than Teams takes.
|
|
431
|
+
*/
|
|
432
|
+
async editText(channelId, messageId, text, options) {
|
|
433
|
+
if (text.length > TEAMS_MESSAGE_CHARS) {
|
|
434
|
+
throw new Error(`[teams] A message holds at most ${TEAMS_MESSAGE_CHARS} characters`);
|
|
435
|
+
}
|
|
436
|
+
const known = this.updates.get(messageId)?.next ?? this.sent.get(messageId);
|
|
437
|
+
const textFormat = textFormatOf(options?.format);
|
|
438
|
+
const card = options?.buttons?.length ? buttonsCard(options.buttons) : undefined;
|
|
439
|
+
return this.update(known?.conversationId ?? joinConversation(channelId, options?.topicId), messageId, (current) => ({ ...current, text, textFormat, ...(card && { card }) }));
|
|
440
|
+
}
|
|
441
|
+
/** Replaces the buttons of a message the bot sent, or removes them with `null`. */
|
|
442
|
+
async editButtons(channelId, messageId, buttons) {
|
|
443
|
+
const known = this.sent.get(messageId) ?? this.updates.get(messageId)?.next;
|
|
444
|
+
if (!known) {
|
|
445
|
+
throw new Error(`[teams] The text of message ${messageId} in ${channelId} is not known, so its buttons cannot be changed`);
|
|
446
|
+
}
|
|
447
|
+
const card = buttons?.length ? buttonsCard(buttons) : undefined;
|
|
448
|
+
return this.update(known.conversationId, messageId, (current) => {
|
|
449
|
+
const kept = {
|
|
450
|
+
conversationId: current.conversationId,
|
|
451
|
+
text: current.text,
|
|
452
|
+
textFormat: current.textFormat,
|
|
453
|
+
};
|
|
454
|
+
return card ? { ...kept, card } : kept;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/** Deletes a message the bot sent. */
|
|
458
|
+
async deleteMessage(channelId, messageId) {
|
|
459
|
+
const conversationId = this.sent.get(messageId)?.conversationId ?? channelId;
|
|
460
|
+
await this.call('DELETE', conversationId, activitiesPath(conversationId, messageId));
|
|
461
|
+
this.sent.delete(messageId);
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Shows the typing indicator. It is skipped when the conversation has no room for it now, or
|
|
465
|
+
* Teams blocks the bot's messages there, since a late indicator says nothing.
|
|
466
|
+
*/
|
|
467
|
+
async sendTyping(channelId, options) {
|
|
468
|
+
const conversationId = joinConversation(channelId, options?.topicId);
|
|
469
|
+
const key = paceKeyOf(conversationId);
|
|
470
|
+
if (this.stopped || this.blocked.has(key) || !this.pacer.tryAcquire(key))
|
|
471
|
+
return;
|
|
472
|
+
await this.call('POST', conversationId, activitiesPath(conversationId), { type: 'typing' }, { priority: 'background', reserved: true, retries: 0 });
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Sends a file. A `buffer` goes out in personal chats only: Teams asks the user to accept it
|
|
476
|
+
* with a file consent card and the file is uploaded to their OneDrive once they do, which needs
|
|
477
|
+
* `supportsFiles` in the app manifest. Bots cannot upload files to group chats and channels
|
|
478
|
+
* without Microsoft Graph, so there a file needs a public `url`: an image is shown inline,
|
|
479
|
+
* another file is sent as a link. Resolves with the id of the message sent.
|
|
480
|
+
*/
|
|
481
|
+
async sendFile(channelId, file, options) {
|
|
482
|
+
const conversationId = joinConversation(channelId, options?.topicId);
|
|
483
|
+
const filename = file.filename ?? (file.type === 'image' ? 'image' : 'file');
|
|
484
|
+
const textFormat = textFormatOf(options?.format);
|
|
485
|
+
if (file.buffer) {
|
|
486
|
+
if (file.buffer.length === 0)
|
|
487
|
+
throw new Error('[teams] The file is empty');
|
|
488
|
+
if (this.typeOf(conversationId) !== 'personal') {
|
|
489
|
+
throw new Error('[teams] Teams takes uploads from bots in personal chats only: in group chats and channels send a file with a public url');
|
|
490
|
+
}
|
|
491
|
+
const uploadId = randomUUID();
|
|
492
|
+
const buffer = Buffer.from(file.buffer);
|
|
493
|
+
this.expireUploads();
|
|
494
|
+
remember(this.uploads, uploadId, {
|
|
495
|
+
conversationId,
|
|
496
|
+
buffer,
|
|
497
|
+
filename,
|
|
498
|
+
...(file.caption && { caption: file.caption }),
|
|
499
|
+
textFormat,
|
|
500
|
+
createdAt: Date.now(),
|
|
501
|
+
}, MAX_PENDING_UPLOADS);
|
|
502
|
+
return this.post(conversationId, {
|
|
503
|
+
text: '',
|
|
504
|
+
textFormat,
|
|
505
|
+
attachments: [
|
|
506
|
+
{
|
|
507
|
+
contentType: FILE_CONSENT_CARD,
|
|
508
|
+
name: filename,
|
|
509
|
+
content: {
|
|
510
|
+
description: file.caption ?? filename,
|
|
511
|
+
sizeInBytes: buffer.length,
|
|
512
|
+
acceptContext: { uploadId },
|
|
513
|
+
declineContext: { uploadId },
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
],
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
if (!file.url || !/^https?:\/\//i.test(file.url)) {
|
|
520
|
+
throw new Error('[teams] A file needs a buffer (personal chats) or a public http(s) url');
|
|
521
|
+
}
|
|
522
|
+
if (file.type === 'image') {
|
|
523
|
+
return this.post(conversationId, {
|
|
524
|
+
text: file.caption ?? '',
|
|
525
|
+
textFormat,
|
|
526
|
+
attachments: [{ contentType: file.mimeType, contentUrl: file.url, name: filename }],
|
|
527
|
+
}, options?.replyTo);
|
|
528
|
+
}
|
|
529
|
+
const link = `[${filename.replace(/[[\]]/g, '')}](<${file.url}>)`;
|
|
530
|
+
return this.sendText(channelId, file.caption ? `${file.caption}\n\n${link}` : link, options);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Answers a button press. An `Action.Execute` press still waiting for its answer gets it in
|
|
534
|
+
* the response, shown to the user who pressed. Otherwise a `text` is sent to the conversation
|
|
535
|
+
* as a message, since Teams has no other way to answer a press. An `alert`, meant for the one
|
|
536
|
+
* who pressed only (such as "this prompt expired"), is not posted where everyone reads it.
|
|
537
|
+
*/
|
|
538
|
+
async answerAction(actionId, options) {
|
|
539
|
+
const pending = this.answers.get(actionId);
|
|
540
|
+
if (pending) {
|
|
541
|
+
this.answers.delete(actionId);
|
|
542
|
+
pending.resolve(options ?? {});
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const conversationId = this.actionConversations.get(actionId);
|
|
546
|
+
this.actionConversations.delete(actionId);
|
|
547
|
+
if (!options?.text || options.alert || !conversationId)
|
|
548
|
+
return;
|
|
549
|
+
await this.post(conversationId, { text: options.text, textFormat: 'plain' });
|
|
550
|
+
}
|
|
551
|
+
allowedTenants() {
|
|
552
|
+
if (this.config.tenants === '*')
|
|
553
|
+
return [];
|
|
554
|
+
return this.config.tenants ?? (this.tenantId ? [this.tenantId] : []);
|
|
555
|
+
}
|
|
556
|
+
/** Whether the sender of an activity may talk to the bot: by their tenant and the user list. */
|
|
557
|
+
admits(activity) {
|
|
558
|
+
if (tenantsDisagree(activity))
|
|
559
|
+
return false;
|
|
560
|
+
if (this.config.tenants !== '*') {
|
|
561
|
+
const tenant = tenantOf(activity)?.toLowerCase();
|
|
562
|
+
if (!tenant || !this.allowedTenants().some((allowed) => allowed.toLowerCase() === tenant)) {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const users = this.config.users;
|
|
567
|
+
if (users?.length) {
|
|
568
|
+
const { aadObjectId, id } = activity.from;
|
|
569
|
+
return users.some((user) => user === aadObjectId || user === id);
|
|
570
|
+
}
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
allowsServiceUrl(serviceUrl) {
|
|
574
|
+
let url;
|
|
575
|
+
try {
|
|
576
|
+
url = new URL(serviceUrl);
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
const plainAllowed = this.config.openIdMetadataUrl !== undefined;
|
|
582
|
+
if (url.protocol !== 'https:' && !(plainAllowed && url.protocol === 'http:'))
|
|
583
|
+
return false;
|
|
584
|
+
const hosts = this.config.serviceUrlHosts;
|
|
585
|
+
if (!hosts?.length)
|
|
586
|
+
return true;
|
|
587
|
+
const host = url.hostname.toLowerCase();
|
|
588
|
+
return hosts.some((allowed) => allowed.toLowerCase() === host);
|
|
589
|
+
}
|
|
590
|
+
/** Remembers the Bot Connector and kind of the conversation of a verified activity. */
|
|
591
|
+
rememberConversation(activity) {
|
|
592
|
+
const { id, conversationType } = activity.conversation;
|
|
593
|
+
const entry = {
|
|
594
|
+
serviceUrl: activity.serviceUrl,
|
|
595
|
+
type: conversationTypeOf(id, conversationType),
|
|
596
|
+
};
|
|
597
|
+
remember(this.conversations, id, entry, MAX_CONVERSATIONS);
|
|
598
|
+
this.blocked.delete(paceKeyOf(id));
|
|
599
|
+
const { channelId } = splitConversation(id);
|
|
600
|
+
if (channelId !== id)
|
|
601
|
+
remember(this.conversations, channelId, entry, MAX_CONVERSATIONS);
|
|
602
|
+
}
|
|
603
|
+
serviceUrlOf(conversationId) {
|
|
604
|
+
return (this.conversations.get(conversationId)?.serviceUrl ??
|
|
605
|
+
this.conversations.get(splitConversation(conversationId).channelId)?.serviceUrl ??
|
|
606
|
+
this.config.serviceUrl ??
|
|
607
|
+
TEAMS_GLOBAL_SERVICE_URL);
|
|
608
|
+
}
|
|
609
|
+
typeOf(conversationId) {
|
|
610
|
+
return this.conversations.get(conversationId)?.type ?? conversationTypeOf(conversationId);
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Calls the Bot Connector for a conversation, paced by its limits. A conversation where Teams
|
|
614
|
+
* blocks the bot's messages is not called again until an activity comes from it, and nothing
|
|
615
|
+
* is called once the channel stopped: its waits and requests under way are aborted.
|
|
616
|
+
*/
|
|
617
|
+
async call(method, conversationId, path, body, options = {}) {
|
|
618
|
+
const key = paceKeyOf(conversationId);
|
|
619
|
+
if (this.stopped)
|
|
620
|
+
throw new Error(`[teams] ${method} ${path} refused: the channel is stopped`);
|
|
621
|
+
if (this.blocked.has(key)) {
|
|
622
|
+
throw new TeamsApiError('auth', `${method} ${path} skipped: Teams blocks the bot's messages in this conversation (MessageWritesBlocked) until the user writes again`, { status: 403, errorCode: 'MessageWritesBlocked' });
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
return await this.client.request(method, this.serviceUrlOf(conversationId), path, body, {
|
|
626
|
+
key,
|
|
627
|
+
...(options.priority && { priority: options.priority }),
|
|
628
|
+
...(options.reserved && { reserved: true }),
|
|
629
|
+
}, {
|
|
630
|
+
signal: this.halt.signal,
|
|
631
|
+
...(options.retries !== undefined && { retries: options.retries }),
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
if (error instanceof TeamsApiError && error.errorCode === 'MessageWritesBlocked') {
|
|
636
|
+
this.blocked.add(key);
|
|
637
|
+
forgetOldest(this.blocked);
|
|
638
|
+
}
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/** Where a message of an activity goes: the chat, and the channel thread it is in. */
|
|
643
|
+
placeOf(activity) {
|
|
644
|
+
const conversation = activity.conversation;
|
|
645
|
+
const type = conversationTypeOf(conversation.id, conversation.conversationType);
|
|
646
|
+
const { channelId, topicId } = splitConversation(conversation.id);
|
|
647
|
+
if (type === 'personal')
|
|
648
|
+
return { channelId };
|
|
649
|
+
if (type === 'groupChat')
|
|
650
|
+
return { channelId, ...(topicId && { topicId }), groupId: channelId };
|
|
651
|
+
const root = topicId ?? activity.replyToId ?? activity.id;
|
|
652
|
+
return {
|
|
653
|
+
channelId,
|
|
654
|
+
...(root && { topicId: root }),
|
|
655
|
+
groupId: teamOf(activity) ?? channelId,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Hands a message to the gateway and answers its request once the gateway took it, or after
|
|
660
|
+
* a few seconds. One the gateway refuses because it stops is answered `503`, and one it gives
|
|
661
|
+
* back after its request was answered is logged by `redeliver`.
|
|
662
|
+
*/
|
|
663
|
+
async handOver(activity, key) {
|
|
664
|
+
const accepted = this.receive(activity, key).catch((error) => {
|
|
665
|
+
console.error('[teams] Message failed:', error);
|
|
666
|
+
return 'taken';
|
|
667
|
+
});
|
|
668
|
+
const pending = Symbol('pending');
|
|
669
|
+
const handedOver = await within(accepted, HANDOVER_TIMEOUT, pending);
|
|
670
|
+
if (handedOver === 'refused') {
|
|
671
|
+
if (key !== undefined)
|
|
672
|
+
this.delivered.delete(key);
|
|
673
|
+
return textResponse(503, 'Not taken now, send it again later');
|
|
674
|
+
}
|
|
675
|
+
if (handedOver === pending && key !== undefined) {
|
|
676
|
+
this.answeredEarly.add(key);
|
|
677
|
+
forgetOldest(this.answeredEarly);
|
|
678
|
+
void accepted.finally(() => this.answeredEarly.delete(key));
|
|
679
|
+
}
|
|
680
|
+
return textResponse(202, 'Accepted');
|
|
681
|
+
}
|
|
682
|
+
async receive(activity, key) {
|
|
683
|
+
const handler = this.handler;
|
|
684
|
+
if (!handler || !this.admits(activity))
|
|
685
|
+
return 'skipped';
|
|
686
|
+
const conversation = activity.conversation;
|
|
687
|
+
const type = conversationTypeOf(conversation.id, conversation.conversationType);
|
|
688
|
+
if (type !== 'personal' && this.config.trigger !== 'all' && !mentionsBot(activity)) {
|
|
689
|
+
return 'skipped';
|
|
690
|
+
}
|
|
691
|
+
const text = textWithoutMention(activity);
|
|
692
|
+
const attachments = await this.inboundAttachments(activity);
|
|
693
|
+
if (!text && attachments.length === 0)
|
|
694
|
+
return 'skipped';
|
|
695
|
+
const place = this.placeOf(activity);
|
|
696
|
+
const id = activity.id ?? randomUUID();
|
|
697
|
+
const message = {
|
|
698
|
+
id,
|
|
699
|
+
channelType: this.type,
|
|
700
|
+
channelId: place.channelId,
|
|
701
|
+
userId: activity.from.aadObjectId ?? activity.from.id,
|
|
702
|
+
...(activity.from.name && { userName: activity.from.name }),
|
|
703
|
+
...(place.groupId && { groupId: place.groupId }),
|
|
704
|
+
text,
|
|
705
|
+
...(attachments.length > 0 && { attachments }),
|
|
706
|
+
...(activity.replyToId && { replyTo: activity.replyToId }),
|
|
707
|
+
...(place.topicId && { topicId: place.topicId }),
|
|
708
|
+
raw: activity,
|
|
709
|
+
};
|
|
710
|
+
const flightKey = key ?? `${conversation.id}|${id}`;
|
|
711
|
+
remember(this.deliveryKeys, id, flightKey);
|
|
712
|
+
this.inFlight.add(flightKey);
|
|
713
|
+
try {
|
|
714
|
+
await handler(message);
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
console.error('[teams] Message handler error:', error);
|
|
718
|
+
}
|
|
719
|
+
finally {
|
|
720
|
+
this.inFlight.delete(flightKey);
|
|
721
|
+
}
|
|
722
|
+
return this.refused.delete(flightKey) ? 'refused' : 'taken';
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* The files of a message the agent can read: files sent in a personal chat, by their download
|
|
726
|
+
* URL, and inline images, downloaded with the bot's token from the Bot Connector the activity
|
|
727
|
+
* came through (and from nowhere else, so the token never leaves it).
|
|
728
|
+
*/
|
|
729
|
+
async inboundAttachments(activity) {
|
|
730
|
+
const attachments = [];
|
|
731
|
+
const serviceHost = new URL(activity.serviceUrl).host;
|
|
732
|
+
for (const attachment of attachmentsOf(activity)) {
|
|
733
|
+
const info = downloadInfoOf(attachment);
|
|
734
|
+
if (info && /^https:\/\//i.test(info.downloadUrl)) {
|
|
735
|
+
const mimeType = mimeTypeOf(info.fileType);
|
|
736
|
+
attachments.push({
|
|
737
|
+
type: attachmentTypeOf(mimeType),
|
|
738
|
+
url: info.downloadUrl,
|
|
739
|
+
mimeType,
|
|
740
|
+
...(attachment.name && { filename: attachment.name }),
|
|
741
|
+
});
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
const kind = attachmentTypeOf(attachment.contentType);
|
|
745
|
+
if (kind === 'file' || !attachment.contentUrl)
|
|
746
|
+
continue;
|
|
747
|
+
let url;
|
|
748
|
+
try {
|
|
749
|
+
url = new URL(attachment.contentUrl);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (url.host !== serviceHost)
|
|
755
|
+
continue;
|
|
756
|
+
const buffer = await this.download(url.href).catch((error) => {
|
|
757
|
+
console.warn('[teams] Could not download a file of a message:', error);
|
|
758
|
+
return undefined;
|
|
759
|
+
});
|
|
760
|
+
if (!buffer)
|
|
761
|
+
continue;
|
|
762
|
+
attachments.push({
|
|
763
|
+
type: kind,
|
|
764
|
+
buffer,
|
|
765
|
+
mimeType: attachment.contentType,
|
|
766
|
+
...(attachment.name && { filename: attachment.name }),
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
return attachments;
|
|
770
|
+
}
|
|
771
|
+
async download(url) {
|
|
772
|
+
const token = await this.credentials.token();
|
|
773
|
+
const response = await this.fetchImpl(url, {
|
|
774
|
+
headers: { authorization: `Bearer ${token}` },
|
|
775
|
+
signal: AbortSignal.timeout(this.config.requestTimeout ?? TEAMS_REQUEST_TIMEOUT),
|
|
776
|
+
});
|
|
777
|
+
if (!response.ok)
|
|
778
|
+
throw new Error(`GET ${url} answered ${response.status}`);
|
|
779
|
+
const length = Number(response.headers.get('content-length'));
|
|
780
|
+
if (Number.isFinite(length) && length > MAX_INBOUND_FILE)
|
|
781
|
+
return undefined;
|
|
782
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
783
|
+
return buffer.byteLength > MAX_INBOUND_FILE ? undefined : buffer;
|
|
784
|
+
}
|
|
785
|
+
/** The action of a button press, from an `Action.Submit` message or an `Action.Execute` invoke. */
|
|
786
|
+
actionOf(activity, data) {
|
|
787
|
+
const place = this.placeOf(activity);
|
|
788
|
+
const id = activity.id ?? randomUUID();
|
|
789
|
+
remember(this.actionConversations, id, joinConversation(place.channelId, place.topicId));
|
|
790
|
+
return {
|
|
791
|
+
id,
|
|
792
|
+
channelType: this.type,
|
|
793
|
+
channelId: place.channelId,
|
|
794
|
+
userId: activity.from.aadObjectId ?? activity.from.id,
|
|
795
|
+
...(activity.from.name && { userName: activity.from.name }),
|
|
796
|
+
...(activity.replyToId && { messageId: activity.replyToId }),
|
|
797
|
+
...(place.topicId && { topicId: place.topicId }),
|
|
798
|
+
...(place.groupId && { groupId: place.groupId }),
|
|
799
|
+
data,
|
|
800
|
+
raw: activity,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
async press(activity, data) {
|
|
804
|
+
if (!this.actionHandler)
|
|
805
|
+
return;
|
|
806
|
+
await this.actionHandler(this.actionOf(activity, data));
|
|
807
|
+
}
|
|
808
|
+
async invoke(activity) {
|
|
809
|
+
if (activity.name === 'adaptiveCard/action')
|
|
810
|
+
return this.execute(activity);
|
|
811
|
+
if (activity.name === 'fileConsent/invoke') {
|
|
812
|
+
const answer = fileConsentOf(activity.value);
|
|
813
|
+
if (answer && this.admits(activity)) {
|
|
814
|
+
void this.consented(activity, answer).catch((error) => console.error('[teams] Answering a file consent failed:', error));
|
|
815
|
+
}
|
|
816
|
+
return jsonResponse(200, {});
|
|
817
|
+
}
|
|
818
|
+
return textResponse(501, 'Not implemented');
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Answers an `Action.Execute` press within the time Teams waits: with the text the press is
|
|
822
|
+
* answered with, or else the message's card once the press is handled (so it shows what the
|
|
823
|
+
* press changed, such as an approval's decision), or as it is when time runs out.
|
|
824
|
+
*/
|
|
825
|
+
async execute(activity) {
|
|
826
|
+
const data = executeDataOf(activity.value);
|
|
827
|
+
if (data === undefined || !this.admits(activity)) {
|
|
828
|
+
return jsonResponse(200, {
|
|
829
|
+
statusCode: 400,
|
|
830
|
+
type: 'application/vnd.microsoft.error',
|
|
831
|
+
value: {
|
|
832
|
+
code: 'BadRequest',
|
|
833
|
+
message: data === undefined ? 'Unknown action' : 'Not allowed',
|
|
834
|
+
},
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
const action = this.actionOf(activity, data);
|
|
838
|
+
let answered = {};
|
|
839
|
+
if (this.actionHandler) {
|
|
840
|
+
const answer = new Promise((resolve) => {
|
|
841
|
+
this.answers.set(action.id, { resolve });
|
|
842
|
+
});
|
|
843
|
+
const handled = this.actionHandler(action).then(() => ({}), (error) => {
|
|
844
|
+
console.error('[teams] Button press failed:', error);
|
|
845
|
+
return {};
|
|
846
|
+
});
|
|
847
|
+
const deadline = Date.now() + INVOKE_TIMEOUT;
|
|
848
|
+
answered = await within(Promise.race([answer, handled]), INVOKE_TIMEOUT, {});
|
|
849
|
+
this.answers.delete(action.id);
|
|
850
|
+
if (!answered.text && !this.stopped) {
|
|
851
|
+
let halt = () => { };
|
|
852
|
+
const halted = new Promise((resolve) => {
|
|
853
|
+
halt = () => resolve({});
|
|
854
|
+
});
|
|
855
|
+
this.onHalt.add(halt);
|
|
856
|
+
await within(Promise.race([handled, halted]), Math.max(0, deadline - Date.now()), {});
|
|
857
|
+
this.onHalt.delete(halt);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (answered.text) {
|
|
861
|
+
return jsonResponse(200, { statusCode: 200, type: ACTIVITY_MESSAGE, value: answered.text });
|
|
862
|
+
}
|
|
863
|
+
const messageId = activity.replyToId;
|
|
864
|
+
const pending = messageId ? this.updates.get(messageId) : undefined;
|
|
865
|
+
const card = pending
|
|
866
|
+
? pending.next.card
|
|
867
|
+
: messageId
|
|
868
|
+
? this.sent.get(messageId)?.card
|
|
869
|
+
: undefined;
|
|
870
|
+
if (card)
|
|
871
|
+
return jsonResponse(200, { statusCode: 200, type: ADAPTIVE_CARD, value: card });
|
|
872
|
+
return jsonResponse(200, { statusCode: 200, type: ACTIVITY_MESSAGE, value: ACTION_RECEIVED });
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Uploads a file the user accepted, in the conversation the consent card was sent to. A card
|
|
876
|
+
* the bot no longer knows (it restarted, or the file expired) and a declined one are answered
|
|
877
|
+
* with a short message.
|
|
878
|
+
*/
|
|
879
|
+
async consented(activity, answer) {
|
|
880
|
+
const conversationId = activity.conversation.id;
|
|
881
|
+
const uploadId = answer.context?.uploadId;
|
|
882
|
+
const upload = uploadId ? this.uploads.get(uploadId) : undefined;
|
|
883
|
+
if (!uploadId || upload?.conversationId !== conversationId) {
|
|
884
|
+
if (answer.action === 'accept') {
|
|
885
|
+
await this.post(conversationId, { text: UPLOAD_GONE, textFormat: 'plain' });
|
|
886
|
+
}
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
this.uploads.delete(uploadId);
|
|
890
|
+
if (answer.action !== 'accept' || !answer.uploadInfo) {
|
|
891
|
+
await this.post(conversationId, { text: UPLOAD_DECLINED, textFormat: 'plain' });
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
await this.upload(upload, answer.uploadInfo).catch(async (error) => {
|
|
895
|
+
console.error(`[teams] Uploading ${upload.filename} failed:`, error);
|
|
896
|
+
await this.post(conversationId, {
|
|
897
|
+
text: `The file ${upload.filename} could not be uploaded.`,
|
|
898
|
+
textFormat: 'plain',
|
|
899
|
+
});
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
async upload(upload, info) {
|
|
903
|
+
if (!/^https:\/\//i.test(info.uploadUrl))
|
|
904
|
+
throw new Error('The upload URL is not https');
|
|
905
|
+
const size = upload.buffer.length;
|
|
906
|
+
for (let start = 0; start < size; start += UPLOAD_FRAGMENT) {
|
|
907
|
+
const end = Math.min(start + UPLOAD_FRAGMENT, size);
|
|
908
|
+
const response = await this.fetchImpl(info.uploadUrl, {
|
|
909
|
+
method: 'PUT',
|
|
910
|
+
headers: {
|
|
911
|
+
'content-length': String(end - start),
|
|
912
|
+
'content-range': `bytes ${start}-${end - 1}/${size}`,
|
|
913
|
+
},
|
|
914
|
+
body: new Uint8Array(upload.buffer.subarray(start, end)),
|
|
915
|
+
signal: AbortSignal.timeout(this.config.requestTimeout ?? TEAMS_REQUEST_TIMEOUT),
|
|
916
|
+
});
|
|
917
|
+
if (!response.ok)
|
|
918
|
+
throw new Error(`The upload answered ${response.status}`);
|
|
919
|
+
}
|
|
920
|
+
await this.post(upload.conversationId, {
|
|
921
|
+
text: upload.caption ?? '',
|
|
922
|
+
textFormat: upload.textFormat,
|
|
923
|
+
attachments: [
|
|
924
|
+
{
|
|
925
|
+
contentType: FILE_INFO_CARD,
|
|
926
|
+
contentUrl: info.contentUrl,
|
|
927
|
+
name: info.name,
|
|
928
|
+
content: { uniqueId: info.uniqueId, fileType: info.fileType ?? '' },
|
|
929
|
+
},
|
|
930
|
+
],
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
expireUploads() {
|
|
934
|
+
const oldest = Date.now() - UPLOAD_TTL;
|
|
935
|
+
for (const [id, upload] of this.uploads) {
|
|
936
|
+
if (upload.createdAt >= oldest)
|
|
937
|
+
break;
|
|
938
|
+
this.uploads.delete(id);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/** Posts a message activity, as a reply to `replyTo` when given, and remembers what it holds. */
|
|
942
|
+
async post(conversationId, content, replyTo) {
|
|
943
|
+
const attachments = content.card
|
|
944
|
+
? [{ contentType: ADAPTIVE_CARD, content: content.card }]
|
|
945
|
+
: content.attachments;
|
|
946
|
+
const activity = {
|
|
947
|
+
type: 'message',
|
|
948
|
+
...(content.text && {
|
|
949
|
+
text: bodyFor(content.text, content.textFormat),
|
|
950
|
+
textFormat: content.textFormat,
|
|
951
|
+
}),
|
|
952
|
+
...(attachments && { attachments }),
|
|
953
|
+
...(replyTo && { replyToId: replyTo }),
|
|
954
|
+
};
|
|
955
|
+
const response = await this.call('POST', conversationId, activitiesPath(conversationId, replyTo), activity);
|
|
956
|
+
const parsed = ResourceSchema.safeParse(response);
|
|
957
|
+
const id = parsed.success ? (parsed.data.id ?? '') : '';
|
|
958
|
+
if (id && !content.attachments) {
|
|
959
|
+
remember(this.sent, id, {
|
|
960
|
+
conversationId,
|
|
961
|
+
text: content.text,
|
|
962
|
+
textFormat: content.textFormat,
|
|
963
|
+
...(content.card && { card: content.card }),
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
return id;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Updates a message the bot sent (`PUT`), one update of it at a time: changes made while one
|
|
970
|
+
* is under way are folded into the next, which sends the latest state.
|
|
971
|
+
*/
|
|
972
|
+
update(conversationId, messageId, change) {
|
|
973
|
+
let state = this.updates.get(messageId);
|
|
974
|
+
if (!state) {
|
|
975
|
+
const base = this.sent.get(messageId) ?? {
|
|
976
|
+
conversationId,
|
|
977
|
+
text: '',
|
|
978
|
+
textFormat: 'extendedmarkdown',
|
|
979
|
+
};
|
|
980
|
+
state = { conversationId, next: base, dirty: false, lastAt: 0 };
|
|
981
|
+
remember(this.updates, messageId, state);
|
|
982
|
+
}
|
|
983
|
+
state.next = change(state.next);
|
|
984
|
+
state.dirty = true;
|
|
985
|
+
state.running ??= this.drainUpdates(messageId, state);
|
|
986
|
+
return state.running;
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Sends the updates of a message one at a time. When one fails while a newer state waits,
|
|
990
|
+
* the newer one is still sent, so a streamed answer ends with its final text, and only a
|
|
991
|
+
* failure of the latest state reaches the callers.
|
|
992
|
+
*/
|
|
993
|
+
async drainUpdates(messageId, state) {
|
|
994
|
+
try {
|
|
995
|
+
while (state.dirty) {
|
|
996
|
+
const wait = state.lastAt + TEAMS_MIN_EDIT_INTERVAL - Date.now();
|
|
997
|
+
if (wait > 0)
|
|
998
|
+
await pause(wait, this.halt.signal);
|
|
999
|
+
state.dirty = false;
|
|
1000
|
+
const entry = state.next;
|
|
1001
|
+
state.lastAt = Date.now();
|
|
1002
|
+
try {
|
|
1003
|
+
await this.call('PUT', state.conversationId, activitiesPath(state.conversationId, messageId), {
|
|
1004
|
+
type: 'message',
|
|
1005
|
+
id: messageId,
|
|
1006
|
+
text: bodyFor(entry.text, entry.textFormat),
|
|
1007
|
+
textFormat: entry.textFormat,
|
|
1008
|
+
...(entry.card && {
|
|
1009
|
+
attachments: [{ contentType: ADAPTIVE_CARD, content: entry.card }],
|
|
1010
|
+
}),
|
|
1011
|
+
}, { priority: 'background' });
|
|
1012
|
+
}
|
|
1013
|
+
catch (error) {
|
|
1014
|
+
if (state.dirty && !this.blocked.has(paceKeyOf(state.conversationId)))
|
|
1015
|
+
continue;
|
|
1016
|
+
throw error;
|
|
1017
|
+
}
|
|
1018
|
+
remember(this.sent, messageId, entry);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
finally {
|
|
1022
|
+
state.running = undefined;
|
|
1023
|
+
if (this.updates.get(messageId) === state)
|
|
1024
|
+
this.updates.delete(messageId);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
listen(port, path, host) {
|
|
1028
|
+
const listener = this.webhookListener();
|
|
1029
|
+
const routes = this.config.webhook?.routes ?? {};
|
|
1030
|
+
const server = createServer((req, res) => {
|
|
1031
|
+
const finished = new Promise((resolve) => res.once('close', resolve));
|
|
1032
|
+
this.activeRequests.add(finished);
|
|
1033
|
+
void finished.then(() => this.activeRequests.delete(finished));
|
|
1034
|
+
let pathname;
|
|
1035
|
+
try {
|
|
1036
|
+
pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
1037
|
+
}
|
|
1038
|
+
catch {
|
|
1039
|
+
res.writeHead(400, { 'content-type': 'text/plain' }).end('Bad request');
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (pathname === path) {
|
|
1043
|
+
listener(req, res);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
const route = Object.hasOwn(routes, pathname) ? routes[pathname] : undefined;
|
|
1047
|
+
if (route) {
|
|
1048
|
+
route(req, res);
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
res.writeHead(404).end();
|
|
1052
|
+
});
|
|
1053
|
+
this.server = server;
|
|
1054
|
+
return new Promise((resolve, reject) => {
|
|
1055
|
+
const failed = (error) => {
|
|
1056
|
+
this.server = undefined;
|
|
1057
|
+
reject(error);
|
|
1058
|
+
};
|
|
1059
|
+
server.once('error', failed);
|
|
1060
|
+
server.listen(port, host, () => {
|
|
1061
|
+
server.off('error', failed);
|
|
1062
|
+
resolve();
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
/** A Microsoft Teams channel on the Bot Framework protocol. */
|
|
1068
|
+
export function teamsChannel(config) {
|
|
1069
|
+
return new TeamsChannel(config);
|
|
1070
|
+
}
|
|
1071
|
+
//# sourceMappingURL=channel.js.map
|