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
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ jobs:
- name: Markdown lint
uses: DavidAnson/markdownlint-cli2-action@v19
with:
globs: "**/*.md"
globs: "**/*.md !**/node_modules/**"

- name: ShellCheck
uses: ludeeus/action-shellcheck@2.0.0
with:
scandir: "."
scandir: "scripts"
severity: warning
additional_files: "start.sh"
59 changes: 59 additions & 0 deletions docs/telegram/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,62 @@ Claude Code Session (local, full filesystem access)
3. **State directory** - Set `TELEGRAM_STATE_DIR` to project-level path for per-project isolation
4. **Bot API limitation** - No message history or search; only real-time messages are visible
5. **MarkdownV2 formatting** - Requires escaping special characters per Telegram's rules; use `format: "text"` for plain messages to avoid issues

---

## Local Fork Setup (Important for Contributors)

This project uses a **local fork** of the official Telegram plugin at
`external_plugins/telegram-channel/` with added features (inline buttons,
session memory STM/LTM/Compactor, `/session` commands).

### Always use `./start.sh`

The `start.sh` script handles all the wiring:

1. Exports `TELEGRAM_STATE_DIR` pointing to project-local `.claude/channels/telegram/`
2. Patches the plugin cache `.mcp.json` so Claude Code runs the local fork code
3. Installs dependencies if `node_modules/` is missing
4. Launches with the correct `--channels plugin:telegram@claude-plugins-official` flag

**Do not launch manually** with `claude --channels ...`. The official plugin
cache gets overwritten on every `claude plugin update` — `start.sh` re-patches
it on each start.

### Required settings

`.claude/settings.local.json` must include:

```json
{
"channelsEnabled": true
}
```

Without this, outbound (reply) works but **inbound messages are silently
dropped** by Claude Code.

### Credential isolation

Bot token and access control must only exist in the **project-local** state
directory:

```text
.claude/channels/telegram/.env # TELEGRAM_BOT_TOKEN=...
.claude/channels/telegram/access.json # allowlist, groups, policy
```

**Never** symlink or copy these to `~/.claude/channels/telegram/`. That global
path is the official plugin's default and would expose credentials to every
Claude Code session on the machine.

### Common pitfalls

| Symptom | Cause | Fix |
| --- | --- | --- |
| Two telegram processes | Official plugin loaded from both marketplace and cache paths | Uninstall and reinstall: `claude plugin uninstall telegram@claude-plugins-official` then `claude plugin install telegram@claude-plugins-official` |
| Outbound works, inbound silent | `channelsEnabled: true` missing or wrong `--channels` tag | Add setting; use `plugin:telegram@...` not `server:telegram` |
| 409 Conflict in logs | Two processes polling the same bot token | Kill stale processes; only one session per bot token |
| STM not writing | `TELEGRAM_STATE_DIR` not set | Use `./start.sh` (exports it automatically) |
| `/session` commands ignored | Server needs restart after code changes | Restart the Claude Code session |
| Plugin update breaks fork | `claude plugin update` overwrites cache | Re-run `./start.sh` to re-patch |
5 changes: 4 additions & 1 deletion external_plugins/telegram-channel/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
"mcpServers": {
"telegram": {
"command": "bun",
"args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"]
"args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"],
"env": {
"TELEGRAM_STATE_DIR": "${CLAUDE_PLUGIN_ROOT}/../../.claude/channels/telegram"
}
}
}
}
65 changes: 61 additions & 4 deletions external_plugins/telegram-channel/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ import type { ReactionTypeEmoji } from 'grammy/types'
import { randomBytes } from 'crypto'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
import { homedir } from 'os'
import { join, extname, sep } from 'path'
import { join, extname, sep, resolve, dirname } from 'path'
import { fileURLToPath } from 'url'

// Session memory (STM / LTM / Compactor)
const __dirname = dirname(fileURLToPath(import.meta.url))
const LIB_DIR = resolve(__dirname, '..', '..', 'lib', 'sessions')
const { appendMessage, buildContextPrompt, loadConfig } = await import(join(LIB_DIR, 'index.ts'))
const { parseSessionCommand, executeSessionCommand } = await import(join(LIB_DIR, 'commands.ts'))
const { startScheduler } = await import(join(LIB_DIR, 'scheduler.ts'))

