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
11 changes: 0 additions & 11 deletions .github/dependabot.yml

This file was deleted.

4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ This project follows Semantic Versioning.

## Unreleased

### Removed

- Removed Dependabot configuration to stop automated dependency update pull requests.

## [0.5.6] - 2026-05-27

### Added
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Default tests mock external systems. They do not require live Telegram, live Ope

GitHub Actions runs the `Check` workflow on pull requests and pushes to `main`. It installs dependencies with `pnpm install --frozen-lockfile` on Node.js 24 and runs `pnpm run check`.

Maintainers should configure branch protection for `main` to require the `Check` workflow before merging. Dependabot checks GitHub Actions and npm dependencies weekly.
Maintainers should configure branch protection for `main` to require the `Check` workflow before merging.

## Release

Expand Down
1 change: 1 addition & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, a
- `/help` shows the available bot commands.
- The Telegram slash-command menu is refreshed on gateway startup.
- Non-command text from the authorized user is sent to OpenCode as a prompt.
- Forwarded Telegram text, photo, album, and voice prompts include safe original-author context when Telegram provides it, with a safe fallback to the authorized user.
- The bot shows Telegram typing activity while a prompt is running.
- The bot can show an editable `Activity` message with OpenCode tools and skills used during a prompt.
- OpenCode permission requests are sent as text with `Allow once`, `Always allow`, and `Deny` buttons, even when voice replies are enabled.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ The bot currently supports:

Any non-command text message from the authorized Telegram user is sent to OpenCode as a prompt. If no active session is selected, the gateway creates one automatically.

Forwarded Telegram text, photo, album, and voice prompts include safe author context for OpenCode when Telegram provides the original author. If Telegram hides or omits the forwarded author, the prompt falls back to the authorized Telegram user without exposing raw Telegram payloads or numeric user IDs.

When a new OpenCode session starts, OpenCode Remote sends hidden gateway context with no assistant reply. This helps the agent understand that voice input may arrive as transcripts and that final text can be delivered as voice notes when voice mode is enabled.

When OpenCode requests permission during a prompt, the bot sends a text message with `Allow once`, `Always allow`, and `Deny` buttons. Permission prompts are always text, including when `/voice on` or `/voice all` would make normal assistant replies voice-only.
Expand Down
52 changes: 52 additions & 0 deletions src/adapters/telegram/author.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export function authorContextFromTelegramMessage(message) {
const forwardedName = forwardedAuthorName(message?.forward_origin)
if (forwardedName) {
return { name: forwardedName, source: "forwarded" }
}

return {
name: telegramUserDisplayName(message?.from) ?? "Authorized Telegram user",
source: "sender",
}
}

function forwardedAuthorName(origin) {
switch (origin?.type) {
case "user":
return telegramUserDisplayName(origin.sender_user)
case "hidden_user":
return safeDisplayName(origin.sender_user_name)
case "chat":
return telegramChatDisplayName(origin.sender_chat)
case "channel":
return telegramChatDisplayName(origin.chat)
default:
return null
}
}

function telegramUserDisplayName(user) {
const fullName = safeDisplayName([user?.first_name, user?.last_name].filter(Boolean).join(" "))
if (fullName) {
return fullName
}
const username = safeDisplayName(user?.username)
return username ? `@${username.replace(/^@/u, "")}` : null
}

function telegramChatDisplayName(chat) {
const title = safeDisplayName(chat?.title)
if (title) {
return title
}
const username = safeDisplayName(chat?.username)
return username ? `@${username.replace(/^@/u, "")}` : null
}

