Skip to content

feat(events): per-resource subscription identity + Match hook + mail - #1184

Closed
liuxinyanglxy wants to merge 21 commits into
larksuite:mainfrom
liuxinyanglxy:feat/event-subscription-id
Closed

feat(events): per-resource subscription identity + Match hook + mail#1184
liuxinyanglxy wants to merge 21 commits into
larksuite:mainfrom
liuxinyanglxy:feat/event-subscription-id

Conversation

@liuxinyanglxy

@liuxinyanglxy liuxinyanglxy commented May 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Mail (and any future per-resource EventKey such as drive comments or task updates) subscribes at a finer granularity than IM: its subscription identity is (EventKey, mailbox), not EventKey alone. The current bus keys dedup on EventKey only, so a second event consume against the same key with a different mailbox is silently skipped (PreConsume never fires, the consumer hangs with no error). Cleanup failures are also swallowed. This PR lifts the framework from one-dimensional to two-dimensional subscription identity, adds a sync Match filter and a NormalizeParams hook, surfaces cleanup errors, and adapts the mail EventKey end-to-end as the proving ground. Raised in response to the review on #851.

Changes

  • Add ParamDef.SubscriptionKey, KeyDefinition.NormalizeParams, KeyDefinition.Match; change PreConsume cleanup from func() to func() error in internal/event/types.go
  • Add ComputeSubscriptionID (sha256 + base64url, 16-char) in internal/event/consume/fingerprint.go
  • Thread SubscriptionID through the wire: Hello, PreShutdownCheck, ConsumerInfo in internal/event/protocol/messages.go
  • Key Hub dedup by SubscriptionID (was EventKey); add SubCount; preserve EventKeyCount as cross-subscription aggregate in internal/event/bus/hub.go, conn.go, bus.go
  • Wire NormalizeParams + ComputeSubscriptionID into the consume run, plumb SubscriptionID through checkLastForKey, surface cleanup errors with an idempotency note in internal/event/consume/{consume,handshake,loop,shutdown}.go
  • Render SUB-KEY column in cmd/event/schema.go and SUB column in cmd/event/status.go
  • Adapt the mail EventKey: union payload schema, me alias normalization (/user_mailboxes/me/profile), mailbox Match, folders/labels filter + msg-format enrichment Process in events/mail/{payload,normalize,match,process,register}.go
  • Document subscription identity, cleanup semantics, and the mail EventKey under skills/lark-event/; remove the mail-domain +watch skill surface so event subscription converges under lark-event (IM-style)
  • Wire compatibility: empty SubscriptionID falls back to EventKey, so old consumer ↔ new daemon and new consumer ↔ old daemon both degrade to today's single-dimension behavior

Test Plan

  • make unit-test passed (all internal/event/..., events/..., cmd/event/... green with -race)
  • validate passed (build + vet + unit + integration + convention + security)
  • local-eval passed (E2E 7/7 for this change; sandbox total 157/16, the 16 are pre-existing failures unrelated to this diff; skillave 5/5)
  • acceptance-reviewer passed (7/7 scenarios)
  • manual verification: lark-cli event consume mail.user_mailbox.event.message_received_v1 -p mailbox=me -p msg-format=metadata --max-events 1 --as user — normalize resolved me, real mail event emitted with from/subject/snippet, cleanup ran cleanly; event schema mail.xxx shows the SUB-KEY column; event status shows the SUB column

Related Issues

N/A

Summary by CodeRabbit

Release Notes

  • New Features

    • Added mail event consumption (mail.user_mailbox.event.message_received_v1) with mailbox selection, folder/label filtering, and configurable message formats.
    • Introduced per-subscription identity tracking for events with subscription-key parameters, enabling distinct subscriptions per parameter combination (e.g., per-mailbox mail subscriptions).
    • Enhanced event status with new "SUB" column displaying subscription identifiers.
    • Extended event schema with "SUB-KEY" column indicating subscription-identifying parameters.
  • Documentation

    • Added comprehensive mail event reference guide with consumption examples and filter recipes.
    • Updated event skill documentation with mail event usage and per-subscription behavior.
  • Chores

    • Removed legacy mail watch functionality in favor of event-based mail notifications.

… cleanup to func() error

