feat: portal connector backend (run chx as a portal client) - #14
feat: portal connector backend (run chx as a portal client)#14antra-tess wants to merge 11 commits into
Conversation
Add a second connector backend so the bot can route through the shared portal-relay (via @animalabs/portal-client over WS) instead of holding its own discord.js gateway. Selectable per-bot through config.yaml `connector_backend: portal` (env vars still override). - IConnector interface; DiscordConnector + new PortalConnector implementations - portal .history engine, fetchContext, attachment cache, pins, role auth, inbound event adapters (34 unit tests) - config-driven portal settings (connector_backend/portal_url/portal_invite) read from the bot config.yaml; enroll-once with a persistent creds path so the persona survives restarts/redeploys; channel access is role-based - deps: @animalabs/portal-client/protocol ^0.1.0, membrane ^0.5.67 (OpenRouter reasoning-trace capture + round-trip) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR adds an optional
Confidence Score: 3/5The portal connector is largely solid and tested, but The reply-to-all-chunks bug is a real behavioral divergence from the discord backend: the discord connector explicitly guards the reply reference with
|
| Filename | Overview |
|---|---|
| src/connector/portal/portal-connector.ts | Core PortalConnector implementation; sendMessage passes replyToId to every content chunk instead of only the first, diverging from the discord backend and creating a reply-pile for long responses. |
| src/connector/portal/adapters.ts | Pure shape-translation layer; well-tested. reaction_add case can produce userId: undefined when the by array is empty, which leaks into the agent loop's bot-identity filter. |
| src/connector/types.ts | New IConnector interface and shared types; cleanly extracted from discord/connector.ts with back-compat re-exports. |
| src/connector/portal/context.ts | Portal context assembly mirroring the discord path; snowflake ordering, anchor extension, and attachment handling all look correct. |
| src/connector/portal/history.ts | Portal .history engine ported from discord context-fetch; uses nativeId snowflakes for ordering, relay id for identity — architecture is sound and well-tested. |
| src/connector/util/attachment-cache.ts | Backend-agnostic image/text attachment cache; correctly detects types via magic bytes and caches to disk with a url-map. Defensive error handling throughout. |
| src/main.ts | Config-driven connector selection; portal path handles enroll-once creds correctly. Error message for missing identity is incorrect for the portal backend. |
| src/discord/connector.ts | Migrated to implement IConnector; types moved to connector/types.ts with re-export for back-compat — no behavioral changes. |
| src/api/server.ts | Switched from DiscordConnector to IConnector; added guards for portal mode where Discord-specific guild/user APIs are unavailable. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[main.ts: connector bootstrap] -->|connector_backend == portal| B[PortalConnector]
A -->|connector_backend == discord / default| C[DiscordConnector]
B --> D[IConnector interface]
C --> D
D --> E[AgentLoop]
D --> F[ApiServer]
B --> G[PortalClient websocket]
G -->|PortalEvent| H[queueEventFromPortal adapters.ts]
H -->|Event| I[EventQueue]
I --> E
B --> J[buildPortalContext context.ts]
J --> K[fetchPortalHistory history.ts]
J --> L[AttachmentCache util/attachment-cache.ts]
B --> M[Pin cache bootstrapPins / ensurePins]
M --> N[extractConfigs / extractSteers / extractSleeps util/pin-extract.ts]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[main.ts: connector bootstrap] -->|connector_backend == portal| B[PortalConnector]
A -->|connector_backend == discord / default| C[DiscordConnector]
B --> D[IConnector interface]
C --> D
D --> E[AgentLoop]
D --> F[ApiServer]
B --> G[PortalClient websocket]
G -->|PortalEvent| H[queueEventFromPortal adapters.ts]
H -->|Event| I[EventQueue]
I --> E
B --> J[buildPortalContext context.ts]
J --> K[fetchPortalHistory history.ts]
J --> L[AttachmentCache util/attachment-cache.ts]
B --> M[Pin cache bootstrapPins / ensurePins]
M --> N[extractConfigs / extractSteers / extractSleeps util/pin-extract.ts]
Comments Outside Diff (2)
-
src/connector/portal/portal-connector.ts, line 1204-1212 (link)Multi-chunk reply: all chunks get
replyToId, not just the firstThe discord connector explicitly only applies the reply reference to the first chunk (
if (i === 0 && replyToMessageId)). Here,replyToId: replyToMessageIdis forwarded on every loop iteration, so a split response sends multiple messages all replying to the same original, which creates a reply-pile instead of a sequential thread. This diverges from the Discord backend behavior users and the relay likely expect.Prompt To Fix With AI
This is a comment left during a code review. Path: src/connector/portal/portal-connector.ts Line: 1204-1212 Comment: **Multi-chunk reply: all chunks get `replyToId`, not just the first** The discord connector explicitly only applies the reply reference to the first chunk (`if (i === 0 && replyToMessageId)`). Here, `replyToId: replyToMessageId` is forwarded on every loop iteration, so a split response sends multiple messages all replying to the same original, which creates a reply-pile instead of a sequential thread. This diverges from the Discord backend behavior users and the relay likely expect. How can I resolve this? If you propose a fix, please make it concise.
-
src/main.ts, line 178-179 (link)The error message hard-codes "Discord" even when the portal backend is active. If the portal connector returns no persona ID (e.g. on a failed enroll), the operator sees a misleading error pointing at Discord instead of the relay.
Prompt To Fix With AI
This is a comment left during a code review. Path: src/main.ts Line: 178-179 Comment: The error message hard-codes "Discord" even when the portal backend is active. If the portal connector returns no persona ID (e.g. on a failed enroll), the operator sees a misleading error pointing at Discord instead of the relay. How can I resolve this? If you propose a fix, please make it concise.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
src/connector/portal/portal-connector.ts:1204-1212
**Multi-chunk reply: all chunks get `replyToId`, not just the first**
The discord connector explicitly only applies the reply reference to the first chunk (`if (i === 0 && replyToMessageId)`). Here, `replyToId: replyToMessageId` is forwarded on every loop iteration, so a split response sends multiple messages all replying to the same original, which creates a reply-pile instead of a sequential thread. This diverges from the Discord backend behavior users and the relay likely expect.
### Issue 2 of 3
src/connector/portal/adapters.ts:115-120
`e.reaction.by[0]?.id` is `undefined` when the `by` array is empty. A reaction with no known reactor then has `userId: undefined`, which silently passes the "skip bot's own reaction" check in the agent loop (`reaction.userId === this.botUserId` is `false` for `undefined`) and is logged with `userId: undefined`. Guard with a fallback to avoid ambiguity.
```suggestion
data: {
messageId: e.messageId,
emoji: e.reaction.emoji,
userId: e.reaction.by[0]?.id ?? 'unknown',
messageAuthorId: ctx.authorOf(e.messageId),
},
```
### Issue 3 of 3
src/main.ts:178-179
The error message hard-codes "Discord" even when the portal backend is active. If the portal connector returns no persona ID (e.g. on a failed enroll), the operator sees a misleading error pointing at Discord instead of the relay.
```suggestion
if (!botUserId || !botUsername) {
throw new Error(`Failed to get bot identity from ${backend === 'portal' ? 'portal relay' : 'Discord'}`)
```
Reviews (1): Last reviewed commit: "feat: portal connector backend (run chx ..." | Re-trigger Greptile
| data: { | ||
| messageId: e.messageId, | ||
| emoji: e.reaction.emoji, | ||
| userId: e.reaction.by[0]?.id, | ||
| messageAuthorId: ctx.authorOf(e.messageId), | ||
| }, |
There was a problem hiding this comment.
e.reaction.by[0]?.id is undefined when the by array is empty. A reaction with no known reactor then has userId: undefined, which silently passes the "skip bot's own reaction" check in the agent loop (reaction.userId === this.botUserId is false for undefined) and is logged with userId: undefined. Guard with a fallback to avoid ambiguity.
| data: { | |
| messageId: e.messageId, | |
| emoji: e.reaction.emoji, | |
| userId: e.reaction.by[0]?.id, | |
| messageAuthorId: ctx.authorOf(e.messageId), | |
| }, | |
| data: { | |
| messageId: e.messageId, | |
| emoji: e.reaction.emoji, | |
| userId: e.reaction.by[0]?.id ?? 'unknown', | |
| messageAuthorId: ctx.authorOf(e.messageId), | |
| }, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/connector/portal/adapters.ts
Line: 115-120
Comment:
`e.reaction.by[0]?.id` is `undefined` when the `by` array is empty. A reaction with no known reactor then has `userId: undefined`, which silently passes the "skip bot's own reaction" check in the agent loop (`reaction.userId === this.botUserId` is `false` for `undefined`) and is logged with `userId: undefined`. Guard with a fallback to avoid ambiguity.
```suggestion
data: {
messageId: e.messageId,
emoji: e.reaction.emoji,
userId: e.reaction.by[0]?.id ?? 'unknown',
messageAuthorId: ctx.authorOf(e.messageId),
},
```
How can I resolve this? If you propose a fix, please make it concise.Brings the mention-aware pinned-command matcher (.config / .sleep / .steer) onto the portal branch and wires the portal backend: TrackedPin/PinnedSteer carry resolved persona/role mentions; portalMessageToTrackedPin populates them from PortalMessage.mentions; the portal pin-extract resolves .config targets against the persona identity (strip-on-match); PortalConnector.getOwnRoleIds returns null (portal targets via persona mention). IConnector gains getOwnRoleIds and the PinnedSteer steer return type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Portal personas have no Discord user id (getBotUserId returns the relay persona id), so charging them via the Discord-user-keyed botId could never match a cost row set by an admin picking the portal-<name> role. Add IConnector.isPortal() and, when true, send the EMS bot name (this.botId) as the checkAndDeduct botId instead of botUserId. This matches the cost key the infra admin commands resolve from the portal role, and is uniform with how Phase 1 /sleep keys portal bots. Account bots stay keyed by Discord user id. Latent until a portal bot has soma enabled; pairs with the infra cost-command change keying portal bots by EMS name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ions, identity error label
sendMessage: only the first chunk replies to the trigger (matches DiscordConnector at connector.ts:1086). Previously replyToId was forwarded on every chunk, so a split response piled multiple replies on the original message.
reaction_add adapter: drop events with no known reactor (empty reaction.by) instead of emitting userId:undefined — which bypassed the agent loop skip-own-reaction check (userId === botUserId is false for undefined) and let the bot react to its own/unattributable reactions. Adds a test.
main.ts: the identity-failure error now names the active backend ("portal relay" vs "Discord") instead of hard-coding Discord, so a failed portal enroll points at the relay.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reply-ping hard rule (1035c11) made replies activate only when the bot lands in message.mentions. That holds for account bots (Discord adds the replied-user) but not for portal personas: webhooks can't carry a native reply/ping, so a reply-ping never appears in mentions — it only shows up as the relay's per- recipient 'reply' address reason. The adapter was discarding that AddressInfo, so after 1035c11 portal bots could no longer be woken by any reply. - Carry AddressInfo (addressedToMe/reasons) from the PortalEvent through the adapter onto InboundMessage._address. - Add a portal-only activation branch: a 'reply' reason wakes the bot, with the same bot-reply-chain-depth guard as the mention path. Account/discord.js events carry no _address, so the hard rule is unchanged for them. - Trace-reason labeling recognizes the portal 'reply' reason (authoritative across restarts, unlike the in-memory botMessageIds set). Restores pre-hard-rule reply behavior for portal only. The relay can't encode the ping toggle for personas, so any reply to one of our messages counts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Picks up the resilient-reconnect fix (jitter + emit failure-isolation) in @animalabs/portal-client 0.2.1. Compatibility verified: connector typechecks and portal tests pass against 0.2.x; 0.1→0.2 protocol changes are additive and the 0.1.0 clients already interoperate with the 0.2.x relay. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The relay addresses personas via pooled Discord roles named portal-<name>, so a resolved role mention in cleanContent reads "@portal-glm52" — addressing plumbing that's noise to the model. Strip the prefix in portalMessageToDiscordMessage so the model sees "@GLM52". Text-only rewrite of the readable rendering; a no-op on raw <@&id> content and on ordinary @mentions. Portal-specific (the prefix only exists in portal contexts), so it lives on the portal branch — main account bots never see these roles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ution + @name dot-filter)
Portal persona enrollment previously always used BOT_NAME (the EMS directory name) as the desiredName, forcing display identity to match the bot's slug. Add config + env override so a persona whose display name differs from its slug (e.g., "Claude Sonnet 5 CHX" for BOT_NAME=sonnet5) can be enrolled with the intended name. Precedence: PORTAL_DESIRED_NAME env > portal_desired_name config > BOT_NAME. Only consulted at first enrollment; existing personas keep whatever name they were originally enrolled with. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Adds an optional second connector backend so a Chapter3 bot can route through the shared portal-relay (via
@animalabs/portal-clientover a websocket) instead of holding its own discord.js gateway connection — letting many agents share one bot slot. The backend is selectable per-bot viaconfig.yaml(connector_backend: portal); the default remains the bot's own discord.js gateway, fully unchanged.Live in production: a GLM-5.2 bot (
glm52) is running on this backend through the prod relay.What's in it
IConnectorinterface (src/connector/types.ts) — the exact surface the agent loop / API / sleep logic depend on.DiscordConnectorimplements it with zero behavioral changes (a discord.jsMessagestructurally satisfies the two narrowed signatures).PortalConnector(src/connector/portal/) wrappingPortalClient:PortalMessage/PortalEvent→ the discord.js-Message-shaped objects the loop reads offevent.data(incl. persona-role-mention → "bot mentioned" translation)..historyengine ported toPortalMessage(identity = relay id, ordering/boundaries =nativeIdsnowflake), reusing the pure.historyparsers.fetchContext(history → thread-parent → cache-anchor → attachments) + a standaloneAttachmentCache..config/.steer/.sleepvialist_pins+pins_update) and role-name auth (list_roles+list_members).fileFromBytes; outbound mention normalization.config.yaml(connector_backend,portal_url,portal_invite, optionalportal_creds_path); env vars override. Enroll-once with a persistent creds path (defaults to the EMS bot dir) so the persona survives restarts/redeploys. Channel access is role/permission-based (no subscriptions).Dependencies
@animalabs/portal-client/@animalabs/portal-protocol^0.1.0(published)@animalabs/membrane^0.5.67(OpenRouter reasoning-trace capture + round-trip)Testing
.historyengine, pin classification).fetch_history/.history,list_pins/list_members/list_roles, send/edit/react,fileFromBytesupload, inbound delivery, and an end-to-end GLM-5.2 conversation.Notes / known degradations (documented in code)
pinMessageis a no-op (portal has no pin-mutation RPC).cleanContent(@name) rather than<@name>brackets.streaming: falsein the bot config (provider streaming mangles reasoning/content); non-streaming is clean.Related (separate, not in this PR)
m @botcan delete the user's trigger) lives inanima-research/portaland needs its own commit/PR + the bot grantedManage Messages.🤖 Generated with Claude Code