@segmentstream/agent 0.7.3 → 0.8.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 +96 -3
- package/dist/extension/channels/telegram.mjs +1 -0
- package/dist/extension/config.d.ts +38 -3
- package/dist/extension/config.mjs +125 -3
- package/dist/extension/extension.d.ts +1 -1
- package/dist/extension/extension.mjs +2 -2
- package/dist/extension/lib/admission.d.ts +13 -0
- package/dist/extension/lib/admission.mjs +42 -0
- package/dist/extension/lib/conversation-runtime.d.ts +1 -1
- package/dist/extension/lib/identities.d.ts +41 -0
- package/dist/extension/lib/identities.mjs +79 -11
- package/dist/extension/lib/ontology-connection.mjs +6 -2
- package/dist/extension/lib/ontology-tools.d.ts +2 -0
- package/dist/extension/lib/ontology-tools.mjs +3 -0
- package/dist/extension/lib/telegram/adapter.d.ts +2 -1
- package/dist/extension/lib/telegram/adapter.mjs +2 -1
- package/dist/extension/lib/telegram/conversations.mjs +47 -11
- package/dist/extension/lib/telegram/inbound.d.ts +2 -1
- package/dist/extension/lib/telegram/inbound.mjs +146 -46
- package/dist/extension/lib/telegram/principal.d.ts +2 -0
- package/dist/extension/lib/telegram/principal.mjs +6 -1
- package/dist/extension/lib/telegram/router.d.ts +1 -0
- package/dist/extension/lib/telegram/router.mjs +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ Both entry points export the same handle: `@segmentstream/agent` and
|
|
|
82
82
|
| Field | Default | Meaning |
|
|
83
83
|
| ---------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
84
84
|
| `channels.telegram.connection` | none | The Connect key whose credential is the bot token. Without `channels.telegram`, no Telegram. |
|
|
85
|
-
| `channels.telegram.policy` | `"smart"` | Classifies unaddressed group messages and captionless uploads;
|
|
85
|
+
| `channels.telegram.policy` | `"smart"` | Classifies unaddressed group messages and captionless uploads; `mention` observes unaddressed messages without decoding or inference |
|
|
86
86
|
| `channels.telegram.linkPreviews` | `external` | Suppress own-workspace previews and preview the first external link; opt into `all` for native previews or `none` to disable them. Caller options take precedence. |
|
|
87
87
|
| `channels.telegram.additionalOwnOrigins` | `[]` | Additional exact HTTP(S) workspace origins, including local ports; no paths or credentials. |
|
|
88
88
|
| `models.stt` | `"spacexai/grok-stt"` | The AI Gateway transcription model for voice messages; `false` leaves them untranscribed |
|
|
@@ -94,6 +94,99 @@ An unknown field or an invalid value fails the mount with `SegmentStreamAgentCon
|
|
|
94
94
|
each path. `manifestOf(config)` returns the normalized configuration, defaults applied, for the
|
|
95
95
|
platform's deployment manifest.
|
|
96
96
|
|
|
97
|
+
## Channel admission and first contact
|
|
98
|
+
|
|
99
|
+
The platform and SDK provide admission, identity resolution, onboarding execution, notice tracking
|
|
100
|
+
and durable limits. Workspace apps own channel policy, member grants, onboarding actions, notice
|
|
101
|
+
content and lower limit values. No channel defaults to open, and no notice or limit reply text
|
|
102
|
+
ships as a default. Existing invited-channel pairing and refusal replies remain unchanged.
|
|
103
|
+
|
|
104
|
+
Admission belongs in the ontology's `signIn.channels`; each unlisted provider stays invited,
|
|
105
|
+
independently of email `signIn.policy`. Opening a channel is an explicit workspace opt-in.
|
|
106
|
+
For an intentionally open provider, declare safe named `member.permissions`. Wildcards and
|
|
107
|
+
generated create/update/delete grants are rejected. Shared named reads need review; move staff
|
|
108
|
+
reads into a named role. Actions handling a member's own records enforce ownership with
|
|
109
|
+
`principal.id`. Groups never register users. Closing a channel stops new registrations;
|
|
110
|
+
block or deactivate an existing member to stop their access.
|
|
111
|
+
|
|
112
|
+
Configure app-owned text and lower limits in `agent/segmentstream.json`. These placeholders
|
|
113
|
+
represent text authored by the workspace, not SDK defaults:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"firstContact": {
|
|
118
|
+
"version": "1",
|
|
119
|
+
"notice": {
|
|
120
|
+
"en": "<workspace-authored first-contact notice>",
|
|
121
|
+
"ru": "<workspace-authored translated notice>"
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"limits": {
|
|
125
|
+
"perSender": { "turnsPerMinute": 4, "turnsPerDay": 40 },
|
|
126
|
+
"newUsersPerHour": 20,
|
|
127
|
+
"workspaceTurnsPerDay": 2000
|
|
128
|
+
},
|
|
129
|
+
"replies": {
|
|
130
|
+
"perSenderLimited": { "en": "<workspace-authored sender limit reply>" },
|
|
131
|
+
"workspaceLimited": { "en": "<workspace-authored workspace limit reply>" },
|
|
132
|
+
"registrationsLimited": { "en": "<workspace-authored registration limit reply>" }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
All three sections are optional. If present, `firstContact` requires a nonblank version of at most
|
|
138
|
+
200 characters, without surrounding whitespace, and a nonempty locale-to-text `notice` map.
|
|
139
|
+
Text values must be nonblank and at most 4000 characters; locale keys must be valid language tags.
|
|
140
|
+
Unknown settings, including the superseded `publicUsers` field, fail validation.
|
|
141
|
+
The sender's language code chooses the exact locale, then its base language, then `en`,
|
|
142
|
+
then the first entry. The same selection applies to configured replies. `replies.limited` is an
|
|
143
|
+
optional fallback for any missing scope-specific limit reply. Without either reply, limits silently
|
|
144
|
+
stop processing. `groupPairingRequired` and `refused` may override existing pairing/refusal copy.
|
|
145
|
+
|
|
146
|
+
Omitted limit fields use platform ceilings: 6 turns/minute and 80/day per sender, 30 new users/hour,
|
|
147
|
+
and 3000 workspace turns/day. Overrides must be positive integers at or below each ceiling.
|
|
148
|
+
The trusted runtime submits these effective limits; the ontology clamps and persists them for other
|
|
149
|
+
admission paths. Only self-registered users with exactly the member role consume sender/workspace
|
|
150
|
+
turn quotas, keyed by user id across identities. Paired-only users and holders of named roles
|
|
151
|
+
remain exempt. Every registration shares the hourly new-user cap. These settings grant no access.
|
|
152
|
+
|
|
153
|
+
Admission runs after cheap edit/service gates, before decoding, then before jev and chat.
|
|
154
|
+
Private messages with content and directly addressed group messages resolve with `turn: true`.
|
|
155
|
+
Other group messages first resolve with `turn: false`, including replies to the bot that address
|
|
156
|
+
another recipient. Mention-policy and other-recipient messages observe without counting,
|
|
157
|
+
downloading or decoding. A recipient-only reply to a recent pending question remains eligible
|
|
158
|
+
as answer data. Empty events remain uncounted even with a pending question.
|
|
159
|
+
Eligible senders obtain counted admission before transcription, attachment work, smart-routing
|
|
160
|
+
inference or pending-answer dispatch. Smart decisions
|
|
161
|
+
consume a turn even if they choose observation, and reuse it if they reply. Each message requests
|
|
162
|
+
counted admission at most once. No-op edits never resolve; caption-only edits reuse decoded
|
|
163
|
+
evidence without downloading the same attachments again. Deferred-card presses use GET lookup
|
|
164
|
+
and act-as authorization, never resolve or quotas: their initiating turn was already counted.
|
|
165
|
+
Limited private inbound events and replies retain the member's archive grant through an uncounted
|
|
166
|
+
binding lookup before the first persistence.
|
|
167
|
+
|
|
168
|
+
Resolver statuses are `existing`, `created`, `pairing_required`, `refused` (inactive/blocked)
|
|
169
|
+
and `limited`. A limit reply requires the server's `notify` flag and an addressed message or
|
|
170
|
+
chosen reply. Empty events get no turn or pairing reply. A configured due notice is sent only on
|
|
171
|
+
a private eligible turn, before processing; resolve carries its version and selected-text SHA-256,
|
|
172
|
+
and acknowledgement records the same version/hash after delivery. Version or selected-text changes
|
|
173
|
+
request delivery again. Failures retain diagnostics and retry on the next private turn.
|
|
174
|
+
Without a configured notice, the SDK sends and acknowledges nothing. Ontology check warns about
|
|
175
|
+
missing notice/limits when agent config is supplied; its CLI reports when that check is skipped.
|
|
176
|
+
|
|
177
|
+
An app may declare `signIn.onNewUser` as an idempotent member-runnable action. The ontology executes
|
|
178
|
+
it before returning an accepted eligible resolve, retries pending failures, and never delegates it
|
|
179
|
+
to the model. Handlers receive read-only `principal.identities[{provider, subject, username?, verified}]`,
|
|
180
|
+
`createdVia` and `authenticatedVia`. The SDK forwards verified provider provenance with the
|
|
181
|
+
member's act-as header. No identity facts contain credentials.
|
|
182
|
+
|
|
183
|
+
### Release order
|
|
184
|
+
|
|
185
|
+
Deploy backend-next's strict validator before publishing agent SDK 0.8.0. Upgrade the workspace
|
|
186
|
+
ontology to the v2 resolver/notice contract before its agent. A resolve-route HTTP 404 falls back
|
|
187
|
+
to the unchanged GET lookup and invited pairing; other failures stop admission. Older agents
|
|
188
|
+
continue using GET lookup. Starter templates and pins remain unchanged in this stage.
|
|
189
|
+
|
|
97
190
|
## What the extension contributes
|
|
98
191
|
|
|
99
192
|
| Name | Slot | What it does |
|
|
@@ -135,7 +228,7 @@ share a group chat's session never share a client.
|
|
|
135
228
|
preview never answers a real chat. Without `channels.telegram` it answers 404.
|
|
136
229
|
- **Pairing.** Bots and channel posts are archived without starting turns. The sender's Telegram
|
|
137
230
|
account is checked on every message, so revocation takes effect immediately. Unlinked group
|
|
138
|
-
speech
|
|
231
|
+
speech and attachment metadata remain raw context, including edits; no unlinked voice is decoded or gets
|
|
139
232
|
a reply. An unknown private sender gets a neutral connection button to `/agent/connect?code=…`,
|
|
140
233
|
valid for ten minutes; revoked or inactive private senders get a neutral refusal marker.
|
|
141
234
|
- **Who the agent is.** A paired sender's turn runs with the principal id `telegram:<user>`
|
|
@@ -144,7 +237,7 @@ share a group chat's session never share a client.
|
|
|
144
237
|
- **Routing.** Private chats and messages explicitly addressed to the bot retain direct admission.
|
|
145
238
|
Other group messages, including captionless attachments, go through one `decide` question with
|
|
146
239
|
the agent's purpose, recent conversation, open questions and confirmation state. The legacy
|
|
147
|
-
`mention` policy
|
|
240
|
+
`mention` policy observes unaddressed messages without paid work. Attachments never force a turn.
|
|
148
241
|
Human reply edges and other recipients are untrusted context for inference. Failed decisions
|
|
149
242
|
observe. Unanswered messages join the bounded backlog; answered messages remain in recent
|
|
150
243
|
shared context without rejoining that backlog on retries. Decision events retain the reason,
|
|
@@ -12,6 +12,7 @@ var telegram_default = segmentstreamTelegram({
|
|
|
12
12
|
models: () => segmentstream.config.models,
|
|
13
13
|
confirmationMode: () => segmentstream.config.confirmations.mode,
|
|
14
14
|
sessions: () => segmentstream.config.sessions,
|
|
15
|
+
admission: () => segmentstream.config,
|
|
15
16
|
decide: (options) => decide(options)
|
|
16
17
|
});
|
|
17
18
|
export { telegram_default as default };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type TelegramLinkPreviews } from "@segmentstream-cloud/ontology/telegram";
|
|
2
2
|
export declare const TELEGRAM_POLICIES: readonly ["mention", "smart"];
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Smart routing classifies unaddressed group messages. Mention policy observes them
|
|
5
|
+
* without decoding or inference; direct requests and pending-question replies still run.
|
|
6
6
|
*/
|
|
7
7
|
export type TelegramPolicy = (typeof TELEGRAM_POLICIES)[number];
|
|
8
8
|
/** The speech-to-text model voice messages are transcribed with, in the AI Gateway catalog. */
|
|
@@ -12,11 +12,46 @@ export declare const DEFAULT_IMAGE_DESCRIPTION_MODEL = "google/gemini-2.5-flash-
|
|
|
12
12
|
export declare const DEFAULT_ATTACHMENTS_MODEL = "google/gemini-2.5-flash-lite";
|
|
13
13
|
/** Pinned platform routing model; independent of eve's own default. */
|
|
14
14
|
export declare const DEFAULT_DECISION_MODEL = "typesafe-ai/jev-1.14.0";
|
|
15
|
+
/** Platform ceilings; workspace configuration may only lower them. */
|
|
16
|
+
export declare const DEFAULT_ADMISSION_LIMITS: {
|
|
17
|
+
readonly perSender: {
|
|
18
|
+
readonly turnsPerMinute: 6;
|
|
19
|
+
readonly turnsPerDay: 80;
|
|
20
|
+
};
|
|
21
|
+
readonly newUsersPerHour: 30;
|
|
22
|
+
readonly workspaceTurnsPerDay: 3000;
|
|
23
|
+
};
|
|
24
|
+
export interface AdmissionLimits {
|
|
25
|
+
readonly perSender: {
|
|
26
|
+
readonly turnsPerMinute: number;
|
|
27
|
+
readonly turnsPerDay: number;
|
|
28
|
+
};
|
|
29
|
+
readonly newUsersPerHour: number;
|
|
30
|
+
readonly workspaceTurnsPerDay: number;
|
|
31
|
+
}
|
|
32
|
+
export type AdmissionLimitsInput = Partial<Omit<AdmissionLimits, "perSender">> & {
|
|
33
|
+
readonly perSender?: Partial<AdmissionLimits["perSender"]>;
|
|
34
|
+
};
|
|
35
|
+
export type LocalizedText = Readonly<Record<string, string>>;
|
|
36
|
+
export interface FirstContactSettings {
|
|
37
|
+
readonly version: string;
|
|
38
|
+
readonly notice: LocalizedText;
|
|
39
|
+
}
|
|
40
|
+
export declare const ADMISSION_REPLY_KEYS: readonly ["limited", "perSenderLimited", "workspaceLimited", "registrationsLimited", "groupPairingRequired", "refused"];
|
|
41
|
+
export type AdmissionReplies = Partial<Record<(typeof ADMISSION_REPLY_KEYS)[number], LocalizedText>>;
|
|
42
|
+
export interface AdmissionSettings {
|
|
43
|
+
readonly firstContact: FirstContactSettings | null;
|
|
44
|
+
readonly limits: AdmissionLimits;
|
|
45
|
+
readonly replies: AdmissionReplies;
|
|
46
|
+
}
|
|
15
47
|
/**
|
|
16
48
|
* `agent/segmentstream.json` as authored. A JSON import widens string literals to `string` and
|
|
17
49
|
* `false` to `boolean`, so the accepted types are wider than the values validation allows.
|
|
18
50
|
*/
|
|
19
51
|
export interface SegmentStreamAgentConfigInput {
|
|
52
|
+
readonly firstContact?: FirstContactSettings;
|
|
53
|
+
readonly limits?: AdmissionLimitsInput;
|
|
54
|
+
readonly replies?: AdmissionReplies;
|
|
20
55
|
readonly channels?: {
|
|
21
56
|
readonly telegram?: {
|
|
22
57
|
/** The Connect key whose credential is the bot token, such as `telegram`. */
|
|
@@ -104,7 +139,7 @@ export interface SessionSettings {
|
|
|
104
139
|
readonly idleResetHours: number | false;
|
|
105
140
|
}
|
|
106
141
|
/** The validated configuration with defaults applied. It stays JSON-serializable. */
|
|
107
|
-
export interface SegmentStreamAgentConfig {
|
|
142
|
+
export interface SegmentStreamAgentConfig extends AdmissionSettings {
|
|
108
143
|
readonly channels: {
|
|
109
144
|
readonly telegram: TelegramSettings | null;
|
|
110
145
|
};
|
|
@@ -11,6 +11,22 @@ const DEFAULT_STT_MODEL = "spacexai/grok-stt";
|
|
|
11
11
|
const DEFAULT_IMAGE_DESCRIPTION_MODEL = "google/gemini-2.5-flash-lite";
|
|
12
12
|
const DEFAULT_ATTACHMENTS_MODEL = DEFAULT_IMAGE_DESCRIPTION_MODEL;
|
|
13
13
|
const DEFAULT_DECISION_MODEL = "typesafe-ai/jev-1.14.0";
|
|
14
|
+
const DEFAULT_ADMISSION_LIMITS = {
|
|
15
|
+
perSender: {
|
|
16
|
+
turnsPerMinute: 6,
|
|
17
|
+
turnsPerDay: 80
|
|
18
|
+
},
|
|
19
|
+
newUsersPerHour: 30,
|
|
20
|
+
workspaceTurnsPerDay: 3e3
|
|
21
|
+
};
|
|
22
|
+
const ADMISSION_REPLY_KEYS = [
|
|
23
|
+
"limited",
|
|
24
|
+
"perSenderLimited",
|
|
25
|
+
"workspaceLimited",
|
|
26
|
+
"registrationsLimited",
|
|
27
|
+
"groupPairingRequired",
|
|
28
|
+
"refused"
|
|
29
|
+
];
|
|
14
30
|
const SESSION_SCOPES = ["chat", "thread"];
|
|
15
31
|
var SegmentStreamAgentConfigError = class extends Error {
|
|
16
32
|
issues;
|
|
@@ -197,6 +213,103 @@ function sessionSettings(value, issues) {
|
|
|
197
213
|
idleResetHours: typeof idleResetHours === "number" && Number.isFinite(idleResetHours) && idleResetHours > 0 ? idleResetHours : false
|
|
198
214
|
};
|
|
199
215
|
}
|
|
216
|
+
function localizedText(value, path, issues) {
|
|
217
|
+
if (!isRecord(value) || Object.keys(value).length === 0) {
|
|
218
|
+
issues.push({
|
|
219
|
+
message: "must be a non-empty locale-to-text object",
|
|
220
|
+
path
|
|
221
|
+
});
|
|
222
|
+
return {};
|
|
223
|
+
}
|
|
224
|
+
for (const [locale, text] of Object.entries(value)) {
|
|
225
|
+
try {
|
|
226
|
+
if (Intl.getCanonicalLocales(locale).length !== 1) throw new RangeError("empty locale");
|
|
227
|
+
} catch {
|
|
228
|
+
issues.push({
|
|
229
|
+
message: "must be a language tag such as en or en-US",
|
|
230
|
+
path: [...path, locale]
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (typeof text !== "string" || text.trim() === "" || text.length > 4e3) issues.push({
|
|
234
|
+
message: "must be non-empty text of at most 4000 characters",
|
|
235
|
+
path: [...path, locale]
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
function firstContactSettings(value, issues) {
|
|
241
|
+
const path = ["firstContact"];
|
|
242
|
+
const settings = section({
|
|
243
|
+
value,
|
|
244
|
+
path,
|
|
245
|
+
allowed: ["version", "notice"],
|
|
246
|
+
issues
|
|
247
|
+
});
|
|
248
|
+
if (settings === void 0) return null;
|
|
249
|
+
const version = settings.version;
|
|
250
|
+
if (typeof version !== "string" || version.trim() === "" || version !== version.trim() || version.length > 200) issues.push({
|
|
251
|
+
message: "must be non-empty text of at most 200 characters without surrounding whitespace",
|
|
252
|
+
path: [...path, "version"]
|
|
253
|
+
});
|
|
254
|
+
const notice = localizedText(settings.notice, [...path, "notice"], issues);
|
|
255
|
+
return {
|
|
256
|
+
version: typeof version === "string" ? version : "",
|
|
257
|
+
notice
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function admissionLimits(value, issues) {
|
|
261
|
+
const path = ["limits"];
|
|
262
|
+
const limits = section({
|
|
263
|
+
value,
|
|
264
|
+
path,
|
|
265
|
+
allowed: Object.keys(DEFAULT_ADMISSION_LIMITS),
|
|
266
|
+
issues
|
|
267
|
+
});
|
|
268
|
+
const perSender = section({
|
|
269
|
+
value: limits?.perSender,
|
|
270
|
+
path: [...path, "perSender"],
|
|
271
|
+
allowed: Object.keys(DEFAULT_ADMISSION_LIMITS.perSender),
|
|
272
|
+
issues
|
|
273
|
+
});
|
|
274
|
+
function limit(value, ceiling, path) {
|
|
275
|
+
if (value === void 0) return ceiling;
|
|
276
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > ceiling) {
|
|
277
|
+
issues.push({
|
|
278
|
+
message: "must be an integer between 1 and " + ceiling,
|
|
279
|
+
path
|
|
280
|
+
});
|
|
281
|
+
return ceiling;
|
|
282
|
+
}
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
perSender: {
|
|
287
|
+
turnsPerMinute: limit(perSender?.turnsPerMinute, DEFAULT_ADMISSION_LIMITS.perSender.turnsPerMinute, [
|
|
288
|
+
...path,
|
|
289
|
+
"perSender",
|
|
290
|
+
"turnsPerMinute"
|
|
291
|
+
]),
|
|
292
|
+
turnsPerDay: limit(perSender?.turnsPerDay, DEFAULT_ADMISSION_LIMITS.perSender.turnsPerDay, [
|
|
293
|
+
...path,
|
|
294
|
+
"perSender",
|
|
295
|
+
"turnsPerDay"
|
|
296
|
+
])
|
|
297
|
+
},
|
|
298
|
+
newUsersPerHour: limit(limits?.newUsersPerHour, DEFAULT_ADMISSION_LIMITS.newUsersPerHour, [...path, "newUsersPerHour"]),
|
|
299
|
+
workspaceTurnsPerDay: limit(limits?.workspaceTurnsPerDay, DEFAULT_ADMISSION_LIMITS.workspaceTurnsPerDay, [...path, "workspaceTurnsPerDay"])
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function admissionReplies(value, issues) {
|
|
303
|
+
const replies = section({
|
|
304
|
+
value,
|
|
305
|
+
path: ["replies"],
|
|
306
|
+
allowed: ADMISSION_REPLY_KEYS,
|
|
307
|
+
issues
|
|
308
|
+
});
|
|
309
|
+
const configured = {};
|
|
310
|
+
for (const key of ADMISSION_REPLY_KEYS) if (replies?.[key] !== void 0) configured[key] = localizedText(replies[key], ["replies", key], issues);
|
|
311
|
+
return configured;
|
|
312
|
+
}
|
|
200
313
|
function validateConfig(input) {
|
|
201
314
|
const issues = [];
|
|
202
315
|
const root = section({
|
|
@@ -207,7 +320,10 @@ function validateConfig(input) {
|
|
|
207
320
|
"models",
|
|
208
321
|
"ontology",
|
|
209
322
|
"sessions",
|
|
210
|
-
"confirmations"
|
|
323
|
+
"confirmations",
|
|
324
|
+
"firstContact",
|
|
325
|
+
"limits",
|
|
326
|
+
"replies"
|
|
211
327
|
],
|
|
212
328
|
issues
|
|
213
329
|
});
|
|
@@ -273,6 +389,9 @@ function validateConfig(input) {
|
|
|
273
389
|
});
|
|
274
390
|
const connection = ontology?.connection ?? true;
|
|
275
391
|
const sessions = sessionSettings(root?.sessions, issues);
|
|
392
|
+
const firstContact = firstContactSettings(root?.firstContact, issues);
|
|
393
|
+
const limits = admissionLimits(root?.limits, issues);
|
|
394
|
+
const replies = admissionReplies(root?.replies, issues);
|
|
276
395
|
if (typeof connection !== "boolean") issues.push({
|
|
277
396
|
message: "must be true or false",
|
|
278
397
|
path: ["ontology", "connection"]
|
|
@@ -289,7 +408,10 @@ function validateConfig(input) {
|
|
|
289
408
|
},
|
|
290
409
|
ontology: { connection: connection === true },
|
|
291
410
|
confirmations: { mode: mode === "held" ? "held" : "deferred" },
|
|
292
|
-
sessions
|
|
411
|
+
sessions,
|
|
412
|
+
firstContact,
|
|
413
|
+
limits,
|
|
414
|
+
replies
|
|
293
415
|
} };
|
|
294
416
|
}
|
|
295
417
|
const configSchema = { "~standard": {
|
|
@@ -303,4 +425,4 @@ function manifestOf(config) {
|
|
|
303
425
|
if (validation.issues !== void 0) throw new SegmentStreamAgentConfigError(validation.issues);
|
|
304
426
|
return validation.value;
|
|
305
427
|
}
|
|
306
|
-
export { DEFAULT_ATTACHMENTS_MODEL, DEFAULT_DECISION_MODEL, DEFAULT_IMAGE_DESCRIPTION_MODEL, DEFAULT_STT_MODEL, SESSION_SCOPES, SegmentStreamAgentConfigError, TELEGRAM_POLICIES, configSchema, manifestOf, validateConfig };
|
|
428
|
+
export { ADMISSION_REPLY_KEYS, DEFAULT_ADMISSION_LIMITS, DEFAULT_ATTACHMENTS_MODEL, DEFAULT_DECISION_MODEL, DEFAULT_IMAGE_DESCRIPTION_MODEL, DEFAULT_STT_MODEL, SESSION_SCOPES, SegmentStreamAgentConfigError, TELEGRAM_POLICIES, configSchema, manifestOf, validateConfig };
|
|
@@ -15,5 +15,5 @@ export declare const segmentstream: import("eve/extension").ExtensionHandle<{
|
|
|
15
15
|
manifestOf: typeof manifestOf;
|
|
16
16
|
};
|
|
17
17
|
export default segmentstream;
|
|
18
|
-
export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, manifestOf, type ChatModelChoice, type SegmentStreamAgentConfig, type SegmentStreamAgentConfigInput, type SessionScope, type SessionSettings, type TelegramPolicy, type TelegramSettings, } from "./config.js";
|
|
18
|
+
export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, DEFAULT_ADMISSION_LIMITS, SegmentStreamAgentConfigError, manifestOf, type ChatModelChoice, type AdmissionLimits, type AdmissionLimitsInput, type AdmissionSettings, type AdmissionReplies, type FirstContactSettings, type LocalizedText, type SegmentStreamAgentConfig, type SegmentStreamAgentConfigInput, type SessionScope, type SessionSettings, type TelegramPolicy, type TelegramSettings, } from "./config.js";
|
|
19
19
|
export { chooseModel, model, type DynamicModel, type ModelChoice } from "./model.js";
|
|
@@ -4,7 +4,7 @@ import { createRequire as __eveCreateRequire } from "node:module";
|
|
|
4
4
|
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
|
-
import { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, configSchema, manifestOf } from "./config.mjs";
|
|
7
|
+
import { DEFAULT_ADMISSION_LIMITS, DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, configSchema, manifestOf } from "./config.mjs";
|
|
8
8
|
import { chooseModel, model } from "./model.mjs";
|
|
9
9
|
import { defineExtension } from "eve/extension";
|
|
10
10
|
const extension = defineExtension({ config: configSchema });
|
|
@@ -12,4 +12,4 @@ const segmentstream = Object.assign(extension, {
|
|
|
12
12
|
model: (choice) => model(choice ?? (() => extension.config.models.chat)),
|
|
13
13
|
manifestOf
|
|
14
14
|
});
|
|
15
|
-
export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, chooseModel, segmentstream as default, segmentstream, manifestOf, model };
|
|
15
|
+
export { DEFAULT_ADMISSION_LIMITS, DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, chooseModel, segmentstream as default, segmentstream, manifestOf, model };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AdmissionSettings, LocalizedText } from "../config.js";
|
|
2
|
+
import type { AgentIdentities, IdentityResolution, ResolveIdentityInput } from "./identities.js";
|
|
3
|
+
/** Sender language, then English, then the first configured entry. No platform copy. */
|
|
4
|
+
export declare function localizedText(texts: LocalizedText | undefined, languageCode?: string): string | undefined;
|
|
5
|
+
/** Channel-independent admission and at-least-once versioned notice delivery. */
|
|
6
|
+
export declare function admitMessage(input: {
|
|
7
|
+
readonly identity: ResolveIdentityInput;
|
|
8
|
+
readonly identities: AgentIdentities;
|
|
9
|
+
readonly settings: AdmissionSettings;
|
|
10
|
+
readonly languageCode?: string;
|
|
11
|
+
readonly sendNotice: (text: string) => Promise<unknown>;
|
|
12
|
+
readonly noticeFailed: (error: unknown) => void;
|
|
13
|
+
}): Promise<IdentityResolution>;
|
|
@@ -0,0 +1,42 @@
|
|
|
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 { createHash } from "node:crypto";
|
|
8
|
+
function localizedText(texts, languageCode) {
|
|
9
|
+
if (texts === void 0) return;
|
|
10
|
+
const entries = Object.entries(texts);
|
|
11
|
+
const language = languageCode?.toLowerCase();
|
|
12
|
+
const exact = entries.find(([locale]) => locale.toLowerCase() === language);
|
|
13
|
+
const base = entries.find(([locale]) => locale.toLowerCase() === language?.split("-")[0]);
|
|
14
|
+
const english = entries.find(([locale]) => locale.toLowerCase() === "en");
|
|
15
|
+
return (exact ?? base ?? english ?? entries[0])?.[1];
|
|
16
|
+
}
|
|
17
|
+
async function admitMessage(input) {
|
|
18
|
+
const firstContact = input.settings.firstContact;
|
|
19
|
+
const notice = localizedText(firstContact?.notice, input.languageCode);
|
|
20
|
+
const sha256 = notice === void 0 ? void 0 : createHash("sha256").update(notice).digest("hex");
|
|
21
|
+
const resolved = await input.identities.resolve({
|
|
22
|
+
...input.identity,
|
|
23
|
+
limits: input.settings.limits,
|
|
24
|
+
...firstContact === null ? {} : {
|
|
25
|
+
noticeVersion: firstContact.version,
|
|
26
|
+
noticeSha256: sha256
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
if ((resolved.status === "existing" || resolved.status === "created") && resolved.noticeDue && input.identity.turn && input.identity.chat === "private" && firstContact !== null && notice !== void 0 && sha256 !== void 0) try {
|
|
30
|
+
await input.sendNotice(notice);
|
|
31
|
+
await input.identities.notice({
|
|
32
|
+
provider: input.identity.provider,
|
|
33
|
+
externalId: input.identity.externalId,
|
|
34
|
+
version: firstContact.version,
|
|
35
|
+
sha256
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
input.noticeFailed(error);
|
|
39
|
+
}
|
|
40
|
+
return resolved;
|
|
41
|
+
}
|
|
42
|
+
export { admitMessage, localizedText };
|
|
@@ -9,7 +9,7 @@ export declare function conversationCoordinates(context: {
|
|
|
9
9
|
}): {
|
|
10
10
|
provider: string;
|
|
11
11
|
chatId: string;
|
|
12
|
-
chatType: "
|
|
12
|
+
chatType: "private" | "group" | "channel";
|
|
13
13
|
threadId: string | null;
|
|
14
14
|
participantIds: string[];
|
|
15
15
|
senderId: string | null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AdmissionLimitsInput } from "../config.js";
|
|
1
2
|
import type { RuntimeToken } from "./runtime-token.js";
|
|
2
3
|
/**
|
|
3
4
|
* The ontology service's pairing routes (`agent-design.md`, section 13, "Identity"). The agent
|
|
@@ -25,6 +26,36 @@ export type IdentityLookup = {
|
|
|
25
26
|
| {
|
|
26
27
|
readonly status: "refused";
|
|
27
28
|
};
|
|
29
|
+
export type IdentityResolution = (({
|
|
30
|
+
readonly status: "existing";
|
|
31
|
+
} | {
|
|
32
|
+
readonly status: "created";
|
|
33
|
+
}) & {
|
|
34
|
+
readonly member: PairedMember;
|
|
35
|
+
readonly provisioned: boolean;
|
|
36
|
+
readonly noticeDue: boolean;
|
|
37
|
+
readonly onNewUserPending: boolean;
|
|
38
|
+
}) | {
|
|
39
|
+
readonly status: "pairing_required";
|
|
40
|
+
} | {
|
|
41
|
+
readonly status: "refused";
|
|
42
|
+
readonly reason?: "blocked" | "inactive";
|
|
43
|
+
} | {
|
|
44
|
+
readonly status: "limited";
|
|
45
|
+
readonly scope: "turnsPerMinute" | "turnsPerDay" | "newUsersPerHour" | "workspaceTurnsPerDay";
|
|
46
|
+
readonly retryAfterSeconds: number;
|
|
47
|
+
readonly notify: boolean;
|
|
48
|
+
readonly notificationId?: string;
|
|
49
|
+
};
|
|
50
|
+
export interface ResolveIdentityInput extends ChatAccount {
|
|
51
|
+
readonly chat: "private" | "group";
|
|
52
|
+
readonly turn: boolean;
|
|
53
|
+
readonly display?: string;
|
|
54
|
+
readonly username?: string;
|
|
55
|
+
readonly limits?: AdmissionLimitsInput;
|
|
56
|
+
readonly noticeVersion?: string;
|
|
57
|
+
readonly noticeSha256?: string;
|
|
58
|
+
}
|
|
28
59
|
/** A pairing code the member approves at `/agent/connect?code=<code>`. */
|
|
29
60
|
export interface Pairing {
|
|
30
61
|
readonly code: string;
|
|
@@ -44,6 +75,16 @@ export interface AgentChat {
|
|
|
44
75
|
}
|
|
45
76
|
export interface AgentIdentities {
|
|
46
77
|
lookup(account: ChatAccount): Promise<IdentityLookup>;
|
|
78
|
+
/** Server-owned admission; only an older server's route 404 falls back to GET lookup. */
|
|
79
|
+
resolve(input: ResolveIdentityInput): Promise<IdentityResolution>;
|
|
80
|
+
notice(input: ChatAccount & {
|
|
81
|
+
readonly version: string;
|
|
82
|
+
readonly sha256: string;
|
|
83
|
+
}): Promise<void>;
|
|
84
|
+
completeLimitNotice(input: {
|
|
85
|
+
readonly notificationId: string;
|
|
86
|
+
readonly delivered: boolean;
|
|
87
|
+
}): Promise<void>;
|
|
47
88
|
/** Issues a pairing code, or returns the account's pending one while it has time left. */
|
|
48
89
|
createPairing(account: ChatAccount & {
|
|
49
90
|
readonly display?: string;
|
|
@@ -58,6 +58,33 @@ function pairing(value) {
|
|
|
58
58
|
expiresAt: value.expires_at
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
|
+
function resolution(value) {
|
|
62
|
+
if (isRecord(value)) {
|
|
63
|
+
if ((value.status === "existing" || value.status === "created") && typeof value.provisioned === "boolean" && typeof value.notice_due === "boolean" && typeof value.on_new_user_pending === "boolean") return {
|
|
64
|
+
status: value.status,
|
|
65
|
+
member: pairedMember(value),
|
|
66
|
+
provisioned: value.provisioned,
|
|
67
|
+
noticeDue: value.notice_due,
|
|
68
|
+
onNewUserPending: value.on_new_user_pending
|
|
69
|
+
};
|
|
70
|
+
if (value.status === "pairing_required") return { status: "pairing_required" };
|
|
71
|
+
if (value.status === "refused" && (value.reason === "blocked" || value.reason === "inactive")) return {
|
|
72
|
+
status: "refused",
|
|
73
|
+
reason: value.reason
|
|
74
|
+
};
|
|
75
|
+
if (value.status === "limited" && (value.scope === "turnsPerMinute" || value.scope === "turnsPerDay" || value.scope === "newUsersPerHour" || value.scope === "workspaceTurnsPerDay") && typeof value.retry_after_seconds === "number" && Number.isInteger(value.retry_after_seconds) && value.retry_after_seconds >= 0 && typeof value.notify === "boolean") return {
|
|
76
|
+
status: "limited",
|
|
77
|
+
scope: value.scope,
|
|
78
|
+
retryAfterSeconds: value.retry_after_seconds,
|
|
79
|
+
notify: value.notify,
|
|
80
|
+
...typeof value.notification_id === "string" ? { notificationId: value.notification_id } : {}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
throw new AgentIdentityError({
|
|
84
|
+
code: "invalid_response",
|
|
85
|
+
message: "The ontology service answered the identity resolution with an invalid body."
|
|
86
|
+
});
|
|
87
|
+
}
|
|
61
88
|
function nullableString(value) {
|
|
62
89
|
return value === null || typeof value === "string";
|
|
63
90
|
}
|
|
@@ -138,18 +165,59 @@ function agentIdentities(input) {
|
|
|
138
165
|
});
|
|
139
166
|
}
|
|
140
167
|
}
|
|
168
|
+
async function lookup(account) {
|
|
169
|
+
const response = await call(`/identities/${encodeURIComponent(account.provider)}/${encodeURIComponent(account.externalId)}`, {});
|
|
170
|
+
if (response.status === 404) return { status: "unknown" };
|
|
171
|
+
if (response.status === 403) return { status: "refused" };
|
|
172
|
+
if (!response.ok) throw await safeRefusal(response, "identity lookup");
|
|
173
|
+
const member = pairedMember(await response.json());
|
|
174
|
+
pending.delete(accountKey(account));
|
|
175
|
+
return {
|
|
176
|
+
status: "paired",
|
|
177
|
+
member
|
|
178
|
+
};
|
|
179
|
+
}
|
|
141
180
|
return {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
181
|
+
lookup,
|
|
182
|
+
async resolve(account) {
|
|
183
|
+
const { externalId, noticeVersion, noticeSha256, ...details } = account;
|
|
184
|
+
const response = await call("/identities/resolve", { body: {
|
|
185
|
+
...details,
|
|
186
|
+
external_id: externalId,
|
|
187
|
+
...noticeVersion === void 0 ? {} : { notice_version: noticeVersion },
|
|
188
|
+
...noticeSha256 === void 0 ? {} : { notice_sha256: noticeSha256 }
|
|
189
|
+
} });
|
|
190
|
+
if (response.status === 404) {
|
|
191
|
+
const existing = await lookup(account);
|
|
192
|
+
if (existing.status === "unknown") return { status: "pairing_required" };
|
|
193
|
+
return existing.status === "paired" ? {
|
|
194
|
+
...existing,
|
|
195
|
+
status: "existing",
|
|
196
|
+
provisioned: false,
|
|
197
|
+
noticeDue: false,
|
|
198
|
+
onNewUserPending: false
|
|
199
|
+
} : existing;
|
|
200
|
+
}
|
|
201
|
+
if (!response.ok) throw await safeRefusal(response, "identity resolution");
|
|
202
|
+
const resolved = resolution(await response.json());
|
|
203
|
+
if (resolved.status === "existing" || resolved.status === "created") pending.delete(accountKey(account));
|
|
204
|
+
return resolved;
|
|
205
|
+
},
|
|
206
|
+
async completeLimitNotice(input) {
|
|
207
|
+
const response = await call("/identities/limit-notice", { body: {
|
|
208
|
+
notification_id: input.notificationId,
|
|
209
|
+
delivered: input.delivered
|
|
210
|
+
} });
|
|
211
|
+
if (!response.ok) throw await safeRefusal(response, "limit notice completion");
|
|
212
|
+
},
|
|
213
|
+
async notice(account) {
|
|
214
|
+
const response = await call("/identities/notice", { body: {
|
|
215
|
+
provider: account.provider,
|
|
216
|
+
external_id: account.externalId,
|
|
217
|
+
version: account.version,
|
|
218
|
+
sha256: account.sha256
|
|
219
|
+
} });
|
|
220
|
+
if (!response.ok) throw await safeRefusal(response, "identity notice acknowledgement");
|
|
153
221
|
},
|
|
154
222
|
async createPairing(account) {
|
|
155
223
|
const key = accountKey(account);
|
|
@@ -5,7 +5,7 @@ const __filename = __eveFileURLToPath(import.meta.url);
|
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
7
|
import { appOrigin } from "./environment.mjs";
|
|
8
|
-
import { memberIdOf } from "./telegram/principal.mjs";
|
|
8
|
+
import { identityProviderOf, memberIdOf } from "./telegram/principal.mjs";
|
|
9
9
|
import { CHAT_POST_ENVELOPE, chatPostDigest, chatPostOperationId, isChatPost } from "./chat-post-confirmation.mjs";
|
|
10
10
|
import { actionApproval, confirmations } from "./confirmations.mjs";
|
|
11
11
|
import { runtimeToken, tokenExpiry } from "./runtime-token.mjs";
|
|
@@ -20,7 +20,11 @@ function actAsHeaders(input) {
|
|
|
20
20
|
const memberId = memberIdOf(input.principal);
|
|
21
21
|
if (memberId === void 0) throw new MemberRequiredError("The ontology is reachable only for a member paired with this agent.");
|
|
22
22
|
if (memberId !== input.expectedMemberId) throw new MemberRequiredError("The ontology connection was opened for another member.");
|
|
23
|
-
|
|
23
|
+
const provider = identityProviderOf(input.principal);
|
|
24
|
+
return {
|
|
25
|
+
[ACT_AS_HEADER]: memberId,
|
|
26
|
+
...provider === void 0 ? {} : { "X-SegmentStream-Identity-Provider": provider }
|
|
27
|
+
};
|
|
24
28
|
}
|
|
25
29
|
function ontologyConnection(input) {
|
|
26
30
|
const memberId = memberIdOf(input.principal);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { SessionAuthContext } from "eve/context";
|
|
1
2
|
import type { ActionAnnotations } from "./confirmations.js";
|
|
2
3
|
import { type Environment } from "./environment.js";
|
|
3
4
|
import { type RuntimeToken } from "./runtime-token.js";
|
|
4
5
|
/** A fresh, permission-filtered catalog. Listing tools cannot run an action's prepare step. */
|
|
5
6
|
export declare function ontologyTools(options?: {
|
|
7
|
+
readonly principal?: SessionAuthContext | null;
|
|
6
8
|
readonly environment?: Environment;
|
|
7
9
|
readonly token?: RuntimeToken;
|
|
8
10
|
readonly fetch?: typeof fetch;
|
|
@@ -5,12 +5,14 @@ const __filename = __eveFileURLToPath(import.meta.url);
|
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
7
|
import { appOrigin } from "./environment.mjs";
|
|
8
|
+
import { identityProviderOf, memberIdOf } from "./telegram/principal.mjs";
|
|
8
9
|
import { describeConfirmation, object } from "./confirmation-description.mjs";
|
|
9
10
|
import { describeChatPost, isChatPost, supportsChatPost } from "./chat-post-confirmation.mjs";
|
|
10
11
|
import { redactDiagnostic } from "./diagnostics.mjs";
|
|
11
12
|
import { runtimeToken } from "./runtime-token.mjs";
|
|
12
13
|
function ontologyTools(options = {}) {
|
|
13
14
|
async function rpc(memberId, method, params, signal) {
|
|
15
|
+
const provider = memberIdOf(options.principal) === memberId ? identityProviderOf(options.principal) : void 0;
|
|
14
16
|
const credential = await (options.token ?? runtimeToken)();
|
|
15
17
|
const secrets = new Set([credential]);
|
|
16
18
|
try {
|
|
@@ -19,6 +21,7 @@ function ontologyTools(options = {}) {
|
|
|
19
21
|
headers: {
|
|
20
22
|
authorization: `Bearer ${credential}`,
|
|
21
23
|
"X-SegmentStream-Act-As": memberId,
|
|
24
|
+
...provider === void 0 ? {} : { "X-SegmentStream-Identity-Provider": provider },
|
|
22
25
|
"content-type": "application/json",
|
|
23
26
|
accept: "application/json, text/event-stream",
|
|
24
27
|
"mcp-protocol-version": "2025-11-25"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type AttachmentDescriber } from "./attachment-description.js";
|
|
2
2
|
import { type TelegramChannel } from "eve/channels/telegram";
|
|
3
|
-
import type { ChatModelChoice, SessionSettings, TelegramSettings } from "../../config.js";
|
|
3
|
+
import type { ChatModelChoice, AdmissionSettings, SessionSettings, TelegramSettings } from "../../config.js";
|
|
4
4
|
import { type AgentAttachments } from "../attachments.js";
|
|
5
5
|
import { type Environment } from "../environment.js";
|
|
6
6
|
import { type Conversations } from "../conversations.js";
|
|
@@ -30,6 +30,7 @@ export interface TelegramAdapterOptions {
|
|
|
30
30
|
};
|
|
31
31
|
/** The mounted `sessions`. */
|
|
32
32
|
readonly sessions: () => SessionSettings;
|
|
33
|
+
readonly admission?: () => AdmissionSettings;
|
|
33
34
|
readonly confirmationMode?: () => "deferred" | "held";
|
|
34
35
|
readonly decide: DecideFunction;
|
|
35
36
|
readonly environment?: Environment;
|
|
@@ -4,7 +4,7 @@ import { createRequire as __eveCreateRequire } from "node:module";
|
|
|
4
4
|
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
|
-
import "../../config.mjs";
|
|
7
|
+
import { manifestOf } from "../../config.mjs";
|
|
8
8
|
import { appOrigin, channelsEnabled, proxyEnvironment, runtimeIdentityEnvironment, webhookSecret } from "../environment.mjs";
|
|
9
9
|
import { conversationLanguage, telegramPrincipal } from "./principal.mjs";
|
|
10
10
|
import { chooseModel } from "../../model.mjs";
|
|
@@ -165,6 +165,7 @@ function segmentstreamTelegram(options) {
|
|
|
165
165
|
dependencies: {
|
|
166
166
|
policy: settings().policy,
|
|
167
167
|
sessions: options.sessions,
|
|
168
|
+
admission: options.admission ?? (() => manifestOf({})),
|
|
168
169
|
sessionPolicy: policy,
|
|
169
170
|
sttModel: options.models().stt,
|
|
170
171
|
attachmentsModel: options.models().imageDescription ?? options.models().attachments ?? "google/gemini-2.5-flash-lite",
|
|
@@ -130,11 +130,15 @@ async function captureTelegramWebhook(input) {
|
|
|
130
130
|
attachments,
|
|
131
131
|
at: new Date(sentAt).toISOString()
|
|
132
132
|
});
|
|
133
|
-
|
|
133
|
+
let persisted;
|
|
134
134
|
const capture = {
|
|
135
135
|
event,
|
|
136
136
|
message,
|
|
137
137
|
archive: input.archive,
|
|
138
|
+
persist: () => {
|
|
139
|
+
persisted ??= input.archive.append(capture.event).then(() => void 0);
|
|
140
|
+
return persisted;
|
|
141
|
+
},
|
|
138
142
|
...lookup === void 0 ? {} : { lookup }
|
|
139
143
|
};
|
|
140
144
|
const revisionId = edited === void 0 ? void 0 : `telegram:${message.chat.id}:${message.messageId}:routing:${createHash("sha256").update(JSON.stringify({
|
|
@@ -161,6 +165,7 @@ async function captureTelegramWebhook(input) {
|
|
|
161
165
|
...message.messageThreadId === void 0 ? {} : { messageThreadId: message.messageThreadId }
|
|
162
166
|
});
|
|
163
167
|
const automated = message.from === void 0 || message.from.isBot || message.chat.type === "channel";
|
|
168
|
+
if (automated || edited !== void 0 || message.raw.left_chat_member || message.raw.migrate_to_chat_id || message.raw.migrate_from_chat_id) await capture.persist();
|
|
164
169
|
const history = automated ? void 0 : await input.archive.context({
|
|
165
170
|
provider: "telegram",
|
|
166
171
|
chatId: message.chat.id,
|
|
@@ -200,6 +205,7 @@ async function captureTelegramWebhook(input) {
|
|
|
200
205
|
if (unchangedAttachments && event.attachments.length > 0) {
|
|
201
206
|
const decoded = Array.isArray(previousRouting.decoded) ? previousRouting.decoded : [];
|
|
202
207
|
const media = typeof previousRouting.media === "string" ? previousRouting.media : void 0;
|
|
208
|
+
await capture.persist();
|
|
203
209
|
const metadata = record(JSON.parse(capture.event.argsSummary ?? "{}"));
|
|
204
210
|
capture.event = {
|
|
205
211
|
...capture.event,
|
|
@@ -269,7 +275,7 @@ async function captureTelegramWebhook(input) {
|
|
|
269
275
|
if (latest !== void 0 && !revisionEvents.some((item) => item.eventId === `${latest.eventId}:failed`) && Date.now() - Date.parse(latest.at) < 12e4) return false;
|
|
270
276
|
const attempt = claims.length === 0 ? void 0 : Math.max(...claims.map((item) => Number(item.eventId.slice(revisionId.length).replace(":retry:", "")))) + 1;
|
|
271
277
|
const claim = {
|
|
272
|
-
...event,
|
|
278
|
+
...capture.event,
|
|
273
279
|
eventId: attempt === void 0 ? revisionId : `${revisionId}:retry:${attempt}`,
|
|
274
280
|
at: new Date(Date.now()).toISOString(),
|
|
275
281
|
kind: "decision",
|
|
@@ -289,7 +295,7 @@ async function captureTelegramWebhook(input) {
|
|
|
289
295
|
let accepted = false;
|
|
290
296
|
let dispatchStarted = false;
|
|
291
297
|
const claimEvent = (reason, status, suffix = "") => ({
|
|
292
|
-
...event,
|
|
298
|
+
...capture.event,
|
|
293
299
|
eventId: `${claimId}${suffix}`,
|
|
294
300
|
kind: "decision",
|
|
295
301
|
decision: "reply",
|
|
@@ -331,9 +337,10 @@ async function captureTelegramWebhook(input) {
|
|
|
331
337
|
}) }) : alreadyHandled ? new Request(input.request, { body: JSON.stringify({ update_id: update.update_id }) }) : void 0);
|
|
332
338
|
} catch (error) {
|
|
333
339
|
try {
|
|
340
|
+
await capture.persist();
|
|
334
341
|
await inbound?.finishClaim?.(false);
|
|
335
342
|
await input.archive.append({
|
|
336
|
-
...event,
|
|
343
|
+
...capture.event,
|
|
337
344
|
eventId: `${event.eventId}:decision`,
|
|
338
345
|
kind: "decision",
|
|
339
346
|
text: null,
|
|
@@ -350,14 +357,15 @@ async function captureTelegramWebhook(input) {
|
|
|
350
357
|
}
|
|
351
358
|
throw error;
|
|
352
359
|
}
|
|
360
|
+
await capture.persist();
|
|
353
361
|
await inbound?.finishClaim?.(false);
|
|
354
362
|
await input.archive.append({
|
|
355
|
-
...event,
|
|
363
|
+
...capture.event,
|
|
356
364
|
eventId: `${event.eventId}:decision`,
|
|
357
365
|
kind: "decision",
|
|
358
366
|
argsSummary: JSON.stringify({
|
|
359
367
|
...capture.diagnostics === void 0 ? {} : { decision: capture.diagnostics },
|
|
360
|
-
routing: { membership: capture.lookup === void 0 ? "unknown" : capture.lookup.status === "paired" ? "linked" : "unlinked" }
|
|
368
|
+
routing: { membership: capture.lookup === void 0 ? "unknown" : capture.lookup.status === "paired" || capture.lookup.status === "existing" || capture.lookup.status === "created" ? "linked" : "unlinked" }
|
|
361
369
|
}),
|
|
362
370
|
text: capture.note ?? (edited !== void 0 ? "Message edit recorded as context; no agent turn started." : null),
|
|
363
371
|
decision: capture.decision ?? "silent",
|
|
@@ -402,8 +410,9 @@ async function recordInboundContent(content) {
|
|
|
402
410
|
const inbound = currentInboundRequest();
|
|
403
411
|
const capture = inbound === void 0 ? void 0 : captures.get(inbound);
|
|
404
412
|
if (capture?.archive === void 0) return;
|
|
413
|
+
await capture.persist();
|
|
405
414
|
const metadata = record(JSON.parse(capture.event.argsSummary ?? "{}"));
|
|
406
|
-
const membership = capture.lookup === void 0 ? record(metadata.routing).membership : capture.lookup.status === "paired" ? "linked" : "unlinked";
|
|
415
|
+
const membership = capture.lookup === void 0 ? record(metadata.routing).membership : capture.lookup.status === "paired" || capture.lookup.status === "existing" || capture.lookup.status === "created" ? "linked" : "unlinked";
|
|
407
416
|
if (content.text === capture.event.text && content.media === record(metadata.routing).media && membership === record(metadata.routing).membership && JSON.stringify(content.decoded) === JSON.stringify(record(metadata.routing).decoded)) return;
|
|
408
417
|
const contentHash = createHash("sha256").update(JSON.stringify({
|
|
409
418
|
...content,
|
|
@@ -507,13 +516,40 @@ function captureTelegramFetch(fetchImpl, archive, identities) {
|
|
|
507
516
|
function conversationIdentities(identities) {
|
|
508
517
|
return {
|
|
509
518
|
...identities,
|
|
519
|
+
resolve: async (account) => {
|
|
520
|
+
const resolved = await identities.resolve(account);
|
|
521
|
+
const inbound = currentInboundRequest();
|
|
522
|
+
const capture = inbound === void 0 ? void 0 : captures.get(inbound);
|
|
523
|
+
if (account.provider === "telegram" && capture?.message.from?.id === account.externalId) {
|
|
524
|
+
capture.lookup = resolved;
|
|
525
|
+
if (capture.event.chatType === "private" && (resolved.status === "existing" || resolved.status === "created")) capture.event = {
|
|
526
|
+
...capture.event,
|
|
527
|
+
participantIds: [resolved.member.id]
|
|
528
|
+
};
|
|
529
|
+
if (capture.event.chatType === "private" && resolved.status === "limited") {
|
|
530
|
+
const binding = await identities.lookup(account);
|
|
531
|
+
if (binding.status === "paired") capture.event = {
|
|
532
|
+
...capture.event,
|
|
533
|
+
participantIds: [binding.member.id]
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
await capture.persist();
|
|
537
|
+
}
|
|
538
|
+
return resolved;
|
|
539
|
+
},
|
|
510
540
|
lookup: async (account) => {
|
|
511
541
|
const inbound = currentInboundRequest();
|
|
512
542
|
const capture = inbound === void 0 ? void 0 : captures.get(inbound);
|
|
513
|
-
if (account.provider === "telegram" && capture?.message.from?.id === account.externalId
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
543
|
+
if (account.provider === "telegram" && capture?.message.from?.id === account.externalId) {
|
|
544
|
+
const cached = capture.lookup;
|
|
545
|
+
if (cached?.status === "paired" || cached?.status === "refused" || cached?.status === "unknown") return cached;
|
|
546
|
+
if (cached?.status === "existing" || cached?.status === "created") return {
|
|
547
|
+
status: "paired",
|
|
548
|
+
member: cached.member
|
|
549
|
+
};
|
|
550
|
+
if (cached?.status === "pairing_required") return { status: "unknown" };
|
|
551
|
+
}
|
|
552
|
+
return identities.lookup(account);
|
|
517
553
|
}
|
|
518
554
|
};
|
|
519
555
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TelegramContext, TelegramInboundResult, TelegramMessage } from "eve/channels/telegram";
|
|
2
|
-
import type { SessionSettings, TelegramPolicy } from "../../config.js";
|
|
2
|
+
import type { AdmissionSettings, SessionSettings, TelegramPolicy } from "../../config.js";
|
|
3
3
|
import type { AgentAttachments } from "../attachments.js";
|
|
4
4
|
import type { AgentIdentities, PairedMember } from "../identities.js";
|
|
5
5
|
import type { BotIdentityLookup } from "./bot.js";
|
|
@@ -38,6 +38,7 @@ import type { PdfTextExtractor } from "./pdf-text.js";
|
|
|
38
38
|
*/
|
|
39
39
|
export interface InboundDependencies {
|
|
40
40
|
readonly policy: TelegramPolicy;
|
|
41
|
+
readonly admission: () => AdmissionSettings;
|
|
41
42
|
readonly sessions: () => SessionSettings;
|
|
42
43
|
readonly sessionPolicy: SessionPolicy;
|
|
43
44
|
readonly sttModel: string | false;
|
|
@@ -8,7 +8,7 @@ import { conversationLanguage, telegramPrincipal } from "./principal.mjs";
|
|
|
8
8
|
import { p as reportTelegramError } from "../../../_chunks/inbound-request-BwBvXk0A.mjs";
|
|
9
9
|
import { formatAddress, messageAddress, scopedAddress } from "./sessions.mjs";
|
|
10
10
|
import { contentShape, messageContent } from "./content.mjs";
|
|
11
|
-
import { messageAudience } from "./audience.mjs";
|
|
11
|
+
import { messageAudience, otherRecipient } from "./audience.mjs";
|
|
12
12
|
import { attachmentKind, messageMetadata, observedChatKey, observedContext } from "./observed.mjs";
|
|
13
13
|
import { MAXIMUM_NATIVE_IMAGE_BYTES } from "./images.mjs";
|
|
14
14
|
import { MAXIMUM_ROUTING_ATTACHMENT_BYTES, describeRoutingAttachment, routingAttachmentDeadline } from "./attachment-description.mjs";
|
|
@@ -19,6 +19,7 @@ import { questionAddress, questionFromHistory } from "./confirmations.mjs";
|
|
|
19
19
|
import { routingContext } from "./routing-context.mjs";
|
|
20
20
|
import { recordGateDecision, recordInboundContent, recordRouteDecision } from "./conversations.mjs";
|
|
21
21
|
import { chatContext } from "./chats.mjs";
|
|
22
|
+
import { admitMessage, localizedText } from "../admission.mjs";
|
|
22
23
|
import { attachmentsContext, inboundAttachments, stageFiles } from "./files.mjs";
|
|
23
24
|
import { voiceOf, voiceText } from "./voice.mjs";
|
|
24
25
|
const UNKNOWN_PRIVATE = "🔗";
|
|
@@ -94,13 +95,39 @@ function unknownInGroup(bot) {
|
|
|
94
95
|
}
|
|
95
96
|
async function answerUnpaired(input) {
|
|
96
97
|
const { context, message, lookup, dependencies } = input;
|
|
98
|
+
if (lookup.status === "limited") {
|
|
99
|
+
if (lookup.notify) {
|
|
100
|
+
const replies = dependencies.admission().replies;
|
|
101
|
+
const configured = lookup.scope === "workspaceTurnsPerDay" ? replies.workspaceLimited : lookup.scope === "newUsersPerHour" ? replies.registrationsLimited : replies.perSenderLimited;
|
|
102
|
+
const text = localizedText(configured ?? replies.limited, message.from.languageCode);
|
|
103
|
+
if (text !== void 0) {
|
|
104
|
+
let delivered = false;
|
|
105
|
+
try {
|
|
106
|
+
await context.telegram.post(text);
|
|
107
|
+
delivered = true;
|
|
108
|
+
} finally {
|
|
109
|
+
if (lookup.notificationId !== void 0) try {
|
|
110
|
+
await dependencies.identities.completeLimitNotice({
|
|
111
|
+
notificationId: lookup.notificationId,
|
|
112
|
+
delivered
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
reportTelegramError("limit_notice_completion_failed", error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
97
123
|
if (lookup.status === "refused") {
|
|
98
|
-
|
|
99
|
-
|
|
124
|
+
const text = localizedText(dependencies.admission().replies.refused, message.from.languageCode);
|
|
125
|
+
await context.telegram.post(text ?? REFUSED);
|
|
126
|
+
return true;
|
|
100
127
|
}
|
|
101
128
|
if (message.chat.type !== "private") {
|
|
102
|
-
await context.telegram.post(unknownInGroup(input.bot));
|
|
103
|
-
return;
|
|
129
|
+
await context.telegram.post(localizedText(dependencies.admission().replies.groupPairingRequired, message.from.languageCode) ?? unknownInGroup(input.bot));
|
|
130
|
+
return true;
|
|
104
131
|
}
|
|
105
132
|
const sender = display(message.from);
|
|
106
133
|
const pairing = await dependencies.identities.createPairing({
|
|
@@ -112,6 +139,7 @@ async function answerUnpaired(input) {
|
|
|
112
139
|
text: UNKNOWN_PRIVATE,
|
|
113
140
|
reply_markup: buttonTo("🔗", dependencies.connectUrl(pairing.code))
|
|
114
141
|
});
|
|
142
|
+
return true;
|
|
115
143
|
}
|
|
116
144
|
function isGroupChat(message) {
|
|
117
145
|
return message.chat.type === "group" || message.chat.type === "supergroup";
|
|
@@ -190,10 +218,41 @@ async function handleInboundMessage(input) {
|
|
|
190
218
|
const bot = await dependencies.bot(context.telegram);
|
|
191
219
|
const audience = messageAudience(sent, bot);
|
|
192
220
|
const addressed = addressingReason(sent, bot);
|
|
193
|
-
const
|
|
221
|
+
const elsewhere = otherRecipient(audience);
|
|
222
|
+
if (input.request?.edited) {
|
|
223
|
+
if (addressed === void 0 && !input.request.attachmentsChanged && (input.request.previousText ?? "").trim().replace(/\s+/gu, " ") === audience.originalText.trim().replace(/\s+/gu, " ")) {
|
|
224
|
+
recordGateDecision("message_edit");
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
if (input.request.claimRevision !== void 0 && !await input.request.claimRevision()) {
|
|
228
|
+
recordGateDecision("message_revision_already_handled");
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const sender = display(sent.from);
|
|
233
|
+
const hasContent = (sent.text || sent.caption).trim() !== "" || voiceOf(sent.raw) !== void 0 || sent.attachments.length > 0;
|
|
234
|
+
const identity = {
|
|
194
235
|
provider: "telegram",
|
|
195
|
-
externalId: sent.from.id
|
|
236
|
+
externalId: sent.from.id,
|
|
237
|
+
chat: sent.chat.type === "private" ? "private" : "group",
|
|
238
|
+
...sender === void 0 ? {} : { display: sender },
|
|
239
|
+
...sent.from.username === void 0 ? {} : { username: sent.from.username }
|
|
240
|
+
};
|
|
241
|
+
const resolve = (turn) => admitMessage({
|
|
242
|
+
identity: {
|
|
243
|
+
...identity,
|
|
244
|
+
turn
|
|
245
|
+
},
|
|
246
|
+
identities: dependencies.identities,
|
|
247
|
+
settings: dependencies.admission(),
|
|
248
|
+
languageCode: sent.from.languageCode,
|
|
249
|
+
sendNotice: (text) => context.telegram.post(text),
|
|
250
|
+
noticeFailed: (error) => {
|
|
251
|
+
reportTelegramError("identity_notice_failed", error, { provider: "telegram" });
|
|
252
|
+
}
|
|
196
253
|
});
|
|
254
|
+
const turn = addressed !== void 0 && hasContent && (addressed !== "reply-to-bot" || elsewhere === void 0);
|
|
255
|
+
const lookup = await resolve(turn);
|
|
197
256
|
const group = isGroupChat(sent);
|
|
198
257
|
const chat = observedChatKey({
|
|
199
258
|
chatId: sent.chat.id,
|
|
@@ -202,7 +261,7 @@ async function handleInboundMessage(input) {
|
|
|
202
261
|
const report = group ? dependencies.chats.seen({
|
|
203
262
|
chatId: sent.chat.id,
|
|
204
263
|
title: sent.chat.title ?? "",
|
|
205
|
-
...lookup.status === "
|
|
264
|
+
...lookup.status === "existing" || lookup.status === "created" ? { memberId: lookup.member.id } : {}
|
|
206
265
|
}) : void 0;
|
|
207
266
|
if (report !== void 0) input.request?.waitUntil?.(report);
|
|
208
267
|
const voice = voiceOf(sent.raw);
|
|
@@ -266,37 +325,43 @@ async function handleInboundMessage(input) {
|
|
|
266
325
|
text = [caption, unavailable].filter(Boolean).join("\n\n");
|
|
267
326
|
}
|
|
268
327
|
};
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const retained = input.request?.edited ? void 0 : dependencies.observed.recent(chat).find((entry) => entry.messageId === sent.messageId);
|
|
272
|
-
if (retained === void 0) {
|
|
273
|
-
if (voice !== void 0) await transcribeVoice(true);
|
|
274
|
-
await recordInboundContent({
|
|
275
|
-
text,
|
|
276
|
-
decoded
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
dependencies.observed.add(chat, retained ?? observedMessage(sent, text || "[attachment]", void 0, notes.join("\n"), bot));
|
|
280
|
-
}
|
|
281
|
-
recordGateDecision(`identity_${lookup.status}`, !group && addressed !== void 0 ? "reply" : "silent");
|
|
282
|
-
if (addressed !== void 0 && !group) await answerUnpaired({
|
|
328
|
+
async function reject(refused, reply) {
|
|
329
|
+
const replied = hasContent && reply && (refused.status !== "limited" || refused.notify) ? await answerUnpaired({
|
|
283
330
|
context,
|
|
284
331
|
message: sent,
|
|
285
|
-
lookup,
|
|
332
|
+
lookup: refused,
|
|
286
333
|
bot,
|
|
287
334
|
dependencies
|
|
335
|
+
}) : false;
|
|
336
|
+
recordGateDecision("identity_" + refused.status, replied ? "reply" : "silent");
|
|
337
|
+
}
|
|
338
|
+
async function observeRaw() {
|
|
339
|
+
await recordInboundContent({
|
|
340
|
+
text,
|
|
341
|
+
decoded
|
|
288
342
|
});
|
|
343
|
+
if (group) dependencies.observed.add(chat, observedMessage(sent, text || "[attachment]", void 0, void 0, bot));
|
|
344
|
+
}
|
|
345
|
+
if (lookup.status !== "existing" && lookup.status !== "created") {
|
|
346
|
+
const configuredGroupPairing = lookup.status === "pairing_required" && localizedText(dependencies.admission().replies.groupPairingRequired, sent.from.languageCode) !== void 0;
|
|
347
|
+
await reject(lookup, addressed !== void 0 && (!group || lookup.status === "limited" || configuredGroupPairing));
|
|
348
|
+
await observeRaw();
|
|
289
349
|
return null;
|
|
290
350
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
351
|
+
let member = lookup.member;
|
|
352
|
+
let turnAdmitted = turn || !lookup.provisioned || member.roles.length !== 1 || member.roles[0] !== "member";
|
|
353
|
+
let admissionRefused = false;
|
|
354
|
+
async function admitTurn(reply) {
|
|
355
|
+
if (turnAdmitted) return true;
|
|
356
|
+
const admitted = await resolve(true);
|
|
357
|
+
if (admitted.status !== "existing" && admitted.status !== "created") {
|
|
358
|
+
admissionRefused = true;
|
|
359
|
+
await reject(admitted, reply);
|
|
360
|
+
return false;
|
|
299
361
|
}
|
|
362
|
+
member = admitted.member;
|
|
363
|
+
turnAdmitted = true;
|
|
364
|
+
return true;
|
|
300
365
|
}
|
|
301
366
|
let address = scopedAddress(messageAddress(sent), dependencies.sessions().scope);
|
|
302
367
|
const operations = input.request?.sessions;
|
|
@@ -306,7 +371,7 @@ async function handleInboundMessage(input) {
|
|
|
306
371
|
conversationId: ""
|
|
307
372
|
});
|
|
308
373
|
const questionOwner = {
|
|
309
|
-
memberId:
|
|
374
|
+
memberId: member.id,
|
|
310
375
|
chatId: sent.chat.id,
|
|
311
376
|
messageThreadId: String(sent.messageThreadId ?? "")
|
|
312
377
|
};
|
|
@@ -315,7 +380,7 @@ async function handleInboundMessage(input) {
|
|
|
315
380
|
const owner = questionSession ?? await operations?.resolveSession(address) ?? await operations?.resolveSession(topicAddress);
|
|
316
381
|
const questionHistory = owner === void 0 || dependencies.history === void 0 ? [] : await dependencies.history(owner.id);
|
|
317
382
|
const pending = questionFromHistory(questionHistory, questionOwner);
|
|
318
|
-
let answering = pending !== void 0 && pending.memberId ===
|
|
383
|
+
let answering = pending !== void 0 && pending.memberId === member.id && pending.chatId === sent.chat.id && pending.messageThreadId === String(sent.messageThreadId ?? "");
|
|
319
384
|
const asked = questionHistory.findLast((event) => {
|
|
320
385
|
const data = isRecord(event.data) ? event.data : {};
|
|
321
386
|
const result = isRecord(data.result) ? data.result : {};
|
|
@@ -328,7 +393,27 @@ async function handleInboundMessage(input) {
|
|
|
328
393
|
address = pending.address || (questionSession === void 0 ? topicAddress : memberQuestionAddress);
|
|
329
394
|
input.request.address = address;
|
|
330
395
|
}
|
|
331
|
-
|
|
396
|
+
if (group && (addressed === void 0 && !answering && dependencies.policy === "mention" || elsewhere !== void 0 && (addressed === void 0 || addressed === "reply-to-bot" && !recipientAnswer))) {
|
|
397
|
+
await recordInboundContent({
|
|
398
|
+
text,
|
|
399
|
+
decoded
|
|
400
|
+
});
|
|
401
|
+
dependencies.observed.add(chat, observedMessage(sent, text || "[attachment]", member, void 0, bot));
|
|
402
|
+
recordRouteDecision("observe", addressed, dependencies.policy, {
|
|
403
|
+
reason: elsewhere === void 0 ? "mention_policy" : "other_recipient",
|
|
404
|
+
note: elsewhere ?? "Mention policy observes unaddressed messages."
|
|
405
|
+
});
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
if (hasContent && answering && !await admitTurn(true)) {
|
|
409
|
+
await observeRaw();
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
if ((voice !== void 0 || sent.attachments.length > 0) && !await admitTurn(addressed !== void 0 || answering)) {
|
|
413
|
+
await observeRaw();
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
const mediaPlan = voice !== void 0 || sent.attachments.length > 0 || dependencies.observed.list(chat).some((message) => (message.images?.length ?? 0) > 0) ? await dependencies.mediaPlan?.(member, owner?.id) : void 0;
|
|
332
417
|
if (voice !== void 0 && !group) await transcribeVoice(false);
|
|
333
418
|
if (voice !== void 0 && mediaPlan?.audio) notes.push("[The chat model supports audio, but this host cannot deliver native audio. The configured speech-to-text fallback was used if enabled.]");
|
|
334
419
|
if (text.trim() === "" && sent.attachments.length === 0) {
|
|
@@ -352,7 +437,11 @@ async function handleInboundMessage(input) {
|
|
|
352
437
|
media_type: file.mediaType,
|
|
353
438
|
size: file.size
|
|
354
439
|
})) })];
|
|
355
|
-
let media = [
|
|
440
|
+
let media = [
|
|
441
|
+
input.request?.previousContent?.media,
|
|
442
|
+
...attachmentMetadata,
|
|
443
|
+
...notes
|
|
444
|
+
].filter(Boolean).join("\n");
|
|
356
445
|
let processedMedia;
|
|
357
446
|
let classified = false;
|
|
358
447
|
let question;
|
|
@@ -386,7 +475,7 @@ async function handleInboundMessage(input) {
|
|
|
386
475
|
recent: context.recent,
|
|
387
476
|
pendingConfirmations: context.pendingConfirmations,
|
|
388
477
|
pendingQuestions: context.pendingQuestions.filter((question) => question.chatId === sent.chat.id && question.messageThreadId === String(sent.messageThreadId ?? "")),
|
|
389
|
-
message: observedMessage(sent, text,
|
|
478
|
+
message: observedMessage(sent, text, member, media, bot),
|
|
390
479
|
audience,
|
|
391
480
|
captionlessAttachments: caption.trim() === "" ? messageMetadata(sent, bot).attachmentKinds : []
|
|
392
481
|
};
|
|
@@ -394,12 +483,15 @@ async function handleInboundMessage(input) {
|
|
|
394
483
|
};
|
|
395
484
|
const route = await routeMessage({
|
|
396
485
|
chatId: sent.chat.id,
|
|
397
|
-
memberId:
|
|
486
|
+
memberId: member.id,
|
|
398
487
|
policy: dependencies.policy,
|
|
399
488
|
addressed: addressed ?? (answering ? "pending-question" : void 0),
|
|
400
489
|
recipientAnswer,
|
|
401
490
|
audience,
|
|
402
|
-
record: (decision) =>
|
|
491
|
+
record: (decision) => {
|
|
492
|
+
if (decision.reason !== "admission_refused") recordRouteDecision(decision.route, addressed, dependencies.policy, decision);
|
|
493
|
+
},
|
|
494
|
+
beforeDecide: () => admitTurn(false),
|
|
403
495
|
decide: dependencies.decide,
|
|
404
496
|
question: async () => {
|
|
405
497
|
classified = true;
|
|
@@ -447,11 +539,19 @@ async function handleInboundMessage(input) {
|
|
|
447
539
|
return conversation();
|
|
448
540
|
}
|
|
449
541
|
});
|
|
450
|
-
if (
|
|
542
|
+
if (admissionRefused) {
|
|
543
|
+
await recordInboundContent({
|
|
544
|
+
text,
|
|
545
|
+
decoded
|
|
546
|
+
});
|
|
547
|
+
dependencies.observed.add(chat, observedMessage(sent, text || "[attachment]", member, void 0, bot));
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
if (group && route === "observe") dependencies.observed.add(chat, observedMessage(sent, text, member, media, bot));
|
|
451
551
|
if (sent.attachments.length > 0) await report;
|
|
452
|
-
let files = sent.attachments.length === 0 || dependencies.attachments === void 0 || input.request === void 0 ? new Map() : await stageFiles({
|
|
552
|
+
let files = input.request?.edited && !input.request.attachmentsChanged && (input.request.previousContent?.decoded.length ?? 0) > 0 || sent.attachments.length === 0 || dependencies.attachments === void 0 || input.request === void 0 ? new Map() : await stageFiles({
|
|
453
553
|
message: sent,
|
|
454
|
-
memberId:
|
|
554
|
+
memberId: member.id,
|
|
455
555
|
attachments: dependencies.attachments,
|
|
456
556
|
download: async (fileId) => {
|
|
457
557
|
const bytes = voiceBytes !== void 0 && voice?.fileId === fileId ? voiceBytes : routingBytes !== void 0 && routingFile?.fileId === fileId ? routingBytes : await dependencies.download(fileId);
|
|
@@ -487,7 +587,7 @@ async function handleInboundMessage(input) {
|
|
|
487
587
|
if (route === "observe") {
|
|
488
588
|
const images = [...files.values()].flatMap((file) => file.status === "stored" && file.imageInput === "native" ? [file.attachment] : []);
|
|
489
589
|
dependencies.observed.add(chat, {
|
|
490
|
-
...observedMessage(sent, text === "" ? "[attachment]" : text,
|
|
590
|
+
...observedMessage(sent, text === "" ? "[attachment]" : text, member, media, bot),
|
|
491
591
|
images
|
|
492
592
|
});
|
|
493
593
|
return null;
|
|
@@ -539,7 +639,7 @@ async function handleInboundMessage(input) {
|
|
|
539
639
|
type: "file",
|
|
540
640
|
data: attachmentUrl({
|
|
541
641
|
id: image.id,
|
|
542
|
-
memberId:
|
|
642
|
+
memberId: member.id,
|
|
543
643
|
conversation: `telegram:${sent.chat.id}`
|
|
544
644
|
}),
|
|
545
645
|
mediaType: image.contentType,
|
|
@@ -553,13 +653,13 @@ async function handleInboundMessage(input) {
|
|
|
553
653
|
recordGateDecision("message_already_handled");
|
|
554
654
|
return null;
|
|
555
655
|
}
|
|
556
|
-
dependencies.observed.remember(chat, observedMessage(sent, text,
|
|
656
|
+
dependencies.observed.remember(chat, observedMessage(sent, text, member, retainedMedia, bot));
|
|
557
657
|
const principal = telegramPrincipal({
|
|
558
658
|
message: {
|
|
559
659
|
...sent,
|
|
560
660
|
text
|
|
561
661
|
},
|
|
562
|
-
member
|
|
662
|
+
member,
|
|
563
663
|
conversationLanguage: language
|
|
564
664
|
});
|
|
565
665
|
return {
|
|
@@ -575,7 +675,7 @@ async function handleInboundMessage(input) {
|
|
|
575
675
|
...group ? chatContext(dependencies.chats.chat(sent.chat.id)) : [],
|
|
576
676
|
speakerBlock({
|
|
577
677
|
message: sent,
|
|
578
|
-
member
|
|
678
|
+
member,
|
|
579
679
|
bot,
|
|
580
680
|
omitQuote: group && question?.recent.some((entry) => "role" in entry && entry.confirmation !== void 0 && entry.messageId === audience.replyTo?.messageId)
|
|
581
681
|
}),
|
|
@@ -21,3 +21,5 @@ export declare function conversationLanguage(texts: readonly string[]): "ru" | "
|
|
|
21
21
|
export declare function memberIdOf(principal: SessionAuthContext | null | undefined): string | undefined;
|
|
22
22
|
/** The member's roles on a session principal; empty when it carries none. */
|
|
23
23
|
export declare function rolesOf(principal: SessionAuthContext | null | undefined): ReadonlyArray<string>;
|
|
24
|
+
/** Verified channel provenance stamped by the channel adapter, never by model input. */
|
|
25
|
+
export declare function identityProviderOf(principal: SessionAuthContext | null | undefined): string | undefined;
|
|
@@ -13,6 +13,7 @@ function telegramPrincipal(input) {
|
|
|
13
13
|
const sender = message.from.id;
|
|
14
14
|
const attributes = {
|
|
15
15
|
memberId: member.id,
|
|
16
|
+
identityProvider: "telegram",
|
|
16
17
|
roles: member.roles,
|
|
17
18
|
chatId: message.chat.id,
|
|
18
19
|
chatType: message.chat.type
|
|
@@ -48,4 +49,8 @@ function rolesOf(principal) {
|
|
|
48
49
|
if (typeof roles === "string") return [roles];
|
|
49
50
|
return roles ?? [];
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
+
function identityProviderOf(principal) {
|
|
53
|
+
const provider = principal?.attributes.identityProvider;
|
|
54
|
+
return typeof provider === "string" && provider !== "" ? provider : void 0;
|
|
55
|
+
}
|
|
56
|
+
export { TELEGRAM_AUTHENTICATOR, conversationLanguage, identityProviderOf, memberIdOf, rolesOf, telegramPrincipal };
|
|
@@ -111,5 +111,6 @@ export declare function routeMessage(input: {
|
|
|
111
111
|
readonly record?: (decision: RouteDecision) => void;
|
|
112
112
|
/** Builds the question only when a decision is needed: it reads the agent's description. */
|
|
113
113
|
readonly question: () => Promise<RouteQuestion>;
|
|
114
|
+
readonly beforeDecide?: () => Promise<boolean>;
|
|
114
115
|
readonly decide: RouteDecider;
|
|
115
116
|
}): Promise<Route>;
|
|
@@ -233,6 +233,8 @@ async function routeMessage(input) {
|
|
|
233
233
|
if (elsewhere !== void 0) return complete("observe", "other_recipient", elsewhere);
|
|
234
234
|
if (input.addressed === "reply-to-bot") return complete("reply", "reply-to-bot", "Bot addressed directly: reply-to-bot.");
|
|
235
235
|
if (input.addressed === "pending-question") return complete("reply", "pending-question", "Answer to this member's pending agent question, without another explicit recipient.");
|
|
236
|
+
if (input.policy === "mention") return complete("observe", "mention_policy", "Mention policy observes unaddressed messages.");
|
|
237
|
+
if (input.beforeDecide !== void 0 && !await input.beforeDecide()) return complete("observe", "admission_refused", "Admission refused before decoding or inference.");
|
|
236
238
|
try {
|
|
237
239
|
const question = await input.question();
|
|
238
240
|
const elsewhere = otherRecipient(question.audience);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream/agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Mount a SegmentStream workspace's agent on eve: the ontology as tools, acting as the member who writes, and Telegram through the workspace's connection.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SegmentStream",
|