@segmentstream/agent 0.1.4 → 0.1.5
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 +44 -13
- package/dist/extension/instructions.md +5 -0
- package/dist/extension/lib/environment.d.ts +13 -0
- package/dist/extension/lib/environment.mjs +8 -1
- package/dist/extension/lib/identities.d.ts +22 -1
- package/dist/extension/lib/identities.mjs +20 -0
- package/dist/extension/lib/runtime-identity.d.ts +22 -0
- package/dist/extension/lib/runtime-identity.mjs +62 -0
- package/dist/extension/lib/telegram/adapter.d.ts +3 -0
- package/dist/extension/lib/telegram/adapter.mjs +17 -3
- package/dist/extension/lib/telegram/chats.d.ts +46 -0
- package/dist/extension/lib/telegram/chats.mjs +123 -0
- package/dist/extension/lib/telegram/inbound-request.d.ts +2 -0
- package/dist/extension/lib/telegram/inbound-request.mjs +1 -0
- package/dist/extension/lib/telegram/inbound.d.ts +11 -3
- package/dist/extension/lib/telegram/inbound.mjs +68 -0
- package/dist/extension/lib/telegram/requests.d.ts +71 -0
- package/dist/extension/lib/telegram/requests.mjs +173 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -97,6 +97,7 @@ platform's deployment manifest.
|
|
|
97
97
|
| ------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
98
98
|
| `segmentstream__ontology` | `connections` | The app's `/mcp`, resolved per turn for the turn's member. No member, no connection. |
|
|
99
99
|
| `segmentstream__telegram` | `channels` | `POST /eve/v1/telegram`: Telegram through the connection, with pairing, routing and voice. |
|
|
100
|
+
| | | `POST /eve/v1/telegram/messages`: requests from the ontology's `agent_chat.send_message`. |
|
|
100
101
|
| platform rules | `instructions` | Changes go through actions; `describe` gives the catalog; tools are already narrowed; when to answer `[SILENT]` |
|
|
101
102
|
|
|
102
103
|
### The ontology
|
|
@@ -139,12 +140,39 @@ share a group chat's session never share a client.
|
|
|
139
140
|
such as `[voice message, 12 s]` in context. A file over the proxy's 2 MiB limit, or a failed
|
|
140
141
|
transcription, reaches the agent as a note saying so. Photos and documents up to 2 MiB reach the
|
|
141
142
|
agent as eve's file parts.
|
|
143
|
+
- **Chat registry.** Every group message reports its chat to the ontology's `agent_chat` registry
|
|
144
|
+
(`POST /api/ontology/agent/chats/seen`) with its title and, when the sender is paired, the
|
|
145
|
+
member's id: at most once per chat every ten minutes per process, and at once for a member not
|
|
146
|
+
yet reported there. The report runs in the background; a failure is logged and changes nothing,
|
|
147
|
+
so the next message reports again.
|
|
148
|
+
An answered group turn waits for its report, bounded by the call's ten-second timeout, and
|
|
149
|
+
carries the registry's `title`, `purpose` and `whenToUse` as a `<chat>` block. When a paired
|
|
150
|
+
member leaves the group, or is removed, the agent reports it with `leftMemberId`, so the
|
|
151
|
+
registry unlinks them; it starts no turn. A later message from them links them again. When the
|
|
152
|
+
agent itself leaves or is removed, it reports `agentLeft: true`, so the registry unlinks every
|
|
153
|
+
member and keeps the chat; members link again when they write after the agent is back. When a
|
|
154
|
+
group becomes a supergroup, either notice (`migrate_to_chat_id` in the old chat,
|
|
155
|
+
`migrate_from_chat_id` in the new one) reports the new id with `migratedFrom: <old id>`, starts
|
|
156
|
+
no turn and drops the old chat from the memo. The `<chat>` block carries at most 1000
|
|
157
|
+
characters of `whenToUse`.
|
|
158
|
+
- **Requests.** `POST /eve/v1/telegram/messages` takes a request from the ontology's
|
|
159
|
+
`agent_chat.send_message`: a group or private chat, the request, the person to contact and the
|
|
160
|
+
requester, with their roles (empty when an older ontology sends none) and `actAs` (whether the
|
|
161
|
+
turn can act as them; without it, true for a member). The bearer must be a Vercel OIDC token of a production function of the same project,
|
|
162
|
+
verified with the team's issuer and audience. The request reaches the chat's session through
|
|
163
|
+
eve's proactive `receive` at `chatId::`, the address the chat's own messages continue, after the
|
|
164
|
+
session policy (idle reset, daily compaction) runs on it, with an `<agent_request>` block naming the requester and the person. A member's request with `actAs` runs as that
|
|
165
|
+
member, with their roles, which choose the model of a session the request starts; a service's,
|
|
166
|
+
or an unpaired member's (`actAs: false`), runs without a member and has no ontology, its roles
|
|
167
|
+
still choosing the model. It answers 202 with the session id, 503 outside
|
|
168
|
+
production, 404 without `channels.telegram`, 401 for a missing or invalid token and 400 for a
|
|
169
|
+
body that does not match, including a request over 4000 characters.
|
|
142
170
|
- **Silence.** When the model answers a routed message with exactly `[SILENT]` (or `NO_REPLY`), in
|
|
143
171
|
any case, nothing is sent to the chat. The turn stays in the session.
|
|
144
172
|
|
|
145
|
-
The observed ring
|
|
173
|
+
The observed ring and the chat reports live in process memory, per chat and forum topic, because eve's durable
|
|
146
174
|
`defineState` is session state and an observed message has no session. A new function instance
|
|
147
|
-
starts with an empty ring.
|
|
175
|
+
starts with an empty ring and reports its chats afresh.
|
|
148
176
|
|
|
149
177
|
## Local development
|
|
150
178
|
|
|
@@ -163,17 +191,20 @@ manifest.
|
|
|
163
191
|
The platform sets every variable on the workspace's Vercel project. None of them is a model key or
|
|
164
192
|
a provider token.
|
|
165
193
|
|
|
166
|
-
| Variable
|
|
167
|
-
|
|
|
168
|
-
| `VERCEL_PROJECT_PRODUCTION_URL`
|
|
169
|
-
| `SEGMENTSTREAM_AGENT_ONTOLOGY_URL`
|
|
170
|
-
| `SEGMENTSTREAM_URL`
|
|
171
|
-
| `SEGMENTSTREAM_WORKSPACE_ID`
|
|
172
|
-
| `SEGMENTSTREAM_ORGANIZATION_ID`
|
|
173
|
-
| `SEGMENTSTREAM_AGENT_WEBHOOK_SECRET`
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
194
|
+
| Variable | Used for |
|
|
195
|
+
| ------------------------------------- | ------------------------------------------------------------------------------- |
|
|
196
|
+
| `VERCEL_PROJECT_PRODUCTION_URL` | The app's origin: `/mcp`, `/api/ontology/agent/*` and the `/agent/connect` link |
|
|
197
|
+
| `SEGMENTSTREAM_AGENT_ONTOLOGY_URL` | Overrides that origin in local development |
|
|
198
|
+
| `SEGMENTSTREAM_URL` | backend-next, where the connection proxy answers |
|
|
199
|
+
| `SEGMENTSTREAM_WORKSPACE_ID` | The workspace in the proxy's path |
|
|
200
|
+
| `SEGMENTSTREAM_ORGANIZATION_ID` | The tenant the proxy selects |
|
|
201
|
+
| `SEGMENTSTREAM_AGENT_WEBHOOK_SECRET` | The Telegram webhook secret; the only secret |
|
|
202
|
+
| `SEGMENTSTREAM_RUNTIME_OIDC_ISSUER` | The team's OIDC issuer, which signs the tokens the messages route accepts |
|
|
203
|
+
| `SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE` | The audience those tokens must name |
|
|
204
|
+
| `VERCEL_PROJECT_ID` | The project those tokens must name |
|
|
205
|
+
| `VERCEL_ENV` | Channels run only in `production`; `preview` selects the preview model |
|
|
206
|
+
| `VERCEL_OIDC_TOKEN` | The runtime token outside a request; Vercel sends it on each request otherwise |
|
|
207
|
+
| `AI_GATEWAY_API_KEY` | Optional. AI Gateway uses the OIDC token when it is unset |
|
|
177
208
|
|
|
178
209
|
## License
|
|
179
210
|
|
|
@@ -11,3 +11,8 @@ talking to you, and you act with exactly their permissions.
|
|
|
11
11
|
- Never invent records or values. Say when the ontology does not hold what was asked.
|
|
12
12
|
- When you have been routed a message but a reply would add nothing, answer with exactly
|
|
13
13
|
`[SILENT]` and nothing else. Use it only in that case; it suppresses delivery to the chat.
|
|
14
|
+
- To ask someone something in a group or a private chat, run the ontology action
|
|
15
|
+
`agent_chat.send_message`; their replies come back into that chat's session, where you handle
|
|
16
|
+
them. The groups you are in are listed in `agent_chat`, with the `purpose` and `whenToUse` members
|
|
17
|
+
set; when a member who may change it tells you what a group is for, record it with
|
|
18
|
+
`agent_chat.update`.
|
|
@@ -20,6 +20,19 @@ export interface ProxyEnvironment {
|
|
|
20
20
|
readonly organizationId: string;
|
|
21
21
|
}
|
|
22
22
|
export declare function proxyEnvironment(environment?: Environment): ProxyEnvironment;
|
|
23
|
+
/**
|
|
24
|
+
* Whom the agent trusts as a caller of its own routes: Vercel OIDC tokens of the functions of this
|
|
25
|
+
* deployment's project, such as the ontology service's. The platform sets the issuer and the audience
|
|
26
|
+
* on the project for the ontology service; Vercel sets `VERCEL_PROJECT_ID`.
|
|
27
|
+
*/
|
|
28
|
+
export interface RuntimeIdentityEnvironment {
|
|
29
|
+
/** The team's issuer, `https://oidc.vercel.com/<team slug>`, without a trailing slash. */
|
|
30
|
+
readonly issuer: string;
|
|
31
|
+
/** The audience Vercel issues function tokens for: `https://vercel.com/<team slug>`. */
|
|
32
|
+
readonly audience: string;
|
|
33
|
+
readonly projectId: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function runtimeIdentityEnvironment(environment?: Environment): RuntimeIdentityEnvironment;
|
|
23
36
|
/** The secret Telegram echoes in `X-Telegram-Bot-Api-Secret-Token`; `undefined` when unset. */
|
|
24
37
|
export declare function webhookSecret(environment?: Environment): string | undefined;
|
|
25
38
|
/**
|
|
@@ -33,6 +33,13 @@ function proxyEnvironment(environment = process.env) {
|
|
|
33
33
|
organizationId: required(environment, "SEGMENTSTREAM_ORGANIZATION_ID")
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
+
function runtimeIdentityEnvironment(environment = process.env) {
|
|
37
|
+
return {
|
|
38
|
+
issuer: withoutTrailingSlashes(required(environment, "SEGMENTSTREAM_RUNTIME_OIDC_ISSUER")),
|
|
39
|
+
audience: required(environment, "SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE"),
|
|
40
|
+
projectId: required(environment, "VERCEL_PROJECT_ID")
|
|
41
|
+
};
|
|
42
|
+
}
|
|
36
43
|
function webhookSecret(environment = process.env) {
|
|
37
44
|
const secret = environment.SEGMENTSTREAM_AGENT_WEBHOOK_SECRET;
|
|
38
45
|
return secret === void 0 || secret === "" ? void 0 : secret;
|
|
@@ -43,4 +50,4 @@ function channelsEnabled(environment = process.env) {
|
|
|
43
50
|
function isPreview(environment = process.env) {
|
|
44
51
|
return environment.VERCEL_ENV === "preview";
|
|
45
52
|
}
|
|
46
|
-
export { AgentEnvironmentError, appOrigin, channelsEnabled, isPreview, proxyEnvironment, webhookSecret };
|
|
53
|
+
export { AgentEnvironmentError, appOrigin, channelsEnabled, isPreview, proxyEnvironment, runtimeIdentityEnvironment, webhookSecret };
|
|
@@ -3,7 +3,8 @@ import type { RuntimeToken } from "./runtime-token.js";
|
|
|
3
3
|
* The ontology service's pairing routes (`agent-design.md`, section 13, "Identity"). The agent
|
|
4
4
|
* calls them as the deployment, with its OIDC token: it looks up which member a chat account is
|
|
5
5
|
* paired with, and asks for a pairing code when it is paired with nobody. The member approves the
|
|
6
|
-
* code on the app's `/agent/connect` page.
|
|
6
|
+
* code on the app's `/agent/connect` page. It also reports the group chats it is in to the
|
|
7
|
+
* ontology's `agent_chat` registry.
|
|
7
8
|
*/
|
|
8
9
|
/** A member as the ontology resolves a paired chat account, with their current roles. */
|
|
9
10
|
export interface PairedMember {
|
|
@@ -33,12 +34,32 @@ export interface ChatAccount {
|
|
|
33
34
|
readonly provider: string;
|
|
34
35
|
readonly externalId: string;
|
|
35
36
|
}
|
|
37
|
+
/** A group chat in the ontology's `agent_chat` registry, with what members said it is for. */
|
|
38
|
+
export interface AgentChat {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly title: string;
|
|
41
|
+
readonly purpose: string | null;
|
|
42
|
+
readonly whenToUse: string | null;
|
|
43
|
+
}
|
|
36
44
|
export interface AgentIdentities {
|
|
37
45
|
lookup(account: ChatAccount): Promise<IdentityLookup>;
|
|
38
46
|
/** Issues a pairing code, or returns the account's pending one while it has time left. */
|
|
39
47
|
createPairing(account: ChatAccount & {
|
|
40
48
|
readonly display?: string;
|
|
41
49
|
}): Promise<Pairing>;
|
|
50
|
+
/**
|
|
51
|
+
* Records that the agent saw the group chat, and the paired member who wrote, if any, or the
|
|
52
|
+
* paired member who left it, whom the registry unlinks from the chat, or that the agent itself
|
|
53
|
+
* left it (`agentLeft`), which unlinks every member, or that the chat was the group
|
|
54
|
+
* `migratedFrom` before it became a supergroup, whose record the registry moves to the new id.
|
|
55
|
+
*/
|
|
56
|
+
seenChat(chat: ChatAccount & {
|
|
57
|
+
readonly title: string;
|
|
58
|
+
readonly memberId?: string;
|
|
59
|
+
readonly leftMemberId?: string;
|
|
60
|
+
readonly agentLeft?: true;
|
|
61
|
+
readonly migratedFrom?: string;
|
|
62
|
+
}): Promise<AgentChat>;
|
|
42
63
|
}
|
|
43
64
|
/** The ontology service refused or failed a pairing call. Carries its error code, not its body. */
|
|
44
65
|
export declare class AgentIdentityError extends Error {
|
|
@@ -54,6 +54,21 @@ function pairing(value) {
|
|
|
54
54
|
expiresAt: value.expires_at
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
|
+
function nullableString(value) {
|
|
58
|
+
return value === null || typeof value === "string";
|
|
59
|
+
}
|
|
60
|
+
function agentChat(value) {
|
|
61
|
+
if (!isRecord(value) || typeof value.id !== "string" || typeof value.title !== "string" || !nullableString(value.purpose) || !nullableString(value.whenToUse)) throw new AgentIdentityError({
|
|
62
|
+
code: "invalid_response",
|
|
63
|
+
message: "The ontology service answered the chat report with an invalid body."
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
id: value.id,
|
|
67
|
+
title: value.title,
|
|
68
|
+
purpose: value.purpose,
|
|
69
|
+
whenToUse: value.whenToUse
|
|
70
|
+
};
|
|
71
|
+
}
|
|
57
72
|
async function refusal(response, call) {
|
|
58
73
|
const status = response.status;
|
|
59
74
|
let value;
|
|
@@ -127,6 +142,11 @@ function agentIdentities(input) {
|
|
|
127
142
|
const created = pairing(await response.json());
|
|
128
143
|
remember(pending, key, created);
|
|
129
144
|
return created;
|
|
145
|
+
},
|
|
146
|
+
async seenChat(chat) {
|
|
147
|
+
const response = await call("/chats/seen", { body: chat });
|
|
148
|
+
if (!response.ok) throw await refusal(response, "chat report");
|
|
149
|
+
return agentChat(await response.json());
|
|
130
150
|
}
|
|
131
151
|
};
|
|
132
152
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type JWTVerifyGetKey } from "jose";
|
|
2
|
+
import type { RuntimeIdentityEnvironment } from "./environment.js";
|
|
3
|
+
export type RuntimeVerification = {
|
|
4
|
+
readonly status: "valid";
|
|
5
|
+
}
|
|
6
|
+
/** `reason` names what failed, a jose code or a claim name, never a value. */
|
|
7
|
+
| {
|
|
8
|
+
readonly status: "invalid";
|
|
9
|
+
readonly reason: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The key set could not be read: not the token's fault. `reason` is the jose code, or the error's
|
|
13
|
+
* name and message from fetching the key set, never the token.
|
|
14
|
+
*/
|
|
15
|
+
| {
|
|
16
|
+
readonly status: "unavailable";
|
|
17
|
+
readonly reason: string;
|
|
18
|
+
};
|
|
19
|
+
export type RuntimeTokenVerifier = (token: string) => Promise<RuntimeVerification>;
|
|
20
|
+
/** The issuer's key set, at `<issuer>/.well-known/jwks` as Vercel publishes it; jose caches it. */
|
|
21
|
+
export declare function runtimeKeySet(issuer: string): JWTVerifyGetKey;
|
|
22
|
+
export declare function runtimeTokenVerifier(config: RuntimeIdentityEnvironment, keySet?: JWTVerifyGetKey): RuntimeTokenVerifier;
|
|
@@ -0,0 +1,62 @@
|
|
|
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 { createRemoteJWKSet, errors, jwtVerify } from "jose";
|
|
8
|
+
const CLOCK_TOLERANCE_SECONDS = 5;
|
|
9
|
+
const JWKS_TIMEOUT_MS = 5e3;
|
|
10
|
+
function runtimeKeySet(issuer) {
|
|
11
|
+
return createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks`), { timeoutDuration: JWKS_TIMEOUT_MS });
|
|
12
|
+
}
|
|
13
|
+
function keySetUnavailable(error) {
|
|
14
|
+
if (!(error instanceof errors.JOSEError)) return true;
|
|
15
|
+
return error instanceof errors.JWKSTimeout || error instanceof errors.JWKSInvalid || error.code === errors.JOSEError.code;
|
|
16
|
+
}
|
|
17
|
+
function unavailableReason(error) {
|
|
18
|
+
if (error instanceof errors.JOSEError) return error.code;
|
|
19
|
+
return error instanceof Error ? `${error.name}: ${error.message}` : "unknown";
|
|
20
|
+
}
|
|
21
|
+
function failureReason(error) {
|
|
22
|
+
if (error instanceof errors.JWTClaimValidationFailed || error instanceof errors.JWTExpired) return `claim:${error.claim}`;
|
|
23
|
+
return error instanceof errors.JOSEError ? error.code : "invalid";
|
|
24
|
+
}
|
|
25
|
+
function runtimeTokenVerifier(config, keySet = runtimeKeySet(config.issuer)) {
|
|
26
|
+
return async (token) => {
|
|
27
|
+
try {
|
|
28
|
+
const { payload } = await jwtVerify(token, keySet, {
|
|
29
|
+
algorithms: ["RS256"],
|
|
30
|
+
issuer: config.issuer,
|
|
31
|
+
audience: config.audience,
|
|
32
|
+
clockTolerance: CLOCK_TOLERANCE_SECONDS,
|
|
33
|
+
requiredClaims: [
|
|
34
|
+
"exp",
|
|
35
|
+
"iat",
|
|
36
|
+
"iss",
|
|
37
|
+
"aud",
|
|
38
|
+
"project_id",
|
|
39
|
+
"environment"
|
|
40
|
+
]
|
|
41
|
+
});
|
|
42
|
+
if (payload.project_id !== config.projectId) return {
|
|
43
|
+
status: "invalid",
|
|
44
|
+
reason: "claim:project_id"
|
|
45
|
+
};
|
|
46
|
+
if (payload.environment !== "production") return {
|
|
47
|
+
status: "invalid",
|
|
48
|
+
reason: "claim:environment"
|
|
49
|
+
};
|
|
50
|
+
return { status: "valid" };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return keySetUnavailable(error) ? {
|
|
53
|
+
status: "unavailable",
|
|
54
|
+
reason: unavailableReason(error)
|
|
55
|
+
} : {
|
|
56
|
+
status: "invalid",
|
|
57
|
+
reason: failureReason(error)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export { runtimeKeySet, runtimeTokenVerifier };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type TelegramChannel } from "eve/channels/telegram";
|
|
2
2
|
import type { SessionSettings, TelegramSettings } from "../../config.js";
|
|
3
3
|
import { type Environment } from "../environment.js";
|
|
4
|
+
import { type RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
4
5
|
import { type RuntimeToken } from "../runtime-token.js";
|
|
5
6
|
import { type ObservedMessages } from "./observed.js";
|
|
6
7
|
import { type DecideFunction } from "./router.js";
|
|
@@ -27,6 +28,8 @@ export interface TelegramAdapterOptions {
|
|
|
27
28
|
readonly fetch?: typeof fetch;
|
|
28
29
|
readonly transcribe?: Transcriber;
|
|
29
30
|
readonly observed?: ObservedMessages;
|
|
31
|
+
/** Verifies callers of the messages route; tests pass one on a local key set. */
|
|
32
|
+
readonly verify?: RuntimeTokenVerifier;
|
|
30
33
|
/** Reads the tail of a session's durable stream; tests pass a fake. */
|
|
31
34
|
readonly history?: HistoryReader;
|
|
32
35
|
}
|
|
@@ -4,11 +4,13 @@ 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 { appOrigin, channelsEnabled, proxyEnvironment, webhookSecret } from "../environment.mjs";
|
|
7
|
+
import { appOrigin, channelsEnabled, proxyEnvironment, runtimeIdentityEnvironment, webhookSecret } from "../environment.mjs";
|
|
8
8
|
import { MAXIMUM_PROXY_BODY_BYTES, connectionProxy } from "../connection-proxy.mjs";
|
|
9
9
|
import { agentIdentities } from "../identities.mjs";
|
|
10
|
+
import { runtimeTokenVerifier } from "../runtime-identity.mjs";
|
|
10
11
|
import { runtimeToken } from "../runtime-token.mjs";
|
|
11
12
|
import { botIdentityCache } from "./bot.mjs";
|
|
13
|
+
import { agentChats } from "./chats.mjs";
|
|
12
14
|
import { observedMessages } from "./observed.mjs";
|
|
13
15
|
import { decisionRouter } from "./router.mjs";
|
|
14
16
|
import { scopedAddress, sessionPolicy } from "./sessions.mjs";
|
|
@@ -16,6 +18,7 @@ import { downloadTelegramFile, gatewayTranscriber } from "./voice.mjs";
|
|
|
16
18
|
import { handleInboundMessage } from "./inbound.mjs";
|
|
17
19
|
import { InboundRequest, currentInboundRequest, runInboundRequest, withReplacedText } from "./inbound-request.mjs";
|
|
18
20
|
import { TELEGRAM_PLACEHOLDER_TOKEN, TELEGRAM_PROXY_ORIGIN, telegramProxyFetch } from "./proxy-fetch.mjs";
|
|
21
|
+
import { messagesRoute } from "./requests.mjs";
|
|
19
22
|
import { deliverUnlessSilent } from "./silent.mjs";
|
|
20
23
|
import { telegramWebhookVerifier } from "./webhook.mjs";
|
|
21
24
|
import { telegramChannel } from "eve/channels/telegram";
|
|
@@ -42,6 +45,7 @@ function segmentstreamTelegram(options) {
|
|
|
42
45
|
const bot = botIdentityCache();
|
|
43
46
|
const proxies = new Map();
|
|
44
47
|
let identities;
|
|
48
|
+
let verify = options.verify;
|
|
45
49
|
function settings() {
|
|
46
50
|
const configured = options.settings();
|
|
47
51
|
if (configured === null) throw new Error("The Telegram channel is not configured in agent/segmentstream.json.");
|
|
@@ -69,6 +73,7 @@ function segmentstreamTelegram(options) {
|
|
|
69
73
|
});
|
|
70
74
|
return identities;
|
|
71
75
|
}
|
|
76
|
+
const chats = agentChats({ identities: pairing });
|
|
72
77
|
const channel = telegramChannel({
|
|
73
78
|
api: {
|
|
74
79
|
apiBaseUrl: TELEGRAM_PROXY_ORIGIN,
|
|
@@ -101,6 +106,7 @@ function segmentstreamTelegram(options) {
|
|
|
101
106
|
fileId
|
|
102
107
|
}),
|
|
103
108
|
observed,
|
|
109
|
+
chats,
|
|
104
110
|
connectUrl: (code) => `${appOrigin(environment)}/agent/connect?code=${encodeURIComponent(code)}`
|
|
105
111
|
}
|
|
106
112
|
}),
|
|
@@ -108,11 +114,18 @@ function segmentstreamTelegram(options) {
|
|
|
108
114
|
});
|
|
109
115
|
return {
|
|
110
116
|
...channel,
|
|
111
|
-
routes: channel.routes.map((route) => gatedRoute({
|
|
117
|
+
routes: [...channel.routes.map((route) => gatedRoute({
|
|
112
118
|
route,
|
|
113
119
|
options,
|
|
114
120
|
environment
|
|
115
|
-
}))
|
|
121
|
+
})), messagesRoute({
|
|
122
|
+
channel,
|
|
123
|
+
enabled: () => channelsEnabled(environment),
|
|
124
|
+
configured: () => options.settings() !== null,
|
|
125
|
+
verify: () => verify ??= runtimeTokenVerifier(runtimeIdentityEnvironment(environment)),
|
|
126
|
+
sessionPolicy: policy,
|
|
127
|
+
scope: () => options.sessions().scope
|
|
128
|
+
})]
|
|
116
129
|
};
|
|
117
130
|
}
|
|
118
131
|
function gatedRoute(input) {
|
|
@@ -129,6 +142,7 @@ function gatedRoute(input) {
|
|
|
129
142
|
resolveSession: args.resolveSession,
|
|
130
143
|
from: args.from
|
|
131
144
|
};
|
|
145
|
+
inbound.waitUntil = args.waitUntil;
|
|
132
146
|
return runInboundRequest(inbound, () => route.handler(request, {
|
|
133
147
|
...args,
|
|
134
148
|
from: (address) => withReplacedText(args.from(scoped(address)), inbound)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AgentChat, AgentIdentities } from "../identities.js";
|
|
2
|
+
/** How long a chat's report stands before the next message reports it again. */
|
|
3
|
+
export declare const REPORT_INTERVAL_MS: number;
|
|
4
|
+
export interface ChatReport {
|
|
5
|
+
readonly chatId: string;
|
|
6
|
+
readonly title: string;
|
|
7
|
+
readonly memberId?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface AgentChats {
|
|
10
|
+
/** Reports the chat when due. Resolves when done; never rejects. */
|
|
11
|
+
seen(report: ChatReport): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Reports that the paired member left the chat, so the registry unlinks them, and forgets them
|
|
14
|
+
* here, so that a later message from them links them again. Resolves when done; never rejects.
|
|
15
|
+
*/
|
|
16
|
+
left(report: ChatReport & {
|
|
17
|
+
readonly memberId: string;
|
|
18
|
+
}): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Reports that the agent itself left the chat, so the registry unlinks every member, and forgets
|
|
21
|
+
* the members reported here, so that each links again when they write after the agent is back.
|
|
22
|
+
* Resolves when done; never rejects.
|
|
23
|
+
*/
|
|
24
|
+
agentLeft(report: {
|
|
25
|
+
readonly chatId: string;
|
|
26
|
+
readonly title: string;
|
|
27
|
+
}): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Reports that the group `migratedFrom` became the supergroup `chatId`, so the registry moves its
|
|
30
|
+
* record, and forgets the old chat here. Resolves when done; never rejects.
|
|
31
|
+
*/
|
|
32
|
+
migrated(report: {
|
|
33
|
+
readonly chatId: string;
|
|
34
|
+
readonly title: string;
|
|
35
|
+
readonly migratedFrom: string;
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
/** The registry's latest answer for the chat, if this process has one. */
|
|
38
|
+
chat(chatId: string): AgentChat | undefined;
|
|
39
|
+
}
|
|
40
|
+
export declare function agentChats(input: {
|
|
41
|
+
readonly identities: () => AgentIdentities;
|
|
42
|
+
readonly now?: () => number;
|
|
43
|
+
readonly log?: (message: string, error: unknown) => void;
|
|
44
|
+
}): AgentChats;
|
|
45
|
+
/** The registry's facts as turn context: only the fields it holds. */
|
|
46
|
+
export declare function chatContext(chat: AgentChat | undefined): ReadonlyArray<string>;
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
const MAXIMUM_WHEN_TO_USE_LENGTH = 1e3;
|
|
8
|
+
const REPORT_INTERVAL_MS = 6e5;
|
|
9
|
+
const MAXIMUM_CHATS = 500;
|
|
10
|
+
function agentChats(input) {
|
|
11
|
+
const chats = new Map();
|
|
12
|
+
const now = input.now ?? Date.now;
|
|
13
|
+
const log = input.log ?? ((message, error) => console.warn(message, error));
|
|
14
|
+
function remember(chatId, value) {
|
|
15
|
+
chats.delete(chatId);
|
|
16
|
+
chats.set(chatId, value);
|
|
17
|
+
if (chats.size > MAXIMUM_CHATS) {
|
|
18
|
+
const oldest = chats.keys().next();
|
|
19
|
+
if (oldest.done !== true) chats.delete(oldest.value);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function restore(chatId, written, previous) {
|
|
23
|
+
if (chats.get(chatId) !== written) return;
|
|
24
|
+
if (previous === void 0) chats.delete(chatId);
|
|
25
|
+
else chats.set(chatId, previous);
|
|
26
|
+
}
|
|
27
|
+
async function report(chatId, fallback, body) {
|
|
28
|
+
try {
|
|
29
|
+
const chat = await input.identities().seenChat({
|
|
30
|
+
provider: "telegram",
|
|
31
|
+
externalId: chatId,
|
|
32
|
+
...body
|
|
33
|
+
});
|
|
34
|
+
remember(chatId, {
|
|
35
|
+
...chats.get(chatId) ?? fallback,
|
|
36
|
+
chat
|
|
37
|
+
});
|
|
38
|
+
return true;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
log("The agent could not report the chat to the ontology's registry.", error);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
async seen({ chatId, title, memberId }) {
|
|
46
|
+
const known = chats.get(chatId);
|
|
47
|
+
const at = now();
|
|
48
|
+
if (!(known === void 0 || at - known.reportedAt >= 6e5 || memberId !== void 0 && !known.members.has(memberId))) return;
|
|
49
|
+
const members = new Set(known?.members);
|
|
50
|
+
if (memberId !== void 0) members.add(memberId);
|
|
51
|
+
const written = {
|
|
52
|
+
reportedAt: at,
|
|
53
|
+
members,
|
|
54
|
+
chat: known?.chat
|
|
55
|
+
};
|
|
56
|
+
remember(chatId, written);
|
|
57
|
+
if (!await report(chatId, {
|
|
58
|
+
reportedAt: at,
|
|
59
|
+
members,
|
|
60
|
+
chat: void 0
|
|
61
|
+
}, {
|
|
62
|
+
title,
|
|
63
|
+
...memberId === void 0 ? {} : { memberId }
|
|
64
|
+
})) restore(chatId, written, known);
|
|
65
|
+
},
|
|
66
|
+
async left({ chatId, title, memberId }) {
|
|
67
|
+
const known = chats.get(chatId);
|
|
68
|
+
const remembered = {
|
|
69
|
+
reportedAt: known?.reportedAt ?? now(),
|
|
70
|
+
members: new Set([...known?.members ?? []].filter((member) => member !== memberId)),
|
|
71
|
+
chat: known?.chat
|
|
72
|
+
};
|
|
73
|
+
remember(chatId, remembered);
|
|
74
|
+
if (!await report(chatId, remembered, {
|
|
75
|
+
title,
|
|
76
|
+
leftMemberId: memberId
|
|
77
|
+
})) restore(chatId, remembered, known);
|
|
78
|
+
},
|
|
79
|
+
async agentLeft({ chatId, title }) {
|
|
80
|
+
const known = chats.get(chatId);
|
|
81
|
+
const remembered = {
|
|
82
|
+
reportedAt: known?.reportedAt ?? now(),
|
|
83
|
+
members: new Set(),
|
|
84
|
+
chat: known?.chat
|
|
85
|
+
};
|
|
86
|
+
remember(chatId, remembered);
|
|
87
|
+
if (!await report(chatId, remembered, {
|
|
88
|
+
title,
|
|
89
|
+
agentLeft: true
|
|
90
|
+
})) restore(chatId, remembered, known);
|
|
91
|
+
},
|
|
92
|
+
async migrated({ chatId, title, migratedFrom }) {
|
|
93
|
+
const old = chats.get(migratedFrom);
|
|
94
|
+
const known = chats.get(chatId);
|
|
95
|
+
const remembered = {
|
|
96
|
+
reportedAt: now(),
|
|
97
|
+
members: new Set([...old?.members ?? [], ...known?.members ?? []]),
|
|
98
|
+
chat: known?.chat
|
|
99
|
+
};
|
|
100
|
+
chats.delete(migratedFrom);
|
|
101
|
+
remember(chatId, remembered);
|
|
102
|
+
if (!await report(chatId, remembered, {
|
|
103
|
+
title,
|
|
104
|
+
migratedFrom
|
|
105
|
+
})) {
|
|
106
|
+
restore(chatId, remembered, known);
|
|
107
|
+
if (old !== void 0 && !chats.has(migratedFrom)) remember(migratedFrom, old);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
chat(chatId) {
|
|
111
|
+
return chats.get(chatId)?.chat;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function chatContext(chat) {
|
|
116
|
+
if (chat === void 0) return [];
|
|
117
|
+
const lines = ["<chat>", `title: ${chat.title}`];
|
|
118
|
+
if (chat.purpose !== null && chat.purpose !== "") lines.push(`purpose: ${chat.purpose}`);
|
|
119
|
+
if (chat.whenToUse !== null && chat.whenToUse !== "") lines.push(`when_to_use: ${chat.whenToUse.slice(0, MAXIMUM_WHEN_TO_USE_LENGTH)}`);
|
|
120
|
+
lines.push("</chat>");
|
|
121
|
+
return [lines.join("\n")];
|
|
122
|
+
}
|
|
123
|
+
export { REPORT_INTERVAL_MS, agentChats, chatContext };
|
|
@@ -14,6 +14,8 @@ export declare class InboundRequest {
|
|
|
14
14
|
text: string | undefined;
|
|
15
15
|
/** The route's session operations, on addresses already mapped to the configured scope. */
|
|
16
16
|
sessions: SessionOperations | undefined;
|
|
17
|
+
/** Keeps work the request started, such as a chat report, alive after it answers. */
|
|
18
|
+
waitUntil: ((task: Promise<unknown>) => void) | undefined;
|
|
17
19
|
constructor(
|
|
18
20
|
/** The agent's description from the compiled manifest, for the routing decision. */
|
|
19
21
|
agentDescription: () => Promise<string>);
|
|
@@ -2,6 +2,7 @@ import type { TelegramContext, TelegramInboundResult, TelegramMessage } from "ev
|
|
|
2
2
|
import type { SessionSettings, TelegramPolicy } from "../../config.js";
|
|
3
3
|
import type { AgentIdentities } from "../identities.js";
|
|
4
4
|
import type { BotIdentityLookup } from "./bot.js";
|
|
5
|
+
import { type AgentChats } from "./chats.js";
|
|
5
6
|
import type { InboundRequest } from "./inbound-request.js";
|
|
6
7
|
import { type ObservedMessages } from "./observed.js";
|
|
7
8
|
import { type RouteDecider } from "./router.js";
|
|
@@ -11,13 +12,19 @@ import { type Transcriber } from "./voice.js";
|
|
|
11
12
|
* eve's Telegram `onMessage` hook, in the order `agent-design.md` gives:
|
|
12
13
|
*
|
|
13
14
|
* 1. The hard gate, with no model: bots, channel posts and senders without a live pairing never
|
|
14
|
-
* start a turn. An unknown sender who writes to the bot privately gets a pairing button.
|
|
15
|
+
* start a turn. An unknown sender who writes to the bot privately gets a pairing button. A group
|
|
16
|
+
* message reports its chat to the ontology's registry in the background, whatever follows. A
|
|
17
|
+
* group's notice that a member left is never a turn: a paired member's departure is reported,
|
|
18
|
+
* and so is the agent's own, which unlinks every member. Neither is the notice, in the old chat
|
|
19
|
+
* or the new one, that a group became a supergroup: the migration is reported.
|
|
15
20
|
* 2. Voice becomes text, so the router and the agent read what was said.
|
|
16
21
|
* 3. The router answers or observes. An observed message goes to the chat's ring and stops here.
|
|
17
22
|
* 4. The session policy runs on the session the message continues: an idle one is cleared, the
|
|
18
23
|
* first message after the day's compaction time compacts it.
|
|
19
|
-
* 5. An answered message dispatches as the paired member, with the ring
|
|
20
|
-
* naming the member, their roles and the message they replied to
|
|
24
|
+
* 5. An answered message dispatches as the paired member, with the ring, the registry's `<chat>`
|
|
25
|
+
* block and a `<speaker>` block naming the member, their roles and the message they replied to
|
|
26
|
+
* ahead of it as context. An answered group message waits for its chat's report first, so that
|
|
27
|
+
* a chat new to the process already carries its `<chat>` block.
|
|
21
28
|
*/
|
|
22
29
|
export interface InboundDependencies {
|
|
23
30
|
readonly policy: TelegramPolicy;
|
|
@@ -30,6 +37,7 @@ export interface InboundDependencies {
|
|
|
30
37
|
readonly transcribe: Transcriber;
|
|
31
38
|
readonly download: (fileId: string) => Promise<Uint8Array>;
|
|
32
39
|
readonly observed: ObservedMessages;
|
|
40
|
+
readonly chats: AgentChats;
|
|
33
41
|
/** The app's `/agent/connect` link for a pairing code. */
|
|
34
42
|
readonly connectUrl: (code: string) => string;
|
|
35
43
|
}
|
|
@@ -5,6 +5,7 @@ const __filename = __eveFileURLToPath(import.meta.url);
|
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
7
|
import { telegramPrincipal } from "./principal.mjs";
|
|
8
|
+
import { chatContext } from "./chats.mjs";
|
|
8
9
|
import { observedChatKey, observedContext } from "./observed.mjs";
|
|
9
10
|
import { isAddressed, routeMessage } from "./router.mjs";
|
|
10
11
|
import { messageAddress, scopedAddress } from "./sessions.mjs";
|
|
@@ -90,9 +91,67 @@ async function answerUnpaired(input) {
|
|
|
90
91
|
reply_markup: buttonTo("Connect account", dependencies.connectUrl(pairing.code))
|
|
91
92
|
});
|
|
92
93
|
}
|
|
94
|
+
function isGroupChat(message) {
|
|
95
|
+
return message.chat.type === "group" || message.chat.type === "supergroup";
|
|
96
|
+
}
|
|
97
|
+
async function reportDeparture(input) {
|
|
98
|
+
const { context, message, left, dependencies } = input;
|
|
99
|
+
const id = left.id;
|
|
100
|
+
if (typeof id !== "number" && typeof id !== "string") return;
|
|
101
|
+
if (left.is_bot === true) {
|
|
102
|
+
const bot = await dependencies.bot(context.telegram);
|
|
103
|
+
if (bot !== void 0 && String(id) === bot.id) await dependencies.chats.agentLeft({
|
|
104
|
+
chatId: message.chat.id,
|
|
105
|
+
title: message.chat.title ?? ""
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const lookup = await dependencies.identities.lookup({
|
|
110
|
+
provider: "telegram",
|
|
111
|
+
externalId: String(id)
|
|
112
|
+
});
|
|
113
|
+
if (lookup.status === "paired") await dependencies.chats.left({
|
|
114
|
+
chatId: message.chat.id,
|
|
115
|
+
title: message.chat.title ?? "",
|
|
116
|
+
memberId: lookup.member.id
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function rawChatId(value) {
|
|
120
|
+
return typeof value === "number" || typeof value === "string" && value !== "" ? String(value) : void 0;
|
|
121
|
+
}
|
|
122
|
+
function migrationOf(message) {
|
|
123
|
+
const to = rawChatId(message.raw.migrate_to_chat_id);
|
|
124
|
+
const from = rawChatId(message.raw.migrate_from_chat_id);
|
|
125
|
+
if (to !== void 0) return {
|
|
126
|
+
chatId: to,
|
|
127
|
+
migratedFrom: message.chat.id
|
|
128
|
+
};
|
|
129
|
+
return from === void 0 ? void 0 : {
|
|
130
|
+
chatId: message.chat.id,
|
|
131
|
+
migratedFrom: from
|
|
132
|
+
};
|
|
133
|
+
}
|
|
93
134
|
async function handleInboundMessage(input) {
|
|
94
135
|
const { context, dependencies } = input;
|
|
95
136
|
const message = input.message;
|
|
137
|
+
const left = message.raw.left_chat_member;
|
|
138
|
+
const migration = isGroupChat(message) ? migrationOf(message) : void 0;
|
|
139
|
+
if (isGroupChat(message) && isRecord(left)) {
|
|
140
|
+
await reportDeparture({
|
|
141
|
+
context,
|
|
142
|
+
message,
|
|
143
|
+
left,
|
|
144
|
+
dependencies
|
|
145
|
+
});
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (migration !== void 0) {
|
|
149
|
+
await dependencies.chats.migrated({
|
|
150
|
+
...migration,
|
|
151
|
+
title: message.chat.title ?? ""
|
|
152
|
+
});
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
96
155
|
if (message.from === void 0 || message.from.isBot || message.chat.type === "channel") return null;
|
|
97
156
|
const sent = {
|
|
98
157
|
...message,
|
|
@@ -104,6 +163,13 @@ async function handleInboundMessage(input) {
|
|
|
104
163
|
provider: "telegram",
|
|
105
164
|
externalId: sent.from.id
|
|
106
165
|
});
|
|
166
|
+
const group = isGroupChat(sent);
|
|
167
|
+
const report = group ? dependencies.chats.seen({
|
|
168
|
+
chatId: sent.chat.id,
|
|
169
|
+
title: sent.chat.title ?? "",
|
|
170
|
+
...lookup.status === "paired" ? { memberId: lookup.member.id } : {}
|
|
171
|
+
}) : void 0;
|
|
172
|
+
if (report !== void 0) input.request?.waitUntil?.(report);
|
|
107
173
|
if (lookup.status !== "paired") {
|
|
108
174
|
if (addressed) await answerUnpaired({
|
|
109
175
|
context,
|
|
@@ -160,6 +226,7 @@ async function handleInboundMessage(input) {
|
|
|
160
226
|
dependencies.sessionPolicy.delivered(address);
|
|
161
227
|
}
|
|
162
228
|
await context.telegram.startTyping();
|
|
229
|
+
await report;
|
|
163
230
|
return {
|
|
164
231
|
auth: telegramPrincipal({
|
|
165
232
|
message: sent,
|
|
@@ -167,6 +234,7 @@ async function handleInboundMessage(input) {
|
|
|
167
234
|
}),
|
|
168
235
|
context: [
|
|
169
236
|
...observedContext(dependencies.observed.take(chat)),
|
|
237
|
+
...group ? chatContext(dependencies.chats.chat(sent.chat.id)) : [],
|
|
170
238
|
speakerBlock({
|
|
171
239
|
message: sent,
|
|
172
240
|
member: lookup.member,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type RouteDefinition } from "eve/channels";
|
|
2
|
+
import type { TelegramChannel, TelegramChannelState } from "eve/channels/telegram";
|
|
3
|
+
import type { SessionAuthContext } from "eve/context";
|
|
4
|
+
import type { SessionSettings } from "../../config.js";
|
|
5
|
+
import type { RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
6
|
+
import { type SessionPolicy } from "./sessions.js";
|
|
7
|
+
/**
|
|
8
|
+
* Requests from the ontology's `agent_chat.send_message` action: an external agent or a member asks
|
|
9
|
+
* the workspace agent to contact someone in a group or a private chat. The ontology service resolves
|
|
10
|
+
* the chat and posts here with its own Vercel OIDC token; the request is delivered into the chat's
|
|
11
|
+
* session through eve's proactive `receive`, without a seed message, so it continues the session
|
|
12
|
+
* the chat's own messages reach (`chatId::`, the chat-scope address) and the replies land there too.
|
|
13
|
+
* The session policy runs on that session first, as it does for an inbound message.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MESSAGES_ROUTE = "/eve/v1/telegram/messages";
|
|
16
|
+
export interface AgentRequest {
|
|
17
|
+
readonly target: {
|
|
18
|
+
readonly kind: "group" | "private";
|
|
19
|
+
readonly provider: "telegram";
|
|
20
|
+
/** The group's chat id, or the Telegram user id of a private chat. */
|
|
21
|
+
readonly chatId: string;
|
|
22
|
+
};
|
|
23
|
+
readonly message: string;
|
|
24
|
+
/** Whom to contact, when the requester named someone. */
|
|
25
|
+
readonly person: {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly name: string;
|
|
28
|
+
} | null;
|
|
29
|
+
readonly requester: {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly kind: "member" | "service";
|
|
33
|
+
/** The roles the requester holds; empty from an ontology that does not send them. */
|
|
34
|
+
readonly roles: ReadonlyArray<string>;
|
|
35
|
+
/**
|
|
36
|
+
* Whether the turn can act as the requester: a member with a live paired identity. An ontology
|
|
37
|
+
* that does not send it means a member can.
|
|
38
|
+
*/
|
|
39
|
+
readonly actAs: boolean;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** The request body, or `undefined` when it does not match the contract. */
|
|
43
|
+
export declare function agentRequest(value: unknown): AgentRequest | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Whom the request's turn runs as. A member requester the ontology says can be acted as is the
|
|
46
|
+
* turn's member, so the ontology connection acts as them and the ontology service checks, as for any
|
|
47
|
+
* turn, that they are active and paired. Their roles, as the action's principal held them, choose the
|
|
48
|
+
* model of a session the request starts; the service resolves them afresh on every call. A service
|
|
49
|
+
* user, or a member who has not paired, cannot be acted as, so the request runs without a member and
|
|
50
|
+
* the turn has no ontology; their roles still choose the model, and grant nothing, since the
|
|
51
|
+
* ontology connection acts only for a `memberId`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function requestPrincipal(request: AgentRequest): SessionAuthContext;
|
|
54
|
+
/** What the turn is about, for the model, ahead of the request itself. */
|
|
55
|
+
export declare function requestContext(request: AgentRequest): string;
|
|
56
|
+
/**
|
|
57
|
+
* `POST /eve/v1/telegram/messages`. Gated like the webhook: outside production it answers 503 and a
|
|
58
|
+
* workspace without a Telegram channel 404. The caller must be a function of this deployment's
|
|
59
|
+
* project, verified from its Vercel OIDC token.
|
|
60
|
+
*/
|
|
61
|
+
export declare function messagesRoute(input: {
|
|
62
|
+
readonly channel: TelegramChannel;
|
|
63
|
+
readonly enabled: () => boolean;
|
|
64
|
+
readonly configured: () => boolean;
|
|
65
|
+
readonly verify: () => RuntimeTokenVerifier;
|
|
66
|
+
/** The adapter's session policy, which the webhook route applies too. */
|
|
67
|
+
readonly sessionPolicy: SessionPolicy;
|
|
68
|
+
/** The mounted `sessions.scope`. */
|
|
69
|
+
readonly scope: () => SessionSettings["scope"];
|
|
70
|
+
readonly log?: (message: string, detail: unknown) => void;
|
|
71
|
+
}): RouteDefinition<TelegramChannelState>;
|
|
@@ -0,0 +1,173 @@
|
|
|
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 { TELEGRAM_AUTHENTICATOR } from "./principal.mjs";
|
|
8
|
+
import { formatAddress, scopedAddress } from "./sessions.mjs";
|
|
9
|
+
import { POST } from "eve/channels";
|
|
10
|
+
const MESSAGES_ROUTE = "/eve/v1/telegram/messages";
|
|
11
|
+
const MAX_REQUEST_MESSAGE_LENGTH = 4e3;
|
|
12
|
+
const CHAT_ID = /^-?\d{1,20}$/;
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function isText(value) {
|
|
17
|
+
return typeof value === "string" && value.trim() !== "";
|
|
18
|
+
}
|
|
19
|
+
function user(value) {
|
|
20
|
+
return isRecord(value) && isText(value.id) && isText(value.name) ? {
|
|
21
|
+
id: value.id,
|
|
22
|
+
name: value.name
|
|
23
|
+
} : void 0;
|
|
24
|
+
}
|
|
25
|
+
function roles(value) {
|
|
26
|
+
if (value === void 0) return [];
|
|
27
|
+
return Array.isArray(value) && value.every((role) => typeof role === "string") ? value : void 0;
|
|
28
|
+
}
|
|
29
|
+
function agentRequest(value) {
|
|
30
|
+
if (!isRecord(value)) return;
|
|
31
|
+
const { target, message, requester } = value;
|
|
32
|
+
const person = value.person === null ? null : user(value.person);
|
|
33
|
+
const from = user(requester);
|
|
34
|
+
const held = isRecord(requester) ? roles(requester.roles) : void 0;
|
|
35
|
+
const actAs = isRecord(requester) ? requester.actAs : void 0;
|
|
36
|
+
if (!isRecord(target) || target.kind !== "group" && target.kind !== "private" || target.provider !== "telegram" || typeof target.chatId !== "string" || !CHAT_ID.test(target.chatId) || !isText(message) || message.length > MAX_REQUEST_MESSAGE_LENGTH || person === void 0 || from === void 0 || !isRecord(requester) || requester.kind !== "member" && requester.kind !== "service" || held === void 0 || actAs !== void 0 && typeof actAs !== "boolean") return;
|
|
37
|
+
return {
|
|
38
|
+
target: {
|
|
39
|
+
kind: target.kind,
|
|
40
|
+
provider: "telegram",
|
|
41
|
+
chatId: target.chatId
|
|
42
|
+
},
|
|
43
|
+
message,
|
|
44
|
+
person,
|
|
45
|
+
requester: {
|
|
46
|
+
...from,
|
|
47
|
+
kind: requester.kind,
|
|
48
|
+
roles: held,
|
|
49
|
+
actAs: actAs ?? requester.kind === "member"
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function requestPrincipal(request) {
|
|
54
|
+
const { requester, target } = request;
|
|
55
|
+
if (requester.kind === "member" && requester.actAs) return {
|
|
56
|
+
authenticator: TELEGRAM_AUTHENTICATOR,
|
|
57
|
+
issuer: "segmentstream",
|
|
58
|
+
principalId: `segmentstream:${requester.id}`,
|
|
59
|
+
principalType: "user",
|
|
60
|
+
subject: requester.id,
|
|
61
|
+
attributes: {
|
|
62
|
+
memberId: requester.id,
|
|
63
|
+
memberName: requester.name,
|
|
64
|
+
roles: requester.roles,
|
|
65
|
+
chatId: target.chatId,
|
|
66
|
+
chatType: target.kind
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
authenticator: TELEGRAM_AUTHENTICATOR,
|
|
71
|
+
issuer: "segmentstream",
|
|
72
|
+
principalId: `segmentstream:${requester.kind}:${requester.id}`,
|
|
73
|
+
principalType: requester.kind === "member" ? "user" : "service",
|
|
74
|
+
subject: requester.id,
|
|
75
|
+
attributes: {
|
|
76
|
+
requesterId: requester.id,
|
|
77
|
+
requesterName: requester.name,
|
|
78
|
+
roles: requester.roles,
|
|
79
|
+
chatId: target.chatId,
|
|
80
|
+
chatType: target.kind
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function requestContext(request) {
|
|
85
|
+
const { requester, person } = request;
|
|
86
|
+
const to = person === null ? "" : ` to ${person.name} (user_id ${person.id})`;
|
|
87
|
+
const lines = [
|
|
88
|
+
"<agent_request>",
|
|
89
|
+
`A request from ${requester.name} (${requester.kind}, user_id ${requester.id}) arrived through the ontology action agent_chat.send_message; the message is the request.`,
|
|
90
|
+
`Write in this chat${to}, ask what the request needs, and handle the replies here.`
|
|
91
|
+
];
|
|
92
|
+
if (requester.kind === "service" || !requester.actAs) lines.push(`${requester.kind === "service" ? "The requester is a service, not a member" : "The requester has not paired a Telegram account with the agent"}, so this turn runs without a member and the ontology is not available in it. Each member who replies acts with their own permissions.`);
|
|
93
|
+
lines.push("</agent_request>");
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
function withContext(source, context) {
|
|
97
|
+
return {
|
|
98
|
+
...source,
|
|
99
|
+
send(message, options) {
|
|
100
|
+
return source.send(message, {
|
|
101
|
+
...options,
|
|
102
|
+
context: [...options.context ?? [], context]
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function bearer(header) {
|
|
108
|
+
return (header === null ? null : /^Bearer\s+(\S+)$/i.exec(header.trim()))?.[1];
|
|
109
|
+
}
|
|
110
|
+
function error(code, status) {
|
|
111
|
+
return Response.json({ error: code }, { status });
|
|
112
|
+
}
|
|
113
|
+
function messagesRoute(input) {
|
|
114
|
+
const log = input.log ?? ((message, detail) => console.warn(message, detail));
|
|
115
|
+
return POST(MESSAGES_ROUTE, async (request, args) => {
|
|
116
|
+
if (!input.enabled()) return error("channels_disabled", 503);
|
|
117
|
+
if (!input.configured()) return error("not_found", 404);
|
|
118
|
+
const token = bearer(request.headers.get("authorization"));
|
|
119
|
+
if (token === void 0) return error("unauthorized", 401);
|
|
120
|
+
let verifier;
|
|
121
|
+
try {
|
|
122
|
+
verifier = input.verify();
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
log("The agent cannot verify requests: the runtime identity is not configured.", cause);
|
|
125
|
+
return error("unavailable", 503);
|
|
126
|
+
}
|
|
127
|
+
const verification = await verifier(token);
|
|
128
|
+
if (verification.status === "unavailable") {
|
|
129
|
+
log("The agent cannot verify requests: the runtime key set is unavailable.", verification.reason);
|
|
130
|
+
return error("unavailable", 503);
|
|
131
|
+
}
|
|
132
|
+
if (verification.status !== "valid") {
|
|
133
|
+
log("The agent refused a request with an invalid runtime token.", verification.reason);
|
|
134
|
+
return error("unauthorized", 401);
|
|
135
|
+
}
|
|
136
|
+
let body;
|
|
137
|
+
try {
|
|
138
|
+
body = await request.json();
|
|
139
|
+
} catch {
|
|
140
|
+
return error("invalid_body", 400);
|
|
141
|
+
}
|
|
142
|
+
const parsed = agentRequest(body);
|
|
143
|
+
const receive = input.channel.receive;
|
|
144
|
+
if (parsed === void 0) return error("invalid_body", 400);
|
|
145
|
+
if (receive === void 0) throw new Error("eve's Telegram channel has no receive hook.");
|
|
146
|
+
const chatAddress = scopedAddress(formatAddress({
|
|
147
|
+
chatId: parsed.target.chatId,
|
|
148
|
+
messageThreadId: "",
|
|
149
|
+
conversationId: ""
|
|
150
|
+
}), input.scope());
|
|
151
|
+
await input.sessionPolicy.apply({
|
|
152
|
+
address: chatAddress,
|
|
153
|
+
operations: {
|
|
154
|
+
resolveSession: args.resolveSession,
|
|
155
|
+
from: args.from
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
const session = await receive({
|
|
159
|
+
target: { chatId: parsed.target.chatId },
|
|
160
|
+
message: parsed.message,
|
|
161
|
+
auth: requestPrincipal(parsed)
|
|
162
|
+
}, {
|
|
163
|
+
from: (address) => withContext(args.from(address), requestContext(parsed)),
|
|
164
|
+
resolveSession: args.resolveSession
|
|
165
|
+
});
|
|
166
|
+
input.sessionPolicy.delivered(chatAddress);
|
|
167
|
+
return Response.json({
|
|
168
|
+
delivered: true,
|
|
169
|
+
sessionId: session.id
|
|
170
|
+
}, { status: 202 });
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
export { MESSAGES_ROUTE, agentRequest, messagesRoute, requestContext, requestPrincipal };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|
|
@@ -61,7 +61,8 @@
|
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@vercel/oidc": "^3.8.4",
|
|
64
|
-
"ai": "^7.0.128"
|
|
64
|
+
"ai": "^7.0.128",
|
|
65
|
+
"jose": "^6.2.12"
|
|
65
66
|
},
|
|
66
67
|
"peerDependencies": {
|
|
67
68
|
"eve": "0.76.3"
|