---
name: telegram-message-forwarder
description: >
  Deploy and manage Telegram message forwarding between channels/groups.
  Use when user asks to forward messages from one TG channel to another,
  set up a message relay, monitor channels, or says "消息转发器", "bind频道",
  "转发到我的群".
trigger:
  - telegram forwarder
  - 消息转发器
  - bind频道
  - 转发频道
  - channel forwarder
  - message relay
---

# Telegram Message Forwarder

Deploy Heavrnl/TelegramForwarder — the most feature-rich open-source TG forwarder (1144+ ⭐).

## Why This One

| Project | Stars | Bind Command | GUI | Filters | AI | Docker |
|---------|-------|-------------|-----|---------|-----|--------|
| **Heavrnl/TelegramForwarder** | 1144 | ✅ `/bind` | ✅ Web + Inline KB | ✅ Keyword/Regex | ✅ Multi-provider | ✅ |
| aahnik/tgcf | 1600 | ❌ Config-only | ✅ Web | ✅ Plugin-based | ❌ | ✅ |
| MrMissx/Telegram_Forwarder | 636 | ❌ JSON config | ❌ | ✅ Basic | ❌ | ❌ |
| GentlemanHu/TelegramForwarder | 36 | ❌ Inline KB only | ✅ Inline KB | ✅ Basic | ❌ | ❌ |

**Heavrnl wins** because it has `/bind` command, inline keyboard GUI, regex replace, AI processing, RSS, and multi-platform push (Apprise).

## Quick Deploy (Docker)

```bash
cd ~
git clone https://github.com/Heavrnl/TelegramForwarder.git
cd TelegramForwarder
cp .env.example .env
# Edit .env with required values (see below)
docker-compose run -it telegram-forwarder  # first run: interactive for phone verification
# CTRL+C after verification
# Edit docker-compose.yml: set stdin_open: false, tty: false
docker-compose up -d
```

**⚠️ ghcr.io pulls often fail with "unexpected EOF"** on this server. Use Chinese Docker mirrors:
- `docker.1ms.run` — 1ms acceleration (already configured in `/etc/docker/daemon.json`)
- `ghcr.nju.edu.cn` — NJU mirror for ghcr.io images
If pulls still fail, retry once (transient network issues) or use `network_mode: host` with Mihomo proxy.

## Required .env Values

| Variable | Source | Notes |
|----------|--------|-------|
| `API_ID` | https://my.telegram.org/apps | Same as TG_API_ID in .bashrc |
| `API_HASH` | https://my.telegram.org/apps | Same as TG_API_HASH in .bashrc |
| `BOT_TOKEN` | @BotFather `/newbot` | Must be DIFFERENT from Hermes bot token |
| `PHONE_NUMBER` | User's TG phone | Format: `+86...` |
| `USER_ID` | @userinfobot | Numeric user ID |

**⚠️ BOT_TOKEN conflict**: Cannot reuse Hermes's bot token. Need a separate bot from @BotFather.

## Usage (Bot Commands)

```
/bind https://t.me/channel_name    # Bind source channel to current group
/bind "频道名称"                    # Bind by name
/settings                           # Open settings GUI (inline keyboard)
/add keyword1 keyword2              # Add filter keywords
/remove_keyword keyword1            # Remove keywords
/replace regex_pattern              # Add regex replacement
/list_keyword                       # List current keywords
/list_replace                       # List current replacements
```

### Workflow
1. Create a Telegram group/channel as the destination
2. Add the bot to that group as admin
3. In the group, send `/bind https://t.me/source_channel`
4. Configure filters via `/settings` or `/add`
5. Messages auto-forward with filters applied

## Key Features

- **Multi-source**: Bind multiple sources to one destination
- **Keyword filter**: Whitelist/blacklist mode
- **Regex replace**: Clean up formatting (e.g., remove `**`, fix links)
- **Media filter**: Filter by type (photo, video, audio, document)
- **AI processing**: Use OpenAI/Claude/Gemini/DeepSeek/Qwen for message rewriting
- **RSS**: Generate RSS feed from forwarded messages
- **Apprise push**: Forward to email, Slack, Discord, webhooks, etc.
- **Sync rules**: Mirror settings across multiple bind rules
- **Time-based filter**: Forward only during specific hours

