@yolk-sdk/emulators 0.1.0-canary.96
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/LICENSE +21 -0
- package/README.md +2135 -0
- package/dist/anthropic.d.mts +269 -0
- package/dist/anthropic.d.mts.map +1 -0
- package/dist/anthropic.mjs +177 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/chat-completions.d.mts +288 -0
- package/dist/chat-completions.d.mts.map +1 -0
- package/dist/chat-completions.mjs +451 -0
- package/dist/chat-completions.mjs.map +1 -0
- package/dist/codex.d.mts +265 -0
- package/dist/codex.d.mts.map +1 -0
- package/dist/codex.mjs +199 -0
- package/dist/codex.mjs.map +1 -0
- package/dist/dropbox/api.d.mts +63 -0
- package/dist/dropbox/api.d.mts.map +1 -0
- package/dist/dropbox/api.mjs +565 -0
- package/dist/dropbox/api.mjs.map +1 -0
- package/dist/dropbox/state.d.mts +136 -0
- package/dist/dropbox/state.d.mts.map +1 -0
- package/dist/dropbox/state.mjs +209 -0
- package/dist/dropbox/state.mjs.map +1 -0
- package/dist/dropbox.d.mts +79 -0
- package/dist/dropbox.d.mts.map +1 -0
- package/dist/dropbox.mjs +124 -0
- package/dist/dropbox.mjs.map +1 -0
- package/dist/email-fixtures.d.mts +36 -0
- package/dist/email-fixtures.d.mts.map +1 -0
- package/dist/email-fixtures.mjs +1080 -0
- package/dist/email-fixtures.mjs.map +1 -0
- package/dist/email.d.mts +160 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +608 -0
- package/dist/email.mjs.map +1 -0
- package/dist/emulator-compose.d.mts +40 -0
- package/dist/emulator-compose.d.mts.map +1 -0
- package/dist/emulator-compose.mjs +91 -0
- package/dist/emulator-compose.mjs.map +1 -0
- package/dist/emulator-http.d.mts +53 -0
- package/dist/emulator-http.d.mts.map +1 -0
- package/dist/emulator-http.mjs +117 -0
- package/dist/emulator-http.mjs.map +1 -0
- package/dist/emulator-kernel.d.mts +176 -0
- package/dist/emulator-kernel.d.mts.map +1 -0
- package/dist/emulator-kernel.mjs +413 -0
- package/dist/emulator-kernel.mjs.map +1 -0
- package/dist/fixture-route.d.mts +129 -0
- package/dist/fixture-route.d.mts.map +1 -0
- package/dist/fixture-route.mjs +257 -0
- package/dist/fixture-route.mjs.map +1 -0
- package/dist/fortnox/api.d.mts +92 -0
- package/dist/fortnox/api.d.mts.map +1 -0
- package/dist/fortnox/api.mjs +750 -0
- package/dist/fortnox/api.mjs.map +1 -0
- package/dist/fortnox/state.d.mts +533 -0
- package/dist/fortnox/state.d.mts.map +1 -0
- package/dist/fortnox/state.mjs +612 -0
- package/dist/fortnox/state.mjs.map +1 -0
- package/dist/fortnox.d.mts +128 -0
- package/dist/fortnox.d.mts.map +1 -0
- package/dist/fortnox.mjs +403 -0
- package/dist/fortnox.mjs.map +1 -0
- package/dist/gateway-evaluate-recordings.d.mts +12 -0
- package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
- package/dist/gateway-evaluate-recordings.mjs +128 -0
- package/dist/gateway-evaluate-recordings.mjs.map +1 -0
- package/dist/gateway.d.mts +272 -0
- package/dist/gateway.d.mts.map +1 -0
- package/dist/gateway.mjs +400 -0
- package/dist/gateway.mjs.map +1 -0
- package/dist/github/api.d.mts +63 -0
- package/dist/github/api.d.mts.map +1 -0
- package/dist/github/api.mjs +527 -0
- package/dist/github/api.mjs.map +1 -0
- package/dist/github/state.d.mts +272 -0
- package/dist/github/state.d.mts.map +1 -0
- package/dist/github/state.mjs +303 -0
- package/dist/github/state.mjs.map +1 -0
- package/dist/github.d.mts +77 -0
- package/dist/github.d.mts.map +1 -0
- package/dist/github.mjs +180 -0
- package/dist/github.mjs.map +1 -0
- package/dist/google/calendar.d.mts +10 -0
- package/dist/google/calendar.d.mts.map +1 -0
- package/dist/google/calendar.mjs +252 -0
- package/dist/google/calendar.mjs.map +1 -0
- package/dist/google/drive.d.mts +8 -0
- package/dist/google/drive.d.mts.map +1 -0
- package/dist/google/drive.mjs +252 -0
- package/dist/google/drive.mjs.map +1 -0
- package/dist/google/gmail.d.mts +10 -0
- package/dist/google/gmail.d.mts.map +1 -0
- package/dist/google/gmail.mjs +591 -0
- package/dist/google/gmail.mjs.map +1 -0
- package/dist/google/shared.d.mts +107 -0
- package/dist/google/shared.d.mts.map +1 -0
- package/dist/google/shared.mjs +146 -0
- package/dist/google/shared.mjs.map +1 -0
- package/dist/google/state.d.mts +385 -0
- package/dist/google/state.d.mts.map +1 -0
- package/dist/google/state.mjs +496 -0
- package/dist/google/state.mjs.map +1 -0
- package/dist/google.d.mts +75 -0
- package/dist/google.d.mts.map +1 -0
- package/dist/google.mjs +210 -0
- package/dist/google.mjs.map +1 -0
- package/dist/linkedin-search/api.d.mts +45 -0
- package/dist/linkedin-search/api.d.mts.map +1 -0
- package/dist/linkedin-search/api.mjs +181 -0
- package/dist/linkedin-search/api.mjs.map +1 -0
- package/dist/linkedin-search/state.d.mts +123 -0
- package/dist/linkedin-search/state.d.mts.map +1 -0
- package/dist/linkedin-search/state.mjs +261 -0
- package/dist/linkedin-search/state.mjs.map +1 -0
- package/dist/linkedin-search.d.mts +69 -0
- package/dist/linkedin-search.d.mts.map +1 -0
- package/dist/linkedin-search.mjs +158 -0
- package/dist/linkedin-search.mjs.map +1 -0
- package/dist/mcp/api.d.mts +44 -0
- package/dist/mcp/api.d.mts.map +1 -0
- package/dist/mcp/api.mjs +557 -0
- package/dist/mcp/api.mjs.map +1 -0
- package/dist/mcp/recordings.d.mts +40 -0
- package/dist/mcp/recordings.d.mts.map +1 -0
- package/dist/mcp/recordings.mjs +2390 -0
- package/dist/mcp/recordings.mjs.map +1 -0
- package/dist/mcp/state.d.mts +71 -0
- package/dist/mcp/state.d.mts.map +1 -0
- package/dist/mcp/state.mjs +83 -0
- package/dist/mcp/state.mjs.map +1 -0
- package/dist/mcp.d.mts +84 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +241 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/messages.d.mts +183 -0
- package/dist/messages.d.mts.map +1 -0
- package/dist/messages.mjs +532 -0
- package/dist/messages.mjs.map +1 -0
- package/dist/microsoft/api.d.mts +47 -0
- package/dist/microsoft/api.d.mts.map +1 -0
- package/dist/microsoft/api.mjs +178 -0
- package/dist/microsoft/api.mjs.map +1 -0
- package/dist/microsoft/calendar.d.mts +30 -0
- package/dist/microsoft/calendar.d.mts.map +1 -0
- package/dist/microsoft/calendar.mjs +271 -0
- package/dist/microsoft/calendar.mjs.map +1 -0
- package/dist/microsoft/drive.d.mts +36 -0
- package/dist/microsoft/drive.d.mts.map +1 -0
- package/dist/microsoft/drive.mjs +298 -0
- package/dist/microsoft/drive.mjs.map +1 -0
- package/dist/microsoft/graph.d.mts +198 -0
- package/dist/microsoft/graph.d.mts.map +1 -0
- package/dist/microsoft/graph.mjs +264 -0
- package/dist/microsoft/graph.mjs.map +1 -0
- package/dist/microsoft/mail.d.mts +48 -0
- package/dist/microsoft/mail.d.mts.map +1 -0
- package/dist/microsoft/mail.mjs +488 -0
- package/dist/microsoft/mail.mjs.map +1 -0
- package/dist/microsoft/state.d.mts +480 -0
- package/dist/microsoft/state.d.mts.map +1 -0
- package/dist/microsoft/state.mjs +625 -0
- package/dist/microsoft/state.mjs.map +1 -0
- package/dist/microsoft.d.mts +168 -0
- package/dist/microsoft.d.mts.map +1 -0
- package/dist/microsoft.mjs +483 -0
- package/dist/microsoft.mjs.map +1 -0
- package/dist/node.d.mts +47 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +178 -0
- package/dist/node.mjs.map +1 -0
- package/dist/notion/api.d.mts +51 -0
- package/dist/notion/api.d.mts.map +1 -0
- package/dist/notion/api.mjs +507 -0
- package/dist/notion/api.mjs.map +1 -0
- package/dist/notion/state.d.mts +329 -0
- package/dist/notion/state.d.mts.map +1 -0
- package/dist/notion/state.mjs +432 -0
- package/dist/notion/state.mjs.map +1 -0
- package/dist/notion.d.mts +72 -0
- package/dist/notion.d.mts.map +1 -0
- package/dist/notion.mjs +127 -0
- package/dist/notion.mjs.map +1 -0
- package/dist/openai.d.mts +165 -0
- package/dist/openai.d.mts.map +1 -0
- package/dist/openai.mjs +141 -0
- package/dist/openai.mjs.map +1 -0
- package/dist/opencode-recordings.d.mts +7 -0
- package/dist/opencode-recordings.d.mts.map +1 -0
- package/dist/opencode-recordings.mjs +222 -0
- package/dist/opencode-recordings.mjs.map +1 -0
- package/dist/opencode.d.mts +70 -0
- package/dist/opencode.d.mts.map +1 -0
- package/dist/opencode.mjs +217 -0
- package/dist/opencode.mjs.map +1 -0
- package/dist/r2-fixtures.d.mts +36 -0
- package/dist/r2-fixtures.d.mts.map +1 -0
- package/dist/r2-fixtures.mjs +245 -0
- package/dist/r2-fixtures.mjs.map +1 -0
- package/dist/r2-guard.d.mts +43 -0
- package/dist/r2-guard.d.mts.map +1 -0
- package/dist/r2-guard.mjs +215 -0
- package/dist/r2-guard.mjs.map +1 -0
- package/dist/r2.d.mts +166 -0
- package/dist/r2.d.mts.map +1 -0
- package/dist/r2.mjs +517 -0
- package/dist/r2.mjs.map +1 -0
- package/dist/responses.d.mts +231 -0
- package/dist/responses.d.mts.map +1 -0
- package/dist/responses.mjs +556 -0
- package/dist/responses.mjs.map +1 -0
- package/dist/route-evidence.d.mts +45 -0
- package/dist/route-evidence.d.mts.map +1 -0
- package/dist/route-evidence.mjs +56 -0
- package/dist/route-evidence.mjs.map +1 -0
- package/dist/router.d.mts +93 -0
- package/dist/router.d.mts.map +1 -0
- package/dist/router.mjs +259 -0
- package/dist/router.mjs.map +1 -0
- package/dist/stateful-core.d.mts +17 -0
- package/dist/stateful-core.d.mts.map +1 -0
- package/dist/stateful-core.mjs +41 -0
- package/dist/stateful-core.mjs.map +1 -0
- package/dist/stateful-emulator.d.mts +565 -0
- package/dist/stateful-emulator.d.mts.map +1 -0
- package/dist/stateful-emulator.mjs +1228 -0
- package/dist/stateful-emulator.mjs.map +1 -0
- package/dist/stateful-secrets.d.mts +84 -0
- package/dist/stateful-secrets.d.mts.map +1 -0
- package/dist/stateful-secrets.mjs +216 -0
- package/dist/stateful-secrets.mjs.map +1 -0
- package/dist/subscription-usage-recordings.d.mts +9 -0
- package/dist/subscription-usage-recordings.d.mts.map +1 -0
- package/dist/subscription-usage-recordings.mjs +53 -0
- package/dist/subscription-usage-recordings.mjs.map +1 -0
- package/dist/subscription-usage.d.mts +53 -0
- package/dist/subscription-usage.d.mts.map +1 -0
- package/dist/subscription-usage.mjs +21 -0
- package/dist/subscription-usage.mjs.map +1 -0
- package/dist/telegram/api.d.mts +34 -0
- package/dist/telegram/api.d.mts.map +1 -0
- package/dist/telegram/api.mjs +257 -0
- package/dist/telegram/api.mjs.map +1 -0
- package/dist/telegram/state.d.mts +128 -0
- package/dist/telegram/state.d.mts.map +1 -0
- package/dist/telegram/state.mjs +154 -0
- package/dist/telegram/state.mjs.map +1 -0
- package/dist/telegram.d.mts +62 -0
- package/dist/telegram.d.mts.map +1 -0
- package/dist/telegram.mjs +127 -0
- package/dist/telegram.mjs.map +1 -0
- package/dist/todoist/api.d.mts +55 -0
- package/dist/todoist/api.d.mts.map +1 -0
- package/dist/todoist/api.mjs +415 -0
- package/dist/todoist/api.mjs.map +1 -0
- package/dist/todoist/state.d.mts +275 -0
- package/dist/todoist/state.d.mts.map +1 -0
- package/dist/todoist/state.mjs +345 -0
- package/dist/todoist/state.mjs.map +1 -0
- package/dist/todoist.d.mts +68 -0
- package/dist/todoist.d.mts.map +1 -0
- package/dist/todoist.mjs +181 -0
- package/dist/todoist.mjs.map +1 -0
- package/dist/xai.d.mts +267 -0
- package/dist/xai.d.mts.map +1 -0
- package/dist/xai.mjs +252 -0
- package/dist/xai.mjs.map +1 -0
- package/package.json +157 -0
- package/src/anthropic.ts +289 -0
- package/src/chat-completions.ts +924 -0
- package/src/codex.ts +296 -0
- package/src/dropbox/api.ts +1084 -0
- package/src/dropbox/state.ts +364 -0
- package/src/dropbox.ts +203 -0
- package/src/email-fixtures.ts +1297 -0
- package/src/email.ts +1108 -0
- package/src/emulator-compose.ts +147 -0
- package/src/emulator-http.ts +184 -0
- package/src/emulator-kernel.ts +844 -0
- package/src/fixture-route.ts +561 -0
- package/src/fortnox/api.ts +1352 -0
- package/src/fortnox/state.ts +798 -0
- package/src/fortnox.ts +801 -0
- package/src/gateway-evaluate-recordings.ts +153 -0
- package/src/gateway.ts +530 -0
- package/src/github/api.ts +986 -0
- package/src/github/state.ts +439 -0
- package/src/github.ts +271 -0
- package/src/google/calendar.ts +486 -0
- package/src/google/drive.ts +471 -0
- package/src/google/gmail.ts +1011 -0
- package/src/google/shared.ts +321 -0
- package/src/google/state.ts +686 -0
- package/src/google.ts +298 -0
- package/src/linkedin-search/api.ts +363 -0
- package/src/linkedin-search/state.ts +373 -0
- package/src/linkedin-search.ts +241 -0
- package/src/mcp/api.ts +995 -0
- package/src/mcp/recordings.ts +2665 -0
- package/src/mcp/state.ts +125 -0
- package/src/mcp.ts +319 -0
- package/src/messages.ts +844 -0
- package/src/microsoft/api.ts +426 -0
- package/src/microsoft/calendar.ts +491 -0
- package/src/microsoft/drive.ts +541 -0
- package/src/microsoft/graph.ts +550 -0
- package/src/microsoft/mail.ts +833 -0
- package/src/microsoft/state.ts +822 -0
- package/src/microsoft.ts +982 -0
- package/src/node.ts +293 -0
- package/src/notion/api.ts +976 -0
- package/src/notion/state.ts +512 -0
- package/src/notion.ts +210 -0
- package/src/openai.ts +198 -0
- package/src/opencode-recordings.ts +257 -0
- package/src/opencode.ts +271 -0
- package/src/r2-fixtures.ts +295 -0
- package/src/r2-guard.ts +323 -0
- package/src/r2.ts +890 -0
- package/src/responses.ts +901 -0
- package/src/route-evidence.ts +90 -0
- package/src/router.ts +490 -0
- package/src/stateful-core.ts +70 -0
- package/src/stateful-emulator.ts +2571 -0
- package/src/stateful-secrets.ts +299 -0
- package/src/subscription-usage-recordings.ts +77 -0
- package/src/subscription-usage.ts +68 -0
- package/src/telegram/api.ts +437 -0
- package/src/telegram/state.ts +229 -0
- package/src/telegram.ts +227 -0
- package/src/todoist/api.ts +736 -0
- package/src/todoist/state.ts +456 -0
- package/src/todoist.ts +316 -0
- package/src/xai.ts +341 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Bot API emulator API: the route table (evidence, query allowlist, guarded path,
|
|
3
|
+
* handler) and the token-aware route resolution (internal; re-exported by `src/telegram.ts`, which
|
|
4
|
+
* runs the table on the shared stateful wrapper, `src/stateful-emulator.ts`, in its resolved
|
|
5
|
+
* mode).
|
|
6
|
+
*
|
|
7
|
+
* The bot token is part of every Bot API URL (`/bot<token>/<method>`, `/file/bot<token>/<path>`).
|
|
8
|
+
* It is required, but it never reaches the core, the state, the ledger, a not-emulated message, or
|
|
9
|
+
* a response. Resolution fails closed: only a request whose raw path is exactly an emulated route
|
|
10
|
+
* shape is recognised, and its token is taken from that exact segment; every other request is
|
|
11
|
+
* ledgered and answered with constant text only. For a recognised request the ledgered path reads
|
|
12
|
+
* `/bot<redacted>/...`, the routes learn only whether the token names a bot, and the wrapper
|
|
13
|
+
* scrubs the token and its secret part from the ledger and refuses a path, query, or body that
|
|
14
|
+
* repeats them (raw, percent-decoded, or in any parsed JSON key, string value, or number).
|
|
15
|
+
*
|
|
16
|
+
* Only the routes the four Telegram conformance cases need are emulated, with wire shapes copied
|
|
17
|
+
* from the synthetic fixtures; everything else answers the wrapper's ledgered 400 not-emulated.
|
|
18
|
+
*
|
|
19
|
+
* @experimental
|
|
20
|
+
*/
|
|
21
|
+
import { Predicate } from 'effect'
|
|
22
|
+
import type * as Schema from 'effect/Schema'
|
|
23
|
+
import {
|
|
24
|
+
isNotEmulated,
|
|
25
|
+
notEmulated,
|
|
26
|
+
statefulRoute,
|
|
27
|
+
type Commit,
|
|
28
|
+
type EmulatedRequest,
|
|
29
|
+
type NotEmulated,
|
|
30
|
+
type StatefulResolution,
|
|
31
|
+
type StatefulRoute
|
|
32
|
+
} from '../stateful-emulator.ts'
|
|
33
|
+
import type { TelegramEmulatorChat, TelegramEmulatorState } from './state.ts'
|
|
34
|
+
|
|
35
|
+
/** Drill knobs (tests only): each makes the emulator disagree with one conformance claim. */
|
|
36
|
+
export type TelegramEmulatorDrills = {
|
|
37
|
+
/** `true`: `getChat` of a member chat answers 200 with `ok: false`. */
|
|
38
|
+
readonly getChatOkFalse?: boolean
|
|
39
|
+
/** `true`: the `getChat` errors (absent chat, token naming no bot) answer 200, not 4xx. */
|
|
40
|
+
readonly errorsAs200?: boolean
|
|
41
|
+
/** `true`: `getFile` reports a `file_size` one byte larger than the file. */
|
|
42
|
+
readonly fileSizeOffByOne?: boolean
|
|
43
|
+
/** `true`: `sendMessage` answers 200 with `ok: false` (the message is still recorded as sent). */
|
|
44
|
+
readonly sendOkFalse?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TelegramApiEnv = {
|
|
48
|
+
/** Clock in epoch milliseconds (`date` of sent messages). */
|
|
49
|
+
readonly now: () => number
|
|
50
|
+
readonly drills: Required<TelegramEmulatorDrills>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The route parameter the resolution supplies instead of the token: whether the token names a bot
|
|
55
|
+
* (`known`) or not (`none`). The token itself never reaches a route.
|
|
56
|
+
*/
|
|
57
|
+
const botParam = 'bot'
|
|
58
|
+
|
|
59
|
+
type RouteRequest = {
|
|
60
|
+
readonly query: URLSearchParams
|
|
61
|
+
/** Decoded file path of the file route. */
|
|
62
|
+
readonly filePath: string | undefined
|
|
63
|
+
readonly body: Schema.Json | undefined
|
|
64
|
+
readonly hasBody: boolean
|
|
65
|
+
readonly contentType: string | null
|
|
66
|
+
/** The token names a bot (its bot id is not 0). */
|
|
67
|
+
readonly knownBot: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A route handler (the wrapper's plan): validate without writing, then return the commit. */
|
|
71
|
+
type RouteHandler = (
|
|
72
|
+
state: TelegramEmulatorState,
|
|
73
|
+
request: RouteRequest,
|
|
74
|
+
env: TelegramApiEnv
|
|
75
|
+
) => Commit | NotEmulated
|
|
76
|
+
|
|
77
|
+
type TelegramApiRoute = {
|
|
78
|
+
readonly route: StatefulRoute<TelegramEmulatorState, TelegramApiEnv>
|
|
79
|
+
readonly queryKeys: ReadonlyArray<string>
|
|
80
|
+
/** The token-free path the request must not repeat the token in (the method, or `/file`). */
|
|
81
|
+
readonly guardedPath: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const validateCase = 'telegram.validate.get-chat'
|
|
85
|
+
|
|
86
|
+
const errorCase = 'telegram.errors.error-envelope'
|
|
87
|
+
|
|
88
|
+
const fileCase = 'telegram.files.get-file-path'
|
|
89
|
+
|
|
90
|
+
const sendCase = 'telegram.messages.send-message'
|
|
91
|
+
|
|
92
|
+
/** Bot API JSON answers, as the fixtures record them. */
|
|
93
|
+
const json = (status: number, body: unknown): Response =>
|
|
94
|
+
new Response(JSON.stringify(body), {
|
|
95
|
+
status,
|
|
96
|
+
headers: { 'content-type': 'application/json' }
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/** A chat id: an integer (negative for groups and channels) or a public `@username`. */
|
|
100
|
+
const chatIdPattern = /^(?:-?[1-9][0-9]{0,19}|@[A-Za-z][A-Za-z0-9_]{3,31})$/
|
|
101
|
+
|
|
102
|
+
const isJsonObject = (value: Schema.Json | undefined): value is Schema.JsonObject =>
|
|
103
|
+
value !== undefined && value !== null && Predicate.isObject(value) && !Array.isArray(value)
|
|
104
|
+
|
|
105
|
+
const mediaType = (contentType: string | null): string | undefined =>
|
|
106
|
+
contentType?.split(';', 1)[0]?.trim().toLowerCase()
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A JSON body with exactly the `keys` the fixture records, or a refusal. Refusals never name a
|
|
110
|
+
* request key or value (constant text).
|
|
111
|
+
*/
|
|
112
|
+
const bodyObject = (
|
|
113
|
+
request: RouteRequest,
|
|
114
|
+
keys: ReadonlyArray<string>
|
|
115
|
+
): Schema.JsonObject | NotEmulated => {
|
|
116
|
+
if (mediaType(request.contentType) !== 'application/json') {
|
|
117
|
+
return notEmulated('Bot API bodies other than application/json are not emulated')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!isJsonObject(request.body)) {
|
|
121
|
+
return notEmulated('a request body that is not a JSON object is not emulated')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const sent = Object.keys(request.body)
|
|
125
|
+
|
|
126
|
+
if (sent.some(key => !keys.includes(key))) {
|
|
127
|
+
return notEmulated('a body field this method does not take is not emulated')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return keys.every(key => sent.includes(key))
|
|
131
|
+
? request.body
|
|
132
|
+
: notEmulated(
|
|
133
|
+
`requests without each of the recorded body fields (${keys.join(', ')}) are not emulated`
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const noBody = (request: RouteRequest): NotEmulated | undefined =>
|
|
138
|
+
request.hasBody ? notEmulated('a request body is not emulated on this method') : undefined
|
|
139
|
+
|
|
140
|
+
/** The chat a well-formed `chat_id` names, `null` for none, or a refusal. */
|
|
141
|
+
const chatOf = (
|
|
142
|
+
state: TelegramEmulatorState,
|
|
143
|
+
chatId: Schema.Json | undefined
|
|
144
|
+
): TelegramEmulatorChat | null | NotEmulated => {
|
|
145
|
+
if (!Predicate.isString(chatId) || !chatIdPattern.test(chatId)) {
|
|
146
|
+
return notEmulated('chat_id must be a chat id string (an integer or a public @username)')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return state.chats.find(chat => String(chat.id) === chatId) ?? null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The Bot API error envelope of the error fixture. */
|
|
153
|
+
const botApiError = (env: TelegramApiEnv, status: number, description: string): Response =>
|
|
154
|
+
json(env.drills.errorsAs200 ? 200 : status, { ok: false, error_code: status, description })
|
|
155
|
+
|
|
156
|
+
const knownBotOnly = (request: RouteRequest): NotEmulated | undefined =>
|
|
157
|
+
request.knownBot
|
|
158
|
+
? undefined
|
|
159
|
+
: notEmulated('a bot token that names no bot is emulated only on getChat (as recorded)')
|
|
160
|
+
|
|
161
|
+
// Handlers: validate (not emulated) first, then return the commit that writes.
|
|
162
|
+
|
|
163
|
+
const getChat: RouteHandler = (state, request, env) => {
|
|
164
|
+
const body = bodyObject(request, ['chat_id'])
|
|
165
|
+
|
|
166
|
+
if (isNotEmulated(body)) return body
|
|
167
|
+
|
|
168
|
+
const chat = chatOf(state, body.chat_id)
|
|
169
|
+
|
|
170
|
+
if (isNotEmulated(chat)) return chat
|
|
171
|
+
|
|
172
|
+
if (!request.knownBot) return () => botApiError(env, 401, 'Unauthorized')
|
|
173
|
+
|
|
174
|
+
if (chat === null) return () => botApiError(env, 400, 'Bad Request: chat not found')
|
|
175
|
+
|
|
176
|
+
return () =>
|
|
177
|
+
json(200, {
|
|
178
|
+
ok: !env.drills.getChatOkFalse,
|
|
179
|
+
result: {
|
|
180
|
+
id: chat.id,
|
|
181
|
+
title: chat.title,
|
|
182
|
+
type: chat.type,
|
|
183
|
+
permissions: { can_send_messages: chat.permissions.can_send_messages },
|
|
184
|
+
accent_color_id: chat.accent_color_id,
|
|
185
|
+
max_reaction_count: chat.max_reaction_count
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const getFile: RouteHandler = (state, request, env) => {
|
|
191
|
+
const refused = noBody(request) ?? knownBotOnly(request)
|
|
192
|
+
|
|
193
|
+
if (refused !== undefined) return refused
|
|
194
|
+
|
|
195
|
+
const fileId = request.query.get('file_id')
|
|
196
|
+
const file = state.files.find(item => item.file_id === fileId)
|
|
197
|
+
|
|
198
|
+
if (file === undefined) {
|
|
199
|
+
return notEmulated('getFile of a file id the bot did not receive is not emulated')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return () =>
|
|
203
|
+
json(200, {
|
|
204
|
+
ok: true,
|
|
205
|
+
result: {
|
|
206
|
+
file_id: file.file_id,
|
|
207
|
+
file_unique_id: file.file_unique_id,
|
|
208
|
+
file_size: file.file_size + (env.drills.fileSizeOffByOne ? 1 : 0),
|
|
209
|
+
file_path: file.file_path
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const downloadFile: RouteHandler = (state, request) => {
|
|
215
|
+
const refused = noBody(request) ?? knownBotOnly(request)
|
|
216
|
+
|
|
217
|
+
if (refused !== undefined) return refused
|
|
218
|
+
|
|
219
|
+
const file = state.files.find(item => item.file_path === request.filePath)
|
|
220
|
+
|
|
221
|
+
if (file === undefined) return notEmulated('a file path getFile did not answer is not emulated')
|
|
222
|
+
|
|
223
|
+
return () =>
|
|
224
|
+
new Response(new TextEncoder().encode(file.content), {
|
|
225
|
+
status: 200,
|
|
226
|
+
headers: { 'content-type': 'application/octet-stream' }
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const sendMessage: RouteHandler = (state, request, env) => {
|
|
231
|
+
const refused = knownBotOnly(request)
|
|
232
|
+
|
|
233
|
+
if (refused !== undefined) return refused
|
|
234
|
+
|
|
235
|
+
const body = bodyObject(request, ['chat_id', 'text', 'disable_web_page_preview'])
|
|
236
|
+
|
|
237
|
+
if (isNotEmulated(body)) return body
|
|
238
|
+
|
|
239
|
+
const chat = chatOf(state, body.chat_id)
|
|
240
|
+
|
|
241
|
+
if (isNotEmulated(chat)) return chat
|
|
242
|
+
|
|
243
|
+
if (chat === null) {
|
|
244
|
+
return notEmulated('sendMessage to a chat the bot is not a member of is not emulated')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const { text, disable_web_page_preview: disablePreview } = body
|
|
248
|
+
|
|
249
|
+
if (!Predicate.isString(text) || text === '') {
|
|
250
|
+
return notEmulated('text must be a non-empty string')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (disablePreview !== true) {
|
|
254
|
+
return notEmulated('disable_web_page_preview other than true (as recorded) is not emulated')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return () => {
|
|
258
|
+
// Read the clock before anything is written: a failing clock sends nothing.
|
|
259
|
+
const now = env.now()
|
|
260
|
+
|
|
261
|
+
if (!Number.isFinite(now)) throw new Error('the emulator clock is not a finite instant')
|
|
262
|
+
|
|
263
|
+
const date = Math.floor(now / 1000)
|
|
264
|
+
const messageId = state.counters.nextMessageId
|
|
265
|
+
|
|
266
|
+
state.counters = { ...state.counters, nextMessageId: messageId + 1 }
|
|
267
|
+
state.sentMessages = [
|
|
268
|
+
...state.sentMessages,
|
|
269
|
+
{ message_id: messageId, chat_id: chat.id, text, date, disable_web_page_preview: true }
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
return json(200, {
|
|
273
|
+
ok: !env.drills.sendOkFalse,
|
|
274
|
+
result: {
|
|
275
|
+
message_id: messageId,
|
|
276
|
+
from: {
|
|
277
|
+
id: state.bot.id,
|
|
278
|
+
is_bot: true,
|
|
279
|
+
first_name: state.bot.first_name,
|
|
280
|
+
username: state.bot.username
|
|
281
|
+
},
|
|
282
|
+
chat: { id: chat.id, title: chat.title, type: chat.type },
|
|
283
|
+
date,
|
|
284
|
+
text
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The route's request, as the handlers see it (the wrapper checked and parsed the body). */
|
|
291
|
+
const routeRequest = (request: EmulatedRequest): RouteRequest => ({
|
|
292
|
+
query: request.query,
|
|
293
|
+
filePath: request.params.filePath,
|
|
294
|
+
body: request.json,
|
|
295
|
+
// A non-empty body is valid JSON by now (`json-or-empty`), so it always parses.
|
|
296
|
+
hasBody: request.json !== undefined,
|
|
297
|
+
contentType: request.header('content-type') ?? null,
|
|
298
|
+
knownBot: request.params[botParam] === 'known'
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const route = (
|
|
302
|
+
method: string,
|
|
303
|
+
path: string,
|
|
304
|
+
guardedPath: string,
|
|
305
|
+
write: boolean,
|
|
306
|
+
caseIds: ReadonlyArray<string>,
|
|
307
|
+
handler: RouteHandler,
|
|
308
|
+
queryKeys: ReadonlyArray<string> = []
|
|
309
|
+
): TelegramApiRoute => ({
|
|
310
|
+
route: statefulRoute(
|
|
311
|
+
{ method, path, kind: 'connector', write, caseIds, evidence: 'unverified' },
|
|
312
|
+
'json-or-empty',
|
|
313
|
+
routeRequest,
|
|
314
|
+
(state, request, context) => handler(state, request, context.env)
|
|
315
|
+
),
|
|
316
|
+
queryKeys,
|
|
317
|
+
guardedPath
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
/** The route table: evidence plus handler. `telegramEmulatorRoutes` is its evidence part. */
|
|
321
|
+
export const telegramApiRoutes: ReadonlyArray<TelegramApiRoute> = [
|
|
322
|
+
route('POST', '/bot{token}/getChat', '/getChat', false, [validateCase, errorCase], getChat),
|
|
323
|
+
route('GET', '/bot{token}/getFile', '/getFile', false, [fileCase], getFile, ['file_id']),
|
|
324
|
+
route('GET', '/file/bot{token}/{filePath}', '/file', false, [fileCase], downloadFile),
|
|
325
|
+
route('POST', '/bot{token}/sendMessage', '/sendMessage', true, [sendCase], sendMessage)
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* A Bot API token as the connector sends it: `<bot id>:<secret>`, with a secret of at least 8
|
|
330
|
+
* characters, so the secret part of every accepted token is long enough to be guarded on its own.
|
|
331
|
+
* Matched on the RAW path segment: a percent-encoded character makes the request unrecognised.
|
|
332
|
+
*/
|
|
333
|
+
const tokenSource = '[0-9]+:[A-Za-z0-9_-]{8,}'
|
|
334
|
+
|
|
335
|
+
/** `/bot<token>/<method>`, exactly: one of the emulated Bot API methods, no other path text. */
|
|
336
|
+
const botPathPattern = new RegExp(`^/bot(${tokenSource})/(getChat|getFile|sendMessage)$`)
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* `/file/bot<token>/<file_path>`, exactly: a relative file path of letters, digits, and `_ . -`
|
|
340
|
+
* segments (no percent-encoding, no empty or dot segments), the shape the download helper sends.
|
|
341
|
+
*/
|
|
342
|
+
const filePathPattern = new RegExp(
|
|
343
|
+
`^/file/bot(${tokenSource})/((?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+)$`
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
const isPlainFilePath = (path: string): boolean =>
|
|
347
|
+
path.split('/').every(part => part !== '.' && part !== '..')
|
|
348
|
+
|
|
349
|
+
/** The constant reason of every unrecognised request (no method, path, or query is quoted). */
|
|
350
|
+
const unrecognisedReason = 'no emulated Bot API route for this method and path'
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Resolve one Bot API request, failing closed. Only a request whose raw path is exactly an
|
|
354
|
+
* emulated route shape (`/bot<token>/<method>` or `/file/bot<token>/<file_path>`, the strict token
|
|
355
|
+
* on the raw segment, an emulated method, and no other path text) under that route's HTTP method
|
|
356
|
+
* is recognised; the token is extracted from that exact segment, never searched for elsewhere.
|
|
357
|
+
* Every other request is `unrecognised`: the wrapper ledgers and answers it with constant text
|
|
358
|
+
* only, so nothing it carries can leak.
|
|
359
|
+
*/
|
|
360
|
+
export const resolveTelegramRequest = (
|
|
361
|
+
request: Request,
|
|
362
|
+
url: URL
|
|
363
|
+
): StatefulResolution<TelegramEmulatorState, TelegramApiEnv> => {
|
|
364
|
+
const method = request.method.toUpperCase()
|
|
365
|
+
const fileMatch = filePathPattern.exec(url.pathname)
|
|
366
|
+
const botMatch = fileMatch === null ? botPathPattern.exec(url.pathname) : null
|
|
367
|
+
const token = fileMatch?.[1] ?? botMatch?.[1]
|
|
368
|
+
|
|
369
|
+
const template =
|
|
370
|
+
fileMatch !== null
|
|
371
|
+
? '/file/bot{token}/{filePath}'
|
|
372
|
+
: botMatch === null
|
|
373
|
+
? undefined
|
|
374
|
+
: `/bot{token}/${botMatch[2] ?? ''}`
|
|
375
|
+
|
|
376
|
+
const matched = telegramApiRoutes.find(
|
|
377
|
+
candidate => candidate.route.method === method && candidate.route.path === template
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
const filePath = fileMatch?.[2]
|
|
381
|
+
|
|
382
|
+
if (
|
|
383
|
+
matched === undefined ||
|
|
384
|
+
token === undefined ||
|
|
385
|
+
(filePath !== undefined && !isPlainFilePath(filePath))
|
|
386
|
+
) {
|
|
387
|
+
return { kind: 'unrecognised', reason: unrecognisedReason }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// The guarded values: the token and its secret part (plain text: the segment is unencoded).
|
|
391
|
+
const secrets = [token, token.slice(token.indexOf(':') + 1)]
|
|
392
|
+
|
|
393
|
+
const ledgerPath =
|
|
394
|
+
filePath === undefined
|
|
395
|
+
? `/bot<redacted>/${botMatch?.[2] ?? ''}`
|
|
396
|
+
: `/file/bot<redacted>/${filePath}`
|
|
397
|
+
|
|
398
|
+
const refuse = (reason: string): StatefulResolution<TelegramEmulatorState, TelegramApiEnv> => ({
|
|
399
|
+
kind: 'refused',
|
|
400
|
+
ledgerPath,
|
|
401
|
+
reason,
|
|
402
|
+
route: matched.route,
|
|
403
|
+
secrets
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
const keys = [...url.searchParams.keys()]
|
|
407
|
+
|
|
408
|
+
// Constant text: a query key may carry anything, the token included.
|
|
409
|
+
if (keys.some(key => !matched.queryKeys.includes(key))) {
|
|
410
|
+
return refuse('a query parameter this method does not take is not emulated')
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const missing = matched.queryKeys.find(key => !keys.includes(key))
|
|
414
|
+
|
|
415
|
+
if (missing !== undefined) {
|
|
416
|
+
return refuse(`requests without the query parameter ${missing} are not emulated`)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (keys.length !== matched.queryKeys.length) {
|
|
420
|
+
return refuse('repeated query parameters are not emulated')
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Routes see whether the token names a bot and the file path, never the token.
|
|
424
|
+
const bot = /^0+:/.test(token) ? 'none' : 'known'
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
kind: 'route',
|
|
428
|
+
ledgerPath,
|
|
429
|
+
route: matched.route,
|
|
430
|
+
params: filePath === undefined ? { [botParam]: bot } : { [botParam]: bot, filePath },
|
|
431
|
+
guardedPath:
|
|
432
|
+
filePath === undefined
|
|
433
|
+
? `${matched.guardedPath}${url.search}`
|
|
434
|
+
: `${matched.guardedPath}/${encodeURIComponent(filePath)}`,
|
|
435
|
+
secrets
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Bot API emulator state: the typed entities, the seed input, the default seed, and the
|
|
3
|
+
* profiles (internal; re-exported by `src/telegram.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Entity shapes and the default entities are copied as data from the synthetic Telegram
|
|
6
|
+
* conformance fixtures (the same chat and file ids as `telegramConformanceFixtureSeeds`, the bot
|
|
7
|
+
* the send fixture answers as `from`, and the 32-byte synthetic file), never imported from SDK
|
|
8
|
+
* code. The state holds no bot token: tokens are never stored.
|
|
9
|
+
*
|
|
10
|
+
* @experimental
|
|
11
|
+
*/
|
|
12
|
+
import { Result } from 'effect'
|
|
13
|
+
import * as Schema from 'effect/Schema'
|
|
14
|
+
|
|
15
|
+
/** The bot every token with a non-zero bot id answers as (`from` of sent messages). */
|
|
16
|
+
export const TelegramEmulatorBot = Schema.Struct({
|
|
17
|
+
id: Schema.Int,
|
|
18
|
+
first_name: Schema.String,
|
|
19
|
+
username: Schema.String
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export type TelegramEmulatorBot = typeof TelegramEmulatorBot.Type
|
|
23
|
+
|
|
24
|
+
/** A chat the bot is a member of, in the `getChat` fixture's wire shape. */
|
|
25
|
+
export const TelegramEmulatorChat = Schema.Struct({
|
|
26
|
+
id: Schema.Int,
|
|
27
|
+
title: Schema.String,
|
|
28
|
+
type: Schema.String,
|
|
29
|
+
permissions: Schema.Struct({ can_send_messages: Schema.Boolean }),
|
|
30
|
+
accent_color_id: Schema.Int,
|
|
31
|
+
max_reaction_count: Schema.Int
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export type TelegramEmulatorChat = typeof TelegramEmulatorChat.Type
|
|
35
|
+
|
|
36
|
+
const FileId = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{1,256}$/))
|
|
37
|
+
|
|
38
|
+
/** A relative file path the download helper accepts (no empty or dot segments). */
|
|
39
|
+
const FilePath = Schema.String.check(
|
|
40
|
+
Schema.makeFilter(path =>
|
|
41
|
+
/^[A-Za-z0-9_./-]+$/.test(path) &&
|
|
42
|
+
!path.startsWith('/') &&
|
|
43
|
+
path.split('/').every(part => part !== '' && part !== '.' && part !== '..')
|
|
44
|
+
? undefined
|
|
45
|
+
: 'a relative path of letters, digits, and _ . / - without empty or dot segments'
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
/** A file the bot received: its `getFile` answer and its UTF-8 text content. */
|
|
50
|
+
export const TelegramEmulatorFile = Schema.Struct({
|
|
51
|
+
file_id: FileId,
|
|
52
|
+
file_unique_id: FileId,
|
|
53
|
+
file_size: Schema.Int,
|
|
54
|
+
file_path: FilePath,
|
|
55
|
+
/** UTF-8 text; `file_size` must equal its byte length. */
|
|
56
|
+
content: Schema.String
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
export type TelegramEmulatorFile = typeof TelegramEmulatorFile.Type
|
|
60
|
+
|
|
61
|
+
/** A message the bot sent (irreversible: kept until reset or seed). */
|
|
62
|
+
export const TelegramEmulatorSentMessage = Schema.Struct({
|
|
63
|
+
message_id: Schema.Int,
|
|
64
|
+
chat_id: Schema.Int,
|
|
65
|
+
text: Schema.String,
|
|
66
|
+
date: Schema.Int,
|
|
67
|
+
disable_web_page_preview: Schema.Boolean
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
export type TelegramEmulatorSentMessage = typeof TelegramEmulatorSentMessage.Type
|
|
71
|
+
|
|
72
|
+
const Counters = Schema.Struct({
|
|
73
|
+
/** Next `message_id` of a sent message. */
|
|
74
|
+
nextMessageId: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
/** The whole emulator state (JSON-compatible; what `snapshot()` returns). */
|
|
78
|
+
export const TelegramEmulatorStateSchema = Schema.Struct({
|
|
79
|
+
bot: TelegramEmulatorBot,
|
|
80
|
+
chats: Schema.Array(TelegramEmulatorChat),
|
|
81
|
+
files: Schema.Array(TelegramEmulatorFile),
|
|
82
|
+
sentMessages: Schema.Array(TelegramEmulatorSentMessage),
|
|
83
|
+
counters: Counters
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
/** The emulator state. The container is mutable; entities are replaced, never edited in place. */
|
|
87
|
+
export type TelegramEmulatorState = {
|
|
88
|
+
bot: TelegramEmulatorBot
|
|
89
|
+
chats: ReadonlyArray<TelegramEmulatorChat>
|
|
90
|
+
files: ReadonlyArray<TelegramEmulatorFile>
|
|
91
|
+
sentMessages: ReadonlyArray<TelegramEmulatorSentMessage>
|
|
92
|
+
counters: typeof Counters.Type
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Account-variance profiles for the default seed. */
|
|
96
|
+
export const TelegramEmulatorProfile = Schema.Literals(['default', 'empty'])
|
|
97
|
+
|
|
98
|
+
export type TelegramEmulatorProfile = typeof TelegramEmulatorProfile.Type
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A typed seed. Start from `profile` (default `'default'`, the fixture entities; `'empty'` keeps
|
|
102
|
+
* only the bot); every other key, when given, replaces that part of the profile.
|
|
103
|
+
* `nextMessageId` defaults to 101 (the send fixture's `message_id`).
|
|
104
|
+
*/
|
|
105
|
+
export const TelegramEmulatorSeed = Schema.Struct({
|
|
106
|
+
profile: Schema.optionalKey(TelegramEmulatorProfile),
|
|
107
|
+
bot: Schema.optionalKey(TelegramEmulatorBot),
|
|
108
|
+
chats: Schema.optionalKey(Schema.Array(TelegramEmulatorChat)),
|
|
109
|
+
files: Schema.optionalKey(Schema.Array(TelegramEmulatorFile)),
|
|
110
|
+
nextMessageId: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)))
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
export type TelegramEmulatorSeed = typeof TelegramEmulatorSeed.Type
|
|
114
|
+
|
|
115
|
+
const strict = { onExcessProperty: 'error' } as const
|
|
116
|
+
|
|
117
|
+
const decodeSeedInput = Schema.decodeUnknownResult(TelegramEmulatorSeed, strict)
|
|
118
|
+
|
|
119
|
+
const decodeStateInput = Schema.decodeUnknownResult(TelegramEmulatorStateSchema, strict)
|
|
120
|
+
|
|
121
|
+
const issueMessage = (issue: Schema.SchemaError['issue']): string =>
|
|
122
|
+
new Schema.SchemaError(issue).message
|
|
123
|
+
|
|
124
|
+
// Default entities, copied from the fixtures.
|
|
125
|
+
|
|
126
|
+
const defaultBot: TelegramEmulatorBot = {
|
|
127
|
+
id: 123456789,
|
|
128
|
+
first_name: 'Synthetic Practice Bot',
|
|
129
|
+
username: 'yolk_synthetic_bot'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const defaultChats: ReadonlyArray<TelegramEmulatorChat> = [
|
|
133
|
+
{
|
|
134
|
+
id: -1001000000001,
|
|
135
|
+
title: 'Synthetic practice group',
|
|
136
|
+
type: 'supergroup',
|
|
137
|
+
permissions: { can_send_messages: true },
|
|
138
|
+
accent_color_id: 0,
|
|
139
|
+
max_reaction_count: 11
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
const defaultFiles: ReadonlyArray<TelegramEmulatorFile> = [
|
|
144
|
+
{
|
|
145
|
+
file_id: 'BQACAgIAAxkDAAIC-yolk_synthetic_file_0001',
|
|
146
|
+
file_unique_id: 'AgADyolkSynthetic01',
|
|
147
|
+
file_size: 32,
|
|
148
|
+
file_path: 'documents/file_0.txt',
|
|
149
|
+
content: 'yolk-conformance synthetic file\n'
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
type ProfileEntities = {
|
|
154
|
+
readonly bot: TelegramEmulatorBot
|
|
155
|
+
readonly chats: ReadonlyArray<TelegramEmulatorChat>
|
|
156
|
+
readonly files: ReadonlyArray<TelegramEmulatorFile>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const profileEntities = (profile: TelegramEmulatorProfile): ProfileEntities => {
|
|
160
|
+
switch (profile) {
|
|
161
|
+
case 'default':
|
|
162
|
+
return { bot: defaultBot, chats: defaultChats, files: defaultFiles }
|
|
163
|
+
case 'empty':
|
|
164
|
+
return { bot: defaultBot, chats: [], files: [] }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const duplicate = <A>(values: ReadonlyArray<A>): A | undefined =>
|
|
169
|
+
values.find((value, index) => values.indexOf(value) !== index)
|
|
170
|
+
|
|
171
|
+
const utf8Length = (text: string): number => new TextEncoder().encode(text).byteLength
|
|
172
|
+
|
|
173
|
+
const seedProblem = (entities: ProfileEntities): string | undefined => {
|
|
174
|
+
if (entities.bot.id <= 0) return 'the bot id must be positive'
|
|
175
|
+
|
|
176
|
+
const chat = duplicate(entities.chats.map(item => item.id))
|
|
177
|
+
|
|
178
|
+
if (chat !== undefined) return `duplicate chat id ${chat}`
|
|
179
|
+
|
|
180
|
+
const fileId = duplicate(entities.files.map(item => item.file_id))
|
|
181
|
+
|
|
182
|
+
if (fileId !== undefined) return `duplicate file id ${fileId}`
|
|
183
|
+
|
|
184
|
+
const filePath = duplicate(entities.files.map(item => item.file_path))
|
|
185
|
+
|
|
186
|
+
if (filePath !== undefined) return `duplicate file path ${filePath}`
|
|
187
|
+
|
|
188
|
+
const sized = entities.files.find(item => item.file_size !== utf8Length(item.content))
|
|
189
|
+
|
|
190
|
+
return sized === undefined
|
|
191
|
+
? undefined
|
|
192
|
+
: `file ${sized.file_id} has a file_size other than its content's byte length`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const stateFromSeed = (seed: TelegramEmulatorSeed): TelegramEmulatorState | string => {
|
|
196
|
+
const profile = profileEntities(seed.profile ?? 'default')
|
|
197
|
+
|
|
198
|
+
const entities: ProfileEntities = {
|
|
199
|
+
bot: seed.bot ?? profile.bot,
|
|
200
|
+
chats: seed.chats ?? profile.chats,
|
|
201
|
+
files: seed.files ?? profile.files
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const problem = seedProblem(entities)
|
|
205
|
+
|
|
206
|
+
if (problem !== undefined) return problem
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
...entities,
|
|
210
|
+
sentMessages: [],
|
|
211
|
+
counters: { nextMessageId: seed.nextMessageId ?? 101 }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Decode and build a seed; a string is the reason it is invalid. */
|
|
216
|
+
export const buildTelegramSeedState = (input: unknown): TelegramEmulatorState | string => {
|
|
217
|
+
const decoded = decodeSeedInput(input)
|
|
218
|
+
|
|
219
|
+
return Result.isFailure(decoded)
|
|
220
|
+
? issueMessage(decoded.failure.issue)
|
|
221
|
+
: stateFromSeed(decoded.success)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Decode a full state (a restored snapshot); a string is the reason it is invalid. */
|
|
225
|
+
export const decodeTelegramState = (input: unknown): TelegramEmulatorState | string => {
|
|
226
|
+
const decoded = decodeStateInput(input)
|
|
227
|
+
|
|
228
|
+
return Result.isFailure(decoded) ? issueMessage(decoded.failure.issue) : { ...decoded.success }
|
|
229
|
+
}
|