Change-Id: I23ba07c089b2c0eb3e42451d8118849a1c5f7a9d
…dedup

Change-Id: Iaeb09c15c754c9e4ca8ce9784623f82f55fe308b
…nsumerInfo

Add SubscriptionID field (omitempty) to Hello, PreShutdownCheck, and ConsumerInfo.
Update NewHello and NewPreShutdownCheck constructors to accept subscriptionID param;
patch all call sites with "" placeholder pending Tasks 7/8.

Change-Id: I469a1fb0de2c31326513feef5693d8097dfc8415
Add subID field to Conn, update NewConn signature to accept it, add
SubscriptionID() getter (falls back to EventKey when empty), update
handleControlMessage PreShutdownCheck branch to use message SubscriptionID
with EventKey fallback, and wire hello.SubscriptionID through handleHello.

Change-Id: Iea9195dd2d343fd51c84317e4d7f2d5d8067d51d
Change-Id: I3cc81e8440656470f92f1b50074b840b0875c781
Change-Id: Ieb7c2237621a84721b6fc73967838d9caec2a837
…o Run

Change-Id: Ifd43aa2a82c315ad3d496a39ce9f0dbdd43f3bb8
…sumeLoop

Change-Id: Ib40b148c45f546ca112d30976749f42fae1f877a
…note

Change-Id: I121ec0d3e7d50f3ebb728f47f1f54d89322e0143
Run keyDef.Match(raw, params) at the top of processAndOutput before any
Process/sink work; drop the event (return false, nil) when it returns false.
Move the RawEvent allocation above the Match guard so both Match and Process
share the same struct. Three new unit tests cover drop, nil-accepts-all,
and run-before-process ordering.

Change-Id: I21571b3da91a9a918903b6fc3a6ff897eb83a747
Change-Id: I2f88774c58719af0419c471f40de1bf12242a5ab
Add a SUB column to the human-readable consumer table in `event status`.
Renders the fingerprint hash suffix (after the colon) when present;
falls back to "-" for legacy consumers where SubscriptionID is empty
or equal to EventKey.

Change-Id: Ia6271475e562844fc275f3751c64ca408ebda82c
Change-Id: Ie880d12e5dda73925ccaadc60ff8d3acda401dd6
Change-Id: I02cca113d50067e9a5964b896b2bc84210d2a35b
Change-Id: I3dcd481fa93ce8f5dbde1e065aa00c18330265c0
Change-Id: I99c64618900dde0b5420ce8a4f5c088960098e66
…ork hooks

Adds events/mail/register.go with Keys() exposing the mail.user_mailbox.event.message_received_v1
EventKey: mailbox (SUB-KEY), folders, labels, msg-format params; wires NormalizeParams,
PreConsume (subscribe/unsubscribe), Match, and Process hooks. Updates events/register.go to
include mail.Keys() alongside im.Keys().

Note: Schema.Custom is used (not Native) because processMailEvent produces the complete output
shape — Schema.Native is incompatible with Process per registry validation.

Change-Id: Ibf48dc19dee5db65730810b0f2e4b5ebed73c4f0
Change-Id: I14636cae6a459cdf5a82455bc18022d5edd6c5ce
…ddress

Change-Id: I03d0780aaac0fd7d59c1395df6912f5bb043f996
Change-Id: If46580dff10e15a56858157dbcdfb79f2c43d227
The mail message_received event's subscriber field is a
{user_ids:[{user_id,open_id,union_id}]} object per the Feishu SDK
(P2UserMailboxEventMessageReceivedV1Data.Subscriber), not a string.
Decoding it as string made Process fail at runtime with
'cannot unmarshal object into Go struct field .event.subscriber of
type string', silently dropping every mail event.

Change-Id: I91dcd7e82659e3962a2530fae8d5ee34fe40dbf6
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces comprehensive mail event support to the Lark CLI event system. It refactors the event bus to track subscriptions by stable subscription ID instead of event key, implements mail event processing with filtering and enrichment, and adds CLI display and documentation for the new feature.

Changes

Mail Event Support with Subscription-ID Tracking

