stitchkit 0.100.2 → 0.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +2 -1
  3. package/dist/entrypoints/agent-runtime/browser.js +119 -119
  4. package/dist/entrypoints/agent-runtime/coding-tools.js +15 -20
  5. package/dist/entrypoints/agent-runtime/harness.js +55 -63
  6. package/dist/entrypoints/agent-runtime/openrouter.js +6 -6
  7. package/dist/entrypoints/agent-runtime/sandbox.js +20 -25
  8. package/dist/entrypoints/agent-runtime/sqlite/bun.js +11 -13
  9. package/dist/entrypoints/agent-runtime/sqlite/node.js +11 -13
  10. package/dist/entrypoints/agent-runtime/testing.js +32 -34
  11. package/dist/entrypoints/agent-runtime.js +395 -402
  12. package/dist/entrypoints/application/diagnostic-journal.js +26 -27
  13. package/dist/entrypoints/application/directory-inbox.js +16 -17
  14. package/dist/entrypoints/application/grammy.js +13 -13
  15. package/dist/entrypoints/application/opentelemetry.js +13 -13
  16. package/dist/entrypoints/application/schemas.js +24 -24
  17. package/dist/entrypoints/application.js +209 -210
  18. package/dist/entrypoints/bin/upgrade-cli.js +2 -2
  19. package/dist/entrypoints/cli.js +81 -84
  20. package/dist/entrypoints/contract.js +54 -57
  21. package/dist/entrypoints/declaration.js +22 -22
  22. package/dist/entrypoints/files.js +18 -21
  23. package/dist/entrypoints/geo.js +7 -8
  24. package/dist/entrypoints/google.js +2 -2
  25. package/dist/entrypoints/index.js +150 -154
  26. package/dist/entrypoints/live.js +45 -47
  27. package/dist/entrypoints/node.js +68 -74
  28. package/dist/entrypoints/oauth.js +6 -6
  29. package/dist/entrypoints/observability.d.ts +1 -0
  30. package/dist/entrypoints/observability.d.ts.map +1 -1
  31. package/dist/entrypoints/observability.js +116 -87
  32. package/dist/entrypoints/primitives.js +35 -35
  33. package/dist/entrypoints/react/keyboard.d.ts +4 -0
  34. package/dist/entrypoints/react/keyboard.d.ts.map +1 -0
  35. package/dist/entrypoints/react/keyboard.js +397 -0
  36. package/dist/entrypoints/react.js +10 -10
  37. package/dist/entrypoints/release.js +7 -7
  38. package/dist/entrypoints/remote.js +15 -17
  39. package/dist/entrypoints/server.js +198 -200
  40. package/dist/entrypoints/telegram/html.d.ts +5 -0
  41. package/dist/entrypoints/telegram/html.d.ts.map +1 -0
  42. package/dist/entrypoints/telegram/html.js +26 -0
  43. package/dist/entrypoints/telegram/screens.d.ts +14 -0
  44. package/dist/entrypoints/telegram/screens.d.ts.map +1 -0
  45. package/dist/entrypoints/telegram/screens.js +2028 -0
  46. package/dist/entrypoints/telegram.d.ts +11 -3
  47. package/dist/entrypoints/telegram.d.ts.map +1 -1
  48. package/dist/entrypoints/telegram.js +779 -159
  49. package/dist/entrypoints/testing.js +75 -79
  50. package/dist/entrypoints/tools/connections.js +30 -32
  51. package/dist/entrypoints/tools/contract.js +18 -20
  52. package/dist/entrypoints/tools/invoker.js +12 -17
  53. package/dist/entrypoints/tools.js +224 -229
  54. package/dist/entrypoints/tracking/server.js +6 -6
  55. package/dist/entrypoints/tracking.js +40 -42
  56. package/dist/entrypoints/voice.js +3 -3
  57. package/dist/{index-eaysmdtq.js → index-0ndk4555.js} +56 -56
  58. package/dist/{index-2bxbyab0.js → index-1bx1xv1k.js} +1 -1
  59. package/dist/{index-p5r7qpdd.js → index-1eeqpadm.js} +4 -4
  60. package/dist/{index-hadmp193.js → index-3ct7hhq6.js} +210 -210
  61. package/dist/{index-e01r054k.js → index-45tdvqa3.js} +26 -26
  62. package/dist/{index-dce5wxcq.js → index-4s0fep5q.js} +8 -8
  63. package/dist/index-50ejfz75.js +9 -0
  64. package/dist/{index-m9zn6cpq.js → index-5107q189.js} +12 -12
  65. package/dist/{index-sbdmyz75.js → index-5cc6r990.js} +3 -3
  66. package/dist/{index-c6bxbsnc.js → index-7kxw1awn.js} +2 -2
  67. package/dist/{index-0r0fbk7z.js → index-7z9ym7t8.js} +28 -28
  68. package/dist/{index-p7hgdyhb.js → index-814xpr6d.js} +2 -2
  69. package/dist/{index-gtj3zc53.js → index-8kzct1d6.js} +99 -99
  70. package/dist/{index-1zbbzgfh.js → index-9pw0a51a.js} +4 -4
  71. package/dist/{index-79z7m7dt.js → index-a9pd417t.js} +35 -35
  72. package/dist/{index-ham5sz8m.js → index-aaj97r4y.js} +45 -45
  73. package/dist/{index-s812ycat.js → index-aan6hqb9.js} +3 -3
  74. package/dist/{index-10x2hbx0.js → index-adhgd4pe.js} +44 -44
  75. package/dist/{index-2gxzjvyr.js → index-afjvjyck.js} +23 -23
  76. package/dist/{index-qmy6zq75.js → index-ahvsksw5.js} +16 -16
  77. package/dist/{index-hr4tzbwq.js → index-ap3gqe56.js} +3 -3
  78. package/dist/{index-znfs20ss.js → index-b2bz0kdm.js} +16 -16
  79. package/dist/{index-r066jx76.js → index-c6aagrbd.js} +148 -120
  80. package/dist/{index-k30h66yd.js → index-ck71tbcf.js} +9 -9
  81. package/dist/{index-eqqvgdb3.js → index-ct75c3at.js} +2 -2
  82. package/dist/{index-gw8bmyaj.js → index-djjhhgjk.js} +41 -41
  83. package/dist/{index-8qt2m66q.js → index-dvwj1f43.js} +43 -43
  84. package/dist/index-e05cjgtf.js +144 -0
  85. package/dist/{index-yw4fzmdz.js → index-e6c2spsd.js} +79 -30
  86. package/dist/{index-fhwqwxe0.js → index-ej331hah.js} +1 -1
  87. package/dist/{index-9t3dffv2.js → index-ewbaywk5.js} +5 -5
  88. package/dist/{index-7p1f0vp2.js → index-fyphpknh.js} +25 -25
  89. package/dist/{index-83bdg97s.js → index-g149e9pk.js} +1 -1
  90. package/dist/{index-ngfhvfpd.js → index-g6a3na27.js} +4 -4
  91. package/dist/{index-qwhynv6x.js → index-h124epcf.js} +20 -20
  92. package/dist/{index-h6eeys1v.js → index-h97tqqp1.js} +9 -9
  93. package/dist/{index-zcgf3gqf.js → index-hby4qhax.js} +10 -10
  94. package/dist/{index-5jrd0yng.js → index-j8aw2c0e.js} +4 -4
  95. package/dist/{index-d8m8zpd8.js → index-jzczzcsb.js} +14 -14
  96. package/dist/index-kqnd6a85.js +26 -0
  97. package/dist/{index-35mh5bhj.js → index-kw1ws0cs.js} +12 -12
  98. package/dist/{index-4gcnakhx.js → index-m38ej7e5.js} +2 -2
  99. package/dist/{index-ndddxtyx.js → index-m79yr938.js} +19 -19
  100. package/dist/{index-5p2m7we1.js → index-m9fndta6.js} +11 -11
  101. package/dist/index-mnkk1ffz.js +150 -0
  102. package/dist/index-n15jny5b.js +4 -0
  103. package/dist/{index-2vtcmrj9.js → index-n53mswt3.js} +20 -20
  104. package/dist/{index-bt14acb5.js → index-n5yqxd3d.js} +21 -21
  105. package/dist/{index-bm5rx7n6.js → index-n9tyz8z3.js} +12 -12
  106. package/dist/index-nmaqxdx7.js +463 -0
  107. package/dist/{index-mqv3b5rh.js → index-nx6eazt0.js} +30 -30
  108. package/dist/{index-3z73fh2c.js → index-q0th8q0z.js} +9 -9
  109. package/dist/{index-v260df89.js → index-qjeggp8w.js} +20 -20
  110. package/dist/{index-zfrhfn82.js → index-r1z0rd9c.js} +20 -20
  111. package/dist/{index-wqgjfstn.js → index-r5c4fedv.js} +3 -3
  112. package/dist/{index-cyktc4bp.js → index-r5hsgkz5.js} +118 -118
  113. package/dist/{index-0b1ess5e.js → index-r7a7r7ha.js} +1 -1
  114. package/dist/{index-1j8pm5t0.js → index-rfkh44ss.js} +15 -15
  115. package/dist/index-rz3z13e9.js +47 -0
  116. package/dist/{index-k6gpggb9.js → index-s80awwsw.js} +35 -35
  117. package/dist/{index-vqr7f7v8.js → index-ssmd41kh.js} +124 -124
  118. package/dist/index-sznqf8fx.js +20 -0
  119. package/dist/{index-ensd0r74.js → index-v14qqmys.js} +41 -41
  120. package/dist/{index-paz6sgdp.js → index-v4a2fjtk.js} +36 -36
  121. package/dist/{index-d9pk3td4.js → index-w0eqdhyd.js} +14 -14
  122. package/dist/{index-5qn1kx34.js → index-w575evhq.js} +29 -29
  123. package/dist/{index-3gye9wzb.js → index-w5s3xftz.js} +26 -26
  124. package/dist/{index-tm88avnj.js → index-wykebpn9.js} +3 -3
  125. package/dist/{index-sh3s86na.js → index-xkhk5d6g.js} +17 -17
  126. package/dist/{index-6k1937bx.js → index-xqhede6x.js} +4 -4
  127. package/dist/{index-f4fx2tza.js → index-zs4ms3th.js} +181 -181
  128. package/dist/{index-k5hsfc3j.js → index-zsvzhjtk.js} +1 -1
  129. package/dist/internal/secret-shapes.d.ts +21 -0
  130. package/dist/internal/secret-shapes.d.ts.map +1 -0
  131. package/dist/observability/bounded-logger.d.ts +2 -0
  132. package/dist/observability/bounded-logger.d.ts.map +1 -1
  133. package/dist/observability/sanitize.d.ts +6 -0
  134. package/dist/observability/sanitize.d.ts.map +1 -1
  135. package/dist/observability/secret-values.d.ts +25 -0
  136. package/dist/observability/secret-values.d.ts.map +1 -0
  137. package/dist/react/keyboard/hooks.d.ts +71 -0
  138. package/dist/react/keyboard/hooks.d.ts.map +1 -0
  139. package/dist/react/keyboard/layers.d.ts +98 -0
  140. package/dist/react/keyboard/layers.d.ts.map +1 -0
  141. package/dist/react/keyboard/zone.d.ts +139 -0
  142. package/dist/react/keyboard/zone.d.ts.map +1 -0
  143. package/dist/telegram/bot-api.d.ts +7 -4
  144. package/dist/telegram/bot-api.d.ts.map +1 -1
  145. package/dist/telegram/crypto.d.ts +16 -0
  146. package/dist/telegram/crypto.d.ts.map +1 -0
  147. package/dist/telegram/html/check.d.ts +26 -0
  148. package/dist/telegram/html/check.d.ts.map +1 -0
  149. package/dist/telegram/html/nodes.d.ts +70 -0
  150. package/dist/telegram/html/nodes.d.ts.map +1 -0
  151. package/dist/telegram/html/parse.d.ts +35 -0
  152. package/dist/telegram/html/parse.d.ts.map +1 -0
  153. package/dist/telegram/html/render.d.ts +39 -0
  154. package/dist/telegram/html/render.d.ts.map +1 -0
  155. package/dist/telegram/init-data.d.ts +3 -44
  156. package/dist/telegram/init-data.d.ts.map +1 -1
  157. package/dist/telegram/operator-channel.d.ts +19 -3
  158. package/dist/telegram/operator-channel.d.ts.map +1 -1
  159. package/dist/telegram/operator-dedupe.d.ts +44 -0
  160. package/dist/telegram/operator-dedupe.d.ts.map +1 -0
  161. package/dist/telegram/screens/callback-codec.d.ts +34 -0
  162. package/dist/telegram/screens/callback-codec.d.ts.map +1 -0
  163. package/dist/telegram/screens/dispatch.d.ts +22 -0
  164. package/dist/telegram/screens/dispatch.d.ts.map +1 -0
  165. package/dist/telegram/screens/display.d.ts +70 -0
  166. package/dist/telegram/screens/display.d.ts.map +1 -0
  167. package/dist/telegram/screens/execute.d.ts +57 -0
  168. package/dist/telegram/screens/execute.d.ts.map +1 -0
  169. package/dist/telegram/screens/html.d.ts +36 -0
  170. package/dist/telegram/screens/html.d.ts.map +1 -0
  171. package/dist/telegram/screens/outcome.d.ts +74 -0
  172. package/dist/telegram/screens/outcome.d.ts.map +1 -0
  173. package/dist/telegram/screens/path.d.ts +29 -0
  174. package/dist/telegram/screens/path.d.ts.map +1 -0
  175. package/dist/telegram/screens/reconcile.d.ts +67 -0
  176. package/dist/telegram/screens/reconcile.d.ts.map +1 -0
  177. package/dist/telegram/screens/registry.d.ts +23 -0
  178. package/dist/telegram/screens/registry.d.ts.map +1 -0
  179. package/dist/telegram/screens/render.d.ts +70 -0
  180. package/dist/telegram/screens/render.d.ts.map +1 -0
  181. package/dist/telegram/screens/runtime.d.ts +133 -0
  182. package/dist/telegram/screens/runtime.d.ts.map +1 -0
  183. package/dist/telegram/screens/screen-types.d.ts +130 -0
  184. package/dist/telegram/screens/screen-types.d.ts.map +1 -0
  185. package/dist/telegram/screens/screen.d.ts +138 -0
  186. package/dist/telegram/screens/screen.d.ts.map +1 -0
  187. package/dist/telegram/screens/state.d.ts +115 -0
  188. package/dist/telegram/screens/state.d.ts.map +1 -0
  189. package/dist/telegram/screens/telegram-screens.d.ts +18 -0
  190. package/dist/telegram/screens/telegram-screens.d.ts.map +1 -0
  191. package/dist/telegram/screens/test-chat-telegram.d.ts +47 -0
  192. package/dist/telegram/screens/test-chat-telegram.d.ts.map +1 -0
  193. package/dist/telegram/screens/test-chat.d.ts +66 -0
  194. package/dist/telegram/screens/test-chat.d.ts.map +1 -0
  195. package/dist/telegram/screens/view.d.ts +89 -0
  196. package/dist/telegram/screens/view.d.ts.map +1 -0
  197. package/dist/telegram/send-failure.d.ts +25 -0
  198. package/dist/telegram/send-failure.d.ts.map +1 -1
  199. package/dist/telegram/update-intake.d.ts +82 -0
  200. package/dist/telegram/update-intake.d.ts.map +1 -0
  201. package/dist/telegram/update-store-check.d.ts +22 -0
  202. package/dist/telegram/update-store-check.d.ts.map +1 -0
  203. package/dist/telegram/update-store-postgres.d.ts +33 -0
  204. package/dist/telegram/update-store-postgres.d.ts.map +1 -0
  205. package/dist/telegram/update-store-sqlite.d.ts +26 -0
  206. package/dist/telegram/update-store-sqlite.d.ts.map +1 -0
  207. package/dist/telegram/update-store.d.ts +67 -0
  208. package/dist/telegram/update-store.d.ts.map +1 -0
  209. package/dist/telegram/user.d.ts +51 -0
  210. package/dist/telegram/user.d.ts.map +1 -0
  211. package/dist/telegram/webhook.d.ts +105 -0
  212. package/dist/telegram/webhook.d.ts.map +1 -0
  213. package/dist/tools/cli/args-fields.d.ts +8 -0
  214. package/dist/tools/cli/args-fields.d.ts.map +1 -1
  215. package/dist/tools/cli/args-route.d.ts.map +1 -1
  216. package/dist/tools/cli/args.d.ts.map +1 -1
  217. package/dist/tools/cli/help.d.ts.map +1 -1
  218. package/llms/cli.txt +8 -2
  219. package/llms/observability.txt +15 -2
  220. package/llms/react-keyboard.txt +193 -0
  221. package/llms/server.4.txt +16 -0
  222. package/llms/stitchkit.txt +10 -4
  223. package/llms/telegram-html.txt +35 -0
  224. package/llms/telegram-screens.txt +365 -0
  225. package/llms/telegram.txt +245 -11
  226. package/llms/{upgrading-0.74.0-0.100.0.txt → upgrading-0.74.0-0.101.0.txt} +39 -3
  227. package/llms/upgrading.txt +1 -1
  228. package/llms-full.txt +889 -19
  229. package/llms.txt +12 -7
  230. package/package.json +17 -2
  231. package/dist/index-2k4yrqkc.js +0 -9
  232. package/dist/index-crykmqkr.js +0 -26
  233. package/dist/index-y64k3s0h.js +0 -150
  234. package/dist/index-y91ry9h2.js +0 -4
  235. /package/dist/{index-hffjpmq9.js → index-9jfdm5tg.js} +0 -0
  236. /package/dist/{index-zjjyynm3.js → index-d4pa2bgs.js} +0 -0
  237. /package/dist/{index-rxfy4cq7.js → index-dwfv9a85.js} +0 -0
  238. /package/dist/{index-5jm36zv1.js → index-gfk7nrht.js} +0 -0
  239. /package/dist/{index-b9aefrqd.js → index-n1dqr7gf.js} +0 -0
  240. /package/dist/{index-q3pyb9n0.js → index-sykb5ag7.js} +0 -0
  241. /package/dist/{index-48ffdxgk.js → index-xhzq2qvj.js} +0 -0
  242. /package/dist/{index-kzfs85xp.js → index-zsarb5xp.js} +0 -0