## Proxy Setup (Required for This Server)

This server **cannot reach Telegram directly**. Telethon (used by TelegramForwarder) needs explicit SOCKS5 proxy configuration — standard `HTTP_PROXY` env vars are NOT enough for MTProto connections.

### Step-by-step

1. **Patch `main.py`** to add SOCKS5 proxy support for Telethon:

```python
# Add after 'from telethon import TelegramClient, types':
try:
    import socks
    HAS_SOCKS = True
except ImportError:
    HAS_SOCKS = False

# Replace client creation:
proxy_config = None
if os.getenv('HTTP_PROXY') or os.getenv('http_proxy'):
    proxy_addr = os.getenv('HTTP_PROXY') or os.getenv('http_proxy')
    import re
    m = re.match(r'https?://([\d.]+):(\d+)', proxy_addr)
    if m and HAS_SOCKS:
        proxy_host, proxy_port = m.group(1), int(m.group(2))
        proxy_config = (socks.SOCKS5, proxy_host, proxy_port)

user_client = TelegramClient('./sessions/user', api_id, api_hash, proxy=proxy_config)
bot_client = TelegramClient('./sessions/bot', api_id, api_hash, proxy=proxy_config)
```

2. **Create `Dockerfile.custom`** (base image lacks PySocks):

```dockerfile
FROM heavrnl/telegramforwarder:latest
RUN pip install --no-cache-dir PySocks
COPY main.py /app/main.py
```

3. **Update `docker-compose.yml`** — use `network_mode: host` + proxy env:

```yaml
services:
  telegram-forwarder:
    build:
      context: .
      dockerfile: Dockerfile.custom
    container_name: telegram-forwarder
    restart: unless-stopped
    network_mode: host
    environment:
      - HTTP_PROXY=http://127.0.0.1:7890
      - HTTPS_PROXY=http://127.0.0.1:7890
      - http_proxy=http://127.0.0.1:7890
      - https_proxy=http://127.0.0.1:7890
      - NO_PROXY=localhost,127.0.0.1
    volumes:
      - ./db:/app/db
      - ./.env:/app/.env
      - ./logs:/app/logs
      - ./sessions:/app/sessions
      - ./temp:/app/temp
      - ./ufb/config:/app/ufb/config
      - ./config:/app/config
      - ./rss/data:/app/rss/data
      - ./rss/media:/app/rss/media
    stdin_open: true
    tty: true
```

4. **Build and run**:
```bash
docker-compose build
docker-compose run --rm telegram-forwarder   # interactive for phone code
# After verification, set stdin_open: false, tty: false
docker-compose up -d
```

**⚠️ Phone verification should be done by the user via SSH**, not by the agent. Verification codes expire in ~30 seconds, and agent latency through `process(action='submit')` is too slow. Tell the user to SSH in and run the interactive command themselves. If 2FA is enabled, they'll also need their Telegram two-step verification password.

### Pitfalls

- **🔴 CRITICAL: Competing gateway processes steal bot token polling** — If another process (old OpenClaw Gateway, duplicate Hermes instance, test script) is using the same Telegram bot token for `getUpdates`, the Hermes adapter will get stuck in permanent polling conflict. **Symptom**: Gateway shows `telegram: connected` in state, but `getUpdates` via API returns 0 results WITHOUT a 409 conflict (meaning no one is actually polling). No "✓ telegram connected" in logs. Repeated `Telegram polling conflict (1/5)` warnings. Group messages NEVER processed. **Root cause**: Two processes share the same bot token — Telegram only allows ONE `getUpdates` session per token. **Diagnosis**: `ps aux | grep gateway | grep -v grep` — look for stale processes (e.g., old `openclaw ... gateway --port 18789` from before migration to Hermes). **Fix**: Kill the stale process first, THEN restart Hermes: `kill <stale_pid> && systemctl --user restart hermes-gateway`. **Also check**: `getUpdates` returning 0 without 409 conflict means NO adapter is polling — this is the key diagnostic signal. If you get 409, the adapter IS polling (normal). If you get 0, nobody is polling (broken).
- **🔴 CRITICAL: Gateway restart causes Telegram polling conflict** — When `hermes gateway restart` is called, the old Telegram polling session may NOT release on Telegram's servers. The new adapter gets stuck in a permanent conflict loop: `Conflict: terminated by other getUpdates request; make sure that only one bot instance is running`. **Symptom**: Gateway restarts but group messages are never processed, zero Telegram adapter logs. **Root cause**: `hermes gateway restart` sends SIGTERM → systemd restarts immediately → old polling session still active → new adapter can't connect. **Fix**: NEVER use `hermes gateway restart`. Instead, use the three-step process:
  ```bash
  systemctl --user stop hermes-gateway   # stop
  sleep 30                               # wait for Telegram session to expire
  systemctl --user start hermes-gateway  # start fresh
  ```
  Or from inside the gateway, kill the PID to trigger systemd auto-restart:
  ```bash
  kill <gateway_pid>  # SIGTERM, systemd Restart=always kicks in
  ```
  But even the kill approach may hit the conflict if systemd restarts too fast. The safest approach is stop → wait → start. **This bug means group messages may have NEVER worked** — every time the user restarted the gateway, the conflict silently killed the adapter.