const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
const ACCESS_FILE = join(STATE_DIR, 'access.json')
Expand Down Expand Up @@ -58,6 +66,10 @@ if (!TOKEN) {
}
const INBOX_DIR = join(STATE_DIR, 'inbox')

// Session memory — load config and start background scheduler
const sessionConfig = loadConfig(STATE_DIR)
const stopScheduler = startScheduler(STATE_DIR, sessionConfig)

// Last-resort safety net — without these the process dies silently on any
// unhandled promise rejection. With them it logs and keeps serving tools.
process.on('unhandledRejection', err => {
Expand Down Expand Up @@ -586,6 +598,15 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
}
}

// Session: log outbound assistant message to STM
// Derive userId from chat_id (in private chats, chat_id == user_id)
appendMessage(STATE_DIR, chat_id, {
ts: new Date().toISOString(),
role: 'assistant',
text,
channel: 'telegram',
})

const result =
sentIds.length === 1
? `sent (id: ${sentIds[0]})`
Expand Down Expand Up @@ -750,6 +771,18 @@ bot.on('callback_query:data', async ctx => {
await ctx.editMessageText(`${msg.text}\n\n→ ${label}`).catch(() => {})
}

// Session: log button press to STM
const btnTs = new Date().toISOString()
appendMessage(STATE_DIR, senderId, {
ts: btnTs,
role: 'user',
text: label,
channel: 'telegram',
})

// Build context for button callback
const btnContext = buildContextPrompt(STATE_DIR, senderId, sessionConfig)

// Relay button label as a channel inbound message.
void mcp.notification({
method: 'notifications/claude/channel',
Expand All @@ -759,8 +792,9 @@ bot.on('callback_query:data', async ctx => {
chat_id,
user: from.username ?? senderId,
user_id: senderId,
ts: new Date().toISOString(),
ts: btnTs,
button: 'true',
...(btnContext ? { session_context: btnContext } : {}),
},
},
}).catch(err => {
Expand Down Expand Up @@ -981,6 +1015,14 @@ async function handleInbound(
return
}

// Session command intercept: handle /session locally without LLM
const sessionCmd = parseSessionCommand(text)
if (sessionCmd) {
const response = executeSessionCommand(STATE_DIR, String(from.id), sessionCmd)
await bot.api.sendMessage(chat_id, response).catch(() => {})
return
}

// Typing indicator — signals "processing" until we reply (or ~5s elapses).
void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})

Expand All @@ -997,6 +1039,20 @@ async function handleInbound(

const imagePath = downloadImage ? await downloadImage() : undefined

// Session: log inbound user message to STM
const userId = String(from.id)
const msgTs = new Date((ctx.message?.date ?? 0) * 1000).toISOString()
appendMessage(STATE_DIR, userId, {
ts: msgTs,
role: 'user',
text,
msgId: msgId != null ? String(msgId) : undefined,
channel: 'telegram',
})

// Session: build context from STM (summary + recent messages)
const context = buildContextPrompt(STATE_DIR, userId, sessionConfig)

// image_path goes in meta only — an in-content "[image attached — read: PATH]"
// annotation is forgeable by any allowlisted sender typing that string.
mcp.notification({
Expand All @@ -1007,8 +1063,8 @@ async function handleInbound(
chat_id,
...(msgId != null ? { message_id: String(msgId) } : {}),
user: from.username ?? String(from.id),
user_id: String(from.id),
ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
user_id: userId,
ts: msgTs,
...(imagePath ? { image_path: imagePath } : {}),
...(attachment ? {
attachment_kind: attachment.kind,
Expand All @@ -1017,6 +1073,7 @@ async function handleInbound(
...(attachment.mime ? { attachment_mime: attachment.mime } : {}),
...(attachment.name ? { attachment_name: attachment.name } : {}),
} : {}),
...(context ? { session_context: context } : {}),
},
},
}).catch(err => {
Expand Down
56 changes: 38 additions & 18 deletions start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ cd "$PROJECT_DIR"

# Channel plugins (bidirectional DM bridge via --channels).
# Source of truth is external_plugins/<name>-channel/ (version-controlled).
# On start, we symlink the plugin cache dir → local dir so Claude Code
# loads our fork instead of the official version.
# On start, we patch the plugin cache .mcp.json so Claude Code runs our
# local fork code with project-local state dir. No symlinks needed.
declare -A CHANNEL_PLUGINS=(
[telegram]="plugin:telegram@claude-plugins-official"
[discord]="plugin:discord@claude-plugins-official"
Expand Down Expand Up @@ -38,6 +38,30 @@ resolve_cache_base() {
echo "$HOME/.claude/plugins/cache/$plugin_org/$plugin_name"
}

# Patch the .mcp.json in a plugin cache version dir so it runs our local
# fork code with the project-local state dir.
# Args: $1=cache_version_dir $2=local_abs_path $3=channel_name
patch_cache_mcp() {
local ver_dir="$1" local_abs="$2" ch_name="$3"
local mcp_file="$ver_dir/.mcp.json"
local state_dir="$PROJECT_DIR/.claude/channels/$ch_name"
local env_key="${ch_name^^}_STATE_DIR"

cat > "$mcp_file" <<MCPEOF
{
"mcpServers": {
"$ch_name": {
"command": "bun",
"args": ["run", "--cwd", "$local_abs", "--shell=bun", "--silent", "start"],
"env": {
"$env_key": "$state_dir"
}
}
}
}
MCPEOF
}

# ── Usage / help ──────────────────────────────────────────
if [[ "${1:-}" =~ ^(-h|--help|help)$ ]]; then
cat <<EOF
Expand Down Expand Up @@ -91,7 +115,7 @@ for ch in "${CHANNELS[@]}"; do
done

# Otherwise, use Claude Code --channels
CHANNEL_ARGS=""
CHANNEL_ARGS=()
for ch in "${CHANNELS[@]}"; do
plugin="${CHANNEL_PLUGINS[$ch]:-}"
if [[ -z "$plugin" ]]; then
Expand All @@ -100,11 +124,10 @@ for ch in "${CHANNELS[@]}"; do
exit 1
fi
export "${ch^^}_STATE_DIR=$PROJECT_DIR/.claude/channels/$ch"
CHANNEL_ARGS+=" --channels $plugin"
CHANNEL_ARGS+=(--channels "$plugin")

# Symlink plugin cache → local fork.
# Claude Code re-extracts official plugins on startup, overwriting the cache.
# Symlinking the version dir ensures our fork is always loaded.
# Patch plugin cache .mcp.json → local fork code + project-local state dir.
# This is idempotent and re-applied on every start (survives plugin updates).
local_dir="${CHANNEL_LOCAL[$ch]:-}"
if [[ -n "$local_dir" && -f "$local_dir/server.ts" ]]; then
local_abs="$(cd "$local_dir" && pwd)"
Expand All @@ -116,20 +139,17 @@ for ch in "${CHANNELS[@]}"; do
bun install --cwd "$local_abs" --no-summary
fi

# Find existing version dir(s) and symlink each to our local fork
# Patch each cached version dir's .mcp.json
if [[ -d "$cache_base" ]]; then
for ver_dir in "$cache_base"/*/; do
[[ -d "$ver_dir" ]] || continue
ver_name="$(basename "$ver_dir")"
target="$cache_base/$ver_name"
# Skip if already a symlink to the right place
if [[ -L "$target" ]] && [[ "$(readlink "$target")" == "$local_abs" ]]; then
continue
# Restore original dir if a previous symlink exists
if [[ -L "$ver_dir" ]]; then
rm "$ver_dir"
[[ -d "${ver_dir}.official" ]] && mv "${ver_dir}.official" "$ver_dir"
fi
# Backup original and create symlink
[[ -d "$target" && ! -L "$target" ]] && mv "$target" "${target}.official"
ln -sfn "$local_abs" "$target"
echo "Linked $target → $local_abs"
patch_cache_mcp "$ver_dir" "$local_abs" "$ch"
echo "Patched $(basename "$ver_dir")/.mcp.json → $local_abs"
done
else
echo "WARNING: plugin cache not found for $ch"
Expand All @@ -140,4 +160,4 @@ for ch in "${CHANNELS[@]}"; do
done

echo "Starting Claude Code with channel(s): ${CHANNELS[*]}"
exec claude $CHANNEL_ARGS
exec claude "${CHANNEL_ARGS[@]}"
Loading