Skip to content
Draft
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
14 changes: 13 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,21 @@ MODEL_ID=claude-sonnet-4-20250514
# TELEGRAM_BOT_TOKEN=123456:ABC-DEF

# Feishu / Lark (optional, for s04_channels.py)
# Create a self-built app at https://open.feishu.cn (or https://open.larksuite.com
# for international Lark), grab App ID + App Secret, and enable "Long Connection"
# (长连接) mode under Event Subscription. Install: pip install lark-oapi
# FEISHU_APP_ID=cli_xxxxxxxx
# FEISHU_APP_SECRET=xxxxxxxx
# FEISHU_DOMAIN=feishu # "feishu" for domestic, "lark" for international
# Inbound mode: ws (long connection, default, recommended) | webhook
# In ws mode the SDK pushes events into the agent loop automatically; in webhook
# mode you expose parse_event behind your own HTTP endpoint.
# FEISHU_MODE=ws
# International Lark? Set to true to use open.larksuite.com instead of open.feishu.cn
# FEISHU_IS_LARK=false
# Optional, webhook mode only: encrypt key for signature verification
# FEISHU_ENCRYPT_KEY=
# Optional: bot open_id, used to filter group messages to only those @-mentioning the bot
# FEISHU_BOT_OPEN_ID=ou_xxxxxxxx

# Heartbeat (optional, for s07_heartbeat_cron.py)
# HEARTBEAT_INTERVAL=1800
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ websockets>=12.0
croniter>=2.0.0
python-telegram-bot>=21.0
httpx>=0.27.0
lark-oapi>=1.7.0
7 changes: 6 additions & 1 deletion sessions/en/s04_channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
- **InboundMessage**: a dataclass that normalizes all platform payloads into one format.
- **Channel ABC**: `receive()` + `send()` is the entire contract.
- **TelegramChannel**: long-polling, offset persistence, media group buffering, text coalescing.
- **FeishuChannel**: webhook-based, token auth, mention detection, multi-type message parsing.
- **FeishuChannel**: two inbound modes -- long connection (WebSocket, default; the lark-oapi SDK dials out to Feishu, so no public URL needed) and webhook (you expose parse_event behind your own HTTP endpoint). Outbound always goes through `im/v1/messages` + tenant token; mention detection, multi-type message parsing.
- **ChannelManager**: registry that holds all active channels.

## Key Code Walkthrough
Expand Down Expand Up @@ -146,6 +146,11 @@ python en/s04_channels.py
# With Feishu -- add to .env:
# FEISHU_APP_ID=cli_xxxxx
# FEISHU_APP_SECRET=xxxxx
# FEISHU_MODE=ws # ws=long connection (default, recommended) | webhook
# FEISHU_IS_LARK=false # true=international Lark, else domestic Feishu
# FEISHU_BOT_OPEN_ID=ou_xx # optional, group chats only respond when @-mentioning the bot
# FEISHU_ENCRYPT_KEY= # optional, webhook-mode signature verification only
# Long-connection mode requires: pip install lark-oapi

# REPL commands
# You > /channels (list registered channels)
Expand Down
148 changes: 143 additions & 5 deletions sessions/en/s04_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
ANTHROPIC_API_KEY=sk-ant-xxxxx
MODEL_ID=claude-sonnet-4-20250514
# Optional: TELEGRAM_BOT_TOKEN, FEISHU_APP_ID, FEISHU_APP_SECRET
# Feishu inbound mode: FEISHU_MODE=ws (long connection, recommended)
# or webhook (you expose parse_event behind your own HTTP endpoint).
"""

import json, os, sys, time, threading
Expand All @@ -35,6 +37,17 @@
except ImportError:
HAS_HTTPX = False

# Feishu long-connection (WebSocket) client -- optional. The lark-oapi SDK
# maintains an outbound WebSocket to Feishu, so no public callback URL is
# needed. Install with: pip install lark-oapi
try:
import lark_oapi as lark
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1 # noqa: F401 (type hint)
HAS_LARK = True
except ImportError:
lark = None # type: ignore[assignment]
HAS_LARK = False

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -376,8 +389,8 @@ def __init__(self, account: ChannelAccount) -> None:
self.app_secret = account.config.get("app_secret", "")
self._encrypt_key = account.config.get("encrypt_key", "")
self._bot_open_id = account.config.get("bot_open_id", "")
is_lark = account.config.get("is_lark", False)
self.api_base = ("https://open.larksuite.com/open-apis" if is_lark
self._is_lark = account.config.get("is_lark", False)
self.api_base = ("https://open.larksuite.com/open-apis" if self._is_lark
else "https://open.feishu.cn/open-apis")
self._tenant_token: str = ""
self._token_expires_at: float = 0.0
Expand Down Expand Up @@ -478,6 +491,122 @@ def parse_event(self, payload: dict, token: str = "") -> InboundMessage | None:
media=media, is_group=is_group, raw=payload,
)

def _ws_bot_mentioned(self, message: Any) -> bool:
"""Whether the bot is @-mentioned in a long-connection event message.

