lark-channel-molecule

Bridge daemon connecting one Lark / Feishu bot to one Molecule workspace (the target agent). It runs in one of two modes (--mode, default poll):

  • poll (below): the daemon is itself a Molecule workspace — registered delivery_mode="poll", so no public endpoint is needed on either side: the Lark side rides the SDK's WebSocket long connection, the Molecule side rides the platform activity poll.
  • daemon: the connected workspace spawns the bridge as its own channel daemon (runtime#216 contributes.daemons — see Daemon mode below). No second workspace, no self-registration.
 Lark / Feishu cloud                    Molecule platform
┌──────────────────────┐            ┌──────────────────────────────┐
│  DM / group @mention │            │  target workspace (agent)    │
└──────────┬───────────┘            └────────▲──────────┬──────────┘
           │ ws long connection              │ peer A2A │ reply
           │ im.v1.message.receive_v1        │ POST /workspaces/
┌──────────▼──────────────────────────────┐  │   {target}/a2a
│      lark-channel-molecule daemon       ├──┘          │
│  (its own workspace, delivery_mode=poll)◄─────────────┘
└──────────┬──────────────────────────────┘   inbox poll:
           │ im.v1 message reply/create       /workspaces/{id}/activity
┌──────────▼───────────┐                        ?type=a2a_receive
│  originating chat    │
└──────────────────────┘
  • Lark → Molecule: a DM (or a group message that @mentions the bot) is forwarded into the target workspace via send_a2a_message, with the Lark metadata (chat_id, sender open_id, message_id, brand) carried in the payload header.
  • Molecule → Lark: the target's reply lands on the bridge's own inbox poll as a peer_agent message and is delivered back into the originating chat as a threaded reply. Unsolicited target messages go to the session-default chat (most recently active), else LARK_DEFAULT_CHAT_ID, else are logged and dropped.
  • Dedup: a bounded LRU of processed Lark message_ids absorbs Lark's event redelivery. It is persisted in the session store (sessions.json, alongside the in-flight turn), so a daemon restart doesn't re-forward a message it already relayed.

Install

pip install lark-channel-molecule            # + '[qr]' for QR rendering
# molecules-workspace-runtime comes from the org registry:
#   PIP_INDEX_URL=https://git.moleculesai.app/api/packages/molecule-ai/pypi/simple/

Onboarding (create the Lark/Feishu app)

lark-channel-molecule connect

Runs lark-oapi's scan-to-create device grant (RFC 8628): it prints a verification URL — and, with the qr extra installed, an ASCII QR on stdout plus a PNG under the state dir — which you open/scan in the Lark or Feishu mobile app and approve. The flow is dual-brand: it starts on the Feishu accounts domain and switches automatically when the poll reports tenant_brand=lark. On completion the freshly-minted bot's {app_id, app_secret, tenant_brand} is persisted to $LARK_CHANNEL_STATE_DIR/credentials.json (mode 0600, default dir ~/.lark-channel-molecule).

In the platform integration the AGENT runs this same flow and posts the QR image into chat for the user to scan; this CLI is the operator-facing equivalent.

Daemon mode (workspace-launched)

--mode daemon is the channels-as-plugins shape (Lark RFC §2.4): the connected workspace itself spawns the bridge via the plugin manifest's contributes.daemons entry (runtime#216) — the runtime starts it after boot, restarts it on crash, and kills it with the workspace. Differences from poll mode:

  • No self-registration, no heartbeat — the host workspace is already registered; the daemon inherits its identity (WORKSPACE_ID, MOLECULE_WORKSPACE_TOKEN, PLATFORM_URL from the workspace env; LARK_* from workspace secrets).
  • Target defaults to the host workspaceMOLECULE_TARGET_WORKSPACE_ID may be omitted.
  • Replies: a synchronous a2a response (push-delivery hosts) is delivered directly; a queued ack falls back to the inbox-poll path. Known limit: a queued reply for target==host is dropped by the runtime's self-echo guard — covered by the sync path today; the runtime#215 PR-2 event socket is the durable fix.
  • Bootstrap: the plugin dir is delivered as files (never pip-installed), so the manifest's daemon command runs daemon-bootstrap.sh, which idempotently installs the pinned wheel from the org registry and execs python3 -m lark_channel_molecule.daemon --mode daemon. tests/test_manifest_daemon.py keeps plugin.yaml / pyproject / the bootstrap pin in sync.

Env contract

Variable Required Meaning
WORKSPACE_ID yes poll: UUID of the channel workspace this daemon registers + polls as · daemon mode: the HOST workspace (inherited)
PLATFORM_URL yes https://<tenant>.moleculesai.app
MOLECULE_WORKSPACE_TOKEN yes bearer token for the channel workspace
MOLECULE_TARGET_WORKSPACE_ID poll: yes · daemon: no UUID of the agent workspace turns are relayed into (daemon mode defaults to the host workspace)
LARK_APP_ID / LARK_APP_SECRET no* Lark bot credentials (env wins over the creds file)
LARK_BRAND no feishu (default) or lark — overrides the file's tenant_brand
LARK_BOT_OPEN_ID no the bot's own open_id; when set, a group message forwards only if one of its mentions[].id.open_id equals it. Unset ⇒ any-mention fallback, correct under the fleet-default im:message.group_at_msg scope (Lark then only delivers group messages that @mention the bot). Tenants granted the broader im:message.group_msg scope must set this.
LARK_DEFAULT_CHAT_ID no fallback chat for unsolicited target messages
LARK_CHANNEL_STATE_DIR no state dir (creds, sessions.json, QR PNG); default ~/.lark-channel-molecule

* required unless credentials.json exists (written by connect). Missing required vars fail loudly at startup, listing every missing name (exit 2).

Brand axis

brand ∈ {feishu, lark} selects every domain: the OpenAPI base (https://open.feishu.cn vs https://open.larksuite.com — REST + ws) and the accounts domain for the device grant. lark-oapi defaults to Feishu, so the bridge always passes the domain explicitly, derived from the credential tuple (app_id, app_secret, brand). lark_channel_molecule/brand.py owns the mapping; a test greps the package to keep domains out of every other module.

systemd unit example

[Unit]
Description=Lark channel bridge for Molecule workspace
After=network-online.target

[Service]
Environment=WORKSPACE_ID=<channel-ws-uuid>
Environment=PLATFORM_URL=https://<tenant>.moleculesai.app
Environment=MOLECULE_WORKSPACE_TOKEN=<token>
Environment=MOLECULE_TARGET_WORKSPACE_ID=<agent-ws-uuid>
# creds file from `lark-channel-molecule connect` is picked up from
# ~/.lark-channel-molecule/credentials.json (or set LARK_APP_ID/SECRET here)
ExecStart=/usr/local/bin/lark-channel-molecule --log-level INFO
Restart=on-failure
RestartSec=5
User=molecule

[Install]
WantedBy=multi-user.target

Limitations (MVP)

  • Text-only turns — non-text Lark messages (images, files, cards) are logged and skipped; the reply direction is plain text.
  • Global turn serialization — at most one in-flight turn to the target across ALL chats (one turn at a time per bridge). Peer A2A returns no task/queue id to correlate replies on, so the bridge keeps a single persisted in-flight slot and attributes every target reply to it — cross-chat reply misdelivery is structurally impossible. Waiting messages queue in arrival order (per-chat order preserved), in memory. Throughput: the bridge advances one turn per target reply — or per --turn-timeout-secs expiry (default 600), which releases the slot and lets the queue advance — so a slow turn in one chat delays every chat. Fine for a single-team bot; wrong shape for high-fanout deployments. The durable fix is an a2a correlation id echoed by the platform (see the follow-up issues, #2).
  • Group messages relay only when the bot is @mentioned. Without LARK_BOT_OPEN_ID the check is "the message carries any @mention" — exact under the fleet-default im:message.group_at_msg event scope (Lark only delivers group messages that @mention the bot), but over-broad for tenants granted im:message.group_msg (all group traffic): those deployments must set LARK_BOT_OPEN_ID so only true bot-mentions forward.

Follow-ups

Tracked as repo issues #1#4 — see the issue tracker; not duplicated here.

S
Description
Lark (Feishu) channel bridge — connects Lark chats to a Molecule workspace agent (websocket long-connection, poll-mode workspace identity)
Readme 198 KiB
Languages
Python 99.3%
Shell 0.7%