create-stitchkit 0.6.4 → 0.6.6

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/CHANGELOG.md CHANGED
@@ -12,6 +12,46 @@ step is overwritten by the next release.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.6.6] — 2026-09-24
16
+
17
+ ### Fixed
18
+
19
+ - **A project from `--template agent` or `--template telegram-bot` installs.**
20
+ The scaffolder rewrote their Stitchkit dependency to `catalog:` and wrote a
21
+ root `catalog`, which Bun reads only in a workspace root, so `bun install`
22
+ refused the generated project ("stitchkit@catalog: is not in the catalog").
23
+ A project without `workspaces` now gets the range itself —
24
+ `"stitchkit": "^0.96.0"` — and no catalog; the application template, a
25
+ workspace, is unchanged. A project already generated from either template
26
+ replaces each `"catalog:"` in its dependencies with the range from its
27
+ `catalog` field and deletes that field.
28
+ - **A project from `--template agent` passes its own tests.** It mounts its
29
+ agent with `stitchkit/tools`, whose optional peer
30
+ `@modelcontextprotocol/server` the template did not declare; inside this
31
+ repository the workspace supplied it. The template now depends on
32
+ `@modelcontextprotocol/server` `^2.0.0`.
33
+ - A new lane generates both single-package templates the way a user does,
34
+ installs them from the registry, and runs their check and tests — neither
35
+ defect above could be seen by a lane that installs the template directory.
36
+
37
+ ## [0.6.5] — 2026-09-24
38
+
39
+ ### Added
40
+
41
+ - `--template telegram-bot` — a long-polling Telegram bot assembled from
42
+ Stitchkit primitives: `grammyBotResources` (command menu, then polling with
43
+ updates admitted by batch), the JSON journal, an SQLite database resource,
44
+ an optional operator channel and local Bot API files, an entry point with
45
+ signals and the one policy for a poller that ended on its own (shut down,
46
+ exit 1), a `runtime-bindings.ts` where a deployment platform's state
47
+ publisher attaches, and a lifecycle test against a stand-in for Telegram.
48
+ Generated with `bun create stitchkit my-bot --template telegram-bot`.
49
+
50
+ ### Changed
51
+
52
+ - Generated projects track Stitchkit `^0.96.0`, the release that ships the
53
+ bot primitives the new template is built from.
54
+
15
55
  ## [0.6.4] — 2026-09-23
16
56
 
17
57
  ### Fixed
package/README.md CHANGED
@@ -32,6 +32,22 @@ and recovery state. The bounded startup picker reads current tool-capable models
32
32
  windows from OpenRouter instead of duplicating provider metadata in environment variables. The
33
33
  workspace path is a containment boundary, not an OS sandbox.
34
34
 
35
+ To start from a long-polling Telegram bot:
36
+
37
+ ```bash
38
+ bun create stitchkit my-bot --template telegram-bot
39
+ cd my-bot
40
+ cp .env.example .env
41
+ # Set BOT_TOKEN.
42
+ bun run dev
43
+ ```
44
+
45
+ The bot is assembled from published primitives only: the bot's resources with
46
+ updates admitted by batch, a JSON journal, an SQLite database resource, an
47
+ optional operator channel and local Bot API files, and one entry point that
48
+ turns signals — and a poller that ended on its own — into a bounded shutdown.
49
+ Its product is `src/handlers.ts`.
50
+
35
51
  It uses one conventional `packages/*` namespace: `backend`, `frontend`,
36
52
  `config`, `db` and `shared`. The destination name becomes the generated slug;
37
53
  `--display-name` sets the human title. Both are recorded once in
package/dist/cli.js CHANGED
@@ -206,7 +206,7 @@ function withIdentity(declaration2, identity) {
206
206
  var HELP = `Create a production-shaped Stitchkit application.
207
207
 
208
208
  Usage:
209
- bun create stitchkit <directory> [--template application|agent] [--display-name "Product Name"] [--example repository] [--no-install]
209
+ bun create stitchkit <directory> [--template application|agent|telegram-bot] [--display-name "Product Name"] [--example repository] [--no-install]
210
210
 
211
211
  Options:
212
212
  --no-install Generate files without installing dependencies
@@ -238,10 +238,10 @@ function parseOptions(args) {
238
238
  if (templateFlagIndex !== -1 && template === undefined) {
239
239
  throw new Error("--template requires a value");
240
240
  }
241
- if (template !== "application" && template !== "agent") {
241
+ if (template !== "application" && template !== "agent" && template !== "telegram-bot") {
242
242
  throw new Error(`Unknown template: ${template}`);
243
243
  }
244
- if (template === "agent" && example !== undefined) {
244
+ if (template !== "application" && example !== undefined) {
245
245
  throw new Error("--example is only supported by the application template");
246
246
  }
247
247
  const displayNameFlagIndex = args.indexOf("--display-name");
@@ -299,6 +299,7 @@ var TEMPLATE_RENAMES = new Map([
299
299
  var RootManifestSchema = z2.looseObject({
300
300
  name: z2.string().min(1),
301
301
  catalog: z2.record(z2.string(), z2.string()).optional(),
302
+ workspaces: z2.unknown().optional(),
302
303
  dependencies: z2.record(z2.string(), z2.string()).optional(),
303
304
  devDependencies: z2.record(z2.string(), z2.string()).optional()
304
305
  });
@@ -435,21 +436,33 @@ async function scaffoldProject(templateDirectory, destination, options = {}) {
435
436
  const manifestPath = join(resolvedDestination, "package.json");
436
437
  const manifest = RootManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
437
438
  const catalog = options.stitchkitCatalogTarget ? { ...manifest.catalog ?? {}, stitchkit: options.stitchkitCatalogTarget } : manifest.catalog;
439
+ const isWorkspace = manifest.workspaces !== undefined;
440
+ const localReference = (name) => {
441
+ if (isWorkspace)
442
+ return "catalog:";
443
+ const range = catalog?.[name];
444
+ if (!range)
445
+ throw new Error(`Template catalog has no range for ${name}`);
446
+ return range;
447
+ };
438
448
  const replaceLocalPackages = (dependencies) => {
439
449
  if (!dependencies)
440
450
  return dependencies;
441
451
  return {
442
452
  ...dependencies,
443
- ...dependencies.stitchkit?.startsWith("file:") && { stitchkit: "catalog:" },
453
+ ...dependencies.stitchkit?.startsWith("file:") && {
454
+ stitchkit: localReference("stitchkit")
455
+ },
444
456
  ...dependencies["stitchkit-tui"]?.startsWith("file:") && {
445
- "stitchkit-tui": "catalog:"
457
+ "stitchkit-tui": localReference("stitchkit-tui")
446
458
  }
447
459
  };
448
460
  };
461
+ const { catalog: _templateCatalog, ...withoutCatalog } = manifest;
449
462
  await writeFile(manifestPath, `${JSON.stringify({
450
- ...manifest,
463
+ ...isWorkspace ? manifest : withoutCatalog,
451
464
  name: identity.slug,
452
- ...catalog && { catalog },
465
+ ...isWorkspace && catalog && { catalog },
453
466
  ...manifest.dependencies && {
454
467
  dependencies: replaceLocalPackages(manifest.dependencies)
455
468
  },
@@ -479,12 +492,12 @@ async function run(args) {
479
492
  }
480
493
  const destination = resolve2(options.destination);
481
494
  const applicationTemplateDirectory = resolve2(import.meta.dir, "../template");
482
- const templateDirectory = options.template === "agent" ? resolve2(import.meta.dir, "../templates/agent") : applicationTemplateDirectory;
495
+ const templateDirectory = options.template === "application" ? applicationTemplateDirectory : resolve2(import.meta.dir, `../templates/${options.template}`);
483
496
  const overlayDirectory = options.example ? resolve2(import.meta.dir, `../examples/${options.example}`) : undefined;
484
497
  await scaffoldProject(templateDirectory, destination, {
485
498
  ...overlayDirectory && { overlayDirectory },
486
499
  ...options.displayName && { displayName: options.displayName },
487
- ...options.template === "agent" && {
500
+ ...options.template !== "application" && {
488
501
  identityModule: false,
489
502
  lockfile: false,
490
503
  stitchkitCatalogTarget: await readStitchkitCatalogTarget(applicationTemplateDirectory)
@@ -501,7 +514,7 @@ async function run(args) {
501
514
  if (exitCode !== 0)
502
515
  throw new Error(`bun install failed with exit code ${exitCode}`);
503
516
  }
504
- const mode = options.template === "agent" ? " from the agent template" : options.example ? ` with the ${options.example} example` : "";
517
+ const mode = options.template !== "application" ? ` from the ${options.template} template` : options.example ? ` with the ${options.example} example` : "";
505
518
  process.stdout.write(`
506
519
  Created ${options.displayName ?? basename3(destination)}${mode}
507
520
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stitchkit",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Create a production-shaped Stitchkit application",
5
5
  "license": "MIT",
6
6
  "author": "Max Listov <maxlistov@gmail.com>",
@@ -45,6 +45,7 @@
45
45
  "!templates/**/*.tsbuildinfo",
46
46
  "!templates/**/coverage/**",
47
47
  "!templates/agent/bun.lock",
48
+ "!templates/telegram-bot/bun.lock",
48
49
  "!examples/**/.env",
49
50
  "!examples/**/.build-stamp.json",
50
51
  "!examples/**/node_modules/**",
@@ -68,7 +69,7 @@
68
69
  },
69
70
  "scripts": {
70
71
  "check": "bun x tsc --noEmit",
71
- "test": "bun test tests",
72
+ "test": "bun test ./tests",
72
73
  "build": "rm -rf dist && bun build src/cli.ts --outdir dist --target bun --packages external && chmod +x dist/cli.js",
73
74
  "prepublishOnly": "bun run check && bun run test && bun run build"
74
75
  },
@@ -83,7 +84,7 @@
83
84
  "@types/react": "^19.3.0",
84
85
  "ai": "^7.0.111",
85
86
  "react": "^19.3.0",
86
- "stitchkit": "0.95.3",
87
+ "stitchkit": "0.96.0",
87
88
  "typescript": "^7.0.2"
88
89
  },
89
90
  "engines": {
package/template/bun.lock CHANGED
@@ -145,7 +145,7 @@
145
145
  "zod": "4.6.5",
146
146
  },
147
147
  "catalog": {
148
- "stitchkit": "^0.95.3",
148
+ "stitchkit": "^0.96.0",
149
149
  },
150
150
  "packages": {
151
151
  "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.89", "", { "dependencies": { "@ai-sdk/provider": "4.0.17", "@ai-sdk/provider-utils": "5.0.45", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-n0Q88UUASYQBGK3Ogs9pCe8h3ZXeTHFSuGOLBJAyH73MVGNKUv9w8EJX2f340WUF3eDWWOV305gO7YBpSCPblA=="],
@@ -1128,7 +1128,7 @@
1128
1128
 
1129
1129
  "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
1130
1130
 
1131
- "stitchkit": ["stitchkit@0.95.3", "", { "dependencies": { "ky": "^2.1.0" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2 || ^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.1.0", "@opentelemetry/api": "^1.9.0", "@socket.io/bun-engine": "^0.1.2", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.4.2", "ai": "^7.0.107", "google-auth-library": "^11.1.0", "grammy": "^1.46.0", "maxmind": "^5.0.7", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5 || ^1.0.5", "zod": "^4.6.5" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "google-auth-library", "grammy", "maxmind", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"], "bin": { "stitchkit": "dist/entrypoints/bin/upgrade-cli.js" } }, "sha512-IVIf7CF951Q4Olfiu+wONxAmc802zvPwGYWjNo+657OHua4gzc91XNo4/zmmPPZzkvA6iyOQmzFurgrbmJ9SrQ=="],
1131
+ "stitchkit": ["stitchkit@0.96.0", "", { "dependencies": { "ky": "^2.1.0" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2 || ^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.1.0", "@opentelemetry/api": "^1.9.0", "@socket.io/bun-engine": "^0.1.2", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.4.2", "ai": "^7.0.107", "google-auth-library": "^11.1.0", "grammy": "^1.46.0", "maxmind": "^5.0.7", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5 || ^1.0.5", "zod": "^4.6.5" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "google-auth-library", "grammy", "maxmind", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"], "bin": { "stitchkit": "dist/entrypoints/bin/upgrade-cli.js" } }, "sha512-5cgzNatZtqE0HCXozqGXdcAeudG+QLxKXKZSHFasYu7O6JvU0rViDVO76hWFSwOiczaSoqsqwk/GM01ArMMUmw=="],
1132
1132
 
1133
1133
  "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
1134
1134
 
@@ -7,7 +7,7 @@
7
7
  "packages/*"
8
8
  ],
9
9
  "catalog": {
10
- "stitchkit": "^0.95.3"
10
+ "stitchkit": "^0.96.0"
11
11
  },
12
12
  "overrides": {
13
13
  "@playwright/test": "1.63.0",
@@ -16,6 +16,7 @@
16
16
  "build": "bun build src/index.ts --outdir dist --target bun --packages external"
17
17
  },
18
18
  "dependencies": {
19
+ "@modelcontextprotocol/server": "^2.0.0",
19
20
  "@openrouter/ai-sdk-provider": "^3.1.0",
20
21
  "ai": "^7.0.111",
21
22
  "stitchkit": "file:../../../core",
@@ -0,0 +1,41 @@
1
+ # Stitchkit Telegram Bot
2
+
3
+ A long-polling Telegram bot assembled from Stitchkit primitives. The product is
4
+ `src/handlers.ts`; everything else is the assembly every bot needs and none
5
+ should write again.
6
+
7
+ ## Start
8
+
9
+ ```bash
10
+ cp .env.example .env
11
+ # Fill BOT_TOKEN.
12
+ bun run dev
13
+ ```
14
+
15
+ ## Shape
16
+
17
+ - `src/handlers.ts` — the product: commands, messages, what goes to the operators' chat.
18
+ - `src/application.ts` — the resource graph: `database` → (`telegram-files`) →
19
+ (`operator-channel`) → `telegram-configuration` → `telegram-polling`. Queues, an
20
+ HTTP server for payment webhooks, schedules and metrics go between the database
21
+ and the bot.
22
+ - `src/index.ts` — the entry point: environment, journal, signals, and the one
23
+ policy for a poller that ended on its own (shut down, exit 1, let the supervisor
24
+ restart).
25
+ - `src/runtime-bindings.ts` — where a deployment platform's state publisher is
26
+ attached, without the bot importing it anywhere else.
27
+ - `src/log.ts` — the journal: one JSON line per event, `"level":50` is an error.
28
+ - `tests/application.test.ts` — the whole life of the bot against a stand-in for Telegram.
29
+
30
+ Updates are admitted by batch: nothing is fetched while the application is not
31
+ ready, and a batch taken before a stop is finished before the offset is
32
+ confirmed, so a restart neither loses nor repeats an update.
33
+
34
+ ## Commands
35
+
36
+ ```bash
37
+ bun run check # types
38
+ bun run lint
39
+ bun test
40
+ bun run build # dist/index.js, started by `bun run start`
41
+ ```
@@ -0,0 +1,11 @@
1
+ # The bot's token from @BotFather.
2
+ BOT_TOKEN=
3
+ # debug | info | warn | error
4
+ LOG_LEVEL=info
5
+ # SQLite file for the bot's own data; relative paths are from the working directory.
6
+ DATABASE_PATH=.data/bot.sqlite
7
+ # Optional: the operators' chat (a group, forum topics welcome) for product events.
8
+ OPERATOR_CHAT_ID=
9
+ # Optional: a local Bot API server, and the directory it was started with (--dir).
10
+ BOT_API_URL=
11
+ BOT_API_FILES_ROOT=
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ .data/
5
+ coverage/
6
+ *.log
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
3
+ "files": {
4
+ "includes": ["**", "!!node_modules", "!!dist", "!!project.json", "!!**/.data"]
5
+ },
6
+ "formatter": {
7
+ "enabled": true,
8
+ "indentStyle": "space",
9
+ "indentWidth": 2,
10
+ "lineWidth": 96
11
+ },
12
+ "javascript": {
13
+ "formatter": {
14
+ "quoteStyle": "single",
15
+ "jsxQuoteStyle": "single",
16
+ "trailingCommas": "all",
17
+ "semicolons": "always"
18
+ }
19
+ },
20
+ "linter": {
21
+ "enabled": true,
22
+ "rules": {
23
+ "preset": "recommended",
24
+ "suspicious": {
25
+ "noExplicitAny": "error",
26
+ "noUnknownAttribute": "off"
27
+ }
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "stitchkit-telegram-bot-starter",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "bun --watch src/index.ts",
8
+ "start": "bun dist/index.js",
9
+ "check": "bun x tsgo --noEmit",
10
+ "lint": "bun x biome check --error-on-warnings .",
11
+ "lint:fix": "bun x biome check --write .",
12
+ "test": "bun test",
13
+ "build": "bun build src/index.ts --outdir dist --target bun --packages external"
14
+ },
15
+ "dependencies": {
16
+ "@grammyjs/auto-retry": "^2.0.2",
17
+ "grammy": "^1.46.0",
18
+ "stitchkit": "file:../../../core",
19
+ "zod": "4.6.5"
20
+ },
21
+ "devDependencies": {
22
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
23
+ "@types/bun": "^1.4.2",
24
+ "typescript": "^7.0.2"
25
+ },
26
+ "engines": {
27
+ "bun": ">=1.3.0"
28
+ },
29
+ "packageManager": "bun@1.3.14"
30
+ }
@@ -0,0 +1,76 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "application",
4
+ "identity": {
5
+ "slug": "stitchkit-telegram-bot-starter",
6
+ "name": "Stitchkit Telegram Bot Starter",
7
+ "version": "0.1.0",
8
+ "description": {
9
+ "en": "A long-polling Telegram bot assembled from Stitchkit primitives."
10
+ }
11
+ },
12
+ "roles": [
13
+ {
14
+ "name": "bot",
15
+ "workingDirectory": ".",
16
+ "commands": {
17
+ "development": {
18
+ "executable": "bun",
19
+ "args": [
20
+ "--watch",
21
+ "src/index.ts"
22
+ ]
23
+ },
24
+ "production": {
25
+ "executable": "bun",
26
+ "args": [
27
+ "dist/index.js"
28
+ ]
29
+ }
30
+ },
31
+ "drainFloorMs": 1000
32
+ }
33
+ ],
34
+ "requires": [],
35
+ "release": {},
36
+ "env": {
37
+ "variables": [
38
+ {
39
+ "name": "BOT_TOKEN",
40
+ "shape": "string",
41
+ "required": true
42
+ },
43
+ {
44
+ "name": "LOG_LEVEL",
45
+ "shape": "enum",
46
+ "required": false,
47
+ "members": [
48
+ "debug",
49
+ "info",
50
+ "warn",
51
+ "error"
52
+ ]
53
+ },
54
+ {
55
+ "name": "DATABASE_PATH",
56
+ "shape": "string",
57
+ "required": false
58
+ },
59
+ {
60
+ "name": "OPERATOR_CHAT_ID",
61
+ "shape": "integer",
62
+ "required": false
63
+ },
64
+ {
65
+ "name": "BOT_API_URL",
66
+ "shape": "url",
67
+ "required": false
68
+ },
69
+ {
70
+ "name": "BOT_API_FILES_ROOT",
71
+ "shape": "string",
72
+ "required": false
73
+ }
74
+ ]
75
+ }
76
+ }
@@ -0,0 +1,92 @@
1
+ import type { Bot } from 'grammy';
2
+ import {
3
+ type ApplicationHandle,
4
+ createApplication,
5
+ defineManagedResource,
6
+ type ManagedResource,
7
+ } from 'stitchkit/application';
8
+ import { type GrammyPollingEnd, grammyBotResources } from 'stitchkit/application/grammy';
9
+ import type { TelegramLocalFiles, TelegramOperatorChannel } from 'stitchkit/telegram';
10
+ import { createDatabase } from './database';
11
+ import { COMMANDS, type OperatorTopic, registerHandlers } from './handlers';
12
+ import type { Log } from './log';
13
+
14
+ export interface BotApplicationConfig {
15
+ readonly bot: Bot;
16
+ readonly databasePath: string;
17
+ readonly log: Log;
18
+ readonly operators?: TelegramOperatorChannel<OperatorTopic>;
19
+ /** Present when a local Bot API server shares its files directory with the bot. */
20
+ readonly files?: TelegramLocalFiles;
21
+ /** Polling ended on its own; the entry point turns this into its shutdown. */
22
+ readonly onPollingEnded: (end: GrammyPollingEnd) => void;
23
+ /** How long stopping may take. */
24
+ readonly shutdown?: { readonly gracePeriodMs: number; readonly forceTimeoutMs: number };
25
+ }
26
+
27
+ /**
28
+ * The graph, in start order:
29
+ *
30
+ * database → [telegram-files] → [operator-channel] → telegram-configuration → telegram-polling
31
+ *
32
+ * Product resources — queues, an HTTP server for payment webhooks, schedules,
33
+ * metrics — go between the database and the bot, and join the bot's
34
+ * `dependsOn` when handlers need them before the first update.
35
+ */
36
+ export function createBotApplication(config: BotApplicationConfig): ApplicationHandle {
37
+ const database = createDatabase(config.databasePath);
38
+ registerHandlers(config.bot, {
39
+ store: database.store,
40
+ ...(config.operators && { operators: config.operators }),
41
+ });
42
+ config.bot.catch(({ error, ctx }) => {
43
+ config.log.error('Telegram update failed', { updateId: ctx.update.update_id, error });
44
+ config.operators?.post(`Update ${ctx.update.update_id} failed`, 'errors');
45
+ });
46
+
47
+ const files = config.files;
48
+ const botDependencies: ManagedResource[] = [database.resource];
49
+ if (files) {
50
+ botDependencies.push(
51
+ defineManagedResource({
52
+ id: 'telegram-files',
53
+ dependsOn: [database.resource],
54
+ async start() {
55
+ const check = await files.check();
56
+ if (!check.ready) throw new Error(`Local Bot API files unavailable: ${check.reason}`);
57
+ },
58
+ }),
59
+ );
60
+ }
61
+ const operators = config.operators;
62
+ if (operators) {
63
+ botDependencies.push(
64
+ defineManagedResource({
65
+ id: 'operator-channel',
66
+ required: false,
67
+ start() {},
68
+ drain: (context) => operators.drain(context.signal),
69
+ close: () => operators.close(),
70
+ }),
71
+ );
72
+ }
73
+
74
+ const telegram = grammyBotResources({
75
+ bot: config.bot,
76
+ dependsOn: botDependencies,
77
+ configure: async () => {
78
+ await config.bot.api.setMyCommands(COMMANDS);
79
+ },
80
+ onStart: ({ username }) => config.log.info('Telegram polling started', { username }),
81
+ onError: (error) => config.log.error('Telegram polling failed', { error }),
82
+ onEnded: config.onPollingEnded,
83
+ });
84
+
85
+ return createApplication({
86
+ id: 'telegram-bot',
87
+ resources: [...botDependencies, ...telegram.resources],
88
+ shutdown: config.shutdown ?? { gracePeriodMs: 30_000, forceTimeoutMs: 5_000 },
89
+ onResourceFailure: ({ resourceId, phase, error }) =>
90
+ config.log.error('Resource failed', { resourceId, phase, error }),
91
+ });
92
+ }
@@ -0,0 +1,61 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { defineManagedResource } from 'stitchkit/application';
5
+
6
+ /** What the bot stores. Replace it with the product's own tables. */
7
+ export interface Store {
8
+ /** Remember a user; `true` the first time this user is seen. */
9
+ rememberUser(user: { id: number; firstName: string }): boolean;
10
+ userCount(): number;
11
+ }
12
+
13
+ function openStore(path: string): { store: Store; close(): void } {
14
+ if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
15
+ const db = new Database(path, { create: true, strict: true });
16
+ db.run('PRAGMA journal_mode = WAL');
17
+ db.run(`CREATE TABLE IF NOT EXISTS users (
18
+ id INTEGER PRIMARY KEY,
19
+ first_name TEXT NOT NULL,
20
+ first_seen_at TEXT NOT NULL
21
+ )`);
22
+ const insert = db.query(
23
+ 'INSERT OR IGNORE INTO users (id, first_name, first_seen_at) VALUES ($id, $firstName, $at)',
24
+ );
25
+ const count = db.query<{ count: number }, []>('SELECT count(*) AS count FROM users');
26
+ return {
27
+ store: {
28
+ rememberUser: (user) =>
29
+ insert.run({ id: user.id, firstName: user.firstName, at: new Date().toISOString() })
30
+ .changes > 0,
31
+ userCount: () => count.get()?.count ?? 0,
32
+ },
33
+ close: () => db.close(),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * The database as the first resource of the graph. Handlers are registered
39
+ * before it opens, so they reach the store through `store()`, which the graph
40
+ * guarantees is open by the time an update is admitted.
41
+ */
42
+ export function createDatabase(path: string) {
43
+ let opened: ReturnType<typeof openStore> | undefined;
44
+ const resource = defineManagedResource({
45
+ id: 'database',
46
+ start() {
47
+ opened = openStore(path);
48
+ },
49
+ close() {
50
+ opened?.close();
51
+ opened = undefined;
52
+ },
53
+ });
54
+ return {
55
+ resource,
56
+ store(): Store {
57
+ if (!opened) throw new Error('The database is not open');
58
+ return opened.store;
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+
3
+ const blankIsAbsent = (value: unknown) => (value === '' ? undefined : value);
4
+
5
+ /** The variables `project.json` declares, parsed once at the edge of the process. */
6
+ export const EnvSchema = z.object({
7
+ BOT_TOKEN: z.string().min(1, 'BOT_TOKEN is required — the token from @BotFather'),
8
+ LOG_LEVEL: z.preprocess(
9
+ blankIsAbsent,
10
+ z.enum(['debug', 'info', 'warn', 'error']).default('info'),
11
+ ),
12
+ DATABASE_PATH: z.preprocess(blankIsAbsent, z.string().min(1).default('.data/bot.sqlite')),
13
+ OPERATOR_CHAT_ID: z.preprocess(blankIsAbsent, z.coerce.number().int().optional()),
14
+ BOT_API_URL: z.preprocess(blankIsAbsent, z.url().optional()),
15
+ BOT_API_FILES_ROOT: z.preprocess(
16
+ blankIsAbsent,
17
+ z.string().startsWith('/', 'BOT_API_FILES_ROOT must be absolute').optional(),
18
+ ),
19
+ });
20
+ export type Env = z.infer<typeof EnvSchema>;
21
+
22
+ export function readEnv(source: Record<string, string | undefined> = process.env): Env {
23
+ const env = EnvSchema.parse(source);
24
+ if (env.BOT_API_URL && !env.BOT_API_FILES_ROOT) {
25
+ throw new Error(
26
+ 'BOT_API_FILES_ROOT is required with BOT_API_URL: the bot reads its files from there',
27
+ );
28
+ }
29
+ return env;
30
+ }
@@ -0,0 +1,32 @@
1
+ import type { Bot } from 'grammy';
2
+ import type { TelegramOperatorChannel } from 'stitchkit/telegram';
3
+ import type { Store } from './database';
4
+
5
+ /** The command menu Telegram shows; published by `telegram-configuration`. */
6
+ export const COMMANDS = [
7
+ { command: 'start', description: 'Start' },
8
+ { command: 'users', description: 'How many people found this bot' },
9
+ ];
10
+
11
+ export type OperatorTopic = 'users' | 'errors';
12
+
13
+ export interface Product {
14
+ readonly store: () => Store;
15
+ readonly operators?: TelegramOperatorChannel<OperatorTopic>;
16
+ }
17
+
18
+ /** The product. Everything else in this project is the assembly around it. */
19
+ export function registerHandlers(bot: Bot, product: Product): void {
20
+ bot.command('start', async (ctx) => {
21
+ if (!ctx.from) return;
22
+ const isNew = product
23
+ .store()
24
+ .rememberUser({ id: ctx.from.id, firstName: ctx.from.first_name });
25
+ if (isNew) product.operators?.post(`New user ${ctx.from.id}`, 'users');
26
+ await ctx.reply(`Hello, ${ctx.from.first_name}!`);
27
+ });
28
+
29
+ bot.command('users', (ctx) => ctx.reply(`${product.store().userCount()} people so far.`));
30
+
31
+ bot.on('message:text', (ctx) => ctx.reply(ctx.message.text));
32
+ }
@@ -0,0 +1,100 @@
1
+ import { autoRetry } from '@grammyjs/auto-retry';
2
+ import { Bot } from 'grammy';
3
+ import { bindProcessSignals } from 'stitchkit/server';
4
+ import {
5
+ createTelegramLocalFiles,
6
+ createTelegramOperatorChannel,
7
+ telegramOperatorSender,
8
+ } from 'stitchkit/telegram';
9
+ import { createBotApplication } from './application';
10
+ import { readEnv } from './env';
11
+ import type { OperatorTopic } from './handlers';
12
+ import { createLog } from './log';
13
+ import { runtimeBindings } from './runtime-bindings';
14
+
15
+ const env = readEnv();
16
+ const log = createLog(env);
17
+
18
+ const bot = new Bot(env.BOT_TOKEN, {
19
+ ...(env.BOT_API_URL && { client: { apiRoot: env.BOT_API_URL } }),
20
+ });
21
+ // A 429 on a reply is Telegram asking to wait, not a failure: wait what it names, then repeat.
22
+ bot.api.config.use(autoRetry({ maxRetryAttempts: 3, maxDelaySeconds: 10 }));
23
+
24
+ const operators =
25
+ env.OPERATOR_CHAT_ID === undefined
26
+ ? undefined
27
+ : createTelegramOperatorChannel<OperatorTopic>({
28
+ chatId: env.OPERATOR_CHAT_ID,
29
+ // Forum topic ids of the operators' chat, when it has topics.
30
+ topics: {},
31
+ send: telegramOperatorSender({
32
+ token: env.BOT_TOKEN,
33
+ ...(env.BOT_API_URL && { apiRoot: env.BOT_API_URL }),
34
+ }),
35
+ onDropped: (drop) => log.warn('Operator message dropped', { ...drop }),
36
+ });
37
+
38
+ const app = createBotApplication({
39
+ bot,
40
+ databasePath: env.DATABASE_PATH,
41
+ log,
42
+ ...(operators && { operators }),
43
+ ...(env.BOT_API_FILES_ROOT && {
44
+ files: createTelegramLocalFiles({ root: env.BOT_API_FILES_ROOT, token: env.BOT_TOKEN }),
45
+ }),
46
+ // grammY's poller does not recover in-process: stop the way a signal would,
47
+ // and exit non-zero so the supervisor starts a fresh process.
48
+ onPollingEnded: ({ error }) => {
49
+ log.error('Telegram polling ended', { error });
50
+ stopAndExit(1);
51
+ },
52
+ });
53
+ const bindings = runtimeBindings(app, log);
54
+
55
+ let exiting = false;
56
+ async function finish(code: number): Promise<never> {
57
+ await Promise.allSettled(bindings.map((binding) => binding.close()));
58
+ process.exit(code);
59
+ }
60
+ function stopAndExit(code: number): void {
61
+ if (exiting) return;
62
+ exiting = true;
63
+ signals.close();
64
+ app
65
+ .shutdown()
66
+ .then((result) => {
67
+ log.info('Application stopped', { outcome: result.outcome });
68
+ return finish(code);
69
+ })
70
+ .catch((error: unknown) => {
71
+ log.error('Application shutdown failed', { error });
72
+ return finish(1);
73
+ });
74
+ }
75
+
76
+ const signals = bindProcessSignals(app, {
77
+ async onComplete(result) {
78
+ if (exiting) return;
79
+ exiting = true;
80
+ log.info('Application stopped', { outcome: result.outcome });
81
+ await finish(result.outcome === 'clean' && result.cleanupComplete ? 0 : 1);
82
+ },
83
+ onError(phase, error) {
84
+ log.error('Application lifecycle failed', { phase, error });
85
+ void finish(1);
86
+ },
87
+ onEscalationBlocked() {
88
+ void finish(1);
89
+ },
90
+ });
91
+
92
+ async function main(): Promise<void> {
93
+ for (const binding of bindings) await binding.start();
94
+ await app.start();
95
+ }
96
+
97
+ main().catch((error: unknown) => {
98
+ log.error('Application startup failed', { error });
99
+ void finish(1);
100
+ });
@@ -0,0 +1,18 @@
1
+ import { createJsonLogger } from 'stitchkit/observability';
2
+ import { TELEGRAM_BOT_TOKEN_PATTERN } from 'stitchkit/telegram';
3
+ import type { Env } from './env';
4
+
5
+ /**
6
+ * The process journal: one JSON line per event on standard output. An `Error`
7
+ * under any key keeps its name, message, stack and cause, and a bot token
8
+ * inside a URL or an error message is masked. A line with `"level":50` is an
9
+ * error — the one contract a supervisor reading the journal relies on.
10
+ */
11
+ export function createLog(env: Pick<Env, 'LOG_LEVEL'>) {
12
+ return createJsonLogger({
13
+ level: env.LOG_LEVEL,
14
+ sensitiveUrlPatterns: [TELEGRAM_BOT_TOKEN_PATTERN],
15
+ });
16
+ }
17
+
18
+ export type Log = ReturnType<typeof createLog>;
@@ -0,0 +1,24 @@
1
+ import type { ApplicationHandle } from 'stitchkit/application';
2
+ import type { Log } from './log';
3
+
4
+ /**
5
+ * Something outside the bot that follows its application state — a deployment
6
+ * platform publishing readiness for its supervisor, a metrics exporter. It
7
+ * starts before the application and closes after it.
8
+ */
9
+ export interface RuntimeBinding {
10
+ start(): Promise<void>;
11
+ close(): Promise<void>;
12
+ }
13
+
14
+ /**
15
+ * The one place such a binding is attached. The template binds nothing: a
16
+ * platform's own package supplies the binding, for example
17
+ *
18
+ * return [bindReleasedApplication(app, { onError: (error) => log.error('…', { error }) })];
19
+ *
20
+ * and nothing else in the bot changes.
21
+ */
22
+ export function runtimeBindings(_app: ApplicationHandle, _log: Log): RuntimeBinding[] {
23
+ return [];
24
+ }
@@ -0,0 +1,120 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { Bot } from 'grammy';
3
+ import { z } from 'zod';
4
+ import { createBotApplication } from '../src/application';
5
+ import { createLog } from '../src/log';
6
+
7
+ /*
8
+ * The bot's whole life against a stand-in for Telegram: it starts in graph
9
+ * order, publishes its menu, answers, and stops cleanly — confirming exactly
10
+ * the updates it handled.
11
+ */
12
+
13
+ const Poll = z.object({ offset: z.number().optional(), timeout: z.number().optional() });
14
+
15
+ function fakeTelegram() {
16
+ let queue: number[] = [];
17
+ const calls: { method: string; body: Record<string, unknown> }[] = [];
18
+ const offsets: number[] = [];
19
+ let wake: (() => void) | undefined;
20
+ const server = Bun.serve({
21
+ port: 0,
22
+ hostname: '127.0.0.1',
23
+ async fetch(request) {
24
+ const method = new URL(request.url).pathname.split('/').pop() ?? '';
25
+ const body = z
26
+ .record(z.string(), z.unknown())
27
+ .parse(await request.json().catch(() => ({})));
28
+ calls.push({ method, body });
29
+ if (method === 'getMe') {
30
+ return Response.json({
31
+ ok: true,
32
+ result: { id: 1, is_bot: true, first_name: 'Bot', username: 'template_bot' },
33
+ });
34
+ }
35
+ if (method !== 'getUpdates') return Response.json({ ok: true, result: true });
36
+ const { offset = 0, timeout } = Poll.parse(body);
37
+ offsets.push(offset);
38
+ queue = queue.filter((id) => id >= offset);
39
+ if (queue.length === 0 && timeout !== undefined) {
40
+ await new Promise<void>((resolve) => {
41
+ wake = resolve;
42
+ request.signal.addEventListener('abort', () => resolve());
43
+ setTimeout(resolve, 200);
44
+ });
45
+ }
46
+ const batch = timeout === undefined ? [] : [...queue];
47
+ return Response.json({
48
+ ok: true,
49
+ result: batch.map((update_id) => ({
50
+ update_id,
51
+ message: {
52
+ message_id: update_id,
53
+ date: 0,
54
+ chat: { id: 7, type: 'private', first_name: 'Ada' },
55
+ from: { id: 7, is_bot: false, first_name: 'Ada' },
56
+ text: '/start',
57
+ entities: [{ type: 'bot_command', offset: 0, length: 6 }],
58
+ },
59
+ })),
60
+ });
61
+ },
62
+ });
63
+ return {
64
+ origin: server.url.origin,
65
+ calls,
66
+ offsets,
67
+ push(id: number) {
68
+ queue.push(id);
69
+ wake?.();
70
+ },
71
+ stop: () => server.stop(true),
72
+ };
73
+ }
74
+
75
+ const servers: { stop(): void }[] = [];
76
+ afterEach(() => {
77
+ for (const server of servers.splice(0)) server.stop();
78
+ });
79
+
80
+ async function until(check: () => boolean): Promise<void> {
81
+ for (let attempt = 0; attempt < 400 && !check(); attempt += 1) await Bun.sleep(5);
82
+ expect(check()).toBe(true);
83
+ }
84
+
85
+ describe('the bot application', () => {
86
+ test('starts in graph order, answers /start and stops cleanly', async () => {
87
+ const telegram = fakeTelegram();
88
+ servers.push(telegram);
89
+ const ended: unknown[] = [];
90
+ const bot = new Bot('1:template', { client: { apiRoot: telegram.origin } });
91
+ const app = createBotApplication({
92
+ bot,
93
+ databasePath: ':memory:',
94
+ log: createLog({ LOG_LEVEL: 'info' }),
95
+ onPollingEnded: (end) => void ended.push(end),
96
+ shutdown: { gracePeriodMs: 2_000, forceTimeoutMs: 200 },
97
+ });
98
+
99
+ const snapshot = await app.start();
100
+ expect(snapshot.ready).toBe(true);
101
+ expect(snapshot.resources.map((resource) => resource.id)).toEqual([
102
+ 'database',
103
+ 'telegram-configuration',
104
+ 'telegram-polling',
105
+ ]);
106
+ expect(telegram.calls.map((call) => call.method)).toContain('setMyCommands');
107
+
108
+ telegram.push(5);
109
+ await until(() => telegram.calls.some((call) => call.method === 'sendMessage'));
110
+ expect(telegram.calls.find((call) => call.method === 'sendMessage')?.body).toMatchObject({
111
+ chat_id: 7,
112
+ text: 'Hello, Ada!',
113
+ });
114
+
115
+ const result = await app.shutdown();
116
+ expect(result.outcome).toBe('clean');
117
+ expect(Math.max(...telegram.offsets)).toBe(6);
118
+ expect(ended).toEqual([]);
119
+ });
120
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "exactOptionalPropertyTypes": true,
10
+ "skipLibCheck": true,
11
+ "types": ["bun"]
12
+ },
13
+ "include": ["src", "tests"]
14
+ }