create-stitchkit 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +16 -0
- package/dist/cli.js +6 -6
- package/examples/repository/_env.example.append +0 -14
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +2 -0
- package/package.json +4 -3
- package/template/README.md +6 -4
- package/template/_env.example +14 -2
- package/template/bun.lock +2 -2
- package/template/e2e/starter.spec.ts +18 -0
- package/template/package.json +1 -1
- package/{examples/repository → template}/packages/frontend/src/app/api/[...path]/route.ts +15 -7
- package/template/packages/frontend/src/features/board/board-live.ts +4 -0
- package/templates/telegram-bot/README.md +41 -0
- package/templates/telegram-bot/_env.example +11 -0
- package/templates/telegram-bot/_gitignore +6 -0
- package/templates/telegram-bot/biome.json +30 -0
- package/templates/telegram-bot/package.json +30 -0
- package/templates/telegram-bot/project.json +76 -0
- package/templates/telegram-bot/src/application.ts +92 -0
- package/templates/telegram-bot/src/database.ts +61 -0
- package/templates/telegram-bot/src/env.ts +30 -0
- package/templates/telegram-bot/src/handlers.ts +32 -0
- package/templates/telegram-bot/src/index.ts +100 -0
- package/templates/telegram-bot/src/log.ts +18 -0
- package/templates/telegram-bot/src/runtime-bindings.ts +24 -0
- package/templates/telegram-bot/tests/application.test.ts +120 -0
- package/templates/telegram-bot/tsconfig.json +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,53 @@ step is overwritten by the next release.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.6.5] — 2026-09-24
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `--template telegram-bot` — a long-polling Telegram bot assembled from
|
|
20
|
+
Stitchkit primitives: `grammyBotResources` (command menu, then polling with
|
|
21
|
+
updates admitted by batch), the JSON journal, an SQLite database resource,
|
|
22
|
+
an optional operator channel and local Bot API files, an entry point with
|
|
23
|
+
signals and the one policy for a poller that ended on its own (shut down,
|
|
24
|
+
exit 1), a `runtime-bindings.ts` where a deployment platform's state
|
|
25
|
+
publisher attaches, and a lifecycle test against a stand-in for Telegram.
|
|
26
|
+
Generated with `bun create stitchkit my-bot --template telegram-bot`.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Generated projects track Stitchkit `^0.96.0`, the release that ships the
|
|
31
|
+
bot primitives the new template is built from.
|
|
32
|
+
|
|
33
|
+
## [0.6.4] — 2026-09-23
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **The board reaches the API on two local ports.** The base template's board
|
|
38
|
+
posts to its own origin (`/api/…`), and the README said the web role forwards
|
|
39
|
+
that — but the forwarding route lived only in the repository example, so a
|
|
40
|
+
blank scaffold answered 404. The route
|
|
41
|
+
(`packages/frontend/src/app/api/[...path]/route.ts`) is now in the base
|
|
42
|
+
template, and says in words when `INTERNAL_API_URL` is unset.
|
|
43
|
+
- **The browser build carries the socket client.** The board passes
|
|
44
|
+
`peers: { client: () => import('socket.io-client') }` to
|
|
45
|
+
`createRealtimeClient`; without the literal loader the bundle had no
|
|
46
|
+
`socket.io-client` and the page failed with "needs the socket.io-client peer".
|
|
47
|
+
- **The socket dials the API role in development.** `.env.example` now sets
|
|
48
|
+
`INTERNAL_API_URL`, `PUBLIC_REALTIME_ORIGIN` and `CORS_ORIGIN` for the two
|
|
49
|
+
local ports, and the repository example passes its realtime origin to the
|
|
50
|
+
board. A two-tab browser test proves a note posted in one tab reaches the
|
|
51
|
+
other in both variants.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **A scaffold starts on stitchkit 0.95.3**, whose watch client no longer drops
|
|
56
|
+
the values of a restarted API. The template's range moves from `^0.94.0` to
|
|
57
|
+
`^0.95.3`, over a lockfile resolving 0.95.3.
|
|
58
|
+
|
|
59
|
+
A project generated from 0.6.3 adds the route file above, the `peers` line in
|
|
60
|
+
`features/board/board-live.ts` and the three variables to its `.env`.
|
|
61
|
+
|
|
15
62
|
## [0.6.3] — 2026-09-23
|
|
16
63
|
|
|
17
64
|
### Changed
|
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
|
|
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");
|
|
@@ -479,12 +479,12 @@ async function run(args) {
|
|
|
479
479
|
}
|
|
480
480
|
const destination = resolve2(options.destination);
|
|
481
481
|
const applicationTemplateDirectory = resolve2(import.meta.dir, "../template");
|
|
482
|
-
const templateDirectory = options.template === "
|
|
482
|
+
const templateDirectory = options.template === "application" ? applicationTemplateDirectory : resolve2(import.meta.dir, `../templates/${options.template}`);
|
|
483
483
|
const overlayDirectory = options.example ? resolve2(import.meta.dir, `../examples/${options.example}`) : undefined;
|
|
484
484
|
await scaffoldProject(templateDirectory, destination, {
|
|
485
485
|
...overlayDirectory && { overlayDirectory },
|
|
486
486
|
...options.displayName && { displayName: options.displayName },
|
|
487
|
-
...options.template
|
|
487
|
+
...options.template !== "application" && {
|
|
488
488
|
identityModule: false,
|
|
489
489
|
lockfile: false,
|
|
490
490
|
stitchkitCatalogTarget: await readStitchkitCatalogTarget(applicationTemplateDirectory)
|
|
@@ -501,7 +501,7 @@ async function run(args) {
|
|
|
501
501
|
if (exitCode !== 0)
|
|
502
502
|
throw new Error(`bun install failed with exit code ${exitCode}`);
|
|
503
503
|
}
|
|
504
|
-
const mode = options.template
|
|
504
|
+
const mode = options.template !== "application" ? ` from the ${options.template} template` : options.example ? ` with the ${options.example} example` : "";
|
|
505
505
|
process.stdout.write(`
|
|
506
506
|
Created ${options.displayName ?? basename3(destination)}${mode}
|
|
507
507
|
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
# The web role reaches the API role internally, and forwards the browser's
|
|
2
|
-
# same-origin `/api/…` calls to it. This one is required.
|
|
3
|
-
INTERNAL_API_URL=http://127.0.0.1:3211
|
|
4
|
-
|
|
5
|
-
# The realtime socket, and ONLY it. A WebSocket upgrade does not survive the
|
|
6
|
-
# route handler that forwards `/api`, so two roles on two loopback ports must
|
|
7
|
-
# name the socket's origin even though their HTTP is already same-origin.
|
|
8
|
-
# Behind one routing layer that forwards `/socket.io`, leave this unset.
|
|
9
|
-
PUBLIC_REALTIME_ORIGIN=http://127.0.0.1:3211
|
|
10
|
-
|
|
11
|
-
# The browser origin the API role admits — for HTTP and for the realtime
|
|
12
|
-
# handshake alike. Needed here because the socket above is cross-origin.
|
|
13
|
-
CORS_ORIGIN=http://127.0.0.1:3210
|
|
14
|
-
|
|
15
1
|
# THE CROSS-ORIGIN HTTP VARIANT — unset, and unnecessary for this example.
|
|
16
2
|
# Set it only for a frontend that dials the API role itself instead of calling
|
|
17
3
|
# its own `/api`: separate hostnames with nothing in front of them. Setting it
|
|
@@ -3,6 +3,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
|
|
|
3
3
|
import type { Metadata } from 'next';
|
|
4
4
|
import { getTranslations } from 'next-intl/server';
|
|
5
5
|
import { LocaleSchema } from '@/i18n/locales';
|
|
6
|
+
import { publicRealtimeOrigin } from '@/lib/api/place';
|
|
6
7
|
import { useRepository } from '@/lib/api/queries';
|
|
7
8
|
import { createServerRepositoryApi } from '@/lib/api/server-client';
|
|
8
9
|
import { getQueryClient } from '@/lib/query-client';
|
|
@@ -39,6 +40,7 @@ export default async function Page({ params }: { params: Promise<{ locale: strin
|
|
|
39
40
|
heroTitle={t('heroTitle')}
|
|
40
41
|
catalogueLabel={t('ui')}
|
|
41
42
|
locale={appLocale}
|
|
43
|
+
realtimeOrigin={publicRealtimeOrigin()}
|
|
42
44
|
/>
|
|
43
45
|
</HydrationBoundary>
|
|
44
46
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-stitchkit",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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.
|
|
87
|
+
"stitchkit": "0.96.0",
|
|
87
88
|
"typescript": "^7.0.2"
|
|
88
89
|
},
|
|
89
90
|
"engines": {
|
package/template/README.md
CHANGED
|
@@ -72,10 +72,12 @@ Two things can pull a deployment out of that, and they are separate questions,
|
|
|
72
72
|
so they have separate variables. **`PUBLIC_REALTIME_ORIGIN`** is the socket: a
|
|
73
73
|
WebSocket upgrade does not survive the route handler that forwards `/api`, so
|
|
74
74
|
two roles on two ports with nothing in front of them must name the socket's
|
|
75
|
-
origin even though their HTTP is already same-origin
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
origin even though their HTTP is already same-origin — `.env.example` sets it,
|
|
76
|
+
with `CORS_ORIGIN` admitting the web origin, for the two local ports. Behind one
|
|
77
|
+
routing layer that forwards `/socket.io`, leave both unset.
|
|
78
|
+
**`PUBLIC_API_ORIGIN`** is HTTP, for a frontend that genuinely dials the API
|
|
79
|
+
role itself — and setting it changes nothing on its own: with
|
|
80
|
+
`--example repository`, switching is one import in
|
|
79
81
|
`packages/frontend/src/lib/api/queries.ts`, documented in
|
|
80
82
|
`packages/frontend/src/lib/api/cross-origin.ts`. That variant costs a
|
|
81
83
|
server-delivered address, a client built on first use (hence the parentheses),
|
package/template/_env.example
CHANGED
|
@@ -22,5 +22,17 @@ SMOKE_WEB_ORIGIN=http://127.0.0.1:3210
|
|
|
22
22
|
# carries. List here only the hosts this deployment really answers for.
|
|
23
23
|
PUBLIC_WEB_HOSTS=127.0.0.1:3210
|
|
24
24
|
LOG_FORMAT=pretty
|
|
25
|
-
|
|
26
|
-
#
|
|
25
|
+
|
|
26
|
+
# The web role reaches the API role internally, and forwards the browser's
|
|
27
|
+
# same-origin `/api/…` calls to it (packages/frontend/src/app/api/[...path]).
|
|
28
|
+
INTERNAL_API_URL=http://127.0.0.1:3211
|
|
29
|
+
|
|
30
|
+
# The realtime socket, and ONLY it. A WebSocket upgrade does not survive the
|
|
31
|
+
# route handler that forwards `/api`, so two roles on two loopback ports must
|
|
32
|
+
# name the socket's origin even though their HTTP is already same-origin.
|
|
33
|
+
# Behind one routing layer that forwards `/socket.io`, leave this unset.
|
|
34
|
+
PUBLIC_REALTIME_ORIGIN=http://127.0.0.1:3211
|
|
35
|
+
|
|
36
|
+
# The browser origin the API role admits — for HTTP and for the realtime
|
|
37
|
+
# handshake alike. Needed here because the socket above is cross-origin.
|
|
38
|
+
CORS_ORIGIN=http://127.0.0.1:3210
|
package/template/bun.lock
CHANGED
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"zod": "4.6.5",
|
|
146
146
|
},
|
|
147
147
|
"catalog": {
|
|
148
|
-
"stitchkit": "^0.
|
|
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.
|
|
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
|
|
|
@@ -35,6 +35,24 @@ test('calls the live backend through the typed contract client', async () => {
|
|
|
35
35
|
}).toPass({ timeout: 5_000 });
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
test('a note posted in one tab reaches another through the web origin and the socket', async ({
|
|
39
|
+
browser,
|
|
40
|
+
}) => {
|
|
41
|
+
// The browser's own origin carries HTTP (`/api` is forwarded by the web
|
|
42
|
+
// role) and the socket dials the API role; a post in one tab must reach a
|
|
43
|
+
// second tab that never asked for it. Both halves failed silently before:
|
|
44
|
+
// `/api` was a 404 on the web port, and the browser build had no socket.
|
|
45
|
+
const writer = await browser.newPage();
|
|
46
|
+
const reader = await browser.newPage();
|
|
47
|
+
await Promise.all([writer.goto('/en'), reader.goto('/en')]);
|
|
48
|
+
const note = `note ${Date.now()}`;
|
|
49
|
+
await writer.getByRole('textbox', { name: 'Note' }).fill(note);
|
|
50
|
+
await writer.getByRole('button', { name: 'Post' }).click();
|
|
51
|
+
await expect(writer.getByText(note)).toBeVisible();
|
|
52
|
+
await expect(reader.getByText(note)).toBeVisible({ timeout: 10_000 });
|
|
53
|
+
await Promise.all([writer.close(), reader.close()]);
|
|
54
|
+
});
|
|
55
|
+
|
|
38
56
|
test('publishes complete page metadata', async ({ page }) => {
|
|
39
57
|
await page.goto('/en/ui/themes');
|
|
40
58
|
await expect(page).toHaveTitle(`Theme system · ${appDeclaration.identity.name}`);
|
package/template/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { env } from '@/env';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The default shape: the browser talks to its OWN origin, and the web role
|
|
5
|
-
* forwards to the API role.
|
|
5
|
+
* forwards `/api/…` to the API role.
|
|
6
6
|
*
|
|
7
|
-
* This is what makes the
|
|
7
|
+
* This is what makes the board's client a plain module constant. A browser
|
|
8
8
|
* that dials the API role directly needs that role's public address, which is a
|
|
9
9
|
* property of the place — so the address has to arrive from the server at
|
|
10
10
|
* runtime, the client cannot exist until it does, and every call site pays for
|
|
@@ -13,9 +13,9 @@ import { internalApiUrl } from '@/lib/api/place';
|
|
|
13
13
|
*
|
|
14
14
|
* What it costs: one extra hop through the web role, and no WebSocket — a
|
|
15
15
|
* route handler cannot proxy an upgrade. The realtime socket is therefore the
|
|
16
|
-
* one place
|
|
17
|
-
* in front of both roles that
|
|
18
|
-
*
|
|
16
|
+
* one place the browser still needs the API role's address
|
|
17
|
+
* (`PUBLIC_REALTIME_ORIGIN`), or a routing layer in front of both roles that
|
|
18
|
+
* serves them on one origin.
|
|
19
19
|
*/
|
|
20
20
|
export const dynamic = 'force-dynamic';
|
|
21
21
|
|
|
@@ -28,10 +28,18 @@ const FORWARDED_REQUEST_HEADERS = [
|
|
|
28
28
|
const FORWARDED_RESPONSE_HEADERS = ['content-type', 'cache-control', 'etag'];
|
|
29
29
|
|
|
30
30
|
async function forward(request: Request): Promise<Response> {
|
|
31
|
+
const apiUrl = env.INTERNAL_API_URL;
|
|
32
|
+
if (!apiUrl) {
|
|
33
|
+
// Said here, in words, rather than as a 404 from a route that is not there.
|
|
34
|
+
return Response.json(
|
|
35
|
+
{ error: 'INTERNAL_API_URL is not set, so the web role cannot reach the API role' },
|
|
36
|
+
{ status: 503 },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
31
39
|
const incoming = new URL(request.url);
|
|
32
40
|
// Rebuilt from the incoming pathname rather than from the matched segments,
|
|
33
41
|
// so an encoded segment reaches the API role exactly as it arrived.
|
|
34
|
-
const target = new URL(`${incoming.pathname}${incoming.search}`,
|
|
42
|
+
const target = new URL(`${incoming.pathname}${incoming.search}`, apiUrl);
|
|
35
43
|
|
|
36
44
|
const headers = new Headers();
|
|
37
45
|
for (const name of FORWARDED_REQUEST_HEADERS) {
|
|
@@ -27,6 +27,10 @@ let shared: ReturnType<typeof connect> | undefined;
|
|
|
27
27
|
function connect(realtimeOrigin?: string) {
|
|
28
28
|
const realtime = createRealtimeClient(liveContract, {
|
|
29
29
|
url: realtimeOrigin ?? window.location.origin,
|
|
30
|
+
// The literal loader is what puts the socket client into this bundle.
|
|
31
|
+
// Stitchkit's own import of the peer is left alone by bundlers on purpose,
|
|
32
|
+
// so without this line the browser build has no `socket.io-client` in it.
|
|
33
|
+
peers: { client: () => import('socket.io-client') },
|
|
30
34
|
});
|
|
31
35
|
realtime.connect();
|
|
32
36
|
return {
|
|
@@ -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,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
|
+
}
|