- **Group migrated to supergroup (silent failure)**: Telegram sometimes migrates a basic group to a supergroup, changing its ID. The old chat ID becomes `deactivated=True` and forwarding fails silently with `An invalid Peer was used... bots cannot start conversations`. To diagnose: `bot.get_entity(old_id)` — the returned object will have `deactivated=True` and `migrated_to=InputChannel(channel_id=NEW_ID, access_hash=...)`. The new supergroup ID in Telethon format is `-100` + `channel_id` (e.g., channel_id=3966251111 → `-1003966251111`). Update BOTH the forwarder DB AND Hermes's `free_response_channels` in `config.yaml`. If `hermes gateway restart` is refused from inside the gateway, ask the user to run it from a separate shell.
- **Bot-to-bot messaging FAILS**: If the forwarding target is a bot (e.g., Hermes Bot), Telethon throws `Bots can't send messages to other bots`. Telegram prohibits bot→bot messaging. **Fix**: Forward to the GROUP where the Hermes bot resides, NOT directly to the bot. The bot reads messages from the group as a member. If there's no group yet, create one, add both bots, and forward there. The `chats` table column is `telegram_chat_id` (NOT `chat_id`).
- **Bot Privacy Mode blocks group messages (CRITICAL)**: Telegram bots have **privacy mode ON by default**. A bot with privacy mode can only see: (1) commands (/start), (2) messages that @mention it, (3) replies to its messages. It CANNOT see regular group messages — even with `free_response_channels` configured in Hermes. **Symptom**: Forwarder sends messages to the group successfully, but Hermes never responds. **Fix**: Open @BotFather → `/mybots` → select the Hermes bot → **Bot Settings** → **Group Privacy** → **Turn off**. **After turning off privacy mode, kick the bot from the group and re-add it** — the change doesn't apply to bots already in a group. Verify with API: `can_read_all_group_messages` must be `true`. Must be done for BOTH the Hermes bot AND the forwarder bot if both need to read group messages.
- **Testing: use human account, not bot**: When testing the forwarding → Hermes chain, send test messages from a **human Telegram account**, NOT from the forwarder bot. Hermes may filter or ignore messages sent by other bots in groups. Use the Telethon **user client** (`sessions/user`) to send test messages to the source channel, or have the user manually post in the group. Bot-sent test messages can falsely appear as "Hermes not receiving" when the real issue is bot-message filtering.
- **Telethon ignores HTTP_PROXY**: Telethon uses raw TCP sockets for MTProto, not HTTP. You MUST configure proxy at the Telethon client level via the `proxy` parameter. The `HTTP_PROXY` env var alone does NOT work.
- **`network_mode: host`** lets the container reach `127.0.0.1:7890` on the host. Without it, you'd need `extra_hosts: ["host.docker.internal:host-gateway"]` and use that hostname instead.
- **Mihomo mixed-port 7890** supports both HTTP and SOCKS5 on the same port. Use SOCKS5 for Telethon.
- **Session database locked**: If you see `sqlite3.OperationalError: database is locked`, clean `./sessions/*.session*` files and retry. Caused by unclean shutdown.
- **git clone needs proxy**: `http_proxy=http://127.0.0.1:7890 https_proxy=http://127.0.0.1:7890 git clone ...`
- **Phone verification**: First run MUST be interactive (`docker-compose run --rm`, not `up -d`). The code is sent to the user's Telegram. After verification, session file persists in `./sessions/`.
- **Custom image required**: Modifying `requirements.txt` locally doesn't affect the pre-built Docker image. You must build a custom image with `Dockerfile.custom` that installs PySocks AND copies the patched `main.py`.

### Hermes Bot Integration (Forwarded Messages → Agent Processing)

When the user wants forwarded messages to be **processed by Hermes** (e.g., auto-trade on signals), set up the forwarding chain:

1. **Create a destination group** (e.g., "Telegram消息转发") — this is the meeting point
2. **Add the forwarder bot** (MsgForwarder) to the group as admin
3. **Add the Hermes bot** to the group as admin
4. **⚠️ DISABLE PRIVACY MODE on the Hermes bot** via @BotFather → `/mybots` → select bot → Bot Settings → Group Privacy → Turn off. **This step is mandatory** — without it, Hermes cannot see group messages and `free_response_channels` has no effect. **After disabling privacy mode, you MUST kick the bot from the group and re-add it** — the privacy mode change does not take effect for bots already in a group. Verify with `getMe` API: `can_read_all_group_messages` must be `true`.
5. **Configure `free_response_chats`** in `~/.hermes/config.yaml`:
   ```yaml
   telegram:
     extra:
       free_response_chats: '<destination_group_chat_id>'
   ```
   This lets Hermes respond to messages in that group **without requiring @mention**.
6. **Restart Hermes** for config to take effect. Run `hermes gateway restart` from a separate shell (not from inside the gateway).

**⚠️ CRITICAL PITFALL**: The config key is `free_response_chats` inside `telegram.extra`, NOT `free_response_channels` at the top level! The Hermes source code reads `self.config.extra.get("free_response_chats")`. Wrong key = silently ignored, bot only responds to @mentions.

```yaml
telegram:
  extra:
    free_response_chats: '-100XXXXXXXXXX'  # ← correct key
  # free_response_channels: '...'  ← WRONG, ignored by code
```

**⚠️ Pitfall**: `require_mention: true` (default) means Hermes ignores group messages unless @mentioned. Without correct `free_response_chats`, forwarded messages are silently ignored.

**⚠️ Pitfall**: After changing bot privacy mode in BotFather, you must kick the bot from the group and re-add it for the change to take effect.

**⚠️ Pitfall**: Don't reuse the Hermes bot token as the forwarder's BOT_TOKEN — they'll conflict. Use a separate @BotFather bot for the forwarder.

**⚠️ Pitfall**: If the destination group was ever migrated from a basic group to a supergroup, the chat ID changed. See "Group migrated to supergroup" pitfall above. Update BOTH the forwarder DB and `free_response_channels`.

### End-to-End Verification

After setting up the forwarding → Hermes chain, verify each link:

1. **Forwarder listening**: `docker logs telegram-forwarder 2>&1 | grep "收到新消息"` — should show messages from source channel
2. **Forwarder sending**: `docker logs telegram-forwarder 2>&1 | grep "已发送"` — should show successful sends to target group
3. **Hermes receiving**: `hermes logs 2>&1 | grep "inbound message"` — should show messages from the group (chat ID should match the group, not the user DM)
4. **Hermes responding**: Check the group for Hermes's reply

**Common failure chain**:
- Forwarder logs show "收到新消息" but no "已发送" → keyword filter issue or invalid Peer (check group ID)
- Forwarder logs show "已发送" but Hermes shows no inbound from group → privacy mode still on, or `free_response_channels` wrong
- Hermes shows inbound but no response → check `require_mention` and `free_response_channels`

**⚠️ For testing, use a human account** (not the forwarder bot) to send messages to the source channel or directly to the group. Bot-sent messages may be filtered by Hermes.

