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