Webhook payloads give mentions as dicts; long-connection events give
typed MentionEvent objects whose .id is a UserId. We check both shapes.
"""
for m in (message.mentions or []):
mid = getattr(m, "id", None)
if mid is not None and getattr(mid, "open_id", None) == self._bot_open_id:
return True
if getattr(m, "key", None) == self._bot_open_id:
return True
return False

def parse_ws_event(self, data: Any) -> InboundMessage | None:
"""Parse a long-connection (WebSocket) P2ImMessageReceiveV1 event.

This is the inbound twin of parse_event: same InboundMessage shape,
different source -- the lark-oapi SDK delivers typed event objects
instead of raw webhook JSON. We reuse _parse_content for the message
body (note: ws events name the field message_type, not msg_type).
"""
try:
event = getattr(data, "event", None)
if event is None:
return None
message = event.message
sender = event.sender
if message is None or sender is None:
return None

user_id = ""
sid = sender.sender_id
if sid is not None:
user_id = sid.open_id or sid.user_id or sid.union_id or ""
chat_id = message.chat_id or ""
chat_type = message.chat_type or ""
is_group = chat_type == "group"

if is_group and self._bot_open_id and not self._ws_bot_mentioned(message):
return None

# Reuse the webhook content parser; ws events use message_type.
text, media = self._parse_content(
{"msg_type": message.message_type, "content": message.content or "{}"}
)
if not text:
return None

raw: dict = {}
if lark is not None:
try:
raw = lark.JSON.marshal(data)
except Exception:
raw = {}
return InboundMessage(
text=text, sender_id=user_id, channel="feishu",
account_id=self.account_id,
peer_id=user_id if chat_type == "p2p" else chat_id,
media=media, is_group=is_group, raw=raw,
)
except Exception as exc:
print(f" {RED}[feishu] ws parse error: {exc}{RESET}")
return None

def start_long_connection(
self, msg_queue: list, q_lock: threading.Lock,
) -> threading.Thread | None:
"""Start the WebSocket long-connection client in a daemon thread.

