@hahnfeld/teams-adapter 1.2.0 → 1.3.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 +81 -28
- package/dist/adapter.d.ts +16 -25
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +154 -233
- package/dist/adapter.js.map +1 -1
- package/dist/formatting.d.ts +0 -57
- package/dist/formatting.d.ts.map +1 -1
- package/dist/formatting.js +0 -183
- package/dist/formatting.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/message-composer.d.ts +94 -0
- package/dist/message-composer.d.ts.map +1 -0
- package/dist/message-composer.js +331 -0
- package/dist/message-composer.js.map +1 -0
- package/dist/rate-limiter.d.ts +17 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +201 -0
- package/dist/rate-limiter.js.map +1 -0
- package/package.json +2 -2
- package/dist/draft-manager.d.ts +0 -69
- package/dist/draft-manager.d.ts.map +0 -1
- package/dist/draft-manager.js +0 -333
- package/dist/draft-manager.js.map +0 -1
- package/dist/renderer.d.ts +0 -49
- package/dist/renderer.d.ts.map +0 -1
- package/dist/renderer.js +0 -55
- package/dist/renderer.js.map +0 -1
- package/dist/task-modules.d.ts +0 -34
- package/dist/task-modules.d.ts.map +0 -1
- package/dist/task-modules.js +0 -136
- package/dist/task-modules.js.map +0 -1
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,iBAAiB,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAC7B,eAAe,iBAAiB,EAAE,CAAC;AACnC,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,iBAAiB,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAC7B,eAAe,iBAAiB,EAAE,CAAC;AACnC,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAG9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAE5D,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,sBAAsB,EAAE,cAAc,EAAE,wBAAwB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message composer — manages a single "main message" per session in Teams.
|
|
3
|
+
*
|
|
4
|
+
* The main message has four zones:
|
|
5
|
+
* TITLE (persistent, bold) — session name, survives finalize
|
|
6
|
+
* HEADER (ephemeral, italic) — tool_call, tool_update, thought
|
|
7
|
+
* BODY (persistent, streamed) — text, attachments
|
|
8
|
+
* FOOTER (persistent, italic) — usage, session_end
|
|
9
|
+
*
|
|
10
|
+
* All zones are composed into a single markdown string and sent/updated
|
|
11
|
+
* as one Teams message via the rate limiter.
|
|
12
|
+
*/
|
|
13
|
+
import type { TurnContext } from "@microsoft/agents-hosting";
|
|
14
|
+
import type { ConversationRateLimiter } from "./rate-limiter.js";
|
|
15
|
+
export interface MessageRef {
|
|
16
|
+
activityId: string;
|
|
17
|
+
conversationId: string;
|
|
18
|
+
serviceUrl: string;
|
|
19
|
+
}
|
|
20
|
+
export type AcquireBotToken = () => Promise<string | null>;
|
|
21
|
+
/**
|
|
22
|
+
* A single main message with title/header/body/footer zones.
|
|
23
|
+
*/
|
|
24
|
+
export declare class SessionMessage {
|
|
25
|
+
private context;
|
|
26
|
+
private conversationId;
|
|
27
|
+
private sessionId;
|
|
28
|
+
private rateLimiter;
|
|
29
|
+
private acquireBotToken;
|
|
30
|
+
private title;
|
|
31
|
+
private header;
|
|
32
|
+
private body;
|
|
33
|
+
private footer;
|
|
34
|
+
private ref;
|
|
35
|
+
private lastSent;
|
|
36
|
+
private stallTimer?;
|
|
37
|
+
constructor(context: TurnContext, conversationId: string, sessionId: string, rateLimiter: ConversationRateLimiter, acquireBotToken: AcquireBotToken);
|
|
38
|
+
updateContext(context: TurnContext): void;
|
|
39
|
+
getRef(): MessageRef | null;
|
|
40
|
+
getBody(): string;
|
|
41
|
+
getFooter(): string | null;
|
|
42
|
+
/** Set the persistent session title (bold, survives finalize). */
|
|
43
|
+
setTitle(text: string): void;
|
|
44
|
+
/** Max header length — keeps the message compact; tool diffs can be huge. */
|
|
45
|
+
private static readonly MAX_HEADER_LENGTH;
|
|
46
|
+
/** Replace the ephemeral header (tool_call, thought, etc.). */
|
|
47
|
+
setHeader(text: string): void;
|
|
48
|
+
/** Clear the ephemeral header and flush the update. */
|
|
49
|
+
clearHeader(): void;
|
|
50
|
+
/** Append text to the body (streaming text chunks). */
|
|
51
|
+
appendBody(text: string): void;
|
|
52
|
+
/** Set the persistent footer (usage, completion). */
|
|
53
|
+
setFooter(text: string): void;
|
|
54
|
+
/** Append to the existing footer (e.g., adding "Task completed" after usage). */
|
|
55
|
+
appendFooter(text: string): void;
|
|
56
|
+
/**
|
|
57
|
+
* Close any unclosed code fences in the body.
|
|
58
|
+
* If the body has an odd number of ``` markers, the footer would
|
|
59
|
+
* be swallowed into the code block — append a closing fence.
|
|
60
|
+
*/
|
|
61
|
+
private static closeCodeFences;
|
|
62
|
+
/** Compose the four zones into a single markdown string. */
|
|
63
|
+
compose(): string;
|
|
64
|
+
/** Request a flush through the rate limiter. */
|
|
65
|
+
requestFlush(): void;
|
|
66
|
+
private flush;
|
|
67
|
+
private updateViaRest;
|
|
68
|
+
/** Split: finalize current message at body limit, start fresh. */
|
|
69
|
+
private split;
|
|
70
|
+
private resetStallTimer;
|
|
71
|
+
/** Finalize: clear stall timer, clear ephemeral header, do a last flush. */
|
|
72
|
+
finalize(): Promise<MessageRef | null>;
|
|
73
|
+
stripPattern(pattern: RegExp): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Manages SessionMessage instances and plan refs across sessions.
|
|
77
|
+
*/
|
|
78
|
+
export declare class SessionMessageManager {
|
|
79
|
+
private rateLimiter;
|
|
80
|
+
private acquireBotToken;
|
|
81
|
+
private messages;
|
|
82
|
+
private planRefs;
|
|
83
|
+
constructor(rateLimiter: ConversationRateLimiter, acquireBotToken: AcquireBotToken);
|
|
84
|
+
getOrCreate(sessionId: string, context: TurnContext): SessionMessage;
|
|
85
|
+
get(sessionId: string): SessionMessage | undefined;
|
|
86
|
+
has(sessionId: string): boolean;
|
|
87
|
+
/** Finalize and remove a session's message and plan ref. */
|
|
88
|
+
finalize(sessionId: string): Promise<MessageRef | null>;
|
|
89
|
+
/** Get or set the plan message ref for a session. */
|
|
90
|
+
getPlanRef(sessionId: string): MessageRef | undefined;
|
|
91
|
+
setPlanRef(sessionId: string, ref: MessageRef): void;
|
|
92
|
+
cleanup(sessionId: string): void;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=message-composer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-composer.d.ts","sourceRoot":"","sources":["../src/message-composer.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { log } from "@openacp/plugin-sdk";
|
|
2
|
+
import { sendText } from "./send-utils.js";
|
|
3
|
+
const MAX_BODY_LENGTH = 25_000;
|
|
4
|
+
const STALL_TIMEOUT = 30_000;
|
|
5
|
+
/** Escape asterisks to prevent breaking markdown italic/bold spans. */
|
|
6
|
+
function escapeEmphasis(text) {
|
|
7
|
+
return text.replace(/\*/g, "\\*");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Normalize newlines for Teams rendering.
|
|
11
|
+
* Teams collapses single \n in markdown — use \n\n for line breaks.
|
|
12
|
+
*/
|
|
13
|
+
function teamsNewlines(text) {
|
|
14
|
+
return text.replace(/(?<!\n)\n(?!\n)/g, "\n\n");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A single main message with title/header/body/footer zones.
|
|
18
|
+
*/
|
|
19
|
+
export class SessionMessage {
|
|
20
|
+
context;
|
|
21
|
+
conversationId;
|
|
22
|
+
sessionId;
|
|
23
|
+
rateLimiter;
|
|
24
|
+
acquireBotToken;
|
|
25
|
+
title = null;
|
|
26
|
+
header = null;
|
|
27
|
+
body = "";
|
|
28
|
+
footer = null;
|
|
29
|
+
ref = null;
|
|
30
|
+
lastSent = "";
|
|
31
|
+
stallTimer;
|
|
32
|
+
constructor(context, conversationId, sessionId, rateLimiter, acquireBotToken) {
|
|
33
|
+
this.context = context;
|
|
34
|
+
this.conversationId = conversationId;
|
|
35
|
+
this.sessionId = sessionId;
|
|
36
|
+
this.rateLimiter = rateLimiter;
|
|
37
|
+
this.acquireBotToken = acquireBotToken;
|
|
38
|
+
}
|
|
39
|
+
updateContext(context) {
|
|
40
|
+
this.context = context;
|
|
41
|
+
}
|
|
42
|
+
getRef() {
|
|
43
|
+
return this.ref;
|
|
44
|
+
}
|
|
45
|
+
getBody() {
|
|
46
|
+
return this.body;
|
|
47
|
+
}
|
|
48
|
+
getFooter() {
|
|
49
|
+
return this.footer;
|
|
50
|
+
}
|
|
51
|
+
/** Set the persistent session title (bold, survives finalize). */
|
|
52
|
+
setTitle(text) {
|
|
53
|
+
this.title = text;
|
|
54
|
+
this.requestFlush();
|
|
55
|
+
}
|
|
56
|
+
/** Max header length — keeps the message compact; tool diffs can be huge. */
|
|
57
|
+
static MAX_HEADER_LENGTH = 120;
|
|
58
|
+
/** Replace the ephemeral header (tool_call, thought, etc.). */
|
|
59
|
+
setHeader(text) {
|
|
60
|
+
// Truncate to first line, then cap length — headers are status indicators, not content
|
|
61
|
+
const firstLine = text.split("\n")[0];
|
|
62
|
+
this.header = firstLine.length > SessionMessage.MAX_HEADER_LENGTH
|
|
63
|
+
? firstLine.slice(0, SessionMessage.MAX_HEADER_LENGTH) + "..."
|
|
64
|
+
: firstLine;
|
|
65
|
+
this.requestFlush();
|
|
66
|
+
}
|
|
67
|
+
/** Clear the ephemeral header and flush the update. */
|
|
68
|
+
clearHeader() {
|
|
69
|
+
if (this.header === null)
|
|
70
|
+
return;
|
|
71
|
+
this.header = null;
|
|
72
|
+
this.requestFlush();
|
|
73
|
+
}
|
|
74
|
+
/** Append text to the body (streaming text chunks). */
|
|
75
|
+
appendBody(text) {
|
|
76
|
+
if (!text)
|
|
77
|
+
return;
|
|
78
|
+
this.body += text;
|
|
79
|
+
this.resetStallTimer();
|
|
80
|
+
// Check if body needs splitting
|
|
81
|
+
if (this.body.length > MAX_BODY_LENGTH && this.ref) {
|
|
82
|
+
this.split();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.requestFlush();
|
|
86
|
+
}
|
|
87
|
+
/** Set the persistent footer (usage, completion). */
|
|
88
|
+
setFooter(text) {
|
|
89
|
+
this.footer = text;
|
|
90
|
+
this.requestFlush();
|
|
91
|
+
}
|
|
92
|
+
/** Append to the existing footer (e.g., adding "Task completed" after usage). */
|
|
93
|
+
appendFooter(text) {
|
|
94
|
+
this.footer = this.footer ? `${this.footer} · ${text}` : text;
|
|
95
|
+
this.requestFlush();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Close any unclosed code fences in the body.
|
|
99
|
+
* If the body has an odd number of ``` markers, the footer would
|
|
100
|
+
* be swallowed into the code block — append a closing fence.
|
|
101
|
+
*/
|
|
102
|
+
static closeCodeFences(text) {
|
|
103
|
+
const fenceCount = (text.match(/^```/gm) || []).length;
|
|
104
|
+
if (fenceCount % 2 !== 0) {
|
|
105
|
+
return text + "\n```";
|
|
106
|
+
}
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
109
|
+
/** Compose the four zones into a single markdown string. */
|
|
110
|
+
compose() {
|
|
111
|
+
const parts = [];
|
|
112
|
+
if (this.title) {
|
|
113
|
+
parts.push(`**${escapeEmphasis(this.title)}**`);
|
|
114
|
+
}
|
|
115
|
+
if (this.header) {
|
|
116
|
+
parts.push(`*${escapeEmphasis(this.header)}*`);
|
|
117
|
+
}
|
|
118
|
+
if (this.title || this.header) {
|
|
119
|
+
parts.push("---");
|
|
120
|
+
}
|
|
121
|
+
if (this.body) {
|
|
122
|
+
parts.push(SessionMessage.closeCodeFences(this.body));
|
|
123
|
+
}
|
|
124
|
+
if (this.footer) {
|
|
125
|
+
if (this.body)
|
|
126
|
+
parts.push("---");
|
|
127
|
+
parts.push(`*${escapeEmphasis(this.footer)}*`);
|
|
128
|
+
}
|
|
129
|
+
return parts.join("\n\n");
|
|
130
|
+
}
|
|
131
|
+
/** Request a flush through the rate limiter. */
|
|
132
|
+
requestFlush() {
|
|
133
|
+
const composed = this.compose();
|
|
134
|
+
if (!composed)
|
|
135
|
+
return;
|
|
136
|
+
if (composed === this.lastSent)
|
|
137
|
+
return;
|
|
138
|
+
// Coalescing key: activityId for updates, session for new sends
|
|
139
|
+
const key = this.ref ? `update:${this.ref.activityId}` : `new:${this.sessionId}`;
|
|
140
|
+
this.rateLimiter.enqueue(this.conversationId, () => this.flush(), key).catch((err) => {
|
|
141
|
+
log.warn({ err, sessionId: this.sessionId }, "[SessionMessage] flush failed");
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async flush() {
|
|
145
|
+
const composed = this.compose();
|
|
146
|
+
if (!composed || composed === this.lastSent)
|
|
147
|
+
return;
|
|
148
|
+
if (!this.ref) {
|
|
149
|
+
// First send — create the message
|
|
150
|
+
const result = await sendText(this.context, composed);
|
|
151
|
+
if (result?.id) {
|
|
152
|
+
this.ref = {
|
|
153
|
+
activityId: result.id,
|
|
154
|
+
conversationId: this.context.activity.conversation?.id,
|
|
155
|
+
serviceUrl: this.context.activity.serviceUrl,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
this.lastSent = composed;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Update existing message via REST
|
|
162
|
+
const success = await this.updateViaRest(composed);
|
|
163
|
+
if (success) {
|
|
164
|
+
this.lastSent = composed;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Check if state changed during the flush (new chunks arrived while we were awaiting)
|
|
168
|
+
const current = this.compose();
|
|
169
|
+
if (current && current !== this.lastSent) {
|
|
170
|
+
this.requestFlush();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async updateViaRest(text) {
|
|
174
|
+
if (!this.ref)
|
|
175
|
+
return false;
|
|
176
|
+
const token = await this.acquireBotToken();
|
|
177
|
+
if (!token)
|
|
178
|
+
return false;
|
|
179
|
+
const url = `${this.ref.serviceUrl}/v3/conversations/${encodeURIComponent(this.ref.conversationId)}/activities/${encodeURIComponent(this.ref.activityId)}`;
|
|
180
|
+
try {
|
|
181
|
+
const response = await fetch(url, {
|
|
182
|
+
method: "PUT",
|
|
183
|
+
headers: {
|
|
184
|
+
"Content-Type": "application/json",
|
|
185
|
+
"Authorization": `Bearer ${token}`,
|
|
186
|
+
},
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
type: "message",
|
|
189
|
+
text: teamsNewlines(text),
|
|
190
|
+
textFormat: "markdown",
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
log.warn({ status: response.status, sessionId: this.sessionId }, "[SessionMessage] REST update failed");
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
log.warn({ err, sessionId: this.sessionId }, "[SessionMessage] REST update error");
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Split: finalize current message at body limit, start fresh. */
|
|
205
|
+
split() {
|
|
206
|
+
const finalBody = this.body.slice(0, MAX_BODY_LENGTH);
|
|
207
|
+
const overflow = this.body.slice(MAX_BODY_LENGTH);
|
|
208
|
+
log.info({ sessionId: this.sessionId, finalLen: finalBody.length, overflowLen: overflow.length }, "[SessionMessage] splitting");
|
|
209
|
+
// Clear ephemeral header on the finalized message; footer stays per spec
|
|
210
|
+
this.header = null;
|
|
211
|
+
this.body = finalBody;
|
|
212
|
+
// Final flush of the current message (with footer preserved)
|
|
213
|
+
const composed = this.compose();
|
|
214
|
+
if (this.ref && composed !== this.lastSent) {
|
|
215
|
+
this.rateLimiter.enqueue(this.conversationId, () => this.updateViaRest(composed).then(() => { }), `update:${this.ref.activityId}`).catch(() => { });
|
|
216
|
+
}
|
|
217
|
+
// Reset for a new message — footer carries over to new message
|
|
218
|
+
this.ref = null;
|
|
219
|
+
this.lastSent = "";
|
|
220
|
+
this.body = overflow;
|
|
221
|
+
if (overflow) {
|
|
222
|
+
this.requestFlush();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
resetStallTimer() {
|
|
226
|
+
if (this.stallTimer)
|
|
227
|
+
clearTimeout(this.stallTimer);
|
|
228
|
+
this.stallTimer = setTimeout(() => {
|
|
229
|
+
if (this.body && !this.footer) {
|
|
230
|
+
log.warn({ sessionId: this.sessionId }, "[SessionMessage] Stream stalled — adding cutoff notice");
|
|
231
|
+
this.header = null;
|
|
232
|
+
// Use appendBody so split check is applied
|
|
233
|
+
this.appendBody("\n\n---\n_Response was cut short — the model likely reached its output token limit. Send a follow-up message to continue._");
|
|
234
|
+
}
|
|
235
|
+
}, STALL_TIMEOUT);
|
|
236
|
+
if (this.stallTimer.unref)
|
|
237
|
+
this.stallTimer.unref();
|
|
238
|
+
}
|
|
239
|
+
/** Finalize: clear stall timer, clear ephemeral header, do a last flush. */
|
|
240
|
+
async finalize() {
|
|
241
|
+
if (this.stallTimer) {
|
|
242
|
+
clearTimeout(this.stallTimer);
|
|
243
|
+
this.stallTimer = undefined;
|
|
244
|
+
}
|
|
245
|
+
// Clear ephemeral header on finalize; title and footer persist
|
|
246
|
+
this.header = null;
|
|
247
|
+
// Final flush
|
|
248
|
+
const composed = this.compose();
|
|
249
|
+
if (composed && composed !== this.lastSent) {
|
|
250
|
+
if (!this.ref) {
|
|
251
|
+
const result = await sendText(this.context, composed);
|
|
252
|
+
if (result?.id) {
|
|
253
|
+
this.ref = {
|
|
254
|
+
activityId: result.id,
|
|
255
|
+
conversationId: this.context.activity.conversation?.id,
|
|
256
|
+
serviceUrl: this.context.activity.serviceUrl,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
await this.updateViaRest(composed);
|
|
262
|
+
}
|
|
263
|
+
this.lastSent = composed;
|
|
264
|
+
}
|
|
265
|
+
return this.ref;
|
|
266
|
+
}
|
|
267
|
+
async stripPattern(pattern) {
|
|
268
|
+
if (!this.body)
|
|
269
|
+
return;
|
|
270
|
+
try {
|
|
271
|
+
this.body = this.body.replace(pattern, "").trim();
|
|
272
|
+
}
|
|
273
|
+
catch { /* leave unchanged */ }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Manages SessionMessage instances and plan refs across sessions.
|
|
278
|
+
*/
|
|
279
|
+
export class SessionMessageManager {
|
|
280
|
+
rateLimiter;
|
|
281
|
+
acquireBotToken;
|
|
282
|
+
messages = new Map();
|
|
283
|
+
planRefs = new Map();
|
|
284
|
+
constructor(rateLimiter, acquireBotToken) {
|
|
285
|
+
this.rateLimiter = rateLimiter;
|
|
286
|
+
this.acquireBotToken = acquireBotToken;
|
|
287
|
+
}
|
|
288
|
+
getOrCreate(sessionId, context) {
|
|
289
|
+
let msg = this.messages.get(sessionId);
|
|
290
|
+
if (!msg) {
|
|
291
|
+
const conversationId = context.activity.conversation?.id;
|
|
292
|
+
msg = new SessionMessage(context, conversationId, sessionId, this.rateLimiter, this.acquireBotToken);
|
|
293
|
+
this.messages.set(sessionId, msg);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
msg.updateContext(context);
|
|
297
|
+
}
|
|
298
|
+
return msg;
|
|
299
|
+
}
|
|
300
|
+
get(sessionId) {
|
|
301
|
+
return this.messages.get(sessionId);
|
|
302
|
+
}
|
|
303
|
+
has(sessionId) {
|
|
304
|
+
return this.messages.has(sessionId);
|
|
305
|
+
}
|
|
306
|
+
/** Finalize and remove a session's message and plan ref. */
|
|
307
|
+
async finalize(sessionId) {
|
|
308
|
+
const msg = this.messages.get(sessionId);
|
|
309
|
+
if (!msg)
|
|
310
|
+
return null;
|
|
311
|
+
this.messages.delete(sessionId);
|
|
312
|
+
this.planRefs.delete(sessionId);
|
|
313
|
+
return msg.finalize();
|
|
314
|
+
}
|
|
315
|
+
/** Get or set the plan message ref for a session. */
|
|
316
|
+
getPlanRef(sessionId) {
|
|
317
|
+
return this.planRefs.get(sessionId);
|
|
318
|
+
}
|
|
319
|
+
setPlanRef(sessionId, ref) {
|
|
320
|
+
this.planRefs.set(sessionId, ref);
|
|
321
|
+
}
|
|
322
|
+
cleanup(sessionId) {
|
|
323
|
+
const msg = this.messages.get(sessionId);
|
|
324
|
+
if (msg) {
|
|
325
|
+
msg.finalize().catch(() => { });
|
|
326
|
+
}
|
|
327
|
+
this.messages.delete(sessionId);
|
|
328
|
+
this.planRefs.delete(sessionId);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
//# sourceMappingURL=message-composer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-composer.js","sourceRoot":"","sources":["../src/message-composer.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,GAAG,EAAE,MAAM,qBAAqB,CAAC;AAE1C,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAG3C,MAAM,eAAe,GAAG,MAAM,CAAC;AAC/B,MAAM,aAAa,GAAG,MAAM,CAAC;AAU7B,uEAAuE;AACvE,SAAS,cAAc,CAAC,IAAY;IAClC,OAAO,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;AACpC,CAAC;AAED;;;GAGG;AACH,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,CAAC,OAAO,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;AAClD,CAAC;AAED;;GAEG;AACH,MAAM,OAAO,cAAc;IAUf;IACA;IACA;IACA;IACA;IAbF,KAAK,GAAkB,IAAI,CAAC;IAC5B,MAAM,GAAkB,IAAI,CAAC;IAC7B,IAAI,GAAG,EAAE,CAAC;IACV,MAAM,GAAkB,IAAI,CAAC;IAC7B,GAAG,GAAsB,IAAI,CAAC;IAC9B,QAAQ,GAAG,EAAE,CAAC;IACd,UAAU,CAAiC;IAEnD,YACU,OAAoB,EACpB,cAAsB,EACtB,SAAiB,EACjB,WAAoC,EACpC,eAAgC;QAJhC,YAAO,GAAP,OAAO,CAAa;QACpB,mBAAc,GAAd,cAAc,CAAQ;QACtB,cAAS,GAAT,SAAS,CAAQ;QACjB,gBAAW,GAAX,WAAW,CAAyB;QACpC,oBAAe,GAAf,eAAe,CAAiB;IACvC,CAAC;IAEJ,aAAa,CAAC,OAAoB;QAChC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IACzB,CAAC;IAED,MAAM;QACJ,OAAO,IAAI,CAAC,GAAG,CAAC;IAClB,CAAC;IAED,OAAO;QACL,OAAO,IAAI,CAAC,IAAI,CAAC;IACnB,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,MAAM,CAAC;IACrB,CAAC;IAED,kEAAkE;IAClE,QAAQ,CAAC,IAAY;QACnB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QAClB,IAAI,CAAC,YAAY,EAAE,CAAC;IACtB,CAAC;IAED,6EAA6E;IACrE,MAAM,CAAU,iBAAiB,GAAG,GAAG,CAAC;IAEhD,+DAA+D;IAC/D,SAAS,CAAC,IAAY;QACpB,uFAAuF;QACvF,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACtC,IAAI,CAAC,MAAM,GAAG,SAAS,CAAC,MAAM,GAAG,cAAc,CAAC,iBAAiB;YAC/D,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,cAAc,CAAC,iBAAiB,CAAC,GAAG,KAAK;YAC9D,CAAC,CAAC,SAAS,CAAC;QACd,IAAI,CAAC,YAAY,EAAE,CAAC;IACtB,CAAC;IAED,uDAAuD;IACvD,WAAW;QACT,IAAI,IAAI,CAAC,MAAM,KAAK,IAAI;YAAE,OAAO;QACjC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,IAAI,CAAC,YAAY,EAAE,CAAC;IACtB,CAAC;IAED,uDAAuD;IACvD,UAAU,CAAC,IAAY;QACrB,IAAI,CAAC,IAAI;YAAE,OAAO;QAClB,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC;QAClB,IAAI,CAAC,eAAe,EAAE,CAAC;QAEvB,gCAAgC;QAChC,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,eAAe,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;YACnD,IAAI,CAAC,KAAK,EAAE,CAAC;YACb,OAAO;QACT,CAAC;QAED,IAAI,CAAC,YAAY,EAAE,CAAC;IACtB,CAAC;IAED,qDAAqD;IACrD,SAAS,CAAC,IAAY;QACpB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,IAAI,CAAC,YAAY,EAAE,CAAC;IACtB,CAAC;IAED,iFAAiF;IACjF,YAAY,CAAC,IAAY;QACvB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,MAAM,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;QAC9D,IAAI,CAAC,YAAY,EAAE,CAAC;IACtB,CAAC;IAED;;;;OAIG;IACK,MAAM,CAAC,eAAe,CAAC,IAAY;QACzC,MAAM,UAAU,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC;QACvD,IAAI,UAAU,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YACzB,OAAO,IAAI,GAAG,OAAO,CAAC;QACxB,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,4DAA4D;IAC5D,OAAO;QACL,MAAM,KAAK,GAAa,EAAE,CAAC;QAE3B,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YACf,KAAK,CAAC,IAAI,CAAC,KAAK,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAClD,CAAC;QAED,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAChB,KAAK,CAAC,IAAI,CAAC,IAAI,cAAc,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACjD,CAAC;QAED,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAC9B,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACpB,CAAC;QAED,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;YACd,KAAK,CAAC,IAAI,CAAC,cAAc,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;QACxD,CAAC;QAED,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAChB,IAAI,IAAI,CAAC,IAAI;gBAAE,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACjC,KAAK,CAAC,IAAI,CAAC,IAAI,cAAc,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACjD,CAAC;QAED,OAAO,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC5B,CAAC;IAED,gDAAgD;IAChD,YAAY;QACV,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAChC,IAAI,CAAC,QAAQ;YAAE,OAAO;QACtB,IAAI,QAAQ,KAAK,IAAI,CAAC,QAAQ;YAAE,OAAO;QAEvC,gEAAgE;QAChE,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,OAAO,IAAI,CAAC,SAAS,EAAE,CAAC;QACjF,IAAI,CAAC,WAAW,CAAC,OAAO,CACtB,IAAI,CAAC,cAAc,EACnB,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAClB,GAAG,CACJ,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;YACd,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,EAAE,+BAA+B,CAAC,CAAC;QAChF,CAAC,CAAC,CAAC;IACL,CAAC;IAEO,KAAK,CAAC,KAAK;QACjB,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAChC,IAAI,CAAC,QAAQ,IAAI,QAAQ,KAAK,IAAI,CAAC,QAAQ;YAAE,OAAO;QAEpD,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;YACd,kCAAkC;YAClC,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAgC,CAAC;YACrF,IAAI,MAAM,EAAE,EAAE,EAAE,CAAC;gBACf,IAAI,CAAC,GAAG,GAAG;oBACT,UAAU,EAAE,MAAM,CAAC,EAAE;oBACrB,cAAc,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,EAAY;oBAChE,UAAU,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,UAAoB;iBACvD,CAAC;YACJ,CAAC;YACD,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QAC3B,CAAC;aAAM,CAAC;YACN,mCAAmC;YACnC,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;YACnD,IAAI,OAAO,EAAE,CAAC;gBACZ,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;YAC3B,CAAC;QACH,CAAC;QAED,sFAAsF;QACtF,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAC/B,IAAI,OAAO,IAAI,OAAO,KAAK,IAAI,CAAC,QAAQ,EAAE,CAAC;YACzC,IAAI,CAAC,YAAY,EAAE,CAAC;QACtB,CAAC;IACH,CAAC;IAEO,KAAK,CAAC,aAAa,CAAC,IAAY;QACtC,IAAI,CAAC,IAAI,CAAC,GAAG;YAAE,OAAO,KAAK,CAAC;QAC5B,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,eAAe,EAAE,CAAC;QAC3C,IAAI,CAAC,KAAK;YAAE,OAAO,KAAK,CAAC;QAEzB,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,qBAAqB,kBAAkB,CAAC,IAAI,CAAC,GAAG,CAAC,cAAc,CAAC,eAAe,kBAAkB,CAAC,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC;QAE3J,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;gBAChC,MAAM,EAAE,KAAK;gBACb,OAAO,EAAE;oBACP,cAAc,EAAE,kBAAkB;oBAClC,eAAe,EAAE,UAAU,KAAK,EAAE;iBACnC;gBACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;oBACnB,IAAI,EAAE,SAAS;oBACf,IAAI,EAAE,aAAa,CAAC,IAAI,CAAC;oBACzB,UAAU,EAAE,UAAU;iBACvB,CAAC;aACH,CAAC,CAAC;YAEH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,EAAE,qCAAqC,CAAC,CAAC;gBACxG,OAAO,KAAK,CAAC;YACf,CAAC;YACD,OAAO,IAAI,CAAC;QACd,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,EAAE,oCAAoC,CAAC,CAAC;YACnF,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC;IAED,kEAAkE;IAC1D,KAAK;QACX,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,eAAe,CAAC,CAAC;QACtD,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;QAElD,GAAG,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,SAAS,CAAC,MAAM,EAAE,WAAW,EAAE,QAAQ,CAAC,MAAM,EAAE,EAAE,4BAA4B,CAAC,CAAC;QAEhI,yEAAyE;QACzE,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,IAAI,CAAC,IAAI,GAAG,SAAS,CAAC;QAEtB,6DAA6D;QAC7D,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAChC,IAAI,IAAI,CAAC,GAAG,IAAI,QAAQ,KAAK,IAAI,CAAC,QAAQ,EAAE,CAAC;YAC3C,IAAI,CAAC,WAAW,CAAC,OAAO,CACtB,IAAI,CAAC,cAAc,EACnB,GAAG,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,EACjD,UAAU,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,CAChC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QACpB,CAAC;QAED,+DAA+D;QAC/D,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC;QAChB,IAAI,CAAC,QAAQ,GAAG,EAAE,CAAC;QACnB,IAAI,CAAC,IAAI,GAAG,QAAQ,CAAC;QAErB,IAAI,QAAQ,EAAE,CAAC;YACb,IAAI,CAAC,YAAY,EAAE,CAAC;QACtB,CAAC;IACH,CAAC;IAEO,eAAe;QACrB,IAAI,IAAI,CAAC,UAAU;YAAE,YAAY,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACnD,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC,GAAG,EAAE;YAChC,IAAI,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;gBAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,EAAE,wDAAwD,CAAC,CAAC;gBAClG,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;gBACnB,2CAA2C;gBAC3C,IAAI,CAAC,UAAU,CAAC,4HAA4H,CAAC,CAAC;YAChJ,CAAC;QACH,CAAC,EAAE,aAAa,CAAC,CAAC;QAClB,IAAI,IAAI,CAAC,UAAU,CAAC,KAAK;YAAE,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;IACrD,CAAC;IAED,4EAA4E;IAC5E,KAAK,CAAC,QAAQ;QACZ,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YACpB,YAAY,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YAC9B,IAAI,CAAC,UAAU,GAAG,SAAS,CAAC;QAC9B,CAAC;QAED,+DAA+D;QAC/D,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QAEnB,cAAc;QACd,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAChC,IAAI,QAAQ,IAAI,QAAQ,KAAK,IAAI,CAAC,QAAQ,EAAE,CAAC;YAC3C,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;gBACd,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAgC,CAAC;gBACrF,IAAI,MAAM,EAAE,EAAE,EAAE,CAAC;oBACf,IAAI,CAAC,GAAG,GAAG;wBACT,UAAU,EAAE,MAAM,CAAC,EAAE;wBACrB,cAAc,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,EAAY;wBAChE,UAAU,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,UAAoB;qBACvD,CAAC;gBACJ,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;YACrC,CAAC;YACD,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QAC3B,CAAC;QAED,OAAO,IAAI,CAAC,GAAG,CAAC;IAClB,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,OAAe;QAChC,IAAI,CAAC,IAAI,CAAC,IAAI;YAAE,OAAO;QACvB,IAAI,CAAC;YACH,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACpD,CAAC;QAAC,MAAM,CAAC,CAAC,qBAAqB,CAAC,CAAC;IACnC,CAAC;;AAGH;;GAEG;AACH,MAAM,OAAO,qBAAqB;IAKtB;IACA;IALF,QAAQ,GAAG,IAAI,GAAG,EAA0B,CAAC;IAC7C,QAAQ,GAAG,IAAI,GAAG,EAAsB,CAAC;IAEjD,YACU,WAAoC,EACpC,eAAgC;QADhC,gBAAW,GAAX,WAAW,CAAyB;QACpC,oBAAe,GAAf,eAAe,CAAiB;IACvC,CAAC;IAEJ,WAAW,CAAC,SAAiB,EAAE,OAAoB;QACjD,IAAI,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACvC,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,MAAM,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,EAAY,CAAC;YACnE,GAAG,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,eAAe,CAAC,CAAC;YACrG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;QACpC,CAAC;aAAM,CAAC;YACN,GAAG,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QAC7B,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAED,GAAG,CAAC,SAAiB;QACnB,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACtC,CAAC;IAED,GAAG,CAAC,SAAiB;QACnB,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACtC,CAAC;IAED,4DAA4D;IAC5D,KAAK,CAAC,QAAQ,CAAC,SAAiB;QAC9B,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACzC,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,CAAC;QACtB,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAChC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAChC,OAAO,GAAG,CAAC,QAAQ,EAAE,CAAC;IACxB,CAAC;IAED,qDAAqD;IACrD,UAAU,CAAC,SAAiB;QAC1B,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACtC,CAAC;IAED,UAAU,CAAC,SAAiB,EAAE,GAAe;QAC3C,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;IACpC,CAAC;IAED,OAAO,CAAC,SAAiB;QACvB,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACzC,IAAI,GAAG,EAAE,CAAC;YACR,GAAG,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QACjC,CAAC;QACD,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAChC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IAClC,CAAC;CACF"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages per-conversation rate-limited queues.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const limiter = new ConversationRateLimiter();
|
|
6
|
+
* await limiter.enqueue(conversationId, () => sendText(ctx, text), "main-msg");
|
|
7
|
+
*/
|
|
8
|
+
export declare class ConversationRateLimiter {
|
|
9
|
+
private queues;
|
|
10
|
+
/** Enqueue an operation for a conversation. Key enables coalescing. */
|
|
11
|
+
enqueue<T>(conversationId: string, fn: () => Promise<T>, key?: string): Promise<T | undefined>;
|
|
12
|
+
/** Clean up a conversation's queue (e.g., on session end). */
|
|
13
|
+
cleanup(conversationId: string): void;
|
|
14
|
+
/** Clean up all queues. */
|
|
15
|
+
destroy(): void;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=rate-limiter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../src/rate-limiter.ts"],"names":[],"mappings":"AAqLA;;;;;;GAMG;AACH,qBAAa,uBAAuB;IAClC,OAAO,CAAC,MAAM,CAAwC;IAEtD,uEAAuE;IACvE,OAAO,CAAC,CAAC,EAAE,cAAc,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;IAS9F,8DAA8D;IAC9D,OAAO,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI;IAQrC,2BAA2B;IAC3B,OAAO,IAAI,IAAI;CAIhB"}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-conversation rate limiter for Teams Bot Framework API.
|
|
3
|
+
*
|
|
4
|
+
* Teams enforces rate limits per bot per conversation thread:
|
|
5
|
+
* 7 ops/1s, 8 ops/2s, 60 ops/30s, 1800 ops/3600s
|
|
6
|
+
*
|
|
7
|
+
* This limiter tracks sliding windows and queues operations when any
|
|
8
|
+
* window would be exceeded. Supports coalescing — queued operations
|
|
9
|
+
* with the same key replace earlier ones (e.g., rapid message edits
|
|
10
|
+
* collapse into a single PUT).
|
|
11
|
+
*/
|
|
12
|
+
import { log } from "@openacp/plugin-sdk";
|
|
13
|
+
/** Our targets — slightly under Teams limits to leave headroom. */
|
|
14
|
+
const RATE_WINDOWS = [
|
|
15
|
+
{ windowMs: 1_000, max: 6 },
|
|
16
|
+
{ windowMs: 2_000, max: 7 },
|
|
17
|
+
{ windowMs: 30_000, max: 55 },
|
|
18
|
+
{ windowMs: 3_600_000, max: 1_700 },
|
|
19
|
+
];
|
|
20
|
+
/** Per-conversation queue state. */
|
|
21
|
+
class ConversationQueue {
|
|
22
|
+
/** Timestamps of completed operations (for sliding window tracking). */
|
|
23
|
+
timestamps = [];
|
|
24
|
+
queue = [];
|
|
25
|
+
draining = false;
|
|
26
|
+
destroyed = false;
|
|
27
|
+
pausedUntil = 0;
|
|
28
|
+
drainTimer;
|
|
29
|
+
/** Resolves the inner drain-wait promise on destroy, preventing coroutine leak. */
|
|
30
|
+
drainWaitResolve;
|
|
31
|
+
/** Enqueue an operation. If key matches a pending op, replace it (coalescing). */
|
|
32
|
+
enqueue(fn, key) {
|
|
33
|
+
if (this.destroyed)
|
|
34
|
+
return Promise.resolve(undefined);
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
if (key) {
|
|
37
|
+
const idx = this.queue.findIndex((op) => op.key === key);
|
|
38
|
+
if (idx !== -1) {
|
|
39
|
+
// Coalesce: resolve the old op as undefined (skipped), replace with new
|
|
40
|
+
this.queue[idx].resolve(undefined);
|
|
41
|
+
this.queue[idx] = { fn, key, resolve: resolve, reject };
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
this.queue.push({ fn, key, resolve: resolve, reject });
|
|
46
|
+
this.scheduleDrain();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
get pending() {
|
|
50
|
+
return this.queue.length;
|
|
51
|
+
}
|
|
52
|
+
destroy() {
|
|
53
|
+
this.destroyed = true;
|
|
54
|
+
if (this.drainTimer) {
|
|
55
|
+
clearTimeout(this.drainTimer);
|
|
56
|
+
this.drainTimer = undefined;
|
|
57
|
+
}
|
|
58
|
+
// Unblock any suspended drain-wait so the coroutine can exit
|
|
59
|
+
if (this.drainWaitResolve) {
|
|
60
|
+
this.drainWaitResolve();
|
|
61
|
+
this.drainWaitResolve = undefined;
|
|
62
|
+
}
|
|
63
|
+
// Resolve all pending as undefined
|
|
64
|
+
for (const op of this.queue)
|
|
65
|
+
op.resolve(undefined);
|
|
66
|
+
this.queue.length = 0;
|
|
67
|
+
}
|
|
68
|
+
scheduleDrain() {
|
|
69
|
+
if (this.draining)
|
|
70
|
+
return;
|
|
71
|
+
if (this.drainTimer)
|
|
72
|
+
return;
|
|
73
|
+
const delay = this.getDelay();
|
|
74
|
+
this.drainTimer = setTimeout(() => {
|
|
75
|
+
this.drainTimer = undefined;
|
|
76
|
+
this.drain();
|
|
77
|
+
}, delay);
|
|
78
|
+
}
|
|
79
|
+
/** Calculate how long to wait before next op is allowed. */
|
|
80
|
+
getDelay() {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
// Respect 429 pause
|
|
83
|
+
if (now < this.pausedUntil) {
|
|
84
|
+
return this.pausedUntil - now;
|
|
85
|
+
}
|
|
86
|
+
let maxDelay = 0;
|
|
87
|
+
for (const { windowMs, max } of RATE_WINDOWS) {
|
|
88
|
+
const cutoff = now - windowMs;
|
|
89
|
+
// Timestamps are in ascending order — find the first one in the window
|
|
90
|
+
const firstIdx = this.timestamps.findIndex((t) => t > cutoff);
|
|
91
|
+
if (firstIdx === -1)
|
|
92
|
+
continue;
|
|
93
|
+
const opsInWindow = this.timestamps.length - firstIdx;
|
|
94
|
+
if (opsInWindow >= max) {
|
|
95
|
+
const oldest = this.timestamps[firstIdx];
|
|
96
|
+
const wait = oldest + windowMs - now + 1;
|
|
97
|
+
maxDelay = Math.max(maxDelay, wait);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return maxDelay;
|
|
101
|
+
}
|
|
102
|
+
async drain() {
|
|
103
|
+
if (this.draining)
|
|
104
|
+
return;
|
|
105
|
+
this.draining = true;
|
|
106
|
+
try {
|
|
107
|
+
while (this.queue.length > 0 && !this.destroyed) {
|
|
108
|
+
const delay = this.getDelay();
|
|
109
|
+
if (delay > 0) {
|
|
110
|
+
await new Promise((r) => {
|
|
111
|
+
this.drainWaitResolve = r;
|
|
112
|
+
this.drainTimer = setTimeout(() => {
|
|
113
|
+
this.drainTimer = undefined;
|
|
114
|
+
this.drainWaitResolve = undefined;
|
|
115
|
+
r();
|
|
116
|
+
}, delay);
|
|
117
|
+
});
|
|
118
|
+
// Check if destroyed while waiting
|
|
119
|
+
if (this.destroyed)
|
|
120
|
+
break;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const op = this.queue.shift();
|
|
124
|
+
try {
|
|
125
|
+
const result = await op.fn();
|
|
126
|
+
// Record timestamp only on success — 429s should not consume quota
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
this.timestamps.push(now);
|
|
129
|
+
this.pruneTimestamps(now);
|
|
130
|
+
op.resolve(result);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
const statusCode = err?.statusCode;
|
|
134
|
+
if (statusCode === 429) {
|
|
135
|
+
// Do NOT record a timestamp — the call was rejected
|
|
136
|
+
const retryAfterRaw = err?.headers?.["retry-after"];
|
|
137
|
+
const retryAfterSec = retryAfterRaw ? parseInt(retryAfterRaw, 10) : NaN;
|
|
138
|
+
const retryMs = !isNaN(retryAfterSec) && retryAfterSec > 0
|
|
139
|
+
? retryAfterSec * 1000
|
|
140
|
+
: 2000;
|
|
141
|
+
log.warn({ retryMs }, "[RateLimiter] 429 received, pausing");
|
|
142
|
+
this.pausedUntil = Date.now() + retryMs;
|
|
143
|
+
// Re-queue the failed op at the front
|
|
144
|
+
this.queue.unshift(op);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// Non-429 errors still consumed a slot
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
this.timestamps.push(now);
|
|
150
|
+
this.pruneTimestamps(now);
|
|
151
|
+
op.reject(err);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
this.draining = false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Remove timestamps older than the largest window. */
|
|
160
|
+
pruneTimestamps(now) {
|
|
161
|
+
const maxWindow = RATE_WINDOWS[RATE_WINDOWS.length - 1].windowMs;
|
|
162
|
+
const cutoff = now - maxWindow;
|
|
163
|
+
while (this.timestamps.length > 0 && this.timestamps[0] <= cutoff) {
|
|
164
|
+
this.timestamps.shift();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Manages per-conversation rate-limited queues.
|
|
170
|
+
*
|
|
171
|
+
* Usage:
|
|
172
|
+
* const limiter = new ConversationRateLimiter();
|
|
173
|
+
* await limiter.enqueue(conversationId, () => sendText(ctx, text), "main-msg");
|
|
174
|
+
*/
|
|
175
|
+
export class ConversationRateLimiter {
|
|
176
|
+
queues = new Map();
|
|
177
|
+
/** Enqueue an operation for a conversation. Key enables coalescing. */
|
|
178
|
+
enqueue(conversationId, fn, key) {
|
|
179
|
+
let queue = this.queues.get(conversationId);
|
|
180
|
+
if (!queue) {
|
|
181
|
+
queue = new ConversationQueue();
|
|
182
|
+
this.queues.set(conversationId, queue);
|
|
183
|
+
}
|
|
184
|
+
return queue.enqueue(fn, key);
|
|
185
|
+
}
|
|
186
|
+
/** Clean up a conversation's queue (e.g., on session end). */
|
|
187
|
+
cleanup(conversationId) {
|
|
188
|
+
const queue = this.queues.get(conversationId);
|
|
189
|
+
if (queue) {
|
|
190
|
+
queue.destroy();
|
|
191
|
+
this.queues.delete(conversationId);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Clean up all queues. */
|
|
195
|
+
destroy() {
|
|
196
|
+
for (const queue of this.queues.values())
|
|
197
|
+
queue.destroy();
|
|
198
|
+
this.queues.clear();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=rate-limiter.js.map
|