Layer / File(s) Summary
Protocol messages and event types with subscription ID support
internal/event/types.go, internal/event/protocol/messages.go, internal/event/protocol/messages_test.go, internal/event/protocol/codec_test.go
ParamDef gains SubscriptionKey field marking identity-scoping parameters, PreConsume hook returns cleanup func() error, and Hello/PreShutdownCheck/ConsumerInfo messages add subscription_id fields for per-subscription tracking throughout the protocol.
Subscription ID fingerprinting and computation
internal/event/consume/fingerprint.go, internal/event/consume/fingerprint_test.go
ComputeSubscriptionID computes stable deduplication IDs from SubscriptionKey parameters via SHA256 hashing and base64 encoding, falling back to event key when no subscription params exist.
Event bus refactoring for subscription-based tracking
internal/event/bus/hub.go, internal/event/bus/conn.go, internal/event/bus/bus.go, internal/event/bus/conn_test.go, internal/event/bus/handle_hello_test.go, internal/event/bus/hub_test.go, internal/event/bus/hub_observability_test.go, internal/event/bus/hub_publish_race_test.go, internal/event/bus/bus_shutdown_test.go
Hub switches from event-key to subscription-id tracking for registration/unregistration, cleanup locks, and consumer counting; Conn stores and exposes subscription ID with fallback to event key; handshake passes subscription ID to bus.
Consumer loop integration with subscription ID
internal/event/consume/consume.go, internal/event/consume/handshake.go, internal/event/consume/loop.go, internal/event/consume/shutdown.go, internal/event/consume/consume_test.go, internal/event/consume/handshake_test.go, internal/event/consume/loop_test.go, internal/event/consume/shutdown_test.go
Normalize parameters before subscription ID computation, pass subscription ID through handshake and loop, apply Match filter early before Process, and use subscription ID in shutdown coordination.
Mail event definition, normalization, and filtering
events/mail/payload.go, events/mail/normalize.go, events/mail/match.go, events/mail/normalize_test.go, events/mail/match_test.go
Define MailReceivedPayload schema with msg-format-dependent fields; normalize mailbox parameter via me profile API lookup and trimming; filter events by mailbox address matching.
Mail event processing with fetching and enrichment
events/mail/process.go, events/mail/process_test.go
Process mail events by conditionally fetching message details based on msg-format, applying folder/label filtering (OR/AND semantics respectively), and enriching payload with metadata/plaintext/full fields.
Mail event registration and subscription lifecycle
events/mail/register.go, events/mail/register_test.go, events/register.go
Register mail event with parameter schema, required OAuth scopes, normalization/match/process/pre-consume hooks, and lifecycle including per-mailbox subscription and graceful unsubscribe cleanup.
CLI status and schema display updates
cmd/event/format_helpers_test.go, cmd/event/schema.go, cmd/event/schema_test.go, cmd/event/status.go
Add SUB-KEY column to schema output showing subscription-key parameter markers, and add SUB column to status output showing per-subscription suffix from subscription ID.
User documentation for mail events
skills/lark-event/SKILL.md, skills/lark-event/references/lark-event-mail.md, skills/lark-mail/SKILL.md, skills/lark-mail/references/lark-mail-triage.md, skills/lark-mail/references/lark-mail-watch.md
Document mail event consumption with parameter descriptions, msg-format schema, filtering recipes, multi-mailbox subscription independence, and cleanup semantics; update mail skill references.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

domain/mail, domain/vc, size/XL

Suggested reviewers

  • liangshuo-1
  • zhaoleibd
  • zhangjun-bytedance
  • chanthuang

Poem

🐰 Hark! The mailbox arrives at last,
With fingerprints so stable, unsurpassed—
Each subscription scoped, each filter applied,
The event bus rejoices, subscription-ID-fied!
From me to mailbox, enriched and refined,
New mail upon Lark, for all humankind. 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main feature additions: per-resource subscription identity, a Match hook, and mail event support.
Description check ✅ Passed The description provides a clear Summary section, comprehensive Changes list covering all major modifications, and detailed Test Plan with checkmarks. All required template sections are present and well-filled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/event-subscription-id

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added domain/mail PR touches the mail domain size/L Large or sensitive change across domains or core paths labels May 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
cmd/event/schema_test.go (1)

147-169: ⚡ Quick win

