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/README.md b/README.md
index b1d4dd6..845410d 100644
--- a/README.md
+++ b/README.md
@@ -207,9 +207,9 @@ You: approve
### Telegram
-| Ask | Reply |
-|-----|-------|
-|
|
|
+| Ask | Reply | Inline Buttons |
+|-----|-------|----------------|
+|
|
|
|
### Discord
diff --git a/docs/screenshots/discord/ask.png b/docs/screenshots/discord/ask.png
index 1be0edb..add4b5a 100644
Binary files a/docs/screenshots/discord/ask.png and b/docs/screenshots/discord/ask.png differ
diff --git a/docs/screenshots/discord/reply.png b/docs/screenshots/discord/reply.png
index d13feaa..32010f0 100644
Binary files a/docs/screenshots/discord/reply.png and b/docs/screenshots/discord/reply.png differ
diff --git a/docs/screenshots/telegram/ask.png b/docs/screenshots/telegram/ask.png
index add4b5a..1be0edb 100644
Binary files a/docs/screenshots/telegram/ask.png and b/docs/screenshots/telegram/ask.png differ
diff --git a/docs/screenshots/telegram/button.png b/docs/screenshots/telegram/button.png
new file mode 100644
index 0000000..79f6e3b
Binary files /dev/null and b/docs/screenshots/telegram/button.png differ
diff --git a/docs/screenshots/telegram/reply.png b/docs/screenshots/telegram/reply.png
index 32010f0..d13feaa 100644
Binary files a/docs/screenshots/telegram/reply.png and b/docs/screenshots/telegram/reply.png differ
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 1729c5e..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" <