The SDK maintains an outbound WebSocket to Feishu and auto-reconnects;
no public callback URL, encrypt key, or challenge handshake is needed.
Inbound events are parsed into InboundMessage and pushed into the
shared msg_queue -- the same pipeline Telegram and CLI feed into, so
the agent loop needs no changes. Requires lark-oapi.
"""
if not HAS_LARK:
print(f" {RED}[feishu] Long-connection mode needs lark-oapi: "
f"pip install lark-oapi{RESET}")
return None
if not (self.app_id and self.app_secret):
print(f" {RED}[feishu] Long-connection needs FEISHU_APP_ID + "
f"FEISHU_APP_SECRET{RESET}")
return None

def _on_msg(data: Any) -> None:
inbound = self.parse_ws_event(data)
if inbound is not None:
with q_lock:
msg_queue.append(inbound)
print_channel(f" [feishu/ws] {inbound.sender_id}: "
f"{inbound.text[:80]}")

dispatcher = (lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(_on_msg).build())
domain = ("https://open.larksuite.com" if self._is_lark
else "https://open.feishu.cn")
ws_client = lark.ws.Client(
self.app_id, self.app_secret,
event_handler=dispatcher,
log_level=lark.LogLevel.INFO,
domain=domain,
)

def _run() -> None:
print_channel(f" [feishu/ws] Long connection started for "
f"{self.account_id}")
try:
ws_client.start() # blocking; auto-reconnect handled by SDK
except Exception as exc:
print(f" {RED}[feishu/ws] connection error: {exc}{RESET}")

t = threading.Thread(target=_run, daemon=True, name="feishu-ws")
t.start()
return t

def receive(self) -> InboundMessage | None:
return None

Expand Down Expand Up @@ -711,6 +840,7 @@ def agent_loop() -> None:

fs_id = os.getenv("FEISHU_APP_ID", "").strip()
fs_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
fs_channel: FeishuChannel | None = None
if fs_id and fs_secret and HAS_HTTPX:
fs_acc = ChannelAccount(
channel="feishu", account_id="feishu-primary",
Expand All @@ -722,7 +852,15 @@ def agent_loop() -> None:
},
)
mgr.accounts.append(fs_acc)
mgr.register(FeishuChannel(fs_acc))
fs_channel = FeishuChannel(fs_acc)
mgr.register(fs_channel)

# Feishu inbound mode: long connection (default) vs webhook. In ws mode the
# SDK pushes events into msg_queue through a background thread; in webhook
# mode you must expose parse_event behind your own HTTP endpoint yourself.
fs_mode = os.getenv("FEISHU_MODE", "ws").strip().lower()
if fs_channel is not None and fs_mode == "ws":
fs_channel.start_long_connection(msg_queue, q_lock)

print_info("=" * 60)
print_info(" claw0 | Section 04: Channels")
Expand All @@ -735,12 +873,12 @@ def agent_loop() -> None:
conversations: dict[str, list[dict]] = {}

while True:
# Drain Telegram queue
# Drain the inbound queue (Telegram polling + Feishu long-connection).
with q_lock:
tg_msgs = msg_queue[:]
msg_queue.clear()
for m in tg_msgs:
print_channel(f"\n [telegram] {m.sender_id}: {m.text[:80]}")
print_channel(f"\n [{m.channel}] {m.sender_id}: {m.text[:80]}")
run_agent_turn(m, conversations, mgr)

# CLI input (non-blocking when Telegram is active)
Expand Down
7 changes: 6 additions & 1 deletion sessions/ja/s04_channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
- **InboundMessage**: 全プラットフォームのペイロードを1つのフォーマットに正規化するデータクラス。
- **Channel ABC**: `receive()` + `send()` がコントラクトの全て。
- **TelegramChannel**: ロングポーリング、オフセット永続化、メディアグループバッファリング、テキスト結合。
- **FeishuChannel**: Webhookベース、トークン認証、メンション検出、複数タイプのメッセージ解析。
- **FeishuChannel**: 2つの受信モード -- 長接続 (WebSocket, デフォルト; lark-oapi SDK が飛書へ発信接続するため公開URL不要) と webhook (parse_event を自身のHTTPエンドポイントに公開). 送信は常に `im/v1/messages` + テナントトークン; メンション検出、複数タイプのメッセージ解析。
- **ChannelManager**: アクティブな全チャネルを保持するレジストリ。

## コードウォークスルー
Expand Down Expand Up @@ -143,6 +143,11 @@ python ja/s04_channels.py
# Feishu を使う場合 -- .env に追加:
# FEISHU_APP_ID=cli_xxxxx
# FEISHU_APP_SECRET=xxxxx
# FEISHU_MODE=ws # ws=長接続 (デフォルト, 推奨) | webhook
# FEISHU_IS_LARK=false # true=国際版 Lark, それ以外は国内飛書
# FEISHU_BOT_OPEN_ID=ou_xx # 任意, グループチャットはボットへのメンション時のみ応答
# FEISHU_ENCRYPT_KEY= # 任意, webhook モードの署名検証のみ
# 長接続モードには要インストール: pip install lark-oapi

# REPL コマンド
# You > /channels (登録済みチャネルの一覧)
Expand Down
Loading