package/llms/telegram.txt CHANGED
@@ -5,6 +5,9 @@
5
5
 
6
6
  Contains: Guide: Telegram bots (docs/guide/telegram.md) · API reference: `stitchkit/telegram` (docs/api/reference.md)
7
7
 
8
+ Also read:
9
+ - see `llms/telegram-screens.txt` for the Telegram screens guide
10
+
8
11
  ==============================================================================
9
12
  # Guide: Telegram bots (docs/guide/telegram.md)
10
13
  ==============================================================================
@@ -21,6 +24,8 @@ way: [ADR 0201](../decisions/0201-a-telegram-bot-is-assembled-from-primitives.md
21
24
  | Job | Where |
22
25
  |---|---|
23
26
  | the bot as resources: command menu, then long polling with updates admitted by batch | `grammyBotResources` — [application kernel](application-kernel.md#optional-grammy-adapter) |
27
+ | a bot on a webhook: set only where it is this process's, updates recorded before Telegram is answered | `claimTelegramWebhook`, `createTelegramUpdateIntake`, `receiveTelegramWebhook` — [below](#a-bot-on-a-webhook) |
28
+ | text for Telegram's HTML: cleaned, cut to the limit, checked | `stitchkit/telegram/html` — [below](#message-markup) |
24
29
  | the process journal | `createJsonLogger` from `stitchkit/observability` |
25
30
  | the operator's chat: product events, apart from the journal | `createTelegramOperatorChannel` |
26
31
  | a broadcast that survives crashes, deploys and Ctrl-C | `runTelegramBroadcast` |
@@ -28,6 +33,7 @@ way: [ADR 0201](../decisions/0201-a-telegram-bot-is-assembled-from-primitives.md
28
33
  | why a send was refused | `classifyTelegramSendFailure` — [auth and errors](auth-and-errors.md#telegram-mini-apps) |
29
34
  | Mini App `initData` | `verifyTelegramInitData` — [auth and errors](auth-and-errors.md#telegram-mini-apps) |
30
35
  | one Bot API call without a bot library | `callTelegramBotApi` |
36
+ | menus: screens, buttons, actions, input and message editing | `stitchkit/telegram/screens` — [Telegram screens](telegram-screens.md) |
31
37
 
32
38
  ## The journal
33
39
 
@@ -36,21 +42,46 @@ output, in pino's shape — numeric `level` (`debug` 20, `info` 30, `warn` 40,
36
42
  `error` 50), `time` in epoch milliseconds, `msg` — so `pino-pretty` reads it on
37
43
  a terminal and a supervisor matching `"level":50` sees errors. Unlike pino, an
38
44
  `Error` under *any* key is written with its name, message, stack and cause; an
39
- error logged as `{ error }` does not become `"error":{}`.
45
+ error logged as `{ error }` does not become `"error":{}`, and its own fields —
46
+ a `TelegramBotApiError`'s `error_code` and `parameters.retry_after` — are on the
47
+ line beside them.
48
+
49
+ A bot token is masked wherever it appears — a request URL in an error message,
50
+ a local Bot API file path — with no configuration: `123456:[redacted]` keeps the
51
+ bot's id, which is public and is what tells two bots apart in one journal. So is
52
+ the password in an address and a query parameter named as a secret (`?token=`).
40
53
 
41
54
  ```ts
42
55
  import { createJsonLogger } from 'stitchkit/observability'
43
- import { TELEGRAM_BOT_TOKEN_PATTERN } from 'stitchkit/telegram'
44
56
 
45
57
  export const logger = createJsonLogger({
46
58
  level: env.LOG_LEVEL,
47
59
  fields: { service: 'my-bot' },
48
- sensitiveUrlPatterns: [TELEGRAM_BOT_TOKEN_PATTERN],
49
60
  })
50
61
 
51
62
  logger.error('Application startup failed', { error })
52
63
  ```
53
64
 
65
+ A provider's key, a webhook secret or a database password has no shape a
66
+ pattern could guess, so the journal also masks exact values — and the
67
+ environment already names them. `secretValuesFromEnv` collects every variable
68
+ named as a token, secret, key, password or credential, the password and secret
69
+ query values inside any URL variable (`DATABASE_URL`), and each URL-encoded:
70
+
71
+ ```ts
72
+ import { createJsonLogger, secretValuesFromEnv } from 'stitchkit/observability'
73
+
74
+ export const logger = createJsonLogger({
75
+ sensitiveValues: secretValuesFromEnv(process.env),
76
+ })
77
+ ```
78
+
79
+ A string of your own — a message for a person, a report — masks bot tokens with
80
+ `redactTelegramBotToken(text)` from `stitchkit/telegram`.
81
+
82
+ A key added to the environment is masked from its first line; a URL's user name
83
+ is left alone, since it names a role that appears in ordinary text.
84
+
54
85
  The error line contract, for whatever recognises a failure from the journal: a
55
86
  line whose `level` is `50` or more is an error. There is no separate `fatal`;
56
87
  an unrecoverable end is an `error` line followed by a non-zero exit.
@@ -79,8 +110,46 @@ operators.post(`New user ${user.id}`, 'users')
79
110
 
80
111
  Every message is masked before it leaves: a bot token always — bots post their
81
112
  errors here, and an error about a local Bot API file carries the token in its
82
- path — plus any `sensitivePatterns` the bot adds; `telegramOperatorSender` also
83
- masks its own exact token. What to post stays the bot's. On the way down, drain it within the grace period
113
+ path — plus any `sensitivePatterns` and exact `sensitiveValues` the bot adds;
114
+ `telegramOperatorSender` also masks its own exact token, and with `parseMode:
115
+ 'HTML'` repairs the markup — a text cut at Telegram's limit never leaves a tag
116
+ open.
117
+
118
+ A failure that repeats a hundred times, or a storm of a hundred different ones
119
+ when a database goes down, is a line with a count for the person reading, not a
120
+ hundred messages. `dedupe` sends one message per fingerprint per window — the
121
+ topic and the text with numbers, UUIDs and long identifiers blanked — and the
122
+ next one of that kind carries how many were held back; past `maxPerWindow`
123
+ messages in a window the rest are counted onto the next message sent:
124
+
125
+ ```ts
126
+ const operators = createTelegramOperatorChannel<'errors'>({
127
+ chatId: env.OPERATOR_CHAT_ID,
128
+ send: telegramOperatorSender({ token: env.BOT_TOKEN }),
129
+ sensitiveValues: secretValuesFromEnv(process.env),
130
+ dedupe: {
131
+ windowMs: 10 * 60_000,
132
+ maxPerWindow: 20,
133
+ repeatedLine: (count) => `Same again: ${count}`,
134
+ },
135
+ })
136
+ ```
137
+
138
+ Held-back messages reach `onDropped` as `repeated` or `over-budget`.
139
+
140
+ A bot that already sends its alerts itself — its own bot instance, its own retry
141
+ — takes the window alone instead of switching the channel off with parameters:
142
+
143
+ ```ts
144
+ import { createTelegramOperatorDedupe } from 'stitchkit/telegram'
145
+
146
+ const admit = createTelegramOperatorDedupe<'errors'>({ windowMs: 10 * 60_000 })
147
+
148
+ const verdict = admit(text, 'errors')
149
+ if (verdict.send) await sendAlert(verdict.text) // the text carries the held-back counts
150
+ ```
151
+
152
+ What to post stays the bot's. On the way down, drain it within the grace period
84
153
  and close it: `drain: (context) => operators.drain(context.signal)`,
85
154
  `close: () => operators.close()` in a resource.
86
155
 
@@ -124,6 +193,155 @@ application (the application's signal, with admission held by the caller).
124
193
  The recipient state contains Telegram ids: keep it in the state directory, out
125
194
  of git.
126
195
 
196
+ ## Message markup
197
+
198
+ `stitchkit/telegram/html` is Telegram's HTML parse mode as a tree, with no peer
199
+ and no DOM, so a server sending a message and a browser previewing it share one
200
+ parser. Text written by someone else — a model, an editor, an owner's greeting —
201
+ becomes markup Telegram accepts: synonyms renamed (`strong` → `b`), block tags
202
+ turned into line breaks, a link kept only with an `href` Telegram opens,
203
+ anything Telegram forbids (a tag it does not know, formatting inside `code`, a
204
+ quote inside a quote) left as its words.
205
+
206
+ ```ts
207
+ import { sanitizeTelegramHtml, splitTelegramHtml } from 'stitchkit/telegram/html'
208
+
209
+ for (const part of splitTelegramHtml(summary)) {
210
+ await ctx.reply(part, { parse_mode: 'HTML' })
211
+ }
212
+ ```
213
+
214
+ Telegram's limits — 4096 for a message, 1024 for a caption (`{ limit:
215
+ TELEGRAM_CAPTION_LIMIT }`) — count the text after the markup is parsed, so a
216
+ text is cut by what the reader sees, not by the length of its tags. A cut
217
+ prefers a paragraph, then a line, then a space; it never splits a character or
218
+ a custom emoji, and an element open across it is closed in one part and opened
219
+ again, attributes and all, in the next. `truncateTelegramHtml` keeps one part
220
+ with an ellipsis; `telegramHtmlText` is what the reader sees; `parseTelegramHtml`
221
+ gives the nodes to render a preview or an email from. `checkTelegramHtml` says,
222
+ without repairing anything, what Telegram would refuse — for an editor that
223
+ should warn a person rather than change their text.
224
+
225
+ ## A bot on a webhook
226
+
227
+ A webhook is one place in all of Telegram, and `setWebhook` takes it without
228
+ asking whose it was: a staging host or a developer's machine started with the
229
+ production token takes the production bot with one call. `claimTelegramWebhook`
230
+ sets it only where it already points here — address *and* secret — or where the
231
+ bot names the current owner's host as the one it takes over from (`'none'` when
232
+ there is no webhook: a bot on long polling is somebody's bot too). Telegram never
233
+ returns the secret, so the address carries an `owner` tag derived from it; a
234
+ copy of the production address with another secret is refused as
235
+ `other-secret` instead of quietly installing its own.
236
+
237
+ ```ts
238
+ import {
239
+ checkTelegramWebhook,
240
+ claimTelegramWebhook,
241
+ createTelegramUpdateIntake,
242
+ receiveTelegramWebhook,
243
+ sqliteTelegramUpdateStore,
244
+ } from 'stitchkit/telegram'
245
+ import { Database } from 'bun:sqlite'
246
+ import type { Update } from 'grammy/types'
247
+
248
+ const webhook = { token: env.BOT_TOKEN, url: env.WEBHOOK_URL, secret: env.WEBHOOK_SECRET }
249
+
250
+ const intake = createTelegramUpdateIntake<Update>({
251
+ store: sqliteTelegramUpdateStore({ database: new Database(env.UPDATES_DB) }),
252
+ handle: (update) => bot.handleUpdate(update),
253
+ onFailure: (failure) => logger.warn('Update failed', { ...failure }),
254
+ })
255
+
256
+ // start: set the webhook, then handle what was recorded before the restart
257
+ await bot.init()
258
+ await claimTelegramWebhook({ ...webhook, takeoverFrom: env.WEBHOOK_TAKEOVER_FROM })
259
+ await intake.start()
260
+
261
+ // the route
262
+ const route = (request: Request) =>
263
+ receiveTelegramWebhook(request, { secret: webhook.secret, accept: intake.accept })
264
+
265
+ // stop admission: `await intake.close()` waits for the handlers in flight
266
+ ```
267
+
268
+ A refusal names the owner's host, never its path — a foreign webhook's path may
269
+ itself be a secret — and says what to pass: set the takeover for one start and
270
+ remove it. `checkTelegramWebhook` on a timer notices a webhook that moved
271
+ elsewhere, which this process cannot see any other way.
272
+
273
+ **Moving to it from a claim of your own.** A bot that tagged its webhook itself
274
+ holds an address this one does not recognise, so the first claim is refused —
275
+ `other-secret` when the address is the same, `owned-elsewhere` otherwise — and
276
+ names its own host. Start once with `takeoverFrom` set to that host, then remove
277
+ it. The tag is derived from the secret with a fixed context, so it is the same
278
+ across stitchkit releases: that takeover is needed once per bot, not per
279
+ upgrade. A secret rotation is a new owner, and takes a takeover too.
280
+
281
+ **The update is recorded before Telegram is answered.** A handler that runs
282
+ longer than Telegram waits — a large file from a local Bot API server — gets the
283
+ same update again while it still works, and a process that answers and then
284
+ dies loses the update for good. `receiveTelegramWebhook` answers 200 once the
285
+ intake recorded the body; the handler runs after, outside the request. Updates
286
+ of one chat run in order, other chats alongside (`maxConcurrent`, 32). An
287
+ attempt holds a lease it renews while the handler lives; a sweep takes over what
288
+ was left — `pending` whose process never got to it (`pendingGraceMs`), `failed`
289
+ whose retry is due, `processing` whose process died. A failure is retried with
290
+ backoff or after Telegram's `retry_after`; one Telegram said repeating cannot
291
+ fix (a refused message, a blocked user) is abandoned; `maxAttempts` (5) ends
292
+ the rest. Delivery is at least once: a handler that finished but could not be
293
+ recorded as finished runs again.
294
+
295
+ With grammY the handler is `bot.handleUpdate`, which wraps a middleware's error
296
+ in a `BotError`; the intake unwraps it, so `retry`, `onFailure` and the error
297
+ stored with the update are what the middleware threw — a `retry` that returns
298
+ `false` for the bot's own terminal error sees that error.
299
+
300
+ The store is an interface of six atomic steps (`TelegramUpdateStore`), so the
301
+ bot's own database holds it:
302
+
303
+ | Store | Takes |
304
+ |---|---|
305
+ | `sqliteTelegramUpdateStore` | a `bun:sqlite` `Database` or `node:sqlite` `DatabaseSync` as it is |
306
+ | `postgresTelegramUpdateStore` | one `query(text, parameters)` over the client the bot already holds |
307
+ | `memoryTelegramUpdateStore` | nothing — tests, or a bot that accepts losing unhandled updates to a restart |
308
+
309
+ ```ts
310
+ import { postgresTelegramUpdateStore } from 'stitchkit/telegram'
311
+
312
+ // Bun
313
+ postgresTelegramUpdateStore({ query: (text, parameters) => sql.unsafe(text, [...parameters]) })
314
+ // Prisma
315
+ postgresTelegramUpdateStore({
316
+ query: (text, parameters) => prisma.$queryRawUnsafe(text, ...parameters),
317
+ createTable: false, // the migration below owns the table
318
+ })
319
+ // pg
320
+ postgresTelegramUpdateStore({
321
+ query: (text, parameters) => pool.query(text, [...parameters]).then((result) => result.rows),
322
+ })
323
+ ```
324
+
325
+ Each rule is one conditional statement, so a claim is atomic across every
326
+ process sharing the table. By default the table is created on first use; where
327
+ migrations own the schema, `postgresTelegramUpdateStoreSchema(table)` is the
328
+ statement to put in one.
329
+
330
+ A store of your own — over an ORM, another database — is put through the same
331
+ rules the shipped ones are, concurrent claims included:
332
+
333
+ ```ts
334
+ import { checkTelegramUpdateStore } from 'stitchkit/telegram'
335
+
336
+ test('our update store keeps the rules', async () => {
337
+ expect(await checkTelegramUpdateStore(() => ourStore(freshTable()))).toEqual([])
338
+ })
339
+ ```
340
+
341
+ A store that reads and then writes in two statements passes every test with one
342
+ process and runs one update twice in production; the check races eight claims
343
+ against each other to catch it.
344
+
127
345
  ## Files from a local Bot API server
128
346
 
129
347
  A local `telegram-bot-api` keeps downloaded files under `<dir>/<bot token>/`,
@@ -169,21 +387,37 @@ injected grammY bot. → ADR 0143, ADR 0201 — [guide](../guide/telegram.md)
169
387
  | `verifyTelegramInitData` | function | verify a Mini App `initData` signature against the bot token in constant time, before reading anything out of it |
170
388
  | `VerifyTelegramInitDataOptions` | _type_ | raw `initData`, bot token, optional `maxAgeSeconds` bound and injected clock |
171
389
  | `TelegramInitDataVerification` | _type_ | `{ valid: true, data }` or a refusal carrying its reason |
172
- | `TelegramInitData` | _type_ | verified `user`/`receiver`, `authDate`, `ageSeconds`, `queryId`, `startParam`, `chatType`, `chatInstance` and every signed pair in `raw` |
173
- | `TelegramInitDataUser` | _type_ | camelCase user record inferred from Telegram's signed `user` payload |
390
+ | `TelegramInitData` | _type_ | verified `user`/`receiver` as `TelegramUser`, `authDate`, `ageSeconds`, `queryId`, `startParam`, `chatType`, `chatInstance` and every signed pair in `raw` |
391
+ | `parseTelegramUser` / `TelegramUser` | function / _type_ | Telegram's `snake_case` user — grammY's `ctx.from`, a Bot API `User`, a Mini App `user` — as the one camelCase record initData returns too; `undefined` when it is not a user |
174
392
  | `TelegramInitDataRefusal` | _type_ | `missing-hash` / `signature-mismatch` / `malformed` / `expired` — an expired string is not a forged one |
175
393
  | `classifyTelegramSendFailure` | function | name a refused Bot API send and separate "retry this send" from "stop addressing this recipient" |
176
394
  | `TelegramSendFailure` | _type_ | reason, `status`, Telegram-stated `retryAfterSeconds`, `retryable`, `recipientUnreachable` and which evidence produced the answer |
177
- | `TelegramSendFailureReason` | _type_ | `blocked-by-user` / `user-deactivated` / `chat-not-found` / `not-started` / `rate-limited` / `message-invalid` / `server-error` / `unknown` |
395
+ | `TelegramSendFailureReason` / `TELEGRAM_SEND_FAILURE_REASONS` | _type_ / constant | `blocked-by-user` / `user-deactivated` / `chat-not-found` / `not-started` / `rate-limited` / `message-invalid` / `server-error` / `unknown`, and the same as a list; a `Record<TelegramSendFailureReason, …>` over them fails to compile when a reason is added |
178
396
  | `runTelegramBroadcast` | function | a resumable broadcast by `name` under a state `directory`: audience written once, an append-only journal, pacing at `ratePerSecond` (25), 429 waits `retry_after`, unreachable recipients never addressed again, a `message-invalid` or unreachable Telegram halts without charging the recipient, a send in flight at a crash is `uncertain` and not repeated, `dryRun`, one runner per name |
179
397
  | `TelegramBroadcastConfig` / `TelegramBroadcastSend` / `TelegramBroadcastReport` / `TelegramBroadcastRunOutcome` / `TelegramBroadcastOutcome` / `TelegramBroadcastRecipient` | _type_ | `name`, `directory`, `recipients()`, `send({ recipient, attempt })`, `maxAttempts`, `signal`, `onProgress`; counts `delivered` / `unreachable` / `failed` / `uncertain` / `pending` and `finished` / `stopped` / `halted` / `dry-run` |
180
398
  | `telegramBroadcastSender` / `TelegramBroadcastSenderConfig` / `TelegramBroadcastMessage` | function / _type_ | the standard `send`: `sendMessage` with a text or `copyMessage` of a prepared message, through the Bot API |
181
399
  | `createTelegramOperatorChannel` | function | the operator chat, apart from the journal: `post(text, topic?)` returns at once and never throws; paced sends into forum topics, 429 honoured, oldest dropped on overflow, `drain(signal)` and `close()` for the way down |
182
- | `TelegramOperatorChannelConfig` / `TelegramOperatorChannel` / `TelegramOperatorMessage` / `TelegramOperatorDrop` / `TelegramChatId` | _type_ | `chatId`, `topics`, `send`, `maxQueued`, `minIntervalMs`, `maxAttempts`, `onDropped`, `sensitivePatterns` (a bot token is always masked); a drop is `overflow` / `refused` / `attempts` / `closed` with its classification |
183
- | `telegramOperatorSender` / `TelegramOperatorSenderConfig` | function / _type_ | the standard `send`: `sendMessage` into the chat and topic, plain text unless `parseMode` |
400
+ | `TelegramOperatorChannelConfig` / `TelegramOperatorChannel` / `TelegramOperatorMessage` / `TelegramOperatorDrop` / `TelegramChatId` | _type_ | `chatId`, `topics`, `send`, `maxQueued`, `minIntervalMs`, `maxAttempts`, `onDropped`, `sensitivePatterns` and `sensitiveValues` (a bot token is always masked), `dedupe`; a drop is `overflow` / `refused` / `attempts` / `closed` / `repeated` / `over-budget` with its classification |
401
+ | `createTelegramOperatorDedupe` / `TelegramOperatorDedupeVerdict` | function / _type_ | the channel's dedupe window alone, for a sender with its own bot and retry: `(text, topic?)` → `{ send: true, text }` with the held-back counts, or `{ send: false, reason: 'repeated' \| 'over-budget' }` |
402
+ | `TelegramOperatorDedupe` | _type_ | `windowMs` (10 min): one message per `fingerprint` per window, the next one sent carries `repeatedLine(count)`; `maxPerWindow` (20) across fingerprints, the overflow counted onto the next one sent with `overBudgetLine(count)`; `now` |
403
+ | `telegramOperatorSender` / `TelegramOperatorSenderConfig` | function / _type_ | the standard `send`: `sendMessage` into the chat and topic, plain text unless `parseMode`; with `'HTML'` the markup is cleaned and cut by what it shows, so the channel's cut never leaves a tag open |
184
404
  | `createTelegramLocalFiles` | function | files the local Bot API server wrote under `<root>/<token>/`: `resolve(file_path)` and `remove(file_path)` only inside the bot's directory, judged on real paths so a link is judged by where it leads; `check()` for readiness. Recommended root variable: `BOT_API_FILES_ROOT` |
185
405
  | `TelegramLocalFilesConfig` / `TelegramLocalFiles` / `TelegramLocalFilesCheck` / `TelegramLocalFileError` / `TelegramLocalFileRefusal` | _type_ / class | `root`, `token`; refusals `root-not-absolute` / `bot-directory-unavailable` / `outside-bot-directory` / `missing` / `not-a-file`, never carrying the path or the token |
186
406
  | `callTelegramBotApi` / `TelegramBotApiCall` / `TelegramBotApiError` | function / _type_ / class | one Bot API method over `fetch`; a refusal keeps `error_code`, `description` and `parameters` for the classifier, and no error names the URL |
187
- | `TELEGRAM_BOT_TOKEN_PATTERN` | constant | the shape of a bot token, for a logger's `sensitiveUrlPatterns` |
407
+ | `TELEGRAM_BOT_TOKEN_PATTERN` / `redactTelegramBotToken` | constant / function | the secret half of a bot token — the bot's id before the colon is not matched; `redact` and every logger on it mask it already, `redactTelegramBotToken(text)` masks every token in a string to `123456:[redacted]` |
408
+ | `claimTelegramWebhook` | function | set the webhook only where it already points here, or by naming the host it is taken from; confirm Telegram holds it; pending updates kept unless `dropPendingUpdates` |
409
+ | `ClaimTelegramWebhookConfig` / `TelegramWebhookConfig` / `TelegramWebhookClaim` | _type_ | `token`, `url`, `secret`, `apiRoot`, `fetch`, `signal`; `takeoverFrom` (a host, or `'none'`), `allowedUpdates`, `maxConnections`, `dropPendingUpdates`; the claim's tagged `url`, `takenFrom`, `pendingUpdates` |
410
+ | `TelegramWebhookClaimError` / `TelegramWebhookRefusal` / `TELEGRAM_WEBHOOK_NONE` | class / _type_ / constant | `owned-elsewhere` / `other-secret` / `not-confirmed` with `ownerHost` — a host, never a path; `'none'` names no webhook |
411
+ | `telegramWebhookUrl` | function | the address with an `owner` tag derived from the secret: equal for one secret, different for another, the path untouched |
412
+ | `checkTelegramWebhook` / `TelegramWebhookState` | function / _type_ | whether the webhook still points here: `owned`, `ownerHost`, `pendingUpdates`, Telegram's `lastError` |
413
+ | `receiveTelegramWebhook` / `ReceiveTelegramWebhookOptions` / `TelegramUpdateAcceptance` | function / _type_ | one webhook `Request`: 403 without the secret (compared in constant time), 405 for another method, 400 for no update, 200 once `accept` recorded it |
414
+ | `createTelegramUpdateIntake` | function | webhook updates recorded before Telegram is answered and handled after: chat order kept, other chats alongside up to `maxConcurrent`, a renewed lease per attempt, a sweep that takes over what a restart or a dead process left, retries, `maxAttempts`, finished updates remembered `retainMs` to refuse repeats; at least once |
415
+ | `TelegramUpdateIntakeConfig` / `TelegramUpdateIntake` / `TelegramUpdateEnvelope` / `TelegramUpdateFailure` / `TelegramUpdateStoreStep` | _type_ | `store`, `handle` (a grammY `BotError` is unwrapped: `retry`, `onFailure` and the stored error see what the middleware threw), `retry`, `maxAttempts` (5), `leaseMs` (120 000), `pendingGraceMs` (60 000), `sweepEveryMs` (30 000), `retainMs` (24 h), `maxConcurrent` (32), `onFailure`, `onStoreError`, `now`; `accept(body)`, `start()`, `sweep()`, `idle()`, `close()` |
416
+ | `TelegramUpdateStore` / `StoredTelegramUpdate` / `TelegramUpdateState` / `TelegramUpdateSettlement` / `TelegramUpdateClaimOptions` / `TelegramUpdateDueQuery` | _type_ | the store an intake runs on: `add`, `claim`, `renew`, `settle`, `due`, `prune`, each atomic on one record — a claim takes `pending`, a due `failed` or a lapsed `processing`; renew and settle only by the attempt holding it |
417
+ | `memoryTelegramUpdateStore` | function | the store in this process's memory — tests, or a bot that accepts losing unhandled updates to a restart |
418
+ | `sqliteTelegramUpdateStore` / `SqliteTelegramUpdateStoreConfig` / `TelegramSqliteDatabase` | function / _type_ | the store in SQLite over the handle the application opened — `bun:sqlite` `Database` or `node:sqlite` `DatabaseSync`; `table` (default `telegram_updates`) created when missing |
419
+ | `postgresTelegramUpdateStore` / `PostgresTelegramUpdateStoreConfig` / `TelegramPostgresQuery` | function / _type_ | the store in Postgres over the client the application holds, as one `query(text, parameters)` returning rows — Bun `sql.unsafe`, Prisma `$queryRawUnsafe`, `pg` `pool.query`; each rule one conditional statement; `table`, `createTable` (default `true`) |
420
+ | `postgresTelegramUpdateStoreSchema` | function | the table and its index as a statement for a migration, safe to run twice |
421
+ | `checkTelegramUpdateStore` | function | put any store through every rule, each on a fresh store from `make`, concurrent claims included; resolves with the violations, empty when it keeps them |
188
422
 
189
423
  ---
@@ -1,14 +1,50 @@
1
- # Upgrading 0.74.0 → 0.100.0 — stitchkit docs slice
1
+ # Upgrading 0.74.0 → 0.101.0 — stitchkit docs slice
2
2
 
3
3
  > Generated from the `docs/` tree by `bun run gen:llms`. The index of all slices is
4
4
  > `llms.txt` in this package.
5
5
 
6
- Contains: Guide: Upgrading, 0.74.0 → 0.100.0 (docs/guide/upgrading.md)
6
+ Contains: Guide: Upgrading, 0.74.0 → 0.101.0 (docs/guide/upgrading.md)
7
7
 
8
8
  ==============================================================================
9
- # Guide: Upgrading, 0.74.0 → 0.100.0 (docs/guide/upgrading.md)
9
+ # Guide: Upgrading, 0.74.0 → 0.101.0 (docs/guide/upgrading.md)
10
10
  ==============================================================================
11
11
 
12
+ ## Released migration: 0.101.0
13
+
14
+ ### `TelegramInitDataUser` is `TelegramUser`
15
+
16
+ **Who must act:** code that names the type `TelegramInitDataUser` from
17
+ `stitchkit/telegram`. The record is the same; its name now says it is any
18
+ Telegram user, since `parseTelegramUser(ctx.from)` returns it for a Bot API user
19
+ too.
20
+
21
+ ```ts
22
+ // Before
23
+ import type { TelegramInitDataUser } from 'stitchkit/telegram'
24
+ function ensureAccount(user: TelegramInitDataUser) {}
25
+ // After
26
+ import type { TelegramUser } from 'stitchkit/telegram'
27
+ function ensureAccount(user: TelegramUser) {}
28
+ ```
29
+
30
+ ### `TELEGRAM_BOT_TOKEN_PATTERN` matches the secret half only
31
+
32
+ **Who must act:** code that uses `TELEGRAM_BOT_TOKEN_PATTERN` to find or cut a
33
+ whole token. It now matches what follows `<bot id>:`, so a masking replace keeps
34
+ the bot's id — `123456:[redacted]`. Masking needs nothing any more: `redact` and
35
+ every logger on it mask bot tokens by shape; remove the pattern from
36
+ `sensitiveUrlPatterns`, and use `redactTelegramBotToken(text)` for a string of
37
+ your own.
38
+
39
+ ```ts
40
+ // Before
41
+ createJsonLogger({ sensitiveUrlPatterns: [TELEGRAM_BOT_TOKEN_PATTERN] })
42
+ text.replace(new RegExp(TELEGRAM_BOT_TOKEN_PATTERN.source, 'g'), '[redacted]')
43
+ // After
44
+ createJsonLogger({})
45
+ redactTelegramBotToken(text)
46
+ ```
47
+
12
48
  ## Released migration: 0.100.0
13
49
 
14
50
  ### watch capacity and session/live/delivery adoption
@@ -11,7 +11,7 @@ Contains: Guide: Upgrading, general (docs/guide/upgrading.md)
11
11
 
12
12
  Load this slice, then every range slice between your installed version and the target:
13
13
 
14
- - 0.74.0 → 0.100.0: `llms/upgrading-0.74.0-0.100.0.txt`
14
+ - 0.74.0 → 0.101.0: `llms/upgrading-0.74.0-0.101.0.txt`
15
15
  - 0.61.0 → 0.73.0: `llms/upgrading-0.61.0-0.73.0.txt`
16
16
  - 0.44.0 → 0.60.0: `llms/upgrading-0.44.0-0.60.0.txt`
17
17
  - 0.37.0 → 0.37.0: `llms/upgrading-0.37.0.txt`