**⚠️ STRICT REGEX SILENTLY BLOCKS ALL SIGNALS (2026-06-25 discovered)**: If the whitelist keyword is a strict regex like `【币种】[\\s\\S]*【方向】[\\s\\S]*【仓位】`, ANY message that doesn't contain ALL those keywords in order gets silently dropped. The forwarder logs show "未匹配到普通白名单关键词，不转发" but there's NO alert to the user. **This happened in production** — 39 signals were blocked over 8 hours before the user noticed. **Root cause**: the source channel changed its message format (added `【field】: value` with colons, changed trader name format), but the forwarder filter still required the old keyword pattern. **Lesson**: Always use `.*` for agent-side filtering. If you must filter at the forwarder level, add monitoring: periodically check `docker logs telegram-forwarder | grep -c "未匹配"` to detect mass-blocked messages.

**⚠️ Agent-Side Filtering Pattern (Verified 2026-06-25 in production)**: Set forwarder to pass ALL messages (`.*` whitelist) and let the Hermes agent classify via `channel_prompts`. This session proved this pattern works: forwarder passes everything, agent prompt classifies messages into A(交易信号)/B(确认取消)/C(平仓)/D(非交易) and only processes A/B/C. If source format changes, only the agent prompt needs updating, not the forwarder database. Full template in `okx-auto-position` skill's `references/channel-prompts-template.md`.

**⚠️ Privacy mode off but bot still can't see group messages**: If `getMe` shows `can_read_all_group_messages: true` but Hermes still doesn't receive group messages after re-adding the bot and restarting the gateway, try these steps in order:
1. Verify the bot is actually IN the group: use `iter_participants` via Telethon or `getChatMember` API
2. Check `allowed_chats` in Hermes config — if set, the group ID must be listed
3. Have a **human** (not a bot) send a message in the group — bot-to-bot messages are filtered by Telegram itself
4. Try `@Mike94_HermesBot hello` (explicit @mention) to test if the bot receives mentioned messages at all
5. If @mentions work but regular messages don't, the issue is likely a Telegram API propagation delay for the privacy mode change — wait 5-10 minutes and try again
6. As a last resort, create a brand new bot via @BotFather with privacy mode off from the start

### Direct Database Keyword Management

When bot commands aren't accessible (e.g., from agent), manage keywords directly in SQLite.

**⚠️ `sqlite3` CLI is NOT available in the container.** Always use Python's `sqlite3` module inside the container, OR copy the DB out to host:

**Option A: Edit from host (recommended for complex changes)**:
```bash
docker cp telegram-forwarder:/app/db/forward.db /tmp/forward.db
sqlite3 /tmp/forward.db "DELETE FROM keywords WHERE rule_id=1 AND is_blacklist=0;"
sqlite3 /tmp/forward.db "INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (1, '.*', 1, 0);"
docker cp /tmp/forward.db telegram-forwarder:/app/db/forward.db
docker restart telegram-forwarder
```

**Option B: Edit inside container (Python)**:
```bash
docker exec telegram-forwarder python3 -c "
import sqlite3
conn = sqlite3.connect('/app/db/forward.db')
c = conn.cursor()
c.execute('INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (1, \\\"未知\\\", 0, 1)')
conn.commit()
conn.close()
"
docker restart telegram-forwarder
```

**⚠️ Option A pitfall**: `docker cp` out while container is running works for READING, but WRITING back may give `attempt to write a readonly database` if SQLite WAL mode is active. Safer to stop the container first, copy back, then start. Or use `docker exec` with Python (Option B) which handles the lock correctly.

**⚠️ The DB file is locked while the container runs.** If you need to edit from the host (not via `docker exec`), stop the container first: `docker stop telegram-forwarder`, edit `./db/forward.db`, then `docker start telegram-forwarder`. Writing from host while container is up gives `attempt to write a readonly database`.

**DB Schema**:
- `forward_rules` — source→dest mappings, filter modes, templates
- `keywords` — `(id, rule_id, keyword, is_regex, is_blacklist)` where `is_blacklist=1` means skip matching messages, `is_regex=1` enables regex mode
- `replace_rules` — regex find/replace patterns
- `chats` — `(id, telegram_chat_id, name, current_add_id)` — column is `telegram_chat_id`, NOT `chat_id`
- **Multiple white keywords = OR logic** (any match triggers forwarding). For AND (all must match), use a single regex. See "Whitelist Regex" section below.

### Whitelist Regex: Let Everything Through (Recommended for Agent-Side Filtering)