Assert subscription_key structurally, not by substring.

The current check can false-pass if any other boolean field renders as true. Unmarshal the payload and verify the specific param entry instead.

Suggested fix
 func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
 	const syntheticKey = "test.evt_json"
 	t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
@@
 	if err := runSchema(f, syntheticKey, true); err != nil {
 		t.Fatalf("runSchema json: %v", err)
 	}
 
-	if !strings.Contains(stdout.String(), `"subscription_key"`) {
-		t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
-	}
-	if !strings.Contains(stdout.String(), `true`) {
-		t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
-	}
+	var payload struct {
+		Params []struct {
+			Name            string `json:"name"`
+			SubscriptionKey bool   `json:"subscription_key"`
+		} `json:"params"`
+	}
+	if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
+		t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
+	}
+	if len(payload.Params) != 1 || payload.Params[0].Name != "mailbox" || !payload.Params[0].SubscriptionKey {
+		t.Errorf("unexpected params payload: %+v", payload.Params)
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/event/schema_test.go` around lines 147 - 169, The test
TestSchema_JSON_IncludesSubscriptionKey currently asserts subscription_key by
substring which can false-pass; instead parse the JSON output from stdout (the
result of runSchema called with syntheticKey) into a suitable structure (e.g.,
map[string]interface{} or a struct matching the schema output), locate the
params array entry for the param named "mailbox" (registered via
eventlib.RegisterKey in the test) and assert that that param's
"subscription_key" field exists and is true; update the assertions to use the
unmarshalled value rather than string containment checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@events/mail/match.go`:
- Around line 31-33: The handler currently does json.Unmarshal(raw.Payload,
&env) and returns true on error (fail-open), which can route events to the wrong
mailbox when params["mailbox"] is set; change the logic in the mailbox filter to
fail-closed by returning false when json.Unmarshal returns an error (optionally
logging the unmarshal error and the offending raw.Payload and/or rejecting the
event), i.e., in the block around json.Unmarshal(raw.Payload, &env) switch the
return value on err from true to false so the event is rejected rather than
passed through to per-mailbox subscribers.

In `@internal/event/bus/conn.go`:
- Around line 150-156: In PreShutdownCheck (the Conn method where scope is
computed from m.SubscriptionID/m.EventKey), stop trusting the incoming message
identity and use the connection’s registered scope by calling c.SubscriptionID()
when computing scope for c.checkLastForKey; if m.SubscriptionID or m.EventKey
differ from c.SubscriptionID(), optionally emit a debug/warn log about the
mismatch before proceeding so checkLastForKey always evaluates the conn’s true
subscription scope.

In `@internal/event/consume/consume_test.go`:
- Around line 35-55: The test currently reconstructs the wrapper string with
fmt.Errorf instead of exercising the real wrapping logic; update
TestNormalizeParams_ErrorIsWrappedWithEventKey to either (A) invoke the actual
code path that performs the wrap (call the production wrapper used around
keyDef.NormalizeParams — the same function Run uses) so the returned err is the
wrapped error coming from that logic, or (B) extract the wrapping/formatting
into a helper (e.g., a new helper used by Run/NormalizeParams wrapper) and
assert that helper directly; then assert on the returned error from that real
wrapper (use strings.Contains or strings.HasPrefix on err.Error() for the prefix
"normalize params for "+keyDef.Key and use errors.Is/unwrap to verify the
underlying "simulated normalize failure") instead of building a new fmt.Errorf
in the test.

In `@internal/event/consume/consume.go`:
- Around line 61-67: Move the timeout-wrapping of the request context so it is
applied before calling keyDef.NormalizeParams to ensure NormalizeParams runs
under opts.Timeout; specifically, in the Consume flow wrap the incoming ctx with
the timeout (the existing opts.Timeout installation code) before invoking
keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params) and ensure the deferred
cancel is still called after NormalizeParams returns; adjust any references to
ctx passed to NormalizeParams, doHello, PreConsume, Match, and Process so they
all use the timeout-wrapped context.

