Compare commits
7 commits
main
...
feat/teleg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff12a990dc | ||
|
|
a4e7991d3e | ||
|
|
8f47340f9e | ||
|
|
2ce34a86a2 | ||
|
|
7299846e5d | ||
|
|
bc77b1d86f | ||
|
|
3aef215663 |
18 changed files with 2577 additions and 1 deletions
|
|
@ -25,6 +25,7 @@ CONTEXT7_API_KEY=your-context7-api-key-here
|
|||
TELEGRAM_API_ID=your-telegram-api-id
|
||||
TELEGRAM_API_HASH=your-telegram-api-hash
|
||||
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||
TELEGRAM_CHAT_ID=your-telegram-chat-id
|
||||
|
||||
# Cloudflare Tunnel (optional — see separate tunnel repo)
|
||||
CLOUDFLARE_TUNNEL_TOKEN=
|
||||
|
|
|
|||
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
|
||||
}
|
||||
130
.opencode/telegram/tests/api.test.ts
Normal file
130
.opencode/telegram/tests/api.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from "vitest"
|
||||
import { writeFileSync, unlinkSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { sendMessage, sendDocument, sendPhoto } from "../src/api.ts"
|
||||
|
||||
const TOKEN = "TESTOKEN"
|
||||
const CHAT = "123"
|
||||
|
||||
const TMP_DOC = join(tmpdir(), "tg-test-doc.txt")
|
||||
const TMP_PHOTO = join(tmpdir(), "tg-test-photo.png")
|
||||
|
||||
function okResponse(result: unknown) {
|
||||
const payload = { ok: true as const, result }
|
||||
return { ...payload, json: async () => payload }
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
writeFileSync(TMP_DOC, "hello document")
|
||||
writeFileSync(TMP_PHOTO, "fake-png-bytes")
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
unlinkSync(TMP_DOC)
|
||||
unlinkSync(TMP_PHOTO)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("POSTs to sendMessage with chat_id, text; parse_mode omitted when undefined", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 42, chat: { id: 999 } }) as never)
|
||||
await sendMessage(TOKEN, CHAT, "hi")
|
||||
const [url, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendMessage`)
|
||||
expect(opts.method).toBe("POST")
|
||||
expect((opts.headers as Record<string, string>)["Content-Type"]).toBe("application/json")
|
||||
const body = JSON.parse(opts.body as string)
|
||||
expect(body).toEqual({ chat_id: CHAT, text: "hi" })
|
||||
})
|
||||
|
||||
it("POSTs to sendMessage with parse_mode=MarkdownV2 when explicitly passed", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 1, chat: { id: 1 } }) as never)
|
||||
await sendMessage(TOKEN, CHAT, "hi", "MarkdownV2")
|
||||
const [, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(opts.body as string)
|
||||
expect(body).toEqual({ chat_id: CHAT, text: "hi", parse_mode: "MarkdownV2" })
|
||||
})
|
||||
|
||||
it("parseMode='plain' omits parse_mode from body", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 1, chat: { id: 1 } }) as never)
|
||||
await sendMessage(TOKEN, CHAT, "hi", "plain")
|
||||
const [, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(opts.body as string)
|
||||
expect(body).toEqual({ chat_id: CHAT, text: "hi" })
|
||||
expect(body).not.toHaveProperty("parse_mode")
|
||||
})
|
||||
|
||||
it("parseMode='HTML' sets parse_mode=HTML", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 1, chat: { id: 1 } }) as never)
|
||||
await sendMessage(TOKEN, CHAT, "hi", "HTML")
|
||||
const [, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(opts.body as string)
|
||||
expect(body.parse_mode).toBe("HTML")
|
||||
})
|
||||
|
||||
it("returns {ok, message_id, chat_id} on success", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 42, chat: { id: 999 } }) as never)
|
||||
const r = await sendMessage(TOKEN, CHAT, "hi")
|
||||
expect(r).toEqual({ ok: true, message_id: 42, chat_id: "999" })
|
||||
})
|
||||
|
||||
it("throws on Telegram API error (ok:false)", async () => {
|
||||
const errPayload = { ok: false, description: "Unauthorized" }
|
||||
vi.mocked(fetch).mockResolvedValue({ ...errPayload, json: async () => errPayload } as never)
|
||||
await expect(sendMessage(TOKEN, CHAT, "hi")).rejects.toThrow(/Unauthorized/)
|
||||
})
|
||||
|
||||
it("throws on network error (fetch rejects)", async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error("network"))
|
||||
await expect(sendMessage(TOKEN, CHAT, "hi")).rejects.toThrow(/network/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("sendDocument", () => {
|
||||
it("POSTs to sendDocument with FormData (chat_id, document, caption)", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 7, chat: { id: CHAT } }) as never)
|
||||
const appendSpy = vi.spyOn(FormData.prototype, "append")
|
||||
await sendDocument(TOKEN, CHAT, TMP_DOC, "cap", "MarkdownV2")
|
||||
const [url, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendDocument`)
|
||||
expect(opts.method).toBe("POST")
|
||||
const form = opts.body as FormData
|
||||
expect(form.get("chat_id")).toBe(CHAT)
|
||||
expect(form.get("caption")).toBe("cap")
|
||||
expect(form.get("parse_mode")).toBe("MarkdownV2")
|
||||
const doc = form.get("document")
|
||||
expect(doc).toBeInstanceOf(Blob)
|
||||
expect((doc as File).name).toBe("tg-test-doc.txt")
|
||||
expect(appendSpy).toHaveBeenCalledWith("chat_id", CHAT)
|
||||
expect(appendSpy).toHaveBeenCalledWith("document", expect.any(Blob), "tg-test-doc.txt")
|
||||
appendSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("sendPhoto", () => {
|
||||
it("POSTs to sendPhoto with FormData (chat_id, photo field)", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(okResponse({ message_id: 9, chat: { id: CHAT } }) as never)
|
||||
const appendSpy = vi.spyOn(FormData.prototype, "append")
|
||||
await sendPhoto(TOKEN, CHAT, TMP_PHOTO, "cover", "HTML")
|
||||
const [url, opts] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendPhoto`)
|
||||
expect(opts.method).toBe("POST")
|
||||
const form = opts.body as FormData
|
||||
expect(form.get("chat_id")).toBe(CHAT)
|
||||
expect(form.get("caption")).toBe("cover")
|
||||
expect(form.get("parse_mode")).toBe("HTML")
|
||||
const photo = form.get("photo")
|
||||
expect(photo).toBeInstanceOf(Blob)
|
||||
expect((photo as File).name).toBe("tg-test-photo.png")
|
||||
expect(appendSpy).toHaveBeenCalledWith("photo", expect.any(Blob), "tg-test-photo.png")
|
||||
appendSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
54
.opencode/telegram/tests/config.test.ts
Normal file
54
.opencode/telegram/tests/config.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
import { loadConfig } from "../src/config.ts"
|
||||
|
||||
describe("loadConfig", () => {
|
||||
let savedToken: string | undefined
|
||||
let savedChatId: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
savedToken = process.env.TELEGRAM_BOT_TOKEN
|
||||
savedChatId = process.env.TELEGRAM_CHAT_ID
|
||||
delete process.env.TELEGRAM_BOT_TOKEN
|
||||
delete process.env.TELEGRAM_CHAT_ID
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (savedToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = savedToken
|
||||
}
|
||||
if (savedChatId === undefined) {
|
||||
delete process.env.TELEGRAM_CHAT_ID
|
||||
} else {
|
||||
process.env.TELEGRAM_CHAT_ID = savedChatId
|
||||
}
|
||||
})
|
||||
|
||||
it("returns {token, chatId} when both env vars present", () => {
|
||||
process.env.TELEGRAM_BOT_TOKEN = "tok"
|
||||
process.env.TELEGRAM_CHAT_ID = "cid"
|
||||
expect(loadConfig()).toEqual({ token: "tok", chatId: "cid" })
|
||||
})
|
||||
|
||||
it("throws when token missing", () => {
|
||||
process.env.TELEGRAM_CHAT_ID = "cid"
|
||||
expect(() => loadConfig()).toThrow(/TELEGRAM_BOT_TOKEN/)
|
||||
})
|
||||
|
||||
it("throws when chatId missing without override", () => {
|
||||
process.env.TELEGRAM_BOT_TOKEN = "tok"
|
||||
expect(() => loadConfig()).toThrow(/chat_id/)
|
||||
})
|
||||
|
||||
it("override chatId takes priority over env", () => {
|
||||
process.env.TELEGRAM_BOT_TOKEN = "tok"
|
||||
process.env.TELEGRAM_CHAT_ID = "env-id"
|
||||
expect(loadConfig({ chatId: "argv-id" })).toEqual({ token: "tok", chatId: "argv-id" })
|
||||
})
|
||||
|
||||
it("override chatId works even when env chatId missing", () => {
|
||||
process.env.TELEGRAM_BOT_TOKEN = "tok"
|
||||
expect(loadConfig({ chatId: "argv-id" })).toEqual({ token: "tok", chatId: "argv-id" })
|
||||
})
|
||||
})
|
||||
18
.opencode/telegram/tests/e2e.manual.ts
Normal file
18
.opencode/telegram/tests/e2e.manual.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Manual E2E test — run with:
|
||||
// node --experimental-strip-types tests/e2e.manual.ts
|
||||
// Requires real TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env vars.
|
||||
import { sendMessage, sendDocument, sendPhoto } from "../src/api.ts"
|
||||
import { loadConfig } from "../src/config.ts"
|
||||
|
||||
async function main() {
|
||||
const { token, chatId } = loadConfig()
|
||||
console.log("[e2e] sending text to", chatId)
|
||||
const r1 = await sendMessage(token, chatId, "E2E test: *bold* _italic_ `code`", "MarkdownV2")
|
||||
console.log("[e2e] text sent:", r1)
|
||||
// document + photo — раскомментируй и подставь пути к реальным файлам:
|
||||
// const r2 = await sendDocument(token, chatId, "/path/to/file.md", "caption")
|
||||
// console.log("[e2e] document sent:", r2)
|
||||
// const r3 = await sendPhoto(token, chatId, "/path/to/cover.png", "cover")
|
||||
// console.log("[e2e] photo sent:", r3)
|
||||
}
|
||||
main().catch((e) => { console.error("[e2e] failed:", e); process.exit(1) })
|
||||
41
.opencode/telegram/tests/markdown.test.ts
Normal file
41
.opencode/telegram/tests/markdown.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { escapeMarkdownV2, MARKDOWN_V2_SPECIAL_CHARS } from "../src/markdown.ts"
|
||||
|
||||
describe("escapeMarkdownV2", () => {
|
||||
it("empty string → empty", () => {
|
||||
expect(escapeMarkdownV2("")).toBe("")
|
||||
})
|
||||
|
||||
it("string without special chars → unchanged", () => {
|
||||
expect(escapeMarkdownV2("hello world 123 abc")).toBe("hello world 123 abc")
|
||||
})
|
||||
|
||||
it("cyrillic → unchanged", () => {
|
||||
expect(escapeMarkdownV2("Привет мир")).toBe("Привет мир")
|
||||
})
|
||||
|
||||
it.each([...MARKDOWN_V2_SPECIAL_CHARS])("escapes single special char %j", (ch) => {
|
||||
expect(escapeMarkdownV2(ch)).toBe("\\" + ch)
|
||||
})
|
||||
|
||||
it("mix cyrillic + special chars: cyrillic untouched, special escaped", () => {
|
||||
// comma and space are NOT in the special set — stay as-is
|
||||
expect(escapeMarkdownV2("Привет, *мир*!")).toBe("Привет, \\*мир\\*\\!")
|
||||
})
|
||||
|
||||
it("all special chars at once", () => {
|
||||
const all = [...MARKDOWN_V2_SPECIAL_CHARS].join("")
|
||||
const expected = [...MARKDOWN_V2_SPECIAL_CHARS].map((c) => "\\" + c).join("")
|
||||
expect(escapeMarkdownV2(all)).toBe(expected)
|
||||
})
|
||||
|
||||
it("backtick is escaped", () => {
|
||||
expect(escapeMarkdownV2("`code`")).toBe("\\`code\\`")
|
||||
})
|
||||
|
||||
it("backslash is NOT in MARKDOWN_V2_SPECIAL_CHARS and passes through", () => {
|
||||
expect(MARKDOWN_V2_SPECIAL_CHARS as readonly string[]).not.toContain("\\")
|
||||
// input `\*` (2 chars): backslash stays, asterisk escaped → `\\*` (3 chars)
|
||||
expect(escapeMarkdownV2("\\*")).toBe("\\\\*")
|
||||
})
|
||||
})
|
||||
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,
|
||||
},
|
||||
})
|
||||
35
.opencode/tools/telegram-send.ts
Normal file
35
.opencode/tools/telegram-send.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { spawnSync } from "child_process"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import path from "path"
|
||||
|
||||
export default tool({
|
||||
description: "Send a message, document, or photo to Telegram via Bot API. Supports MarkdownV2/HTML/plain text. Returns { ok, message_id, chat_id }. Requires TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env vars.",
|
||||
args: {
|
||||
action: tool.schema.enum(["text", "document", "photo"]).describe("Type of send: text message, document file, or photo image"),
|
||||
chat_id: tool.schema.string().optional().describe("Override default TELEGRAM_CHAT_ID"),
|
||||
text: tool.schema.string().optional().describe("Message text (required for action=text)"),
|
||||
path: tool.schema.string().optional().describe("Absolute path to file (required for action=document/photo)"),
|
||||
caption: tool.schema.string().optional().describe("Caption for document/photo"),
|
||||
parse_mode: tool.schema.enum(["MarkdownV2", "HTML", "plain"]).optional().describe("Parse mode, default MarkdownV2"),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const telegramDir = path.resolve(import.meta.dir, "..", "telegram")
|
||||
const cliPath = path.join(telegramDir, "cli.ts")
|
||||
|
||||
const cliArgs = [args.action]
|
||||
if (args.chat_id) cliArgs.push("--chat-id", args.chat_id)
|
||||
if (args.text) cliArgs.push("--text", args.text)
|
||||
if (args.path) cliArgs.push("--path", args.path)
|
||||
if (args.caption) cliArgs.push("--caption", args.caption)
|
||||
if (args.parse_mode) cliArgs.push("--parse-mode", args.parse_mode)
|
||||
|
||||
const r = spawnSync("node", ["--experimental-strip-types", cliPath, ...cliArgs], {
|
||||
encoding: "utf-8",
|
||||
cwd: context.worktree,
|
||||
})
|
||||
if (r.status !== 0) {
|
||||
return `⚠️ telegram-send failed (exit ${r.status}): ${r.stderr || r.stdout}`
|
||||
}
|
||||
return r.stdout.trim()
|
||||
},
|
||||
})
|
||||
37
docs/decisions/073-pr-174-telegram-send-tool.md
Normal file
37
docs/decisions/073-pr-174-telegram-send-tool.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# ADR-073: telegram-send plugin-tool — Bot API messaging via CLI
|
||||
|
||||
## Статус
|
||||
|
||||
Accepted (PR #174)
|
||||
|
||||
## Контекст
|
||||
|
||||
Оркестратору нужен быстрый способ показывать пользователю результаты работы — сгенерированный `draw-image` PNG, .md-файл с архитектурой, текстовый вывод. Существующий способ показать файл — tunnel skill (запуск процесса + проброс порта через Cloudflare), что тяжело для разовой отправки и требует живого процесса.
|
||||
|
||||
В репо уже есть паттерн plugin-tool + CLI-проекта на примере `draw-image`: тонкая обёртка `.opencode/tools/draw-image.ts` дёргает CLI из `.opencode/draw-image/cli.ts` через `spawnSync("node", ["--experimental-strip-types", cliPath, ...args])`. Plugin-tools авто-дискаверятся opencode из папки `.opencode/tools/` — без записи в `opencode.json`, без MCP-сервера.
|
||||
|
||||
## Решение
|
||||
|
||||
Создан `telegram-send` plugin-tool + CLI-проект `.opencode/telegram/` по паттерну `draw-image`:
|
||||
|
||||
1. **Plugin-tool, не MCP-сервер** — `.opencode/tools/telegram-send.ts` авто-дискаверится opencode; `spawnSync` запускает CLI как дочерний процесс; результат парсится из stdout; ошибка → `⚠️ telegram-send failed (exit N): <stderr>`. Не требует записи в `opencode.json`, не требует long-running процесса.
|
||||
|
||||
2. **Без runtime-зависимостей** — `package.json` имеет только devDeps (`@types/node`, `typescript`, `vitest`). Рантайм использует встроенный `fetch`/`FormData`/`Blob`/`File` Node 22+. Это упрощает установку (только `npm install` для devDeps) и аудит.
|
||||
|
||||
3. **Один tool с параметром `action`** — `action: "text" | "document" | "photo"` вместо трёх отдельных tools. Причины: единый контрак (`{ ok, message_id, chat_id }`), один набор env-настроек, меньше шума в списке tools оркестратора. Параметры `path`/`caption`/`text` условно-обязательные (зависят от `action`).
|
||||
|
||||
4. **CLI как тонкий слой над api.ts** — `cli.ts` парсит argv, применяет `parse_mode` по умолчанию (`"MarkdownV2"`), диспетчеризует в `sendMessage`/`sendDocument`/`sendPhoto`. `api.ts` — низкоуровневый, не применяет дефолты (добавляет `parse_mode` в body только при явной передаче). Разделение: api.ts тестируется изолированно, cli.ts тестирует дефолты.
|
||||
|
||||
5. **`lib: ["ES2022", "DOM"]` в tsconfig** — DOM-типы нужны только для `fetch`/`BodyInit`/`Blob`/`FormData`/`File` (все используются в `src/api.ts`). Рантайм-значения берутся из Node 22+ глобалов, не из браузера. `allowImportingTsExtensions: true` — сознательный паттерн (как `draw-image`) для `node --experimental-strip-types`.
|
||||
|
||||
## Альтернативы
|
||||
|
||||
1. **MCP-сервер для Telegram** — отвергнуто: требует long-running процесса, записи в `opencode.json`, сложнее отладка. Plugin-tool проще и следует существующему паттерну `draw-image`.
|
||||
|
||||
2. **Прямой `fetch` из plugin-tool (без CLI)** — отвергнуто: plugin-tool выполняется в Bun plugin-host (`import.meta.dir` доступен), но `fetch` там может вести себя иначе; CLI-процесс на Node 22+ даёт предсказуемый рантайм с встроенным `fetch`. Плюс CLI тестируется изолированно (`npm test`).
|
||||
|
||||
3. **Внешние зависимости (`node-telegram-bot-api`, `telegraf`)** — отвергнуто: добавляют audit-поверхность, усложняют установку, избыточны для 3 endpoint'ов (`sendMessage`, `sendDocument`, `sendPhoto`). Встроенный `fetch` + `FormData` достаточны.
|
||||
|
||||
4. **Три отдельных tools (`telegram-send-text`, `telegram-send-document`, `telegram-send-photo`)** — отвергнуто: засоряет список tools, дублирует env-логику и контрак. Один tool с `action`-enum чище.
|
||||
|
||||
5. **Markdown → MarkdownV2 авто-конвертер** — отвергнуто (out-of-scope): пользователь сам экранирует через `escapeMarkdownV2` (экспортируется из `src/markdown.ts`) или использует `parse_mode: "plain"`/`"HTML"`. Конвертер — отдельная нетривиальная задача.
|
||||
43
docs/handoff/pr-174-telegram-send-tool.md
Normal file
43
docs/handoff/pr-174-telegram-send-tool.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
pr: 174
|
||||
title: feat(telegram): add telegram-send plugin-tool for Bot API messaging
|
||||
---
|
||||
|
||||
## Что сделано
|
||||
|
||||
- `.opencode/telegram/` — CLI-проект (по аналогии с `.opencode/draw-image/`):
|
||||
- `package.json` — `"type": "module"`, без runtime-deps; devDeps: `@types/node`, `typescript`, `vitest`; script `test: vitest run`
|
||||
- `tsconfig.json` — `target: ES2022`, `module: ESNext`, `moduleResolution: Bundler`, `strict`, `allowImportingTsExtensions: true`, `lib: ["ES2022", "DOM"]` (DOM нужен для типов `fetch`, `BodyInit`, `Blob`, `FormData`, `File` — все используются в `src/api.ts`)
|
||||
- `vitest.config.ts` — `environment: "node"`, `include: ["tests/**/*.test.ts"]` (e2e.manual.ts исключён — нет `.test.` в имени)
|
||||
- `src/config.ts` — `loadConfig(overrides?)`: читает `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` из env, валидирует, override chatId имеет приоритет над env
|
||||
- `src/markdown.ts` — `escapeMarkdownV2(text)`: экранирует спецсимволы `_*[]()~\`>#+-=|{}.!` (backslash НЕ в наборе), кириллицу не трогает
|
||||
- `src/api.ts` — `sendMessage`/`sendDocument`/`sendPhoto` через встроенный `fetch` Node 22+; `telegramFetch` парсит `result`, возвращает `{ ok, message_id, chat_id }`; при `ok:false` — throw с `description`
|
||||
- `cli.ts` — argv-парсер, диспетчер по `action` (text/document/photo), `parse_mode` по умолчанию `"MarkdownV2"`; успех → `console.log(JSON.stringify(result))`, провал → stderr + `exit(1)`
|
||||
- `.opencode/tools/telegram-send.ts` — plugin-tool обёртка (~35 строк): `spawnSync("node", ["--experimental-strip-types", cliPath, ...cliArgs])`, при `status !== 0` возвращает `⚠️ telegram-send failed (exit N): ...`, иначе `stdout.trim()`
|
||||
- Тесты в `.opencode/telegram/tests/` (39 unit-тестов, все проходят):
|
||||
- `markdown.test.ts` — 25 кейсов: пустая строка, без спецсимволов, кириллица, каждый спецсимвол `it.each`, микс кириллицы+спецсимволов, все спецсимволы сразу, backtick, `\` не в наборе
|
||||
- `config.test.ts` — 5 кейсов: валидный env, отсутствие token, отсутствие chatId, override приоритет, override без env
|
||||
- `api.test.ts` — 9 кейсов: URL/method/body/headers sendMessage, parse_mode omit/plain/HTML, возвращаемое значение, Telegram API error, network error, sendDocument FormData, sendPhoto FormData (spy на `FormData.prototype.append`)
|
||||
- `e2e.manual.ts` — ручной скрипт (НЕ в `npm test`)
|
||||
- `.env.example` — добавлена `TELEGRAM_CHAT_ID=your-telegram-chat-id` в секцию `# Telegram (optional)` (после `TELEGRAM_BOT_TOKEN`)
|
||||
|
||||
## Почему
|
||||
|
||||
Оркестратору нужен быстрый способ показывать пользователю результаты работы — например, сгенерированный `draw-image` PNG или .md-файл с архитектурой проекта. Сейчас единственный способ показать файл — через tunnel skill (запуск процесса, проброс порта, ссылка), что тяжело для разовой отправки. Прямая отправка в Telegram через Bot API решает это одним вызовом tool'а: `telegram-send({ action: "photo", path })` — и файл у пользователя.
|
||||
|
||||
Plugin-tool (не MCP-сервер) выбран как паттерн: авто-дискаверится opencode из `.opencode/tools/`, не требует записи в `opencode.json`, тонкая TS-обёртка дёргает CLI через `spawnSync`. Без runtime-зависимостей — только встроенный `fetch`/`FormData`/`Blob` Node 22+.
|
||||
|
||||
## Pending
|
||||
|
||||
- Реальная E2E-проверка с живым ботом (нужны `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` в env) — `node --experimental-strip-types tests/e2e.manual.ts`
|
||||
- Проверка цепочки `draw-image` → `telegram-send photo` в реальном сценарии оркестратора
|
||||
- Возможно: интеграция `escapeMarkdownV2` в оркестратор для авто-экранирования текста перед `action: "text"`
|
||||
|
||||
## Watch out
|
||||
|
||||
- `parse_mode: "MarkdownV2"` — формат по умолчанию в `cli.ts:29`, но НЕ в `api.ts` (api-функции добавляют `parse_mode` в body только когда явно передан `MarkdownV2`/`HTML`; `undefined`/`"plain"` → поле отсутствует). Это сознательно: api.ts — низкоуровневый, cli.ts — применяет дефолт
|
||||
- `lib: ["ES2022", "DOM"]` в `tsconfig.json` — DOM нужен только для типов `fetch`/`BodyInit`/`Blob`/`FormData`/`File`; рантайм-значения берутся из Node 22+ глобалов (НЕ из браузера)
|
||||
- `allowImportingTsExtensions: true` — сознательный паттерн проекта (как `draw-image`), т.к. запуск через `node --experimental-strip-types` с `.ts`-импортами
|
||||
- Node 22+ требуется (встроенные `fetch`, `Blob`, `FormData`, `File` глобально)
|
||||
- `tests/e2e.manual.ts` исключён из `npm test` двумя механизмами: отсутствие `.test.` в имени + `include: ["tests/**/*.test.ts"]` в vitest config — двойная защита
|
||||
- Plugin-tool НЕ MCP-сервер — НЕ добавлять в `opencode.json`, авто-дискаверится из `.opencode/tools/`
|
||||
|
|
@ -60,6 +60,7 @@ opencode-config/
|
|||
│ │ ├── post-docs-review.ts # post-docs-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Docs Review Summary heading; optional repo?: string) — PR#46, PR#65
|
||||
│ │ ├── post-review.ts # post-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Code Review Summary heading; optional repo?: string) — PR#46, PR#65
|
||||
│ │ ├── spec-status.ts # spec-status tool wrapper
|
||||
│ │ ├── telegram-send.ts # telegram-send plugin-tool wrapper (spawnSync CLI .opencode/telegram/cli.ts; action: text|document|photo; MarkdownV2 default; error → ⚠️ telegram-send failed) — PR#174
|
||||
│ │ └── tunnel.ts # Cloudflare tunnel toggle tool (start/stop без args) — PR#34
|
||||
│ ├── draw-image/ # SVG template renderer for on-brand covers (sharp + lucide-static) — PR#133
|
||||
│ │ ├── package.json # deps: sharp, lucide-static; devDeps: vitest, typescript; postinstall sync-lucide
|
||||
|
|
@ -95,6 +96,20 @@ opencode-config/
|
|||
│ │ ├── render.optional.test.ts # unit: empty badge → no rect, empty subtitle → no text (PR#144)
|
||||
│ │ ├── render.optional.integration.test.ts # integration: PNG valid with/without subtitle+badge (PR#144)
|
||||
│ │ └── e2e.optional.test.ts # e2e: title-only CLI → exit 0, clean SVG (PR#144)
|
||||
│ ├── telegram/ # Telegram Bot API CLI-проект (по паттерну draw-image: plugin-tool + CLI, без runtime-deps) — PR#174
|
||||
│ │ ├── package.json # "type": "module", без runtime-deps; devDeps: @types/node, typescript, vitest; script test: vitest run
|
||||
│ │ ├── tsconfig.json # ES2022 + DOM (для типов fetch/Blob/FormData/File), moduleResolution: Bundler, allowImportingTsExtensions: true
|
||||
│ │ ├── vitest.config.ts # environment: node, include: tests/**/*.test.ts (e2e.manual.ts исключён — нет .test. в имени)
|
||||
│ │ ├── cli.ts # argv-парсер, диспетчер по action (text/document/photo), parse_mode=MarkdownV2 по умолчанию; успех → JSON в stdout, провал → stderr + exit(1)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── config.ts # loadConfig(overrides?): читает TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID из env, валидирует, override chatId приоритетнее env
|
||||
│ │ │ ├── markdown.ts # escapeMarkdownV2(text): экранирует спецсимволы MarkdownV2 (_*[]()~`>#+-=|{}.!), кириллицу не трогает, backslash НЕ в наборе
|
||||
│ │ │ └── api.ts # sendMessage/sendDocument/sendPhoto через встроенный fetch Node 22+; telegramFetch парсит result → {ok, message_id, chat_id}; ok:false → throw с description
|
||||
│ │ └── tests/ # vitest: 39 unit-тестов (markdown 25, config 5, api 9), все проходят
|
||||
│ │ ├── markdown.test.ts # 25 кейсов: пустая строка, без спецсимволов, кириллица, it.each по каждому спецсимволу, микс, backtick, `\` не в наборе
|
||||
│ │ ├── config.test.ts # 5 кейсов: валидный env, отсутствие token/chatId, override приоритет, override без env
|
||||
│ │ ├── api.test.ts # 9 кейсов: URL/method/body/headers sendMessage, parse_mode omit/plain/HTML, return value, Telegram API error, network error, sendDocument/sendPhoto FormData
|
||||
│ │ └── e2e.manual.ts # ручной скрипт (НЕ в npm test — нет .test. в имени + vitest include)
|
||||
│ ├── scripts/
|
||||
│ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml)
|
||||
│ │ ├── check-permissions.py # Permissions validator (permissions-check.yml)
|
||||
|
|
@ -170,7 +185,7 @@ opencode-config/
|
|||
├── docker-entrypoint.sh # Self-healing entrypoint shim: checks node_modules/sharp, runs npm ci if missing (continue-on-error), exec opencode "$@" — PR#144
|
||||
├── .dockerignore # Excludes app_data/, .git, **/node_modules from docker build context — PR#144
|
||||
│ # Memory deps install layers (PR#107): COPY .opencode/package.json → npm install --omit=dev (runtime: @vscode/ripgrep for memory-search.ts); COPY pyproject.toml uv.lock → uv sync --no-dev --frozen (runtime: httpx, numpy, tenacity for python -m src.memory)
|
||||
├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR), PR#106 (AI_PROVIDER_* removed, OPENCODE_MEMORY_REMOTE now optional), PR#118 (OPENCODE_SERVER_USERNAME)
|
||||
├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR), PR#106 (AI_PROVIDER_* removed, OPENCODE_MEMORY_REMOTE now optional), PR#118 (OPENCODE_SERVER_USERNAME), PR#174 (TELEGRAM_CHAT_ID)
|
||||
├── app_data/
|
||||
│ ├── opencode-memory/ # Persistent memory (separate git repo, gitignored) — PR#36
|
||||
│ ├── workspaces/ # Agent working directory (.gitkeep)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue