Skip to content

feat: add login reminder message for WeChat session activation and im…#18

Merged
cnjack merged 1 commit intomainfrom
feat/wexin-msg-idempotency
Apr 17, 2026
Merged

feat: add login reminder message for WeChat session activation and im…#18
cnjack merged 1 commit intomainfrom
feat/wexin-msg-idempotency

Conversation

@cnjack
Copy link
Copy Markdown
Owner

@cnjack cnjack commented Apr 17, 2026

…plement idempotent message deduplication

Summary by CodeRabbit

Release Notes

  • New Features

    • WeChat channel now displays login reminders guiding users to activate the notification session window.
    • Added UI banner after successful WeChat login requiring user acknowledgment.
  • Bug Fixes

    • Improved detection and handling of expired session errors during message sends.
    • Eliminated duplicate message processing based on message sequence tracking.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

This PR introduces a login reminder feature for WeChat channels. It adds a new reminder message function, integrates reminder sends into the channel enable and web login flows, implements message deduplication in the WeChat client, and adds a frontend UI banner to display the reminder to users.

Changes

Cohort / File(s) Summary
Message Definition
internal/channel/messages.go
Added new exported function LoginReminderMessage() that returns a fixed instruction text for users to activate their 24-hour WeChat notification session.
Channel Enable Flow
internal/command/channel.go
Extended the enable flow to send tui.ChannelStateMsg after successful channel enabling and added asynchronous sending of the login reminder message after the welcome message.
WeChat Client Logic
internal/pkg/weixin/client.go
Implemented message deduplication via seenSeqs tracking to skip duplicate message Seq values within a configured window, and added session-expired error detection in SendText to disable the client and return a re-login prompt.
Web Login Handler
internal/web/channel.go
Added background work after successful WeChat web login scan to send welcome and login reminder messages via the WeChat client, with failure logging for each send attempt.
Frontend UI
web/src/components/SettingsDialog.vue
Added channelLoginReminder reactive state and updated pollChannelState() to show a login reminder banner when the channel transitions to enabled, with a close button to dismiss it.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • Feat/weixin #13: Introduced the initial WeChat channel enable and login flows that this PR extends with reminder messages and enhanced client-side handling.

Poem

🐰 A reminder hops forth for our users true,
"Send a message quick, keep the session new!"
From backend to frontend, the banners now gleam,
With dedup and re-login—a well-oiled scheme. 🎯

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The PR title is truncated and incomplete. It ends with 'and im…' which suggests the full title was cut off, making it unclear what the complete feature is. Verify the complete PR title. The current title appears incomplete and should be reviewed for clarity and full representation of both features (login reminder and message deduplication).
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wexin-msg-idempotency

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/src/components/SettingsDialog.vue (1)

37-60: ⚠️ Potential issue | 🟡 Minor

Reset channelLoginReminder so the banner doesn't stick across sessions.

channelLoginReminder is only cleared by the user clicking ✕ (line 418). If the user closes the dialog with the banner still up, it will reappear next time the dialog opens, even if the channel has since been disconnected or is no longer in the scanning→enabled transition. Clear it alongside channelQRContent on close (and/or on channelLogout) so the banner lifecycle matches the actual connection flow.

🩹 Proposed fix
   } else {
     channelQRContent.value = ''
+    channelLoginReminder.value = false
   }

And in channelLogout():

   try {
     await api.channelLogout()
     channelState.value = 'none'
     channelQRContent.value = ''
+    channelLoginReminder.value = false
     store.channelEnabled = false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/SettingsDialog.vue` around lines 37 - 60, When the
SettingsDialog watcher for props.open closes the dialog it only clears
channelQRContent, so channelLoginReminder can persist across dialog reopen;
update the watcher (the else branch in the watch(() => props.open, ...)) to also
reset channelLoginReminder.value = '' (or false depending on its type), and also
clear channelLoginReminder inside the channelLogout() function (the
channelLogout method) so the reminder lifecycle is reset both on dialog close
and explicit logout.
🧹 Nitpick comments (1)
internal/web/channel.go (1)

56-64: Unnecessary nested goroutine.

The outer go func() (line 46) is already detached and does nothing after this block, so wrapping the two SendText calls in an inner goroutine adds scheduling overhead and makes the flow harder to follow without any concurrency benefit. Inline the sends in the outer goroutine.

♻️ Proposed simplification
-		// Send welcome and login reminder messages
-		go func() {
-			if err := s.wechatClient.SendText(channelpkg.WelcomeMessage(time.Now())); err != nil {
-				config.Logger().Printf("[wechat] failed to send welcome: %v", err)
-			}
-			if err := s.wechatClient.SendText(channelpkg.LoginReminderMessage()); err != nil {
-				config.Logger().Printf("[wechat] failed to send login reminder: %v", err)
-			}
-		}()
+		// Send welcome and login reminder messages
+		if err := s.wechatClient.SendText(channelpkg.WelcomeMessage(time.Now())); err != nil {
+			config.Logger().Printf("[wechat] failed to send welcome: %v", err)
+		}
+		if err := s.wechatClient.SendText(channelpkg.LoginReminderMessage()); err != nil {
+			config.Logger().Printf("[wechat] failed to send login reminder: %v", err)
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/web/channel.go` around lines 56 - 64, The nested go func around the
two SendText calls is redundant; remove the inner goroutine and inline the two
sends directly in the outer goroutine so you call
s.wechatClient.SendText(channelpkg.WelcomeMessage(time.Now())) and
s.wechatClient.SendText(channelpkg.LoginReminderMessage()) sequentially,
preserving the existing error logging (config.Logger().Printf(...)) for each
call and eliminating the extra scheduling overhead introduced by the inner
anonymous goroutine.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/pkg/weixin/client.go`:
- Around line 275-286: The current dedup eviction uses map iteration which is
random and can drop recent seqs; replace c.seenSeqs map-only approach with an
insertion-ordered structure (e.g., maintain a slice or container/list alongside
c.seenSeqs) so you can evict the oldest entries deterministically when len
exceeds dedupWindowSize: on insertion of seq (where c.seenSeqs[seq] is set) also
append seq to the ordered queue and, while len(queue) > dedupWindowSize, pop
from the front and delete that key from c.seenSeqs; update any references to
c.seenSeqs and the dedup check around seq to use the map for fast membership and
the queue for eviction to guarantee a true most-recent-N window.

---

Outside diff comments:
In `@web/src/components/SettingsDialog.vue`:
- Around line 37-60: When the SettingsDialog watcher for props.open closes the
dialog it only clears channelQRContent, so channelLoginReminder can persist
across dialog reopen; update the watcher (the else branch in the watch(() =>
props.open, ...)) to also reset channelLoginReminder.value = '' (or false
depending on its type), and also clear channelLoginReminder inside the
channelLogout() function (the channelLogout method) so the reminder lifecycle is
reset both on dialog close and explicit logout.

---

Nitpick comments:
In `@internal/web/channel.go`:
- Around line 56-64: The nested go func around the two SendText calls is
redundant; remove the inner goroutine and inline the two sends directly in the
outer goroutine so you call
s.wechatClient.SendText(channelpkg.WelcomeMessage(time.Now())) and
s.wechatClient.SendText(channelpkg.LoginReminderMessage()) sequentially,
preserving the existing error logging (config.Logger().Printf(...)) for each
call and eliminating the extra scheduling overhead introduced by the inner
anonymous goroutine.
🪄 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: 53111d7b-7ec5-4653-99a6-351025c72ca2

📥 Commits

Reviewing files that changed from the base of the PR and between 0db6b33 and 4017e20.

📒 Files selected for processing (5)
  • internal/channel/messages.go
  • internal/command/channel.go
  • internal/pkg/weixin/client.go
  • internal/web/channel.go
  • web/src/components/SettingsDialog.vue

Comment on lines +275 to +286
// Mark as seen; evict oldest entries when window is full.
c.seenSeqs[seq] = struct{}{}
if len(c.seenSeqs) > dedupWindowSize {
newSeen := make(map[int]struct{}, dedupWindowSize)
for k := range c.seenSeqs {
newSeen[k] = struct{}{}
if len(newSeen) >= dedupWindowSize/2 {
break
}
}
c.seenSeqs = newSeen
}
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

Dedup eviction keeps an arbitrary half, not the most-recent — undermines idempotency.

Go's for k := range c.seenSeqs iterates in randomized order, so the newSeen map retains ~128 keys selected at random. The most-recently-seen Seq values may be dropped, and if the server retransmits one of those Seqs on the next long-poll cycle, the dedup check at line 270 will miss and the message will be delivered a second time — the exact scenario this data structure is meant to prevent.

To preserve a true "most recent N" window, keep insertion order (e.g., with a slice used as a FIFO, or container/list). Sketch:

♻️ Proposed refactor to FIFO eviction
 type Client struct {
 	mu        sync.Mutex
 	state     channel.State
 	creds     *Credentials
 	cancel    context.CancelFunc
 	onMessage func(from, text string)
 
 	// idempotent deduplication: track recently processed message Seq values.
-	seenSeqs map[int]struct{}
+	seenSeqs  map[int]struct{}
+	seqOrder  []int // FIFO of Seq insertion order, same length as seenSeqs
 }
 			// Mark as seen; evict oldest entries when window is full.
 			c.seenSeqs[seq] = struct{}{}
-			if len(c.seenSeqs) > dedupWindowSize {
-				newSeen := make(map[int]struct{}, dedupWindowSize)
-				for k := range c.seenSeqs {
-					newSeen[k] = struct{}{}
-					if len(newSeen) >= dedupWindowSize/2 {
-						break
-					}
-				}
-				c.seenSeqs = newSeen
-			}
+			c.seqOrder = append(c.seqOrder, seq)
+			if len(c.seqOrder) > dedupWindowSize {
+				drop := c.seqOrder[0]
+				c.seqOrder = c.seqOrder[1:]
+				delete(c.seenSeqs, drop)
+			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/pkg/weixin/client.go` around lines 275 - 286, The current dedup
eviction uses map iteration which is random and can drop recent seqs; replace
c.seenSeqs map-only approach with an insertion-ordered structure (e.g., maintain
a slice or container/list alongside c.seenSeqs) so you can evict the oldest
entries deterministically when len exceeds dedupWindowSize: on insertion of seq
(where c.seenSeqs[seq] is set) also append seq to the ordered queue and, while
len(queue) > dedupWindowSize, pop from the front and delete that key from
c.seenSeqs; update any references to c.seenSeqs and the dedup check around seq
to use the map for fast membership and the queue for eviction to guarantee a
true most-recent-N window.

@cnjack cnjack merged commit ab9147d into main Apr 17, 2026
1 check passed
@cnjack cnjack deleted the feat/wexin-msg-idempotency branch April 21, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant