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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ This project follows Semantic Versioning.

## Unreleased

## [0.10.5] - 2026-05-31

### Fixed

- Fixed the Telegram `/skills` menu to split oversized skill lists into Telegram-safe messages while preserving the full list and final action buttons. (#48)

## [0.10.4] - 2026-05-31

### Changed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@crankshift/opencode-remote",
"description": "A messenger-based chat interface for OpenCode, starting with Telegram.",
"version": "0.10.4",
"version": "0.10.5",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
53 changes: 49 additions & 4 deletions src/adapters/telegram/skillsMenu.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { InlineKeyboard } from "grammy"
import { chunkText } from "../../core/formatting/chunkText.js"

const MAX_TELEGRAM_MESSAGE_LENGTH = 3900

export function createTelegramSkillsMenu({
discoverSkills,
Expand Down Expand Up @@ -27,10 +30,16 @@ export function createTelegramSkillsMenu({
)
const keyboard = new InlineKeyboard().text("Refresh", "skills:refresh").row()
keyboard.text("New skill", "skills:create")
await reply(ctx, formatSkillsList(result, { bundledMemeStatus }), {
parse_mode: "HTML",
reply_markup: keyboard,
})
const chunks = chunkHtmlTextByLines(
formatSkillsList(result, { bundledMemeStatus }),
MAX_TELEGRAM_MESSAGE_LENGTH,
)
for (const [index, chunk] of chunks.entries()) {
await reply(ctx, chunk, {
parse_mode: "HTML",
...(index === chunks.length - 1 ? { reply_markup: keyboard } : {}),
})
}
},

async handleCallback(ctx) {
Expand Down Expand Up @@ -134,6 +143,42 @@ function skillsDiscoveryLogContext({ skills = [], remoteSkillUrls = [] } = {}) {
}
}

function chunkHtmlTextByLines(text, maxLength) {
const chunks = []
let current = ""

for (const segment of text.match(/[^\n]*(?:\n|$)/gu) ?? []) {
if (segment.length === 0) {
continue
}

if (segment.length > maxLength) {
if (current.length > 0) {
chunks.push(current)
current = ""
}
chunks.push(...chunkText(segment, maxLength))
continue
}

if (current.length + segment.length <= maxLength) {
current += segment
continue
}

if (current.length > 0) {
chunks.push(current)
}
current = segment
}

if (current.length > 0) {
chunks.push(current)
}

return chunks
}

function uniqueSorted(values) {
return [...new Set(values.filter(Boolean))].sort()
}
Expand Down
2 changes: 1 addition & 1 deletion src/bin/program.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function createGatewayProgram({
const program = new Command()
const afterCreate = createStartupAfterConfigHook({ enableGatewayStartup, output })

program.name("opencode-remote").description("OpenCode messaging gateway").version("0.10.4")
program.name("opencode-remote").description("OpenCode messaging gateway").version("0.10.5")

program
.command("setup")
Expand Down
44 changes: 44 additions & 0 deletions tests/adapters/telegramBot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,50 @@ describe("createTelegramBot", () => {
expect(keyboard.inline_keyboard.flat().map((button) => button.text)).toContain("New skill")
})

test("skills command splits long skill lists into Telegram-safe messages", async () => {
const skills = Array.from({ length: 45 }, (_, index) => ({
name: `project-skill-${index}`,
description:
"Use when listing enough project skills to exceed a single Telegram sendMessage text payload safely.",
scope: "project",
source: "config-path",
generated: false,
filePath: `/project/skills/project-skill-${index}/SKILL.md`,
}))
const bot = createTelegramBot({
token: "token",
telegram: testTelegram(),
controller: {},
logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
botFactory: FakeBot,
discoverSkills: vi.fn(async () => ({ skills, remoteSkillUrls: [] })),
bundledMemeRuntimeStatus: vi.fn(async () => ({ enabled: true })),
})
const reply = vi.fn(async () => undefined)

await bot.commands.get("skills")({ reply })

expect(reply.mock.calls.length).toBeGreaterThan(1)
for (const [text, options] of reply.mock.calls) {
expect(text.length).toBeLessThanOrEqual(3900)
expect(options).toEqual(expect.objectContaining({ parse_mode: "HTML" }))
}
for (const [text] of reply.mock.calls.slice(0, -1)) {
expect(text.endsWith("\n")).toBe(true)
expect(text.match(/<b>/gu)?.length ?? 0).toBe(text.match(/<\/b>/gu)?.length ?? 0)
}
const deliveredText = reply.mock.calls.map(([text]) => text).join("")
for (const skill of skills) {
expect(deliveredText).toContain(skill.name)
}
expect(reply.mock.calls.at(-1)[1].reply_markup.inline_keyboard.flat()).toEqual(
expect.arrayContaining([expect.objectContaining({ text: "Refresh" })]),
)
for (const [, options] of reply.mock.calls.slice(0, -1)) {
expect(options.reply_markup).toBeUndefined()
}
})

test("skills command does not show an enable action when bundled meme runtime is disabled", async () => {
const bundledMemeRuntimeStatus = vi.fn(async () => ({ enabled: false }))
const bot = createTelegramBot({
Expand Down