**When the downstream agent (Hermes) handles message classification**, set the forwarder to pass ALL messages and let the agent decide what to process. This is more robust than maintaining regex patterns in the forwarder — if the source channel's message format changes, you only need to update the agent's prompt, not the forwarder's database.

```bash
# Set whitelist regex to .*=match everything
docker cp telegram-forwarder:/app/db/forward.db /tmp/forward.db
sqlite3 /tmp/forward.db "DELETE FROM keywords WHERE rule_id=1 AND is_blacklist=0;"
sqlite3 /tmp/forward.db "INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (1, '.*', 1, 0);"
docker cp /tmp/forward.db telegram-forwarder:/app/db/forward.db
docker restart telegram-forwarder
```

**Agent-Side Filtering** via `channel_prompts` in Hermes config.yaml:
```
A类(交易信号) → 处理
B类(确认/取消) → 处理
C类(平仓) → 处理
D类(非交易消息) → 忽略，不回复
```

**Cross-platform push from channel_prompts** (`send_message` is NOT agent-callable — use `hermes send` instead):

The Hermes internal `send_message` tool is **intentionally NOT registered as an agent-callable model tool**. Old channel_prompts that reference `send_message` will silently fail. The correct approach is to use `terminal("hermes send -t qqbot 'message'")` in the prompt:

```yaml
telegram:
  channel_prompts:
    '-1003966251111': '你是交易信号处理器。解析【币种】【方向】等字段。然后通过 terminal("hermes send -t qqbot '"'"'信号摘要+建议操作'"'"'") 推送到QQ。不要在群里回复。'
```

See the `cross-platform-push` skill for full details on `hermes send` vs cronjob deliver methods.

### Whitelist Regex: Require Multiple Keywords (AND Logic — Only If Filtering at Forwarder Level)

**⚠️ Only use this approach if you need to filter at the forwarder level.** For agent-side filtering, use `.*` above.

To forward ONLY messages containing ALL of several keywords (e.g., 【币种】【方向】【仓位】):

**⚠️ CRITICAL PITFALL: Do NOT use lookahead syntax `(?=.*A)(?=.*B)(?=.*C)` with Chinese brackets `【】`!**

The TelegramForwarder regex engine fails silently on lookahead patterns with Chinese full-width brackets. The log shows "未匹配到普通白名单关键词" even though the keywords are clearly present. Use **sequential matching** instead:

```bash
docker exec telegram-forwarder python3 -c "
import sqlite3
conn = sqlite3.connect('/app/db/forward.db')
c = conn.cursor()

# Switch rule to WHITELIST mode
c.execute(\"UPDATE forward_rules SET forward_mode='WHITELIST', add_mode='WHITELIST' WHERE id=1\")

# Clear old keywords
c.execute('DELETE FROM keywords WHERE rule_id = 1')

# ✅ CORRECT: Sequential match with [\s\S]* (matches newlines)
regex = '【币种】[\\s\\S]*【方向】[\\s\\S]*【仓位】'
c.execute('INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (1, ?, 1, 0)', (regex,))

# Add blacklist: exclude messages with '未知'
c.execute(\"INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (1, '未知', 0, 1)\")

conn.commit()
conn.close()
\"
docker restart telegram-forwarder
```

**Key rules**:

| Approach | Pattern | Result |
|----------|---------|--------|
| ❌ Lookahead (broken) | `(?=.*【币种】)(?=.*【方向】)(?=.*【仓位】)` | 未匹配，不转发 |
| ✅ Sequential (works) | `【币种】[\s\S]*【方向】[\s\S]*【仓位】` | 匹配成功 |
| ❌ Dot-star (newline issue) | `【币种】.*【方向】.*【仓位】` | 不匹配跨行消息 |

**Why `[\s\S]*` not `.*`**: Messages have newlines between fields. `.` doesn't match `\n` by default. `[\s\S]` matches any character including newlines.

**Why not separate keywords**: Multiple white keywords in the `keywords` table are evaluated with **OR logic** — matching ANY one keyword triggers forwarding. To require ALL keywords, you MUST use a single regex pattern. See the filter chain source at `/app/utils/common.py` lines ~428-460.

**Keyword order matters**: The sequential pattern assumes keywords appear in a fixed order (e.g., 币种→方向→仓位). If the source channel always formats messages in the same order (which trading signal channels typically do), this is fine. If order varies, you'd need multiple regex patterns joined with `|` for each permutation.

