threadwire 0.1.26 → 0.1.28
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 +15 -0
- package/bin/tensorbuzz-queue-bridge.js +23 -0
- package/docker/tensorbuzz-queue-bridge-healthcheck.mjs +3 -0
- package/docs/isolated-provider-runtime.md +13 -1
- package/docs/tensorbuzz-queue-bridge.md +133 -0
- package/package.json +9 -2
- package/scripts/seed-production-package-cache.js +32 -0
- package/scripts/verify-package.js +11 -0
- package/src/tensorbuzz-queue-bridge/bridge.js +249 -0
- package/src/tensorbuzz-queue-bridge/config.js +61 -0
- package/src/tensorbuzz-queue-bridge/http.js +134 -0
- package/src/tensorbuzz-queue-bridge/mtproto-sender.js +61 -0
- package/src/tensorbuzz-queue-bridge/service.js +72 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Add an optional loopback-only TensorBuzz-to-Telegram queue bridge. It verifies
|
|
6
|
+
bounded raw-body HMAC-SHA-256 requests before JSON parsing, admits a closed
|
|
7
|
+
`build_group.completed` schema, builds a bounded canonical `/queue` prompt,
|
|
8
|
+
and routes through a reviewed read-only event-class mapping. A dedicated
|
|
9
|
+
MTProto user sender uses durable `node:sqlite` receipts, stable send random
|
|
10
|
+
IDs, per-topic FIFO, classified transient retry, audit state, and persisted
|
|
11
|
+
Telegram message IDs. The profile-gated container receives only its dedicated
|
|
12
|
+
secret files and includes liveness/readiness endpoints plus activation,
|
|
13
|
+
rotation, revocation, pause, and rollback documentation.
|
|
14
|
+
|
|
15
|
+
- Require `THREADWIRE_KIMI_E2E_OUTER_BROKER_HOST` for
|
|
16
|
+
`npm run test:kimi-isolated-runtime-e2e`, publish the outer broker on a
|
|
17
|
+
Docker-assigned host port, and verify that exact endpoint from a task-DinD
|
|
18
|
+
container before launching the isolated runtime.
|
|
19
|
+
|
|
5
20
|
- Enforce per-run worker mutation boundaries in isolated runtime. `threadwire run
|
|
6
21
|
--relay-write` accepts a closed `--mutation-policy <json>` with
|
|
7
22
|
`{worktreeEdit, commit, push, githubWrite}` booleans; omitted capabilities
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
import {loadBridgeConfig} from "../src/tensorbuzz-queue-bridge/config.js"
|
|
5
|
+
import {TeleprotoUserSender} from "../src/tensorbuzz-queue-bridge/mtproto-sender.js"
|
|
6
|
+
import {TensorBuzzQueueBridgeService} from "../src/tensorbuzz-queue-bridge/service.js"
|
|
7
|
+
|
|
8
|
+
const config = await loadBridgeConfig(process.env)
|
|
9
|
+
const sender = await TeleprotoUserSender.connect(config)
|
|
10
|
+
const service = new TensorBuzzQueueBridgeService({...config, sender})
|
|
11
|
+
await service.listen(config.host, config.port)
|
|
12
|
+
|
|
13
|
+
let stopping = false
|
|
14
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
15
|
+
process.once(signal, () => {
|
|
16
|
+
if (stopping) return
|
|
17
|
+
stopping = true
|
|
18
|
+
void service.close().then(() => sender.close()).then(() => process.exit(0), (error) => {
|
|
19
|
+
process.stderr.write(`TensorBuzz queue bridge shutdown failed: ${error instanceof Error ? error.message : "unknown error"}\n`)
|
|
20
|
+
process.exit(1)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
}
|
|
@@ -315,7 +315,19 @@ state makes health and preflight fail closed with redacted errors.
|
|
|
315
315
|
### Kimi validation and rollback
|
|
316
316
|
|
|
317
317
|
Run `npm run test:kimi-isolated-runtime-e2e` from an approved Docker supervisor
|
|
318
|
-
boundary after supplying the documented immutable image variables
|
|
318
|
+
boundary after supplying the documented immutable image variables and an
|
|
319
|
+
explicit outer broker address:
|
|
320
|
+
|
|
321
|
+
```sh
|
|
322
|
+
THREADWIRE_KIMI_E2E_OUTER_BROKER_HOST='<outer-host-ipv4>' \
|
|
323
|
+
npm run test:kimi-isolated-runtime-e2e
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Replace `<outer-host-ipv4>` with an IPv4 address on the outer Docker host that
|
|
327
|
+
the outer daemon can bind and containers created by the task DinD can reach.
|
|
328
|
+
The E2E asks Docker to allocate the published broker host port, inspects that
|
|
329
|
+
exact binding, and probes it from a throwaway task-DinD container; do not infer
|
|
330
|
+
the address from a default gateway or reserve a fixed host port. A real
|
|
319
331
|
account smoke must then cover fresh execution, capture of exactly one native
|
|
320
332
|
session ID, exact resume with that ID, an approved non-default alias if used,
|
|
321
333
|
Telegram DM/topic routing, cancellation, and confirmation that service logs and
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# TensorBuzz queue bridge
|
|
2
|
+
|
|
3
|
+
The optional `threadwire-tensorbuzz-queue-bridge` service admits one closed
|
|
4
|
+
TensorBuzz event class, durably records it, and sends a real MTProto **user**
|
|
5
|
+
message to a statically reviewed Telegram destination. Its only outbound text is
|
|
6
|
+
`/queue <canonical prompt>`. Event payloads cannot select a route, sender,
|
|
7
|
+
method, command, or arbitrary prompt fields.
|
|
8
|
+
|
|
9
|
+
The bridge is deliberately separate from Telegram bot ingress and from worker
|
|
10
|
+
execution. It does not synthesize Telegram updates, call a Hermes API or
|
|
11
|
+
adapter, impersonate another Telegram user, or execute queued work itself.
|
|
12
|
+
|
|
13
|
+
## Security and delivery model
|
|
14
|
+
|
|
15
|
+
- HTTP binds only to IPv4 loopback (`127.0.0.1`, default port `8790`). Compose
|
|
16
|
+
uses host networking so a host-local TensorBuzz webhook producer can reach
|
|
17
|
+
that loopback socket without publishing it externally.
|
|
18
|
+
- `POST /webhook` accepts at most 65,536 raw bytes. The lowercase-hex
|
|
19
|
+
HMAC-SHA-256 over `<unix-seconds>.<exact raw body>` is checked before JSON
|
|
20
|
+
parsing. Required headers are `X-Webhook-Timestamp`,
|
|
21
|
+
`X-TensorBuzz-Delivery`, and `X-Webhook-Signature-V2`. Timestamps have a
|
|
22
|
+
five-minute freshness window. Delivery IDs use a bounded 128-character
|
|
23
|
+
identifier for durable idempotency only and are not part of the signature.
|
|
24
|
+
- Only `build_group.completed` is accepted. Its exact fields are `event`,
|
|
25
|
+
`project`, `buildGroupId`, `status`, `commitSha`, `branch`, and `summary`;
|
|
26
|
+
values are bounded and controls/newlines are rejected. Unknown or missing
|
|
27
|
+
fields fail closed.
|
|
28
|
+
- The reviewed route file must contain exactly one mapping:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"build_group.completed": {
|
|
33
|
+
"chatId": "-100REVIEWED_SUPERGROUP_ID",
|
|
34
|
+
"messageThreadId": 123
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Replace both example values. Review this file like code and mount it
|
|
40
|
+
read-only; never derive it from a webhook payload.
|
|
41
|
+
- Built-in `node:sqlite` stores receipts, a bounded audit ledger, a stable
|
|
42
|
+
MTProto `randomId`, retry state, and the returned Telegram message ID. Startup
|
|
43
|
+
resumes an interrupted send with the same `randomId`, allowing Telegram's
|
|
44
|
+
MTProto idempotency to prevent a second message if the process stopped after
|
|
45
|
+
Telegram accepted the first send but before SQLite recorded it. Topic order
|
|
46
|
+
is FIFO. Only structured flood-wait and transport failures retry; other
|
|
47
|
+
failures become terminal and surface.
|
|
48
|
+
- `GET /healthz` is liveness. `GET /readyz` becomes ready only after the
|
|
49
|
+
authorized MTProto client, SQLite store, and loopback listener are active.
|
|
50
|
+
|
|
51
|
+
## Install and provision
|
|
52
|
+
|
|
53
|
+
Create four root/operator-readable files outside the repository and one
|
|
54
|
+
reviewed non-secret route file. Do not put their contents in shell exports,
|
|
55
|
+
Compose values, source, images, logs, prompts, or command output:
|
|
56
|
+
|
|
57
|
+
- webhook HMAC secret (at least 32 random bytes);
|
|
58
|
+
- Telegram API ID;
|
|
59
|
+
- Telegram API hash;
|
|
60
|
+
- serialized Teleproto user session;
|
|
61
|
+
- reviewed route JSON shown above.
|
|
62
|
+
|
|
63
|
+
Provision the serialized session interactively outside this service, using a
|
|
64
|
+
dedicated automation Telegram user. Phone number and 2FA password are
|
|
65
|
+
provisioning inputs only and must not be stored in this repository, Compose, or
|
|
66
|
+
the runtime container. Give that user access only to the reviewed destination.
|
|
67
|
+
|
|
68
|
+
Set only file paths in the operator environment:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
THREADWIRE_TENSORBUZZ_WEBHOOK_SECRET_FILE=/secure/threadwire/tensorbuzz-hmac
|
|
72
|
+
THREADWIRE_MTPROTO_API_ID_FILE=/secure/threadwire/mtproto-api-id
|
|
73
|
+
THREADWIRE_MTPROTO_API_HASH_FILE=/secure/threadwire/mtproto-api-hash
|
|
74
|
+
THREADWIRE_MTPROTO_SESSION_FILE=/secure/threadwire/mtproto-session
|
|
75
|
+
THREADWIRE_TENSORBUZZ_ROUTES_FILE=/secure/threadwire/tensorbuzz-routes.json
|
|
76
|
+
export THREADWIRE_TENSORBUZZ_WEBHOOK_SECRET_FILE THREADWIRE_MTPROTO_API_ID_FILE
|
|
77
|
+
export THREADWIRE_MTPROTO_API_HASH_FILE THREADWIRE_MTPROTO_SESSION_FILE
|
|
78
|
+
export THREADWIRE_TENSORBUZZ_ROUTES_FILE
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
These variables contain paths, not credentials. Build without activating:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
docker compose --profile tensorbuzz-queue-bridge build tensorbuzz-queue-bridge
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Activate and verify
|
|
88
|
+
|
|
89
|
+
Start only after the route and dedicated user access have been reviewed:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
docker compose --profile tensorbuzz-queue-bridge up --detach tensorbuzz-queue-bridge
|
|
93
|
+
docker compose --profile tensorbuzz-queue-bridge exec -T tensorbuzz-queue-bridge \
|
|
94
|
+
node docker/tensorbuzz-queue-bridge-healthcheck.mjs
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Configure TensorBuzz to POST to `http://127.0.0.1:8790/webhook` with the signing
|
|
98
|
+
contract above. Never place the shared HMAC secret in the URL.
|
|
99
|
+
|
|
100
|
+
## Rotate, revoke, pause, and roll back
|
|
101
|
+
|
|
102
|
+
HMAC rotation is a coordinated cutover: pause TensorBuzz delivery, atomically
|
|
103
|
+
replace the secret file, recreate the bridge, update TensorBuzz's signer, then
|
|
104
|
+
resume and verify readiness. There is intentionally no old-secret fallback.
|
|
105
|
+
|
|
106
|
+
MTProto rotation uses a newly provisioned session for the same dedicated user:
|
|
107
|
+
pause delivery, atomically replace the session file, recreate, verify readiness,
|
|
108
|
+
then revoke the old Telegram session in Telegram's active-sessions control.
|
|
109
|
+
Rotate API hash files with the same pause/recreate/readiness sequence.
|
|
110
|
+
|
|
111
|
+
To revoke immediately, pause TensorBuzz delivery and stop the profile service,
|
|
112
|
+
then revoke the dedicated Telegram session and remove its destination access.
|
|
113
|
+
Keep the SQLite volume for audit and idempotent recovery unless its retention
|
|
114
|
+
policy explicitly authorizes deletion.
|
|
115
|
+
|
|
116
|
+
To pause without revocation:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
docker compose --profile tensorbuzz-queue-bridge stop tensorbuzz-queue-bridge
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
For rollback, keep TensorBuzz paused, select the prior immutable Threadwire
|
|
123
|
+
image, recreate the bridge against the existing SQLite volume, verify
|
|
124
|
+
`/readyz`, then resume. Do not roll back the database by deleting or replacing
|
|
125
|
+
the volume; retained receipts are what prevent duplicate sends.
|
|
126
|
+
|
|
127
|
+
## Live-proof blocker
|
|
128
|
+
|
|
129
|
+
Automated tests use only an injected fake MTProto sender. Live end-to-end proof
|
|
130
|
+
is blocked until a dedicated automation Telegram user is provisioned with its
|
|
131
|
+
own MTProto session and access to the reviewed destination chat/topic. No
|
|
132
|
+
production Telegram session, phone number, 2FA secret, API hash, or message was
|
|
133
|
+
used while implementing this service.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "threadwire",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"CHANGELOG.md",
|
|
19
19
|
"bin/",
|
|
20
|
+
"docker/tensorbuzz-queue-bridge-healthcheck.mjs",
|
|
20
21
|
"docs/",
|
|
21
22
|
"scripts/",
|
|
22
23
|
"src/",
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
"threadwire-isolated-runtime": "bin/isolated-runtime.js",
|
|
27
28
|
"threadwire-kimi-model-broker": "bin/kimi-model-broker.js",
|
|
28
29
|
"threadwire-model-broker": "bin/model-broker.js",
|
|
30
|
+
"threadwire-tensorbuzz-queue-bridge": "bin/tensorbuzz-queue-bridge.js",
|
|
29
31
|
"threadwire": "bin/threadwire.js",
|
|
30
32
|
"threadwire-telegram-webhook": "bin/telegram-webhook.js"
|
|
31
33
|
},
|
|
@@ -69,5 +71,10 @@
|
|
|
69
71
|
"release-patch": "1.0.3",
|
|
70
72
|
"typescript": "6.0.2"
|
|
71
73
|
},
|
|
72
|
-
"license": "AGPL-3.0-only"
|
|
74
|
+
"license": "AGPL-3.0-only",
|
|
75
|
+
"dependencies": {
|
|
76
|
+
"big-integer": "1.6.52",
|
|
77
|
+
"teleproto": "1.228.5",
|
|
78
|
+
"typanic": "1.0.9"
|
|
79
|
+
}
|
|
73
80
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {execFile} from "node:child_process"
|
|
4
|
+
import {promisify} from "node:util"
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile)
|
|
7
|
+
|
|
8
|
+
export const PRODUCTION_PACKAGE_SPECS = Object.freeze([
|
|
9
|
+
"big-integer@1.6.52",
|
|
10
|
+
"graceful-fs@4.2.11",
|
|
11
|
+
"imurmurhash@0.1.4",
|
|
12
|
+
"ip-address@10.5.0",
|
|
13
|
+
"mime@3.0.0",
|
|
14
|
+
"node-localstorage@2.2.1",
|
|
15
|
+
"slide@1.1.6",
|
|
16
|
+
"smart-buffer@4.2.0",
|
|
17
|
+
"socks@2.8.9",
|
|
18
|
+
"store2@2.14.4",
|
|
19
|
+
"teleproto@1.228.5",
|
|
20
|
+
"typanic@1.0.9",
|
|
21
|
+
"write-file-atomic@1.3.4"
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
/** @param {string} cacheDirectory @param {NodeJS.ProcessEnv} environment */
|
|
25
|
+
export async function seedProductionPackageCache(cacheDirectory, environment) {
|
|
26
|
+
for (const packageSpec of PRODUCTION_PACKAGE_SPECS) {
|
|
27
|
+
await execFileAsync("npm", ["cache", "add", "--cache", cacheDirectory, packageSpec], {
|
|
28
|
+
env: environment,
|
|
29
|
+
maxBuffer: 10 * 1024 * 1024
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -6,6 +6,7 @@ import {mkdtemp, rm} from "node:fs/promises"
|
|
|
6
6
|
import {tmpdir} from "node:os"
|
|
7
7
|
import {join} from "node:path"
|
|
8
8
|
import {promisify} from "node:util"
|
|
9
|
+
import {seedProductionPackageCache} from "./seed-production-package-cache.js"
|
|
9
10
|
|
|
10
11
|
const execFileAsync = promisify(execFile)
|
|
11
12
|
const npmEnvironment = {
|
|
@@ -24,6 +25,7 @@ const EXPECTED_FILES = [
|
|
|
24
25
|
"bin/isolated-runtime.js",
|
|
25
26
|
"bin/kimi-model-broker.js",
|
|
26
27
|
"bin/model-broker.js",
|
|
28
|
+
"bin/tensorbuzz-queue-bridge.js",
|
|
27
29
|
"docs/capacity-admission.md",
|
|
28
30
|
"docs/card-10520-plan.md",
|
|
29
31
|
"docs/container-runtime.md",
|
|
@@ -31,6 +33,8 @@ const EXPECTED_FILES = [
|
|
|
31
33
|
"docs/development-container.md",
|
|
32
34
|
"docs/evidence-artifacts.md",
|
|
33
35
|
"docs/isolated-provider-runtime.md",
|
|
36
|
+
"docs/tensorbuzz-queue-bridge.md",
|
|
37
|
+
"docker/tensorbuzz-queue-bridge-healthcheck.mjs",
|
|
34
38
|
"package.json",
|
|
35
39
|
"scripts/atomic-install.js",
|
|
36
40
|
"scripts/install-local-launcher.js",
|
|
@@ -39,6 +43,7 @@ const EXPECTED_FILES = [
|
|
|
39
43
|
"scripts/provider-shims/codex.adapter.sh",
|
|
40
44
|
"scripts/provider-shims/front-door.sh.template",
|
|
41
45
|
"scripts/provider-shims/opencode-local-fleet.adapter.sh",
|
|
46
|
+
"scripts/seed-production-package-cache.js",
|
|
42
47
|
"scripts/verify-package.js",
|
|
43
48
|
"src/absolute-deadline.js",
|
|
44
49
|
"src/activity-log.js",
|
|
@@ -89,6 +94,11 @@ const EXPECTED_FILES = [
|
|
|
89
94
|
"src/telegram-ingress/http.js",
|
|
90
95
|
"src/telegram-ingress/update-guard.js",
|
|
91
96
|
"src/telegram-webhook.js",
|
|
97
|
+
"src/tensorbuzz-queue-bridge/bridge.js",
|
|
98
|
+
"src/tensorbuzz-queue-bridge/config.js",
|
|
99
|
+
"src/tensorbuzz-queue-bridge/http.js",
|
|
100
|
+
"src/tensorbuzz-queue-bridge/mtproto-sender.js",
|
|
101
|
+
"src/tensorbuzz-queue-bridge/service.js",
|
|
92
102
|
"src/threadwire-binding.js",
|
|
93
103
|
"src/types.js",
|
|
94
104
|
"src/worker-control.js"
|
|
@@ -121,6 +131,7 @@ async function main() {
|
|
|
121
131
|
)
|
|
122
132
|
|
|
123
133
|
const tarballPath = join(temporaryDirectory, packResult.filename)
|
|
134
|
+
await seedProductionPackageCache(cacheDirectory, npmEnvironment)
|
|
124
135
|
const {stdout: helpOutput} = await execFileAsync("npm", [
|
|
125
136
|
"exec",
|
|
126
137
|
"--dry-run=false",
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {randomBytes} from "node:crypto"
|
|
4
|
+
import {DatabaseSync} from "node:sqlite"
|
|
5
|
+
import {forcedInteger, forcedString} from "typanic"
|
|
6
|
+
|
|
7
|
+
const EVENT_FIELDS = Object.freeze(["branch", "buildGroupId", "commitSha", "event", "project", "status", "summary"])
|
|
8
|
+
const STATUSES = new Set(["passed", "failed", "cancelled"])
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{chatId: string, messageThreadId: number}} Route
|
|
12
|
+
* @typedef {{chatId: string, messageThreadId: number, text: string, randomId: string}} OutboundMessage
|
|
13
|
+
* @typedef {{sendMessage: (message: OutboundMessage) => Promise<{messageId: number}>}} MtProtoSender
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export class TensorBuzzQueueBridge {
|
|
17
|
+
/** @type {DatabaseSync} */
|
|
18
|
+
#database
|
|
19
|
+
/** @type {Readonly<Record<string, Route>>} */
|
|
20
|
+
#routes
|
|
21
|
+
/** @type {MtProtoSender} */
|
|
22
|
+
#sender
|
|
23
|
+
/** @type {() => number} */
|
|
24
|
+
#now
|
|
25
|
+
|
|
26
|
+
/** @param {{databasePath: string, routes: Readonly<Record<string, Route>>, sender: MtProtoSender, now?: () => number}} options */
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.#routes = options.routes
|
|
29
|
+
this.#sender = options.sender
|
|
30
|
+
this.#now = options.now ?? Date.now
|
|
31
|
+
this.#database = new DatabaseSync(options.databasePath)
|
|
32
|
+
this.#database.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 5000")
|
|
33
|
+
this.#database.exec(`
|
|
34
|
+
CREATE TABLE IF NOT EXISTS receipts (
|
|
35
|
+
delivery_id TEXT PRIMARY KEY,
|
|
36
|
+
event_class TEXT NOT NULL,
|
|
37
|
+
topic_key TEXT NOT NULL,
|
|
38
|
+
chat_id TEXT NOT NULL,
|
|
39
|
+
message_thread_id INTEGER NOT NULL,
|
|
40
|
+
prompt TEXT NOT NULL,
|
|
41
|
+
random_id TEXT NOT NULL UNIQUE,
|
|
42
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'sending', 'sent', 'failed')),
|
|
43
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
next_attempt_at INTEGER NOT NULL,
|
|
45
|
+
telegram_message_id INTEGER,
|
|
46
|
+
last_error_class TEXT,
|
|
47
|
+
created_at INTEGER NOT NULL,
|
|
48
|
+
updated_at INTEGER NOT NULL
|
|
49
|
+
);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS audit (
|
|
51
|
+
id INTEGER PRIMARY KEY,
|
|
52
|
+
delivery_id TEXT NOT NULL,
|
|
53
|
+
action TEXT NOT NULL,
|
|
54
|
+
at INTEGER NOT NULL,
|
|
55
|
+
detail TEXT,
|
|
56
|
+
FOREIGN KEY (delivery_id) REFERENCES receipts(delivery_id)
|
|
57
|
+
);
|
|
58
|
+
`)
|
|
59
|
+
const now = this.#now()
|
|
60
|
+
this.#database.prepare("UPDATE receipts SET state = 'pending', next_attempt_at = ?, updated_at = ? WHERE state = 'sending'").run(now, now)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {unknown} candidate @param {string} deliveryId */
|
|
64
|
+
acceptEvent(candidate, deliveryId) {
|
|
65
|
+
const event = validateEvent(candidate)
|
|
66
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(deliveryId)) throw new Error("Invalid delivery ID")
|
|
67
|
+
const route = this.#routes[event.event]
|
|
68
|
+
if (!route) throw new Error(`Unreviewed event class: ${event.event}`)
|
|
69
|
+
const prompt = canonicalPrompt(event)
|
|
70
|
+
const now = this.#now()
|
|
71
|
+
const randomId = randomBytes(8).readBigInt64BE().toString()
|
|
72
|
+
this.#database.exec("BEGIN IMMEDIATE")
|
|
73
|
+
try {
|
|
74
|
+
const result = this.#database.prepare(`
|
|
75
|
+
INSERT OR IGNORE INTO receipts
|
|
76
|
+
(delivery_id, event_class, topic_key, chat_id, message_thread_id, prompt, random_id, state, next_attempt_at, created_at, updated_at)
|
|
77
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
|
|
78
|
+
`).run(deliveryId, event.event, `${route.chatId}:${route.messageThreadId}`, route.chatId, route.messageThreadId, prompt, randomId, now, now, now)
|
|
79
|
+
if (result.changes === 1) {
|
|
80
|
+
this.#database.prepare("INSERT INTO audit (delivery_id, action, at) VALUES (?, 'accepted', ?)").run(deliveryId, now)
|
|
81
|
+
} else {
|
|
82
|
+
const existing = this.#database.prepare("SELECT event_class, chat_id, message_thread_id, prompt FROM receipts WHERE delivery_id = ?").get(deliveryId)
|
|
83
|
+
if (!existing ||
|
|
84
|
+
forcedString(existing.event_class, "stored event_class") !== event.event ||
|
|
85
|
+
forcedString(existing.chat_id, "stored chat_id") !== route.chatId ||
|
|
86
|
+
forcedInteger(existing.message_thread_id, "stored message_thread_id") !== route.messageThreadId ||
|
|
87
|
+
forcedString(existing.prompt, "stored prompt") !== prompt) {
|
|
88
|
+
throw new Error("Authenticated delivery ID conflict")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
this.#database.exec("COMMIT")
|
|
92
|
+
return result.changes === 1
|
|
93
|
+
} catch (error) {
|
|
94
|
+
this.#database.exec("ROLLBACK")
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async drainOnce() {
|
|
100
|
+
const now = this.#now()
|
|
101
|
+
const candidate = this.#database.prepare(`
|
|
102
|
+
SELECT r.* FROM receipts r
|
|
103
|
+
WHERE r.state = 'pending' AND r.next_attempt_at <= ?
|
|
104
|
+
AND NOT EXISTS (
|
|
105
|
+
SELECT 1 FROM receipts earlier
|
|
106
|
+
WHERE earlier.topic_key = r.topic_key AND earlier.state IN ('pending', 'sending')
|
|
107
|
+
AND earlier.rowid < r.rowid
|
|
108
|
+
)
|
|
109
|
+
ORDER BY r.created_at, r.rowid LIMIT 1
|
|
110
|
+
`).get(now)
|
|
111
|
+
if (!candidate) return false
|
|
112
|
+
const row = readQueuedReceipt(candidate)
|
|
113
|
+
this.#database.exec("BEGIN IMMEDIATE")
|
|
114
|
+
try {
|
|
115
|
+
const claimed = this.#database.prepare("UPDATE receipts SET state = 'sending', attempts = attempts + 1, updated_at = ? WHERE delivery_id = ? AND state = 'pending'").run(now, row.deliveryId)
|
|
116
|
+
if (claimed.changes !== 1) {
|
|
117
|
+
this.#database.exec("ROLLBACK")
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
this.#database.prepare("INSERT INTO audit (delivery_id, action, at) VALUES (?, 'sending', ?)").run(row.deliveryId, now)
|
|
121
|
+
this.#database.exec("COMMIT")
|
|
122
|
+
} catch (error) {
|
|
123
|
+
this.#database.exec("ROLLBACK")
|
|
124
|
+
throw error
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const result = await this.#sender.sendMessage({
|
|
129
|
+
chatId: row.chatId,
|
|
130
|
+
messageThreadId: row.messageThreadId,
|
|
131
|
+
text: `/queue ${row.prompt}`,
|
|
132
|
+
randomId: row.randomId
|
|
133
|
+
})
|
|
134
|
+
this.#finish(row.deliveryId, "sent", result.messageId, null, now)
|
|
135
|
+
return true
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const retry = classifyTransient(error)
|
|
138
|
+
if (retry) {
|
|
139
|
+
const retryAt = now + retry.delayMs
|
|
140
|
+
this.#database.exec("BEGIN IMMEDIATE")
|
|
141
|
+
try {
|
|
142
|
+
this.#database.prepare("UPDATE receipts SET state = 'pending', next_attempt_at = ?, last_error_class = ?, updated_at = ? WHERE delivery_id = ?").run(retryAt, retry.errorClass, now, row.deliveryId)
|
|
143
|
+
this.#database.prepare("INSERT INTO audit (delivery_id, action, at, detail) VALUES (?, 'retry', ?, ?)").run(row.deliveryId, now, retry.errorClass)
|
|
144
|
+
this.#database.exec("COMMIT")
|
|
145
|
+
} catch (databaseError) {
|
|
146
|
+
this.#database.exec("ROLLBACK")
|
|
147
|
+
throw databaseError
|
|
148
|
+
}
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
this.#finish(row.deliveryId, "failed", null, "permanent", now)
|
|
152
|
+
throw error
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @param {string} deliveryId */
|
|
157
|
+
receipt(deliveryId) {
|
|
158
|
+
const row = this.#database.prepare("SELECT state, telegram_message_id FROM receipts WHERE delivery_id = ?").get(deliveryId)
|
|
159
|
+
if (!row) return null
|
|
160
|
+
const state = forcedString(row.state, "receipt state")
|
|
161
|
+
if (state !== "pending" && state !== "sending" && state !== "sent" && state !== "failed") throw new Error("Receipt has invalid state")
|
|
162
|
+
const telegramMessageId = row.telegram_message_id === null ? null : forcedInteger(row.telegram_message_id, "receipt telegram_message_id")
|
|
163
|
+
return {state, telegramMessageId}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
close() {
|
|
167
|
+
this.#database.close()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @param {string} deliveryId @param {"sent" | "failed"} state @param {number | null} messageId @param {string | null} detail @param {number} now */
|
|
171
|
+
#finish(deliveryId, state, messageId, detail, now) {
|
|
172
|
+
this.#database.exec("BEGIN IMMEDIATE")
|
|
173
|
+
try {
|
|
174
|
+
this.#database.prepare("UPDATE receipts SET state = ?, telegram_message_id = ?, last_error_class = ?, updated_at = ? WHERE delivery_id = ?").run(state, messageId, detail, now, deliveryId)
|
|
175
|
+
this.#database.prepare("INSERT INTO audit (delivery_id, action, at, detail) VALUES (?, ?, ?, ?)").run(deliveryId, state, now, detail)
|
|
176
|
+
this.#database.exec("COMMIT")
|
|
177
|
+
} catch (error) {
|
|
178
|
+
this.#database.exec("ROLLBACK")
|
|
179
|
+
throw error
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** @param {Record<string, import("node:sqlite").SQLOutputValue>} row */
|
|
185
|
+
function readQueuedReceipt(row) {
|
|
186
|
+
const deliveryId = forcedString(row.delivery_id, "receipt delivery_id")
|
|
187
|
+
const chatId = forcedString(row.chat_id, "receipt chat_id")
|
|
188
|
+
const messageThreadId = forcedInteger(row.message_thread_id, "receipt message_thread_id")
|
|
189
|
+
const prompt = forcedString(row.prompt, "receipt prompt")
|
|
190
|
+
const randomId = forcedString(row.random_id, "receipt random_id")
|
|
191
|
+
return {deliveryId, chatId, messageThreadId, prompt, randomId}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @param {unknown} candidate */
|
|
195
|
+
function validateEvent(candidate) {
|
|
196
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate) || Object.getPrototypeOf(candidate) !== Object.prototype) {
|
|
197
|
+
throw new Error("Event must be a plain object")
|
|
198
|
+
}
|
|
199
|
+
const keys = Object.keys(candidate).sort()
|
|
200
|
+
if (keys.length !== EVENT_FIELDS.length || keys.some((key, index) => key !== EVENT_FIELDS[index])) throw new Error("Event has an unknown or missing field")
|
|
201
|
+
const record = /** @type {Record<string, unknown>} */ (candidate)
|
|
202
|
+
const event = forcedString(record.event, "event")
|
|
203
|
+
if (event !== "build_group.completed") throw new Error("Unsupported event class")
|
|
204
|
+
const project = bounded(record.project, "project", 1, 200)
|
|
205
|
+
const buildGroupId = bounded(record.buildGroupId, "buildGroupId", 1, 128)
|
|
206
|
+
const status = forcedString(record.status, "status")
|
|
207
|
+
if (!STATUSES.has(status)) throw new Error("Unsupported build status")
|
|
208
|
+
const commitSha = forcedString(record.commitSha, "commitSha")
|
|
209
|
+
if (!/^[a-f0-9]{40}$/u.test(commitSha)) throw new Error("commitSha must be a full lowercase Git SHA")
|
|
210
|
+
const branch = bounded(record.branch, "branch", 1, 200)
|
|
211
|
+
const summary = bounded(record.summary, "summary", 1, 500)
|
|
212
|
+
return {event, project, buildGroupId, status, commitSha, branch, summary}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @param {unknown} value @param {string} label @param {number} minimum @param {number} maximum */
|
|
216
|
+
function bounded(value, label, minimum, maximum) {
|
|
217
|
+
const string = forcedString(value, label)
|
|
218
|
+
let hasControl = false
|
|
219
|
+
for (const character of string) {
|
|
220
|
+
const codePoint = character.codePointAt(0)
|
|
221
|
+
if (codePoint !== undefined && (codePoint < 32 || codePoint === 127)) hasControl = true
|
|
222
|
+
}
|
|
223
|
+
if (string.length < minimum || string.length > maximum || hasControl) throw new Error(`${label} has invalid length or characters`)
|
|
224
|
+
return string
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** @param {ReturnType<typeof validateEvent>} event */
|
|
228
|
+
function canonicalPrompt(event) {
|
|
229
|
+
return [
|
|
230
|
+
"TensorBuzz build group completed",
|
|
231
|
+
`Project: ${event.project}`,
|
|
232
|
+
`Build group: ${event.buildGroupId}`,
|
|
233
|
+
`Status: ${event.status}`,
|
|
234
|
+
`Commit: ${event.commitSha}`,
|
|
235
|
+
`Branch: ${event.branch}`,
|
|
236
|
+
`Summary: ${event.summary}`
|
|
237
|
+
].join("\n")
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** @param {unknown} error */
|
|
241
|
+
function classifyTransient(error) {
|
|
242
|
+
if (!error || typeof error !== "object") return null
|
|
243
|
+
const record = /** @type {{errorMessage?: unknown, seconds?: unknown, code?: unknown}} */ (error)
|
|
244
|
+
if (typeof record.errorMessage === "string" && /^FLOOD_WAIT_\d+$/u.test(record.errorMessage) && Number.isSafeInteger(record.seconds) && Number(record.seconds) > 0) {
|
|
245
|
+
return {errorClass: "flood-wait", delayMs: Math.min(Number(record.seconds) * 1_000, 3_600_000)}
|
|
246
|
+
}
|
|
247
|
+
if (record.code === "ECONNRESET" || record.code === "ETIMEDOUT" || record.code === 500) return {errorClass: "transport", delayMs: 5_000}
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {readFile} from "node:fs/promises"
|
|
4
|
+
import {isAbsolute} from "node:path"
|
|
5
|
+
import {forcedInteger, forcedString} from "typanic"
|
|
6
|
+
|
|
7
|
+
const LOOPBACK_HOST = "127.0.0.1"
|
|
8
|
+
|
|
9
|
+
/** @param {NodeJS.ProcessEnv} environment */
|
|
10
|
+
export async function loadBridgeConfig(environment) {
|
|
11
|
+
const secret = await readSecretFile(environment.THREADWIRE_TENSORBUZZ_WEBHOOK_SECRET_FILE, "THREADWIRE_TENSORBUZZ_WEBHOOK_SECRET_FILE")
|
|
12
|
+
if (Buffer.byteLength(secret) < 32) throw new Error("TensorBuzz webhook secret must contain at least 32 bytes")
|
|
13
|
+
const apiIdText = await readSecretFile(environment.THREADWIRE_MTPROTO_API_ID_FILE, "THREADWIRE_MTPROTO_API_ID_FILE")
|
|
14
|
+
if (!/^\d+$/u.test(apiIdText)) throw new Error("MTProto API ID file must contain a positive integer")
|
|
15
|
+
const apiId = Number(apiIdText)
|
|
16
|
+
if (!Number.isSafeInteger(apiId) || apiId <= 0) throw new Error("MTProto API ID must be a positive safe integer")
|
|
17
|
+
const apiHash = await readSecretFile(environment.THREADWIRE_MTPROTO_API_HASH_FILE, "THREADWIRE_MTPROTO_API_HASH_FILE")
|
|
18
|
+
const session = await readSecretFile(environment.THREADWIRE_MTPROTO_SESSION_FILE, "THREADWIRE_MTPROTO_SESSION_FILE")
|
|
19
|
+
const routesPath = requiredAbsolutePath(environment.THREADWIRE_TENSORBUZZ_ROUTES_FILE, "THREADWIRE_TENSORBUZZ_ROUTES_FILE")
|
|
20
|
+
let routes
|
|
21
|
+
try {
|
|
22
|
+
routes = parseRoutes(JSON.parse(await readFile(routesPath, "utf8")))
|
|
23
|
+
} catch (error) {
|
|
24
|
+
throw new Error("TensorBuzz routes configuration could not be loaded", {cause: error})
|
|
25
|
+
}
|
|
26
|
+
const databasePath = requiredAbsolutePath(environment.THREADWIRE_TENSORBUZZ_DATABASE_PATH, "THREADWIRE_TENSORBUZZ_DATABASE_PATH")
|
|
27
|
+
const port = environment.THREADWIRE_TENSORBUZZ_PORT === undefined ? 8790 : Number(environment.THREADWIRE_TENSORBUZZ_PORT)
|
|
28
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error("THREADWIRE_TENSORBUZZ_PORT must be a valid TCP port")
|
|
29
|
+
return {host: LOOPBACK_HOST, port, secret, apiId, apiHash, session, routes, databasePath}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {string | undefined} path @param {string} label */
|
|
33
|
+
async function readSecretFile(path, label) {
|
|
34
|
+
const value = (await readFile(requiredAbsolutePath(path, label), "utf8")).trim()
|
|
35
|
+
if (!value) throw new Error(`${label} must reference a nonempty file`)
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {string | undefined} value @param {string} label */
|
|
40
|
+
function requiredAbsolutePath(value, label) {
|
|
41
|
+
const path = forcedString(value, label)
|
|
42
|
+
if (!isAbsolute(path)) throw new Error(`${label} must be absolute`)
|
|
43
|
+
return path
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {unknown} candidate */
|
|
47
|
+
function parseRoutes(candidate) {
|
|
48
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate) || Object.getPrototypeOf(candidate) !== Object.prototype) throw new Error("Routes must be a plain object")
|
|
49
|
+
const keys = Object.keys(candidate)
|
|
50
|
+
if (keys.length !== 1 || keys[0] !== "build_group.completed") throw new Error("Routes must contain only build_group.completed")
|
|
51
|
+
const route = /** @type {Record<string, unknown>} */ (candidate)["build_group.completed"]
|
|
52
|
+
if (!route || typeof route !== "object" || Array.isArray(route) || Object.getPrototypeOf(route) !== Object.prototype) throw new Error("Route must be a plain object")
|
|
53
|
+
const record = /** @type {Record<string, unknown>} */ (route)
|
|
54
|
+
const routeKeys = Object.keys(record).sort()
|
|
55
|
+
if (routeKeys.join(",") !== "chatId,messageThreadId") throw new Error("Route has an unknown or missing field")
|
|
56
|
+
const chatId = forcedString(record.chatId, "route chatId")
|
|
57
|
+
if (!/^-100[1-9]\d+$/u.test(chatId)) throw new Error("Route chatId must be a Telegram supergroup ID")
|
|
58
|
+
const messageThreadId = forcedInteger(record.messageThreadId, "route messageThreadId")
|
|
59
|
+
if (messageThreadId <= 0) throw new Error("Route messageThreadId must be positive")
|
|
60
|
+
return Object.freeze({"build_group.completed": Object.freeze({chatId, messageThreadId})})
|
|
61
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {createHmac, timingSafeEqual} from "node:crypto"
|
|
4
|
+
|
|
5
|
+
export const MAX_BODY_BYTES = 65_536
|
|
6
|
+
export const MAX_CLOCK_SKEW_SECONDS = 300
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {{sendMessage: (message: {chatId: string, messageThreadId: number, text: string, randomId: string}) => Promise<{messageId: number}>}} MtProtoSender
|
|
10
|
+
* @typedef {{secret: string, now?: () => number, sender?: MtProtoSender, acceptEvent?: (event: unknown, deliveryId: string) => Promise<void> | void, isReady?: () => boolean}} HandlerOptions
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create the loopback bridge HTTP handler.
|
|
15
|
+
* @param {HandlerOptions} options
|
|
16
|
+
*/
|
|
17
|
+
export function createTensorBuzzQueueBridgeHandler(options) {
|
|
18
|
+
const now = options.now ?? Date.now
|
|
19
|
+
return (/** @type {import("node:http").IncomingMessage} */ request, /** @type {import("node:http").ServerResponse} */ response) => {
|
|
20
|
+
void handleRequest(request, response, options, now).catch(() => {
|
|
21
|
+
if (!response.headersSent) send(response, 500)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {import("node:http").IncomingMessage} request
|
|
28
|
+
* @param {import("node:http").ServerResponse} response
|
|
29
|
+
* @param {HandlerOptions} options
|
|
30
|
+
* @param {() => number} now
|
|
31
|
+
*/
|
|
32
|
+
async function handleRequest(request, response, options, now) {
|
|
33
|
+
if (request.method === "GET" && request.url === "/healthz") return send(response, 200, "ok\n")
|
|
34
|
+
if (request.method === "GET" && request.url === "/readyz") {
|
|
35
|
+
return options.isReady?.() === false ? send(response, 503, "not ready\n") : send(response, 200, "ready\n")
|
|
36
|
+
}
|
|
37
|
+
if (request.method !== "POST" || request.url !== "/webhook") return reject(request, response, 404)
|
|
38
|
+
if (request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
|
|
39
|
+
return reject(request, response, 415)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let rawBody
|
|
43
|
+
try {
|
|
44
|
+
rawBody = await readBoundedBody(request)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return send(response, error instanceof BodyTooLargeError ? 413 : 400)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const deliveryId = singleHeader(request.headers["x-tensorbuzz-delivery"])
|
|
50
|
+
const timestamp = singleHeader(request.headers["x-webhook-timestamp"])
|
|
51
|
+
const signature = singleHeader(request.headers["x-webhook-signature-v2"])
|
|
52
|
+
if (!deliveryId || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(deliveryId) ||
|
|
53
|
+
!timestamp || !/^\d{10}$/u.test(timestamp) ||
|
|
54
|
+
Math.abs(Math.floor(now() / 1000) - Number(timestamp)) > MAX_CLOCK_SKEW_SECONDS ||
|
|
55
|
+
!signatureMatches(options.secret, timestamp, rawBody, signature)) {
|
|
56
|
+
return send(response, 401)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let event
|
|
60
|
+
try {
|
|
61
|
+
event = JSON.parse(rawBody.toString("utf8"))
|
|
62
|
+
} catch {
|
|
63
|
+
return send(response, 400)
|
|
64
|
+
}
|
|
65
|
+
if (options.acceptEvent) await options.acceptEvent(event, deliveryId)
|
|
66
|
+
return send(response, 202)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {import("node:http").IncomingMessage} request */
|
|
70
|
+
function readBoundedBody(request) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
/** @type {Buffer[]} */
|
|
73
|
+
const chunks = []
|
|
74
|
+
let bytes = 0
|
|
75
|
+
let settled = false
|
|
76
|
+
/** @param {Buffer | string} chunk */
|
|
77
|
+
const onData = (chunk) => {
|
|
78
|
+
if (settled) return
|
|
79
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
80
|
+
bytes += buffer.length
|
|
81
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
82
|
+
settled = true
|
|
83
|
+
request.removeListener("data", onData)
|
|
84
|
+
request.removeListener("end", onEnd)
|
|
85
|
+
request.removeListener("error", onError)
|
|
86
|
+
reject(new BodyTooLargeError())
|
|
87
|
+
request.resume()
|
|
88
|
+
} else {
|
|
89
|
+
chunks.push(buffer)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const onEnd = () => {
|
|
93
|
+
if (settled) return
|
|
94
|
+
settled = true
|
|
95
|
+
resolve(Buffer.concat(chunks, bytes))
|
|
96
|
+
}
|
|
97
|
+
/** @param {Error} error */
|
|
98
|
+
const onError = (error) => {
|
|
99
|
+
if (settled) return
|
|
100
|
+
settled = true
|
|
101
|
+
reject(error)
|
|
102
|
+
}
|
|
103
|
+
request.on("data", onData)
|
|
104
|
+
request.once("end", onEnd)
|
|
105
|
+
request.once("error", onError)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class BodyTooLargeError extends Error {}
|
|
110
|
+
|
|
111
|
+
/** @param {string} secret @param {string} timestamp @param {Buffer} body @param {string | null} provided */
|
|
112
|
+
function signatureMatches(secret, timestamp, body, provided) {
|
|
113
|
+
if (!provided || !/^[a-f0-9]{64}$/u.test(provided)) return false
|
|
114
|
+
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest()
|
|
115
|
+
const actual = Buffer.from(provided, "hex")
|
|
116
|
+
return timingSafeEqual(expected, actual)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @param {string | string[] | undefined} value */
|
|
120
|
+
function singleHeader(value) {
|
|
121
|
+
return typeof value === "string" ? value : null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @param {import("node:http").IncomingMessage} request @param {import("node:http").ServerResponse} response @param {number} status */
|
|
125
|
+
function reject(request, response, status) {
|
|
126
|
+
request.resume()
|
|
127
|
+
send(response, status)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {import("node:http").ServerResponse} response @param {number} status @param {string} [body] */
|
|
131
|
+
function send(response, status, body = "") {
|
|
132
|
+
response.writeHead(status, {"cache-control": "no-store", "content-type": "text/plain; charset=utf-8"})
|
|
133
|
+
response.end(body)
|
|
134
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import bigInteger from "big-integer"
|
|
4
|
+
import {Api, TelegramClient, sessions} from "teleproto"
|
|
5
|
+
|
|
6
|
+
export class TeleprotoUserSender {
|
|
7
|
+
/** @type {TelegramClient} */
|
|
8
|
+
#client
|
|
9
|
+
|
|
10
|
+
/** @param {TelegramClient} client */
|
|
11
|
+
constructor(client) {
|
|
12
|
+
this.#client = client
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {{apiId: number, apiHash: string, session: string}} config */
|
|
16
|
+
static async connect(config) {
|
|
17
|
+
const client = new TelegramClient(new sessions.StringSession(config.session), config.apiId, config.apiHash, {connectionRetries: 3})
|
|
18
|
+
await client.connect()
|
|
19
|
+
if (!await client.checkAuthorization()) {
|
|
20
|
+
await client.disconnect()
|
|
21
|
+
throw new Error("The dedicated MTProto automation user session is not authorized")
|
|
22
|
+
}
|
|
23
|
+
return new TeleprotoUserSender(client)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {{chatId: string, messageThreadId: number, text: string, randomId: string}} outbound */
|
|
27
|
+
async sendMessage(outbound) {
|
|
28
|
+
const peer = await this.#client.getInputEntity(outbound.chatId)
|
|
29
|
+
const updates = await this.#client.invoke(new Api.messages.SendMessage({
|
|
30
|
+
peer,
|
|
31
|
+
message: outbound.text,
|
|
32
|
+
randomId: bigInteger(outbound.randomId),
|
|
33
|
+
replyTo: new Api.InputReplyToMessage({
|
|
34
|
+
replyToMsgId: outbound.messageThreadId,
|
|
35
|
+
topMsgId: outbound.messageThreadId
|
|
36
|
+
})
|
|
37
|
+
}))
|
|
38
|
+
const messageId = extractMessageId(updates)
|
|
39
|
+
if (messageId === null) throw new Error("MTProto send response did not contain a Telegram message ID")
|
|
40
|
+
return {messageId}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async close() {
|
|
44
|
+
await this.#client.disconnect()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {unknown} response */
|
|
49
|
+
function extractMessageId(response) {
|
|
50
|
+
if (!response || typeof response !== "object") return null
|
|
51
|
+
const updates = /** @type {{updates?: unknown}} */ (response).updates
|
|
52
|
+
if (!Array.isArray(updates)) return null
|
|
53
|
+
for (const update of updates) {
|
|
54
|
+
if (!update || typeof update !== "object") continue
|
|
55
|
+
const message = /** @type {{message?: unknown}} */ (update).message
|
|
56
|
+
if (!message || typeof message !== "object") continue
|
|
57
|
+
const id = /** @type {{id?: unknown}} */ (message).id
|
|
58
|
+
if (Number.isSafeInteger(id) && Number(id) > 0) return Number(id)
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {createServer} from "node:http"
|
|
4
|
+
import {TensorBuzzQueueBridge} from "./bridge.js"
|
|
5
|
+
import {createTensorBuzzQueueBridgeHandler} from "./http.js"
|
|
6
|
+
|
|
7
|
+
export class TensorBuzzQueueBridgeService {
|
|
8
|
+
#bridge
|
|
9
|
+
#server
|
|
10
|
+
#draining = false
|
|
11
|
+
#ready = false
|
|
12
|
+
/** @type {NodeJS.Timeout | null} */
|
|
13
|
+
#retryTimer = null
|
|
14
|
+
|
|
15
|
+
/** @param {{host: string, port: number, secret: string, databasePath: string, routes: Readonly<Record<string, {chatId: string, messageThreadId: number}>>, sender: import("./bridge.js").MtProtoSender}} options */
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.#bridge = new TensorBuzzQueueBridge(options)
|
|
18
|
+
this.#server = createServer(createTensorBuzzQueueBridgeHandler({
|
|
19
|
+
secret: options.secret,
|
|
20
|
+
sender: options.sender,
|
|
21
|
+
isReady: () => this.#ready,
|
|
22
|
+
acceptEvent: (event, deliveryId) => {
|
|
23
|
+
this.#bridge.acceptEvent(event, deliveryId)
|
|
24
|
+
this.#scheduleDrain(0)
|
|
25
|
+
}
|
|
26
|
+
}))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @param {string} host @param {number} port */
|
|
30
|
+
async listen(host, port) {
|
|
31
|
+
if (host !== "127.0.0.1") throw new Error("TensorBuzz queue bridge must bind IPv4 loopback")
|
|
32
|
+
await new Promise((resolve, reject) => {
|
|
33
|
+
this.#server.once("error", reject)
|
|
34
|
+
this.#server.listen(port, host, () => {
|
|
35
|
+
this.#server.removeListener("error", reject)
|
|
36
|
+
resolve(undefined)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
this.#ready = true
|
|
40
|
+
this.#scheduleDrain(0)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async close() {
|
|
44
|
+
this.#ready = false
|
|
45
|
+
if (this.#retryTimer) clearTimeout(this.#retryTimer)
|
|
46
|
+
await new Promise((resolve, reject) => this.#server.close((error) => error ? reject(error) : resolve(undefined)))
|
|
47
|
+
this.#bridge.close()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {number} delayMs */
|
|
51
|
+
#scheduleDrain(delayMs) {
|
|
52
|
+
if (this.#draining || this.#retryTimer) return
|
|
53
|
+
this.#retryTimer = setTimeout(() => {
|
|
54
|
+
this.#retryTimer = null
|
|
55
|
+
void this.#drain()
|
|
56
|
+
}, delayMs)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async #drain() {
|
|
60
|
+
if (this.#draining) return
|
|
61
|
+
this.#draining = true
|
|
62
|
+
try {
|
|
63
|
+
let drained
|
|
64
|
+
do {
|
|
65
|
+
drained = await this.#bridge.drainOnce()
|
|
66
|
+
} while (drained)
|
|
67
|
+
} finally {
|
|
68
|
+
this.#draining = false
|
|
69
|
+
if (this.#ready) this.#scheduleDrain(1_000)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|