From c6f461a702a44b1e510709002da84dfc26c088d4 Mon Sep 17 00:00:00 2001 From: "kevin.wu" Date: Fri, 27 Mar 2026 14:32:13 +0800 Subject: [PATCH 1/3] fix: CI shellcheck and markdownlint exclude node_modules - Scope shellcheck to scripts/ and start.sh (skip node_modules) - Add !**/node_modules/** to markdownlint globs - Fix SC2086 in start.sh: use array for CHANNEL_ARGS --- .github/workflows/ci.yml | 5 +++-- start.sh | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 102698c..b2cd6f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" diff --git a/start.sh b/start.sh index 1729c5e..b11142b 100644 --- a/start.sh +++ b/start.sh @@ -91,7 +91,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 @@ -100,7 +100,7 @@ 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. @@ -140,4 +140,4 @@ for ch in "${CHANNELS[@]}"; do done echo "Starting Claude Code with channel(s): ${CHANNELS[*]}" -exec claude $CHANNEL_ARGS +exec claude "${CHANNEL_ARGS[@]}" From b12ed74f1945950263ac915a646c70e02c2f2b47 Mon Sep 17 00:00:00 2001 From: osisdie Date: Fri, 27 Mar 2026 06:51:58 +0000 Subject: [PATCH 2/3] fix: CI shellcheck and markdownlint exclude node_modules From 917edb2662b818b550c2cb7d9e970069a9a62a9e Mon Sep 17 00:00:00 2001 From: "kevin.wu" Date: Fri, 27 Mar 2026 22:06:26 +0800 Subject: [PATCH 3/3] feat: add session memory (STM/LTM) to Telegram plugin, replace symlink with cache patching - Integrate STM append/context into Telegram MCP server (inbound, outbound, button callbacks) - Add /session command intercept (status, clear, profile, forget, export) - Start background scheduler for auto-compaction and cleanup - Replace start.sh symlink mechanism with .mcp.json cache patching - Add TELEGRAM_STATE_DIR env to plugin .mcp.json for project-local state isolation - Document local fork setup, credential isolation, and common pitfalls --- docs/telegram/install.md | 59 +++++++++++++++++++ external_plugins/telegram-channel/.mcp.json | 5 +- external_plugins/telegram-channel/server.ts | 65 +++++++++++++++++++-- start.sh | 50 +++++++++++----- 4 files changed, 159 insertions(+), 20 deletions(-) diff --git a/docs/telegram/install.md b/docs/telegram/install.md index fdb7c96..be3e1f7 100644 --- a/docs/telegram/install.md +++ b/docs/telegram/install.md @@ -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 | diff --git a/external_plugins/telegram-channel/.mcp.json b/external_plugins/telegram-channel/.mcp.json index cf7195b..5ac4c41 100644 --- a/external_plugins/telegram-channel/.mcp.json +++ b/external_plugins/telegram-channel/.mcp.json @@ -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" + } } } } diff --git a/external_plugins/telegram-channel/server.ts b/external_plugins/telegram-channel/server.ts index d6c23ce..fd66ab3 100644 --- a/external_plugins/telegram-channel/server.ts +++ b/external_plugins/telegram-channel/server.ts @@ -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') @@ -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 => { @@ -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]})` @@ -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', @@ -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 => { @@ -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(() => {}) @@ -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({ @@ -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, @@ -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 => { diff --git a/start.sh b/start.sh index b11142b..c8d3b4b 100644 --- a/start.sh +++ b/start.sh @@ -6,8 +6,8 @@ cd "$PROJECT_DIR" # Channel plugins (bidirectional DM bridge via --channels). # Source of truth is external_plugins/-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" @@ -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" <