## Adding a New Source Channel (Post-Deploy)

When you need to bind an additional Telegram source to an existing forwarder (same target group), here is the complete process:

### Step-by-Step

**1. Resolve the entity** (find its Telegram chat ID):

Run INSIDE the container while it is running (entity resolution does not need the DB):

```bash
docker exec telegram-forwarder python3 -c "
import os, asyncio, socks
os.environ['API_ID'] = '37679203'
os.environ['API_HASH'] = '514a2ba8b7898365c9070da4739e8deb'
from telethon import TelegramClient
from telethon.tl.functions.channels import JoinChannelRequest

async def resolve():
    client = TelegramClient('./sessions/user', int(os.environ['API_ID']), os.environ['API_HASH'],
                            proxy=(socks.SOCKS5, '127.0.0.1', 7890))
    await client.start()
    entity = await client.get_entity('shipanbtc')  # ← replace with target username
    print(f'type={type(entity).__name__} id={entity.id} title={getattr(entity, \"title\", \"\")}')
    await client(JoinChannelRequest(entity))
    print('Joined OK')
    await client.disconnect()

asyncio.run(resolve())
"
```

Output example: `type=Channel id=3432963646 title=链上监控 | 魏神 麻吉 内幕哥`

**⚠️ Proxy format**: Telethon needs `(socks.SOCKS5, '127.0.0.1', 7890)` — use `socks.SOCKS5`, NOT the raw number `5`.

**2. Stop container** (to release session DB lock):

```bash
docker stop telegram-forwarder
```

**⚠️ Session DB lock**: Trying to create a Telethon client that uses the same session file while the container is running fails with `database is locked`. Always stop the container before making DB changes, or resolve the entity first (which can run while the container is up).

**3. Add chat entry + forward rule**:

```bash
docker start telegram-forwarder  # needed so docker exec works
docker exec telegram-forwarder python3 -c "
import os, asyncio, socks, sqlite3
os.environ['API_ID'] = '37679203'
os.environ['API_HASH'] = '514a2ba8b7898365c9070da4739e8deb'
from telethon import TelegramClient
from telethon.tl.functions.channels import JoinChannelRequest

async def resolve_and_bind():
    client = TelegramClient('./sessions/user', int(os.environ['API_ID']), os.environ['API_HASH'],
                            proxy=(socks.SOCKS5, '127.0.0.1', 7890))
    await client.start()
    
    entity = await client.get_entity('shipanbtc')  # ← replace
    try: await client(JoinChannelRequest(entity))
    except: pass
    entity2 = await client.get_entity('shipanbtc')
    entity_id_str = str(entity2.id)
    print(f'Entity: id={entity_id_str} title={getattr(entity2, \"title\", \"\")}')
    
    # Update DB
    conn = sqlite3.connect('/app/db/forward.db')
    c = conn.cursor()
    existing = c.execute('SELECT id FROM chats WHERE telegram_chat_id=?', (entity_id_str,)).fetchone()
    if existing:
        new_chat_id = existing[0]
        print(f'Chat exists: id={new_chat_id}')
    else:
        max_id = c.execute('SELECT MAX(id) FROM chats').fetchone()[0] or 0
        new_chat_id = max_id + 1
        c.execute('INSERT INTO chats (id, telegram_chat_id, name) VALUES (?, ?, ?)',
                  (new_chat_id, entity_id_str, getattr(entity2, 'title', 'New Source')))
        print(f'Inserted chat: id={new_chat_id}')
    
    existing_rule = c.execute('SELECT id FROM forward_rules WHERE source_chat_id=? AND target_chat_id=2', (new_chat_id,)).fetchone()
    if existing_rule:
        print(f'Rule exists: id={existing_rule[0]}')
    else:
        c.execute('PRAGMA table_info(forward_rules)')
        cols = [r[1] for r in c.fetchall()]
        existing = dict(zip(cols, c.execute('SELECT * FROM forward_rules WHERE id=1').fetchone()))
        insert_cols = [k for k in cols if k != 'id']
        vals = [existing[k] if k != 'source_chat_id' else new_chat_id for k in insert_cols]
        col_str = ', '.join(insert_cols); ph = ', '.join(['?'] * len(vals))
        c.execute(f'INSERT INTO forward_rules ({col_str}) VALUES ({ph})', vals)
        new_rule_id = c.lastrowid
        for kw in c.execute('SELECT keyword, is_regex, is_blacklist FROM keywords WHERE rule_id=1').fetchall():
            c.execute('INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (?, ?, ?, ?)',
                      (new_rule_id, kw[0], kw[1], kw[2]))
        print(f'Created rule id={new_rule_id}')
    
    conn.commit(); conn.close()
    print('Done')
    await client.disconnect()

asyncio.run(resolve_and_bind())
"
docker stop telegram-forwarder
```