In `@skills/lark-event/references/lark-event-mail.md`:
- Around line 117-122: Update the paragraph describing server-side subscription
keying to include the mailbox/resource dimension so it matches earlier
documentation: change the described key from "(app, user, event_type)" to
include "mailbox" (e.g., "(app, user, mailbox, event_type)"), and adjust the
explanations about overwrite/leak behavior and the warning about manual
unsubscribe so they refer to per-mailbox subscriptions and show that failed
unsubscribe leaks are per-mailbox (not global per-user) and that manual
unsubscribe can terminate a different consumer on the same mailbox; ensure the
text references the same "mailbox" terminology used earlier in this document.

In `@skills/lark-event/SKILL.md`:
- Around line 157-162: The current text incorrectly implies a single server
record per `(app, user, event_type)` and uses "reference-counted"; update the
wording to state that subscriptions are tracked by subscription scope (the
SubscriptionID / per-mailbox qualifier) so different mailbox/resource
subscriptions have distinct server records, and replace "reference-counted" with
a gentler description (e.g., server-side subscriptions are keyed by
SubscriptionID and are idempotent for the same scope), then rephrase the WARN
explanation to say the warning is informational and the next subscribe for the
same SubscriptionID self-heals rather than implying global shared state.

---

Nitpick comments:
In `@cmd/event/schema_test.go`:
- Around line 147-169: The test TestSchema_JSON_IncludesSubscriptionKey
currently asserts subscription_key by substring which can false-pass; instead
parse the JSON output from stdout (the result of runSchema called with
syntheticKey) into a suitable structure (e.g., map[string]interface{} or a
struct matching the schema output), locate the params array entry for the param
named "mailbox" (registered via eventlib.RegisterKey in the test) and assert
that that param's "subscription_key" field exists and is true; update the
assertions to use the unmarshalled value rather than string containment checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7472c298-466d-44f3-95c3-5a11f7accdec

📥 Commits

Reviewing files that changed from the base of the PR and between b1ecf2d and 482d460.

📒 Files selected for processing (42)
  • cmd/event/format_helpers_test.go
  • cmd/event/schema.go
  • cmd/event/schema_test.go
  • cmd/event/status.go
  • events/mail/match.go
  • events/mail/match_test.go
  • events/mail/normalize.go
  • events/mail/normalize_test.go
  • events/mail/payload.go
  • events/mail/process.go
  • events/mail/process_test.go
  • events/mail/register.go
  • events/mail/register_test.go
  • events/register.go
  • internal/event/bus/bus.go
  • internal/event/bus/bus_shutdown_test.go
  • internal/event/bus/conn.go
  • internal/event/bus/conn_test.go
  • internal/event/bus/handle_hello_test.go
  • internal/event/bus/hub.go
  • internal/event/bus/hub_observability_test.go
  • internal/event/bus/hub_publish_race_test.go
  • internal/event/bus/hub_test.go
  • internal/event/consume/consume.go
  • internal/event/consume/consume_test.go
  • internal/event/consume/fingerprint.go
  • internal/event/consume/fingerprint_test.go
  • internal/event/consume/handshake.go
  • internal/event/consume/handshake_test.go
  • internal/event/consume/loop.go
  • internal/event/consume/loop_test.go
  • internal/event/consume/shutdown.go
  • internal/event/consume/shutdown_test.go
  • internal/event/protocol/codec_test.go
  • internal/event/protocol/messages.go
  • internal/event/protocol/messages_test.go
  • internal/event/types.go
  • skills/lark-event/SKILL.md
  • skills/lark-event/references/lark-event-mail.md
  • skills/lark-mail/SKILL.md
  • skills/lark-mail/references/lark-mail-triage.md
  • skills/lark-mail/references/lark-mail-watch.md
💤 Files with no reviewable changes (1)
  • skills/lark-mail/references/lark-mail-watch.md

