feat(telegram): add CLI project with Bot API client
This commit is contained in:
parent
43b60d6101
commit
3aef215663
9 changed files with 2202 additions and 0 deletions
1
.opencode/telegram/.gitignore
vendored
Normal file
1
.opencode/telegram/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
62
.opencode/telegram/cli.ts
Normal file
62
.opencode/telegram/cli.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { loadConfig } from "./src/config.ts"
|
||||
import { sendMessage, sendDocument, sendPhoto } from "./src/api.ts"
|
||||
|
||||
function parseArgs(argv: string[]): { action: string; opts: Record<string, string> } {
|
||||
const action = argv[0] ?? ""
|
||||
const opts: Record<string, string> = {}
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a.startsWith("--")) {
|
||||
const key = a.slice(2)
|
||||
const val = argv[i + 1] ?? ""
|
||||
opts[key] = val
|
||||
i++
|
||||
}
|
||||
}
|
||||
return { action, opts }
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2)
|
||||
if (argv.length < 1) {
|
||||
process.stderr.write('usage: node cli.ts <text|document|photo> --text "..." [--chat-id ...] [--path ...] [--caption ...] [--parse-mode MarkdownV2|HTML|plain]\n')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const { action, opts } = parseArgs(argv)
|
||||
const chatIdOverride = opts["chat-id"] ? { chatId: opts["chat-id"] } : undefined
|
||||
const { token, chatId } = loadConfig(chatIdOverride)
|
||||
const parseMode = (opts["parse-mode"] ?? "MarkdownV2") as "MarkdownV2" | "HTML" | "plain"
|
||||
|
||||
let result
|
||||
if (action === "text") {
|
||||
if (!opts.text) {
|
||||
process.stderr.write('error: --text is required for action "text"\n')
|
||||
process.exit(2)
|
||||
}
|
||||
result = await sendMessage(token, chatId, opts.text, parseMode)
|
||||
} else if (action === "document") {
|
||||
if (!opts.path) {
|
||||
process.stderr.write('error: --path is required for action "document"\n')
|
||||
process.exit(2)
|
||||
}
|
||||
result = await sendDocument(token, chatId, opts.path, opts.caption, parseMode)
|
||||
} else if (action === "photo") {
|
||||
if (!opts.path) {
|
||||
process.stderr.write('error: --path is required for action "photo"\n')
|
||||
process.exit(2)
|
||||
}
|
||||
result = await sendPhoto(token, chatId, opts.path, opts.caption, parseMode)
|
||||
} else {
|
||||
process.stderr.write(`error: unknown action "${action}" (expected: text|document|photo)\n`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result))
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(`⚠️ telegram-send failed (exit 1): ${msg}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
1979
.opencode/telegram/package-lock.json
generated
Normal file
1979
.opencode/telegram/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
15
.opencode/telegram/package.json
Normal file
15
.opencode/telegram/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "telegram",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Telegram Bot API client for sending messages, documents, and photos",
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
87
.opencode/telegram/src/api.ts
Normal file
87
.opencode/telegram/src/api.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { readFileSync } from "node:fs"
|
||||
import { basename } from "node:path"
|
||||
|
||||
export interface TelegramResult {
|
||||
ok: true
|
||||
message_id: number
|
||||
chat_id: string
|
||||
}
|
||||
|
||||
type ParseMode = "MarkdownV2" | "HTML" | "plain"
|
||||
|
||||
const API_BASE = "https://api.telegram.org/bot"
|
||||
|
||||
async function telegramFetch(
|
||||
token: string,
|
||||
method: string,
|
||||
body: BodyInit,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<TelegramResult> {
|
||||
const url = `${API_BASE}${token}/${method}`
|
||||
const res = await fetch(url, { method: "POST", body, headers })
|
||||
const data = await res.json() as { ok: boolean; description?: string; result?: { message_id: number; chat: { id: number | string } } }
|
||||
if (!data.ok) {
|
||||
throw new Error(data.description ?? `Telegram API error (HTTP ${res.status})`)
|
||||
}
|
||||
if (!data.result) {
|
||||
throw new Error("Telegram API returned no result")
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
message_id: data.result.message_id,
|
||||
chat_id: String(data.result.chat.id),
|
||||
}
|
||||
}
|
||||
|
||||
function shouldIncludeParseMode(parseMode?: ParseMode): parseMode is "MarkdownV2" | "HTML" {
|
||||
return parseMode === "MarkdownV2" || parseMode === "HTML"
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
token: string,
|
||||
chatId: string,
|
||||
text: string,
|
||||
parseMode?: ParseMode,
|
||||
): Promise<TelegramResult> {
|
||||
const body: Record<string, string> = { chat_id: chatId, text }
|
||||
if (shouldIncludeParseMode(parseMode)) {
|
||||
body.parse_mode = parseMode
|
||||
}
|
||||
return telegramFetch(token, "sendMessage", JSON.stringify(body), {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendDocument(
|
||||
token: string,
|
||||
chatId: string,
|
||||
filePath: string,
|
||||
caption?: string,
|
||||
parseMode?: ParseMode,
|
||||
): Promise<TelegramResult> {
|
||||
const buf = readFileSync(filePath)
|
||||
const blob = new Blob([buf])
|
||||
const form = new FormData()
|
||||
form.append("chat_id", chatId)
|
||||
form.append("document", blob, basename(filePath))
|
||||
if (caption) form.append("caption", caption)
|
||||
if (shouldIncludeParseMode(parseMode)) form.append("parse_mode", parseMode)
|
||||
return telegramFetch(token, "sendDocument", form)
|
||||
}
|
||||
|
||||
export async function sendPhoto(
|
||||
token: string,
|
||||
chatId: string,
|
||||
filePath: string,
|
||||
caption?: string,
|
||||
parseMode?: ParseMode,
|
||||
): Promise<TelegramResult> {
|
||||
const buf = readFileSync(filePath)
|
||||
const blob = new Blob([buf])
|
||||
const form = new FormData()
|
||||
form.append("chat_id", chatId)
|
||||
form.append("photo", blob, basename(filePath))
|
||||
if (caption) form.append("caption", caption)
|
||||
if (shouldIncludeParseMode(parseMode)) form.append("parse_mode", parseMode)
|
||||
return telegramFetch(token, "sendPhoto", form)
|
||||
}
|
||||
18
.opencode/telegram/src/config.ts
Normal file
18
.opencode/telegram/src/config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export interface TelegramConfig {
|
||||
token: string
|
||||
chatId: string
|
||||
}
|
||||
|
||||
export function loadConfig(overrides?: { chatId?: string }): TelegramConfig {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN
|
||||
if (!token) {
|
||||
throw new Error("TELEGRAM_BOT_TOKEN env var is required (see .env.example)")
|
||||
}
|
||||
|
||||
const chatId = overrides?.chatId ?? process.env.TELEGRAM_CHAT_ID
|
||||
if (!chatId) {
|
||||
throw new Error("chat_id is required: pass --chat-id or set TELEGRAM_CHAT_ID env var")
|
||||
}
|
||||
|
||||
return { token, chatId }
|
||||
}
|
||||
15
.opencode/telegram/src/markdown.ts
Normal file
15
.opencode/telegram/src/markdown.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export const MARKDOWN_V2_SPECIAL_CHARS = [
|
||||
"_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!",
|
||||
] as const
|
||||
|
||||
export function escapeMarkdownV2(text: string): string {
|
||||
let out = ""
|
||||
for (const ch of text) {
|
||||
if ((MARKDOWN_V2_SPECIAL_CHARS as readonly string[]).includes(ch)) {
|
||||
out += "\\" + ch
|
||||
} else {
|
||||
out += ch
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
16
.opencode/telegram/tsconfig.json
Normal file
16
.opencode/telegram/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "cli.ts", "tests/**/*.ts"]
|
||||
}
|
||||
9
.opencode/telegram/vitest.config.ts
Normal file
9
.opencode/telegram/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 30000,
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue