@segmentstream/agent 0.1.5 → 0.2.1
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 +21 -9
- package/dist/extension/instructions.md +6 -0
- package/dist/extension/lib/attachment-tools.d.ts +68 -0
- package/dist/extension/lib/attachment-tools.mjs +110 -0
- package/dist/extension/lib/attachments.d.ts +96 -0
- package/dist/extension/lib/attachments.mjs +233 -0
- package/dist/extension/lib/connection-proxy.d.ts +14 -1
- package/dist/extension/lib/connection-proxy.mjs +135 -39
- package/dist/extension/lib/telegram/adapter.d.ts +3 -0
- package/dist/extension/lib/telegram/adapter.mjs +52 -7
- package/dist/extension/lib/telegram/files.d.ts +25 -0
- package/dist/extension/lib/telegram/files.mjs +77 -0
- package/dist/extension/lib/telegram/inbound-request.d.ts +20 -1
- package/dist/extension/lib/telegram/inbound-request.mjs +29 -1
- package/dist/extension/lib/telegram/inbound.d.ts +12 -5
- package/dist/extension/lib/telegram/inbound.mjs +37 -10
- package/dist/extension/lib/telegram/observed.d.ts +29 -3
- package/dist/extension/lib/telegram/observed.mjs +48 -14
- package/dist/extension/lib/telegram/proxy-fetch.mjs +3 -2
- package/dist/extension/lib/telegram/router.d.ts +11 -3
- package/dist/extension/lib/telegram/router.mjs +89 -13
- package/dist/extension/lib/telegram/routing-context.d.ts +13 -0
- package/dist/extension/lib/telegram/routing-context.mjs +40 -0
- package/dist/extension/lib/telegram/sessions.d.ts +5 -0
- package/dist/extension/lib/telegram/silent.d.ts +2 -0
- package/dist/extension/lib/telegram/silent.mjs +30 -5
- package/dist/extension/lib/telegram/turn-errors.d.ts +9 -0
- package/dist/extension/lib/telegram/turn-errors.mjs +20 -0
- package/dist/extension/lib/telegram/voice.d.ts +2 -2
- package/dist/extension/lib/telegram/voice.mjs +2 -2
- package/dist/extension/tools/attachments_discard.d.ts +30 -0
- package/dist/extension/tools/attachments_discard.mjs +22 -0
- package/dist/extension/tools/attachments_list.d.ts +30 -0
- package/dist/extension/tools/attachments_list.mjs +25 -0
- package/dist/extension/tools/attachments_promote.d.ts +36 -0
- package/dist/extension/tools/attachments_promote.mjs +69 -0
- package/dist/extension/tools/attachments_read.d.ts +9 -0
- package/dist/extension/tools/attachments_read.mjs +43 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.mjs +5 -1
- package/package.json +1 -1
|
@@ -5,6 +5,8 @@ const __filename = __eveFileURLToPath(import.meta.url);
|
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
7
|
const MAXIMUM_PROXY_BODY_BYTES = 2097152;
|
|
8
|
+
const MAXIMUM_DOWNLOAD_BYTES = 20971520;
|
|
9
|
+
const PROXY_STREAM_MEDIA_TYPE = "application/vnd.segmentstream.proxy-stream.v1+ndjson";
|
|
8
10
|
const CALL_TIMEOUT_MS = 4e4;
|
|
9
11
|
var ConnectionProxyError = class extends Error {
|
|
10
12
|
name = "ConnectionProxyError";
|
|
@@ -55,59 +57,153 @@ function connectionProxy(input) {
|
|
|
55
57
|
const { environment, connection } = input;
|
|
56
58
|
const fetchImpl = input.fetch ?? fetch;
|
|
57
59
|
const url = `${`${environment.segmentstreamUrl}/api/internal/v1/workspaces/${environment.workspaceId}`}/ontology/connections/${encodeURIComponent(connection)}/request`;
|
|
60
|
+
async function send(call, accept) {
|
|
61
|
+
if (call.body.byteLength > 2097152) throw new ConnectionProxyError({
|
|
62
|
+
connection,
|
|
63
|
+
code: "invalid_request",
|
|
64
|
+
message: "The request body exceeds the proxy's 2 MiB limit."
|
|
65
|
+
});
|
|
66
|
+
const token = await input.token();
|
|
67
|
+
try {
|
|
68
|
+
return await fetchImpl(url, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
"x-segmentstream-runtime-identity": token,
|
|
72
|
+
"x-segmentstream-organization-id": environment.organizationId,
|
|
73
|
+
"content-type": "application/json",
|
|
74
|
+
accept
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
...call.api === void 0 ? {} : { api: call.api },
|
|
78
|
+
method: call.method,
|
|
79
|
+
path: call.path,
|
|
80
|
+
headers: call.headers,
|
|
81
|
+
body: Buffer.from(call.body).toString("base64")
|
|
82
|
+
}),
|
|
83
|
+
redirect: "error",
|
|
84
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS)
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new ConnectionProxyError({
|
|
88
|
+
connection,
|
|
89
|
+
code: error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "unavailable",
|
|
90
|
+
message: "SegmentStream's connection proxy could not be reached.",
|
|
91
|
+
cause: error
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function buffered(response) {
|
|
96
|
+
const text = await response.text();
|
|
97
|
+
let value;
|
|
98
|
+
try {
|
|
99
|
+
value = JSON.parse(text);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (response.ok) throw new ConnectionProxyError({
|
|
102
|
+
connection,
|
|
103
|
+
code: "invalid_response",
|
|
104
|
+
message: "The connection proxy answered with an invalid response.",
|
|
105
|
+
cause: error
|
|
106
|
+
});
|
|
107
|
+
throw refusal(connection, response.status, void 0, error);
|
|
108
|
+
}
|
|
109
|
+
if (!response.ok) throw refusal(connection, response.status, value);
|
|
110
|
+
return decodeReply(connection, value);
|
|
111
|
+
}
|
|
58
112
|
return {
|
|
59
113
|
connection,
|
|
60
114
|
async request(call) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
let response;
|
|
115
|
+
return buffered(await send(call, "application/json"));
|
|
116
|
+
},
|
|
117
|
+
async download(call, maxBytes = MAXIMUM_DOWNLOAD_BYTES) {
|
|
118
|
+
const response = await send(call, PROXY_STREAM_MEDIA_TYPE);
|
|
119
|
+
const type = response.headers.get("content-type") ?? "";
|
|
120
|
+
if (!response.ok || !type.startsWith("application/vnd.segmentstream.proxy-stream.v1+ndjson") || response.body === null) return buffered(response);
|
|
68
121
|
try {
|
|
69
|
-
|
|
70
|
-
method: "POST",
|
|
71
|
-
headers: {
|
|
72
|
-
"x-segmentstream-runtime-identity": token,
|
|
73
|
-
"x-segmentstream-organization-id": environment.organizationId,
|
|
74
|
-
"content-type": "application/json",
|
|
75
|
-
accept: "application/json"
|
|
76
|
-
},
|
|
77
|
-
body: JSON.stringify({
|
|
78
|
-
...call.api === void 0 ? {} : { api: call.api },
|
|
79
|
-
method: call.method,
|
|
80
|
-
path: call.path,
|
|
81
|
-
headers: call.headers,
|
|
82
|
-
body: Buffer.from(call.body).toString("base64")
|
|
83
|
-
}),
|
|
84
|
-
redirect: "error",
|
|
85
|
-
signal: AbortSignal.timeout(CALL_TIMEOUT_MS)
|
|
86
|
-
});
|
|
122
|
+
return await framedReply(connection, response.body, maxBytes);
|
|
87
123
|
} catch (error) {
|
|
124
|
+
if (error instanceof ConnectionProxyError) throw error;
|
|
88
125
|
throw new ConnectionProxyError({
|
|
89
126
|
connection,
|
|
90
127
|
code: error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "unavailable",
|
|
91
|
-
message: "SegmentStream's connection proxy
|
|
128
|
+
message: "SegmentStream's connection proxy stopped relaying the response.",
|
|
92
129
|
cause: error
|
|
93
130
|
});
|
|
94
131
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async function framedReply(connection, body, maxBytes) {
|
|
136
|
+
const invalid = () => new ConnectionProxyError({
|
|
137
|
+
connection,
|
|
138
|
+
code: "invalid_response",
|
|
139
|
+
message: "The connection proxy answered with an invalid stream."
|
|
140
|
+
});
|
|
141
|
+
const reader = body.getReader();
|
|
142
|
+
const decoder = new TextDecoder();
|
|
143
|
+
const chunks = [];
|
|
144
|
+
let head;
|
|
145
|
+
let size = 0;
|
|
146
|
+
let pending = "";
|
|
147
|
+
let ended = false;
|
|
148
|
+
try {
|
|
149
|
+
while (!ended) {
|
|
150
|
+
const next = await reader.read();
|
|
151
|
+
if (next.done) break;
|
|
152
|
+
pending += decoder.decode(next.value, { stream: true });
|
|
153
|
+
for (let newline = pending.indexOf("\n"); newline >= 0; newline = pending.indexOf("\n")) {
|
|
154
|
+
const line = pending.slice(0, newline);
|
|
155
|
+
pending = pending.slice(newline + 1);
|
|
156
|
+
if (line.trim() === "") continue;
|
|
157
|
+
let frame;
|
|
158
|
+
try {
|
|
159
|
+
frame = JSON.parse(line);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new ConnectionProxyError({
|
|
162
|
+
connection,
|
|
163
|
+
code: "invalid_response",
|
|
164
|
+
message: "The connection proxy answered with an invalid stream.",
|
|
165
|
+
cause: error
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (!isRecord(frame)) throw invalid();
|
|
169
|
+
if (head === void 0) {
|
|
170
|
+
if (typeof frame.statusCode !== "number" || !isHeaderPairs(frame.headers)) throw invalid();
|
|
171
|
+
head = {
|
|
172
|
+
status: frame.statusCode,
|
|
173
|
+
headers: frame.headers
|
|
174
|
+
};
|
|
175
|
+
} else if (typeof frame.data === "string") {
|
|
176
|
+
const chunk = Buffer.from(frame.data, "base64");
|
|
177
|
+
size += chunk.byteLength;
|
|
178
|
+
if (size > maxBytes) throw new ConnectionProxyError({
|
|
179
|
+
connection,
|
|
180
|
+
code: "too_large",
|
|
181
|
+
message: `The response exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MiB download limit.`
|
|
182
|
+
});
|
|
183
|
+
chunks.push(chunk);
|
|
184
|
+
} else if (frame.end === true) {
|
|
185
|
+
ended = true;
|
|
186
|
+
break;
|
|
187
|
+
} else if (typeof frame.error === "string") throw new ConnectionProxyError({
|
|
101
188
|
connection,
|
|
102
|
-
code:
|
|
103
|
-
message: "
|
|
104
|
-
cause: error
|
|
189
|
+
code: frame.error,
|
|
190
|
+
message: "SegmentStream's connection proxy failed while relaying the response."
|
|
105
191
|
});
|
|
106
|
-
throw
|
|
192
|
+
else throw invalid();
|
|
107
193
|
}
|
|
108
|
-
if (!response.ok) throw refusal(connection, response.status, value);
|
|
109
|
-
return decodeReply(connection, value);
|
|
110
194
|
}
|
|
195
|
+
} finally {
|
|
196
|
+
await reader.cancel().catch(() => void 0);
|
|
197
|
+
}
|
|
198
|
+
if (head === void 0 || !ended) throw new ConnectionProxyError({
|
|
199
|
+
connection,
|
|
200
|
+
code: "unavailable",
|
|
201
|
+
message: "The connection proxy's stream ended before the response did."
|
|
202
|
+
});
|
|
203
|
+
return {
|
|
204
|
+
status: head.status,
|
|
205
|
+
headers: head.headers,
|
|
206
|
+
body: new Uint8Array(Buffer.concat(chunks))
|
|
111
207
|
};
|
|
112
208
|
}
|
|
113
|
-
export { ConnectionProxyError, MAXIMUM_PROXY_BODY_BYTES, connectionProxy };
|
|
209
|
+
export { ConnectionProxyError, MAXIMUM_DOWNLOAD_BYTES, MAXIMUM_PROXY_BODY_BYTES, PROXY_STREAM_MEDIA_TYPE, connectionProxy };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type TelegramChannel } from "eve/channels/telegram";
|
|
2
2
|
import type { SessionSettings, TelegramSettings } from "../../config.js";
|
|
3
|
+
import { type AgentAttachments } from "../attachments.js";
|
|
3
4
|
import { type Environment } from "../environment.js";
|
|
4
5
|
import { type RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
5
6
|
import { type RuntimeToken } from "../runtime-token.js";
|
|
@@ -32,6 +33,8 @@ export interface TelegramAdapterOptions {
|
|
|
32
33
|
readonly verify?: RuntimeTokenVerifier;
|
|
33
34
|
/** Reads the tail of a session's durable stream; tests pass a fake. */
|
|
34
35
|
readonly history?: HistoryReader;
|
|
36
|
+
/** The attachment store; tests pass a fake. */
|
|
37
|
+
readonly attachments?: AgentAttachments;
|
|
35
38
|
}
|
|
36
39
|
/** The Telegram channel of the extension. */
|
|
37
40
|
export declare function segmentstreamTelegram(options: TelegramAdapterOptions): TelegramChannel;
|
|
@@ -5,21 +5,23 @@ const __filename = __eveFileURLToPath(import.meta.url);
|
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
7
|
import { appOrigin, channelsEnabled, proxyEnvironment, runtimeIdentityEnvironment, webhookSecret } from "../environment.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { MAXIMUM_DOWNLOAD_BYTES, connectionProxy } from "../connection-proxy.mjs";
|
|
9
|
+
import { runtimeToken } from "../runtime-token.mjs";
|
|
10
|
+
import { agentAttachments, parseAttachmentUrl } from "../attachments.mjs";
|
|
9
11
|
import { agentIdentities } from "../identities.mjs";
|
|
10
12
|
import { runtimeTokenVerifier } from "../runtime-identity.mjs";
|
|
11
|
-
import { runtimeToken } from "../runtime-token.mjs";
|
|
12
13
|
import { botIdentityCache } from "./bot.mjs";
|
|
13
14
|
import { agentChats } from "./chats.mjs";
|
|
14
15
|
import { observedMessages } from "./observed.mjs";
|
|
15
16
|
import { decisionRouter } from "./router.mjs";
|
|
17
|
+
import { replyDelivery } from "./silent.mjs";
|
|
16
18
|
import { scopedAddress, sessionPolicy } from "./sessions.mjs";
|
|
17
19
|
import { downloadTelegramFile, gatewayTranscriber } from "./voice.mjs";
|
|
18
20
|
import { handleInboundMessage } from "./inbound.mjs";
|
|
19
21
|
import { InboundRequest, currentInboundRequest, runInboundRequest, withReplacedText } from "./inbound-request.mjs";
|
|
20
22
|
import { TELEGRAM_PLACEHOLDER_TOKEN, TELEGRAM_PROXY_ORIGIN, telegramProxyFetch } from "./proxy-fetch.mjs";
|
|
21
23
|
import { messagesRoute } from "./requests.mjs";
|
|
22
|
-
import {
|
|
24
|
+
import { withTurnErrorLogging } from "./turn-errors.mjs";
|
|
23
25
|
import { telegramWebhookVerifier } from "./webhook.mjs";
|
|
24
26
|
import { telegramChannel } from "eve/channels/telegram";
|
|
25
27
|
const eveHistory = async (sessionId) => {
|
|
@@ -30,7 +32,11 @@ const eveHistory = async (sessionId) => {
|
|
|
30
32
|
follow: false
|
|
31
33
|
})) events.push({
|
|
32
34
|
type: event.type,
|
|
33
|
-
meta: { at: event.meta.at }
|
|
35
|
+
meta: { at: event.meta.at },
|
|
36
|
+
...event.type === "message.completed" ? { data: {
|
|
37
|
+
message: event.data.message,
|
|
38
|
+
finishReason: event.data.finishReason
|
|
39
|
+
} } : {}
|
|
34
40
|
});
|
|
35
41
|
return events;
|
|
36
42
|
};
|
|
@@ -38,9 +44,10 @@ function segmentstreamTelegram(options) {
|
|
|
38
44
|
const environment = options.environment ?? process.env;
|
|
39
45
|
const token = options.token ?? runtimeToken;
|
|
40
46
|
const observed = options.observed ?? observedMessages();
|
|
47
|
+
const readHistory = options.history ?? eveHistory;
|
|
41
48
|
const policy = sessionPolicy({
|
|
42
49
|
settings: options.sessions,
|
|
43
|
-
read:
|
|
50
|
+
read: readHistory
|
|
44
51
|
});
|
|
45
52
|
const bot = botIdentityCache();
|
|
46
53
|
const proxies = new Map();
|
|
@@ -74,6 +81,15 @@ function segmentstreamTelegram(options) {
|
|
|
74
81
|
return identities;
|
|
75
82
|
}
|
|
76
83
|
const chats = agentChats({ identities: pairing });
|
|
84
|
+
let store = options.attachments;
|
|
85
|
+
function attachments() {
|
|
86
|
+
store ??= agentAttachments({
|
|
87
|
+
origin: appOrigin(environment),
|
|
88
|
+
token,
|
|
89
|
+
...options.fetch === void 0 ? {} : { fetch: options.fetch }
|
|
90
|
+
});
|
|
91
|
+
return store;
|
|
92
|
+
}
|
|
77
93
|
const channel = telegramChannel({
|
|
78
94
|
api: {
|
|
79
95
|
apiBaseUrl: TELEGRAM_PROXY_ORIGIN,
|
|
@@ -84,7 +100,7 @@ function segmentstreamTelegram(options) {
|
|
|
84
100
|
botToken: TELEGRAM_PLACEHOLDER_TOKEN,
|
|
85
101
|
webhookVerifier: telegramWebhookVerifier(() => webhookSecret(environment))
|
|
86
102
|
},
|
|
87
|
-
uploadPolicy: { maxBytes:
|
|
103
|
+
uploadPolicy: { maxBytes: MAXIMUM_DOWNLOAD_BYTES },
|
|
88
104
|
onMessage: (context, message) => handleInboundMessage({
|
|
89
105
|
context,
|
|
90
106
|
message,
|
|
@@ -105,15 +121,40 @@ function segmentstreamTelegram(options) {
|
|
|
105
121
|
proxy: proxy(),
|
|
106
122
|
fileId
|
|
107
123
|
}),
|
|
124
|
+
attachments: attachments(),
|
|
108
125
|
observed,
|
|
126
|
+
readHistory,
|
|
109
127
|
chats,
|
|
110
128
|
connectUrl: (code) => `${appOrigin(environment)}/agent/connect?code=${encodeURIComponent(code)}`
|
|
111
129
|
}
|
|
112
130
|
}),
|
|
113
|
-
events: { "message.completed":
|
|
131
|
+
events: { "message.completed": replyDelivery(observed) }
|
|
114
132
|
});
|
|
133
|
+
const adapter = channel.adapter;
|
|
134
|
+
const fetchTelegramFile = adapter?.fetchFile;
|
|
135
|
+
const turnFailed = adapter?.["turn.failed"];
|
|
136
|
+
async function fetchFile(url, context) {
|
|
137
|
+
const reference = parseAttachmentUrl(url);
|
|
138
|
+
if (reference === void 0) return fetchTelegramFile === void 0 ? null : fetchTelegramFile(url, context);
|
|
139
|
+
const sessionId = sessionIdOf(context);
|
|
140
|
+
const { attachment, bytes } = await attachments().read({
|
|
141
|
+
memberId: reference.memberId,
|
|
142
|
+
conversation: reference.conversation,
|
|
143
|
+
...sessionId === void 0 ? {} : { sessionId }
|
|
144
|
+
}, reference.id);
|
|
145
|
+
return {
|
|
146
|
+
bytes: Buffer.from(bytes),
|
|
147
|
+
mediaType: attachment.contentType,
|
|
148
|
+
filename: attachment.filename
|
|
149
|
+
};
|
|
150
|
+
}
|
|
115
151
|
return {
|
|
116
152
|
...channel,
|
|
153
|
+
...adapter === void 0 ? {} : { adapter: {
|
|
154
|
+
...adapter,
|
|
155
|
+
fetchFile,
|
|
156
|
+
...turnFailed === void 0 ? {} : { "turn.failed": withTurnErrorLogging(turnFailed) }
|
|
157
|
+
} },
|
|
117
158
|
routes: [...channel.routes.map((route) => gatedRoute({
|
|
118
159
|
route,
|
|
119
160
|
options,
|
|
@@ -128,6 +169,10 @@ function segmentstreamTelegram(options) {
|
|
|
128
169
|
})]
|
|
129
170
|
};
|
|
130
171
|
}
|
|
172
|
+
function sessionIdOf(context) {
|
|
173
|
+
const session = typeof context === "object" && context !== null ? context.session : void 0;
|
|
174
|
+
return typeof session?.id === "string" && session.id !== "" ? session.id : void 0;
|
|
175
|
+
}
|
|
131
176
|
function gatedRoute(input) {
|
|
132
177
|
const { route, options, environment } = input;
|
|
133
178
|
if (route.transport === "websocket") return route;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TelegramMessage } from "eve/channels/telegram";
|
|
2
|
+
import { type AgentAttachments } from "../attachments.js";
|
|
3
|
+
import type { StagedFile } from "./inbound-request.js";
|
|
4
|
+
/**
|
|
5
|
+
* Photos and documents a member sends the agent (`docs/agent.md`, "Attachments"). Before the turn
|
|
6
|
+
* starts, each file is downloaded through the connection and stored once as a temporary attachment
|
|
7
|
+
* of the member, in this chat, for 24 hours. The turn reads the stored copy, and the model learns
|
|
8
|
+
* each attachment's id from an `<attachments>` block, so that it can read, discard or promote it
|
|
9
|
+
* with the attachment tools. A file that cannot be stored is never passed on silently: the turn
|
|
10
|
+
* gets a note saying why.
|
|
11
|
+
*/
|
|
12
|
+
/** The chat an attachment belongs to: `telegram:<chat id>`. */
|
|
13
|
+
export declare function telegramConversation(chatId: string): string;
|
|
14
|
+
/** Stores the message's files one after another, so that a large album holds one file at a time. */
|
|
15
|
+
export declare function stageFiles(input: {
|
|
16
|
+
readonly message: TelegramMessage;
|
|
17
|
+
readonly memberId: string;
|
|
18
|
+
readonly attachments: AgentAttachments;
|
|
19
|
+
readonly download: (fileId: string) => Promise<Uint8Array>;
|
|
20
|
+
}): Promise<ReadonlyMap<string, StagedFile>>;
|
|
21
|
+
/** The `<attachments>` block that tells the model which attachments the message carries. */
|
|
22
|
+
export declare function attachmentsContext(input: {
|
|
23
|
+
readonly files: ReadonlyMap<string, StagedFile>;
|
|
24
|
+
readonly group: boolean;
|
|
25
|
+
}): ReadonlyArray<string>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { fileURLToPath as __eveFileURLToPath } from "node:url";
|
|
2
|
+
import { dirname as __eveDirname } from "node:path";
|
|
3
|
+
import { createRequire as __eveCreateRequire } from "node:module";
|
|
4
|
+
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
|
+
__eveDirname(__filename);
|
|
6
|
+
__eveCreateRequire(import.meta.url);
|
|
7
|
+
import { MAXIMUM_ATTACHMENT_BYTES, attachmentUrl } from "../attachments.mjs";
|
|
8
|
+
function telegramConversation(chatId) {
|
|
9
|
+
return `telegram:${chatId}`;
|
|
10
|
+
}
|
|
11
|
+
function described(file, index) {
|
|
12
|
+
return {
|
|
13
|
+
filename: file.fileName ?? (file.kind === "photo" ? `photo-${index}.jpg` : `file-${index}`),
|
|
14
|
+
contentType: file.mediaType ?? (file.kind === "photo" ? "image/jpeg" : "application/octet-stream")
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function megabytes(bytes) {
|
|
18
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
19
|
+
}
|
|
20
|
+
async function stageFiles(input) {
|
|
21
|
+
const { message } = input;
|
|
22
|
+
const conversation = telegramConversation(message.chat.id);
|
|
23
|
+
const staged = new Map();
|
|
24
|
+
for (const [index, file] of message.attachments.entries()) {
|
|
25
|
+
const { filename, contentType } = described(file, index);
|
|
26
|
+
if (file.size !== void 0 && file.size > MAXIMUM_ATTACHMENT_BYTES) {
|
|
27
|
+
staged.set(file.fileId, {
|
|
28
|
+
status: "refused",
|
|
29
|
+
filename,
|
|
30
|
+
reason: `it is ${megabytes(file.size)}, over the ${megabytes(MAXIMUM_ATTACHMENT_BYTES)} a bot may download`
|
|
31
|
+
});
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const bytes = await input.download(file.fileId);
|
|
36
|
+
const attachment = await input.attachments.receive({
|
|
37
|
+
memberId: input.memberId,
|
|
38
|
+
conversation
|
|
39
|
+
}, {
|
|
40
|
+
bytes,
|
|
41
|
+
filename,
|
|
42
|
+
contentType,
|
|
43
|
+
sourceRef: `${message.chat.id}:${message.messageId}:${index}`
|
|
44
|
+
});
|
|
45
|
+
staged.set(file.fileId, {
|
|
46
|
+
status: "stored",
|
|
47
|
+
attachment,
|
|
48
|
+
url: attachmentUrl({
|
|
49
|
+
id: attachment.id,
|
|
50
|
+
memberId: input.memberId,
|
|
51
|
+
conversation
|
|
52
|
+
})
|
|
53
|
+
});
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error("[segmentstream] attachment could not be stored", {
|
|
56
|
+
kind: file.kind,
|
|
57
|
+
size: file.size,
|
|
58
|
+
error
|
|
59
|
+
});
|
|
60
|
+
staged.set(file.fileId, {
|
|
61
|
+
status: "refused",
|
|
62
|
+
filename,
|
|
63
|
+
reason: error instanceof Error ? error.message : "it could not be stored"
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return staged;
|
|
68
|
+
}
|
|
69
|
+
function attachmentsContext(input) {
|
|
70
|
+
if (input.files.size === 0) return [];
|
|
71
|
+
const lines = ["<attachments>", `The member's files from this message, kept for 24 hours for this member in this chat only. Read, discard or promote them with the segmentstream__attachments_* tools and these ids; promote only what should be kept.`];
|
|
72
|
+
for (const staged of input.files.values()) lines.push(staged.status === "stored" ? `- id: ${staged.attachment.id}, filename: ${JSON.stringify(staged.attachment.filename)}, type: ${staged.attachment.contentType}, size: ${staged.attachment.size} bytes, expires_at: ${staged.attachment.expiresAt}` : `- ${JSON.stringify(staged.filename)}: not received, ${staged.reason}`);
|
|
73
|
+
if (input.group) lines.push("This is a group chat: everyone in it shares this conversation's history, so do not repeat a private file's contents here. Suggest sending private files in a direct message.");
|
|
74
|
+
lines.push("</attachments>");
|
|
75
|
+
return [lines.join("\n")];
|
|
76
|
+
}
|
|
77
|
+
export { attachmentsContext, stageFiles, telegramConversation };
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { ChannelSource } from "eve/channels";
|
|
2
|
+
import type { Attachment } from "../attachments.js";
|
|
2
3
|
import type { SessionOperations } from "./sessions.js";
|
|
4
|
+
/** What became of one Telegram file of the message: stored as an attachment, or why not. */
|
|
5
|
+
export type StagedFile = {
|
|
6
|
+
readonly status: "stored";
|
|
7
|
+
readonly attachment: Attachment;
|
|
8
|
+
readonly url: URL;
|
|
9
|
+
} | {
|
|
10
|
+
readonly status: "refused";
|
|
11
|
+
readonly filename: string;
|
|
12
|
+
readonly reason: string;
|
|
13
|
+
};
|
|
3
14
|
/**
|
|
4
15
|
* What one webhook request shares between eve's `onMessage` hook and the route wrapper around it.
|
|
5
16
|
* `onMessage` may only return `{ auth, context, title }`; it cannot change the message eve sends.
|
|
@@ -12,6 +23,11 @@ export declare class InboundRequest {
|
|
|
12
23
|
readonly agentDescription: () => Promise<string>;
|
|
13
24
|
/** The text the turn should carry instead of the parsed message, once a transcript exists. */
|
|
14
25
|
text: string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* The message's Telegram files by file id, once stored as attachments: the turn reads each from
|
|
28
|
+
* the attachment store instead of Telegram, and one that could not be stored becomes a note.
|
|
29
|
+
*/
|
|
30
|
+
files: ReadonlyMap<string, StagedFile> | undefined;
|
|
15
31
|
/** The route's session operations, on addresses already mapped to the configured scope. */
|
|
16
32
|
sessions: SessionOperations | undefined;
|
|
17
33
|
/** Keeps work the request started, such as a chat report, alive after it answers. */
|
|
@@ -23,5 +39,8 @@ export declare class InboundRequest {
|
|
|
23
39
|
export declare function runInboundRequest<Result>(request: InboundRequest, run: () => Result): Result;
|
|
24
40
|
/** The current webhook request, or `undefined` outside the wrapped route. */
|
|
25
41
|
export declare function currentInboundRequest(): InboundRequest | undefined;
|
|
26
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* A channel source whose `send` delivers the request's replacement text, when one was recorded,
|
|
44
|
+
* and its stored attachments in place of the Telegram files.
|
|
45
|
+
*/
|
|
27
46
|
export declare function withReplacedText<State>(source: ChannelSource<State>, request: InboundRequest): ChannelSource<State>;
|
|
@@ -8,6 +8,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
8
8
|
var InboundRequest = class {
|
|
9
9
|
agentDescription;
|
|
10
10
|
text;
|
|
11
|
+
files;
|
|
11
12
|
sessions;
|
|
12
13
|
waitUntil;
|
|
13
14
|
constructor(agentDescription) {
|
|
@@ -28,11 +29,38 @@ function replaceText(message, text) {
|
|
|
28
29
|
text
|
|
29
30
|
}, ...message.filter((part) => part.type !== "text")];
|
|
30
31
|
}
|
|
32
|
+
function telegramFileId(data) {
|
|
33
|
+
const href = data instanceof URL ? data.href : typeof data === "string" ? data : void 0;
|
|
34
|
+
if (href === void 0 || !href.startsWith("telegram-file:")) return;
|
|
35
|
+
try {
|
|
36
|
+
return decodeURIComponent(new URL(href).pathname);
|
|
37
|
+
} catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function replaceFiles(message, files) {
|
|
42
|
+
if (typeof message === "string") return message;
|
|
43
|
+
return message.map((part) => {
|
|
44
|
+
if (part.type !== "file") return part;
|
|
45
|
+
const fileId = telegramFileId(part.data);
|
|
46
|
+
const staged = fileId === void 0 ? void 0 : files.get(fileId);
|
|
47
|
+
if (staged === void 0) return part;
|
|
48
|
+
if (staged.status === "refused") return {
|
|
49
|
+
type: "text",
|
|
50
|
+
text: `[file ${staged.filename} was not received: ${staged.reason}]`
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
...part,
|
|
54
|
+
data: staged.url
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
31
58
|
function withReplacedText(source, request) {
|
|
32
59
|
return {
|
|
33
60
|
...source,
|
|
34
61
|
send(message, options) {
|
|
35
|
-
|
|
62
|
+
const text = request.text === void 0 ? message : replaceText(message, request.text);
|
|
63
|
+
return source.send(request.files === void 0 ? text : replaceFiles(text, request.files), options);
|
|
36
64
|
}
|
|
37
65
|
};
|
|
38
66
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { TelegramContext, TelegramInboundResult, TelegramMessage } from "eve/channels/telegram";
|
|
2
2
|
import type { SessionSettings, TelegramPolicy } from "../../config.js";
|
|
3
|
+
import type { AgentAttachments } from "../attachments.js";
|
|
3
4
|
import type { AgentIdentities } from "../identities.js";
|
|
4
5
|
import type { BotIdentityLookup } from "./bot.js";
|
|
5
6
|
import { type AgentChats } from "./chats.js";
|
|
6
7
|
import type { InboundRequest } from "./inbound-request.js";
|
|
7
8
|
import { type ObservedMessages } from "./observed.js";
|
|
8
9
|
import { type RouteDecider } from "./router.js";
|
|
9
|
-
import { type SessionPolicy } from "./sessions.js";
|
|
10
|
+
import { type HistoryReader, type SessionPolicy } from "./sessions.js";
|
|
10
11
|
import { type Transcriber } from "./voice.js";
|
|
11
12
|
/**
|
|
12
13
|
* eve's Telegram `onMessage` hook, in the order `agent-design.md` gives:
|
|
@@ -21,10 +22,13 @@ import { type Transcriber } from "./voice.js";
|
|
|
21
22
|
* 3. The router answers or observes. An observed message goes to the chat's ring and stops here.
|
|
22
23
|
* 4. The session policy runs on the session the message continues: an idle one is cleared, the
|
|
23
24
|
* first message after the day's compaction time compacts it.
|
|
24
|
-
* 5. An answered message
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* a
|
|
25
|
+
* 5. An answered message's photos and documents are stored as the member's temporary attachments,
|
|
26
|
+
* which the turn reads in place of the Telegram files.
|
|
27
|
+
* 6. An answered message dispatches as the paired member, with the ring, the registry's `<chat>`
|
|
28
|
+
* block, a `<speaker>` block naming the member, their roles and the message they replied to and
|
|
29
|
+
* an `<attachments>` block naming its stored files ahead of it as context. An answered group
|
|
30
|
+
* message waits for its chat's report first, so that a chat new to the process already carries
|
|
31
|
+
* its `<chat>` block.
|
|
28
32
|
*/
|
|
29
33
|
export interface InboundDependencies {
|
|
30
34
|
readonly policy: TelegramPolicy;
|
|
@@ -36,7 +40,10 @@ export interface InboundDependencies {
|
|
|
36
40
|
readonly decide: RouteDecider;
|
|
37
41
|
readonly transcribe: Transcriber;
|
|
38
42
|
readonly download: (fileId: string) => Promise<Uint8Array>;
|
|
43
|
+
/** The attachment store; without it, files reach the turn as eve's Telegram file parts. */
|
|
44
|
+
readonly attachments?: AgentAttachments;
|
|
39
45
|
readonly observed: ObservedMessages;
|
|
46
|
+
readonly readHistory: HistoryReader;
|
|
40
47
|
readonly chats: AgentChats;
|
|
41
48
|
/** The app's `/agent/connect` link for a pairing code. */
|
|
42
49
|
readonly connectUrl: (code: string) => string;
|