**4. Restart**:

```bash
docker start telegram-forwarder
sleep 3
docker logs telegram-forwarder --tail 5
# Expect: "用户客户端已启动" + "机器人客户端已启动"
```

**5. Verify**:

```bash
docker exec telegram-forwarder python3 -c "
import sqlite3
conn = sqlite3.connect('/app/db/forward.db')
c = conn.cursor()
for r in c.execute('''SELECT fr.id, c1.name as source, c2.name as target, fr.enable_rule
  FROM forward_rules fr LEFT JOIN chats c1 ON fr.source_chat_id=c1.id
  LEFT JOIN chats c2 ON fr.target_chat_id=c2.id''').fetchall():
  print(f'{\"✅\" if r[3] else \"❌\"} Rule#{r[0]}: {r[1]} -> {r[2]}')
conn.close()
"
```

### DB Schema (Forward Rules)

```sql
chats:           (id INTEGER, telegram_chat_id VARCHAR, name VARCHAR, current_add_id VARCHAR)
forward_rules:   (id INTEGER, source_chat_id INTEGER->chats.id, target_chat_id INTEGER->chats.id,
                   forward_mode VARCHAR, add_mode VARCHAR, enable_rule BOOLEAN, ...)
keywords:        (id INTEGER, rule_id INTEGER->forward_rules.id, keyword TEXT,
                   is_regex BOOLEAN, is_blacklist BOOLEAN)
```

Key relationships:
- `forward_rules.source_chat_id` → `chats.id` (not `telegram_chat_id` directly)
- `forward_rules.target_chat_id` → `chats.id`
- `keywords.rule_id` → `forward_rules.id`
- `chats.telegram_chat_id` holds the resolved numeric entity ID (e.g. `3805472665`)
- Public channels: positive integers, Groups/supergroups: negative (e.g. `-1003966251111`)

### Alternative: Direct DB Edit (Container Stopped)

When you already know the entity ID:

```bash
docker stop telegram-forwarder
docker cp telegram-forwarder:/app/db/forward.db /tmp/forward.db
# Edit /tmp/forward.db with sqlite3
docker cp /tmp/forward.db telegram-forwarder:/app/db/forward.db
docker start telegram-forwarder
```

**⚠️ Copy the DB while container is stopped**. Writing from host while container runs gives `attempt to write a readonly database` (SQLite WAL lock).

### Pitfalls

- **Session DB lock**: The forwarder keeps `sessions/user` locked while running. Any Telethon client using the same session file fails with `database is locked`. Resolve entity first (which does not need the DB), or stop the container before connecting.
- **JoinChannelRequest required**: `get_entity` gives you the entity ID, but the user client does not receive messages until it joins via `JoinChannelRequest`. For public channels this subscribes the client.
- **Multiple white keywords = OR logic**: Separate keyword rows are evaluated as OR. To require ALL keywords, use a single regex row with `[\\s\\S]*` between terms (sequential match, not lookahead).
- **Proxy format**: Telethon proxy parameter is `(socks.SOCKS5, '127.0.0.1', 7890)`. Using raw `5` gives `ValueError: Unknown proxy protocol type: 5`.

## References

- Repo: https://github.com/Heavrnl/TelegramForwarder
- Docker Hub: https://hub.docker.com/r/heavrnl/telegramforwarder
- **Diagnostic script**: `scripts/diagnose_chain.py` — run inside container to check DB config, entity resolution, send permissions, and Hermes config in one shot.
- **Debugging guide**: `references/debugging-guide.md` — systematic step-by-step for "信号没收到" complaints. Follow the chain: source → forwarder → group → Hermes → user.
