Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ runs:
using: composite
steps:
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5

- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
node_modules
.dev.vars
.dev.vars
.prod.vars
.env
.wrangler
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
],
"github.copilot.enable": {
"*": true,
"dotenv": false
"dotenv": false,
".dev.vars": false
}
}
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ Use it for project orientation, quality gates, and safe editing workflow.
## Project Snapshot

- Project: switch-operator
- Workspace: pnpm workspaces (`apps/`, `tooling/`)
- Workspace: pnpm workspaces (`apps/`, `packages/`, `tooling/`)
- App workspaces: `apps/operator` (Cloudflare Worker with Hono)
- Package workspaces: `packages/http-client`, `packages/logger`
- Shared tooling configs: `tooling/eslint`, `tooling/prettier`, `tooling/typescript`
- Package manager: `pnpm` (lockfile: `pnpm-lock.yaml`)
- Required Node version: `24.12.0` (from `.nvmrc`)
Expand All @@ -30,6 +31,7 @@ Run from project root.
- Typecheck: `pnpm typecheck`
- Lint: `pnpm lint`
- Test: `pnpm test`
- Operator webhook helper: `pnpm --filter @repo/operator set-webhook <url> [-- --prod]`
- Format all files: `pnpm format`
- Format check: `pnpm format:check`

Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,31 @@
# switch-operator

Cloudflare Worker-based Telegram operator. The current implementation provides
health checks, webhook validation, and echo replies; LLM-driven assistant
features are planned next.

## Tech Stack

- **Runtime:** Cloudflare Workers
- **Framework:** Hono
- **Language:** TypeScript (strict)
- **Messaging:** Telegram Bot API
- **LLM:** Claude API (planned)
- **Validation:** Zod
- **Monorepo:** pnpm workspaces

## Structure

```
apps/
operator/ # Cloudflare Worker (Telegram bot)
packages/
http-client/ # Shared fetch wrapper with response validation
logger/ # Shared structured logger
tooling/
eslint/ # Shared ESLint config
prettier/ # Shared Prettier config
typescript/ # Shared TypeScript config
```

See [apps/operator/README.md](apps/operator/README.md) for setup and development instructions.
76 changes: 76 additions & 0 deletions apps/operator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Operator

Cloudflare Worker that acts as a personal AI assistant via Telegram. Built with Hono.

Run commands from the repo root unless noted otherwise.

## Setup

1. Create a Telegram bot via [@BotFather](https://t.me/BotFather)
2. Create `.dev.vars` (gitignored):
```
TELEGRAM_BOT_TOKEN=<token from BotFather>
TELEGRAM_WEBHOOK_SECRET=<any random string>
ALLOWED_CHAT_ID=<your Telegram user ID>
```
3. Install dependencies from repo root: `pnpm install`

## Local Development

Requires two terminals and [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/).

**Terminal 1** - start the worker:

```sh
pnpm dev
```

**Terminal 2** - start a tunnel:

```sh
cloudflared tunnel --url http://localhost:8787
```

Then register the webhook with the tunnel URL:

```sh
pnpm --filter @repo/operator set-webhook <tunnel-url>
# e.g. pnpm --filter @repo/operator set-webhook https://xxx.trycloudflare.com
```

Send a message to your bot on Telegram - it should echo it back.

## Production

Set secrets via Wrangler CLI:

```sh
pnpm --filter @repo/operator exec wrangler secret put TELEGRAM_BOT_TOKEN
pnpm --filter @repo/operator exec wrangler secret put TELEGRAM_WEBHOOK_SECRET
pnpm --filter @repo/operator exec wrangler secret put ALLOWED_CHAT_ID
```

Create `.prod.vars` (gitignored) with your production bot credentials, then register the webhook:

```sh
pnpm --filter @repo/operator set-webhook \
https://switch-operator.<account>.workers.dev -- --prod
```

## Scripts

| Command | Description |
| ------------------------------------------------------------ | ----------------------------- |
| `pnpm dev` | Start local dev server |
| `pnpm deploy` | Deploy to Cloudflare Workers |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm lint` | Run ESLint |
| `pnpm test` | Run tests |
| `pnpm --filter @repo/operator set-webhook <url> [-- --prod]` | Register Telegram webhook URL |

## Endpoints

| Method | Path | Description |
| ------ | ------------------- | ------------------------- |
| GET | `/health` | Health check |
| POST | `/webhook/telegram` | Telegram webhook receiver |
3 changes: 2 additions & 1 deletion apps/operator/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { baseConfig } from "@repo/eslint/base";
import { defineConfig } from "eslint/config";

export default baseConfig;
export default defineConfig(baseConfig, { ignores: ["scripts/**"] });
11 changes: 9 additions & 2 deletions apps/operator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@
"deploy": "wrangler deploy",
"typecheck": "tsc",
"lint": "eslint .",
"test": "node --test"
"test": "vitest run --passWithNoTests",
"set-webhook": "tsx scripts/set-webhook.ts"
},
"dependencies": {
"hono": "4.12.9"
"@hono/zod-validator": "0.7.6",
"@repo/http-client": "workspace:*",
"@repo/logger": "workspace:*",
"hono": "4.12.9",
"zod": "4.3.6"
},
"devDependencies": {
"@cloudflare/workers-types": "4.20260317.1",
Expand All @@ -20,7 +25,9 @@
"@types/node": "25.2.3",
"eslint": "9.39.1",
"prettier": "3.8.1",
"tsx": "4.21.0",
"typescript": "5.9.3",
"vitest": "4.1.2",
"wrangler": "4.78.0"
}
}
101 changes: 101 additions & 0 deletions apps/operator/scripts/set-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

import { HttpClient, HttpClientError } from "@repo/http-client";
import { Logger } from "@repo/logger";
import { z } from "zod";

const parseVarsFile = (path: string): Record<string, string> => {
const content = readFileSync(path, "utf-8");
const vars: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const eqIndex = trimmed.indexOf("=");
if (eqIndex !== -1) {
vars[trimmed.slice(0, eqIndex).trim()] = trimmed
.slice(eqIndex + 1)
.trim();
}
}
return vars;
};

const main = async () => {
const args = process.argv.slice(2);
const isProd = args.includes("--prod");
const baseUrl = args.find((a) => !a.startsWith("--"));

if (!baseUrl) {
console.error("Usage: pnpm set-webhook <base-url> [--prod]");
console.error("Example: pnpm set-webhook https://xxx.trycloudflare.com");
console.error(
" Prod: pnpm set-webhook https://switch-operator.xxx.workers.dev --prod"
);
process.exit(1);
}

const varsFile = isProd ? ".prod.vars" : ".dev.vars";
const varsPath = resolve(import.meta.dirname, `../${varsFile}`);

let vars: Record<string, string>;
try {
vars = parseVarsFile(varsPath);
} catch {
console.error(`Could not read ${varsFile}. Create it with:`);
console.error(" TELEGRAM_BOT_TOKEN=<token>");
console.error(" TELEGRAM_WEBHOOK_SECRET=<secret>");
process.exit(1);
}

const token = vars["TELEGRAM_BOT_TOKEN"];
const secret = vars["TELEGRAM_WEBHOOK_SECRET"];

if (!token || !secret) {
console.error(
`Missing TELEGRAM_BOT_TOKEN or TELEGRAM_WEBHOOK_SECRET in ${varsFile}`
);
process.exit(1);
}

const webhookUrl = `${baseUrl.replace(/\/$/, "")}/webhook/telegram`;

console.log(`[${isProd ? "PROD" : "DEV"}] Setting webhook to: ${webhookUrl}`);

const logger = new Logger({ context: "set-webhook" });
const client = new HttpClient({
logger,
baseUrl: `https://api.telegram.org/bot${token}`,
headers: { "Content-Type": "application/json" },
});

const responseSchema = z
.object({
ok: z.boolean(),
description: z.string().optional(),
})
.loose();

try {
const result = await client.post("/setWebhook", {
schema: responseSchema,
body: { url: webhookUrl, secret_token: secret },
});
console.log("Response:", JSON.stringify(result, null, 2));

if (!result.ok) {
console.error("Webhook registration failed:", result.description);
process.exit(1);
}
} catch (err) {
if (err instanceof HttpClientError) {
console.error("Webhook registration failed:", err.message, err.body);
process.exit(1);
}
throw err;
}
};

main();
7 changes: 7 additions & 0 deletions apps/operator/scripts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "@repo/typescript/base.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["."]
}
20 changes: 16 additions & 4 deletions apps/operator/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { Hono } from "hono";

const app = new Hono();
import { envValidator } from "./middleware/env";
import { notFoundHandler, onErrorHandler } from "./middleware/error-handlers";
import { loggerMiddleware } from "./middleware/logger";
import { healthRoutes } from "./modules/health/routes";
import { telegramRoutes } from "./modules/telegram/routes";
import type { AppEnv } from "./types/env";

app.get("/health", (c) => {
return c.json({ status: "ok" });
});
const app = new Hono<AppEnv>();

app.use("*", loggerMiddleware);
app.use("*", envValidator());
app.onError(onErrorHandler);

app.route("/", healthRoutes);
app.route("/", telegramRoutes);

app.notFound(notFoundHandler);

// eslint-disable-next-line import/no-default-export
export default app;
Loading
Loading