@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.
Files changed (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. 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
+ }