Compare commits

...

7 commits

Author SHA1 Message Date
opencode-agent
ff12a990dc docs: update project map for telegram-send tool 2026-07-31 17:39:31 +00:00
opencode-agent
a4e7991d3e docs(handoff): set PR number 174 2026-07-31 17:36:46 +00:00
opencode-agent
8f47340f9e docs(handoff): add handoff and ADR for telegram-send 2026-07-31 17:33:58 +00:00
opencode-agent
2ce34a86a2 chore(env): add TELEGRAM_CHAT_ID to .env.example 2026-07-31 17:33:56 +00:00
opencode-agent
7299846e5d test(telegram): add unit tests for markdown, config, api 2026-07-31 17:33:54 +00:00
opencode-agent
bc77b1d86f feat(telegram): add telegram-send plugin-tool 2026-07-31 17:33:50 +00:00
opencode-agent
3aef215663 feat(telegram): add CLI project with Bot API client 2026-07-31 17:33:47 +00:00
18 changed files with 2577 additions and 1 deletions

View file

@ -25,6 +25,7 @@ CONTEXT7_API_KEY=your-context7-api-key-here
TELEGRAM_API_ID=your-telegram-api-id TELEGRAM_API_ID=your-telegram-api-id
TELEGRAM_API_HASH=your-telegram-api-hash TELEGRAM_API_HASH=your-telegram-api-hash
TELEGRAM_BOT_TOKEN=your-telegram-bot-token TELEGRAM_BOT_TOKEN=your-telegram-bot-token
TELEGRAM_CHAT_ID=your-telegram-chat-id
# Cloudflare Tunnel (optional — see separate tunnel repo) # Cloudflare Tunnel (optional — see separate tunnel repo)
CLOUDFLARE_TUNNEL_TOKEN= CLOUDFLARE_TUNNEL_TOKEN=

1
.opencode/telegram/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

62
.opencode/telegram/cli.ts Normal file
View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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)
}

View 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 }
}

View 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
}

View 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()
})
})

View 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" })
})
})

View 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) })

View 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("\\\\*")
})
})

View 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"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
},
})

View 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()
},
})

View 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"`. Конвертер — отдельная нетривиальная задача.

View 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/`

View file

@ -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-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 │ │ ├── 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 │ │ ├── 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 │ │ └── 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 │ ├── 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 │ │ ├── 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.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) │ │ ├── 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) │ │ └── 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/ │ ├── scripts/
│ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml) │ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml)
│ │ ├── check-permissions.py # Permissions validator (permissions-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 ├── 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 ├── .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) │ # 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/ ├── app_data/
│ ├── opencode-memory/ # Persistent memory (separate git repo, gitignored) — PR#36 │ ├── opencode-memory/ # Persistent memory (separate git repo, gitignored) — PR#36
│ ├── workspaces/ # Agent working directory (.gitkeep) │ ├── workspaces/ # Agent working directory (.gitkeep)