diff --git a/docs/configuration.md b/docs/configuration.md index a5a2b31..f8f6c04 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -106,6 +106,10 @@ jcode stores all configuration in a single JSON file at `~/.jcode/config.json`. } }, + "channel": { + "web_enabled": true + }, + "disabled_providers": [] } ``` @@ -191,6 +195,16 @@ Multi-agent team settings. Set to `true` to auto-approve all tool calls. Equivalent to running with `--unsafe` flag. +### channel + +Notification channel settings. See [Channels](overview/channels). + +| Field | Default | Description | +|---|---|---| +| `web_enabled` | false | Enable WeChat channel in `jcode web` mode | + +In TUI mode, channels are always available via `/channel` — no config needed. + ## Changing Configuration - **Setup wizard**: Run `jcode` and the wizard launches if config is missing diff --git a/docs/overview.md b/docs/overview.md index 1951872..c46b354 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -31,6 +31,7 @@ jcode is an AI-powered coding agent that runs in your terminal. You describe tas | **MCP Integration** | Connect any MCP-compatible server for extended capabilities | | **Session Resume** | Every conversation is recorded. Pick up where you left off | | **Web Interface** | Browser-based UI with terminal access | +| **Channels** | Push notifications to WeChat when approval is needed or tasks complete | | **Skills** | Domain-specific skill packs loaded on demand | ## Design Philosophy diff --git a/docs/overview/channels.md b/docs/overview/channels.md new file mode 100644 index 0000000..b169e3a --- /dev/null +++ b/docs/overview/channels.md @@ -0,0 +1,139 @@ +--- +title: Channels +parent: Overview +nav_order: 13 +--- + +# Channels + +Channels let jcode send push notifications to your messaging apps. When the agent needs your attention — approval required, task completed, or task failed — you get a message on your phone instead of having to watch the terminal. + +Currently supported channels: + +| Channel | Protocol | Status | +|---|---|---| +| **WeChat** | [iLink Bot API](https://ilinkai.weixin.qq.com) | Supported | + +## How It Works + +1. **Connect** — Scan a QR code to link your WeChat account +2. **Work normally** — Use jcode in the TUI or web interface +3. **Get notified** — When the agent waits for approval or finishes a task, you receive a WeChat message +4. **Send messages** — You can also send prompts to jcode directly from WeChat + +Channels are a notification sidecar — they don't replace the TUI or web interface. The agent still runs locally; channels just let you step away without missing anything. + +## WeChat Setup + +### TUI (Terminal) + +1. Open jcode and type `/channel` +2. Select **WeChat** → **Login** +3. Scan the QR code with your WeChat app +4. The channel auto-enables after a successful scan + +``` +You › /channel + + ┌─────────────────────────────┐ + │ 📱 Channels │ + │ │ + │ WeChat Not connected │ + │ ───────────────────────── │ + │ [L] Login [D] Disable │ + └─────────────────────────────┘ +``` + +### Web Interface + +1. Open **Settings** → **Channels** tab +2. Click **Connect** and scan the QR code with WeChat +3. After scanning, the channel is ready to use + +Once connected, a **WeChat toggle** appears in the input toolbar (next to the Auto toggle). Use it to quickly enable or disable notifications without opening Settings. + +To disconnect entirely, go back to **Settings** → **Channels** → **Disconnect**. + +### Auto-Enable on Startup + +If you've previously logged in, jcode remembers your credentials. On the next launch: + +- **TUI mode** — The channel auto-enables automatically. No configuration needed. +- **Web mode** — Add `"channel": { "web_enabled": true }` to your config to auto-enable on startup. Without this, you can still enable manually via the toolbar toggle. + +## Notifications + +When the channel is enabled, you receive WeChat notifications for: + +| Event | Notification | +|---|---| +| **Approval needed** | Tool name + arguments (sent after 10 seconds of no response) | +| **Task completed** | Summary of the agent's output | +| **Task failed** | Error message | +| **Session started** | Time-aware welcome message (TUI only) | +| **Session ended** | Time-aware goodbye message | + +### Message Format + +``` +⏳ Approval Needed +———————————————— +Tool: execute +Args: npm test +———————————————— +Please return to terminal +``` + +``` +✅ Task Completed +———————————————— +All 42 tests passing. Updated the +README with the new API documentation. +``` + +### Time-Aware Messages + +Welcome and goodbye messages adapt to the time of day: + +| Time | Greeting | +|---|---| +| Before 6am | 🌙 Burning the midnight oil? | +| 6am–12pm | 🌅/☀️ Good morning! | +| 12pm–6pm | ☀️ Good afternoon! | +| 6pm–10pm | 🌆 Good evening! | +| After 10pm | 🌙 Working late? | + +Weekend messages include a casual "Enjoy your weekend!" touch. + +## Sending Messages from WeChat + +You can send prompts to jcode directly from WeChat. Your message is submitted to the agent just as if you typed it in the TUI or web interface. + +- In **web mode**, inbound WeChat messages appear in the web UI with a green **WeChat** label, so you can tell them apart from locally typed prompts. +- If the agent is currently busy, you'll receive an immediate reply letting you know your message has been queued. + +When the channel is disabled, inbound messages are silently ignored. + +## Configuration + +Add to `~/.jcode/config.json`: + +```json +{ + "channel": { + "web_enabled": true + } +} +``` + +| Setting | Effect | +|---|---| +| `channel.web_enabled` | Auto-enable WeChat on `jcode web` startup (if already logged in) | + +This setting only controls auto-enable. You can always connect and toggle the channel manually through the UI, even without this config. + +In TUI mode, no configuration is needed — channels are always available via `/channel`. + +### Credential Storage + +WeChat credentials are stored at `~/.jcode/channel/wechat.json` and created automatically after the first successful login. Delete this file to force a re-login. diff --git a/docs/web-interface.md b/docs/web-interface.md index 226f275..b8d90ed 100644 --- a/docs/web-interface.md +++ b/docs/web-interface.md @@ -72,6 +72,11 @@ The web server exposes a REST API for programmatic access: | `GET /api/mcp` | List MCP servers and status | | `GET /api/skills` | List available skills | | `POST /api/pty` | Create a terminal session | +| `GET /api/channel` | Get channel connection status | +| `POST /api/channel/login` | Start channel login (returns QR code) | +| `POST /api/channel/logout` | Disconnect channel | +| `POST /api/channel/enable` | Enable notifications | +| `POST /api/channel/disable` | Disable notifications | {: .tip } The REST API makes it easy to integrate jcode into your own tools and workflows. diff --git a/go.mod b/go.mod index 118149f..ed57978 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/mdp/qrterminal/v3 v3.2.1 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -82,7 +83,9 @@ require ( golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 179651f..5db54ad 100644 --- a/go.sum +++ b/go.sum @@ -145,6 +145,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -260,3 +262,5 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal-docs/external/weixin.spec.md b/internal-docs/external/weixin.spec.md new file mode 100644 index 0000000..766be9d --- /dev/null +++ b/internal-docs/external/weixin.spec.md @@ -0,0 +1,216 @@ +# WeChat iLink Bot API Spec + +> Undocumented private HTTP API provided by Tencent's iLink Bot service (`https://ilinkai.weixin.qq.com`). Reverse-engineered from the openclaw-weixin plugin. + +--- + +## Authentication + +All API requests require the following headers: + +``` +Authorization: Bearer {bot_token} +AuthorizationType: ilink_bot_token +Content-Type: application/json +``` + +`bot_token` is issued by the server upon QR login completion and remains valid until session expiry (errcode=-14). + +--- + +## API Endpoints + +Base URL: `https://ilinkai.weixin.qq.com` (or the `baseurl` returned at login for IDC routing) + +### 1. Fetch Login QR Code + +``` +GET /ilink/bot/get_bot_qrcode?bot_type=3 +``` + +No auth headers required. + +**Response:** +```json +{ + "qrcode": "", + "qrcode_img_content": "" +} +``` + +- `qrcode_img_content` is what the user scans — use this as QR image content +- `qrcode` is the session key for status polling, not the QR content itself + +--- + +### 2. Poll QR Scan Status + +``` +GET /ilink/bot/get_qrcode_status?qrcode={qrcode} +``` + +Server-side long-poll; each request holds ~35s. Client must loop until terminal status. + +**Response:** +```json +{ + "status": "wait | scaned | confirmed | expired | scaned_but_redirect", + "bot_token": "", + "ilink_bot_id": "", + "baseurl": "", + "ilink_user_id": "", + "redirect_host": "" +} +``` + +**Status transitions:** +``` +wait → scaned → confirmed + → scaned_but_redirect → (switch baseURL, continue polling) → confirmed + → expired +``` + +- On `confirmed`: extract `bot_token` + `ilink_user_id` + `baseurl` and save as credentials +- On `scaned_but_redirect`: switch `baseURL` to `redirect_host` and continue polling + +--- + +### 3. Long-Poll for Inbound Messages + +``` +POST /ilink/bot/getupdates +``` + +**Request:** +```json +{ + "get_updates_buf": "", + "base_info": { "channel_version": "jcode/1.0.0" } +} +``` + +**Response:** +```json +{ + "ret": 0, + "errcode": 0, + "errmsg": "", + "msgs": [ ], + "get_updates_buf": "", + "longpolling_timeout_ms": 35000 +} +``` + +**Key behaviors:** +- Server holds the connection for ~35s; returns early only if messages arrive +- **The first `getupdates` call implicitly activates the session.** Calling `sendmessage` before the first `getupdates` completes returns `ret=-2` +- `get_updates_buf` must be persisted to disk after every response; used to resume after restart +- `get_updates_buf=""` signals a fresh session with no prior state + +**Error codes:** + +| ret / errcode | Meaning | +|---|---| +| 0 | Success | +| -2 | Session not yet activated (first `getupdates` has not completed) | +| -14 | Session expired — re-login required | + +--- + +### 4. Send Message + +``` +POST /ilink/bot/sendmessage +``` + +**Request:** +```json +{ + "msg": { + "from_user_id": "", + "to_user_id": "", + "client_id": "", + "message_type": 2, + "message_state": 2, + "item_list": [ + { + "type": 1, + "text_item": { "text": "" } + } + ], + "context_token": "" + }, + "base_info": { "channel_version": "jcode/1.0.0" } +} +``` + +**Response:** Empty JSON `{}` on success; body contains `ret`/`errcode`/`errmsg` on failure. + +**Field notes:** +- `message_type: 2` = BOT +- `message_state: 2` = FINISH (complete message) +- `client_id` is client-generated; use a timestamp or UUID +- `context_token` is extracted from inbound messages and should be echoed back to maintain session context + +--- + +## Message Structure + +### WeixinMessage (inbound) + +```json +{ + "seq": 1, + "message_id": 123456, + "from_user_id": "", + "to_user_id": "", + "client_id": "", + "create_time_ms": 1713312000000, + "message_type": 1, + "message_state": 2, + "item_list": [ ], + "context_token": "" +} +``` + +### MessageItem Types + +| type | Kind | Fields | +|---|---|---| +| 1 | TEXT | `text_item.text` | +| 2 | IMAGE | `media` (CDN-encrypted) | +| 3 | VOICE | `media`, `encode_type`, `sample_rate`, `playtime` | +| 4 | FILE | `media`, `file_name`, `md5`, `len` | +| 5 | VIDEO | `media`, `thumb_media`, `video_size`, `play_length` | + +--- + +## Session Lifecycle + +``` +QR Login + ├─ GET /get_bot_qrcode → obtain qrcode + qrcode_img_content + ├─ Display qrcode_img_content as QR image for user to scan + └─ Poll /get_qrcode_status → status=confirmed → save bot_token + user_id + baseurl + +Enable + └─ Start pollLoop (goroutine) + ├─ POST /getupdates (long-poll, ~35s) ← implicitly activates session + ├─ Persist get_updates_buf + └─ Dispatch inbound messages + +Send Message + └─ POST /sendmessage + ├─ If ret=-2: session not yet active; retry (first getupdates may take ~35s) + └─ If ret=-14: session expired; re-login required +``` + +--- + +## Caveats + +1. **`sendmessage` requires `getupdates` to have completed first.** The long-poll is the implicit session activation handshake. Calls before the first poll completes return `ret=-2` and must be retried. +2. **`baseurl` is mutable.** The `scaned_but_redirect` status and the login `baseurl` field may differ from the default. Always use the URL saved in credentials. +3. **`get_updates_buf` must be persisted.** It is the server-side cursor; losing it means either replaying old messages or missing new ones after a restart. +4. **Session expiry (errcode=-14).** Clear credentials and restart the QR login flow. +5. **WeChat client has limited Markdown support.** Bold (`**`), code fences, and tables render correctly. H5/H6 headings and inline images do not. diff --git a/internal/channel/channel.go b/internal/channel/channel.go new file mode 100644 index 0000000..645a05f --- /dev/null +++ b/internal/channel/channel.go @@ -0,0 +1,60 @@ +// Package channel provides an abstraction for external messaging channels +// (e.g. WeChat, Telegram) that connect to the jcode agent as lightweight +// notification + remote prompt sidecars. +package channel + +// State represents the lifecycle state of a channel. +type State int + +const ( + // StateNone means the channel has never been configured (no credentials). + StateNone State = iota + // StateDisabled means the channel has credentials but push/receive is off. + StateDisabled + // StateEnabled means the channel is actively pushing notifications and receiving messages. + StateEnabled +) + +func (s State) String() string { + switch s { + case StateNone: + return "none" + case StateDisabled: + return "disabled" + case StateEnabled: + return "enabled" + default: + return "unknown" + } +} + +// Channel is the interface that all messaging channel implementations must satisfy. +type Channel interface { + // ID returns the channel identifier (e.g. "wechat"). + ID() string + // State returns the current lifecycle state. + State() State + // Login initiates the authentication flow (e.g. QR scan). + // Returns a login session that can be waited on. + Login() (*LoginSession, error) + // Logout clears credentials and stops the channel. + Logout() error + // Enable starts push notifications and inbound message polling. + Enable() error + // Disable stops push notifications and polling but keeps credentials. + Disable() error + // SendText sends a text message to the connected user. + SendText(text string) error +} + +// LoginSession represents an in-progress login that requires user action (e.g. QR scan). +type LoginSession struct { + // QRCodeURL is a URL that renders a QR code image for scanning. + QRCodeURL string + // QRCodeContent is the raw content to encode as a QR code in terminal. + QRCodeContent string + // SessionKey identifies this login attempt. + SessionKey string + // WaitFunc blocks until login completes or times out. Returns nil on success. + WaitFunc func() error +} diff --git a/internal/channel/channel_test.go b/internal/channel/channel_test.go new file mode 100644 index 0000000..c64ac1c --- /dev/null +++ b/internal/channel/channel_test.go @@ -0,0 +1,36 @@ +package channel + +import ( + "testing" +) + +func TestState_String(t *testing.T) { + tests := []struct { + state State + want string + }{ + {StateNone, "none"}, + {StateDisabled, "disabled"}, + {StateEnabled, "enabled"}, + {State(99), "unknown"}, + } + + for _, tt := range tests { + got := tt.state.String() + if got != tt.want { + t.Errorf("State(%d).String() = %q, want %q", tt.state, got, tt.want) + } + } +} + +func TestState_Iota(t *testing.T) { + if StateNone != 0 { + t.Errorf("StateNone = %d, want 0", StateNone) + } + if StateDisabled != 1 { + t.Errorf("StateDisabled = %d, want 1", StateDisabled) + } + if StateEnabled != 2 { + t.Errorf("StateEnabled = %d, want 2", StateEnabled) + } +} diff --git a/internal/channel/messages.go b/internal/channel/messages.go new file mode 100644 index 0000000..015ece9 --- /dev/null +++ b/internal/channel/messages.go @@ -0,0 +1,116 @@ +package channel + +import "time" + +// WelcomeMessage returns a time-aware welcome message. +func WelcomeMessage(t time.Time) string { + greeting := timeGreeting(t) + if isWeekend(t) { + return greeting + " jcode is online.\n" + + "————————————————\n" + + "Weekend mode — I'll notify you\n" + + "when approval is needed or tasks complete.\n" + + "Enjoy your time off!" + } + return greeting + " jcode is online.\n" + + "————————————————\n" + + "I'll notify you when approval is\n" + + "needed or tasks complete." +} + +// GoodbyeMessage returns a time-aware goodbye message. +func GoodbyeMessage(t time.Time) string { + farewell := timeFarewell(t) + if isWeekend(t) { + return "jcode session ended.\n" + + "————————————————\n" + + farewell + " Enjoy your weekend!" + } + return "jcode session ended.\n" + + "————————————————\n" + + farewell +} + +// ApprovalMessage returns a formatted approval notification. +func ApprovalMessage(toolName, toolArgs, hint string) string { + argsSummary := toolArgs + if len(argsSummary) > 150 { + argsSummary = argsSummary[:150] + "..." + } + return "⏳ Approval Needed\n" + + "————————————————\n" + + "Tool: " + toolName + "\n" + + "Args: " + argsSummary + "\n" + + "————————————————\n" + + hint +} + +// DoneMessage returns a formatted task completion notification. +func DoneMessage(summary string, err error) string { + if err != nil { + return "❌ Task Failed\n" + + "————————————————\n" + + err.Error() + } + s := summary + if len(s) > 500 { + s = s[:500] + "..." + } + if s == "" { + s = "(no summary)" + } + return "✅ Task Completed\n" + + "————————————————\n" + + s +} + +// BusyMessage returns a message for when a task is already in progress. +func BusyMessage() string { + return "⏳ Task In Progress\n" + + "————————————————\n" + + "A task is currently running.\n" + + "Your message has been queued and\n" + + "will be processed after the current\n" + + "task completes." +} + +func isWeekend(t time.Time) bool { + d := t.Weekday() + return d == time.Saturday || d == time.Sunday +} + +func timeGreeting(t time.Time) string { + h := t.Hour() + switch { + case h < 6: + return "🌙 Burning the midnight oil?" + case h < 9: + return "🌅 Good morning!" + case h < 12: + return "☀️ Good morning!" + case h < 14: + return "🍽 Good afternoon!" + case h < 18: + return "☀️ Good afternoon!" + case h < 22: + return "🌆 Good evening!" + default: + return "🌙 Working late?" + } +} + +func timeFarewell(t time.Time) string { + h := t.Hour() + switch { + case h < 6: + return "Get some rest! 😴" + case h < 12: + return "Have a great day ahead!" + case h < 18: + return "See you later!" + case h < 22: + return "Have a good evening!" + default: + return "Good night! 🌙" + } +} diff --git a/internal/command/channel.go b/internal/command/channel.go new file mode 100644 index 0000000..d500f9d --- /dev/null +++ b/internal/command/channel.go @@ -0,0 +1,138 @@ +package command + +import ( + "time" + + "github.com/cnjack/jcode/internal/channel" + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/tui" +) + +// handleChannelAction processes channel actions from the TUI. +func (s *interactiveState) handleChannelAction(action tui.ChannelAction) { + switch action.ChannelID { + case "wechat": + s.handleWeChatAction(action.Action) + default: + s.p.Send(tui.ChannelStateMsg{ + ChannelID: action.ChannelID, + State: "none", + Message: "Unknown channel: " + action.ChannelID, + }) + } +} + +func (s *interactiveState) handleWeChatAction(action string) { + switch action { + case "login": + s.p.Send(tui.ChannelStateMsg{ + ChannelID: "wechat", + State: s.wechatClient.State().String(), + Message: "Fetching login QR code...", + }) + + session, err := s.wechatClient.Login() + if err != nil { + s.p.Send(tui.ChannelStateMsg{ + ChannelID: "wechat", + State: "none", + Message: "Login failed: " + err.Error(), + }) + return + } + + // Send QR code URL to TUI + s.p.Send(tui.ChannelQRCodeMsg{ + ChannelID: "wechat", + QRCodeURL: session.QRCodeURL, + QRCodeContent: session.QRCodeContent, + Message: "Scan the QR code with WeChat to login", + }) + + // Wait for scan in background + go func() { + if err := session.WaitFunc(); err != nil { + s.p.Send(tui.ChannelStateMsg{ + ChannelID: "wechat", + State: "none", + Message: "Login failed: " + err.Error(), + }) + return + } + + s.p.Send(tui.ChannelStateMsg{ + ChannelID: "wechat", + State: s.wechatClient.State().String(), + Message: "WeChat login successful", + }) + + // Auto-enable after login + s.handleWeChatAction("enable") + }() + + case "logout": + if err := s.wechatClient.Logout(); err != nil { + s.p.Send(tui.ChannelStateMsg{ + ChannelID: "wechat", + State: s.wechatClient.State().String(), + Message: "Logout failed: " + err.Error(), + }) + return + } + s.p.Send(tui.ChannelStateMsg{ + ChannelID: "wechat", + State: "none", + Message: "WeChat logged out", + }) + + case "enable": + // Set up inbound message handler before enabling + s.wechatClient.SetOnMessage(func(from, text string) { + // Notify user if agent is busy + if s.agentRunning.Load() { + _ = s.wechatClient.SendText(channel.BusyMessage()) + } + s.p.Send(tui.ChannelInboundMsg{ + ChannelID: "wechat", + From: from, + Text: text, + }) + }) + + if err := s.wechatClient.Enable(); err != nil { + s.p.Send(tui.ChannelStateMsg{ + ChannelID: "wechat", + State: s.wechatClient.State().String(), + Message: "Enable failed: " + err.Error(), + }) + return + } + s.p.Send(tui.ChannelStateMsg{ + ChannelID: "wechat", + State: channel.StateEnabled.String(), + Message: "WeChat channel enabled", + }) + + // Send welcome message to the newly connected user (async, may wait for first poll) + go func() { + if err := s.wechatClient.SendText(channel.WelcomeMessage(time.Now())); err != nil { + config.Logger().Printf("[wechat] failed to send welcome: %v", err) + } + }() + + case "disable": + if err := s.wechatClient.Disable(); err != nil { + s.p.Send(tui.ChannelStateMsg{ + ChannelID: "wechat", + State: s.wechatClient.State().String(), + Message: "Disable failed: " + err.Error(), + }) + return + } + s.p.Send(tui.ChannelStateMsg{ + ChannelID: "wechat", + State: channel.StateDisabled.String(), + Message: "WeChat channel disabled", + }) + } +} diff --git a/internal/command/interactive.go b/internal/command/interactive.go index 0a78edc..ab2d642 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strconv" "strings" + "sync/atomic" + "time" tea "charm.land/bubbletea/v2" "github.com/cloudwego/eino/adk" @@ -19,9 +21,11 @@ import ( "github.com/cloudwego/eino/schema" "github.com/cnjack/jcode/internal/agent" + "github.com/cnjack/jcode/internal/channel" "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/handler" internalmodel "github.com/cnjack/jcode/internal/model" + weixin "github.com/cnjack/jcode/internal/pkg/weixin" "github.com/cnjack/jcode/internal/prompts" "github.com/cnjack/jcode/internal/runner" "github.com/cnjack/jcode/internal/session" @@ -64,6 +68,10 @@ type interactiveState struct { sessionResumeWarning string sessionBaselineCommit string + + // WeChat channel + wechatClient *weixin.Client + agentRunning atomic.Bool } func (s *interactiveState) buildAllTools() []tool.BaseTool { @@ -275,6 +283,9 @@ func (s *interactiveState) drainModeSwitch(planModeCh <-chan tui.AgentMode) { } func (s *interactiveState) handlePrompt(userPrompt string) { + s.agentRunning.Store(true) + defer s.agentRunning.Store(false) + if s.sessionResumeWarning != "" { userPrompt = s.sessionResumeWarning + "\n\n" + userPrompt s.sessionResumeWarning = "" @@ -629,6 +640,13 @@ func (s *interactiveState) runEventLoop(initialHistory []adk.Message, initialRes autoApproveCh := tui.GetAutoApproveChannel() compactCh := tui.GetCompactChannel() planModeCh := tui.GetPlanModeChannel() + channelActionCh := tui.GetChannelActionChannel() + + // Send initial WeChat state to TUI + s.p.Send(tui.ChannelStateMsg{ + ChannelID: "wechat", + State: s.wechatClient.State().String(), + }) for { select { @@ -661,6 +679,9 @@ func (s *interactiveState) runEventLoop(initialHistory []adk.Message, initialRes case <-addModelCh: s.handleAddModel() + + case action := <-channelActionCh: + s.handleChannelAction(action) } } } @@ -782,6 +803,36 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { rec: rec, } + // Initialize WeChat channel + st.wechatClient = weixin.NewClient() + + // Auto-enable WeChat if credentials exist + if st.wechatClient.State() == channel.StateDisabled { + st.wechatClient.SetOnMessage(func(from, text string) { + if st.p != nil { + // Notify user if agent is busy + if st.agentRunning.Load() { + _ = st.wechatClient.SendText(channel.BusyMessage()) + } + st.p.Send(tui.ChannelInboundMsg{ + ChannelID: "wechat", + From: from, + Text: text, + }) + } + }) + if err := st.wechatClient.Enable(); err != nil { + config.Logger().Printf("[wechat] auto-enable failed: %v", err) + } else { + config.Logger().Printf("[wechat] auto-enabled on startup") + go func() { + if err := st.wechatClient.SendText(channel.WelcomeMessage(time.Now())); err != nil { + config.Logger().Printf("[wechat] failed to send welcome: %v", err) + } + }() + } + } + if cfg.Telemetry != nil && cfg.Telemetry.Langfuse != nil { st.langfuseTracer = telemetry.NewLangfuseTracer(cfg.Telemetry.Langfuse) } @@ -843,8 +894,26 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { teamManager.SetTuiProgram(p) h := handler.NewTUIHandler(p) - st.h = h - approvalState.SetHandler(h) + + // Wrap with notifying handler for WeChat push notifications + notifyingH := handler.NewNotifyingHandler(h, 10*time.Second) + notifyingH.SetApprovalNotifier(func(toolName, toolArgs string) { + if st.wechatClient.State() == channel.StateEnabled { + if err := st.wechatClient.SendText(channel.ApprovalMessage(toolName, toolArgs, "Please return to terminal")); err != nil { + config.Logger().Printf("[wechat] failed to send approval notification: %v", err) + } + } + }) + notifyingH.SetDoneNotifier(func(summary string, err error) { + if st.wechatClient.State() == channel.StateEnabled { + if sendErr := st.wechatClient.SendText(channel.DoneMessage(summary, err)); sendErr != nil { + config.Logger().Printf("[wechat] failed to send done notification: %v", sendErr) + } + } + }) + + st.h = notifyingH + approvalState.SetHandler(notifyingH) teamManager.SetHandlersFactory(func(workerName, workerColor string) []adk.ChatModelAgentMiddleware { return agent.NewTeammateHandlers(approvalState.NewTeammateApprovalFunc(workerName, workerColor)) @@ -951,6 +1020,15 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { } return fmt.Errorf("TUI error: %w", err) } + + // Send goodbye via WeChat if enabled + if st.wechatClient.State() == channel.StateEnabled { + // Best-effort, don't block exit + go func() { _ = st.wechatClient.SendText(channel.GoodbyeMessage(time.Now())) }() + time.Sleep(500 * time.Millisecond) + _ = st.wechatClient.Disable() + } + if st.langfuseTracer != nil { st.langfuseTracer.Flush() } diff --git a/internal/command/web.go b/internal/command/web.go index 7d51933..73642bf 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -6,6 +6,7 @@ import ( "os/signal" "path/filepath" "syscall" + "time" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/adk/middlewares/reduction" @@ -15,9 +16,11 @@ import ( "github.com/spf13/cobra" "github.com/cnjack/jcode/internal/agent" + "github.com/cnjack/jcode/internal/channel" "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/handler" internalmodel "github.com/cnjack/jcode/internal/model" + weixin "github.com/cnjack/jcode/internal/pkg/weixin" "github.com/cnjack/jcode/internal/prompts" "github.com/cnjack/jcode/internal/runner" "github.com/cnjack/jcode/internal/session" @@ -102,6 +105,39 @@ func runWebServer(port int, host string) error { // Create WebHandler early so subagent tool can emit events through it. webHandler := handler.NewWebHandler() + // Wrap handler with NotifyingHandler for WeChat push notifications. + // Callbacks check wechatClient.State() before sending, so this is safe + // even when the channel is disabled or not yet configured. + var finalHandler handler.AgentEventHandler = webHandler + wechatClient := weixin.NewClient() + + // Auto-enable if credentials exist and channel.web_enabled is true. + if cfg.Channel != nil && cfg.Channel.WebEnabled && wechatClient.State() == channel.StateDisabled { + if err := wechatClient.Enable(); err != nil { + config.Logger().Printf("[wechat] web auto-enable failed: %v", err) + } else { + config.Logger().Printf("[wechat] web auto-enabled") + } + } + + // Always wrap with NotifyingHandler — the user can enable via the UI toggle. + notifyingH := handler.NewNotifyingHandler(webHandler, 10*time.Second) + notifyingH.SetApprovalNotifier(func(toolName, toolArgs string) { + if wechatClient.State() == channel.StateEnabled { + if err := wechatClient.SendText(channel.ApprovalMessage(toolName, toolArgs, "Please check the web interface")); err != nil { + config.Logger().Printf("[wechat] failed to send approval notification: %v", err) + } + } + }) + notifyingH.SetDoneNotifier(func(summary string, err error) { + if wechatClient.State() == channel.StateEnabled { + if sendErr := wechatClient.SendText(channel.DoneMessage(summary, err)); sendErr != nil { + config.Logger().Printf("[wechat] failed to send done notification: %v", sendErr) + } + } + }) + finalHandler = notifyingH + // Langfuse tracer. var langfuseTracer *telemetry.LangfuseTracer if cfg.Telemetry != nil && cfg.Telemetry.Langfuse != nil { @@ -261,11 +297,37 @@ func runWebServer(port int, host string) error { Registry: registry, ApprovalState: approvalState, SkillLoader: skillLoader, + WechatClient: wechatClient, WebHandler: webHandler, + EventHandler: finalHandler, }) // Set handler for approval routing. - approvalState.SetHandler(srv.Handler()) + // If WeChat channel wraps the handler, use the wrapping handler for notifications. + approvalState.SetHandler(finalHandler) + + // Set up inbound WeChat message handler now that srv exists. + // Always register regardless of WebEnabled — the user can enable via the UI. + wechatClient.SetOnMessage(func(from, text string) { + if wechatClient.State() != channel.StateEnabled { + return // channel disabled, silently ignore + } + config.Logger().Printf("[wechat] inbound message from %s: %s", from, text) + if !srv.SubmitMessage(text, "wechat") { + // Agent is busy, let the user know. + _ = wechatClient.SendText(channel.BusyMessage()) + } + }) + + // Clean up WeChat on shutdown. + defer func() { + if wechatClient.State() == channel.StateEnabled { + // Best-effort, don't block shutdown + go func() { _ = wechatClient.SendText(channel.GoodbyeMessage(time.Now())) }() + time.Sleep(500 * time.Millisecond) + _ = wechatClient.Disable() + } + }() if err := srv.Start(ctx); err != nil { return fmt.Errorf("server error: %w", err) diff --git a/internal/config/config.go b/internal/config/config.go index acaef3a..4d15eb6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -56,6 +56,12 @@ type BudgetConfig struct { WarningThreshold float64 `json:"warning_threshold,omitempty"` } +// ChannelConfig controls external messaging channel behavior. +type ChannelConfig struct { + // WebEnabled enables WeChat channel in web mode (default false). + WebEnabled bool `json:"web_enabled,omitempty"` +} + // CompactionConfig controls automatic context compaction. type CompactionConfig struct { Enabled bool `json:"enabled,omitempty"` @@ -109,6 +115,9 @@ type Config struct { // AutoApprove sets the default approval mode to auto on startup. AutoApprove bool `json:"auto_approve,omitempty"` + // Channel controls external messaging channel behavior. + Channel *ChannelConfig `json:"channel,omitempty"` + // DisabledProviders lists provider IDs to exclude from registry DisabledProviders []string `json:"disabled_providers,omitempty"` } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 583d1c2..eaf0dfc 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -59,6 +59,7 @@ type TokenUsage struct { type ApprovalRequest struct { ToolName string ToolArgs string + ToolCallID string // unique ID of this tool invocation (from the LLM) IsExternal bool // true when accessing paths outside workpath WorkerName string // non-empty for teammate agents WorkerColor string diff --git a/internal/handler/notifying.go b/internal/handler/notifying.go new file mode 100644 index 0000000..d945685 --- /dev/null +++ b/internal/handler/notifying.go @@ -0,0 +1,128 @@ +package handler + +import ( + "context" + "sync" + "time" +) + +// NotifyingHandler wraps another AgentEventHandler and adds delayed notification +// capabilities for approval requests and agent completion events. +type NotifyingHandler struct { + inner AgentEventHandler + + mu sync.Mutex + onApprovalDelay func(toolName, toolArgs string) // called after delay if approval not resolved + onAgentDone func(summaryText string, err error) // called on agent completion + approvalTimers map[string]*time.Timer + resolvedApprovals map[string]bool // approvalIDs that were already answered + approvalDelay time.Duration + lastText string // capture last text for summary +} + +// NewNotifyingHandler creates a handler that wraps inner and can fire external +// notifications for approvals (after a delay) and agent completion. +func NewNotifyingHandler(inner AgentEventHandler, delay time.Duration) *NotifyingHandler { + return &NotifyingHandler{ + inner: inner, + approvalTimers: make(map[string]*time.Timer), + resolvedApprovals: make(map[string]bool), + approvalDelay: delay, + } +} + +// SetApprovalNotifier sets the callback fired when an approval is not resolved within the delay. +func (h *NotifyingHandler) SetApprovalNotifier(fn func(toolName, toolArgs string)) { + h.mu.Lock() + defer h.mu.Unlock() + h.onApprovalDelay = fn +} + +// SetDoneNotifier sets the callback fired when the agent finishes. +func (h *NotifyingHandler) SetDoneNotifier(fn func(summary string, err error)) { + h.mu.Lock() + defer h.mu.Unlock() + h.onAgentDone = fn +} + +func (h *NotifyingHandler) OnAgentText(text string) { + h.mu.Lock() + h.lastText += text + // Keep only last 600 chars for summary + if len(h.lastText) > 600 { + h.lastText = h.lastText[len(h.lastText)-600:] + } + h.mu.Unlock() + h.inner.OnAgentText(text) +} + +func (h *NotifyingHandler) OnToolCall(name, args, toolCallID string) { + h.inner.OnToolCall(name, args, toolCallID) +} + +func (h *NotifyingHandler) OnToolResult(name, output, toolCallID string, err error) { + h.inner.OnToolResult(name, output, toolCallID, err) +} + +func (h *NotifyingHandler) OnTodoUpdate() { + h.inner.OnTodoUpdate() +} + +func (h *NotifyingHandler) OnAgentDone(err error) { + h.inner.OnAgentDone(err) + + h.mu.Lock() + fn := h.onAgentDone + summary := h.lastText + h.lastText = "" + h.mu.Unlock() + + if fn != nil { + fn(summary, err) + } +} + +func (h *NotifyingHandler) OnTokenUpdate(info TokenUsage) { + h.inner.OnTokenUpdate(info) +} + +func (h *NotifyingHandler) RequestApproval(ctx context.Context, req ApprovalRequest) (ApprovalResponse, error) { + // Use toolCallID if available, otherwise fall back to tool name + args + approvalID := req.ToolCallID + if approvalID == "" { + approvalID = req.ToolName + ":" + req.ToolArgs + } + + // Schedule delayed notification only if not already resolved + h.mu.Lock() + fn := h.onApprovalDelay + if fn != nil && h.approvalDelay > 0 { + timer := time.AfterFunc(h.approvalDelay, func() { + h.mu.Lock() + delete(h.approvalTimers, approvalID) + already := h.resolvedApprovals[approvalID] + notifyFn := h.onApprovalDelay + h.mu.Unlock() + // Only notify if approval hasn't been answered yet + if !already && notifyFn != nil { + notifyFn(req.ToolName, req.ToolArgs) + } + }) + h.approvalTimers[approvalID] = timer + } + h.mu.Unlock() + + // Delegate to inner handler (blocks until user responds) + resp, err := h.inner.RequestApproval(ctx, req) + + // Mark as resolved and cancel the timer if it hasn't fired yet + h.mu.Lock() + h.resolvedApprovals[approvalID] = true + if timer, ok := h.approvalTimers[approvalID]; ok { + timer.Stop() + delete(h.approvalTimers, approvalID) + } + h.mu.Unlock() + + return resp, err +} diff --git a/internal/handler/notifying_test.go b/internal/handler/notifying_test.go new file mode 100644 index 0000000..e6dc354 --- /dev/null +++ b/internal/handler/notifying_test.go @@ -0,0 +1,235 @@ +package handler + +import ( + "context" + "sync" + "testing" + "time" +) + +// mockHandler is a test double for AgentEventHandler. +type mockHandler struct { + mu sync.Mutex + texts []string + toolCalls []string + doneErr error + doneCalled bool + approvalResp ApprovalResponse + approvalErr error + approvalCh chan struct{} // signal when RequestApproval is called + + // block approval until released + approvalGate chan struct{} +} + +func newMockHandler() *mockHandler { + return &mockHandler{ + approvalResp: ApprovalResponse{Approved: true, Mode: ModeManual}, + approvalCh: make(chan struct{}, 1), + approvalGate: make(chan struct{}), + } +} + +func (m *mockHandler) OnAgentText(text string) { + m.mu.Lock() + defer m.mu.Unlock() + m.texts = append(m.texts, text) +} + +func (m *mockHandler) OnToolCall(name, args, toolCallID string) { + m.mu.Lock() + defer m.mu.Unlock() + m.toolCalls = append(m.toolCalls, name) +} + +func (m *mockHandler) OnToolResult(name, output, toolCallID string, err error) {} +func (m *mockHandler) OnTodoUpdate() {} + +func (m *mockHandler) OnAgentDone(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.doneCalled = true + m.doneErr = err +} + +func (m *mockHandler) OnTokenUpdate(info TokenUsage) {} + +func (m *mockHandler) RequestApproval(ctx context.Context, req ApprovalRequest) (ApprovalResponse, error) { + // Signal that approval was requested + select { + case m.approvalCh <- struct{}{}: + default: + } + // Wait for the gate to open (simulates user thinking) + select { + case <-m.approvalGate: + case <-ctx.Done(): + return ApprovalResponse{}, ctx.Err() + } + m.mu.Lock() + defer m.mu.Unlock() + return m.approvalResp, m.approvalErr +} + +func TestNotifyingHandler_DoneNotification(t *testing.T) { + inner := newMockHandler() + close(inner.approvalGate) // don't block + h := NewNotifyingHandler(inner, 10*time.Second) + + var gotSummary string + var gotErr error + h.SetDoneNotifier(func(summary string, err error) { + gotSummary = summary + gotErr = err + }) + + h.OnAgentText("hello ") + h.OnAgentText("world") + h.OnAgentDone(nil) + + if !inner.doneCalled { + t.Fatal("inner.OnAgentDone not called") + } + if gotSummary != "hello world" { + t.Fatalf("expected summary 'hello world', got %q", gotSummary) + } + if gotErr != nil { + t.Fatalf("expected nil error, got %v", gotErr) + } +} + +func TestNotifyingHandler_DoneWithError(t *testing.T) { + inner := newMockHandler() + close(inner.approvalGate) + h := NewNotifyingHandler(inner, 10*time.Second) + + var gotErr error + h.SetDoneNotifier(func(summary string, err error) { + gotErr = err + }) + + testErr := context.DeadlineExceeded + h.OnAgentDone(testErr) + + if gotErr != testErr { + t.Fatalf("expected %v, got %v", testErr, gotErr) + } +} + +func TestNotifyingHandler_ApprovalNotificationFires(t *testing.T) { + inner := newMockHandler() + h := NewNotifyingHandler(inner, 50*time.Millisecond) + + var notifiedTool string + var notifyMu sync.Mutex + h.SetApprovalNotifier(func(toolName, toolArgs string) { + notifyMu.Lock() + notifiedTool = toolName + notifyMu.Unlock() + }) + + // Start approval in background (will block on inner.approvalGate) + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = h.RequestApproval(context.Background(), ApprovalRequest{ + ToolName: "execute", + ToolArgs: `{"command":"rm -rf /"}`, + }) + }() + + // Wait for the notification to fire (50ms delay + buffer) + time.Sleep(150 * time.Millisecond) + + notifyMu.Lock() + if notifiedTool != "execute" { + t.Fatalf("expected notification for 'execute', got %q", notifiedTool) + } + notifyMu.Unlock() + + // Release the approval + close(inner.approvalGate) + <-done +} + +func TestNotifyingHandler_ApprovalResolvedBeforeNotification(t *testing.T) { + inner := newMockHandler() + h := NewNotifyingHandler(inner, 5*time.Second) // long delay + + var notified bool + var notifyMu sync.Mutex + h.SetApprovalNotifier(func(toolName, toolArgs string) { + notifyMu.Lock() + notified = true + notifyMu.Unlock() + }) + + // Immediately release approval + close(inner.approvalGate) + + resp, err := h.RequestApproval(context.Background(), ApprovalRequest{ + ToolName: "edit", + ToolArgs: "{}", + }) + if err != nil { + t.Fatal(err) + } + if !resp.Approved { + t.Fatal("expected approved") + } + + // Wait a bit to make sure notification didn't fire + time.Sleep(100 * time.Millisecond) + + notifyMu.Lock() + if notified { + t.Fatal("notification should NOT have fired since approval resolved quickly") + } + notifyMu.Unlock() +} + +func TestNotifyingHandler_TextSummaryCapping(t *testing.T) { + inner := newMockHandler() + close(inner.approvalGate) + h := NewNotifyingHandler(inner, 10*time.Second) + + var gotLen int + h.SetDoneNotifier(func(summary string, err error) { + gotLen = len(summary) + }) + + // Send more than 600 chars + bigText := string(make([]byte, 1000)) + for i := range bigText { + bigText = bigText[:i] + "x" + bigText[i+1:] + } + h.OnAgentText(bigText) + h.OnAgentDone(nil) + + if gotLen != 600 { + t.Fatalf("expected summary capped to 600 chars, got %d", gotLen) + } +} + +func TestNotifyingHandler_Passthrough(t *testing.T) { + inner := newMockHandler() + close(inner.approvalGate) + h := NewNotifyingHandler(inner, 10*time.Second) + + h.OnAgentText("test") + h.OnToolCall("edit", "{}", "call_1") + h.OnAgentDone(nil) + + inner.mu.Lock() + defer inner.mu.Unlock() + + if len(inner.texts) != 1 || inner.texts[0] != "test" { + t.Fatalf("text not passed through: %v", inner.texts) + } + if len(inner.toolCalls) != 1 || inner.toolCalls[0] != "edit" { + t.Fatalf("tool call not passed through: %v", inner.toolCalls) + } + if !inner.doneCalled { + t.Fatal("done not passed through") + } +} diff --git a/internal/handler/web.go b/internal/handler/web.go index 1c3de8f..2431f1d 100644 --- a/internal/handler/web.go +++ b/internal/handler/web.go @@ -102,6 +102,11 @@ func (h *WebHandler) emit(event string, data any) { } } +// Emit sends a custom event to all connected web clients. +func (h *WebHandler) Emit(event string, data any) { + h.emit(event, data) +} + // --- Output events --- func (h *WebHandler) OnAgentText(text string) { diff --git a/internal/pkg/weixin/api.go b/internal/pkg/weixin/api.go new file mode 100644 index 0000000..7ee3c74 --- /dev/null +++ b/internal/pkg/weixin/api.go @@ -0,0 +1,313 @@ +// Package weixin implements the WeChat iLink Bot API client. +// It provides a pure HTTP SDK for QR login, long-poll message receiving, +// and text sending. No jcode-specific dependencies. +package weixin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + DefaultBaseURL = "https://ilinkai.weixin.qq.com" + defaultBotType = "3" + sendTimeoutMs = 15000 + pollHTTPTimeoutSec = 40 +) + +// --- Wire types --- + +type qrCodeResponse struct { + QRCode string `json:"qrcode"` + QRCodeImgContent string `json:"qrcode_img_content"` +} + +type qrStatusResponse struct { + Status string `json:"status"` + BotToken string `json:"bot_token"` + ILinkBotID string `json:"ilink_bot_id"` + BaseURL string `json:"baseurl"` + ILinkUserID string `json:"ilink_user_id"` + RedirectHost string `json:"redirect_host"` +} + +type baseInfo struct { + ChannelVersion string `json:"channel_version"` +} + +type getUpdatesRequest struct { + GetUpdatesBuf string `json:"get_updates_buf"` + BaseInfo baseInfo `json:"base_info"` +} + +// GetUpdatesResponse is the parsed response from /ilink/bot/getupdates. +type GetUpdatesResponse struct { + Ret int `json:"ret"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + Msgs []Message `json:"msgs"` + GetUpdatesBuf string `json:"get_updates_buf"` +} + +// Message is a single inbound WeChat message. +type Message struct { + Seq int `json:"seq"` + MessageID int `json:"message_id"` + FromUserID string `json:"from_user_id"` + ToUserID string `json:"to_user_id"` + ClientID string `json:"client_id"` + CreateTimeMs int64 `json:"create_time_ms"` + MessageType int `json:"message_type"` + MessageState int `json:"message_state"` + ItemList []Item `json:"item_list"` + ContextToken string `json:"context_token"` +} + +// TextBody extracts the text content from the message, or returns empty string. +func (m Message) TextBody() string { + for _, item := range m.ItemList { + if item.Type == ItemTypeText && item.TextItem != nil { + return item.TextItem.Text + } + } + return "" +} + +// Item is a single content item within a message. +type Item struct { + Type int `json:"type"` + TextItem *TextItem `json:"text_item,omitempty"` +} + +// TextItem holds the text payload. +type TextItem struct { + Text string `json:"text"` +} + +// Item type constants. +const ( + ItemTypeText = 1 + ItemTypeImage = 2 + ItemTypeVoice = 3 + ItemTypeFile = 4 + ItemTypeVideo = 5 +) + +type sendMessageRequest struct { + Msg sendMsg `json:"msg"` + BaseInfo baseInfo `json:"base_info"` +} + +type sendMsg struct { + FromUserID string `json:"from_user_id"` + ToUserID string `json:"to_user_id"` + ClientID string `json:"client_id"` + MessageType int `json:"message_type"` + MessageState int `json:"message_state"` + ItemList []Item `json:"item_list"` + ContextToken string `json:"context_token,omitempty"` +} + +// LoginResult is the outcome of a QR login flow. +type LoginResult struct { + Connected bool + Token string + BaseURL string + UserID string + Message string +} + +// --- API functions --- + +// FetchQRCode fetches a login QR code from the iLink service. +// Returns QRCodeImgContent (the URL to encode as QR) and QRCode (the session polling key). +func FetchQRCode(baseURL string) (qrContent, sessionKey string, err error) { + if baseURL == "" { + baseURL = DefaultBaseURL + } + u := fmt.Sprintf("%s/ilink/bot/get_bot_qrcode?bot_type=%s", baseURL, url.QueryEscape(defaultBotType)) + resp, err := http.Get(u) //nolint:gosec + if err != nil { + return "", "", err + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + var r qrCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return "", "", err + } + return r.QRCodeImgContent, r.QRCode, nil +} + +// PollLoginUntilDone polls the QR scan status until confirmed, expired, or timeout. +// It handles IDC redirects (scaned_but_redirect) transparently. +func PollLoginUntilDone(baseURL, sessionKey string, timeout time.Duration) *LoginResult { + if baseURL == "" { + baseURL = DefaultBaseURL + } + client := &http.Client{Timeout: pollHTTPTimeoutSec * time.Second} + currentBase := baseURL + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + u := fmt.Sprintf("%s/ilink/bot/get_qrcode_status?qrcode=%s", currentBase, url.QueryEscape(sessionKey)) + resp, err := client.Get(u) + if err != nil { + time.Sleep(2 * time.Second) + continue + } + var r qrStatusResponse + _ = json.NewDecoder(resp.Body).Decode(&r) + resp.Body.Close() //nolint:errcheck + + switch r.Status { + case "wait", "scaned": + // continue + case "confirmed": + resultBase := DefaultBaseURL + if r.BaseURL != "" { + resultBase = r.BaseURL + } + return &LoginResult{ + Connected: true, + Token: r.BotToken, + BaseURL: resultBase, + UserID: r.ILinkUserID, + Message: "login successful", + } + case "expired": + return &LoginResult{Connected: false, Message: "QR code expired"} + case "scaned_but_redirect": + if r.RedirectHost != "" { + currentBase = r.RedirectHost + } + } + } + return &LoginResult{Connected: false, Message: "login timed out"} +} + +// GetUpdates performs one long-poll for inbound messages. +// The context controls the HTTP request lifetime; cancel it to stop the poll. +func GetUpdates(ctx context.Context, baseURL, token, syncBuf string) (*GetUpdatesResponse, error) { + body, err := json.Marshal(getUpdatesRequest{ + GetUpdatesBuf: syncBuf, + BaseInfo: baseInfo{ChannelVersion: "jcode/1.0.0"}, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + baseURL+"/ilink/bot/getupdates", bytes.NewReader(body)) + if err != nil { + return nil, err + } + setAuthHeaders(req, token) + + hc := &http.Client{Timeout: pollHTTPTimeoutSec * time.Second} + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("session expired (401)") + } + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b)) + } + + var r GetUpdatesResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return nil, err + } + if r.Ret != 0 || r.ErrCode != 0 { + return nil, fmt.Errorf("getupdates error: ret=%d errcode=%d errmsg=%s", r.Ret, r.ErrCode, r.ErrMsg) + } + return &r, nil +} + +// SendText sends a text message to toUserID. +// contextToken should be echoed from the inbound message when replying; pass "" for proactive sends. +func SendText(baseURL, token, toUserID, text, contextToken string) error { + clientID := fmt.Sprintf("jcode-%d", time.Now().UnixNano()) + body, err := json.Marshal(sendMessageRequest{ + Msg: sendMsg{ + FromUserID: "", + ToUserID: toUserID, + ClientID: clientID, + MessageType: 2, // BOT + MessageState: 2, // FINISH + ItemList: []Item{ + {Type: ItemTypeText, TextItem: &TextItem{Text: text}}, + }, + ContextToken: contextToken, + }, + BaseInfo: baseInfo{ChannelVersion: "jcode/1.0.0"}, + }) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, baseURL+"/ilink/bot/sendmessage", bytes.NewReader(body)) + if err != nil { + return err + } + setAuthHeaders(req, token) + + hc := &http.Client{Timeout: time.Duration(sendTimeoutMs) * time.Millisecond} + resp, err := hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("sendmessage HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + var result struct { + Ret int `json:"ret"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(respBody, &result); err == nil && (result.Ret != 0 || result.ErrCode != 0) { + return fmt.Errorf("sendmessage API error: ret=%d errcode=%d errmsg=%s", result.Ret, result.ErrCode, result.ErrMsg) + } + return nil +} + +func setAuthHeaders(req *http.Request, token string) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("AuthorizationType", "ilink_bot_token") +} + +// IsSessionNotReady returns true if the error indicates the session has not +// been activated yet (getupdates has not completed at least once). +func IsSessionNotReady(err error) bool { + return err != nil && strings.Contains(err.Error(), "ret=-2") +} + +// IsSessionExpired returns true if the error indicates the bot token is invalid. +func IsSessionExpired(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "session expired") || + strings.Contains(err.Error(), "errcode=-14") || + strings.Contains(err.Error(), "ret=-14") +} diff --git a/internal/pkg/weixin/client.go b/internal/pkg/weixin/client.go new file mode 100644 index 0000000..a2b3d63 --- /dev/null +++ b/internal/pkg/weixin/client.go @@ -0,0 +1,325 @@ +// Package weixin is the channel.Channel implementation for WeChat. +// It wraps the iLink API (internal/pkg/weixin/api.go) with credential +// persistence, poll loop lifecycle, and the jcode channel interface. +package weixin + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/cnjack/jcode/internal/channel" + "github.com/cnjack/jcode/internal/config" +) + +const ( + maxConsecutiveFailures = 3 + backoffDelay = 30 * time.Second + retryDelay = 2 * time.Second + sendRetryDeadline = 45 * time.Second + sendRetryInterval = 3 * time.Second + loginTimeout = 8 * time.Minute +) + +// Credentials holds the persisted login state. +type Credentials struct { + Token string `json:"token"` + BaseURL string `json:"base_url"` + UserID string `json:"user_id"` + SyncBuf string `json:"sync_buf,omitempty"` + SavedAt string `json:"saved_at"` +} + +// Client implements channel.Channel for WeChat via the iLink Bot API. +type Client struct { + mu sync.Mutex + state channel.State + creds *Credentials + cancel context.CancelFunc + onMessage func(from, text string) +} + +// NewClient creates a new WeChat channel client, loading any saved credentials. +func NewClient() *Client { + c := &Client{state: channel.StateNone} + if creds, err := loadCredentials(); err == nil && creds.Token != "" { + c.creds = creds + c.state = channel.StateDisabled + } + return c +} + +func (c *Client) ID() string { return "wechat" } + +func (c *Client) State() channel.State { + c.mu.Lock() + defer c.mu.Unlock() + return c.state +} + +// SetOnMessage registers a callback for inbound messages. +func (c *Client) SetOnMessage(fn func(from, text string)) { + c.mu.Lock() + defer c.mu.Unlock() + c.onMessage = fn +} + +// Login initiates a QR code login flow and returns a LoginSession. +func (c *Client) Login() (*channel.LoginSession, error) { + qrContent, sessionKey, err := FetchQRCode(DefaultBaseURL) + if err != nil { + return nil, fmt.Errorf("fetch QR code: %w", err) + } + + sess := &channel.LoginSession{ + QRCodeURL: qrContent, + QRCodeContent: qrContent, + SessionKey: sessionKey, + } + sess.WaitFunc = func() error { + result := PollLoginUntilDone(DefaultBaseURL, sessionKey, loginTimeout) + if !result.Connected { + return fmt.Errorf("login failed: %s", result.Message) + } + creds := &Credentials{ + Token: result.Token, + BaseURL: result.BaseURL, + UserID: result.UserID, + SavedAt: time.Now().Format(time.RFC3339), + } + if err := saveCredentials(creds); err != nil { + return fmt.Errorf("save credentials: %w", err) + } + c.mu.Lock() + c.creds = creds + c.state = channel.StateDisabled + c.mu.Unlock() + return nil + } + return sess, nil +} + +// Logout clears credentials and stops polling. +func (c *Client) Logout() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.cancel != nil { + c.cancel() + c.cancel = nil + } + c.creds = nil + c.state = channel.StateNone + return deleteCredentials() +} + +// Enable starts the inbound message poll loop. +func (c *Client) Enable() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.creds == nil || c.creds.Token == "" { + return fmt.Errorf("not logged in, please login first") + } + if c.state == channel.StateEnabled { + return nil + } + ctx, cancel := context.WithCancel(context.Background()) + c.cancel = cancel + c.state = channel.StateEnabled + go c.pollLoop(ctx) + return nil +} + +// Disable stops polling but keeps credentials. +func (c *Client) Disable() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.cancel != nil { + c.cancel() + c.cancel = nil + } + if c.creds != nil { + c.state = channel.StateDisabled + } else { + c.state = channel.StateNone + } + return nil +} + +// SendText sends a text message to the connected user. +// Retries for up to 45 s if the session is not yet activated (ret=-2). +func (c *Client) SendText(text string) error { + c.mu.Lock() + creds := c.creds + state := c.state + c.mu.Unlock() + + if state != channel.StateEnabled { + return fmt.Errorf("channel not enabled") + } + if creds == nil { + return fmt.Errorf("not logged in") + } + + baseURL := creds.BaseURL + if baseURL == "" { + baseURL = DefaultBaseURL + } + + deadline := time.Now().Add(sendRetryDeadline) + for { + err := SendText(baseURL, creds.Token, creds.UserID, text, "") + if err == nil { + config.Logger().Printf("[weixin] sent %d bytes to %s", len(text), creds.UserID) + return nil + } + if IsSessionNotReady(err) && time.Now().Before(deadline) { + time.Sleep(sendRetryInterval) + continue + } + return err + } +} + +// pollLoop is the long-poll background goroutine. +func (c *Client) pollLoop(ctx context.Context) { + consecutiveFailures := 0 + + for { + select { + case <-ctx.Done(): + return + default: + } + + c.mu.Lock() + creds := c.creds + var syncBuf string + if creds != nil { + syncBuf = creds.SyncBuf + } + c.mu.Unlock() + + if creds == nil { + return + } + + baseURL := creds.BaseURL + if baseURL == "" { + baseURL = DefaultBaseURL + } + + resp, err := GetUpdates(ctx, baseURL, creds.Token, syncBuf) + if err != nil { + if ctx.Err() != nil { + return + } + if IsSessionExpired(err) { + config.Logger().Printf("[weixin] session expired, disabling") + _ = c.Disable() + return + } + consecutiveFailures++ + wait := retryDelay + if consecutiveFailures >= maxConsecutiveFailures { + consecutiveFailures = 0 + wait = backoffDelay + } + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + continue + } + + consecutiveFailures = 0 + + if resp.GetUpdatesBuf != "" { + c.mu.Lock() + if c.creds != nil { + c.creds.SyncBuf = resp.GetUpdatesBuf + _ = saveCredentials(c.creds) + } + c.mu.Unlock() + } + + for _, msg := range resp.Msgs { + text := msg.TextBody() + if text == "" { + continue + } + c.mu.Lock() + handler := c.onMessage + c.mu.Unlock() + if handler != nil { + handler(msg.FromUserID, text) + } + } + } +} + +// --- Credential persistence --- + +func credentialsPath() string { + return filepath.Join(config.ConfigDir(), "channel", "wechat.json") +} + +func legacyCredentialsPath() string { + return filepath.Join(config.ConfigDir(), "wechat.json") +} + +func legacySyncBufPath() string { + return filepath.Join(config.ConfigDir(), "wechat_sync.buf") +} + +func loadCredentials() (*Credentials, error) { + data, err := os.ReadFile(credentialsPath()) + if err != nil { + // Try legacy paths and migrate + data, err = os.ReadFile(legacyCredentialsPath()) + if err != nil { + return nil, err + } + var creds Credentials + if err := json.Unmarshal(data, &creds); err != nil { + return nil, err + } + if buf, bufErr := os.ReadFile(legacySyncBufPath()); bufErr == nil { + creds.SyncBuf = string(buf) + } + _ = saveCredentials(&creds) + _ = os.Remove(legacyCredentialsPath()) + _ = os.Remove(legacySyncBufPath()) + return &creds, nil + } + var creds Credentials + if err := json.Unmarshal(data, &creds); err != nil { + return nil, err + } + return &creds, nil +} + +func saveCredentials(creds *Credentials) error { + data, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return err + } + dir := filepath.Join(config.ConfigDir(), "channel") + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + return os.WriteFile(credentialsPath(), data, 0o600) +} + +func deleteCredentials() error { + if err := os.Remove(credentialsPath()); err != nil && !os.IsNotExist(err) { + return err + } + _ = os.Remove(legacyCredentialsPath()) + _ = os.Remove(legacySyncBufPath()) + return nil +} diff --git a/internal/tui/channel_panel.go b/internal/tui/channel_panel.go new file mode 100644 index 0000000..b6af6cc --- /dev/null +++ b/internal/tui/channel_panel.go @@ -0,0 +1,164 @@ +package tui + +import ( + "bytes" + "fmt" + "strings" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + qrterminal "github.com/mdp/qrterminal/v3" +) + +// handleChannelKeyPress handles keyboard input when the channel panel is active. +func (m Model) handleChannelKeyPress(msg tea.KeyPressMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + selected := m.channelMenu.SelectedItem() + if selected == nil { + return m, tea.Batch(cmds...) + } + + switch item := selected.(type) { + case channelItem: + // Selected a channel — show its actions + return m.showChannelActions(item.key, cmds) + case channelActionItem: + // Selected an action — dispatch it + m.showingChannel = false + m.textarea.Focus() + + select { + case channelActionCh <- ChannelAction{ + ChannelID: item.channelID, + Action: item.action, + }: + default: + } + + m.lines = append(m.lines, toolLabelStyle.Render( + fmt.Sprintf("📡 %s: %s...", item.channelID, item.action))) + m.refreshViewport() + return m, tea.Batch(cmds...) + } + + case "ctrl+c", "esc": + m.showingChannel = false + m.textarea.Focus() + m.refreshViewport() + return m, tea.Batch(cmds...) + } + + var cmd tea.Cmd + m.channelMenu, cmd = m.channelMenu.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +// showChannelActions replaces the channel list with available actions for the selected channel. +func (m Model) showChannelActions(channelID string, cmds []tea.Cmd) (tea.Model, tea.Cmd) { + state := m.channelStates[channelID] + var items []list.Item + + switch state { + case "enabled": + items = []list.Item{ + channelActionItem{title: "Disable", desc: "Stop push notifications and message polling", action: "disable", channelID: channelID}, + channelActionItem{title: "Logout", desc: "Clear login credentials", action: "logout", channelID: channelID}, + } + case "disabled": + items = []list.Item{ + channelActionItem{title: "Enable", desc: "Start push notifications and message polling", action: "enable", channelID: channelID}, + channelActionItem{title: "Logout", desc: "Clear login credentials", action: "logout", channelID: channelID}, + } + default: // "none" + items = []list.Item{ + channelActionItem{title: "Login", desc: "Scan QR code to connect WeChat", action: "login", channelID: channelID}, + } + } + + m.channelMenu.SetItems(items) + m.channelMenu.Title = fmt.Sprintf("↑/↓ navigate · Enter confirm · Esc cancel") + return m, tea.Batch(cmds...) +} + +// channelActionItem represents an action within the channel detail view +type channelActionItem struct { + title string + desc string + action string // "login", "logout", "enable", "disable" + channelID string +} + +func (i channelActionItem) Title() string { return i.title } +func (i channelActionItem) Description() string { return i.desc } +func (i channelActionItem) FilterValue() string { return i.title } + +// channelPanelView renders the channel management panel. +func (m Model) channelPanelView() string { + w, h := m.width, m.height + if w <= 0 { + w = 80 + } + if h <= 0 { + h = 24 + } + + contentW := w - 12 + if contentW > 72 { + contentW = 72 + } + if contentW < 30 { + contentW = 30 + } + listH := h - 10 + if listH < 4 { + listH = 4 + } + + boxStyle := dialogBoxStyle.Width(contentW) + + headerText := lipgloss.NewStyle().Bold(true).Foreground(colorPrimary). + Render("📡 Channels") + + m.channelMenu.SetSize(contentW-4, listH) + m.channelMenu.SetShowHelp(false) + m.channelMenu.SetShowStatusBar(false) + m.channelMenu.SetShowPagination(false) + + var contentParts []string + contentParts = append(contentParts, headerText) + contentParts = append(contentParts, "") + contentParts = append(contentParts, m.channelMenu.View()) + + content := lipgloss.JoinVertical(lipgloss.Left, contentParts...) + + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, boxStyle.Render(content)) +} + +// renderQRCode renders a QR code as terminal text lines. +// Uses half-block characters (▀▄█ ) so each terminal cell represents +// two vertical pixels, producing a square QR code. +func renderQRCode(content string) []string { + var buf bytes.Buffer + cfg := qrterminal.Config{ + Level: qrterminal.L, + Writer: &buf, + HalfBlocks: true, + BlackChar: qrterminal.BLACK_BLACK, + WhiteChar: qrterminal.WHITE_WHITE, + BlackWhiteChar: qrterminal.BLACK_WHITE, + WhiteBlackChar: qrterminal.WHITE_BLACK, + QuietZone: 1, + } + qrterminal.GenerateWithConfig(content, cfg) + lines := strings.Split(buf.String(), "\n") + var result []string + for _, line := range lines { + if line != "" { + result = append(result, " "+line) + } + } + return result +} diff --git a/internal/tui/input_views.go b/internal/tui/input_views.go index ee1f796..7fa2285 100644 --- a/internal/tui/input_views.go +++ b/internal/tui/input_views.go @@ -132,6 +132,7 @@ func (m Model) getCommandHints(input string) string { {"/resume", "Resume a previous session"}, {"/compact", "Compress conversation context"}, {"/bg", "List background tasks"}, + {"/channel", "Manage channels (WeChat etc.)"}, } // Add dynamically-loaded skill slash commands. @@ -151,6 +152,29 @@ func (m Model) getCommandHints(input string) string { return " " + strings.Join(matches, " │ ") } +// handleChannelInput shows the channel management panel. +func (m Model) handleChannelInput(cmds []tea.Cmd) (tea.Model, tea.Cmd) { + items := []list.Item{ + channelItem{title: "💬 WeChat", desc: m.channelStateDesc("wechat"), key: "wechat"}, + } + m.channelMenu.SetItems(items) + m.showingChannel = true + m.textarea.Blur() + return m, tea.Batch(cmds...) +} + +// channelStateDesc returns a human-readable description for the channel state. +func (m Model) channelStateDesc(channelID string) string { + switch m.channelStates[channelID] { + case "enabled": + return "Enabled" + case "disabled": + return "Disabled" + default: + return "Not connected" + } +} + // handleSettingInput shows the setting menu. func (m Model) handleSettingInput(cmds []tea.Cmd) (tea.Model, tea.Cmd) { items := []list.Item{ diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 4bb5631..6421869 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -329,3 +329,41 @@ type SkillSlashInfo struct { Slash string Description string } + +// --- Channel (WeChat etc.) messages --- + +// ChannelAction represents an action the user wants to perform on a channel. +type ChannelAction struct { + ChannelID string // "wechat" + Action string // "login", "logout", "enable", "disable" +} + +// channelActionCh carries channel actions from TUI to the main goroutine. +var channelActionCh = make(chan ChannelAction, 1) + +// GetChannelActionChannel returns the channel for channel action events. +func GetChannelActionChannel() <-chan ChannelAction { + return channelActionCh +} + +// ChannelStateMsg is sent from the main goroutine to update channel state display in TUI. +type ChannelStateMsg struct { + ChannelID string // "wechat" + State string // "none", "disabled", "enabled" + Message string // status message (e.g. "已连接", "扫码中...") +} + +// ChannelQRCodeMsg is sent when a QR code is available for scanning. +type ChannelQRCodeMsg struct { + ChannelID string + QRCodeURL string // image data or URL + QRCodeContent string // raw content to encode as terminal QR + Message string +} + +// ChannelInboundMsg is sent when an inbound message arrives from a channel. +type ChannelInboundMsg struct { + ChannelID string + From string + Text string +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 52e0288..5908555 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -74,6 +74,11 @@ type Model struct { sessionPicker list.Model pickingSession bool + // Channel panel state + channelMenu list.Model + showingChannel bool + channelStates map[string]string // channelID → state ("none", "disabled", "enabled") + agentsMdFound bool pwd string @@ -218,6 +223,17 @@ func (i sshAliasItem) Title() string { return i.title } func (i sshAliasItem) Description() string { return i.desc } func (i sshAliasItem) FilterValue() string { return i.title } +// channelItem for the channel management picker +type channelItem struct { + title string + desc string + key string // channel ID (e.g. "wechat") +} + +func (i channelItem) Title() string { return i.title } +func (i channelItem) Description() string { return i.desc } +func (i channelItem) FilterValue() string { return i.title } + func newTextarea() textarea.Model { ta := textarea.New() ta.Placeholder = "Type your prompt here..." @@ -296,6 +312,13 @@ func NewModel(hasPrompt bool, pwd string, todoStore *tools.TodoStore) Model { sesl.Title = "Sessions" sesl.SetShowHelp(false) + // Channel menu list + channelDel := list.NewDefaultDelegate() + channelDel.SetSpacing(0) + chl := list.New([]list.Item{}, channelDel, 0, 0) + chl.Title = "Channels" + chl.SetShowHelp(false) + m := Model{ mode: mode, spinner: s, @@ -309,6 +332,8 @@ func NewModel(hasPrompt bool, pwd string, todoStore *tools.TodoStore) Model { settingMenu: sl, sshAliasPicker: sal, sessionPicker: sesl, + channelMenu: chl, + channelStates: make(map[string]string), pwd: pwd, history: loadHistory(), todoStore: todoStore, @@ -788,6 +813,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen return m, tea.Batch(cmds...) } + // Channel panel handling + if m.showingChannel { + return m.handleChannelKeyPress(msg, cmds) + } + // SSH alias picker handling if m.pickingSSHAlias { switch msg.String() { @@ -1037,6 +1067,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen return m.handleCompactInput(cmds) } + if prompt == "/channel" { + return m.handleChannelInput(cmds) + } + // Check skill slash commands (e.g. /review-pr, /security-review) if strings.HasPrefix(prompt, "/") { if skillCmd := m.matchSkillSlash(prompt); skillCmd != nil { @@ -1287,6 +1321,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen m.mcpStatuses = msg.Statuses m.refreshViewport() + case ChannelStateMsg: + m.channelStates[msg.ChannelID] = msg.State + if msg.Message != "" { + m.lines = append(m.lines, toolLabelStyle.Render("📡 Channel:")+" "+msg.Message) + m.refreshViewport() + } + + case ChannelQRCodeMsg: + m.lines = append(m.lines, toolLabelStyle.Render("📡 "+msg.Message)) + if msg.QRCodeContent != "" { + qrLines := renderQRCode(msg.QRCodeContent) + for _, line := range qrLines { + m.lines = append(m.lines, line) + } + } + m.refreshViewport() + + case ChannelInboundMsg: + m.lines = append(m.lines, lipgloss.NewStyle().Foreground(colorSecondary). + Render(fmt.Sprintf("📱 [WeChat] %s", msg.Text))) + m.refreshViewport() + // Queue the inbound message as a pending prompt + select { + case pendingPromptCh <- msg.Text: + default: + } + case TodoUpdateMsg: m.refreshViewport() @@ -1874,6 +1935,10 @@ func (m Model) View() tea.View { return m.newView(m.settingMenuView()) } + if m.showingChannel { + return m.newView(m.channelPanelView()) + } + if m.pickingSSHAlias { return m.newView(m.sshAliasPickerView()) } diff --git a/internal/web/channel.go b/internal/web/channel.go new file mode 100644 index 0000000..3a1c499 --- /dev/null +++ b/internal/web/channel.go @@ -0,0 +1,99 @@ +package web + +import ( + "encoding/json" + "net/http" + "time" + + channelpkg "github.com/cnjack/jcode/internal/channel" + "github.com/cnjack/jcode/internal/config" +) + +func (s *Server) handleChannelStatus(w http.ResponseWriter, r *http.Request) { + if s.wechatClient == nil { + writeJSON(w, http.StatusOK, map[string]any{ + "available": false, + "state": "none", + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "available": true, + "channel": "wechat", + "state": s.wechatClient.State().String(), + }) +} + +func (s *Server) handleChannelLogin(w http.ResponseWriter, r *http.Request) { + if s.wechatClient == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "channel not available"}) + return + } + session, err := s.wechatClient.Login() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + // Return QR code content (URL to encode) — frontend renders the QR code + // to avoid CORS issues with the official QR image URL. + writeJSON(w, http.StatusOK, map[string]any{ + "status": "pending", + "qr_content": session.QRCodeContent, + }) + + // Wait for scan in background and auto-enable + go func() { + if err := session.WaitFunc(); err != nil { + config.Logger().Printf("[wechat] web login scan failed: %v", err) + return + } + config.Logger().Printf("[wechat] web login scan successful, auto-enabling") + if err := s.wechatClient.Enable(); err != nil { + config.Logger().Printf("[wechat] web auto-enable after login failed: %v", err) + return + } + }() +} + +func (s *Server) handleChannelLogout(w http.ResponseWriter, r *http.Request) { + if s.wechatClient == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "channel not available"}) + return + } + if s.wechatClient.State() == channelpkg.StateEnabled { + _ = s.wechatClient.SendText(channelpkg.GoodbyeMessage(time.Now())) + } + if err := s.wechatClient.Logout(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "state": "none"}) +} + +func (s *Server) handleChannelEnable(w http.ResponseWriter, r *http.Request) { + if s.wechatClient == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "channel not available"}) + return + } + if err := s.wechatClient.Enable(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "state": "enabled"}) +} + +func (s *Server) handleChannelDisable(w http.ResponseWriter, r *http.Request) { + if s.wechatClient == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "channel not available"}) + return + } + if err := s.wechatClient.Disable(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "state": "disabled"}) +} + +// Ensure writeJSON is used (defined in server.go). +var _ = json.Marshal diff --git a/internal/web/server.go b/internal/web/server.go index dbfbbbb..5cdca7e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -20,6 +20,7 @@ import ( "github.com/cloudwego/eino/schema" "github.com/gorilla/websocket" + "github.com/cnjack/jcode/internal/channel" "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/handler" "github.com/cnjack/jcode/internal/model" @@ -81,6 +82,13 @@ type Server struct { // disabledMCP tracks MCP servers that have been disabled via the UI. disabledMCP map[string]bool + + // wechatClient is the optional WeChat channel client. + wechatClient channel.Channel + + // eventHandler is the handler passed to the runner — may be a NotifyingHandler + // wrapping the WebHandler, or the WebHandler itself. + eventHandler handler.AgentEventHandler } // ServerConfig holds the configuration for creating a new Server. @@ -101,7 +109,9 @@ type ServerConfig struct { Registry *model.ModelRegistry ApprovalState *runner.ApprovalState SkillLoader *skills.Loader - WebHandler *handler.WebHandler // optional: pre-created handler for sharing with tools + WechatClient channel.Channel // optional WeChat channel + WebHandler *handler.WebHandler // optional: pre-created handler for sharing with tools + EventHandler handler.AgentEventHandler // optional: handler for runner (e.g. NotifyingHandler) } // NewServer creates a new web server. @@ -110,6 +120,10 @@ func NewServer(cfg *ServerConfig) *Server { if h == nil { h = handler.NewWebHandler() } + var eh handler.AgentEventHandler = h + if cfg.EventHandler != nil { + eh = cfg.EventHandler + } return &Server{ port: cfg.Port, host: cfg.Host, @@ -133,6 +147,8 @@ func NewServer(cfg *ServerConfig) *Server { approvalState: cfg.ApprovalState, skillLoader: cfg.SkillLoader, disabledMCP: make(map[string]bool), + wechatClient: cfg.WechatClient, + eventHandler: eh, } } @@ -179,6 +195,11 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/pty/{id}/ws", s.handlePTYWebSocket) mux.HandleFunc("GET /api/approval/mode", s.handleGetApprovalMode) mux.HandleFunc("POST /api/approval/mode", s.handleSetApprovalMode) + mux.HandleFunc("GET /api/channel", s.handleChannelStatus) + mux.HandleFunc("POST /api/channel/login", s.handleChannelLogin) + mux.HandleFunc("POST /api/channel/logout", s.handleChannelLogout) + mux.HandleFunc("POST /api/channel/enable", s.handleChannelEnable) + mux.HandleFunc("POST /api/channel/disable", s.handleChannelDisable) // Serve embedded frontend (SPA with fallback to index.html) mux.Handle("GET /", newSPAHandler()) @@ -281,16 +302,43 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { return } - s.running.Store(true) - - // Apply mode prefix if plan mode requested. - message := req.Message mode := req.Mode if mode == "" { mode = s.mode } + + s.submitMessage(req.Message, mode, "") + writeJSON(w, http.StatusAccepted, map[string]string{"status": "processing"}) +} + +// SubmitMessage submits a message for agent processing from an external source +// (e.g. WeChat inbound message). Returns false if the agent is busy. +func (s *Server) SubmitMessage(message, source string) bool { + if s.running.Load() { + return false + } + s.submitMessage(message, s.mode, source) + return true +} + +// submitMessage is the shared implementation for starting an agent run. +// source is an optional label (e.g. "wechat") for the user_message event. +func (s *Server) submitMessage(message, mode, source string) { + s.running.Store(true) + + // Apply mode prefix if plan mode requested. + agentMsg := message if mode == "plan" { - message = "[PLAN MODE — Read-only. Analyze the codebase and create a plan. Do NOT edit, write, or delete any files.]\n\n" + message + agentMsg = "[PLAN MODE — Read-only. Analyze the codebase and create a plan. Do NOT edit, write, or delete any files.]\n\n" + agentMsg + } + + // Emit user_message event for external sources (e.g. WeChat) so web clients see it. + // Web-originated messages are already added by the frontend's sendMessage(). + if source != "" { + s.handler.Emit("user_message", map[string]string{ + "content": message, + "source": source, + }) } // Ensure a recorder exists (lazy creation on first message). @@ -301,18 +349,17 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { // Record user message. if s.recorder != nil { - s.recorder.RecordUser(message) + s.recorder.RecordUser(agentMsg) } s.mu.Lock() - s.history = append(s.history, schema.UserMessage(message)) + s.history = append(s.history, schema.UserMessage(agentMsg)) history := make([]adk.Message, len(s.history)) copy(history, s.history) agent := s.agent s.mu.Unlock() // Stream response via SSE — run agent in background. - // Use server context, not request context (which is canceled when the response is sent). runCtx, runCancel := context.WithCancel(s.ctx) s.mu.Lock() s.runCancel = runCancel @@ -325,15 +372,13 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { s.runCancel = nil s.mu.Unlock() }() - resp := runner.Run(runCtx, agent, history, s.handler, s.recorder, s.todoStore, s.tracer, nil) + resp := runner.Run(runCtx, agent, history, s.eventHandler, s.recorder, s.todoStore, s.tracer, nil) if resp != "" { s.mu.Lock() s.history = append(s.history, &schema.Message{Role: schema.Assistant, Content: resp}) s.mu.Unlock() } }() - - writeJSON(w, http.StatusAccepted, map[string]string{"status": "processing"}) } func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { diff --git a/script/poc/weixin_poc.go b/script/poc/weixin_poc.go new file mode 100644 index 0000000..0d4147b --- /dev/null +++ b/script/poc/weixin_poc.go @@ -0,0 +1,342 @@ +//go:build ignore + +// WeChat iLink Bot API POC +// Usage: go run script/poc/weixin_poc.go +// +// Flow: +// 1. Fetch QR code and display in terminal +// 2. User scans with WeChat +// 3. Poll until login confirmed → obtain bot_token +// 4. Start receiving messages via getupdates long-poll +// 5. Echo every inbound text message back to sender + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/mdp/qrterminal/v3" +) + +const ( + defaultBaseURL = "https://ilinkai.weixin.qq.com" + botType = "3" +) + +// --- API Types --- + +type qrCodeResp struct { + QRCode string `json:"qrcode"` + QRCodeImgContent string `json:"qrcode_img_content"` +} + +type qrStatusResp struct { + Status string `json:"status"` + BotToken string `json:"bot_token"` + ILinkBotID string `json:"ilink_bot_id"` + BaseURL string `json:"baseurl"` + ILinkUserID string `json:"ilink_user_id"` + RedirectHost string `json:"redirect_host"` +} + +type baseInfo struct { + ChannelVersion string `json:"channel_version"` +} + +type getUpdatesReq struct { + GetUpdatesBuf string `json:"get_updates_buf"` + BaseInfo baseInfo `json:"base_info"` +} + +type messageItem struct { + Type int `json:"type"` + TextItem *textItem `json:"text_item,omitempty"` +} + +type textItem struct { + Text string `json:"text"` +} + +type weixinMessage struct { + Seq int `json:"seq"` + MessageID int `json:"message_id"` + FromUserID string `json:"from_user_id"` + ToUserID string `json:"to_user_id"` + ClientID string `json:"client_id"` + CreateTimeMs int64 `json:"create_time_ms"` + MessageType int `json:"message_type"` + MessageState int `json:"message_state"` + ItemList []messageItem `json:"item_list"` + ContextToken string `json:"context_token"` +} + +type getUpdatesResp struct { + Ret int `json:"ret"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + Msgs []weixinMessage `json:"msgs"` + GetUpdatesBuf string `json:"get_updates_buf"` +} + +type sendMsgReq struct { + Msg sendMsg `json:"msg"` + BaseInfo baseInfo `json:"base_info"` +} + +type sendMsg struct { + FromUserID string `json:"from_user_id"` + ToUserID string `json:"to_user_id"` + ClientID string `json:"client_id"` + MessageType int `json:"message_type"` + MessageState int `json:"message_state"` + ItemList []messageItem `json:"item_list"` + ContextToken string `json:"context_token,omitempty"` +} + +// --- Session state --- + +type session struct { + BaseURL string + Token string + UserID string + SyncBuf string +} + +// --- Step 1: Fetch QR code --- + +func fetchQR(baseURL string) (*qrCodeResp, error) { + u := fmt.Sprintf("%s/ilink/bot/get_bot_qrcode?bot_type=%s", baseURL, url.QueryEscape(botType)) + resp, err := http.Get(u) //nolint:gosec + if err != nil { + return nil, err + } + defer resp.Body.Close() + var r qrCodeResp + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return nil, err + } + return &r, nil +} + +// --- Step 2: Poll QR scan status until confirmed --- + +func pollLogin(baseURL, qrcode string) (*session, error) { + client := &http.Client{Timeout: 40 * time.Second} + currentBase := baseURL + deadline := time.Now().Add(8 * time.Minute) + + for time.Now().Before(deadline) { + u := fmt.Sprintf("%s/ilink/bot/get_qrcode_status?qrcode=%s", currentBase, url.QueryEscape(qrcode)) + resp, err := client.Get(u) + if err != nil { + log.Printf("[warn] poll error: %v — retrying", err) + time.Sleep(2 * time.Second) + continue + } + var r qrStatusResp + _ = json.NewDecoder(resp.Body).Decode(&r) + resp.Body.Close() + + switch r.Status { + case "wait": + // continue polling + case "scaned": + fmt.Println("[*] QR scanned — waiting for confirmation...") + case "confirmed": + s := &session{ + BaseURL: defaultBaseURL, + Token: r.BotToken, + UserID: r.ILinkUserID, + } + if r.BaseURL != "" { + s.BaseURL = r.BaseURL + } + return s, nil + case "expired": + return nil, fmt.Errorf("QR code expired") + case "scaned_but_redirect": + if r.RedirectHost != "" { + currentBase = r.RedirectHost + log.Printf("[*] Redirecting to %s", currentBase) + } + } + } + return nil, fmt.Errorf("login timed out") +} + +// --- Step 3: Send a text message --- + +func sendText(s *session, toUserID, text, contextToken string) error { + clientID := fmt.Sprintf("poc-%d", time.Now().UnixNano()) + body := sendMsgReq{ + Msg: sendMsg{ + FromUserID: "", + ToUserID: toUserID, + ClientID: clientID, + MessageType: 2, // BOT + MessageState: 2, // FINISH + ItemList: []messageItem{ + {Type: 1, TextItem: &textItem{Text: text}}, + }, + ContextToken: contextToken, + }, + BaseInfo: baseInfo{ChannelVersion: "poc/1.0.0"}, + } + data, _ := json.Marshal(body) + + req, err := http.NewRequest(http.MethodPost, s.BaseURL+"/ilink/bot/sendmessage", bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.Token) + req.Header.Set("AuthorizationType", "ilink_bot_token") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + var result struct { + Ret int `json:"ret"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(respBody, &result); err == nil && (result.Ret != 0 || result.ErrCode != 0) { + return fmt.Errorf("API error ret=%d errcode=%d errmsg=%s", result.Ret, result.ErrCode, result.ErrMsg) + } + return nil +} + +// --- Step 4: Long-poll loop --- + +func pollMessages(s *session) { + log.Printf("[*] Listening for messages (echo bot)...") + + for { + body := getUpdatesReq{ + GetUpdatesBuf: s.SyncBuf, + BaseInfo: baseInfo{ChannelVersion: "poc/1.0.0"}, + } + data, _ := json.Marshal(body) + + req, err := http.NewRequest(http.MethodPost, s.BaseURL+"/ilink/bot/getupdates", bytes.NewReader(data)) + if err != nil { + log.Printf("[error] build request: %v", err) + time.Sleep(2 * time.Second) + continue + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.Token) + req.Header.Set("AuthorizationType", "ilink_bot_token") + + client := &http.Client{Timeout: 40 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("[warn] getupdates error: %v", err) + time.Sleep(2 * time.Second) + continue + } + + var r getUpdatesResp + _ = json.NewDecoder(resp.Body).Decode(&r) + resp.Body.Close() + + if r.Ret != 0 || r.ErrCode != 0 { + log.Printf("[warn] getupdates API error ret=%d errcode=%d errmsg=%s", r.Ret, r.ErrCode, r.ErrMsg) + time.Sleep(2 * time.Second) + continue + } + + if r.GetUpdatesBuf != "" { + s.SyncBuf = r.GetUpdatesBuf + } + + for _, msg := range r.Msgs { + text := extractText(msg) + if text == "" { + continue + } + log.Printf("[msg] from=%s text=%q", msg.FromUserID, text) + + // Echo the message back + reply := fmt.Sprintf("Echo: %s", text) + if err := sendText(s, msg.FromUserID, reply, msg.ContextToken); err != nil { + log.Printf("[error] send failed: %v", err) + } else { + log.Printf("[sent] echo to %s", msg.FromUserID) + } + } + } +} + +func extractText(msg weixinMessage) string { + for _, item := range msg.ItemList { + if item.Type == 1 && item.TextItem != nil { + return item.TextItem.Text + } + } + return "" +} + +// --- Main --- + +func main() { + fmt.Println("=== WeChat iLink Bot POC ===") + + // Step 1: Fetch QR + fmt.Println("\n[1] Fetching QR code...") + qr, err := fetchQR(defaultBaseURL) + if err != nil { + log.Fatalf("fetch QR: %v", err) + } + + // Render QR in terminal + fmt.Println("\nScan with WeChat:") + qrterminal.GenerateHalfBlock(qr.QRCodeImgContent, qrterminal.L, os.Stdout) + fmt.Printf("\n URL: %s\n\n", qr.QRCodeImgContent) + + // Step 2: Wait for login + fmt.Println("[2] Waiting for scan...") + s, err := pollLogin(defaultBaseURL, qr.QRCode) + if err != nil { + log.Fatalf("login: %v", err) + } + fmt.Printf("\n[+] Logged in! user_id=%s base_url=%s\n", s.UserID, s.BaseURL) + + // Step 3: Send a greeting (may fail with ret=-2 until first getupdates completes — that's fine) + fmt.Println("\n[3] Sending greeting (first getupdates activates session, greeting may retry)...") + go func() { + // Retry a few times for session activation + for i := range 10 { + err := sendText(s, s.UserID, "Hello from jcode POC! Send me a message and I'll echo it back.", "") + if err == nil { + log.Printf("[+] Greeting sent") + return + } + if strings.Contains(err.Error(), "ret=-2") { + log.Printf("[wait] session not ready yet (attempt %d), retrying in 3s...", i+1) + time.Sleep(3 * time.Second) + continue + } + log.Printf("[warn] greeting failed: %v", err) + return + } + }() + + // Step 4: Long-poll loop (blocks) + fmt.Println("\n[4] Listening for messages (Ctrl+C to quit)...") + pollMessages(s) +} diff --git a/web/package.json b/web/package.json index 650b8d8..5357764 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.2", "@types/dompurify": "^3.2.0", + "@types/qrcode": "^1.5.6", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", @@ -28,6 +29,7 @@ "marked": "^18.0.0", "marked-highlight": "^2.2.4", "pinia": "^3.0.4", + "qrcode": "^1.5.4", "vue": "^3.5.31", "vue-router": "^5.0.4" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 077dcdc..6df7668 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@types/dompurify': specifier: ^3.2.0 version: 3.2.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -47,6 +50,9 @@ importers: pinia: specifier: ^3.0.4 version: 3.0.4(typescript@6.0.2)(vue@3.5.32(typescript@6.0.2)) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 vue: specifier: ^3.5.31 version: 3.5.32(typescript@6.0.2) @@ -411,48 +417,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.42.0': resolution: {integrity: sha512-+JA0YMlSdDqmacygGi2REp57c3fN+tzARD8nwsukx9pkCHK+6DkbAA9ojS4lNKsiBjIW8WWa0pBrBWhdZEqfuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.42.0': resolution: {integrity: sha512-VfnET0j4Y5mdfCzh5gBt0NK28lgn5DKx+8WgSMLYYeSooHhohdbzwAStLki9pNuGy51y4I7IoW8bqwAaCMiJQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.42.0': resolution: {integrity: sha512-gVlCbmBkB0fxBWbhBj9rcxezPydsQHf4MFKeHoTSPicOQ+8oGeTQgQ8EeesSybWeiFPVRx3bgdt4IJnH6nOjAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.42.0': resolution: {integrity: sha512-zN5OfstL0avgt/IgvRu0zjQzVh/EPkcLzs33E9LMAzpqlLWiPWeMDZyMGFlSRGOdDjuNmlZBCgj0pFnK5u32TQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.42.0': resolution: {integrity: sha512-9X6+H2L0qMc2sCAgO9HS03bkGLMKvOFjmEdchaFlany3vNZOjnVui//D8k/xZAtQv2vaCs1reD5KAgPoIU4msA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.42.0': resolution: {integrity: sha512-BajxJ6KQvMMdpXGPWhBGyjb2Jvx4uec0w+wi6TJZ6Tv7+MzPwe0pO8g5h1U0jyFgoaF7mDl6yKPW3ykWcbUJRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.42.0': resolution: {integrity: sha512-0wV284I6vc5f0AqAhgAbHU2935B4bVpncPoe5n/WzVZY/KnHgqxC8iSFGeSyLWEgstFboIcWkOPck7tqbdHkzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.42.0': resolution: {integrity: sha512-p4BG6HpGnhfgHk1rzZfyR6zcWkE7iLrWxyehHfXUy4Qa5j3e0roglFOdP/Nj5cJJ58MA3isQ5dlfkW2nNEpolw==} @@ -525,48 +539,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.57.0': resolution: {integrity: sha512-i66WyEPVEvq9bxRUCJ/MP5EBfnTDN3nhwEdFZFTO5MmLLvzngfWEG3NSdXQzTT3vk5B9i6C2XSIYBh+aG6uqyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.57.0': resolution: {integrity: sha512-oMZDCwz4NobclZU3pH+V1/upVlJZiZvne4jQP+zhJwt+lmio4XXr4qG47CehvrW1Lx2YZiIHuxM2D4YpkG3KVA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.57.0': resolution: {integrity: sha512-uoBnjJ3MMEBbfnWC1jSFr7/nSCkcQYa72NYoNtLl1imshDnWSolYCjzb8LVCwYCCfLJXD+0gBLD7fyC14c0+0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.57.0': resolution: {integrity: sha512-BdrwD7haPZ8a9KrZhKJRSj6jwCor+Z8tHFZ3PT89Y3Jq5v3LfMfEePeAmD0LOTWpiTmzSzdmyw9ijneapiVHKQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.57.0': resolution: {integrity: sha512-BNs+7ZNsRstVg2tpNxAXfMX/Iv5oZh204dVyb8Z37+/gCh+yZqNTlg6YwCLIMPSk5wLWIGOaQjT0GUOahKYImw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.57.0': resolution: {integrity: sha512-AghS18w+XcENcAX0+BQGLiqjpqpaxKJa4cWWP0OWNLacs27vHBxu7TYkv9LUSGe5w8lOJHeMxcYfZNOAPqw2bg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.57.0': resolution: {integrity: sha512-E/FV3GB8phu/Rpkhz5T96hAiJlGzn91qX5yj5gU754P5cmVGXY1Jw/VSjDSlZBCY3VHjsVLdzgdkJaomEmcNOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.57.0': resolution: {integrity: sha512-xvZ2yZt0nUVfU14iuGv3V25jpr9pov5N0Wr28RXnHFxHCRxNDMtYPHV61gGLhN9IlXM96gI4pyYpLSJC5ClLCQ==} @@ -630,36 +652,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} @@ -728,24 +756,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -815,6 +847,9 @@ packages: '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1043,6 +1078,14 @@ packages: alien-signals@3.1.2: resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1098,6 +1141,10 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001787: resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} @@ -1105,6 +1152,16 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -1139,6 +1196,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1158,12 +1219,18 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dompurify@3.4.0: resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} electron-to-chromium@1.5.335: resolution: {integrity: sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.20.1: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} @@ -1289,6 +1356,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1312,6 +1383,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1351,6 +1426,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1457,24 +1536,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1496,6 +1579,10 @@ packages: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1601,14 +1688,26 @@ packages: oxlint-tsgolint: optional: true + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -1660,6 +1759,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -1683,6 +1786,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -1697,6 +1805,13 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1728,6 +1843,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1752,6 +1870,14 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} @@ -1945,6 +2071,9 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1959,6 +2088,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -1967,6 +2100,9 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1975,6 +2111,14 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2548,6 +2692,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.12.2 + '@types/trusted-types@2.0.7': optional: true @@ -2880,6 +3028,12 @@ snapshots: alien-signals@3.1.2: {} + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@6.2.3: {} ansis@4.2.0: {} @@ -2931,12 +3085,26 @@ snapshots: dependencies: run-applescript: 7.1.0 + camelcase@5.3.1: {} + caniuse-lite@1.0.30001787: {} chokidar@5.0.0: dependencies: readdirp: 5.0.0 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + confbox@0.1.8: {} confbox@0.2.4: {} @@ -2961,6 +3129,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-is@0.1.4: {} default-browser-id@5.0.1: {} @@ -2974,12 +3144,16 @@ snapshots: detect-libc@2.1.2: {} + dijkstrajs@1.0.3: {} + dompurify@3.4.0: optionalDependencies: '@types/trusted-types': 2.0.7 electron-to-chromium@1.5.335: {} + emoji-regex@8.0.0: {} + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 @@ -3115,6 +3289,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3134,6 +3313,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3158,6 +3339,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -3262,6 +3445,10 @@ snapshots: pkg-types: 2.3.0 quansync: 0.2.11 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3399,14 +3586,24 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.57.0 '@oxlint/binding-win32-x64-msvc': 1.57.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -3446,6 +3643,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pngjs@5.0.0: {} + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -3468,6 +3667,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -3479,6 +3684,10 @@ snapshots: readdirp@5.0.0: {} + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -3516,6 +3725,8 @@ snapshots: semver@7.7.4: {} + set-blocking@2.0.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3534,6 +3745,16 @@ snapshots: speakingurl@14.0.1: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + superjson@2.2.6: dependencies: copy-anything: 4.0.5 @@ -3729,6 +3950,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3739,14 +3962,41 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 xml-name-validator@4.0.0: {} + y18n@4.0.3: {} + yallist@3.1.1: {} yaml@2.8.3: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yocto-queue@0.1.0: {} diff --git a/web/src/App.vue b/web/src/App.vue index 2434ef1..28eddd1 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -51,6 +51,10 @@ const { connected } = useWebSocket({ onSubagentProgress: (data) => { store.addSubagentProgress(data.agent_name, data.event, data.tool_name, data.detail) }, + onUserMessage: (data) => { + store.addMessage('user', data.content, data.source || undefined) + store.isRunning = true + }, }) watch(connected, (val) => { store.wsConnected = val }) @@ -104,6 +108,7 @@ onMounted(async () => { store.fetchModels() store.fetchSessions() store.fetchApprovalMode() + store.fetchChannelState() if (store.pwd) { projectStore.ensureCurrentProject(store.pwd) } diff --git a/web/src/components/ChatInput.vue b/web/src/components/ChatInput.vue index 7aff7ef..a87d2a7 100644 --- a/web/src/components/ChatInput.vue +++ b/web/src/components/ChatInput.vue @@ -252,6 +252,23 @@ watch(() => store.isRunning, (running) => { Auto + + +
diff --git a/web/src/components/ChatMessage.vue b/web/src/components/ChatMessage.vue index ef96a45..643a351 100644 --- a/web/src/components/ChatMessage.vue +++ b/web/src/components/ChatMessage.vue @@ -11,17 +11,17 @@ defineProps<{
- {{ message.role === 'user' ? 'You' : message.role === 'assistant' ? 'jcode' : 'System' }} + {{ message.role === 'user' ? (message.source === 'wechat' ? 'WeChat' : 'You') : message.role === 'assistant' ? 'jcode' : 'System' }}
-import { ref, watch } from 'vue' +import { ref, watch, nextTick } from 'vue' import { useChatStore } from '@/stores/chat' import { api } from '@/composables/api' import type { MCPServerInfo, SSHAlias } from '@/types/api' +import QRCode from 'qrcode' import { Dialog, DialogPanel, @@ -20,12 +21,19 @@ const emit = defineEmits<{ }>() const store = useChatStore() -const activeTab = ref<'general' | 'mcp' | 'ssh' | 'shortcuts'>('general') +const activeTab = ref<'general' | 'mcp' | 'ssh' | 'channels' | 'shortcuts'>('general') const mcpServers = ref>({}) const sshAliases = ref([]) const sshCurrent = ref('local') const mcpLoading = ref(false) +// Channel state +const channelAvailable = ref(false) +const channelState = ref('none') +const channelLoading = ref(false) +const channelQRContent = ref('') +const qrCanvas = ref(null) + watch(() => props.open, async (isOpen) => { if (isOpen) { // Load MCP servers @@ -42,6 +50,15 @@ watch(() => props.open, async (isOpen) => { sshAliases.value = sshData.aliases sshCurrent.value = sshData.current } catch { /* ignore */ } + + // Load channel status + try { + const ch = await api.channelStatus() + channelAvailable.value = ch.available + channelState.value = ch.state ?? 'none' + } catch { /* ignore */ } + } else { + channelQRContent.value = '' } }) @@ -70,6 +87,68 @@ const shortcuts = [ { keys: 'Ctrl+,', desc: 'Open settings' }, { keys: 'Ctrl+`', desc: 'Toggle terminal' }, ] + +async function channelLogin() { + channelLoading.value = true + try { + const result = await api.channelLogin() + channelQRContent.value = result.qr_content + channelState.value = 'scanning' + await nextTick() + if (qrCanvas.value && channelQRContent.value) { + await QRCode.toCanvas(qrCanvas.value, channelQRContent.value, { + width: 200, + margin: 2, + color: { dark: '#1c1917', light: '#ffffff' }, + }) + } + // Poll for state change + pollChannelState() + } catch (err) { + console.error('Channel login failed:', err) + } + channelLoading.value = false +} + +async function channelLogout() { + channelLoading.value = true + try { + await api.channelLogout() + channelState.value = 'none' + channelQRContent.value = '' + // Sync store state + store.channelEnabled = false + } catch (err) { + console.error('Channel logout failed:', err) + } + channelLoading.value = false +} + +function pollChannelState() { + const interval = setInterval(async () => { + try { + const ch = await api.channelStatus() + if (ch.state === 'enabled' || ch.state === 'disabled') { + channelState.value = ch.state + channelQRContent.value = '' + // Sync store so toolbar toggle reflects the new state + store.channelAvailable = true + store.channelEnabled = ch.state === 'enabled' + clearInterval(interval) + } + } catch { /* ignore */ } + }, 2000) + // Stop polling after 3 minutes + setTimeout(() => clearInterval(interval), 180000) +} + +const tabLabel: Record = { + general: 'General', + mcp: 'MCP Servers', + ssh: 'SSH', + channels: 'Channels', + shortcuts: 'Shortcuts', +}