function safeDisplayName(value) {
if (typeof value !== "string") {
return null
}
const name = value.replace(/\s+/gu, " ").trim()
return name || null
}
12 changes: 10 additions & 2 deletions src/adapters/telegram/bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
recordProgressEvent,
} from "../../core/formatting/progressText.js"
import { isAuthorizedTelegramUser } from "./auth.js"
import { authorContextFromTelegramMessage } from "./author.js"
import {
captionFromMessages,
cleanupAttachments as defaultCleanupMediaAttachments,
Expand Down Expand Up @@ -275,7 +276,10 @@ export function createTelegramBot({
try {
await setEmojiReaction(ctx, chatId, messageId, "👀", logger)
const response = await sendPromptWithProgress(
formatPromptWithTelegramReactionInstruction(ctx.message.text),
formatPromptWithTelegramReactionInstruction({
text: ctx.message.text,
author: authorContextFromTelegramMessage(ctx.message),
}),
progress,
ctx,
)
Expand Down Expand Up @@ -337,6 +341,7 @@ export function createTelegramBot({
const response = await sendPromptWithProgress(
formatPromptWithTelegramReactionInstruction({
text: captionFromMessages(messages),
author: authorContextFromTelegramMessage(messages[0]),
attachments,
}),
progress,
Expand Down Expand Up @@ -398,7 +403,10 @@ export function createTelegramBot({
const transcript = await voiceService.transcribe(attachment.filePath)
const progress = await createPromptProgressRenderer(ctx)
const response = await sendPromptWithProgress(
formatPromptWithTelegramReactionInstruction(transcript),
formatPromptWithTelegramReactionInstruction({
text: transcript,
author: authorContextFromTelegramMessage(ctx.message),
}),
progress,
ctx,
)
Expand Down
37 changes: 36 additions & 1 deletion src/core/opencode/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,45 @@ function toPromptParts(prompt) {
mime: attachment.mime,
url: attachment.url,
})),
{ type: "text", text: String(prompt?.text ?? "") },
{ type: "text", text: formatPromptText(prompt) },
]
}

function formatPromptText(prompt) {
const text = String(prompt?.text ?? "")
const author = normalizePromptAuthor(prompt?.author)
if (!author) {
return text
}

return [
"Message author context:",
`- Author: ${author.name}`,
`- Attribution: ${formatAuthorAttribution(author.source)}`,
"",
"Message:",
text,
].join("\n")
}

function normalizePromptAuthor(author) {
if (!author || typeof author !== "object") {
return null
}
const name = firstString(author.name)
if (!name) {
return null
}
return { name, source: firstString(author.source) ?? "sender" }
}

function formatAuthorAttribution(source) {
if (source === "forwarded") {
return "forwarded original author"
}
return "message sender"
}

function toData(result) {
if (result && typeof result === "object" && "data" in result) {
return result.data
Expand Down
92 changes: 92 additions & 0 deletions tests/adapters/telegramAuthor.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, test } from "vitest"
import { authorContextFromTelegramMessage } from "../../src/adapters/telegram/author.js"

describe("telegram author context", () => {
test("uses the known forwarded user as the forwarded author", () => {
const author = authorContextFromTelegramMessage({
from: { id: 123, is_bot: false, first_name: "Forwarder" },
forward_origin: {
type: "user",
sender_user: {
id: 999,
is_bot: false,
first_name: "Ada",
last_name: "Lovelace",
username: "ada_private",
},
},
})

expect(author).toEqual({ name: "Ada Lovelace", source: "forwarded" })
})

test("uses hidden forwarded sender names without raw Telegram payloads", () => {
const author = authorContextFromTelegramMessage({
from: { id: 123, is_bot: false, first_name: "Forwarder" },
forward_origin: {
type: "hidden_user",
sender_user_name: "Private Sender",
},
})

expect(author).toEqual({ name: "Private Sender", source: "forwarded" })
})

test("uses forwarded chat titles as forwarded author context", () => {
const author = authorContextFromTelegramMessage({
from: { id: 123, is_bot: false, first_name: "Forwarder" },
forward_origin: {
type: "chat",
sender_chat: { id: -1001, type: "supergroup", title: "Private Group" },
},
})

expect(author).toEqual({ name: "Private Group", source: "forwarded" })
})

test("uses forwarded channel titles as forwarded author context", () => {
const author = authorContextFromTelegramMessage({
from: { id: 123, is_bot: false, first_name: "Forwarder" },
forward_origin: {
type: "channel",
chat: { id: -1002, type: "channel", title: "Release Notes" },
},
})

expect(author).toEqual({ name: "Release Notes", source: "forwarded" })
})

test("falls back to the authorized sender when forwarded author data is unavailable", () => {
const author = authorContextFromTelegramMessage({
from: { id: 123, is_bot: false, first_name: "Authorized", last_name: "User" },
forward_origin: {
type: "hidden_user",
sender_user_name: " ",
},
})

expect(author).toEqual({ name: "Authorized User", source: "sender" })
})

test("defaults normal messages to the authorized sender", () => {
const author = authorContextFromTelegramMessage({
from: { id: 123, is_bot: false, first_name: "Authorized", last_name: "User" },
})

expect(author).toEqual({ name: "Authorized User", source: "sender" })
})

test("does not expose numeric Telegram IDs as author names", () => {
const author = authorContextFromTelegramMessage({
from: { id: 123, is_bot: false, first_name: "Authorized" },
forward_origin: {
type: "user",
sender_user: { id: 999, is_bot: false, first_name: " " },
},
})

expect(author).toEqual({ name: "Authorized", source: "sender" })
expect(author.name).not.toContain("999")
expect(author.name).not.toContain("123")
})
})
Loading