Comment thread events/mail/match.go
Comment on lines +31 to +33
if err := json.Unmarshal(raw.Payload, &env); err != nil {
return true // fail-open
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail closed when a mailbox-filtered payload cannot be parsed.

Once params["mailbox"] is set, returning true on unmarshal failure can deliver an event to the wrong per-mailbox subscriber. Here the safer default is to reject the event rather than bypass the filter.

Suggested fix
 	if err := json.Unmarshal(raw.Payload, &env); err != nil {
-		return true // fail-open
+		return false
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err := json.Unmarshal(raw.Payload, &env); err != nil {
return true // fail-open
}
if err := json.Unmarshal(raw.Payload, &env); err != nil {
return false
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@events/mail/match.go` around lines 31 - 33, The handler currently does
json.Unmarshal(raw.Payload, &env) and returns true on error (fail-open), which
can route events to the wrong mailbox when params["mailbox"] is set; change the
logic in the mailbox filter to fail-closed by returning false when
json.Unmarshal returns an error (optionally logging the unmarshal error and the
offending raw.Payload and/or rejecting the event), i.e., in the block around
json.Unmarshal(raw.Payload, &env) switch the return value on err from true to
false so the event is rejected rather than passed through to per-mailbox
subscribers.

Comment on lines +150 to +156
scope := m.SubscriptionID
if scope == "" {
scope = m.EventKey
}
lastForKey := true
if c.checkLastForKey != nil {
lastForKey = c.checkLastForKey(m.EventKey)
lastForKey = c.checkLastForKey(scope)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the connection’s registered subscription scope for PreShutdownCheck.

This path currently trusts m.SubscriptionID/m.EventKey, but the bus already knows the scope for this Conn. If a buggy or stale client sends the wrong identity here, checkLastForKey can answer for another subscription and either skip cleanup or unsubscribe the wrong mailbox stream. Prefer c.SubscriptionID() (optionally logging a mismatch) when computing scope.

Suggested fix
 	case *protocol.PreShutdownCheck:
-		scope := m.SubscriptionID
-		if scope == "" {
-			scope = m.EventKey
-		}
+		scope := c.SubscriptionID()
 		lastForKey := true
 		if c.checkLastForKey != nil {
 			lastForKey = c.checkLastForKey(scope)
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
scope := m.SubscriptionID
if scope == "" {
scope = m.EventKey
}
lastForKey := true
if c.checkLastForKey != nil {
lastForKey = c.checkLastForKey(m.EventKey)
lastForKey = c.checkLastForKey(scope)
scope := c.SubscriptionID()
lastForKey := true
if c.checkLastForKey != nil {
lastForKey = c.checkLastForKey(scope)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/event/bus/conn.go` around lines 150 - 156, In PreShutdownCheck (the
Conn method where scope is computed from m.SubscriptionID/m.EventKey), stop
trusting the incoming message identity and use the connection’s registered scope
by calling c.SubscriptionID() when computing scope for c.checkLastForKey; if
m.SubscriptionID or m.EventKey differ from c.SubscriptionID(), optionally emit a
debug/warn log about the mismatch before proceeding so checkLastForKey always
evaluates the conn’s true subscription scope.

Comment on lines +35 to +55
func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) {
// We test the error wrapping pattern in isolation: same call site Run uses.
keyDef := &event.KeyDefinition{
Key: "test.evt_normalize_fail",
NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error {
return errors.New("simulated normalize failure")
},
}
err := keyDef.NormalizeParams(context.Background(), &fakeRT{}, map[string]string{})
if err == nil {
t.Fatal("expected error from NormalizeParams")
}
// Run wraps with: fmt.Errorf("normalize params for %s: %w", EventKey, err)
wrapped := fmt.Errorf("normalize params for %s: %w", keyDef.Key, err)
if !strings.Contains(wrapped.Error(), "normalize params for test.evt_normalize_fail:") {
t.Errorf("wrap format wrong: %v", wrapped)
}
if !strings.Contains(wrapped.Error(), "simulated normalize failure") {
t.Errorf("underlying error not propagated: %v", wrapped)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

These tests mirror the expected strings instead of verifying the implementation.

Line 48 and Line 104 rebuild the formatting inside the test, so both cases still pass if the production code stops wrapping/logging those messages correctly. Please drive the real branch or extract the formatter/wrapper into a helper and assert that helper directly.

Also applies to: 99-109

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/event/consume/consume_test.go` around lines 35 - 55, The test
currently reconstructs the wrapper string with fmt.Errorf instead of exercising
the real wrapping logic; update TestNormalizeParams_ErrorIsWrappedWithEventKey
to either (A) invoke the actual code path that performs the wrap (call the
production wrapper used around keyDef.NormalizeParams — the same function Run
uses) so the returned err is the wrapped error coming from that logic, or (B)
extract the wrapping/formatting into a helper (e.g., a new helper used by
Run/NormalizeParams wrapper) and assert that helper directly; then assert on the
returned error from that real wrapper (use strings.Contains or strings.HasPrefix
on err.Error() for the prefix "normalize params for "+keyDef.Key and use
errors.Is/unwrap to verify the underlying "simulated normalize failure") instead
of building a new fmt.Errorf in the test.

Comment on lines +61 to +67
// Normalize params (resolve aliases like "me" -> real email) before fingerprint
// compute, PreConsume, Match, Process. Must happen BEFORE doHello so the
// SubscriptionID we send to bus reflects canonical values.
if keyDef.NormalizeParams != nil {
if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil {
return fmt.Errorf("normalize params for %s: %w", opts.EventKey, err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move the timeout context above NormalizeParams.

NormalizeParams is documented to allow OAPI calls, but Line 73 installs opts.Timeout only after normalization runs. A slow alias/lookup call here can block startup indefinitely even when the user passed --timeout.

Suggested fix
-	// Normalize params (resolve aliases like "me" -> real email) before fingerprint
-	// compute, PreConsume, Match, Process. Must happen BEFORE doHello so the
-	// SubscriptionID we send to bus reflects canonical values.
-	if keyDef.NormalizeParams != nil {
-		if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil {
-			return fmt.Errorf("normalize params for %s: %w", opts.EventKey, err)
-		}
-	}
-
-	// Compute subscription identity from normalized params + SubscriptionKey flags.
-	subscriptionID := ComputeSubscriptionID(keyDef, opts.Params)
-
 	if opts.Timeout > 0 {
 		var cancel context.CancelFunc
 		ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
 		defer cancel()
 	}
+
+	// Normalize params (resolve aliases like "me" -> real email) before fingerprint
+	// compute, PreConsume, Match, Process. Must happen BEFORE doHello so the
+	// SubscriptionID we send to bus reflects canonical values.
+	if keyDef.NormalizeParams != nil {
+		if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil {
+			return fmt.Errorf("normalize params for %s: %w", opts.EventKey, err)
+		}
+	}
+
+	// Compute subscription identity from normalized params + SubscriptionKey flags.
+	subscriptionID := ComputeSubscriptionID(keyDef, opts.Params)

Also applies to: 73-77

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/event/consume/consume.go` around lines 61 - 67, Move the
timeout-wrapping of the request context so it is applied before calling
keyDef.NormalizeParams to ensure NormalizeParams runs under opts.Timeout;
specifically, in the Consume flow wrap the incoming ctx with the timeout (the
existing opts.Timeout installation code) before invoking
keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params) and ensure the deferred
cancel is still called after NormalizeParams returns; adjust any references to
ctx passed to NormalizeParams, doHello, PreConsume, Match, and Process so they
all use the timeout-wrapped context.

Comment on lines +117 to +122
On graceful exit (`--max-events`/`--timeout` reached, or SIGTERM/stdin EOF), the last consumer for a `SubscriptionID` runs cleanup: a POST to `unsubscribe`. Two stderr outcomes:

- Success: `[event] cleanup done.`
- Failure: `WARN: cleanup failed: <reason> (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)`

The Feishu server-side subscribe record is `(app, user, event_type)`-keyed and idempotent, so a failed unsubscribe leaks at most one stale record per `(app, user)` until the next `event consume` for that key — which silently overwrites it. Agents **MUST NOT** manually call the unsubscribe API as a recovery action: the server has no reference counting, so a stray unsubscribe will silently kill another co-living consumer's stream.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Cleanup keying here contradicts the per-mailbox subscription model.

This section says the server-side record is keyed only by (app, user, event_type), but the doc earlier describes mailbox as part of subscription identity and says different mailboxes get independent subscriptions. That makes the overwrite/leak explanation inaccurate for operators debugging cleanup. Please update this paragraph to describe the mailbox/resource dimension consistently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/lark-event/references/lark-event-mail.md` around lines 117 - 122,
Update the paragraph describing server-side subscription keying to include the
mailbox/resource dimension so it matches earlier documentation: change the
described key from "(app, user, event_type)" to include "mailbox" (e.g., "(app,
user, mailbox, event_type)"), and adjust the explanations about overwrite/leak
behavior and the warning about manual unsubscribe so they refer to per-mailbox
subscriptions and show that failed unsubscribe leaks are per-mailbox (not global
per-user) and that manual unsubscribe can terminate a different consumer on the
same mailbox; ensure the text references the same "mailbox" terminology used
earlier in this document.

Comment on lines +157 to +162
When the consumer exits gracefully and is the last for its subscription scope, the framework runs the cleanup (unsubscribe) callback. Two stderr outcomes:

- Success: `[event] cleanup done.`
- Failure: `WARN: cleanup failed: <reason> (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)`

**Important**: do NOT manually call unsubscribe APIs as a recovery action. Subscriptions are reference-counted implicitly by Lark's server (one record per `(app, user, event_type)`), so a stray unsubscribe will silently affect other co-living consumers. The WARN is informational; the next consumer's subscribe call self-heals.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the server-side identity wording here.

This paragraph now conflicts with the per-mailbox model introduced above. Saying there is one record per (app, user, event_type) implies mailbox/resource qualifiers are ignored, and "reference-counted" is stronger than the behavior described elsewhere in this PR. Please rephrase this in terms of the subscription scope / SubscriptionID so readers do not infer that different mailbox subscriptions share one server record.

Suggested wording
-**Important**: do NOT manually call unsubscribe APIs as a recovery action. Subscriptions are reference-counted implicitly by Lark's server (one record per `(app, user, event_type)`), so a stray unsubscribe will silently affect other co-living consumers. The WARN is informational; the next consumer's subscribe call self-heals.
+**Important**: do NOT manually call unsubscribe APIs as a recovery action. Lark keeps one server-side subscription record per subscription scope (the same scope this CLI identifies via `SubscriptionID`; for mail that includes the mailbox/resource qualifier). A stray unsubscribe can therefore tear down another co-living consumer in that same scope. The WARN is informational; the next consumer's subscribe call self-heals.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
When the consumer exits gracefully and is the last for its subscription scope, the framework runs the cleanup (unsubscribe) callback. Two stderr outcomes:
- Success: `[event] cleanup done.`
- Failure: `WARN: cleanup failed: <reason> (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)`
**Important**: do NOT manually call unsubscribe APIs as a recovery action. Subscriptions are reference-counted implicitly by Lark's server (one record per `(app, user, event_type)`), so a stray unsubscribe will silently affect other co-living consumers. The WARN is informational; the next consumer's subscribe call self-heals.
When the consumer exits gracefully and is the last for its subscription scope, the framework runs the cleanup (unsubscribe) callback. Two stderr outcomes:
- Success: `[event] cleanup done.`
- Failure: `WARN: cleanup failed: <reason> (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)`
**Important**: do NOT manually call unsubscribe APIs as a recovery action. Lark keeps one server-side subscription record per subscription scope (the same scope this CLI identifies via `SubscriptionID`; for mail that includes the mailbox/resource qualifier). A stray unsubscribe can therefore tear down another co-living consumer in that same scope. The WARN is informational; the next consumer's subscribe call self-heals.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/lark-event/SKILL.md` around lines 157 - 162, The current text
incorrectly implies a single server record per `(app, user, event_type)` and
uses "reference-counted"; update the wording to state that subscriptions are
tracked by subscription scope (the SubscriptionID / per-mailbox qualifier) so
different mailbox/resource subscriptions have distinct server records, and
replace "reference-counted" with a gentler description (e.g., server-side
subscriptions are keyed by SubscriptionID and are idempotent for the same
scope), then rephrase the WARN explanation to say the warning is informational
and the next subscribe for the same SubscriptionID self-heals rather than
implying global shared state.

@liuxinyanglxy

Copy link
Copy Markdown
Collaborator Author

Superseded by a same-repo PR (branch pushed directly to larksuite/cli for native CI). The fork-based PR triggered the external-contributor workflow-approval gate; reopening as an in-repo PR so checks run automatically. Same commits, rebased onto latest main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain/mail PR touches the mail domain feature size/L Large or sensitive change across domains or core paths

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant