mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eb0ba3257 | ||
|
|
af2398d636 | ||
|
|
138bf36bb3 | ||
|
|
0bbd0f2c7d | ||
|
|
fc9f9c1f26 | ||
|
|
fc22e9a04b | ||
|
|
9ba0d15161 | ||
|
|
b8d0f96265 | ||
|
|
2e4cfb4921 | ||
|
|
23066c8eee | ||
|
|
c09b03f854 | ||
|
|
4d4508dfd7 | ||
|
|
05d8137c7d | ||
|
|
17a85d319d | ||
|
|
a16eb24ba9 |
@@ -54,6 +54,12 @@ linters:
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
|
||||
# internal/ legitimately wraps raw HTTP for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
text: shortcuts-no-raw-http
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -70,16 +76,18 @@ linters:
|
||||
desc: >-
|
||||
shortcuts must not import internal/vfs/localfileio directly.
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
shortcuts-no-raw-http:
|
||||
files:
|
||||
- "**/shortcuts/**"
|
||||
deny:
|
||||
- pkg: "net/http"
|
||||
desc: >-
|
||||
use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
|
||||
The client layer handles auth, headers, and error normalization.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||
# Bans request / client construction; constants (http.MethodPost,
|
||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||
# intentionally allowed since they don't bypass the runtime layer.
|
||||
- pattern: http\.(Client|NewRequest|NewRequestWithContext|Get|Post|PostForm|Head|DefaultClient|DefaultTransport|RoundTripper|Do|Serve|ListenAndServe)\b
|
||||
msg: >-
|
||||
[shortcuts-no-raw-http] use RuntimeContext.DoAPI/CallAPI/DoAPIJSON
|
||||
instead of constructing raw HTTP. The runtime handles auth, headers,
|
||||
and error normalization. (Constants and helpers like http.MethodPost,
|
||||
http.StatusOK, http.StatusText remain allowed.)
|
||||
# ── os: already wrapped in internal/vfs ──
|
||||
- pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b
|
||||
msg: "use the corresponding vfs.Xxx() from internal/vfs"
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -2,6 +2,25 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.21] - 2026-04-28
|
||||
|
||||
### Features
|
||||
|
||||
- **contact**: Add search filters and richer profile fields to `+search-user` (#648)
|
||||
- **common**: Backfill resource URL when create APIs omit it (#680)
|
||||
- **risk**: Add risk tiering for command sensitivity classification (#633)
|
||||
- **okr**: Add progress records support (#574)
|
||||
- **calendar**: Enhance event search and meeting room finding (#679)
|
||||
- **event**: Add event subscription & consume system (#654)
|
||||
- **drive**: Extend `+add-comment` to support slides targets (#674)
|
||||
- **slides**: Add font management for slides (#681)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cmdutil**: Default flag completions to disabled (#688)
|
||||
- **e2e/wiki**: Pass `obj_type` when deleting wiki nodes in cleanup (#687)
|
||||
- **readme**: Fix readme statistics (#691)
|
||||
|
||||
## [v1.0.20] - 2026-04-27
|
||||
|
||||
### Features
|
||||
@@ -520,6 +539,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
|
||||
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
|
||||
[v1.0.19]: https://github.com/larksuite/cli/releases/tag/v1.0.19
|
||||
[v1.0.18]: https://github.com/larksuite/cli/releases/tag/v1.0.18
|
||||
[v1.0.17]: https://github.com/larksuite/cli/releases/tag/v1.0.17
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 22 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 23 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 23 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 16 business domains, 200+ curated commands, 23 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -38,7 +38,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||
|
||||
## Installation & Quick Start
|
||||
@@ -156,6 +156,7 @@ lark-cli auth status
|
||||
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
| `lark-okr` | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 23 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 16 大业务域、200+ 精选命令、23 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -38,7 +38,7 @@
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
|
||||
## 安装与快速开始
|
||||
@@ -157,6 +157,7 @@ lark-cli auth status
|
||||
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
| `lark-okr` | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
|
||||
## 认证
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ import (
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
cmdevent "github.com/larksuite/cli/cmd/event"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
@@ -117,6 +119,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
|
||||
67
cmd/build_memstats_test.go
Normal file
67
cmd/build_memstats_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// TestBuild_DefaultNoCompletionLeak verifies that, without any call to
|
||||
// SetFlagCompletionsEnabled, repeated cmd.Build invocations do not leak
|
||||
// *cobra.Command instances into cobra's package-global flag-completion map.
|
||||
//
|
||||
// This guards the new default (completions disabled) — if someone flips the
|
||||
// zero-value back to "enabled", the per-Build memory growth observed under
|
||||
// `scripts/bench_build` would resurface in production hot paths that build
|
||||
// the root command without serving a completion request.
|
||||
func TestBuild_DefaultNoCompletionLeak(t *testing.T) {
|
||||
if cmdutil.FlagCompletionsEnabled() {
|
||||
t.Fatalf("precondition: FlagCompletionsEnabled() = true, want false (state polluted by another test)")
|
||||
}
|
||||
|
||||
snap := func() (heapMB float64, objs uint64) {
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
return float64(m.HeapAlloc) / 1024 / 1024, m.HeapObjects
|
||||
}
|
||||
|
||||
// Warm one-time caches (registry JSON decode, embed reads) so the first
|
||||
// Build's lazy allocations don't skew the per-iteration delta.
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
baseMB, baseObj := snap()
|
||||
|
||||
const N = 20
|
||||
for range N {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
}
|
||||
mb, obj := snap()
|
||||
|
||||
deltaMB := mb - baseMB
|
||||
deltaObj := int64(obj) - int64(baseObj)
|
||||
perBuildKB := deltaMB * 1024 / float64(N)
|
||||
perBuildObj := deltaObj / int64(N)
|
||||
|
||||
t.Logf("%d builds: +%.2f MB, +%d objects (%.1f KB/build, %d objs/build)",
|
||||
N, deltaMB, deltaObj, perBuildKB, perBuildObj)
|
||||
|
||||
// With completions disabled (the default), per-Build retained growth
|
||||
// should be minimal. Threshold is conservative: the previously observed
|
||||
// leak with completions enabled was ~hundreds of KB and thousands of
|
||||
// objects per Build, well above this bound.
|
||||
const maxKBPerBuild = 50.0
|
||||
const maxObjsPerBuild = 500
|
||||
if perBuildKB > maxKBPerBuild {
|
||||
t.Errorf("per-build heap growth = %.1f KB, want <= %.1f KB (completion registration may be leaking)", perBuildKB, maxKBPerBuild)
|
||||
}
|
||||
if perBuildObj > maxObjsPerBuild {
|
||||
t.Errorf("per-build object growth = %d, want <= %d", perBuildObj, maxObjsPerBuild)
|
||||
}
|
||||
}
|
||||
25
cmd/event/appmeta_err.go
Normal file
25
cmd/event/appmeta_err.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
|
||||
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
|
||||
|
||||
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
|
||||
func describeAppMetaErr(err error) string {
|
||||
msg := err.Error()
|
||||
if url := authURLPattern.FindString(msg); url != "" {
|
||||
return fmt.Sprintf("bot is missing scopes needed for app-version metadata; grant at: %s", url)
|
||||
}
|
||||
const maxErrLen = 200
|
||||
if len(msg) > maxErrLen {
|
||||
return msg[:maxErrLen] + "…"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
54
cmd/event/appmeta_err_test.go
Normal file
54
cmd/event/appmeta_err_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const realisticPermError = `API GET /open-apis/application/v6/applications/cli_XXXXXXXXXXXXXXXX/app_versions?lang=zh_cn&page_size=2 returned 400: {"code":99991672,"msg":"Access denied. One of the following scopes is required: [application:application:self_manage, application:application.app_version:readonly].应用尚未开通所需的应用身份权限:[application:application:self_manage, application:application.app_version:readonly],点击链接申请并开通任一权限即可:https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant","error":{"message":"Refer to the documentation...","log_id":"20260421101203E2A5F141245B6F43B3A6"}}`
|
||||
|
||||
func TestDescribeAppMetaErr_PermissionDeniedShort(t *testing.T) {
|
||||
got := describeAppMetaErr(errors.New(realisticPermError))
|
||||
if len(got) > 400 {
|
||||
t.Errorf("summary too long (%d chars): %q", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "scope") {
|
||||
t.Errorf("summary should mention scope requirement, got: %q", got)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant"
|
||||
if !strings.Contains(got, wantURL) {
|
||||
t.Errorf("summary missing grant URL\ngot: %q\nwant: %q", got, wantURL)
|
||||
}
|
||||
for _, noise := range []string{"log_id", `"error":`, "Refer to the documentation"} {
|
||||
if strings.Contains(got, noise) {
|
||||
t.Errorf("summary leaked noise %q: %q", noise, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_UnknownErrorTruncated(t *testing.T) {
|
||||
long := strings.Repeat("x", 500)
|
||||
got := describeAppMetaErr(errors.New(long))
|
||||
if len(got) > 220 {
|
||||
t.Errorf("unknown error not truncated, len=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_ShortErrorPassesThrough(t *testing.T) {
|
||||
got := describeAppMetaErr(errors.New("network unreachable"))
|
||||
if got != "network unreachable" {
|
||||
t.Errorf("short err should pass through unchanged, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_LarkOfficeDomain(t *testing.T) {
|
||||
msg := `... grant link: https://open.larksuite.com/app/cli_xyz/auth?q=application:application:self_manage&op_from=openapi&token_type=tenant ...`
|
||||
got := describeAppMetaErr(errors.New(msg))
|
||||
if !strings.Contains(got, "open.larksuite.com") {
|
||||
t.Errorf("want larksuite URL extracted, got: %q", got)
|
||||
}
|
||||
}
|
||||
69
cmd/event/bus.go
Normal file
69
cmd/event/bus.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/bus"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
|
||||
func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
var domain string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "_bus",
|
||||
Short: "Internal event bus daemon (do not call directly)",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sanitize AppID: an unsanitized value could escape events/ via ".." or separators.
|
||||
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||
defer signal.Stop(sigCh)
|
||||
go func() {
|
||||
select {
|
||||
case <-sigCh:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
|
||||
_ = cmd.Flags().MarkHidden("domain")
|
||||
|
||||
return cmd
|
||||
}
|
||||
24
cmd/event/console_url.go
Normal file
24
cmd/event/console_url.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
|
||||
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
|
||||
host, appID, strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
// consoleEventSubscriptionURL points at the app's event subscription console page.
|
||||
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/event", host, appID)
|
||||
}
|
||||
36
cmd/event/console_url_test.go
Normal file
36
cmd/event/console_url_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
|
||||
"im:message:readonly",
|
||||
"im:message.group_at_msg",
|
||||
})
|
||||
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
|
||||
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
|
||||
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
|
||||
t.Errorf("unexpected url: %s", got)
|
||||
}
|
||||
}
|
||||
371
cmd/event/consume.go
Normal file
371
cmd/event/consume.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/consume"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type consumeCmdOpts struct {
|
||||
params []string
|
||||
jqExpr string
|
||||
quiet bool
|
||||
outputDir string
|
||||
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewCmdConsume(f *cmdutil.Factory) *cobra.Command {
|
||||
var o consumeCmdOpts
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "consume <EventKey>",
|
||||
Short: "Start consuming events for an EventKey",
|
||||
Long: `Start consuming real-time events for the given EventKey.
|
||||
|
||||
The consume command connects to the event bus daemon (starting it if needed),
|
||||
subscribes to the specified EventKey, and streams processed events to stdout.
|
||||
|
||||
Output is one JSON object per line (NDJSON). Pipe through 'jq .' if you need
|
||||
pretty-printed formatting.
|
||||
|
||||
Use 'event list' to see all available EventKeys.
|
||||
Use 'event schema <EventKey>' for parameter details.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConsume(cmd, f, args[0], o)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringArrayVarP(&o.params, "param", "p", nil, "Key=value parameter (repeatable)")
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error {
|
||||
// Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill.
|
||||
ignoreBrokenPipe()
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
paramMap, err := parseParams(o.params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyDef, ok := eventlib.Lookup(eventKey)
|
||||
if !ok {
|
||||
return unknownEventKeyErr(eventKey)
|
||||
}
|
||||
|
||||
identity, err := resolveIdentity(cmd, f, keyDef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
outputDir := o.outputDir
|
||||
if outputDir != "" {
|
||||
safePath, err := sanitizeOutputDir(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir = safePath
|
||||
}
|
||||
|
||||
domain := core.ResolveEndpoints(cfg.Brand).Open
|
||||
|
||||
// Surface auth errors before forking the bus daemon.
|
||||
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiClient, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime := &consumeRuntime{client: apiClient, accessIdentity: identity}
|
||||
// botRuntime pins AsBot: /app_versions rejects UAT (99991668) and /connection is app-level.
|
||||
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: core.AsBot}
|
||||
|
||||
// Weak-dependency fetch: failures leave appVer==nil and downgrade preflight to a no-op.
|
||||
preflightErrOut := f.IOStreams.ErrOut
|
||||
if o.quiet {
|
||||
preflightErrOut = io.Discard
|
||||
}
|
||||
appVer, appVerErr := appmeta.FetchCurrentPublished(cmd.Context(), botRuntime, cfg.AppID)
|
||||
switch {
|
||||
case appVerErr != nil:
|
||||
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(appVerErr))
|
||||
case appVer == nil:
|
||||
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
|
||||
}
|
||||
|
||||
pf := &preflightCtx{
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := preflightScopes(cmd.Context(), pf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(sigCh)
|
||||
go func() {
|
||||
select {
|
||||
case <-sigCh:
|
||||
if !o.quiet && f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "\nShutting down...")
|
||||
}
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
errOut := f.IOStreams.ErrOut
|
||||
if o.quiet {
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
|
||||
EventKey: eventKey,
|
||||
Params: paramMap,
|
||||
JQExpr: o.jqExpr,
|
||||
Quiet: o.quiet,
|
||||
OutputDir: outputDir,
|
||||
Runtime: runtime,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: errOut,
|
||||
RemoteAPIClient: botRuntime,
|
||||
MaxEvents: o.maxEvents,
|
||||
Timeout: o.timeout,
|
||||
IsTTY: f.IOStreams.IsTerminal,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
|
||||
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) {
|
||||
flagAs := core.Identity(cmd.Flag("as").Value.String())
|
||||
identity := f.ResolveAs(cmd.Context(), cmd, flagAs)
|
||||
if len(keyDef.AuthTypes) > 0 {
|
||||
if err := f.CheckIdentity(identity, keyDef.AuthTypes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
type preflightCtx struct {
|
||||
factory *cmdutil.Factory
|
||||
appID string
|
||||
brand core.LarkBrand
|
||||
eventKey string
|
||||
identity core.Identity
|
||||
keyDef *eventlib.KeyDefinition
|
||||
appVer *appmeta.AppVersion
|
||||
}
|
||||
|
||||
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
|
||||
func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(pf.keyDef.Scopes) == 0 || pf.identity == "" {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
var storedScopes string
|
||||
switch {
|
||||
case pf.identity.IsBot():
|
||||
if pf.appVer == nil {
|
||||
return nil
|
||||
}
|
||||
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
|
||||
case pf.identity == core.AsUser:
|
||||
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
|
||||
if err != nil || result == nil || result.Scopes == "" {
|
||||
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
|
||||
}
|
||||
storedScopes = result.Scopes
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := auth.MissingScopes(storedScopes, pf.keyDef.Scopes)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf(
|
||||
"grant these scopes and publish a new app version at: %s",
|
||||
consoleScopeGrantURL(brand, appID, missing),
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
||||
strings.Join(missing, " "),
|
||||
)
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
|
||||
func preflightEventTypes(pf *preflightCtx) error {
|
||||
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
return nil
|
||||
}
|
||||
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
|
||||
for _, t := range pf.appVer.EventTypes {
|
||||
subscribed[t] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, t := range pf.keyDef.RequiredConsoleEvents {
|
||||
if !subscribed[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
|
||||
// resolveTenantToken fetches the app's tenant access token.
|
||||
func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir")
|
||||
)
|
||||
|
||||
func parseParams(raw []string) (map[string]string, error) {
|
||||
m := make(map[string]string)
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// watchStdinEOF drains r until EOF, writes a diagnostic, then cancels; only safe in non-TTY mode.
|
||||
func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
go func() {
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
fmt.Fprintln(errOut, "[event] stdin closed — shutting down. "+
|
||||
"consume treats stdin EOF as exit signal (wired for AI subprocess callers). "+
|
||||
"To keep running: pass --max-events/--timeout for bounded run, "+
|
||||
"or keep stdin open (e.g. `< /dev/tty` interactive, `< <(tail -f /dev/null)` script), "+
|
||||
"or stop via SIGTERM instead of closing stdin.")
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
63
cmd/event/consume_stdin_test.go
Normal file
63
cmd/event/consume_stdin_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchStdinEOF_CancelsOnEOF(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
watchStdinEOF(strings.NewReader(""), cancel, io.Discard)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchStdinEOF_StaysAliveWhileReaderBlocks(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
pr, _ := io.Pipe()
|
||||
defer pr.Close()
|
||||
|
||||
watchStdinEOF(pr, cancel, io.Discard)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("watchStdinEOF cancelled without EOF")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// On EOF the watcher must emit a diagnostic naming stdin close + workarounds (daemon-style callers depend on it).
|
||||
func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
watchStdinEOF(strings.NewReader(""), cancel, &buf)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
got := buf.String()
|
||||
for _, want := range []string{"stdin closed", "--max-events", "--timeout", "SIGTERM"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("diagnostic missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
143
cmd/event/consume_test.go
Normal file
143
cmd/event/consume_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want map[string]string
|
||||
wantSentry error
|
||||
wantEcho string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
in: nil,
|
||||
want: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "single key=value",
|
||||
in: []string{"mailbox=user@example.com"},
|
||||
want: map[string]string{"mailbox": "user@example.com"},
|
||||
},
|
||||
{
|
||||
name: "multiple pairs",
|
||||
in: []string{"a=1", "b=2", "c=3"},
|
||||
want: map[string]string{"a": "1", "b": "2", "c": "3"},
|
||||
},
|
||||
{
|
||||
name: "value containing = is kept intact",
|
||||
in: []string{"filter=foo=bar"},
|
||||
want: map[string]string{"filter": "foo=bar"},
|
||||
},
|
||||
{
|
||||
name: "empty value allowed",
|
||||
in: []string{"key="},
|
||||
want: map[string]string{"key": ""},
|
||||
},
|
||||
{
|
||||
name: "duplicate key — last wins",
|
||||
in: []string{"k=1", "k=2"},
|
||||
want: map[string]string{"k": "2"},
|
||||
},
|
||||
{
|
||||
name: "missing = separator",
|
||||
in: []string{"mailbox"},
|
||||
wantSentry: errInvalidParamFormat,
|
||||
wantEcho: `"mailbox"`,
|
||||
},
|
||||
{
|
||||
name: "leading = (empty key)",
|
||||
in: []string{"=value"},
|
||||
wantSentry: errInvalidParamFormat,
|
||||
wantEcho: `"=value"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseParams(tc.in)
|
||||
if tc.wantSentry != nil {
|
||||
if err == nil {
|
||||
t.Fatalf("want error wrapping %v, got nil", tc.wantSentry)
|
||||
}
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len = %d, want %d; got=%v", len(got), len(tc.want), got)
|
||||
}
|
||||
for k, v := range tc.want {
|
||||
if got[k] != v {
|
||||
t.Errorf("key %q: got %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantSentry error
|
||||
}{
|
||||
{
|
||||
name: "relative path accepted",
|
||||
in: "./output",
|
||||
},
|
||||
{
|
||||
name: "nested relative path accepted",
|
||||
in: "events/today",
|
||||
},
|
||||
{
|
||||
name: "tilde rejected explicitly",
|
||||
in: "~/events",
|
||||
wantSentry: errOutputDirTilde,
|
||||
},
|
||||
{
|
||||
name: "parent escape rejected",
|
||||
in: "../outside",
|
||||
wantSentry: errOutputDirUnsafe,
|
||||
},
|
||||
{
|
||||
name: "absolute path rejected",
|
||||
in: "/tmp/events",
|
||||
wantSentry: errOutputDirUnsafe,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := sanitizeOutputDir(tc.in)
|
||||
if tc.wantSentry != nil {
|
||||
if err == nil {
|
||||
t.Fatalf("want error wrapping %v, got nil (path=%q)", tc.wantSentry, got)
|
||||
}
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Errorf("expected non-empty safe path, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
29
cmd/event/event.go
Normal file
29
cmd/event/event.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "event",
|
||||
Short: "Consume and manage real-time events",
|
||||
Long: `Unified event consumption system. Use 'event consume <EventKey>' to start consuming events.`,
|
||||
// Without SilenceUsage, RunE errors print the full flag help banner.
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
cmd.AddCommand(NewCmdConsume(f))
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdSchema(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdStop(f))
|
||||
cmd.AddCommand(NewCmdBus(f))
|
||||
|
||||
return cmd
|
||||
}
|
||||
265
cmd/event/format_helpers_test.go
Normal file
265
cmd/event/format_helpers_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWriteStopJSON_ShapeAndEmpty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStopJSON(&buf, []stopResult{
|
||||
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 42},
|
||||
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopRefused, PID: 43, Reason: "2 active consumer(s)"},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeStopJSON: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(got.Results) != 2 {
|
||||
t.Fatalf("results len = %d, want 2", len(got.Results))
|
||||
}
|
||||
if got.Results[0]["status"] != "stopped" {
|
||||
t.Errorf("results[0].status = %v, want stopped", got.Results[0]["status"])
|
||||
}
|
||||
if got.Results[1]["status"] != "refused" {
|
||||
t.Errorf("results[1].status = %v, want refused", got.Results[1]["status"])
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := writeStopJSON(&buf, nil); err != nil {
|
||||
t.Fatalf("writeStopJSON(nil): %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("nil output is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if got.Results == nil || len(got.Results) != 0 {
|
||||
t.Errorf("results = %v, want []", got.Results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStopText_RoutesToStdoutOrStderr(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
writeStopText(&out, &errOut, []stopResult{
|
||||
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 1},
|
||||
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopNoBus},
|
||||
{AppID: "cli_ZZZZZZZZZZZZZZZZ", Status: stopRefused, Reason: "busy"},
|
||||
{AppID: "cli_WWWWWWWWWWWWWWWW", Status: stopErrored, Reason: "kill failed"},
|
||||
})
|
||||
if !strings.Contains(out.String(), "Bus stopped for cli_XXXXXXXXXXXXXXXX") {
|
||||
t.Errorf("stopped line missing from stdout: %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "No bus running for cli_YYYYYYYYYYYYYYYY") {
|
||||
t.Errorf("no-bus line missing from stdout: %q", out.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Refused stopping cli_ZZZZZZZZZZZZZZZZ: busy") {
|
||||
t.Errorf("refused line missing from stderr: %q", errOut.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Error stopping cli_WWWWWWWWWWWWWWWW: kill failed") {
|
||||
t.Errorf("error line missing from stderr: %q", errOut.String())
|
||||
}
|
||||
if strings.Contains(out.String(), "Refused") || strings.Contains(out.String(), "Error") {
|
||||
t.Errorf("failure lines leaked to stdout: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusState_String(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
s busState
|
||||
want string
|
||||
}{
|
||||
{stateNotRunning, "not_running"},
|
||||
{stateRunning, "running"},
|
||||
{stateOrphan, "orphan"},
|
||||
} {
|
||||
if got := tc.s.String(); got != tc.want {
|
||||
t.Errorf("busState(%d).String() = %q, want %q", tc.s, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanizeDuration_AllBuckets(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{30 * time.Second, "30s ago"},
|
||||
{90 * time.Second, "1m ago"},
|
||||
{2 * time.Hour, "2h ago"},
|
||||
{50 * time.Hour, "2d ago"},
|
||||
} {
|
||||
if got := humanizeDuration(tc.d); got != tc.want {
|
||||
t.Errorf("humanizeDuration(%v) = %q, want %q", tc.d, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{AppID: "cli_NOTRUNNINGXXXXXX", State: stateNotRunning},
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 3661,
|
||||
Active: 2,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 10, EventKey: "im.message.receive_v1", Received: 5, Dropped: 0},
|
||||
{PID: 11, EventKey: "im.message.receive_v1", Received: 3, Dropped: 1},
|
||||
},
|
||||
},
|
||||
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 5678, UptimeSec: 3600},
|
||||
})
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"── cli_NOTRUNNINGXXXXXX ──",
|
||||
"Bus: not running",
|
||||
"── cli_RUNNINGXXXXXXXXX ──",
|
||||
"running (PID 1234",
|
||||
"Active consumers: 2",
|
||||
"im.message.receive_v1",
|
||||
"── cli_ORPHANXXXXXXXXXX ──",
|
||||
"orphan (PID 5678",
|
||||
"Action: kill 5678",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("writeStatusText missing %q; full:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStatusJSON(&buf, []appStatus{
|
||||
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 99, UptimeSec: 60},
|
||||
{AppID: "cli_RUNNINGXXXXXXXXX", State: stateRunning, PID: 1, UptimeSec: 10, Active: 0},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Apps []map[string]interface{} `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(got.Apps) != 2 {
|
||||
t.Fatalf("apps len = %d", len(got.Apps))
|
||||
}
|
||||
orphan := got.Apps[0]
|
||||
if orphan["status"] != "orphan" {
|
||||
t.Errorf("orphan status = %v", orphan["status"])
|
||||
}
|
||||
if orphan["suggested_action"] != "kill 99" {
|
||||
t.Errorf("orphan suggested_action = %v, want 'kill 99'", orphan["suggested_action"])
|
||||
}
|
||||
if orphan["issue"] == nil {
|
||||
t.Error("orphan issue missing")
|
||||
}
|
||||
run := got.Apps[1]
|
||||
if run["issue"] != nil {
|
||||
t.Errorf("running entry leaked issue: %v", run["issue"])
|
||||
}
|
||||
if run["suggested_action"] != nil {
|
||||
t.Errorf("running entry leaked suggested_action: %v", run["suggested_action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan(t *testing.T) {
|
||||
orphan := []appStatus{{State: stateOrphan}}
|
||||
running := []appStatus{{State: stateRunning}}
|
||||
|
||||
if err := exitForOrphan(orphan, false); err != nil {
|
||||
t.Errorf("flag off + orphan → nil expected, got %v", err)
|
||||
}
|
||||
if err := exitForOrphan(running, false); err != nil {
|
||||
t.Errorf("flag off + running → nil expected, got %v", err)
|
||||
}
|
||||
|
||||
if err := exitForOrphan(running, true); err != nil {
|
||||
t.Errorf("flag on + no orphan → nil expected, got %v", err)
|
||||
}
|
||||
err := exitForOrphan(orphan, true)
|
||||
if err == nil {
|
||||
t.Fatal("flag on + orphan → expected error, got nil")
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %v, want ExitValidation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func errorAs(err error, target interface{}) bool {
|
||||
if e, ok := err.(*output.ExitError); ok {
|
||||
if t, ok := target.(**output.ExitError); ok {
|
||||
*t = e
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestNewCmdFactories_WireFlags(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
|
||||
|
||||
t.Run("consume", func(t *testing.T) {
|
||||
cmd := NewCmdConsume(f)
|
||||
for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("consume missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
if cmd.RunE == nil {
|
||||
t.Error("consume RunE is nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status", func(t *testing.T) {
|
||||
cmd := NewCmdStatus(f)
|
||||
for _, flag := range []string{"json", "current", "fail-on-orphan"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("status missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stop", func(t *testing.T) {
|
||||
cmd := NewCmdStop(f)
|
||||
for _, flag := range []string{"app-id", "all", "force", "json"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("stop missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
cmd := NewCmdList(f)
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
t.Error("list missing --json flag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bus", func(t *testing.T) {
|
||||
cmd := NewCmdBus(f)
|
||||
if !cmd.Hidden {
|
||||
t.Error("bus should be hidden (internal daemon entrypoint)")
|
||||
}
|
||||
if cmd.Flags().Lookup("domain") == nil {
|
||||
t.Error("bus missing --domain flag")
|
||||
}
|
||||
})
|
||||
}
|
||||
121
cmd/event/list.go
Normal file
121
cmd/event/list.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all available EventKeys",
|
||||
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runList(f, asJSON)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(f *cmdutil.Factory, asJSON bool) error {
|
||||
all := eventlib.ListAll()
|
||||
|
||||
if asJSON {
|
||||
return writeListJSON(f, all)
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
// stderr so `event list | jq` doesn't ingest it as a row.
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No EventKeys registered.")
|
||||
return nil
|
||||
}
|
||||
|
||||
type group struct {
|
||||
domain string
|
||||
keys []*eventlib.KeyDefinition
|
||||
}
|
||||
order := []string{}
|
||||
groups := map[string]*group{}
|
||||
|
||||
for _, def := range all {
|
||||
domain := def.Key
|
||||
if idx := strings.Index(def.Key, "."); idx > 0 {
|
||||
domain = def.Key[:idx]
|
||||
}
|
||||
g, ok := groups[domain]
|
||||
if !ok {
|
||||
g = &group{domain: domain}
|
||||
groups[domain] = g
|
||||
order = append(order, domain)
|
||||
}
|
||||
g.keys = append(g.keys, def)
|
||||
}
|
||||
|
||||
// Global widths (not per-section) keep "── domain ──" dividers aligned across groups.
|
||||
headers := []string{"KEY", "AUTH", "PARAMS", "DESCRIPTION"}
|
||||
rowsByDomain := make(map[string][][]string, len(order))
|
||||
var allRows [][]string
|
||||
for _, domain := range order {
|
||||
for _, def := range groups[domain].keys {
|
||||
auth := "-"
|
||||
if len(def.AuthTypes) > 0 {
|
||||
auth = strings.Join(def.AuthTypes, "|")
|
||||
}
|
||||
desc := def.Description
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
row := []string{
|
||||
def.Key,
|
||||
auth,
|
||||
fmt.Sprintf("%d", len(def.Params)),
|
||||
desc,
|
||||
}
|
||||
rowsByDomain[domain] = append(rowsByDomain[domain], row)
|
||||
allRows = append(allRows, row)
|
||||
}
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
const colGap = " "
|
||||
widths := tableWidths(headers, allRows)
|
||||
printTableRow(out, widths, headers, colGap)
|
||||
for _, domain := range order {
|
||||
fmt.Fprintf(out, "\n── %s ──\n", domain)
|
||||
for _, row := range rowsByDomain[domain] {
|
||||
printTableRow(out, widths, row, colGap)
|
||||
}
|
||||
}
|
||||
// stderr keeps stdout pipe-clean for `event list | jq`.
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "\nUse 'event schema <key>' for details.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error {
|
||||
type row struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
}
|
||||
rows := make([]row, len(all))
|
||||
for i, def := range all {
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, rows)
|
||||
return nil
|
||||
}
|
||||
58
cmd/event/list_test.go
Normal file
58
cmd/event/list_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, false); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunList_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, true); err != nil {
|
||||
t.Fatalf("runList json: %v", err)
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("expected at least one EventKey in JSON output")
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
for _, field := range []string{"key", "event_type", "schema"} {
|
||||
if row[field] == nil {
|
||||
t.Errorf("row missing %q: %+v", field, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
cmd/event/preflight_test.go
Normal file
176
cmd/event/preflight_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
key := ""
|
||||
if keyDef != nil {
|
||||
key = keyDef.Key
|
||||
}
|
||||
return &preflightCtx{
|
||||
appID: appID,
|
||||
brand: brand,
|
||||
eventKey: key,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_NilAppVer_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
EventType: "im.message.receive_v1",
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, nil)); err != nil {
|
||||
t.Fatalf("nil appVer must be a weak-dependency skip, got err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_EmptyRequired_SkipsEvenIfEventTypeSet(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.message_read_v1",
|
||||
EventType: "im.message.message_read_v1",
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{"im.message.receive_v1"}}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
|
||||
t.Fatalf("empty RequiredConsoleEvents must skip, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_AllSubscribed_Passes(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.reaction",
|
||||
EventType: "im.message.reaction.created_v1",
|
||||
RequiredConsoleEvents: []string{
|
||||
"im.message.reaction.created_v1",
|
||||
"im.message.reaction.deleted_v1",
|
||||
},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{
|
||||
"im.message.reaction.created_v1",
|
||||
"im.message.reaction.deleted_v1",
|
||||
"im.message.receive_v1",
|
||||
}}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "mail.receive",
|
||||
EventType: "mail.user_mailbox.event.message_received_v1",
|
||||
RequiredConsoleEvents: []string{
|
||||
"mail.user_mailbox.event.message_received_v1",
|
||||
"mail.user_mailbox.event.message_read_v1",
|
||||
},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{
|
||||
"mail.user_mailbox.event.message_received_v1",
|
||||
}}
|
||||
err := preflightEventTypes(newPreflightCtx("cli_XXXXXXXXXXXXXXXX", "feishu", "", def, appVer))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing subscription")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("bot + nil appVer should skip, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{TenantScopes: []string{
|
||||
"im:message",
|
||||
"im:message.group_at_msg",
|
||||
"contact:user:readonly",
|
||||
}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
if err != nil {
|
||||
t.Fatalf("all scopes granted, unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing scope")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
"token_type=tenant",
|
||||
}
|
||||
for _, want := range wantSubstrings {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("hint missing %q\ngot: %s", want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{Key: "x"}
|
||||
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
|
||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||
}
|
||||
}
|
||||
49
cmd/event/runtime.go
Normal file
49
cmd/event/runtime.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// consumeRuntime routes event.APIClient calls through the shared client.APIClient with a pinned identity.
|
||||
type consumeRuntime struct {
|
||||
client *client.APIClient
|
||||
accessIdentity core.Identity
|
||||
}
|
||||
|
||||
func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
resp, err := r.client.DoAPI(ctx, client.RawApiRequest{
|
||||
Method: method,
|
||||
URL: path,
|
||||
Data: body,
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if resp.StatusCode >= 400 && !client.IsJSONContentType(ct) && ct != "" {
|
||||
const maxBodyEcho = 256
|
||||
body := string(resp.RawBody)
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
}
|
||||
return json.RawMessage(resp.RawBody), nil
|
||||
}
|
||||
223
cmd/event/schema.go
Normal file
223
cmd/event/schema.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// resolveSchemaJSON returns the final JSON Schema for an EventKey (reflected base, V2-wrapped for Native, overlay applied); orphans lists unresolved FieldOverrides pointers.
|
||||
func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string, error) {
|
||||
spec, isNative := pickSpec(def.Schema)
|
||||
if spec == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
base, err := renderSpec(spec)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if base == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
if isNative {
|
||||
base = schemas.WrapV2Envelope(base)
|
||||
}
|
||||
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
|
||||
return base, nil, nil
|
||||
}
|
||||
|
||||
// pickSpec returns the non-nil spec and whether it is Native (requires V2 envelope wrap).
|
||||
func pickSpec(s eventlib.SchemaDef) (*eventlib.SchemaSpec, bool) {
|
||||
if s.Native != nil {
|
||||
return s.Native, true
|
||||
}
|
||||
if s.Custom != nil {
|
||||
return s.Custom, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied).
|
||||
func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
if s.Type != nil {
|
||||
return schemas.FromType(s.Type), nil
|
||||
}
|
||||
if len(s.Raw) > 0 {
|
||||
buf := make(json.RawMessage, len(s.Raw))
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema <EventKey>",
|
||||
Short: "Show details for an EventKey",
|
||||
Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runSchema(f, args[0], asJSON)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
def, ok := eventlib.Lookup(key)
|
||||
if !ok {
|
||||
return unknownEventKeyErr(key)
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
return writeSchemaJSON(f, def)
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
|
||||
fmt.Fprintf(out, "Key: %s\n", def.Key)
|
||||
if def.Description != "" {
|
||||
fmt.Fprintf(out, "Description: %s\n", def.Description)
|
||||
}
|
||||
fmt.Fprintf(out, "Event: %s\n", def.EventType)
|
||||
|
||||
if def.PreConsume != nil {
|
||||
fmt.Fprintf(out, "Pre-consume: yes\n")
|
||||
}
|
||||
|
||||
if len(def.Scopes) > 0 {
|
||||
fmt.Fprintf(out, "\nRequired Scopes:\n")
|
||||
for _, s := range def.Scopes {
|
||||
fmt.Fprintf(out, " - %s\n", s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(def.RequiredConsoleEvents) > 0 {
|
||||
fmt.Fprintf(out, "\nRequired Console Events (must be enabled in developer console):\n")
|
||||
for _, e := range def.RequiredConsoleEvents {
|
||||
fmt.Fprintf(out, " - %s\n", e)
|
||||
}
|
||||
}
|
||||
|
||||
if len(def.Params) > 0 {
|
||||
fmt.Fprintf(out, "\nParameters:\n")
|
||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
||||
for _, p := range def.Params {
|
||||
required := "no"
|
||||
if p.Required {
|
||||
required = "yes"
|
||||
}
|
||||
defaultVal := p.Default
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
}
|
||||
desc := p.Description
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
// Inline Values below the table so AI consumers see allowed enum/multi values without --json.
|
||||
for _, p := range def.Params {
|
||||
if len(p.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, "\n %s values:\n", p.Name)
|
||||
vw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
for _, v := range p.Values {
|
||||
fmt.Fprintf(vw, " %s\t%s\n", v.Value, v.Desc)
|
||||
}
|
||||
vw.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
printIndentedJSON(out, resolved)
|
||||
} else {
|
||||
fmt.Fprintf(out, "\nOutput Schema: (schema not declared)\n")
|
||||
if def.Schema.Native != nil {
|
||||
fmt.Fprintf(out, " Consumers receive the V2 envelope: {schema, header, event}.\n")
|
||||
fmt.Fprintf(out, " Inspect real payloads via `lark-cli event consume %s`.\n", def.Key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printIndentedJSON pretty-prints raw JSON with a 2-space leading indent.
|
||||
func printIndentedJSON(out io.Writer, raw json.RawMessage) {
|
||||
var parsed json.RawMessage
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
fmt.Fprintln(out, " <invalid JSON>")
|
||||
return
|
||||
}
|
||||
formatted, err := json.MarshalIndent(parsed, " ", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " %s\n", string(formatted))
|
||||
}
|
||||
|
||||
// writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`.
|
||||
func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error {
|
||||
type payload struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
JQRootPath string `json:"jq_root_path,omitempty"`
|
||||
}
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jqRootPath string
|
||||
if resolved != nil {
|
||||
// Native → V2 envelope ⇒ `.event.xxx`; Custom → flat ⇒ `.`.
|
||||
_, isNative := pickSpec(def.Schema)
|
||||
jqRootPath = "."
|
||||
if isNative {
|
||||
jqRootPath = ".event"
|
||||
}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, payload{
|
||||
KeyDefinition: def,
|
||||
ResolvedSchema: resolved,
|
||||
JQRootPath: jqRootPath,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
131
cmd/event/schema_test.go
Normal file
131
cmd/event/schema_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Key:", "im.message.receive_v1",
|
||||
"Event:", "im.message.receive_v1",
|
||||
"Output Schema:",
|
||||
`"message_id"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("schema output missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Output Schema:",
|
||||
`"schema"`,
|
||||
`"header"`,
|
||||
`"event"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("native schema output missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
err := runSchema(f, "im.message.recieve_v1", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown key")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "unknown EventKey") {
|
||||
t.Errorf("error should mention unknown EventKey: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "im.message.receive_v1") {
|
||||
t.Errorf("error should suggest the real key name (typo correction): %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
for _, field := range []string{"key", "event_type", "schema", "resolved_output_schema"} {
|
||||
if _, ok := payload[field]; !ok {
|
||||
t.Errorf("JSON output missing field %q: %+v", field, payload)
|
||||
}
|
||||
}
|
||||
if payload["key"] != "im.message.receive_v1" {
|
||||
t.Errorf("key = %v, want im.message.receive_v1", payload["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
type out struct {
|
||||
SenderID string `json:"sender_id"`
|
||||
}
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Type: reflect.TypeOf(out{})},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{
|
||||
"/sender_id": {Kind: "open_id"},
|
||||
},
|
||||
},
|
||||
Process: func(context.Context, eventlib.APIClient, *eventlib.RawEvent, map[string]string) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
def, _ := eventlib.Lookup(syntheticKey)
|
||||
resolved, orphans, err := resolveSchemaJSON(def)
|
||||
if err != nil || len(orphans) != 0 {
|
||||
t.Fatalf("resolve: err=%v orphans=%v", err, orphans)
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(resolved, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := parsed["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})["format"]
|
||||
if got != "open_id" {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
17
cmd/event/sigpipe_unix.go
Normal file
17
cmd/event/sigpipe_unix.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build unix
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ignoreBrokenPipe stops Go's default SIGPIPE-on-stdout terminate behavior.
|
||||
// Subsequent stdout writes return syscall.EPIPE so consume can shut down cleanly.
|
||||
func ignoreBrokenPipe() {
|
||||
signal.Ignore(syscall.SIGPIPE)
|
||||
}
|
||||
9
cmd/event/sigpipe_windows.go
Normal file
9
cmd/event/sigpipe_windows.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package event
|
||||
|
||||
// ignoreBrokenPipe is a no-op on Windows (no SIGPIPE; closed-pipe writes return ERROR_BROKEN_PIPE directly).
|
||||
func ignoreBrokenPipe() {}
|
||||
328
cmd/event/status.go
Normal file
328
cmd/event/status.go
Normal file
@@ -0,0 +1,328 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/event/busctl"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
asJSON bool
|
||||
current bool
|
||||
failOnOrphan bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show event bus daemon status for all discovered apps",
|
||||
Long: "Connect to each bus daemon under the config-dir/events/ tree and show PID, uptime, and active consumers. Use --current for only the current profile's app. Use --json for machine-readable output. Use --fail-on-orphan to exit 2 when any orphan bus is detected (for health checks).",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runStatus(f, current, asJSON, failOnOrphan)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)")
|
||||
cmd.Flags().BoolVar(¤t, "current", false, "Only show status for the current profile's app")
|
||||
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type busState int
|
||||
|
||||
const (
|
||||
stateNotRunning busState = iota
|
||||
stateRunning
|
||||
stateOrphan
|
||||
)
|
||||
|
||||
func (s busState) String() string {
|
||||
switch s {
|
||||
case stateRunning:
|
||||
return "running"
|
||||
case stateOrphan:
|
||||
return "orphan"
|
||||
default:
|
||||
return "not_running"
|
||||
}
|
||||
}
|
||||
|
||||
// appStatus bundles one AppID's derived status; State picks which fields are meaningful.
|
||||
type appStatus struct {
|
||||
AppID string
|
||||
State busState
|
||||
PID int
|
||||
UptimeSec int
|
||||
Active int
|
||||
Consumers []protocol.ConsumerInfo
|
||||
}
|
||||
|
||||
type busQuerier interface {
|
||||
QueryBusStatus(appID string) (*protocol.StatusResponse, error)
|
||||
}
|
||||
|
||||
// singleAppScanner wraps a Scanner and filters to one AppID for --current queries.
|
||||
type singleAppScanner struct {
|
||||
appID string
|
||||
inner busdiscover.Scanner
|
||||
}
|
||||
|
||||
func (s singleAppScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
|
||||
if s.inner == nil {
|
||||
return nil, nil
|
||||
}
|
||||
all, err := s.inner.ScanBusProcesses()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := all[:0]
|
||||
for _, p := range all {
|
||||
if p.AppID == s.appID {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type transportQuerier struct {
|
||||
tr transport.IPC
|
||||
}
|
||||
|
||||
func (q *transportQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
|
||||
return busctl.QueryStatus(q.tr, appID)
|
||||
}
|
||||
|
||||
func runStatus(f *cmdutil.Factory, current, asJSON, failOnOrphan bool) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seeds := map[string]struct{}{}
|
||||
if current {
|
||||
seeds[cfg.AppID] = struct{}{}
|
||||
} else {
|
||||
for _, id := range discoverAppIDs() {
|
||||
seeds[id] = struct{}{}
|
||||
}
|
||||
// Always include the current profile so a first-time user sees it as not_running.
|
||||
seeds[cfg.AppID] = struct{}{}
|
||||
}
|
||||
seedList := make([]string, 0, len(seeds))
|
||||
for id := range seeds {
|
||||
seedList = append(seedList, id)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
// --current: scope the scanner to this AppID so unrelated orphans don't surface.
|
||||
var scanner busdiscover.Scanner
|
||||
if current {
|
||||
scanner = singleAppScanner{appID: cfg.AppID, inner: busdiscover.Default()}
|
||||
} else {
|
||||
scanner = busdiscover.Default()
|
||||
}
|
||||
statuses := deriveStatuses(
|
||||
seedList,
|
||||
scanner,
|
||||
&transportQuerier{tr: tr},
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
if asJSON {
|
||||
if err := writeStatusJSON(f.IOStreams.Out, statuses); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
writeStatusText(f.IOStreams.Out, statuses)
|
||||
}
|
||||
return exitForOrphan(statuses, failOnOrphan)
|
||||
}
|
||||
|
||||
// deriveStatuses classifies each AppID as running/orphan/not_running from socket + process-scan inputs; scanner errors are non-fatal.
|
||||
func deriveStatuses(seedAppIDs []string, sc busdiscover.Scanner, q busQuerier, now time.Time) []appStatus {
|
||||
procByAppID := map[string]busdiscover.Process{}
|
||||
if sc != nil {
|
||||
if procs, err := sc.ScanBusProcesses(); err == nil {
|
||||
for _, p := range procs {
|
||||
procByAppID[p.AppID] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ids := map[string]struct{}{}
|
||||
for _, id := range seedAppIDs {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
for id := range procByAppID {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
sorted := make([]string, 0, len(ids))
|
||||
for id := range ids {
|
||||
sorted = append(sorted, id)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
|
||||
// Query in parallel so one wedged peer can't compound the per-op deadline across many apps.
|
||||
type probe struct {
|
||||
resp *protocol.StatusResponse
|
||||
err error
|
||||
}
|
||||
probes := make([]probe, len(sorted))
|
||||
var wg sync.WaitGroup
|
||||
for i, appID := range sorted {
|
||||
wg.Add(1)
|
||||
go func(i int, appID string) {
|
||||
defer wg.Done()
|
||||
probes[i].resp, probes[i].err = q.QueryBusStatus(appID)
|
||||
}(i, appID)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
result := make([]appStatus, 0, len(sorted))
|
||||
for i, appID := range sorted {
|
||||
s := appStatus{AppID: appID, State: stateNotRunning}
|
||||
if probes[i].err == nil {
|
||||
resp := probes[i].resp
|
||||
s.State = stateRunning
|
||||
s.PID = resp.PID
|
||||
s.UptimeSec = resp.UptimeSec
|
||||
s.Active = resp.ActiveConns
|
||||
s.Consumers = resp.Consumers
|
||||
} else if p, ok := procByAppID[appID]; ok {
|
||||
s.State = stateOrphan
|
||||
s.PID = p.PID
|
||||
s.UptimeSec = int(now.Sub(p.StartTime).Seconds())
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// humanizeDuration formats d as a coarse "N unit ago" string.
|
||||
func humanizeDuration(d time.Duration) string {
|
||||
s := int(d.Seconds())
|
||||
if s < 60 {
|
||||
return fmt.Sprintf("%ds ago", s)
|
||||
}
|
||||
m := s / 60
|
||||
if m < 60 {
|
||||
return fmt.Sprintf("%dm ago", m)
|
||||
}
|
||||
h := m / 60
|
||||
if h < 24 {
|
||||
return fmt.Sprintf("%dh ago", h)
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", h/24)
|
||||
}
|
||||
|
||||
func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
for i, s := range statuses {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
fmt.Fprintf(out, "── %s ──\n", s.AppID)
|
||||
switch s.State {
|
||||
case stateNotRunning:
|
||||
fmt.Fprintln(out, " Bus: not running")
|
||||
case stateRunning:
|
||||
fmt.Fprintf(out, " Bus: running (PID %d, uptime %s)\n",
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
||||
rows := make([][]string, 0, len(s.Consumers))
|
||||
for _, c := range s.Consumers {
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("pid=%d", c.PID),
|
||||
c.EventKey,
|
||||
fmt.Sprintf("%d", c.Received),
|
||||
fmt.Sprintf("%d", c.Dropped),
|
||||
})
|
||||
}
|
||||
widths := tableWidths(headers, rows)
|
||||
const colGap = " "
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprint(out, " ")
|
||||
printTableRow(out, widths, headers, colGap)
|
||||
for _, row := range rows {
|
||||
fmt.Fprint(out, " ")
|
||||
printTableRow(out, widths, row, colGap)
|
||||
}
|
||||
}
|
||||
case stateOrphan:
|
||||
if s.PID == 0 {
|
||||
fmt.Fprintln(out, " Bus: orphan (PID unknown — bus.pid file unreadable)")
|
||||
fmt.Fprintln(out, " Issue: live bus detected but pid file is missing or corrupt")
|
||||
fmt.Fprintln(out, " Action: inspect ~/.lark-cli/events/<app>/bus.pid and kill manually")
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(out, " Bus: orphan (PID %d, started %s)\n",
|
||||
s.PID, humanizeDuration(time.Duration(s.UptimeSec)*time.Second))
|
||||
fmt.Fprintln(out, " Issue: socket file missing — consumers cannot connect")
|
||||
fmt.Fprintf(out, " Action: kill %d\n", s.PID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeStatusJSON(w io.Writer, statuses []appStatus) error {
|
||||
type jsonStatus struct {
|
||||
AppID string `json:"app_id"`
|
||||
Status string `json:"status"`
|
||||
Running bool `json:"running"` // backward compat
|
||||
PID int `json:"pid,omitempty"`
|
||||
UptimeSec int `json:"uptime_sec,omitempty"`
|
||||
Active int `json:"active_consumers,omitempty"`
|
||||
Consumers []protocol.ConsumerInfo `json:"consumers,omitempty"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
SuggestedAction string `json:"suggested_action,omitempty"`
|
||||
}
|
||||
payload := make([]jsonStatus, 0, len(statuses))
|
||||
for _, s := range statuses {
|
||||
js := jsonStatus{
|
||||
AppID: s.AppID,
|
||||
Status: s.State.String(),
|
||||
Running: s.State == stateRunning,
|
||||
PID: s.PID,
|
||||
UptimeSec: s.UptimeSec,
|
||||
Active: s.Active,
|
||||
Consumers: s.Consumers,
|
||||
}
|
||||
if s.State == stateOrphan {
|
||||
if s.PID == 0 {
|
||||
js.Issue = "live bus detected but pid file is missing or corrupt"
|
||||
js.SuggestedAction = "inspect events dir and kill manually"
|
||||
} else {
|
||||
js.Issue = "socket file missing"
|
||||
js.SuggestedAction = fmt.Sprintf("kill %d", s.PID)
|
||||
}
|
||||
}
|
||||
payload = append(payload, js)
|
||||
}
|
||||
output.PrintJson(w, map[string]interface{}{"apps": payload})
|
||||
return nil
|
||||
}
|
||||
|
||||
// exitForOrphan returns ExitValidation iff failOnOrphan and any status is orphan; default exit 0 preserves observe-only semantics.
|
||||
func exitForOrphan(statuses []appStatus, failOnOrphan bool) error {
|
||||
if !failOnOrphan {
|
||||
return nil
|
||||
}
|
||||
for _, s := range statuses {
|
||||
if s.State == stateOrphan {
|
||||
return output.ErrBare(output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
cmd/event/status_fail_on_orphan_test.go
Normal file
48
cmd/event/status_fail_on_orphan_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestExitForOrphan_Orphan(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_a", State: stateRunning},
|
||||
{AppID: "cli_b", State: stateOrphan, PID: 70926},
|
||||
}
|
||||
err := exitForOrphan(statuses, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when failOnOrphan=true and orphan present")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan_NoOrphan(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_a", State: stateRunning},
|
||||
{AppID: "cli_b", State: stateNotRunning},
|
||||
}
|
||||
if err := exitForOrphan(statuses, true); err != nil {
|
||||
t.Errorf("expected nil error when no orphan; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan_FlagDisabled(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_b", State: stateOrphan, PID: 70926},
|
||||
}
|
||||
if err := exitForOrphan(statuses, false); err != nil {
|
||||
t.Errorf("flag off should never return error; got %v", err)
|
||||
}
|
||||
}
|
||||
242
cmd/event/status_orphan_test.go
Normal file
242
cmd/event/status_orphan_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
type fakeScanner struct {
|
||||
procs []busdiscover.Process
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
|
||||
return f.procs, f.err
|
||||
}
|
||||
|
||||
type fakeBusQuerier struct {
|
||||
respByAppID map[string]*protocol.StatusResponse
|
||||
}
|
||||
|
||||
func (f *fakeBusQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
|
||||
if r, ok := f.respByAppID[appID]; ok {
|
||||
return r, nil
|
||||
}
|
||||
return nil, errors.New("dial failed")
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_RunningBus(t *testing.T) {
|
||||
q := &fakeBusQuerier{
|
||||
respByAppID: map[string]*protocol.StatusResponse{
|
||||
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
|
||||
},
|
||||
}
|
||||
sc := &fakeScanner{procs: nil}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateRunning {
|
||||
t.Errorf("State = %v, want stateRunning", s.State)
|
||||
}
|
||||
if s.PID != 12345 {
|
||||
t.Errorf("PID = %d, want 12345", s.PID)
|
||||
}
|
||||
if s.UptimeSec != 150 {
|
||||
t.Errorf("UptimeSec = %d, want 150", s.UptimeSec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_OrphanBus(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: []busdiscover.Process{
|
||||
{PID: 70926, AppID: "cli_a", StartTime: time.Now().Add(-19 * time.Hour)},
|
||||
}}
|
||||
|
||||
now := time.Now()
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, now)
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateOrphan {
|
||||
t.Errorf("State = %v, want stateOrphan", s.State)
|
||||
}
|
||||
if s.PID != 70926 {
|
||||
t.Errorf("PID = %d, want 70926", s.PID)
|
||||
}
|
||||
wantUptime := int((19 * time.Hour).Seconds())
|
||||
if s.UptimeSec < wantUptime-60 || s.UptimeSec > wantUptime+60 {
|
||||
t.Errorf("UptimeSec = %d, want ~%d", s.UptimeSec, wantUptime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_NotRunning(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: nil}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateNotRunning {
|
||||
t.Errorf("State = %v, want stateNotRunning", s.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_DiscoversOrphanAppIDsFromProcessScan(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: []busdiscover.Process{
|
||||
{PID: 70926, AppID: "cli_orphan", StartTime: time.Now().Add(-1 * time.Hour)},
|
||||
}}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_known"}, sc, q, time.Now())
|
||||
if len(statuses) != 2 {
|
||||
t.Fatalf("expected 2 statuses, got %d: %+v", len(statuses), statuses)
|
||||
}
|
||||
byID := map[string]appStatus{}
|
||||
for _, s := range statuses {
|
||||
byID[s.AppID] = s
|
||||
}
|
||||
if byID["cli_known"].State != stateNotRunning {
|
||||
t.Errorf("cli_known state = %v, want stateNotRunning", byID["cli_known"].State)
|
||||
}
|
||||
if byID["cli_orphan"].State != stateOrphan {
|
||||
t.Errorf("cli_orphan state = %v, want stateOrphan", byID["cli_orphan"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_ScannerErrorIsNotFatal(t *testing.T) {
|
||||
q := &fakeBusQuerier{
|
||||
respByAppID: map[string]*protocol.StatusResponse{
|
||||
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
|
||||
},
|
||||
}
|
||||
sc := &fakeScanner{err: errors.New("ps failed")}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
if statuses[0].State != stateRunning {
|
||||
t.Errorf("State = %v, want stateRunning (scanner error must not break running detection)", statuses[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_OrphanBlock(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_XXXXXXXXXXXXXXXX",
|
||||
State: stateOrphan,
|
||||
PID: 70926,
|
||||
UptimeSec: 68400,
|
||||
}}
|
||||
writeStatusText(&buf, statuses)
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"── cli_XXXXXXXXXXXXXXXX ──",
|
||||
"Bus: orphan (PID 70926, started 19h ago)",
|
||||
"Issue: socket file missing — consumers cannot connect",
|
||||
"Action: kill 70926",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\nfull output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "running (PID") {
|
||||
t.Errorf("orphan block must not contain 'running' text; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_XXXXXXXXXXXXXXXX",
|
||||
State: stateOrphan,
|
||||
PID: 70926,
|
||||
UptimeSec: 68400,
|
||||
}}
|
||||
if err := writeStatusJSON(&buf, statuses); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Apps []map[string]interface{} `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(payload.Apps) != 1 {
|
||||
t.Fatalf("apps len = %d, want 1", len(payload.Apps))
|
||||
}
|
||||
a := payload.Apps[0]
|
||||
if a["status"] != "orphan" {
|
||||
t.Errorf("status = %v, want \"orphan\"", a["status"])
|
||||
}
|
||||
if a["running"] != false {
|
||||
t.Errorf("running = %v, want false", a["running"])
|
||||
}
|
||||
if a["issue"] != "socket file missing" {
|
||||
t.Errorf("issue = %v, want \"socket file missing\"", a["issue"])
|
||||
}
|
||||
if a["suggested_action"] != "kill 70926" {
|
||||
t.Errorf("suggested_action = %v, want \"kill 70926\"", a["suggested_action"])
|
||||
}
|
||||
if pid, ok := a["pid"].(float64); !ok || int(pid) != 70926 {
|
||||
t.Errorf("pid = %v, want 70926", a["pid"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_RunningOmitsOrphanFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_running",
|
||||
State: stateRunning,
|
||||
PID: 11111,
|
||||
UptimeSec: 60,
|
||||
Active: 0,
|
||||
}}
|
||||
if err := writeStatusJSON(&buf, statuses); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, `"issue"`) {
|
||||
t.Errorf("running status must not include 'issue' field; got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `"suggested_action"`) {
|
||||
t.Errorf("running status must not include 'suggested_action' field; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanizeDuration(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{30 * time.Second, "30s ago"},
|
||||
{90 * time.Second, "1m ago"},
|
||||
{45 * time.Minute, "45m ago"},
|
||||
{90 * time.Minute, "1h ago"},
|
||||
{5 * time.Hour, "5h ago"},
|
||||
{30 * time.Hour, "1d ago"},
|
||||
{80 * time.Hour, "3d ago"},
|
||||
} {
|
||||
got := humanizeDuration(tt.d)
|
||||
if got != tt.want {
|
||||
t.Errorf("humanizeDuration(%v) = %q, want %q", tt.d, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
241
cmd/event/stop.go
Normal file
241
cmd/event/stop.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/event/busctl"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// stopStatus is the outcome tag; JSON wire format is the string form — keep values stable.
|
||||
type stopStatus string
|
||||
|
||||
const (
|
||||
stopStopped stopStatus = "stopped"
|
||||
stopNoBus stopStatus = "no_bus"
|
||||
stopRefused stopStatus = "refused"
|
||||
stopErrored stopStatus = "error"
|
||||
)
|
||||
|
||||
type stopResult struct {
|
||||
AppID string `json:"app_id"`
|
||||
Status stopStatus `json:"status"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type stopCmdOpts struct {
|
||||
appID string
|
||||
all bool
|
||||
force bool
|
||||
asJSON bool
|
||||
}
|
||||
|
||||
func NewCmdStop(f *cmdutil.Factory) *cobra.Command {
|
||||
var o stopCmdOpts
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the event bus daemon",
|
||||
Long: `Stop the event bus daemon. Target is one of:
|
||||
• the current profile's AppID (default)
|
||||
• an explicit AppID via --app-id
|
||||
• every running bus on this machine via --all
|
||||
|
||||
Exit code: 2 if any target was refused or errored, 0 otherwise.
|
||||
|
||||
--force widens two gates:
|
||||
1. Allows stopping a bus that still has active consumers.
|
||||
2. On shutdown-timeout (bus didn't exit within 5s), SIGKILLs the
|
||||
process and cleans up the stale socket instead of returning an
|
||||
error.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runStop(f, o)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&o.appID, "app-id", "", "App ID of the bus to stop (default: current profile)")
|
||||
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
|
||||
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
|
||||
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStop(f *cmdutil.Factory, o stopCmdOpts) error {
|
||||
tr := transport.New()
|
||||
|
||||
var targets []string
|
||||
if o.all {
|
||||
targets = discoverAppIDs()
|
||||
} else {
|
||||
targetAppID := o.appID
|
||||
if targetAppID == "" {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetAppID = cfg.AppID
|
||||
}
|
||||
targets = []string{targetAppID}
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
if o.asJSON {
|
||||
return writeStopJSON(f.IOStreams.Out, nil)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.Out, "No event bus instances found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
results := make([]stopResult, 0, len(targets))
|
||||
for _, id := range targets {
|
||||
results = append(results, stopBusOne(tr, id, o.force))
|
||||
}
|
||||
|
||||
if o.asJSON {
|
||||
return writeStopJSON(f.IOStreams.Out, results)
|
||||
}
|
||||
writeStopText(f.IOStreams.Out, f.IOStreams.ErrOut, results)
|
||||
|
||||
// Non-zero exit for refused/errored so non-JSON callers still get a signal.
|
||||
for _, r := range results {
|
||||
if r.Status == stopRefused || r.Status == stopErrored {
|
||||
return output.ErrBare(output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopBusOne attempts to stop appID's bus; polls tr.Dial post-Shutdown until listener is gone or budget elapses.
|
||||
func stopBusOne(tr transport.IPC, appID string, force bool) stopResult {
|
||||
resp, err := busctl.QueryStatus(tr, appID)
|
||||
if err != nil {
|
||||
return stopResult{AppID: appID, Status: stopNoBus}
|
||||
}
|
||||
|
||||
if resp.ActiveConns > 0 && !force {
|
||||
pids := make([]int, len(resp.Consumers))
|
||||
for i, c := range resp.Consumers {
|
||||
pids[i] = c.PID
|
||||
}
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopRefused,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("%d active consumer(s) (pids: %v); use --force to override", resp.ActiveConns, pids),
|
||||
}
|
||||
}
|
||||
|
||||
if err := busctl.SendShutdown(tr, appID); err != nil {
|
||||
return stopResult{AppID: appID, Status: stopErrored, PID: resp.PID, Reason: err.Error()}
|
||||
}
|
||||
|
||||
const pollInterval = 100 * time.Millisecond
|
||||
deadline := time.Now().Add(shutdownBudget)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
probe, dialErr := tr.Dial(tr.Address(appID))
|
||||
if dialErr != nil {
|
||||
return stopResult{AppID: appID, Status: stopStopped, PID: resp.PID}
|
||||
}
|
||||
probe.Close()
|
||||
}
|
||||
|
||||
if !force {
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopErrored,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("Bus did not exit within %v (pid=%d still listening); use --force to kill", shutdownBudget, resp.PID),
|
||||
}
|
||||
}
|
||||
|
||||
// --force: SIGKILL and clean up the stale socket.
|
||||
if err := killProcess(resp.PID); err != nil {
|
||||
if errors.Is(err, os.ErrProcessDone) {
|
||||
// Bus exited between timeout and kill — treat as success.
|
||||
tr.Cleanup(tr.Address(appID))
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopStopped,
|
||||
PID: resp.PID,
|
||||
Reason: "bus exited during kill attempt",
|
||||
}
|
||||
}
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopErrored,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("failed to kill bus process: %v", err),
|
||||
}
|
||||
}
|
||||
tr.Cleanup(tr.Address(appID))
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopStopped,
|
||||
PID: resp.PID,
|
||||
Reason: "killed (ungraceful) after shutdown timeout",
|
||||
}
|
||||
}
|
||||
|
||||
// killProcess is a var so tests can swap it without spawning sub-processes.
|
||||
var killProcess = func(pid int) error {
|
||||
p, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Kill()
|
||||
}
|
||||
|
||||
// shutdownBudget (var so tests can shrink it) bounds the post-Shutdown exit wait.
|
||||
var shutdownBudget = 5 * time.Second
|
||||
|
||||
func writeStopJSON(w io.Writer, results []stopResult) error {
|
||||
if results == nil {
|
||||
results = []stopResult{}
|
||||
}
|
||||
output.PrintJson(w, map[string]interface{}{"results": results})
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeStopText(out, errOut io.Writer, results []stopResult) {
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case stopStopped:
|
||||
fmt.Fprintf(out, "Bus stopped for %s (pid=%d)\n", r.AppID, r.PID)
|
||||
case stopNoBus:
|
||||
fmt.Fprintf(out, "No bus running for %s\n", r.AppID)
|
||||
case stopRefused:
|
||||
fmt.Fprintf(errOut, "Refused stopping %s: %s\n", r.AppID, r.Reason)
|
||||
case stopErrored:
|
||||
fmt.Fprintf(errOut, "Error stopping %s: %s\n", r.AppID, r.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// discoverAppIDs returns appIDs whose bus.alive.lock is held by a live process.
|
||||
// Cross-platform via lockfile (flock on Unix, LockFileEx on Windows); ignores stale socket files.
|
||||
func discoverAppIDs() []string {
|
||||
procs, err := busdiscover.Default().ScanBusProcesses()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
ids = append(ids, p.AppID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
73
cmd/event/stop_discover_test.go
Normal file
73
cmd/event/stop_discover_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
)
|
||||
|
||||
func TestDiscoverAppIDs_OnlyLiveLockHolders(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
|
||||
eventsDir := filepath.Join(tmp, "events")
|
||||
|
||||
// Two live buses (lock held until t.Cleanup releases it).
|
||||
for _, app := range []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"} {
|
||||
appDir := filepath.Join(eventsDir, app)
|
||||
h, err := busdiscover.WritePIDFile(appDir, 1234)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile %s: %v", app, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = h.Release() })
|
||||
}
|
||||
|
||||
// Dead bus: lock acquired then released → looks like a stale dir on disk.
|
||||
deadDir := filepath.Join(eventsDir, "cli_ZZZZZZZZZZZZZZZZ")
|
||||
hDead, err := busdiscover.WritePIDFile(deadDir, 9999)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile dead: %v", err)
|
||||
}
|
||||
if err := hDead.Release(); err != nil {
|
||||
t.Fatalf("Release dead: %v", err)
|
||||
}
|
||||
|
||||
// Stale bus.sock without alive.lock — old behavior would surface it; new must not.
|
||||
staleSockDir := filepath.Join(eventsDir, "cli_SSSSSSSSSSSSSSSS")
|
||||
if err := os.MkdirAll(staleSockDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staleSockDir, "bus.sock"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Stray non-dir file under events/.
|
||||
if err := os.WriteFile(filepath.Join(eventsDir, "stray.txt"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := discoverAppIDs()
|
||||
sort.Strings(got)
|
||||
want := []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("discoverAppIDs() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("discoverAppIDs()[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAppIDs_MissingEventsDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if got := discoverAppIDs(); len(got) != 0 {
|
||||
t.Errorf("discoverAppIDs() on missing events/ = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
340
cmd/event/stop_integration_test.go
Normal file
340
cmd/event/stop_integration_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
type mockTransport struct {
|
||||
mu sync.Mutex
|
||||
addr string
|
||||
cleaned bool
|
||||
}
|
||||
|
||||
func (t *mockTransport) Listen(addr string) (net.Listener, error) {
|
||||
return net.Listen("tcp", addr)
|
||||
}
|
||||
|
||||
func (t *mockTransport) Dial(addr string) (net.Conn, error) {
|
||||
return net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
}
|
||||
|
||||
func (t *mockTransport) Address(appID string) string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.addr
|
||||
}
|
||||
|
||||
func (t *mockTransport) Cleanup(addr string) {
|
||||
t.mu.Lock()
|
||||
t.cleaned = true
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *mockTransport) didCleanup() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.cleaned
|
||||
}
|
||||
|
||||
type fakeBus struct {
|
||||
listener net.Listener
|
||||
pid int
|
||||
exitDelay time.Duration
|
||||
unresponsive bool
|
||||
|
||||
shutdownCount int32
|
||||
wg sync.WaitGroup
|
||||
|
||||
stopOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newFakeBus(t *testing.T, pid int, exitDelay time.Duration, unresponsive bool) *fakeBus {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
b := &fakeBus{
|
||||
listener: ln,
|
||||
pid: pid,
|
||||
exitDelay: exitDelay,
|
||||
unresponsive: unresponsive,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
b.wg.Add(1)
|
||||
go b.serve()
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *fakeBus) addr() string { return b.listener.Addr().String() }
|
||||
|
||||
func (b *fakeBus) serve() {
|
||||
defer b.wg.Done()
|
||||
for {
|
||||
conn, err := b.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b.wg.Add(1)
|
||||
go b.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBus) handle(conn net.Conn) {
|
||||
defer b.wg.Done()
|
||||
defer conn.Close()
|
||||
|
||||
r := bufio.NewReader(conn)
|
||||
line, err := r.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(line)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.(type) {
|
||||
case *protocol.StatusQuery:
|
||||
_ = protocol.Encode(conn, &protocol.StatusResponse{
|
||||
Type: protocol.MsgTypeStatusResponse,
|
||||
PID: b.pid,
|
||||
UptimeSec: 1,
|
||||
ActiveConns: 0,
|
||||
Consumers: nil,
|
||||
})
|
||||
case *protocol.Shutdown:
|
||||
atomic.AddInt32(&b.shutdownCount, 1)
|
||||
if b.unresponsive {
|
||||
return
|
||||
}
|
||||
if b.exitDelay > 0 {
|
||||
go func() {
|
||||
time.Sleep(b.exitDelay)
|
||||
b.stop()
|
||||
}()
|
||||
} else {
|
||||
go b.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBus) stop() {
|
||||
b.stopOnce.Do(func() {
|
||||
_ = b.listener.Close()
|
||||
close(b.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *fakeBus) wait(t *testing.T, budget time.Duration) {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(budget):
|
||||
t.Fatalf("fakeBus did not shut down within %v", budget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopReturnsStoppedOnlyAfterBusExits(t *testing.T) {
|
||||
const pid = 44441
|
||||
const exitDelay = 500 * time.Millisecond
|
||||
|
||||
bus := newFakeBus(t, pid, exitDelay, false)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Fatalf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < 400*time.Millisecond {
|
||||
t.Fatalf("stopBusOne returned in %v; expected >= %v (waited for bus to exit)", elapsed, exitDelay)
|
||||
}
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("stopBusOne took %v; expected well under 3s", elapsed)
|
||||
}
|
||||
|
||||
bus.wait(t, 2*time.Second)
|
||||
if got := atomic.LoadInt32(&bus.shutdownCount); got != 1 {
|
||||
t.Errorf("fakeBus received %d Shutdown messages; want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopTimesOutOnUnresponsiveBusWithoutForce(t *testing.T) {
|
||||
const pid = 44442
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
origBudget := shutdownBudget
|
||||
t.Cleanup(func() { shutdownBudget = origBudget })
|
||||
shutdownBudget = 500 * time.Millisecond
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "error" {
|
||||
t.Fatalf("status = %q (reason=%q); want error", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
|
||||
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
|
||||
}
|
||||
if !strings.Contains(res.Reason, "did not exit within") {
|
||||
t.Errorf("reason %q should mention 'did not exit within'", res.Reason)
|
||||
}
|
||||
killMu.Lock()
|
||||
defer killMu.Unlock()
|
||||
if len(killCalls) != 0 {
|
||||
t.Errorf("killProcess called %v; want 0 calls without --force", killCalls)
|
||||
}
|
||||
if tr.didCleanup() {
|
||||
t.Errorf("Cleanup should not be called when --force is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopForceKillsUnresponsiveBus(t *testing.T) {
|
||||
const pid = 44443
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
origBudget := shutdownBudget
|
||||
t.Cleanup(func() { shutdownBudget = origBudget })
|
||||
shutdownBudget = 500 * time.Millisecond
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", true)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
|
||||
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
|
||||
}
|
||||
if !strings.Contains(res.Reason, "killed") {
|
||||
t.Errorf("reason %q should mention 'killed'", res.Reason)
|
||||
}
|
||||
|
||||
killMu.Lock()
|
||||
defer killMu.Unlock()
|
||||
if len(killCalls) != 1 || killCalls[0] != pid {
|
||||
t.Errorf("killProcess calls = %v; want [%d]", killCalls, pid)
|
||||
}
|
||||
if !tr.didCleanup() {
|
||||
t.Errorf("Cleanup was not invoked after force-kill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopReturnsStoppedFastWhenBusExitsImmediately(t *testing.T) {
|
||||
const pid = 12345
|
||||
|
||||
bus := newFakeBus(t, pid, 0, false)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("expected stopped, got %q (reason: %s)", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("expected PID=%d, got %d", pid, res.PID)
|
||||
}
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Errorf("expected fast return (<500ms), got %v — possibly waiting the full budget", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopForceHandlesProcessAlreadyDeadRace(t *testing.T) {
|
||||
const pid = 99999
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return os.ErrProcessDone
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
res := stopBusOne(tr, "test-app", true)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Errorf("expected stopped (race treated as success), got %q (reason: %s)", res.Status, res.Reason)
|
||||
}
|
||||
killMu.Lock()
|
||||
if len(killCalls) != 1 || killCalls[0] != pid {
|
||||
t.Errorf("expected killProcess called once with pid=%d, got %v", pid, killCalls)
|
||||
}
|
||||
killMu.Unlock()
|
||||
if !tr.didCleanup() {
|
||||
t.Error("expected Cleanup to be called even when kill reported already-dead")
|
||||
}
|
||||
if !strings.Contains(res.Reason, "exited during kill attempt") {
|
||||
t.Errorf("expected reason about race, got %q", res.Reason)
|
||||
}
|
||||
}
|
||||
102
cmd/event/suggestions.go
Normal file
102
cmd/event/suggestions.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const maxSuggestions = 3
|
||||
|
||||
// suggestEventKeys returns up to maxSuggestions keys resembling input (substring match beats edit distance).
|
||||
func suggestEventKeys(input string) []string {
|
||||
type match struct {
|
||||
key string
|
||||
dist int
|
||||
}
|
||||
var hits []match
|
||||
threshold := max(2, len(input)/5)
|
||||
|
||||
for _, def := range eventlib.ListAll() {
|
||||
if strings.Contains(def.Key, input) {
|
||||
hits = append(hits, match{def.Key, 0})
|
||||
continue
|
||||
}
|
||||
if d := levenshtein(input, def.Key); d <= threshold {
|
||||
hits = append(hits, match{def.Key, d})
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool { return hits[i].dist < hits[j].dist })
|
||||
|
||||
n := min(maxSuggestions, len(hits))
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
out[i] = hits[i].key
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatSuggestions renders keys as a human-readable quoted tail.
|
||||
func formatSuggestions(keys []string) string {
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
quoted := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
quoted[i] = fmt.Sprintf("%q", k)
|
||||
}
|
||||
if len(quoted) == 1 {
|
||||
return quoted[0]
|
||||
}
|
||||
return "one of: " + strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
// unknownEventKeyErr builds the shared "unknown EventKey" error with a suggestion tail when available.
|
||||
func unknownEventKeyErr(key string) error {
|
||||
msg := fmt.Sprintf("unknown EventKey: %s", key)
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
}
|
||||
|
||||
// levenshtein computes classic edit distance (two-row DP).
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
150
cmd/event/suggestions_test.go
Normal file
150
cmd/event/suggestions_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "kitten", 0},
|
||||
{"kitten", "sitten", 1},
|
||||
{"kitten", "sitting", 3},
|
||||
{"飞书", "飞书", 0},
|
||||
{"飞书", "飞s", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := levenshtein(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestEventKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantEmpty bool
|
||||
wantAllHavePrefix string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "typo via Levenshtein (recieve → receive)",
|
||||
input: "im.message.recieve_v1",
|
||||
wantContains: "im.message.receive_v1",
|
||||
},
|
||||
{
|
||||
name: "substring match returns im.message.* keys",
|
||||
input: "im.message",
|
||||
wantAllHavePrefix: "im.message.",
|
||||
},
|
||||
{
|
||||
name: "completely unrelated input returns empty",
|
||||
input: "xyzzy_no_such_event_key_at_all",
|
||||
wantEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "exact key is a substring of itself",
|
||||
input: "im.message.receive_v1",
|
||||
wantContains: "im.message.receive_v1",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := suggestEventKeys(tc.input)
|
||||
if tc.wantEmpty {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty slice, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected non-empty suggestions, got nothing")
|
||||
}
|
||||
if len(got) > maxSuggestions {
|
||||
t.Errorf("got %d suggestions, want at most %d: %v", len(got), maxSuggestions, got)
|
||||
}
|
||||
if tc.wantAllHavePrefix != "" {
|
||||
for _, k := range got {
|
||||
if !strings.HasPrefix(k, tc.wantAllHavePrefix) {
|
||||
t.Errorf("suggestion %q lacks prefix %q (full slice: %v)", k, tc.wantAllHavePrefix, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if tc.wantContains != "" {
|
||||
found := false
|
||||
for _, k := range got {
|
||||
if k == tc.wantContains {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("want %q in suggestions, got %v", tc.wantContains, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSuggestions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{name: "empty → empty string", in: nil, want: ""},
|
||||
{name: "single key → just quoted", in: []string{"a"}, want: `"a"`},
|
||||
{name: "two keys → one of", in: []string{"a", "b"}, want: `one of: "a", "b"`},
|
||||
{name: "three keys → one of", in: []string{"a", "b", "c"}, want: `one of: "a", "b", "c"`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := formatSuggestions(tc.in); got != tc.want {
|
||||
t.Errorf("formatSuggestions(%v) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
|
||||
err := unknownEventKeyErr("im.message.recieve_v1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"unknown EventKey: im.message.recieve_v1",
|
||||
"did you mean",
|
||||
"im.message.receive_v1",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("error %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownEventKeyErr_NoSuggestion(t *testing.T) {
|
||||
err := unknownEventKeyErr("xyzzy_no_such_event_key_at_all")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "unknown EventKey") {
|
||||
t.Errorf("error should mention unknown EventKey: %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "did you mean") {
|
||||
t.Errorf("error should NOT suggest anything for nonsense input: %q", msg)
|
||||
}
|
||||
}
|
||||
39
cmd/event/table.go
Normal file
39
cmd/event/table.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// tableWidths returns the max cell width per column across headers + rows.
|
||||
func tableWidths(headers []string, rows [][]string) []int {
|
||||
widths := make([]int, len(headers))
|
||||
for i, h := range headers {
|
||||
widths[i] = len(h)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if i >= len(widths) {
|
||||
break
|
||||
}
|
||||
if l := len(cell); l > widths[i] {
|
||||
widths[i] = l
|
||||
}
|
||||
}
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
// printTableRow renders one padded row; final cell is unpadded to avoid trailing whitespace.
|
||||
func printTableRow(out io.Writer, widths []int, cells []string, gap string) {
|
||||
for i, cell := range cells {
|
||||
if i == len(cells)-1 {
|
||||
fmt.Fprintln(out, cell)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, "%-*s%s", widths[i], cell, gap)
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func isCompletionCommand(args []string) bool {
|
||||
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
|
||||
// the invocation will actually serve a __complete request.
|
||||
func configureFlagCompletions(args []string) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
|
||||
cmdutil.SetFlagCompletionsEnabled(isCompletionCommand(args))
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
@@ -262,11 +262,15 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Risk:", level)
|
||||
}
|
||||
tips := cmdutil.GetTips(cmd)
|
||||
if len(tips) == 0 {
|
||||
return
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range tips {
|
||||
|
||||
70
cmd/root_risk_help_test.go
Normal file
70
cmd/root_risk_help_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// rendersHelp runs the wrapped help func and returns stdout.
|
||||
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.HelpFunc()(cmd, nil)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
if !strings.Contains(out, "Risk: high-risk-write") {
|
||||
t.Errorf("expected Risk line in help output, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "list", Short: "list items"}
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
if strings.Contains(out, "Risk:") {
|
||||
t.Errorf("expected no Risk line when annotation is absent, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
cmdutil.SetTips(child, []string{"use --yes to confirm"})
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
riskIdx := strings.Index(out, "Risk:")
|
||||
tipsIdx := strings.Index(out, "Tips:")
|
||||
if riskIdx == -1 || tipsIdx == -1 {
|
||||
t.Fatalf("expected both Risk and Tips sections, got:\n%s", out)
|
||||
}
|
||||
if riskIdx >= tipsIdx {
|
||||
t.Errorf("expected Risk to precede Tips; got Risk@%d, Tips@%d", riskIdx, tipsIdx)
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigureFlagCompletions(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -213,10 +213,10 @@ func TestConfigureFlagCompletions(t *testing.T) {
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
|
||||
cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled)
|
||||
configureFlagCompletions(tc.args)
|
||||
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
|
||||
if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
risk := registry.GetStrFromMap(method, "risk")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
|
||||
|
||||
@@ -179,6 +180,9 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
if risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
@@ -194,6 +198,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
cmdutil.SetRisk(cmd, risk)
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
}
|
||||
@@ -249,6 +254,12 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
|
||||
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
|
||||
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
|
||||
return cmdutil.RequireConfirmation(opts.SchemaPath)
|
||||
}
|
||||
}
|
||||
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
114
cmd/service/service_risk_test.go
Normal file
114
cmd/service/service_risk_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
|
||||
// parameter and risk metadata. The returned map is what service registration
|
||||
// reads; the test exercises --yes registration and the gate behavior.
|
||||
func highRiskDeleteMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"risk": "high-risk-write",
|
||||
"parameters": map[string]interface{}{
|
||||
"file_token": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeMethodNoRisk() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"parameters": map[string]interface{}{
|
||||
"file_token": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("yes") == nil {
|
||||
t.Error("expected --yes flag registered for risk=high-risk-write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_YesFlagNotRegisteredForWrite(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), writeMethodNoRisk(), "delete", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("yes") != nil {
|
||||
t.Error("expected --yes flag NOT registered when risk is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_RiskAnnotationSet(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
|
||||
level, ok := cmdutil.GetRisk(cmd)
|
||||
if !ok {
|
||||
t.Fatal("expected Risk annotation to be set")
|
||||
}
|
||||
if level != "high-risk-write" {
|
||||
t.Errorf("level = %q, want high-risk-write", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_RiskAnnotationAbsentForUnsetRisk(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), writeMethodNoRisk(), "delete", "files", nil)
|
||||
|
||||
if _, ok := cmdutil.GetRisk(cmd); ok {
|
||||
t.Error("expected no Risk annotation when meta risk is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_GateBlocksWithoutYes(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
// --as bot skips the scope check so we reach the gate without external creds.
|
||||
cmd.SetArgs([]string{"--as", "bot", "--params", `{"file_token":"tok_abc"}`})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Errorf("expected 'requires confirmation' in error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "drive.files.delete") {
|
||||
t.Errorf("expected schema path in error action, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_DryRunBypassesGate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--as", "bot",
|
||||
"--params", `{"file_token":"tok_abc"}`,
|
||||
"--dry-run",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("dry-run should not hit confirmation gate; got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "files/tok_abc") {
|
||||
t.Errorf("expected dry-run output to contain URL, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
85
events/im/message_receive.go
Normal file
85
events/im/message_receive.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||||
)
|
||||
|
||||
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
|
||||
type ImMessageReceiveOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType string `json:"chat_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"create_time"`
|
||||
Mentions []interface{} `json:"mentions"`
|
||||
} `json:"message"`
|
||||
Sender struct {
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
msg := envelope.Event.Message
|
||||
content := msg.Content
|
||||
if msg.MessageType != "interactive" {
|
||||
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
|
||||
RawContent: msg.Content,
|
||||
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
|
||||
})
|
||||
}
|
||||
|
||||
timestamp := envelope.Header.CreateTime
|
||||
if timestamp == "" {
|
||||
timestamp = msg.CreateTime
|
||||
}
|
||||
|
||||
out := &ImMessageReceiveOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: timestamp,
|
||||
ID: msg.MessageID,
|
||||
MessageID: msg.MessageID,
|
||||
CreateTime: msg.CreateTime,
|
||||
ChatID: msg.ChatID,
|
||||
ChatType: msg.ChatType,
|
||||
MessageType: msg.MessageType,
|
||||
SenderID: envelope.Event.Sender.SenderID.OpenID,
|
||||
Content: content,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
190
events/im/message_receive_test.go
Normal file
190
events/im/message_receive_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestIMKeys_ProcessedReceiveRegistered(t *testing.T) {
|
||||
def, ok := event.Lookup("im.message.receive_v1")
|
||||
if !ok {
|
||||
t.Fatal("im.message.receive_v1 should be registered via Keys()")
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for Processed key")
|
||||
}
|
||||
if len(def.Scopes) == 0 {
|
||||
t.Error("Scopes must not be empty — preflightScopes would bypass validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMKeys_NativeEventsRegistered(t *testing.T) {
|
||||
want := []string{
|
||||
"im.message.message_read_v1",
|
||||
"im.message.reaction.created_v1",
|
||||
"im.message.reaction.deleted_v1",
|
||||
"im.chat.member.bot.added_v1",
|
||||
"im.chat.member.bot.deleted_v1",
|
||||
"im.chat.member.user.added_v1",
|
||||
"im.chat.member.user.withdrawn_v1",
|
||||
"im.chat.member.user.deleted_v1",
|
||||
"im.chat.updated_v1",
|
||||
"im.chat.disbanded_v1",
|
||||
}
|
||||
for _, k := range want {
|
||||
def, ok := event.Lookup(k)
|
||||
if !ok {
|
||||
t.Errorf("%s should be registered via Keys()", k)
|
||||
continue
|
||||
}
|
||||
if def.Schema.Native == nil {
|
||||
t.Errorf("%s: Schema.Native must be set for native key", k)
|
||||
}
|
||||
if def.Schema.Custom != nil {
|
||||
t.Errorf("%s: Native key must not set Schema.Custom", k)
|
||||
}
|
||||
if def.Process != nil {
|
||||
t.Errorf("%s: Native key must not set Process", k)
|
||||
}
|
||||
if def.Schema.Native != nil && def.Schema.Native.Type == nil {
|
||||
t.Errorf("%s: Schema.Native.Type must reference an SDK type", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_test_text",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runReceive(t, payload)
|
||||
|
||||
if out.Type != "im.message.receive_v1" {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.MessageID != "om_text_001" || out.ID != "om_text_001" {
|
||||
t.Errorf("MessageID/ID = %q/%q", out.MessageID, out.ID)
|
||||
}
|
||||
if out.ChatType != "p2p" || out.ChatID != "oc_chat" {
|
||||
t.Errorf("chat_id/chat_type = %q/%q", out.ChatID, out.ChatType)
|
||||
}
|
||||
if out.SenderID != "ou_sender" {
|
||||
t.Errorf("SenderID = %q", out.SenderID)
|
||||
}
|
||||
if out.Content != "hello there" {
|
||||
t.Errorf("Content = %q, want \"hello there\"", out.Content)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_Interactive(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_test_card",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1776409469274",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_card_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "group",
|
||||
"message_type": "interactive",
|
||||
"create_time": "1776409468987",
|
||||
"content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"A card\"}}}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runReceive(t, payload)
|
||||
|
||||
if out.Type != "im.message.receive_v1" {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.MessageType != "interactive" {
|
||||
t.Errorf("MessageType = %q", out.MessageType)
|
||||
}
|
||||
if out.ChatType != "group" {
|
||||
t.Errorf("ChatType = %q", out.ChatType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_MalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out ImMessageReceiveOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid ImMessageReceiveOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
184
events/im/native.go
Normal file
184
events/im/native.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
// nativeIMKey curates metadata for a Native IM event; fieldOverrides paths are JSON Pointer anchored at the V2-wrapped schema (start with /event/...).
|
||||
type nativeIMKey struct {
|
||||
key string
|
||||
title string
|
||||
description string
|
||||
scopes []string
|
||||
bodyType reflect.Type
|
||||
fieldOverrides map[string]schemas.FieldMeta
|
||||
}
|
||||
|
||||
// userIDOv returns open_id/union_id/user_id overrides for a UserID object at prefix.
|
||||
func userIDOv(prefix string) map[string]schemas.FieldMeta {
|
||||
return map[string]schemas.FieldMeta{
|
||||
prefix + "/open_id": {Kind: "open_id"},
|
||||
prefix + "/union_id": {Kind: "union_id"},
|
||||
prefix + "/user_id": {Kind: "user_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// mergeOv merges FieldMeta maps left-to-right (later wins).
|
||||
func mergeOv(ms ...map[string]schemas.FieldMeta) map[string]schemas.FieldMeta {
|
||||
out := map[string]schemas.FieldMeta{}
|
||||
for _, m := range ms {
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var nativeIMKeys = []nativeIMKey{
|
||||
{
|
||||
key: "im.message.message_read_v1",
|
||||
title: "Message read",
|
||||
description: "Triggered after a user reads a P2P message sent by the bot",
|
||||
scopes: []string{"im:message:readonly", "im:message"},
|
||||
bodyType: reflect.TypeOf(larkim.P2MessageReadV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/reader/reader_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/reader/read_time": {Kind: "timestamp_ms"},
|
||||
"/event/message_id_list/*": {Kind: "message_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.message.reaction.created_v1",
|
||||
title: "Reaction added",
|
||||
description: "Triggered when a reaction is added to a message",
|
||||
scopes: []string{"im:message:readonly", "im:message.reactions:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2MessageReactionCreatedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/message_id": {Kind: "message_id"},
|
||||
"/event/action_time": {Kind: "timestamp_ms"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.message.reaction.deleted_v1",
|
||||
title: "Reaction removed",
|
||||
description: "Triggered when a reaction is removed from a message",
|
||||
scopes: []string{"im:message:readonly", "im:message.reactions:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2MessageReactionDeletedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/message_id": {Kind: "message_id"},
|
||||
"/event/action_time": {Kind: "timestamp_ms"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.bot.added_v1",
|
||||
title: "Bot added to chat",
|
||||
description: "Triggered when the bot is added to a chat",
|
||||
scopes: []string{"im:chat.members:bot_access"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberBotAddedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.bot.deleted_v1",
|
||||
title: "Bot removed from chat",
|
||||
description: "Triggered after the bot is removed from a chat",
|
||||
scopes: []string{"im:chat.members:bot_access"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberBotDeletedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.user.added_v1",
|
||||
title: "User added to chat",
|
||||
description: "Triggered when a new user joins a chat (including topic chats)",
|
||||
scopes: []string{"im:chat.members:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserAddedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/users/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.user.withdrawn_v1",
|
||||
title: "User invite withdrawn",
|
||||
description: "Triggered after a pending user invite is withdrawn",
|
||||
scopes: []string{"im:chat.members:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserWithdrawnV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/users/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.user.deleted_v1",
|
||||
title: "User left chat",
|
||||
description: "Triggered when a user leaves or is removed from a chat",
|
||||
scopes: []string{"im:chat.members:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserDeletedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/users/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.updated_v1",
|
||||
title: "Chat updated",
|
||||
description: "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated",
|
||||
scopes: []string{"im:chat:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatUpdatedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/before_change/owner_id"),
|
||||
userIDOv("/event/after_change/owner_id"),
|
||||
userIDOv("/event/moderator_list/added_member_list/*/user_id"),
|
||||
userIDOv("/event/moderator_list/removed_member_list/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.disbanded_v1",
|
||||
title: "Chat disbanded",
|
||||
description: "Triggered after a chat is disbanded",
|
||||
scopes: []string{"im:chat:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatDisbandedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
49
events/im/register.go
Normal file
49
events/im/register.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package im registers IM-domain EventKeys.
|
||||
package im
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Keys returns all IM-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
out := []event.KeyDefinition{
|
||||
{
|
||||
Key: "im.message.receive_v1",
|
||||
DisplayName: "Receive message",
|
||||
Description: "Receive IM messages",
|
||||
EventType: "im.message.receive_v1",
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ImMessageReceiveOutput{})},
|
||||
},
|
||||
Process: processImMessageReceive,
|
||||
// Narrowest grant; kept single-element since MissingScopes uses AND semantics.
|
||||
Scopes: []string{"im:message.p2p_msg:readonly"},
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, rk := range nativeIMKeys {
|
||||
out = append(out, event.KeyDefinition{
|
||||
Key: rk.key,
|
||||
DisplayName: rk.title,
|
||||
Description: rk.description,
|
||||
EventType: rk.key,
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: rk.bodyType},
|
||||
FieldOverrides: rk.fieldOverrides,
|
||||
},
|
||||
Scopes: rk.scopes,
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{rk.key},
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
107
events/lint_test.go
Normal file
107
events/lint_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
func TestAllKeys_FieldOverridePointersResolve(t *testing.T) {
|
||||
for _, def := range event.ListAll() {
|
||||
if len(def.Schema.FieldOverrides) == 0 {
|
||||
continue
|
||||
}
|
||||
raw := renderDefSchemaForLint(t, def)
|
||||
if raw == nil {
|
||||
t.Errorf("%s: FieldOverrides set but Schema has no Native/Custom spec", def.Key)
|
||||
continue
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Errorf("%s: parse schema: %v", def.Key, err)
|
||||
continue
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
if len(orphans) > 0 {
|
||||
t.Errorf("%s: orphan FieldOverrides paths (typo or SDK drift): %v", def.Key, orphans)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderDefSchemaForLint(t *testing.T, def *event.KeyDefinition) json.RawMessage {
|
||||
t.Helper()
|
||||
spec, isNative := pickSpec(def.Schema)
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
raw := renderSpec(t, spec)
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
if isNative {
|
||||
raw = schemas.WrapV2Envelope(raw)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func pickSpec(s event.SchemaDef) (*event.SchemaSpec, bool) {
|
||||
if s.Native != nil {
|
||||
return s.Native, true
|
||||
}
|
||||
if s.Custom != nil {
|
||||
return s.Custom, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func renderSpec(t *testing.T, s *event.SchemaSpec) json.RawMessage {
|
||||
t.Helper()
|
||||
if s.Type != nil {
|
||||
return schemas.FromType(s.Type)
|
||||
}
|
||||
if len(s.Raw) > 0 {
|
||||
return append(json.RawMessage{}, s.Raw...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Proves the pipeline catches orphan FieldOverrides paths, so TestAllKeys_FieldOverridePointersResolve isn't vacuous.
|
||||
func TestOrphanDetectionMechanism(t *testing.T) {
|
||||
type synthetic struct {
|
||||
ValidField string `json:"valid_field"`
|
||||
}
|
||||
spec := &event.SchemaSpec{Type: reflect.TypeOf(synthetic{})}
|
||||
raw := renderSpec(t, spec)
|
||||
if raw == nil {
|
||||
t.Fatal("renderSpec returned nil for synthetic type")
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
overrides := map[string]schemas.FieldMeta{
|
||||
"/valid_field": {Kind: "open_id"},
|
||||
"/broken_typo": {Kind: "chat_id"},
|
||||
"/valid_field/x": {Kind: "email"},
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, overrides)
|
||||
wantOrphans := map[string]bool{"/broken_typo": true, "/valid_field/x": true}
|
||||
if len(orphans) != len(wantOrphans) {
|
||||
t.Fatalf("orphans = %v, want exactly %v", orphans, wantOrphans)
|
||||
}
|
||||
for _, o := range orphans {
|
||||
if !wantOrphans[o] {
|
||||
t.Errorf("unexpected orphan %q", o)
|
||||
}
|
||||
}
|
||||
vf := parsed["properties"].(map[string]interface{})["valid_field"].(map[string]interface{})
|
||||
if vf["format"] != "open_id" {
|
||||
t.Errorf("valid path not applied: %v", vf)
|
||||
}
|
||||
}
|
||||
22
events/register.go
Normal file
22
events/register.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package events wires domain EventKey definitions into the global registry. Blank-import to populate.
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Mail is intentionally omitted: only IM is wired up this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
3
go.mod
3
go.mod
@@ -3,12 +3,13 @@ module github.com/larksuite/cli
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
|
||||
6
go.sum
6
go.sum
@@ -1,5 +1,7 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
@@ -69,8 +71,8 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
||||
97
internal/appmeta/app_version.go
Normal file
97
internal/appmeta/app_version.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package appmeta exposes read-only views of a Feishu app's published version, subscribed event types, and scopes.
|
||||
package appmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// APIClient aliases event.APIClient so one concrete adapter satisfies event, appmeta, and consume.
|
||||
type APIClient = event.APIClient
|
||||
|
||||
// AppVersion is the projected subset of one /app_versions item preflight cares about.
|
||||
type AppVersion struct {
|
||||
VersionID string
|
||||
Version string
|
||||
EventTypes []string
|
||||
TenantScopes []string
|
||||
}
|
||||
|
||||
const appVersionStatusPublished = 1
|
||||
|
||||
// FetchCurrentPublished returns the most recently published version of appID, or (nil, nil) if never published.
|
||||
// page_size=2 suffices: Feishu disallows a new version while an in-progress one exists, so the first status==1 item with publish_time is the live one.
|
||||
func FetchCurrentPublished(ctx context.Context, client APIClient, appID string) (*AppVersion, error) {
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/application/v6/applications/%s/app_versions?lang=zh_cn&page_size=2",
|
||||
appID,
|
||||
)
|
||||
raw, err := client.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
VersionID string `json:"version_id"`
|
||||
Version string `json:"version"`
|
||||
Status int `json:"status"`
|
||||
PublishTime json.RawMessage `json:"publish_time"`
|
||||
EventInfos []struct {
|
||||
EventType string `json:"event_type"`
|
||||
} `json:"event_infos"`
|
||||
Scopes []struct {
|
||||
Scope string `json:"scope"`
|
||||
TokenTypes []string `json:"token_types"`
|
||||
} `json:"scopes"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode app_versions response: %w", err)
|
||||
}
|
||||
|
||||
for _, it := range envelope.Data.Items {
|
||||
if it.Status != appVersionStatusPublished || !publishTimeSet(it.PublishTime) {
|
||||
continue
|
||||
}
|
||||
v := &AppVersion{
|
||||
VersionID: it.VersionID,
|
||||
Version: it.Version,
|
||||
}
|
||||
for _, e := range it.EventInfos {
|
||||
if e.EventType != "" {
|
||||
v.EventTypes = append(v.EventTypes, e.EventType)
|
||||
}
|
||||
}
|
||||
for _, s := range it.Scopes {
|
||||
if s.Scope != "" && containsString(s.TokenTypes, "tenant") {
|
||||
v.TenantScopes = append(v.TenantScopes, s.Scope)
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// publishTimeSet rejects null and empty-string; any other value is a real publish_time.
|
||||
func publishTimeSet(raw json.RawMessage) bool {
|
||||
s := string(raw)
|
||||
return s != "" && s != "null" && s != `""`
|
||||
}
|
||||
|
||||
func containsString(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
138
internal/appmeta/app_version_test.go
Normal file
138
internal/appmeta/app_version_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package appmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/testutil"
|
||||
)
|
||||
|
||||
const respFourVersions = `{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"has_more": false,
|
||||
"items": [
|
||||
{"version_id": "oav_draft", "version": "1.0.3", "status": 4, "publish_time": null,
|
||||
"event_infos": [{"event_type": "im.message.receive_v1"}, {"event_type": "mail.user_mailbox.event.message_received_v1"}],
|
||||
"scopes": [{"scope": "draft:only", "token_types": ["tenant"]}]
|
||||
},
|
||||
{"version_id": "oav_latest", "version": "1.0.2", "status": 1, "publish_time": "1776684746",
|
||||
"event_infos": [
|
||||
{"event_type": "im.message.receive_v1"},
|
||||
{"event_type": "im.message.message_read_v1"}
|
||||
],
|
||||
"scopes": [
|
||||
{"scope": "im:message", "token_types": ["tenant", "user"]},
|
||||
{"scope": "im:message.group_at_msg", "token_types": ["tenant"]},
|
||||
{"scope": "contact:user:readonly", "token_types": ["user"]}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
func TestFetchCurrentPublished_SelectsLatestPublished(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: respFourVersions}
|
||||
|
||||
v, err := FetchCurrentPublished(context.Background(), c, "cli_test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v == nil {
|
||||
t.Fatal("expected a version, got nil")
|
||||
}
|
||||
if v.VersionID != "oav_latest" {
|
||||
t.Errorf("VersionID = %q, want oav_latest", v.VersionID)
|
||||
}
|
||||
if v.Version != "1.0.2" {
|
||||
t.Errorf("Version = %q, want 1.0.2", v.Version)
|
||||
}
|
||||
|
||||
wantEvents := map[string]bool{"im.message.receive_v1": true, "im.message.message_read_v1": true}
|
||||
if len(v.EventTypes) != len(wantEvents) {
|
||||
t.Fatalf("EventTypes = %v, want %v", v.EventTypes, wantEvents)
|
||||
}
|
||||
for _, e := range v.EventTypes {
|
||||
if !wantEvents[e] {
|
||||
t.Errorf("unexpected event type %q in %v", e, v.EventTypes)
|
||||
}
|
||||
}
|
||||
|
||||
wantTenant := map[string]bool{"im:message": true, "im:message.group_at_msg": true}
|
||||
if len(v.TenantScopes) != len(wantTenant) {
|
||||
t.Fatalf("TenantScopes = %v, want %v", v.TenantScopes, wantTenant)
|
||||
}
|
||||
for _, s := range v.TenantScopes {
|
||||
if !wantTenant[s] {
|
||||
t.Errorf("unexpected tenant scope %q in %v", s, v.TenantScopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCurrentPublished_PathContainsQuery(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: respFourVersions}
|
||||
_, _ = FetchCurrentPublished(context.Background(), c, "cli_x")
|
||||
for _, want := range []string{
|
||||
"/open-apis/application/v6/applications/cli_x/app_versions",
|
||||
"lang=zh_cn",
|
||||
"page_size=2",
|
||||
} {
|
||||
if !strings.Contains(c.GotPath, want) {
|
||||
t.Errorf("path %q missing %q", c.GotPath, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCurrentPublished_NoPublishedYet(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[
|
||||
{"version_id":"oav_draft","status":4,"publish_time":null,"event_infos":[],"scopes":[]}
|
||||
]}}`}
|
||||
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v != nil {
|
||||
t.Errorf("want nil (app never published), got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCurrentPublished_EmptyItems(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[]}}`}
|
||||
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v != nil {
|
||||
t.Errorf("want nil for empty items, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCurrentPublished_APIErrorPropagated(t *testing.T) {
|
||||
want := errors.New("insufficient permission level")
|
||||
c := &testutil.StubAPIClient{Err: want}
|
||||
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("err = %v, want wrapping %v", err, want)
|
||||
}
|
||||
if v != nil {
|
||||
t.Errorf("want nil version on error, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCurrentPublished_PublishTimeEmptyStringTreatedAsUnpublished(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[
|
||||
{"version_id":"oav_x","status":1,"publish_time":"","event_infos":[],"scopes":[]}
|
||||
]}}`}
|
||||
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v != nil {
|
||||
t.Errorf("want nil (empty publish_time), got %+v", v)
|
||||
}
|
||||
}
|
||||
@@ -11,26 +11,27 @@ import (
|
||||
|
||||
// Cobra keeps completion callbacks in a package-global map keyed by
|
||||
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
|
||||
// outlive the command itself. Skip registration when the current invocation
|
||||
// will not serve a completion request.
|
||||
var flagCompletionsDisabled atomic.Bool
|
||||
// outlive the command itself. Default to disabled (zero value = false) and let
|
||||
// callers that actually serve a completion request opt in via
|
||||
// SetFlagCompletionsEnabled(true).
|
||||
var flagCompletionsEnabled atomic.Bool
|
||||
|
||||
// SetFlagCompletionsDisabled switches RegisterFlagCompletion between
|
||||
// registering and no-op. Typically set once at process start.
|
||||
func SetFlagCompletionsDisabled(disabled bool) {
|
||||
flagCompletionsDisabled.Store(disabled)
|
||||
// SetFlagCompletionsEnabled toggles whether RegisterFlagCompletion actually
|
||||
// registers callbacks with cobra. Typically set once at process start.
|
||||
func SetFlagCompletionsEnabled(enabled bool) {
|
||||
flagCompletionsEnabled.Store(enabled)
|
||||
}
|
||||
|
||||
// FlagCompletionsDisabled reports the current switch state.
|
||||
func FlagCompletionsDisabled() bool {
|
||||
return flagCompletionsDisabled.Load()
|
||||
// FlagCompletionsEnabled reports the current switch state.
|
||||
func FlagCompletionsEnabled() bool {
|
||||
return flagCompletionsEnabled.Load()
|
||||
}
|
||||
|
||||
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
|
||||
// and honors the package switch. The underlying error is swallowed to match
|
||||
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
|
||||
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
|
||||
if flagCompletionsDisabled.Load() {
|
||||
if !flagCompletionsEnabled.Load() {
|
||||
return
|
||||
}
|
||||
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)
|
||||
|
||||
@@ -12,18 +12,18 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
|
||||
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
|
||||
func TestSetFlagCompletionsEnabled_RoundTrip(t *testing.T) {
|
||||
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
|
||||
|
||||
if FlagCompletionsDisabled() {
|
||||
t.Fatal("expected default false")
|
||||
if FlagCompletionsEnabled() {
|
||||
t.Fatal("expected default false (completions disabled by default)")
|
||||
}
|
||||
SetFlagCompletionsDisabled(true)
|
||||
if !FlagCompletionsDisabled() {
|
||||
SetFlagCompletionsEnabled(true)
|
||||
if !FlagCompletionsEnabled() {
|
||||
t.Fatal("expected true after Set(true)")
|
||||
}
|
||||
SetFlagCompletionsDisabled(false)
|
||||
if FlagCompletionsDisabled() {
|
||||
SetFlagCompletionsEnabled(false)
|
||||
if FlagCompletionsEnabled() {
|
||||
t.Fatal("expected false after Set(false)")
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,8 @@ func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
|
||||
// When disabled, a *cobra.Command must be collectable after the caller drops
|
||||
// its reference — i.e. the wrapper did not touch cobra's global map.
|
||||
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
|
||||
SetFlagCompletionsDisabled(true)
|
||||
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
|
||||
SetFlagCompletionsEnabled(false)
|
||||
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
|
||||
|
||||
const N = 5
|
||||
var collected atomic.Int32
|
||||
@@ -58,7 +58,8 @@ func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
|
||||
|
||||
// When enabled, the registered completion must be reachable via cobra.
|
||||
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
|
||||
SetFlagCompletionsDisabled(false)
|
||||
SetFlagCompletionsEnabled(true)
|
||||
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
|
||||
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("foo", "", "")
|
||||
|
||||
35
internal/cmdutil/confirm.go
Normal file
35
internal/cmdutil/confirm.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// RequireConfirmation constructs a confirmation_required error with exit code
|
||||
// ExitConfirmationRequired and a structured Risk envelope. Used by both
|
||||
// shortcut and service command execution paths when a statically
|
||||
// high-risk-write operation has not been confirmed with --yes.
|
||||
//
|
||||
// action identifies the operation for the agent (e.g. "mail +send",
|
||||
// "drive.files.delete"). The envelope does not carry a pre-built retry
|
||||
// command: agents already know their original invocation and only need to
|
||||
// append --yes per the hint, which keeps the protocol free of shell-quoting
|
||||
// pitfalls.
|
||||
func RequireConfirmation(action string) error {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitConfirmationRequired,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "confirmation_required",
|
||||
Message: fmt.Sprintf("%s requires confirmation", action),
|
||||
Hint: "add --yes to confirm",
|
||||
Risk: &output.RiskDetail{
|
||||
Level: "high-risk-write",
|
||||
Action: action,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
87
internal/cmdutil/confirm_test.go
Normal file
87
internal/cmdutil/confirm_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestRequireConfirmation_EnvelopeShape(t *testing.T) {
|
||||
err := RequireConfirmation("drive +delete")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitConfirmationRequired {
|
||||
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitConfirmationRequired)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("Detail is nil")
|
||||
}
|
||||
d := exitErr.Detail
|
||||
if d.Type != "confirmation_required" {
|
||||
t.Errorf("Type = %q, want confirmation_required", d.Type)
|
||||
}
|
||||
if !strings.Contains(d.Message, "drive +delete") || !strings.Contains(d.Message, "requires confirmation") {
|
||||
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", d.Message)
|
||||
}
|
||||
if d.Hint != "add --yes to confirm" {
|
||||
t.Errorf("Hint = %q, want 'add --yes to confirm'", d.Hint)
|
||||
}
|
||||
if d.Risk == nil {
|
||||
t.Fatal("Risk is nil")
|
||||
}
|
||||
if d.Risk.Level != "high-risk-write" {
|
||||
t.Errorf("Risk.Level = %q, want high-risk-write", d.Risk.Level)
|
||||
}
|
||||
if d.Risk.Action != "drive +delete" {
|
||||
t.Errorf("Risk.Action = %q, want drive +delete", d.Risk.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
err := RequireConfirmation("mail +send")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
raw, mErr := json.Marshal(exitErr.Detail)
|
||||
if mErr != nil {
|
||||
t.Fatalf("marshal: %v", mErr)
|
||||
}
|
||||
var back map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// No fix_command field leaks into the envelope: the protocol avoids
|
||||
// shell-quoting hazards by delegating retry to agent-side logic.
|
||||
if _, has := back["fix_command"]; has {
|
||||
t.Errorf("unexpected fix_command present in JSON: %s", raw)
|
||||
}
|
||||
|
||||
risk, ok := back["risk"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("risk block missing in JSON: %s", raw)
|
||||
}
|
||||
if risk["level"] != "high-risk-write" {
|
||||
t.Errorf("risk.level in JSON = %v", risk["level"])
|
||||
}
|
||||
if risk["action"] != "mail +send" {
|
||||
t.Errorf("risk.action in JSON = %v", risk["action"])
|
||||
}
|
||||
// Action-only protocol: no UpgradedBy / fix_command / upgraded_by leak.
|
||||
if _, has := risk["upgraded_by"]; has {
|
||||
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
|
||||
}
|
||||
}
|
||||
33
internal/cmdutil/risk.go
Normal file
33
internal/cmdutil/risk.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
const riskLevelAnnotationKey = "risk_level"
|
||||
|
||||
// SetRisk stores a command's static risk level on cobra annotations so the
|
||||
// help renderer (cmd/root.go) can surface a Risk: line without importing
|
||||
// shortcuts/common. Levels follow the three-tier convention: "read" | "write"
|
||||
// | "high-risk-write". Framework-level confirmation gating only acts on
|
||||
// "high-risk-write".
|
||||
func SetRisk(cmd *cobra.Command, level string) {
|
||||
if level == "" {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[riskLevelAnnotationKey] = level
|
||||
}
|
||||
|
||||
// GetRisk returns the static risk level. ok is true when the command has a
|
||||
// risk annotation.
|
||||
func GetRisk(cmd *cobra.Command) (level string, ok bool) {
|
||||
if cmd.Annotations == nil {
|
||||
return "", false
|
||||
}
|
||||
level, ok = cmd.Annotations[riskLevelAnnotationKey]
|
||||
return level, ok && level != ""
|
||||
}
|
||||
95
internal/cmdutil/risk_test.go
Normal file
95
internal/cmdutil/risk_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSetRisk_EmptyLevelShortCircuits(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
SetRisk(cmd, "")
|
||||
if cmd.Annotations != nil {
|
||||
t.Errorf("expected annotations untouched for empty level, got %v", cmd.Annotations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRisk_PopulatesLevel(t *testing.T) {
|
||||
cases := []string{"read", "write", "high-risk-write"}
|
||||
for _, level := range cases {
|
||||
t.Run(level, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
SetRisk(cmd, level)
|
||||
got, ok := GetRisk(cmd)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true after SetRisk")
|
||||
}
|
||||
if got != level {
|
||||
t.Errorf("level = %q, want %q", got, level)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRisk_PreservesExistingAnnotations(t *testing.T) {
|
||||
cmd := &cobra.Command{
|
||||
Use: "test",
|
||||
Annotations: map[string]string{"other": "val"},
|
||||
}
|
||||
SetRisk(cmd, "high-risk-write")
|
||||
if cmd.Annotations["other"] != "val" {
|
||||
t.Error("existing annotation should be preserved")
|
||||
}
|
||||
if level, ok := GetRisk(cmd); !ok || level != "high-risk-write" {
|
||||
t.Errorf("risk not written: level=%q ok=%v", level, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRisk_InitializesNilAnnotations(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
if cmd.Annotations != nil {
|
||||
t.Fatal("precondition: Annotations should be nil on a fresh command")
|
||||
}
|
||||
SetRisk(cmd, "write")
|
||||
if cmd.Annotations == nil {
|
||||
t.Fatal("SetRisk should lazily initialize Annotations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRisk_NilAnnotations(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
level, ok := GetRisk(cmd)
|
||||
if ok {
|
||||
t.Error("expected ok=false for nil Annotations")
|
||||
}
|
||||
if level != "" {
|
||||
t.Errorf("expected empty level, got %q", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRisk_NoRiskKey(t *testing.T) {
|
||||
cmd := &cobra.Command{
|
||||
Use: "test",
|
||||
Annotations: map[string]string{"unrelated": "x"},
|
||||
}
|
||||
if _, ok := GetRisk(cmd); ok {
|
||||
t.Error("expected ok=false when risk key is absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRisk_EmptyValueReturnsNotOK(t *testing.T) {
|
||||
cmd := &cobra.Command{
|
||||
Use: "test",
|
||||
Annotations: map[string]string{riskLevelAnnotationKey: ""},
|
||||
}
|
||||
level, ok := GetRisk(cmd)
|
||||
if ok {
|
||||
t.Error("expected ok=false for empty level value")
|
||||
}
|
||||
if level != "" {
|
||||
t.Errorf("expected empty level, got %q", level)
|
||||
}
|
||||
}
|
||||
24
internal/event/appid.go
Normal file
24
internal/event/appid.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import "strings"
|
||||
|
||||
// SanitizeAppID replaces ".." / path separators / NUL with "_" to guard filepath.Join; empty/dot-only collapses to "_".
|
||||
func SanitizeAppID(appID string) string {
|
||||
if appID == "" {
|
||||
return "_"
|
||||
}
|
||||
repl := strings.NewReplacer(
|
||||
"/", "_",
|
||||
"\\", "_",
|
||||
"\x00", "_",
|
||||
"..", "_",
|
||||
)
|
||||
out := repl.Replace(appID)
|
||||
if out == "" || out == "." {
|
||||
return "_"
|
||||
}
|
||||
return out
|
||||
}
|
||||
49
internal/event/appid_test.go
Normal file
49
internal/event/appid_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeAppID_RejectsPathTraversal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantClean string
|
||||
forbidChars string
|
||||
}{
|
||||
{"happy path", "cli_XXXXXXXXXXXXXXXX", "cli_XXXXXXXXXXXXXXXX", "/\\\x00"},
|
||||
{"empty", "", "_", ""},
|
||||
{"dot", ".", "_", ""},
|
||||
{"double-dot only", "..", "_", ".."},
|
||||
{"leading traversal", "../etc/passwd", "__etc_passwd", "/"},
|
||||
{"traversal inside", "cli_../../etc", "cli_____etc", "/"},
|
||||
{"backslash traversal", "..\\windows\\system32", "__windows_system32", "\\"},
|
||||
{"nul injection", "cli_\x00backdoor", "cli__backdoor", "\x00"},
|
||||
{"pure slashes", "///", "___", "/"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := SanitizeAppID(tc.input)
|
||||
if got != tc.wantClean {
|
||||
t.Errorf("SanitizeAppID(%q) = %q, want %q", tc.input, got, tc.wantClean)
|
||||
}
|
||||
for _, c := range tc.forbidChars {
|
||||
if strings.ContainsRune(got, c) {
|
||||
t.Errorf("SanitizeAppID(%q) = %q contains forbidden rune %q", tc.input, got, c)
|
||||
}
|
||||
}
|
||||
joined := filepath.ToSlash(filepath.Join("/root/events", got, "bus.log"))
|
||||
if strings.Contains(joined, "..") {
|
||||
t.Errorf("joined path %q contains .. after sanitization", joined)
|
||||
}
|
||||
if !strings.HasPrefix(joined, "/root/events/") {
|
||||
t.Errorf("joined path %q escaped /root/events/ parent", joined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
362
internal/event/bus/bus.go
Normal file
362
internal/event/bus/bus.go
Normal file
@@ -0,0 +1,362 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package bus implements the per-AppID event-bus daemon; lifecycle is driven by consumer presence (idle timeout) and explicit shutdown.
|
||||
package bus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/source"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
)
|
||||
|
||||
const (
|
||||
idleTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// Bus is the central event bus daemon.
|
||||
type Bus struct {
|
||||
appID string
|
||||
appSecret string
|
||||
domain string
|
||||
transport transport.IPC
|
||||
hub *Hub
|
||||
dedup *event.DedupFilter
|
||||
listener net.Listener
|
||||
logger *log.Logger
|
||||
startTime time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
conns map[*Conn]struct{}
|
||||
idleTimer *time.Timer
|
||||
shutdownCh chan struct{}
|
||||
|
||||
// pidHandle pins the alive.lock fd to the bus lifetime; OS releases on exit.
|
||||
pidHandle *busdiscover.Handle
|
||||
}
|
||||
|
||||
func NewBus(appID, appSecret, domain string, tr transport.IPC, logger *log.Logger) *Bus {
|
||||
return &Bus{
|
||||
appID: appID,
|
||||
appSecret: appSecret,
|
||||
domain: domain,
|
||||
transport: tr,
|
||||
hub: NewHub(),
|
||||
dedup: event.NewDedupFilter(),
|
||||
logger: logger,
|
||||
startTime: time.Now(),
|
||||
conns: make(map[*Conn]struct{}),
|
||||
// Buffered so shutdown and source-exit paths never drop the signal.
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Run binds the IPC socket, starts event sources, and blocks in the accept loop until shutdown.
|
||||
func (b *Bus) Run(ctx context.Context) error {
|
||||
addr := b.transport.Address(b.appID)
|
||||
|
||||
// alive.lock before bind: closes the cleanup-TOCTOU race where two newly forked
|
||||
// buses each unlink and rebind the socket. Brief retry covers stop-then-restart.
|
||||
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(b.appID))
|
||||
pidHandle, pidErr := acquireAliveLock(eventsDir)
|
||||
if pidErr != nil {
|
||||
if errors.Is(pidErr, lockfile.ErrHeld) {
|
||||
b.logger.Printf("Another bus already holds %s/bus.alive.lock, exiting", eventsDir)
|
||||
return nil
|
||||
}
|
||||
b.logger.Printf("[bus] pid file write failed: %v (status discovery may miss this bus)", pidErr)
|
||||
} else {
|
||||
b.pidHandle = pidHandle
|
||||
}
|
||||
|
||||
ln, err := b.transport.Listen(addr)
|
||||
if err != nil {
|
||||
if probe, dialErr := b.transport.Dial(addr); dialErr == nil {
|
||||
probe.Close()
|
||||
b.logger.Printf("Another bus is already running for %s, exiting", b.appID)
|
||||
return nil
|
||||
}
|
||||
b.transport.Cleanup(addr)
|
||||
ln, err = b.transport.Listen(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bus listen: %w", err)
|
||||
}
|
||||
}
|
||||
b.listener = ln
|
||||
b.logger.Printf("Bus started for app=%s pid=%d addr=%s", b.appID, os.Getpid(), addr)
|
||||
|
||||
b.idleTimer = time.NewTimer(idleTimeout)
|
||||
|
||||
sourceCtx, sourceCancel := context.WithCancel(ctx)
|
||||
defer sourceCancel()
|
||||
b.startSources(sourceCtx)
|
||||
|
||||
acceptDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(acceptDone)
|
||||
b.acceptLoop(ctx)
|
||||
}()
|
||||
|
||||
// Re-check live conn count under lock: a stale idle tick can linger past a concurrent Stop+Reset.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
b.logger.Printf("Bus shutting down (context cancelled)")
|
||||
case <-b.idleTimer.C:
|
||||
b.mu.Lock()
|
||||
active := len(b.conns)
|
||||
if active > 0 {
|
||||
b.idleTimer.Reset(idleTimeout)
|
||||
b.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
b.mu.Unlock()
|
||||
b.logger.Printf("Bus shutting down (idle %v, no active connections)", idleTimeout)
|
||||
case <-b.shutdownCh:
|
||||
b.logger.Printf("Bus shutting down (shutdown command received)")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
b.listener.Close()
|
||||
// Don't delete the socket: Run() handles stale sockets on startup, and deletion races a new bus.
|
||||
shutdownConns(b)
|
||||
<-acceptDone
|
||||
b.logger.Printf("Bus exited cleanly")
|
||||
return nil
|
||||
}
|
||||
|
||||
// shutdownConns snapshots b.conns under lock then releases before Close() — Close→onClose reacquires b.mu.
|
||||
func shutdownConns(b *Bus) {
|
||||
b.mu.Lock()
|
||||
conns := make([]*Conn, 0, len(b.conns))
|
||||
for c := range b.conns {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
for _, c := range conns {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// startSources launches registered sources (or a default FeishuSource); any source exit triggers full bus shutdown.
|
||||
func (b *Bus) startSources(ctx context.Context) {
|
||||
sources := source.All()
|
||||
if len(sources) == 0 {
|
||||
sources = []source.Source{&source.FeishuSource{
|
||||
AppID: b.appID,
|
||||
AppSecret: b.appSecret,
|
||||
Domain: b.domain,
|
||||
Logger: b.logger,
|
||||
}}
|
||||
}
|
||||
eventTypes := subscribedEventTypes()
|
||||
b.hub.SetLogger(b.logger)
|
||||
for _, src := range sources {
|
||||
go func(s source.Source) {
|
||||
b.logger.Printf("Starting source: %s", s.Name())
|
||||
err := s.Start(ctx, eventTypes, func(raw *event.RawEvent) {
|
||||
b.logger.Printf("Event received: type=%s id=%s", raw.EventType, raw.EventID)
|
||||
if b.dedup.IsDuplicate(raw.EventID) {
|
||||
b.logger.Printf("Event deduplicated: id=%s", raw.EventID)
|
||||
return
|
||||
}
|
||||
b.hub.Publish(raw)
|
||||
}, func(state, detail string) {
|
||||
b.hub.BroadcastSourceStatus(s.Name(), state, detail)
|
||||
})
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
b.logger.Printf("Source %s exited with error: %v — shutting down bus", s.Name(), err)
|
||||
} else {
|
||||
b.logger.Printf("Source %s exited without error before shutdown — shutting down bus", s.Name())
|
||||
}
|
||||
select {
|
||||
case b.shutdownCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}(src)
|
||||
}
|
||||
}
|
||||
|
||||
// subscribedEventTypes returns the deduplicated union of EventTypes from every registered EventKey.
|
||||
func subscribedEventTypes() []string {
|
||||
seen := make(map[string]struct{})
|
||||
var types []string
|
||||
for _, def := range event.ListAll() {
|
||||
if _, ok := seen[def.EventType]; ok {
|
||||
continue
|
||||
}
|
||||
seen[def.EventType] = struct{}{}
|
||||
types = append(types, def.EventType)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// acceptLoop accepts IPC connections until the listener is closed.
|
||||
func (b *Bus) acceptLoop(ctx context.Context) {
|
||||
for {
|
||||
conn, err := b.listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
b.logger.Printf("Accept error: %v", err)
|
||||
return
|
||||
}
|
||||
go b.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConn reads the first protocol message and dispatches; the bufio.Reader is handed to Conn so buffered bytes carry over.
|
||||
func (b *Bus) handleConn(conn net.Conn) {
|
||||
br := bufio.NewReader(conn)
|
||||
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
switch m := msg.(type) {
|
||||
case *protocol.Hello:
|
||||
b.handleHello(conn, br, m)
|
||||
case *protocol.StatusQuery:
|
||||
b.handleStatusQuery(conn)
|
||||
case *protocol.Shutdown:
|
||||
b.handleShutdown(conn)
|
||||
default:
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn.
|
||||
func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID)
|
||||
bc.SetLogger(b.logger)
|
||||
|
||||
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
|
||||
firstForKey := b.hub.RegisterAndIsFirst(bc)
|
||||
|
||||
bc.SetCheckLastForKey(func(eventKey string) bool {
|
||||
return b.hub.AcquireCleanupLock(eventKey)
|
||||
})
|
||||
bc.SetOnClose(func(c *Conn) {
|
||||
b.hub.UnregisterAndIsLast(c)
|
||||
// Release is idempotent and must fire on every disconnect path so waiters don't block forever.
|
||||
b.hub.ReleaseCleanupLock(c.EventKey())
|
||||
b.mu.Lock()
|
||||
delete(b.conns, c)
|
||||
remaining := len(b.conns)
|
||||
b.mu.Unlock()
|
||||
b.logger.Printf("Consumer disconnected: pid=%d key=%s (remaining=%d)", c.PID(), c.EventKey(), remaining)
|
||||
if remaining == 0 {
|
||||
// Stop+drain before Reset (Go docs) to avoid a stale fire in .C.
|
||||
if !b.idleTimer.Stop() {
|
||||
select {
|
||||
case <-b.idleTimer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
b.idleTimer.Reset(idleTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
b.mu.Lock()
|
||||
b.conns[bc] = struct{}{}
|
||||
// Stop+drain under mu so a fire can't slip past a fresh registration.
|
||||
if !b.idleTimer.Stop() {
|
||||
select {
|
||||
case <-b.idleTimer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
ack := protocol.NewHelloAck("v1", firstForKey)
|
||||
// writeFrame shares writeMu with every other write; bc.Close on failure unwinds hub+bus registration via onClose.
|
||||
if err := bc.writeFrame(ack); err != nil {
|
||||
b.logger.Printf("WARN: hello_ack write to pid=%d key=%q failed: %v (rejecting connection)",
|
||||
hello.PID, hello.EventKey, err)
|
||||
bc.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Quote untrusted fields to prevent log forging via embedded newlines.
|
||||
b.logger.Printf("Consumer connected: pid=%d key=%q event_types=%q first=%v",
|
||||
hello.PID, hello.EventKey, hello.EventTypes, firstForKey)
|
||||
|
||||
bc.Start()
|
||||
}
|
||||
|
||||
// handleStatusQuery replies with status and closes.
|
||||
func (b *Bus) handleStatusQuery(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
resp := protocol.NewStatusResponse(
|
||||
os.Getpid(),
|
||||
int(time.Since(b.startTime).Seconds()),
|
||||
b.hub.ConnCount(),
|
||||
b.hub.Consumers(),
|
||||
)
|
||||
_ = protocol.EncodeWithDeadline(conn, resp, protocol.WriteTimeout)
|
||||
}
|
||||
|
||||
// handleShutdown signals Run() to exit.
|
||||
func (b *Bus) handleShutdown(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
b.logger.Printf("Received shutdown command")
|
||||
select {
|
||||
case b.shutdownCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
aliveLockMaxWait = 2 * time.Second
|
||||
aliveLockPollInterval = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// acquireAliveLock retries on ErrHeld so a stop-then-immediate-restart finds the lock free.
|
||||
func acquireAliveLock(eventsDir string) (*busdiscover.Handle, error) {
|
||||
deadline := time.Now().Add(aliveLockMaxWait)
|
||||
for {
|
||||
h, err := busdiscover.WritePIDFile(eventsDir, os.Getpid())
|
||||
if err == nil {
|
||||
return h, nil
|
||||
}
|
||||
if !errors.Is(err, lockfile.ErrHeld) || time.Now().After(deadline) {
|
||||
return nil, err
|
||||
}
|
||||
time.Sleep(aliveLockPollInterval)
|
||||
}
|
||||
}
|
||||
90
internal/event/bus/bus_shutdown_test.go
Normal file
90
internal/event/bus/bus_shutdown_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Reproduces Run × onClose re-entrant deadlock if b.mu is held across Close.
|
||||
func TestRunShutdownWithMultipleConns(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
}
|
||||
|
||||
const N = 3
|
||||
pipes := make([]net.Conn, 0, N*2)
|
||||
t.Cleanup(func() {
|
||||
for _, p := range pipes {
|
||||
p.Close()
|
||||
}
|
||||
})
|
||||
|
||||
for i := 0; i < N; i++ {
|
||||
server, client := net.Pipe()
|
||||
pipes = append(pipes, server, client)
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i)
|
||||
bc.SetLogger(logger)
|
||||
hub.RegisterAndIsFirst(bc)
|
||||
|
||||
bc.SetOnClose(func(c *Conn) {
|
||||
b.hub.UnregisterAndIsLast(c)
|
||||
b.mu.Lock()
|
||||
delete(b.conns, c)
|
||||
b.mu.Unlock()
|
||||
})
|
||||
|
||||
b.mu.Lock()
|
||||
b.conns[bc] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
shutdownConns(b)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("shutdownConns deadlocked: did not complete within 2s")
|
||||
}
|
||||
|
||||
if got := hub.ConnCount(); got != 0 {
|
||||
t.Errorf("expected 0 subscribers in hub after shutdown, got %d", got)
|
||||
}
|
||||
b.mu.Lock()
|
||||
remaining := len(b.conns)
|
||||
b.mu.Unlock()
|
||||
if remaining != 0 {
|
||||
t.Errorf("expected 0 conns in Bus after shutdown, got %d", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownCh must be buffered so a signal sent before Run's select loop is still delivered.
|
||||
func TestShutdownSignalNotDroppedBeforeRunSelects(t *testing.T) {
|
||||
b := NewBus("test-app", "test-secret", "", nil, log.New(io.Discard, "", 0))
|
||||
|
||||
select {
|
||||
case b.shutdownCh <- struct{}{}:
|
||||
default:
|
||||
t.Fatal("handleShutdown's send took default branch — signal would be lost")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-b.shutdownCh:
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatal("shutdown signal was not latched")
|
||||
}
|
||||
}
|
||||
199
internal/event/bus/conn.go
Normal file
199
internal/event/bus/conn.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
sendChCap = 100
|
||||
writeTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// Conn represents a single consume client connection in the Bus.
|
||||
type Conn struct {
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
sendCh chan interface{}
|
||||
sendMu sync.Mutex // serialises drop+push atomically
|
||||
writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence)
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
pid int
|
||||
onClose func(*Conn)
|
||||
checkLastForKey func(eventKey string) bool
|
||||
logger *log.Logger
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
received atomic.Int64 // events fanned out to us (post-filter)
|
||||
seqCounter atomic.Uint64 // per-conn monotonic seq assigned by Hub.Publish
|
||||
dropped atomic.Int64 // events evicted via drop-oldest backpressure
|
||||
}
|
||||
|
||||
// NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one.
|
||||
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int) *Conn {
|
||||
if reader == nil {
|
||||
reader = bufio.NewReader(conn)
|
||||
}
|
||||
return &Conn{
|
||||
conn: conn,
|
||||
reader: reader,
|
||||
sendCh: make(chan interface{}, sendChCap),
|
||||
eventKey: eventKey,
|
||||
eventTypes: eventTypes,
|
||||
pid: pid,
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn }
|
||||
|
||||
// SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup".
|
||||
func (c *Conn) SetCheckLastForKey(fn func(string) bool) { c.checkLastForKey = fn }
|
||||
|
||||
// SetLogger attaches a logger (nil tolerated).
|
||||
func (c *Conn) SetLogger(l *log.Logger) { c.logger = l }
|
||||
|
||||
func (c *Conn) EventKey() string { return c.eventKey }
|
||||
func (c *Conn) EventTypes() []string { return c.eventTypes }
|
||||
func (c *Conn) SendCh() chan interface{} { return c.sendCh }
|
||||
func (c *Conn) PID() int { return c.pid }
|
||||
func (c *Conn) IncrementReceived() { c.received.Add(1) }
|
||||
func (c *Conn) Received() int64 { return c.received.Load() }
|
||||
|
||||
// NextSeq returns the next monotonic seq for this conn (first call returns 1).
|
||||
func (c *Conn) NextSeq() uint64 { return c.seqCounter.Add(1) }
|
||||
|
||||
func (c *Conn) DroppedCount() int64 { return c.dropped.Load() }
|
||||
func (c *Conn) IncrementDropped() { c.dropped.Add(1) }
|
||||
|
||||
// Start launches the sender and reader goroutines; call exactly once.
|
||||
func (c *Conn) Start() {
|
||||
go c.SenderLoop()
|
||||
go c.ReaderLoop()
|
||||
}
|
||||
|
||||
// writeFrame is the sole write path, serialised via writeMu.
|
||||
func (c *Conn) writeFrame(msg interface{}) error {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if err := c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
return protocol.Encode(c.conn, msg)
|
||||
}
|
||||
|
||||
// SenderLoop exits on closed (not sendCh close) so Hub.Publish can send without panic risk.
|
||||
func (c *Conn) SenderLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return
|
||||
case msg := <-c.sendCh:
|
||||
if err := c.writeFrame(msg); err != nil {
|
||||
if c.logger != nil {
|
||||
c.logger.Printf("WARN: write to pid=%d failed: %v", c.pid, err)
|
||||
}
|
||||
c.shutdown()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReaderLoop reads control messages (Bye, PreShutdownCheck) until EOF.
|
||||
func (c *Conn) ReaderLoop() {
|
||||
for {
|
||||
line, err := protocol.ReadFrame(c.reader)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
line = bytes.TrimRight(line, "\n")
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
msg, err := protocol.Decode(line)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
c.handleControlMessage(msg)
|
||||
}
|
||||
c.shutdown()
|
||||
}
|
||||
|
||||
func (c *Conn) handleControlMessage(msg interface{}) {
|
||||
switch m := msg.(type) {
|
||||
case *protocol.Bye:
|
||||
c.shutdown()
|
||||
case *protocol.PreShutdownCheck:
|
||||
lastForKey := true
|
||||
if c.checkLastForKey != nil {
|
||||
lastForKey = c.checkLastForKey(m.EventKey)
|
||||
}
|
||||
ack := protocol.NewPreShutdownAck(lastForKey)
|
||||
if err := c.writeFrame(ack); err != nil && c.logger != nil {
|
||||
c.logger.Printf("WARN: pre_shutdown_ack to pid=%d failed: %v", c.pid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) shutdown() {
|
||||
c.closeOnce.Do(func() {
|
||||
close(c.closed)
|
||||
c.conn.Close()
|
||||
// sendCh is NOT closed: would race with Hub.Publish holding SendCh() after RUnlock.
|
||||
if c.onClose != nil {
|
||||
c.onClose(c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TrySend enqueues non-evictively under sendMu so it respects PushDropOldest's atomicity contract.
|
||||
func (c *Conn) TrySend(msg interface{}) bool {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// PushDropOldest enqueues msg; on full channel evicts one oldest and retries, atomically under sendMu.
|
||||
// Returns (enqueued, dropped). A rare concurrent drain may make drop unnecessary — still succeeds with dropped=false.
|
||||
func (c *Conn) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true, false
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-c.sendCh:
|
||||
dropped = true
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true, dropped
|
||||
default:
|
||||
return false, dropped
|
||||
}
|
||||
}
|
||||
|
||||
// Close is idempotent.
|
||||
func (c *Conn) Close() {
|
||||
c.shutdown()
|
||||
}
|
||||
144
internal/event/bus/conn_test.go
Normal file
144
internal/event/bus/conn_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
func TestConn_SenderWritesEvents(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345)
|
||||
go bc.SenderLoop()
|
||||
|
||||
bc.SendCh() <- &protocol.Event{
|
||||
Type: protocol.MsgTypeEvent,
|
||||
EventType: "im.message.receive_v1",
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(client)
|
||||
client.SetReadDeadline(time.Now().Add(time.Second))
|
||||
if !scanner.Scan() {
|
||||
t.Fatalf("expected to read a line: %v", scanner.Err())
|
||||
}
|
||||
line := scanner.Bytes()
|
||||
if !bytes.Contains(line, []byte(`"event"`)) {
|
||||
t.Errorf("unexpected line: %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
type serializingDetector struct {
|
||||
net.Conn
|
||||
inFlight atomic.Int32
|
||||
violated atomic.Bool
|
||||
}
|
||||
|
||||
func (s *serializingDetector) Write(b []byte) (int, error) {
|
||||
if s.inFlight.Add(1) > 1 {
|
||||
s.violated.Store(true)
|
||||
}
|
||||
time.Sleep(500 * time.Microsecond)
|
||||
defer s.inFlight.Add(-1)
|
||||
return s.Conn.Write(b)
|
||||
}
|
||||
|
||||
// Two goroutines writing frames (event + ack) must not overlap on the underlying net.Conn.
|
||||
func TestConn_ConcurrentWritesSerialised(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
det := &serializingDetector{Conn: server}
|
||||
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
|
||||
go func() { _, _ = io.Copy(io.Discard, client) }()
|
||||
|
||||
go bc.SenderLoop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const workers = 8
|
||||
const perWorker = 20
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < perWorker && time.Now().Before(deadline); j++ {
|
||||
bc.SendCh() <- &protocol.Event{Type: protocol.MsgTypeEvent, EventType: "im.msg"}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < perWorker && time.Now().Before(deadline); j++ {
|
||||
bc.handleControlMessage(&protocol.PreShutdownCheck{EventKey: "im.msg"})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
bc.Close()
|
||||
|
||||
if det.violated.Load() {
|
||||
t.Error("concurrent Write on net.Conn detected: SenderLoop and handleControlMessage " +
|
||||
"overlapped without serialisation (framing / deadline race)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_TrySend_NonEvicting(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
|
||||
for i := 0; i < sendChCap; i++ {
|
||||
if !bc.TrySend(i) {
|
||||
t.Fatalf("TrySend returned false at iteration %d; expected all sendChCap (%d) to fit", i, sendChCap)
|
||||
}
|
||||
}
|
||||
if bc.TrySend("overflow") {
|
||||
t.Fatal("TrySend on full channel returned true: TrySend must be non-evicting")
|
||||
}
|
||||
first := <-bc.SendCh()
|
||||
if first != 0 {
|
||||
t.Errorf("first drained item = %v, want 0", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_ReaderDetectsEOF(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
bc.ReaderLoop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
client.Close()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ReaderLoop did not exit on EOF")
|
||||
}
|
||||
}
|
||||
65
internal/event/bus/handle_hello_test.go
Normal file
65
internal/event/bus/handle_hello_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// HelloAck write failure must unregister the conn from hub and bus before returning.
|
||||
func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
client.Close()
|
||||
defer server.Close()
|
||||
|
||||
hello := &protocol.Hello{
|
||||
PID: 9999,
|
||||
EventKey: "im.msg",
|
||||
EventTypes: []string{"im.message.receive_v1"},
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s: stuck on write or not handling the error path")
|
||||
}
|
||||
|
||||
if got := hub.ConnCount(); got != 0 {
|
||||
t.Errorf("hub.ConnCount after failed HelloAck = %d, want 0 (connection must be unregistered)", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("im.msg"); got != 0 {
|
||||
t.Errorf("hub.EventKeyCount(im.msg) after failed HelloAck = %d, want 0", got)
|
||||
}
|
||||
b.mu.Lock()
|
||||
remaining := len(b.conns)
|
||||
b.mu.Unlock()
|
||||
if remaining != 0 {
|
||||
t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining)
|
||||
}
|
||||
}
|
||||
215
internal/event/bus/hub.go
Normal file
215
internal/event/bus/hub.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// Subscriber is the interface a connection must satisfy for Hub registration.
|
||||
type Subscriber interface {
|
||||
EventKey() string
|
||||
EventTypes() []string
|
||||
SendCh() chan interface{}
|
||||
PID() int
|
||||
IncrementReceived()
|
||||
Received() int64
|
||||
// PushDropOldest enqueues atomically with drop-oldest backpressure.
|
||||
PushDropOldest(msg interface{}) (enqueued, dropped bool)
|
||||
// TrySend is non-evictive but shares PushDropOldest's mutex.
|
||||
TrySend(msg interface{}) bool
|
||||
DroppedCount() int64
|
||||
IncrementDropped()
|
||||
// NextSeq returns a monotonic per-subscriber seq; tests may return 0.
|
||||
NextSeq() uint64
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[Subscriber]struct{}
|
||||
keyCounts map[string]int
|
||||
// cleanupInProgress[key] holds a channel closed on release; presence means a cleanup lock is held.
|
||||
cleanupInProgress map[string]chan struct{}
|
||||
logger atomic.Pointer[log.Logger]
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
subscribers: make(map[Subscriber]struct{}),
|
||||
keyCounts: make(map[string]int),
|
||||
cleanupInProgress: make(map[string]chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetLogger attaches a logger (nil tolerated).
|
||||
func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) }
|
||||
|
||||
// UnregisterAndIsLast removes s and reports whether it was last for its EventKey; stale unregisters are no-ops.
|
||||
func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if _, registered := h.subscribers[s]; !registered {
|
||||
return false
|
||||
}
|
||||
delete(h.subscribers, s)
|
||||
h.keyCounts[s.EventKey()]--
|
||||
isLast := h.keyCounts[s.EventKey()] == 0
|
||||
if isLast {
|
||||
delete(h.keyCounts, s.EventKey())
|
||||
}
|
||||
return isLast
|
||||
}
|
||||
|
||||
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for eventKey and no lock is held.
|
||||
// Count==0 is rejected (would block future Register calls). On true return, caller MUST Release.
|
||||
func (h *Hub) AcquireCleanupLock(eventKey string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.keyCounts[eventKey] != 1 {
|
||||
return false
|
||||
}
|
||||
if _, alreadyLocked := h.cleanupInProgress[eventKey]; alreadyLocked {
|
||||
return false
|
||||
}
|
||||
h.cleanupInProgress[eventKey] = make(chan struct{})
|
||||
return true
|
||||
}
|
||||
|
||||
// ReleaseCleanupLock is idempotent; OnClose calls unconditionally.
|
||||
func (h *Hub) ReleaseCleanupLock(eventKey string) {
|
||||
h.mu.Lock()
|
||||
ch := h.cleanupInProgress[eventKey]
|
||||
delete(h.cleanupInProgress, eventKey)
|
||||
h.mu.Unlock()
|
||||
if ch != nil {
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAndIsFirst adds s to the hub and reports whether it's the first
|
||||
// subscriber for its EventKey. If a cleanup is in progress for
|
||||
// s.EventKey() (another conn holds the cleanup lock), this waits until
|
||||
// cleanup releases before registering — closing the PreShutdownCheck ×
|
||||
// Hello TOCTOU race. The wait releases h.mu before blocking on the
|
||||
// channel, so concurrent operations on other keys aren't stalled.
|
||||
func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
|
||||
for {
|
||||
h.mu.Lock()
|
||||
ch, locked := h.cleanupInProgress[s.EventKey()]
|
||||
if locked {
|
||||
h.mu.Unlock()
|
||||
<-ch // wait for release, then re-check (defensive against races)
|
||||
continue
|
||||
}
|
||||
isFirst := h.keyCounts[s.EventKey()] == 0
|
||||
h.subscribers[s] = struct{}{}
|
||||
h.keyCounts[s.EventKey()]++
|
||||
h.mu.Unlock()
|
||||
return isFirst
|
||||
}
|
||||
}
|
||||
|
||||
// Publish fans out a RawEvent to all matching subscribers (non-blocking).
|
||||
//
|
||||
// A fresh *protocol.Event is allocated per subscriber so each consumer sees
|
||||
// its own monotonically-increasing Seq (assigned via Conn.NextSeq) — sharing
|
||||
// a single msg struct across subscribers would alias Seq and defeat the
|
||||
// gap-detection at the consume side. The extra allocation per fan-out is
|
||||
// cheap compared to the socket write that follows.
|
||||
func (h *Hub) Publish(raw *event.RawEvent) {
|
||||
h.mu.RLock()
|
||||
matches := make([]Subscriber, 0, len(h.subscribers))
|
||||
for s := range h.subscribers {
|
||||
for _, et := range s.EventTypes() {
|
||||
if et == raw.EventType {
|
||||
matches = append(matches, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// Resolve source time once per Publish (not per subscriber) — same value
|
||||
// across the fan-out. Prefer the upstream header create_time
|
||||
// (raw.SourceTime) over the local arrival timestamp so consumers see
|
||||
// original publisher intent; fall back to Timestamp when SourceTime
|
||||
// wasn't populated (e.g. test-only sources, pre-4.4 RawEvent producers).
|
||||
sourceTime := raw.SourceTime
|
||||
if sourceTime == "" && !raw.Timestamp.IsZero() {
|
||||
sourceTime = fmt.Sprintf("%d", raw.Timestamp.UnixMilli())
|
||||
}
|
||||
|
||||
for _, s := range matches {
|
||||
msg := protocol.NewEvent(
|
||||
raw.EventType,
|
||||
raw.EventID,
|
||||
sourceTime,
|
||||
s.NextSeq(),
|
||||
raw.Payload,
|
||||
)
|
||||
|
||||
enqueued, dropped := s.PushDropOldest(msg)
|
||||
if dropped {
|
||||
s.IncrementDropped()
|
||||
if lg := h.logger.Load(); lg != nil {
|
||||
lg.Printf("WARN: backpressure on conn pid=%d event_key=%s dropped_total=%d",
|
||||
s.PID(), s.EventKey(), s.DroppedCount())
|
||||
}
|
||||
}
|
||||
if enqueued {
|
||||
s.IncrementReceived()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ConnCount returns the current number of registered subscribers.
|
||||
func (h *Hub) ConnCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.subscribers)
|
||||
}
|
||||
|
||||
// EventKeyCount returns the number of subscribers registered for eventKey.
|
||||
func (h *Hub) EventKeyCount(eventKey string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.keyCounts[eventKey]
|
||||
}
|
||||
|
||||
// BroadcastSourceStatus fans out a source-level status change to every
|
||||
// subscriber. Best-effort: channel full → drop silently (status isn't
|
||||
// worth applying back-pressure for). Routes through Subscriber.TrySend
|
||||
// so the send shares PushDropOldest's sendMu — without this a status
|
||||
// broadcast could slip into the tiny window between another
|
||||
// goroutine's drop and its retry push and break the atomicity contract.
|
||||
func (h *Hub) BroadcastSourceStatus(source, state, detail string) {
|
||||
msg := protocol.NewSourceStatus(source, state, detail)
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for s := range h.subscribers {
|
||||
s.TrySend(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Consumers returns info about all connected consumers.
|
||||
func (h *Hub) Consumers() []protocol.ConsumerInfo {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
result := make([]protocol.ConsumerInfo, 0, len(h.subscribers))
|
||||
for s := range h.subscribers {
|
||||
result = append(result, protocol.ConsumerInfo{
|
||||
PID: s.PID(),
|
||||
EventKey: s.EventKey(),
|
||||
Received: s.Received(),
|
||||
Dropped: s.DroppedCount(),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
134
internal/event/bus/hub_observability_test.go
Normal file
134
internal/event/bus/hub_observability_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
func TestHubDroppedCountIncrements(t *testing.T) {
|
||||
h := NewHub()
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
h.Publish(&event.RawEvent{EventType: "t"})
|
||||
h.Publish(&event.RawEvent{EventType: "t"})
|
||||
h.Publish(&event.RawEvent{EventType: "t"})
|
||||
|
||||
if got := c.DroppedCount(); got != 2 {
|
||||
t.Errorf("expected 2 drops, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishAssignsIncrementalSeq(t *testing.T) {
|
||||
h := NewHub()
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c.sendCh = make(chan interface{}, 10)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
h.Publish(&event.RawEvent{EventType: "t"})
|
||||
}
|
||||
|
||||
for i := uint64(1); i <= 5; i++ {
|
||||
msg := <-c.SendCh()
|
||||
ev, ok := msg.(*protocol.Event)
|
||||
if !ok {
|
||||
t.Fatalf("iter %d: expected *protocol.Event, got %T", i, msg)
|
||||
}
|
||||
if ev.Seq != i {
|
||||
t.Errorf("iter %d: expected seq %d, got %d", i, i, ev.Seq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) {
|
||||
h := NewHub()
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
const eid = "test-event-id-123"
|
||||
h.Publish(&event.RawEvent{
|
||||
EventID: eid,
|
||||
EventType: "t",
|
||||
Timestamp: time.UnixMilli(1234567890123),
|
||||
})
|
||||
|
||||
msg := <-c.SendCh()
|
||||
ev := msg.(*protocol.Event)
|
||||
if ev.EventID != eid {
|
||||
t.Errorf("expected EventID %q, got %q", eid, ev.EventID)
|
||||
}
|
||||
if ev.SourceTime != "1234567890123" {
|
||||
t.Errorf("expected SourceTime \"1234567890123\", got %q", ev.SourceTime)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit SourceTime (upstream header.create_time) must win over local Timestamp.
|
||||
func TestPublishSourceTimeTakesPrecedence(t *testing.T) {
|
||||
h := NewHub()
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
const upstreamTs = "1700000000000"
|
||||
h.Publish(&event.RawEvent{
|
||||
EventID: "evt-1",
|
||||
EventType: "t",
|
||||
SourceTime: upstreamTs,
|
||||
Timestamp: time.UnixMilli(1999999999999),
|
||||
})
|
||||
|
||||
msg := <-c.SendCh()
|
||||
ev := msg.(*protocol.Event)
|
||||
if ev.SourceTime != upstreamTs {
|
||||
t.Errorf("SourceTime: got %q, want %q", ev.SourceTime, upstreamTs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishSourceTimeFallback(t *testing.T) {
|
||||
h := NewHub()
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
h.Publish(&event.RawEvent{
|
||||
EventID: "evt-2",
|
||||
EventType: "t",
|
||||
Timestamp: time.UnixMilli(42),
|
||||
})
|
||||
|
||||
msg := <-c.SendCh()
|
||||
ev := msg.(*protocol.Event)
|
||||
if ev.SourceTime != "42" {
|
||||
t.Errorf("SourceTime fallback: got %q, want %q", ev.SourceTime, "42")
|
||||
}
|
||||
}
|
||||
|
||||
func testNetPipe(t *testing.T) (net.Conn, net.Conn) {
|
||||
t.Helper()
|
||||
return net.Pipe()
|
||||
}
|
||||
198
internal/event/bus/hub_publish_race_test.go
Normal file
198
internal/event/bus/hub_publish_race_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Under concurrent Publish with a tiny channel, Received must equal actual enqueues (sendMu + enqueued gate).
|
||||
func TestPublishRaceBookkeepingAccurate(t *testing.T) {
|
||||
h := NewHub()
|
||||
sub := newRaceSubscriber("race.key", []string{"race.type"}, 2)
|
||||
h.RegisterAndIsFirst(sub)
|
||||
|
||||
const publishers = 50
|
||||
const perPublisher = 500
|
||||
const N = publishers * perPublisher
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < publishers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < perPublisher; j++ {
|
||||
h.Publish(&event.RawEvent{
|
||||
EventType: "race.type",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
const trySenders = 20
|
||||
for i := 0; i < trySenders; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < perPublisher; j++ {
|
||||
sub.TrySend("source-status")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("publishers did not complete in 10s")
|
||||
}
|
||||
|
||||
received := sub.Received()
|
||||
enqueued := atomic.LoadInt64(&sub.actualEnqueued)
|
||||
returnedFalse := atomic.LoadInt64(&sub.returnedFalse)
|
||||
|
||||
if received != enqueued {
|
||||
t.Errorf("counter drift: Received=%d actual_enqueued=%d (diff=%d)",
|
||||
received, enqueued, received-enqueued)
|
||||
}
|
||||
|
||||
if received > int64(N) {
|
||||
t.Errorf("Received=%d > N=%d", received, N)
|
||||
}
|
||||
|
||||
if returnedFalse > 0 {
|
||||
t.Errorf("PushDropOldest returned enqueued=false %d times — sendMu missing or broken",
|
||||
returnedFalse)
|
||||
}
|
||||
|
||||
totalPublishes := int64(N)
|
||||
if enqueued+returnedFalse != totalPublishes {
|
||||
t.Errorf("publish accounting drift: enqueued=%d + returnedFalse=%d != total=%d",
|
||||
enqueued, returnedFalse, totalPublishes)
|
||||
}
|
||||
}
|
||||
|
||||
// Hub.Publish must gate IncrementReceived on enqueued=true.
|
||||
func TestPublishDoesNotIncrementWhenPushDropOldestFails(t *testing.T) {
|
||||
h := NewHub()
|
||||
sub := &alwaysFailSubscriber{
|
||||
eventKey: "fail.key",
|
||||
eventTypes: []string{"fail.type"},
|
||||
sendCh: make(chan interface{}, 1),
|
||||
}
|
||||
h.RegisterAndIsFirst(sub)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
h.Publish(&event.RawEvent{
|
||||
EventType: "fail.type",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
})
|
||||
}
|
||||
|
||||
if got := sub.Received(); got != 0 {
|
||||
t.Errorf("Received=%d after 100 Publishes that all failed to enqueue", got)
|
||||
}
|
||||
}
|
||||
|
||||
type alwaysFailSubscriber struct {
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
sendCh chan interface{}
|
||||
received atomic.Int64
|
||||
dropped atomic.Int64
|
||||
}
|
||||
|
||||
func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *alwaysFailSubscriber) PID() int { return 0 }
|
||||
func (s *alwaysFailSubscriber) IncrementReceived() { s.received.Add(1) }
|
||||
func (s *alwaysFailSubscriber) Received() int64 { return s.received.Load() }
|
||||
func (s *alwaysFailSubscriber) DroppedCount() int64 { return s.dropped.Load() }
|
||||
func (s *alwaysFailSubscriber) IncrementDropped() { s.dropped.Add(1) }
|
||||
func (s *alwaysFailSubscriber) NextSeq() uint64 { return 0 }
|
||||
func (s *alwaysFailSubscriber) TrySend(msg interface{}) bool {
|
||||
select {
|
||||
case s.sendCh <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
func (s *alwaysFailSubscriber) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
type raceSubscriber struct {
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
sendCh chan interface{}
|
||||
pid int
|
||||
received atomic.Int64
|
||||
actualEnqueued int64
|
||||
returnedFalse int64
|
||||
dropped atomic.Int64
|
||||
sendMu sync.Mutex
|
||||
}
|
||||
|
||||
func newRaceSubscriber(key string, types []string, capacity int) *raceSubscriber {
|
||||
return &raceSubscriber{
|
||||
eventKey: key,
|
||||
eventTypes: types,
|
||||
sendCh: make(chan interface{}, capacity),
|
||||
pid: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *raceSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *raceSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *raceSubscriber) PID() int { return s.pid }
|
||||
func (s *raceSubscriber) IncrementReceived() { s.received.Add(1) }
|
||||
func (s *raceSubscriber) Received() int64 { return s.received.Load() }
|
||||
func (s *raceSubscriber) DroppedCount() int64 { return s.dropped.Load() }
|
||||
func (s *raceSubscriber) IncrementDropped() { s.dropped.Add(1) }
|
||||
func (s *raceSubscriber) NextSeq() uint64 { return 0 }
|
||||
|
||||
func (s *raceSubscriber) TrySend(msg interface{}) bool {
|
||||
s.sendMu.Lock()
|
||||
defer s.sendMu.Unlock()
|
||||
select {
|
||||
case s.sendCh <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *raceSubscriber) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
|
||||
s.sendMu.Lock()
|
||||
defer s.sendMu.Unlock()
|
||||
select {
|
||||
case s.sendCh <- msg:
|
||||
atomic.AddInt64(&s.actualEnqueued, 1)
|
||||
return true, false
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-s.sendCh:
|
||||
dropped = true
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case s.sendCh <- msg:
|
||||
atomic.AddInt64(&s.actualEnqueued, 1)
|
||||
return true, dropped
|
||||
default:
|
||||
atomic.AddInt64(&s.returnedFalse, 1)
|
||||
return false, dropped
|
||||
}
|
||||
}
|
||||
277
internal/event/bus/hub_test.go
Normal file
277
internal/event/bus/hub_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
func TestHub_Subscribe(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("mail.user_mailbox.event.message_received_v1", []string{"mail.event.v1"})
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
if h.ConnCount() != 1 {
|
||||
t.Errorf("expected 1 conn, got %d", h.ConnCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Publish_RoutesToSubscriber(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("im.msg", []string{"im.message.receive_v1"})
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventID: "evt-1",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}
|
||||
h.Publish(raw)
|
||||
|
||||
select {
|
||||
case msg := <-c.sendCh:
|
||||
evt, ok := msg.(*protocol.Event)
|
||||
if !ok {
|
||||
t.Fatalf("expected *Event, got %T", msg)
|
||||
}
|
||||
if evt.EventType != "im.message.receive_v1" {
|
||||
t.Errorf("got event_type %q", evt.EventType)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timeout waiting for event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Publish_SkipsUnmatchedSubscriber(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("mail.new", []string{"mail.event.v1"})
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventID: "evt-1",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}
|
||||
h.Publish(raw)
|
||||
|
||||
select {
|
||||
case <-c.sendCh:
|
||||
t.Fatal("should not receive unmatched event")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Publish_NonBlocking(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("im", []string{"im.message.receive_v1"})
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
c.sendCh <- &protocol.Event{}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
raw := &event.RawEvent{
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}
|
||||
h.Publish(raw)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Publish blocked on full channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Unregister(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("im", []string{"im.msg"})
|
||||
h.RegisterAndIsFirst(c)
|
||||
h.UnregisterAndIsLast(c)
|
||||
|
||||
if h.ConnCount() != 0 {
|
||||
t.Errorf("expected 0 conns, got %d", h.ConnCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_UnregisterAndIsLast_NeverRegistered(t *testing.T) {
|
||||
h := NewHub()
|
||||
real := newTestConn("im", []string{"im.msg"})
|
||||
h.RegisterAndIsFirst(real)
|
||||
ghost := newTestConn("im", []string{"im.msg"})
|
||||
|
||||
if h.UnregisterAndIsLast(ghost) {
|
||||
t.Error("ghost unregister returned true: must be false when subscriber never registered")
|
||||
}
|
||||
if got := h.EventKeyCount("im"); got != 1 {
|
||||
t.Errorf("keyCount for 'im' = %d after ghost unregister; want 1 (real still registered)", got)
|
||||
}
|
||||
if !h.UnregisterAndIsLast(real) {
|
||||
t.Error("real unregister returned false; expected true (sole subscriber)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_UnregisterAndIsLast_DoubleUnregister(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("im", []string{"im.msg"})
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
if !h.UnregisterAndIsLast(c) {
|
||||
t.Fatal("first unregister returned false; expected true (sole subscriber)")
|
||||
}
|
||||
if h.UnregisterAndIsLast(c) {
|
||||
t.Error("second unregister returned true: duplicate unregister must report false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_EventKeyCount(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1 := newTestConn("mail.user_mailbox.event.message_received_v1", []string{"mail.v1"})
|
||||
c2 := newTestConn("mail.user_mailbox.event.message_received_v1", []string{"mail.v1"})
|
||||
h.RegisterAndIsFirst(c1)
|
||||
h.RegisterAndIsFirst(c2)
|
||||
|
||||
if h.EventKeyCount("mail.user_mailbox.event.message_received_v1") != 2 {
|
||||
t.Errorf("expected 2, got %d", h.EventKeyCount("mail.user_mailbox.event.message_received_v1"))
|
||||
}
|
||||
|
||||
h.UnregisterAndIsLast(c1)
|
||||
if h.EventKeyCount("mail.user_mailbox.event.message_received_v1") != 1 {
|
||||
t.Errorf("expected 1 after unregister, got %d", h.EventKeyCount("mail.user_mailbox.event.message_received_v1"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_RegisterAndIsFirst_Concurrent(t *testing.T) {
|
||||
h := NewHub()
|
||||
const N = 200
|
||||
eventKey := "mail.user_mailbox.event.message_received_v1"
|
||||
|
||||
var firstCount int32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(N)
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < N; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
c := newTestConn(eventKey, []string{"mail.v1"})
|
||||
if h.RegisterAndIsFirst(c) {
|
||||
atomic.AddInt32(&firstCount, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&firstCount); got != 1 {
|
||||
t.Errorf("RegisterAndIsFirst returned true %d times across %d concurrent registrants; want exactly 1", got, N)
|
||||
}
|
||||
if got := h.EventKeyCount(eventKey); got != N {
|
||||
t.Errorf("EventKeyCount = %d, want %d", got, N)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_UnregisterAndIsLast_Concurrent(t *testing.T) {
|
||||
h := NewHub()
|
||||
const N = 200
|
||||
eventKey := "im.message.receive_v1"
|
||||
|
||||
conns := make([]*testConn, N)
|
||||
for i := 0; i < N; i++ {
|
||||
conns[i] = newTestConn(eventKey, []string{"im.v1"})
|
||||
h.RegisterAndIsFirst(conns[i])
|
||||
}
|
||||
|
||||
var lastCount int32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(N)
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < N; i++ {
|
||||
c := conns[i]
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
if h.UnregisterAndIsLast(c) {
|
||||
atomic.AddInt32(&lastCount, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&lastCount); got != 1 {
|
||||
t.Errorf("UnregisterAndIsLast returned true %d times; want exactly 1", got)
|
||||
}
|
||||
if got := h.EventKeyCount(eventKey); got != 0 {
|
||||
t.Errorf("EventKeyCount after all unregister = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
type testConn struct {
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
sendCh chan interface{}
|
||||
pid int
|
||||
received atomic.Int64
|
||||
}
|
||||
|
||||
func newTestConn(eventKey string, eventTypes []string) *testConn {
|
||||
return &testConn{
|
||||
eventKey: eventKey,
|
||||
eventTypes: eventTypes,
|
||||
sendCh: make(chan interface{}, 100),
|
||||
pid: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testConn) EventKey() string { return c.eventKey }
|
||||
func (c *testConn) EventTypes() []string { return c.eventTypes }
|
||||
func (c *testConn) SendCh() chan interface{} { return c.sendCh }
|
||||
func (c *testConn) PID() int { return c.pid }
|
||||
func (c *testConn) IncrementReceived() { c.received.Add(1) }
|
||||
func (c *testConn) Received() int64 { return c.received.Load() }
|
||||
|
||||
func (c *testConn) DroppedCount() int64 { return 0 }
|
||||
|
||||
func (c *testConn) IncrementDropped() {}
|
||||
|
||||
func (c *testConn) NextSeq() uint64 { return 0 }
|
||||
|
||||
func (c *testConn) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true, false
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-c.sendCh:
|
||||
dropped = true
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true, dropped
|
||||
default:
|
||||
return false, dropped
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testConn) TrySend(msg interface{}) bool {
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
100
internal/event/bus/hub_toctou_test.go
Normal file
100
internal/event/bus/hub_toctou_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// While a subscriber holds the cleanup lock for its key, Register for same key must block until release.
|
||||
func TestConcurrentPreShutdownAndHelloRaceFree(t *testing.T) {
|
||||
h := NewHub()
|
||||
subA := newTestConn("mail.key", []string{"mail.receive"})
|
||||
subA.pid = 1001
|
||||
h.RegisterAndIsFirst(subA)
|
||||
|
||||
if !h.AcquireCleanupLock("mail.key") {
|
||||
t.Fatal("A should acquire cleanup lock — it's the only subscriber")
|
||||
}
|
||||
|
||||
subB := newTestConn("mail.key", []string{"mail.receive"})
|
||||
subB.pid = 1002
|
||||
|
||||
registered := make(chan bool, 1)
|
||||
go func() {
|
||||
isFirst := h.RegisterAndIsFirst(subB)
|
||||
registered <- isFirst
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-registered:
|
||||
t.Fatal("B registered DURING A's cleanup — TOCTOU race not fixed")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
h.ReleaseCleanupLock("mail.key")
|
||||
|
||||
select {
|
||||
case isFirst := <-registered:
|
||||
_ = isFirst
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("B never registered after cleanup released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireCleanupLockRejectsIfMultipleSubscribers(t *testing.T) {
|
||||
h := NewHub()
|
||||
subA := newTestConn("shared.key", []string{"t"})
|
||||
subA.pid = 1
|
||||
subB := newTestConn("shared.key", []string{"t"})
|
||||
subB.pid = 2
|
||||
h.RegisterAndIsFirst(subA)
|
||||
h.RegisterAndIsFirst(subB)
|
||||
|
||||
if h.AcquireCleanupLock("shared.key") {
|
||||
t.Fatal("AcquireCleanupLock should reject when >1 subscribers exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireCleanupLockRejectsIfAlreadyLocked(t *testing.T) {
|
||||
h := NewHub()
|
||||
sub := newTestConn("exclusive.key", []string{"t"})
|
||||
sub.pid = 1
|
||||
h.RegisterAndIsFirst(sub)
|
||||
|
||||
if !h.AcquireCleanupLock("exclusive.key") {
|
||||
t.Fatal("first acquire should succeed")
|
||||
}
|
||||
if h.AcquireCleanupLock("exclusive.key") {
|
||||
t.Fatal("second acquire should fail — already locked")
|
||||
}
|
||||
|
||||
h.ReleaseCleanupLock("exclusive.key")
|
||||
if !h.AcquireCleanupLock("exclusive.key") {
|
||||
t.Fatal("re-acquire after release should succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCleanupLockIsIdempotent(t *testing.T) {
|
||||
h := NewHub()
|
||||
h.ReleaseCleanupLock("never.locked.key")
|
||||
h.ReleaseCleanupLock("never.locked.key")
|
||||
}
|
||||
|
||||
func TestAcquireCleanupLockRejectsIfZeroSubscribers(t *testing.T) {
|
||||
h := NewHub()
|
||||
|
||||
if h.AcquireCleanupLock("never.registered.key") {
|
||||
t.Error("AcquireCleanupLock should reject for a never-registered key (count==0)")
|
||||
}
|
||||
|
||||
sub := newTestConn("transient.key", []string{"t"})
|
||||
sub.pid = 1
|
||||
h.RegisterAndIsFirst(sub)
|
||||
h.UnregisterAndIsLast(sub)
|
||||
if h.AcquireCleanupLock("transient.key") {
|
||||
t.Error("AcquireCleanupLock should reject after all subscribers have unregistered (count==0)")
|
||||
}
|
||||
}
|
||||
40
internal/event/bus/log.go
Normal file
40
internal/event/bus/log.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
maxLogSize = 5 * 1024 * 1024 // 5 MB
|
||||
logFileName = "bus.log"
|
||||
logBackupName = "bus.log.1"
|
||||
)
|
||||
|
||||
// SetupBusLogger writes to eventsDir/bus.log with one-shot size-based rotation at startup only.
|
||||
func SetupBusLogger(eventsDir string) (*log.Logger, error) {
|
||||
if err := vfs.MkdirAll(eventsDir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logPath := filepath.Join(eventsDir, logFileName)
|
||||
backupPath := filepath.Join(eventsDir, logBackupName)
|
||||
|
||||
if info, err := vfs.Stat(logPath); err == nil && info.Size() > maxLogSize {
|
||||
_ = vfs.Remove(backupPath)
|
||||
_ = vfs.Rename(logPath, backupPath)
|
||||
}
|
||||
|
||||
f, err := vfs.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return log.New(f, "", log.LstdFlags), nil
|
||||
}
|
||||
57
internal/event/busctl/busctl.go
Normal file
57
internal/event/busctl/busctl.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package busctl is the wire-level control client for the event bus daemon.
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
const readTimeout = 5 * time.Second // matches protocol.WriteTimeout
|
||||
|
||||
func QueryStatus(tr transport.IPC, appID string) (*protocol.StatusResponse, error) {
|
||||
conn, err := tr.Dial(tr.Address(appID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := protocol.EncodeWithDeadline(conn, protocol.NewStatusQuery(), protocol.WriteTimeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, err := protocol.ReadFrame(bufio.NewReader(conn))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, ok := msg.(*protocol.StatusResponse)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response type from bus: %T", msg)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// SendShutdown sends a Shutdown command; caller polls Dial to confirm exit.
|
||||
func SendShutdown(tr transport.IPC, appID string) error {
|
||||
conn, err := tr.Dial(tr.Address(appID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
return protocol.EncodeWithDeadline(conn, protocol.NewShutdown(), protocol.WriteTimeout)
|
||||
}
|
||||
34
internal/event/busdiscover/busdiscover.go
Normal file
34
internal/event/busdiscover/busdiscover.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package busdiscover enumerates live bus daemons via per-AppID PID files protected by a process-lifetime advisory lock.
|
||||
package busdiscover
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type Process struct {
|
||||
PID int
|
||||
AppID string
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
type Scanner interface {
|
||||
ScanBusProcesses() ([]Process, error)
|
||||
}
|
||||
|
||||
func Default() Scanner {
|
||||
return &fsScanner{eventsDir: filepath.Join(core.GetConfigDir(), "events")}
|
||||
}
|
||||
|
||||
type fsScanner struct {
|
||||
eventsDir string
|
||||
}
|
||||
|
||||
func (s *fsScanner) ScanBusProcesses() ([]Process, error) {
|
||||
return scanLiveBuses(s.eventsDir)
|
||||
}
|
||||
129
internal/event/busdiscover/pidfile.go
Normal file
129
internal/event/busdiscover/pidfile.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package busdiscover
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
pidFileName = "bus.pid"
|
||||
aliveLockFileName = "bus.alive.lock"
|
||||
)
|
||||
|
||||
// Handle keeps the lifetime lock fd alive; OS releases on process exit.
|
||||
type Handle struct {
|
||||
lock *lockfile.LockFile
|
||||
}
|
||||
|
||||
// Release is for tests only; production lets process exit release the lock.
|
||||
func (h *Handle) Release() error {
|
||||
if h == nil || h.lock == nil {
|
||||
return nil
|
||||
}
|
||||
return h.lock.Unlock()
|
||||
}
|
||||
|
||||
// WritePIDFile takes the alive lock and atomically writes pid + RFC3339 start time.
|
||||
// Returns lockfile.ErrHeld if another bus holds the lock.
|
||||
func WritePIDFile(eventsDir string, pid int) (*Handle, error) {
|
||||
if err := vfs.MkdirAll(eventsDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("busdiscover: mkdir %s: %w", eventsDir, err)
|
||||
}
|
||||
lock := lockfile.New(filepath.Join(eventsDir, aliveLockFileName))
|
||||
if err := lock.TryLock(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pidPath := filepath.Join(eventsDir, pidFileName)
|
||||
tmpPath := pidPath + ".tmp"
|
||||
payload := fmt.Sprintf("%d\n%s\n", pid, time.Now().UTC().Format(time.RFC3339))
|
||||
if err := vfs.WriteFile(tmpPath, []byte(payload), 0600); err != nil {
|
||||
_ = lock.Unlock()
|
||||
return nil, fmt.Errorf("busdiscover: write pid tmp: %w", err)
|
||||
}
|
||||
if err := vfs.Rename(tmpPath, pidPath); err != nil {
|
||||
_ = vfs.Remove(tmpPath)
|
||||
_ = lock.Unlock()
|
||||
return nil, fmt.Errorf("busdiscover: rename pid file: %w", err)
|
||||
}
|
||||
return &Handle{lock: lock}, nil
|
||||
}
|
||||
|
||||
func readPIDFile(eventsDir string) (int, time.Time, error) {
|
||||
pidPath := filepath.Join(eventsDir, pidFileName)
|
||||
data, err := vfs.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
return 0, time.Time{}, err
|
||||
}
|
||||
lines := strings.SplitN(strings.TrimSpace(string(data)), "\n", 2)
|
||||
if len(lines) < 2 {
|
||||
return 0, time.Time{}, fmt.Errorf("busdiscover: malformed pid file %s", pidPath)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(lines[0]))
|
||||
if err != nil {
|
||||
return 0, time.Time{}, fmt.Errorf("busdiscover: malformed pid in %s: %w", pidPath, err)
|
||||
}
|
||||
startTime, err := time.Parse(time.RFC3339, strings.TrimSpace(lines[1]))
|
||||
if err != nil {
|
||||
return 0, time.Time{}, fmt.Errorf("busdiscover: malformed timestamp in %s: %w", pidPath, err)
|
||||
}
|
||||
return pid, startTime, nil
|
||||
}
|
||||
|
||||
// isBusAlive: try-lock the alive file. ErrHeld = live holder; success = stale (release immediately).
|
||||
func isBusAlive(appDir string) bool {
|
||||
lockPath := filepath.Join(appDir, aliveLockFileName)
|
||||
if _, err := vfs.Stat(lockPath); err != nil {
|
||||
return false
|
||||
}
|
||||
probe := lockfile.New(lockPath)
|
||||
err := probe.TryLock()
|
||||
if errors.Is(err, lockfile.ErrHeld) {
|
||||
return true
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[busdiscover] probe %s: %v\n", lockPath, err) //nolint:forbidigo // internal diagnostic; scanner has no IOStreams plumbing
|
||||
return false
|
||||
}
|
||||
_ = probe.Unlock()
|
||||
return false
|
||||
}
|
||||
|
||||
func scanLiveBuses(eventsDir string) ([]Process, error) {
|
||||
entries, err := vfs.ReadDir(eventsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("busdiscover: read events dir: %w", err)
|
||||
}
|
||||
var result []Process
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
appID := e.Name()
|
||||
appDir := filepath.Join(eventsDir, appID)
|
||||
if !isBusAlive(appDir) {
|
||||
continue
|
||||
}
|
||||
pid, startTime, err := readPIDFile(appDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[busdiscover] live bus at %s but pid file unreadable: %v\n", appDir, err) //nolint:forbidigo // internal diagnostic; scanner has no IOStreams plumbing
|
||||
result = append(result, Process{PID: 0, AppID: appID})
|
||||
continue
|
||||
}
|
||||
result = append(result, Process{PID: pid, AppID: appID, StartTime: startTime})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
151
internal/event/busdiscover/pidfile_test.go
Normal file
151
internal/event/busdiscover/pidfile_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package busdiscover
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
)
|
||||
|
||||
func TestWritePIDFile_WritesPIDAndTimestamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
h, err := WritePIDFile(dir, 4242)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = h.Release() })
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "bus.pid"))
|
||||
if err != nil {
|
||||
t.Fatalf("read pid file: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected 2 lines, got %d: %q", len(lines), string(data))
|
||||
}
|
||||
if lines[0] != "4242" {
|
||||
t.Errorf("pid line = %q, want %q", lines[0], "4242")
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339, lines[1])
|
||||
if err != nil {
|
||||
t.Errorf("timestamp parse: %v (line: %q)", err, lines[1])
|
||||
}
|
||||
if time.Since(ts) > time.Minute {
|
||||
t.Errorf("timestamp = %v, expected within last minute", ts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePIDFile_SecondCallReturnsErrHeld(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
h1, err := WritePIDFile(dir, 1111)
|
||||
if err != nil {
|
||||
t.Fatalf("first WritePIDFile: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = h1.Release() })
|
||||
|
||||
_, err = WritePIDFile(dir, 2222)
|
||||
if !errors.Is(err, lockfile.ErrHeld) {
|
||||
t.Errorf("second WritePIDFile err = %v, want lockfile.ErrHeld", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePIDFile_ReleaseAllowsReacquire(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
h1, err := WritePIDFile(dir, 1111)
|
||||
if err != nil {
|
||||
t.Fatalf("first WritePIDFile: %v", err)
|
||||
}
|
||||
if err := h1.Release(); err != nil {
|
||||
t.Fatalf("Release: %v", err)
|
||||
}
|
||||
h2, err := WritePIDFile(dir, 2222)
|
||||
if err != nil {
|
||||
t.Fatalf("re-acquire after Release: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = h2.Release() })
|
||||
}
|
||||
|
||||
func TestScanLiveBuses_ReturnsLiveBusOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
liveDir := filepath.Join(root, "cli_live")
|
||||
hLive, err := WritePIDFile(liveDir, 7777)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile live: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = hLive.Release() })
|
||||
|
||||
deadDir := filepath.Join(root, "cli_dead")
|
||||
hDead, err := WritePIDFile(deadDir, 8888)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile dead: %v", err)
|
||||
}
|
||||
if err := hDead.Release(); err != nil {
|
||||
t.Fatalf("Release dead: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(root, "empty"), 0700); err != nil {
|
||||
t.Fatalf("mkdir empty: %v", err)
|
||||
}
|
||||
|
||||
procs, err := scanLiveBuses(root)
|
||||
if err != nil {
|
||||
t.Fatalf("scanLiveBuses: %v", err)
|
||||
}
|
||||
if len(procs) != 1 {
|
||||
t.Fatalf("expected 1 live proc, got %d: %+v", len(procs), procs)
|
||||
}
|
||||
if procs[0].AppID != "cli_live" {
|
||||
t.Errorf("AppID = %q, want %q", procs[0].AppID, "cli_live")
|
||||
}
|
||||
if procs[0].PID != 7777 {
|
||||
t.Errorf("PID = %d, want 7777", procs[0].PID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLiveBuses_MissingDirIsNotError(t *testing.T) {
|
||||
procs, err := scanLiveBuses(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil", err)
|
||||
}
|
||||
if len(procs) != 0 {
|
||||
t.Errorf("expected empty result, got %+v", procs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLiveBuses_LiveBusWithCorruptPIDFileSurfaced(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
appDir := filepath.Join(root, "cli_corrupt")
|
||||
if err := os.MkdirAll(appDir, 0700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
lock := lockfile.New(filepath.Join(appDir, aliveLockFileName))
|
||||
if err := lock.TryLock(); err != nil {
|
||||
t.Fatalf("TryLock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = lock.Unlock() })
|
||||
if err := os.WriteFile(filepath.Join(appDir, pidFileName), []byte("garbage"), 0600); err != nil {
|
||||
t.Fatalf("write corrupt pid: %v", err)
|
||||
}
|
||||
|
||||
procs, err := scanLiveBuses(root)
|
||||
if err != nil {
|
||||
t.Fatalf("scanLiveBuses: %v", err)
|
||||
}
|
||||
if len(procs) != 1 {
|
||||
t.Fatalf("expected 1 entry (live bus surfaced anonymously), got %d: %+v", len(procs), procs)
|
||||
}
|
||||
if procs[0].AppID != "cli_corrupt" {
|
||||
t.Errorf("AppID = %q, want %q", procs[0].AppID, "cli_corrupt")
|
||||
}
|
||||
if procs[0].PID != 0 {
|
||||
t.Errorf("PID = %d, want 0 (anonymous)", procs[0].PID)
|
||||
}
|
||||
}
|
||||
80
internal/event/consume/bounded_test.go
Normal file
80
internal/event/consume/bounded_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBoundedLoop_MaxEvents(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var emitted atomic.Int64
|
||||
opts := Options{MaxEvents: 3, ErrOut: io.Discard}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
emitted.Add(1)
|
||||
stopNow := checkMaxEvents(opts, &emitted)
|
||||
if (i + 1) >= 3 {
|
||||
if !stopNow {
|
||||
t.Fatalf("checkMaxEvents should return true at emit %d (max=3)", i+1)
|
||||
}
|
||||
} else {
|
||||
if stopNow {
|
||||
t.Fatalf("checkMaxEvents should not return true at emit %d (max=3)", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = ctx
|
||||
}
|
||||
|
||||
func TestBoundedLoop_NoLimitWhenZero(t *testing.T) {
|
||||
var emitted atomic.Int64
|
||||
opts := Options{MaxEvents: 0, ErrOut: io.Discard}
|
||||
for i := 0; i < 100; i++ {
|
||||
emitted.Add(1)
|
||||
if checkMaxEvents(opts, &emitted) {
|
||||
t.Fatalf("checkMaxEvents should never return true when MaxEvents=0; returned true at emit %d", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitReason_Limit(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
opts := Options{MaxEvents: 5, Timeout: 0}
|
||||
reason := exitReason(ctx, 5, opts)
|
||||
if reason != "limit" {
|
||||
t.Errorf("reason = %q, want \"limit\"", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitReason_Timeout(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||
defer cancel()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
opts := Options{MaxEvents: 5, Timeout: 1 * time.Millisecond}
|
||||
reason := exitReason(ctx, 0, opts)
|
||||
if reason != "timeout" {
|
||||
t.Errorf("reason = %q, want \"timeout\"", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitReason_Signal(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
opts := Options{MaxEvents: 0, Timeout: 0}
|
||||
reason := exitReason(ctx, 0, opts)
|
||||
if reason != "signal" {
|
||||
t.Errorf("reason = %q, want \"signal\"", reason)
|
||||
}
|
||||
}
|
||||
227
internal/event/consume/consume.go
Normal file
227
internal/event/consume/consume.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package consume drives the consume-side half of the events pipeline.
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
EventKey string
|
||||
Params map[string]string
|
||||
JQExpr string
|
||||
Quiet bool
|
||||
OutputDir string
|
||||
Runtime event.APIClient
|
||||
Out io.Writer // nil falls back to os.Stdout
|
||||
ErrOut io.Writer
|
||||
RemoteAPIClient APIClient // nil disables remote-connection preflight
|
||||
|
||||
MaxEvents int // 0 = unlimited
|
||||
Timeout time.Duration // 0 = no timeout
|
||||
IsTTY bool
|
||||
}
|
||||
|
||||
// Run ensures bus is up, performs hello handshake, runs PreConsume for first subscriber,
|
||||
// enters the consume loop, and runs cleanup on exit if we were the last subscriber.
|
||||
func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain string, opts Options) error {
|
||||
errOut := opts.ErrOut
|
||||
if errOut == nil {
|
||||
errOut = os.Stderr //nolint:forbidigo // library-caller fallback
|
||||
}
|
||||
|
||||
keyDef, ok := event.Lookup(opts.EventKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
|
||||
}
|
||||
|
||||
if err := validateParams(keyDef, opts.Params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate jq before any side effects (bus daemon, PreConsume server-side subscriptions).
|
||||
if opts.JQExpr != "" {
|
||||
if _, err := CompileJQ(opts.JQExpr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if !opts.Quiet {
|
||||
if profileName != "" {
|
||||
fmt.Fprintf(errOut, "[event] consuming as %s (%s)\n", profileName, appID)
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] consuming as %s\n", appID)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := EnsureBus(ctx, tr, appID, profileName, domain, opts.RemoteAPIClient, errOut)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
|
||||
if err != nil {
|
||||
return fmt.Errorf("handshake failed: %w", err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
if ack.FirstForKey && keyDef.PreConsume != nil {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running pre-consume setup...\n")
|
||||
}
|
||||
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pre-consume failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
lastForKey := false
|
||||
var emitted atomic.Int64
|
||||
startTime := time.Now()
|
||||
|
||||
// On panic, run cleanup unconditionally — leaking server state is worse than
|
||||
// unsubscribing a still-live co-consumer (recoverable).
|
||||
defer func() {
|
||||
r := recover()
|
||||
if cleanup != nil {
|
||||
switch {
|
||||
case r != nil:
|
||||
fmt.Fprintf(errOut, "WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", opts.EventKey)
|
||||
cleanup()
|
||||
case lastForKey:
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running cleanup...\n")
|
||||
}
|
||||
cleanup()
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] cleanup done.\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !opts.Quiet && r == nil {
|
||||
reason := exitReason(ctx, emitted.Load(), opts)
|
||||
fmt.Fprintf(errOut, "[event] exited — received %d event(s) in %s (reason: %s)\n",
|
||||
emitted.Load(), truncateDuration(time.Since(startTime)), reason)
|
||||
}
|
||||
if r != nil {
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintln(errOut, listeningText(opts))
|
||||
if !opts.IsTTY {
|
||||
fmt.Fprintln(errOut, stopHintText())
|
||||
}
|
||||
}
|
||||
|
||||
writeReadyMarker(errOut, opts)
|
||||
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, &lastForKey, &emitted)
|
||||
}
|
||||
|
||||
func truncateDuration(d time.Duration) time.Duration {
|
||||
return d.Truncate(time.Second)
|
||||
}
|
||||
|
||||
func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
for _, p := range def.Params {
|
||||
if _, ok := params[p.Name]; !ok && p.Default != "" {
|
||||
params[p.Name] = p.Default
|
||||
}
|
||||
}
|
||||
for _, p := range def.Params {
|
||||
if p.Required {
|
||||
if _, ok := params[p.Name]; !ok {
|
||||
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
|
||||
p.Name, def.Key, def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
known := make(map[string]bool, len(def.Params))
|
||||
validNames := make([]string, 0, len(def.Params))
|
||||
for _, p := range def.Params {
|
||||
known[p.Name] = true
|
||||
validNames = append(validNames, p.Name)
|
||||
}
|
||||
sort.Strings(validNames)
|
||||
for k := range params {
|
||||
if known[k] {
|
||||
continue
|
||||
}
|
||||
if len(validNames) == 0 {
|
||||
return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, def.Key)
|
||||
}
|
||||
return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, strings.Join(validNames, ", "), def.Key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMaxEvents(opts Options, emitted *atomic.Int64) bool {
|
||||
if opts.MaxEvents <= 0 {
|
||||
return false
|
||||
}
|
||||
return emitted.Load() >= int64(opts.MaxEvents)
|
||||
}
|
||||
|
||||
func listeningText(opts Options) string {
|
||||
base := fmt.Sprintf("[event] listening for events (key=%s)", opts.EventKey)
|
||||
if opts.IsTTY {
|
||||
return base + ", ctrl+c to stop"
|
||||
}
|
||||
switch {
|
||||
case opts.MaxEvents > 0 && opts.Timeout > 0:
|
||||
return fmt.Sprintf("%s; will exit after %d event(s) or %s timeout", base, opts.MaxEvents, opts.Timeout)
|
||||
case opts.MaxEvents > 0:
|
||||
return fmt.Sprintf("%s; will exit after %d event(s)", base, opts.MaxEvents)
|
||||
case opts.Timeout > 0:
|
||||
return fmt.Sprintf("%s; will exit after %s timeout", base, opts.Timeout)
|
||||
default:
|
||||
return base + "; send SIGTERM or close stdin to stop"
|
||||
}
|
||||
}
|
||||
|
||||
// exitReason: count-first; --max-events races --timeout via inner-vs-outer ctx, do not reorder.
|
||||
func exitReason(ctx context.Context, emitted int64, opts Options) string {
|
||||
if opts.MaxEvents > 0 && emitted >= int64(opts.MaxEvents) {
|
||||
return "limit"
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return "timeout"
|
||||
}
|
||||
return "signal"
|
||||
}
|
||||
|
||||
func stopHintText() string {
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
|
||||
// writeReadyMarker emits the stable AI-facing "ready" contract line; do not add fields.
|
||||
func writeReadyMarker(w io.Writer, opts Options) {
|
||||
if opts.Quiet {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "[event] ready event_key=%s\n", opts.EventKey)
|
||||
}
|
||||
46
internal/event/consume/handshake.go
Normal file
46
internal/event/consume/handshake.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read deadline
|
||||
|
||||
// doHello returns a bufio.Reader holding any bytes already pulled off conn so events
|
||||
// buffered with the ack in one TCP segment aren't dropped.
|
||||
func doHello(conn net.Conn, eventKey string, eventTypes []string) (*protocol.HelloAck, *bufio.Reader, error) {
|
||||
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1")
|
||||
if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := conn.SetReadDeadline(time.Now().Add(helloAckTimeout)); err != nil {
|
||||
return nil, nil, fmt.Errorf("set hello_ack deadline: %w", err)
|
||||
}
|
||||
br := bufio.NewReader(conn)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("no hello_ack received: %w", err)
|
||||
}
|
||||
// best-effort clear; if the conn is already broken, the loop's first read will surface it
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ack, ok := msg.(*protocol.HelloAck)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("expected hello_ack, got %T", msg)
|
||||
}
|
||||
return ack, br, nil
|
||||
}
|
||||
46
internal/event/consume/handshake_test.go
Normal file
46
internal/event/consume/handshake_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// doHello must apply a read deadline on HelloAck so a wedged bus doesn't hang the consumer.
|
||||
func TestDoHello_ReadDeadline(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
if _, err := server.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := doHello(client, "im.msg", []string{"im.msg"})
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("doHello returned nil error when server never replied; must fail with deadline-driven error")
|
||||
}
|
||||
if elapsed > helloAckTimeout+2*time.Second {
|
||||
t.Errorf("doHello returned %v after %v; deadline should fire within ~%v", err, elapsed, helloAckTimeout)
|
||||
}
|
||||
case <-time.After(helloAckTimeout + 3*time.Second):
|
||||
t.Fatal("doHello hung past deadline + 3s slack: read deadline is missing or not being honoured")
|
||||
}
|
||||
}
|
||||
47
internal/event/consume/jq.go
Normal file
47
internal/event/consume/jq.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
)
|
||||
|
||||
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
|
||||
func CompileJQ(expr string) (*gojq.Code, error) {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid jq expression: %w", err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jq compile error: %w", err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// applyJQ returns (nil, nil) when the expression filters out the event (e.g. select).
|
||||
func applyJQ(code *gojq.Code, data json.RawMessage) (json.RawMessage, error) {
|
||||
var input interface{}
|
||||
if err := json.Unmarshal(data, &input); err != nil {
|
||||
return nil, fmt.Errorf("jq: unmarshal input: %w", err)
|
||||
}
|
||||
|
||||
iter := code.Run(input)
|
||||
v, ok := iter.Next()
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
if err, isErr := v.(error); isErr {
|
||||
return nil, fmt.Errorf("jq: %w", err)
|
||||
}
|
||||
|
||||
result, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jq: marshal result: %w", err)
|
||||
}
|
||||
return json.RawMessage(result), nil
|
||||
}
|
||||
81
internal/event/consume/listening_text_test.go
Normal file
81
internal/event/consume/listening_text_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestListeningText_TTY(t *testing.T) {
|
||||
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: true})
|
||||
want := "[event] listening for events (key=im.message.receive_v1), ctrl+c to stop"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListeningText_NonTTY_Default(t *testing.T) {
|
||||
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false})
|
||||
want := "[event] listening for events (key=im.message.receive_v1); send SIGTERM or close stdin to stop"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListeningText_NonTTY_MaxEvents(t *testing.T) {
|
||||
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false, MaxEvents: 1})
|
||||
want := "[event] listening for events (key=im.message.receive_v1); will exit after 1 event(s)"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListeningText_NonTTY_Timeout(t *testing.T) {
|
||||
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false, Timeout: 30 * time.Second})
|
||||
want := "[event] listening for events (key=im.message.receive_v1); will exit after 30s timeout"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
|
||||
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false, MaxEvents: 1, Timeout: 30 * time.Second})
|
||||
want := "[event] listening for events (key=im.message.receive_v1); will exit after 1 event(s) or 30s timeout"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Content(t *testing.T) {
|
||||
got := stopHintText()
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyMarker_EmittedAfterListening(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeReadyMarker(&buf, Options{EventKey: "im.message.receive_v1"})
|
||||
|
||||
got := buf.String()
|
||||
want := "[event] ready event_key=im.message.receive_v1\n"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyMarker_SuppressedWhenQuiet(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeReadyMarker(&buf, Options{EventKey: "im.message.receive_v1", Quiet: true})
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("Quiet=true must suppress ready marker; got %q", buf.String())
|
||||
}
|
||||
}
|
||||
256
internal/event/consume/loop.go
Normal file
256
internal/event/consume/loop.go
Normal file
@@ -0,0 +1,256 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// consumeLoop reads events and dispatches to workers; cancels on terminal sink errors.
|
||||
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, lastForKey *bool, emitted *atomic.Int64) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
sink, err := newSink(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Compile before worker goroutines start to avoid a data race on jqCode.
|
||||
var jqCode *gojq.Code
|
||||
if opts.JQExpr != "" {
|
||||
jqCode, err = CompileJQ(opts.JQExpr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
bufSize := keyDef.BufferSize
|
||||
if bufSize <= 0 {
|
||||
bufSize = event.DefaultBufferSize
|
||||
}
|
||||
socketCh := make(chan *protocol.Event, bufSize)
|
||||
|
||||
// stopReader lets shutdown preempt the reader so PreShutdownCheck can reuse conn.
|
||||
stopReader := make(chan struct{})
|
||||
readerDone := make(chan struct{})
|
||||
|
||||
// ReadBytes (not Scanner) so mid-frame read deadlines don't drop buffered bytes.
|
||||
go func() {
|
||||
defer close(readerDone)
|
||||
defer close(socketCh)
|
||||
var buf []byte
|
||||
var lastSeq uint64 // per-conn monotonic; gaps = bus drop-oldest backpressure
|
||||
for {
|
||||
select {
|
||||
case <-stopReader:
|
||||
return
|
||||
default:
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
chunk, err := br.ReadBytes('\n')
|
||||
if len(chunk) > 0 {
|
||||
// Cap accumulator: dribbling multi-MB lines past 200ms deadlines could grow buf unbounded.
|
||||
if len(buf)+len(chunk) > protocol.MaxFrameBytes {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut,
|
||||
"WARN: dropping oversized frame (>%d bytes) from bus\n", protocol.MaxFrameBytes)
|
||||
}
|
||||
buf = nil
|
||||
continue
|
||||
}
|
||||
buf = append(buf, chunk...)
|
||||
}
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
line := buf
|
||||
if n := len(line); n > 0 && line[n-1] == '\n' {
|
||||
line = line[:n-1]
|
||||
}
|
||||
buf = nil
|
||||
|
||||
msg, decErr := protocol.Decode(line)
|
||||
if decErr != nil {
|
||||
continue
|
||||
}
|
||||
switch m := msg.(type) {
|
||||
case *protocol.Event:
|
||||
if lastSeq > 0 && m.Seq > 0 && m.Seq > lastSeq+1 {
|
||||
gap := m.Seq - lastSeq - 1
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut,
|
||||
"WARN: event seq gap %d->%d, missed %d events (dropped by bus backpressure)\n",
|
||||
lastSeq, m.Seq, gap)
|
||||
}
|
||||
}
|
||||
// Only advance forward — concurrent publishers can deliver out-of-order.
|
||||
if m.Seq > lastSeq {
|
||||
lastSeq = m.Seq
|
||||
}
|
||||
select {
|
||||
case socketCh <- m:
|
||||
default:
|
||||
// drop-oldest back-pressure
|
||||
select {
|
||||
case <-socketCh:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case socketCh <- m:
|
||||
default:
|
||||
}
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut, "WARN: consume backpressure, dropped oldest event\n")
|
||||
}
|
||||
}
|
||||
case *protocol.SourceStatus:
|
||||
if !opts.Quiet {
|
||||
if m.Detail != "" {
|
||||
fmt.Fprintf(opts.ErrOut, "[source] %s: %s (%s)\n", m.Source, m.State, m.Detail)
|
||||
} else {
|
||||
fmt.Fprintf(opts.ErrOut, "[source] %s: %s\n", m.Source, m.State)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// forward-compatible: ignore unknown message types
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
workers := keyDef.Workers
|
||||
if workers <= 0 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for evt := range socketCh {
|
||||
wrote, err := processAndOutput(ctx, keyDef, evt, opts, sink, jqCode)
|
||||
if wrote {
|
||||
emitted.Add(1)
|
||||
// cancel inner ctx so shutdown goes through normal cleanup, not conn rip.
|
||||
if checkMaxEvents(opts, emitted) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if isTerminalSinkError(err) {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut, "consume: output pipe closed (%v), shutting down\n", err)
|
||||
}
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut, "WARN: sink write failed, skipping event: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
allDone := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(allDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Drain reader so PreShutdownCheck has exclusive conn.
|
||||
close(stopReader)
|
||||
<-readerDone
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
*lastForKey = checkLastForKey(conn, opts.EventKey)
|
||||
conn.Close()
|
||||
case <-allDone:
|
||||
// bus-side close; can't query, assume last
|
||||
*lastForKey = true
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processAndOutput returns (wrote, err); err non-nil only for sink.Write failures.
|
||||
func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) {
|
||||
var result json.RawMessage
|
||||
|
||||
if keyDef.Process != nil {
|
||||
raw := &event.RawEvent{
|
||||
EventType: evt.EventType,
|
||||
Payload: evt.Payload,
|
||||
}
|
||||
var err error
|
||||
result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params)
|
||||
if err != nil {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut, "WARN: Process error: %v\n", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if result == nil {
|
||||
return false, nil
|
||||
}
|
||||
} else {
|
||||
result = evt.Payload
|
||||
}
|
||||
|
||||
if jqCode != nil {
|
||||
filtered, err := applyJQ(jqCode, result)
|
||||
if err != nil {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut, "WARN: JQ error: %v\n", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if filtered == nil {
|
||||
return false, nil
|
||||
}
|
||||
result = filtered
|
||||
}
|
||||
|
||||
if err := sink.Write(result); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isTerminalSinkError reports if the output channel is permanently broken (EPIPE/ErrClosed).
|
||||
func isTerminalSinkError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, syscall.EPIPE) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, fs.ErrClosed) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
112
internal/event/consume/loop_jq_test.go
Normal file
112
internal/event/consume/loop_jq_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
_, err := CompileJQ("invalid{{{")
|
||||
if err == nil {
|
||||
t.Fatal("expected compile error for invalid jq expression")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
|
||||
t.Errorf("error should mention compile/parse/invalid, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileJQReturnsUsableCode(t *testing.T) {
|
||||
code, err := CompileJQ(".foo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code == nil {
|
||||
t.Fatal("expected non-nil code")
|
||||
}
|
||||
|
||||
input := json.RawMessage(`{"foo":"bar"}`)
|
||||
result, err := applyJQ(code, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(result) != `"bar"` {
|
||||
t.Errorf("expected \"bar\", got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyJQReusesCompiledCode(t *testing.T) {
|
||||
code, err := CompileJQ(".foo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := json.RawMessage(`{"foo":"bar"}`)
|
||||
for i := 0; i < 10000; i++ {
|
||||
result, err := applyJQ(code, data)
|
||||
if err != nil {
|
||||
t.Fatalf("iteration %d: %v", i, err)
|
||||
}
|
||||
if string(result) != `"bar"` {
|
||||
t.Fatalf("iteration %d: unexpected result %s", i, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyJQFilterReturnsNilOnNoOutput(t *testing.T) {
|
||||
code, err := CompileJQ(`select(.type == "match")`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := applyJQ(code, json.RawMessage(`{"type":"nomatch"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("should not error on filter-out: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result for filtered-out event, got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyJQConcurrentSafe(t *testing.T) {
|
||||
code, err := CompileJQ(".value")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const goroutines = 32
|
||||
const iterationsPerGoroutine = 1000
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, goroutines)
|
||||
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(gid int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterationsPerGoroutine; i++ {
|
||||
input := json.RawMessage(fmt.Sprintf(`{"value":"goroutine-%d-iter-%d"}`, gid, i))
|
||||
result, err := applyJQ(code, input)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("goroutine %d iter %d: %w", gid, i, err)
|
||||
return
|
||||
}
|
||||
expected := fmt.Sprintf(`"goroutine-%d-iter-%d"`, gid, i)
|
||||
if string(result) != expected {
|
||||
errs <- fmt.Errorf("goroutine %d iter %d: expected %s, got %s", gid, i, expected, string(result))
|
||||
return
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
104
internal/event/consume/loop_seq_test.go
Normal file
104
internal/event/consume/loop_seq_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// Mirrors the inline gap-detection logic from consumeLoop's reader; keep in sync with loop.go.
|
||||
type seqGapDetector struct {
|
||||
lastSeq uint64
|
||||
errOut io.Writer
|
||||
quiet bool
|
||||
}
|
||||
|
||||
func (d *seqGapDetector) observe(m *protocol.Event) {
|
||||
if d.lastSeq > 0 && m.Seq > 0 && m.Seq > d.lastSeq+1 {
|
||||
gap := m.Seq - d.lastSeq - 1
|
||||
if !d.quiet {
|
||||
fmt.Fprintf(d.errOut, "WARN: event seq gap %d->%d, missed %d events (dropped by bus backpressure)\n",
|
||||
d.lastSeq, m.Seq, gap)
|
||||
}
|
||||
}
|
||||
// CRITICAL: only advance forward — concurrent Publishers may deliver Seq out-of-order.
|
||||
if m.Seq > d.lastSeq {
|
||||
d.lastSeq = m.Seq
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorNoWarningOnFirstEvent(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf}
|
||||
d.observe(&protocol.Event{Seq: 5})
|
||||
if strings.Contains(buf.String(), "gap") {
|
||||
t.Errorf("unexpected gap warning on first event: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorNoWarningOnContiguous(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf}
|
||||
for i := uint64(1); i <= 10; i++ {
|
||||
d.observe(&protocol.Event{Seq: i})
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("unexpected output on contiguous seqs: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorWarnsOnActualGap(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf}
|
||||
d.observe(&protocol.Event{Seq: 1})
|
||||
d.observe(&protocol.Event{Seq: 5})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "gap 1->5") {
|
||||
t.Errorf("expected 'gap 1->5' in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "missed 3 events") {
|
||||
t.Errorf("expected 'missed 3 events' in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorHandlesOutOfOrderWithoutFalsePositive(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf}
|
||||
d.observe(&protocol.Event{Seq: 6})
|
||||
d.observe(&protocol.Event{Seq: 5})
|
||||
d.observe(&protocol.Event{Seq: 7})
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("unexpected warning for out-of-order (no actual gap): %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorQuietMode(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf, quiet: true}
|
||||
d.observe(&protocol.Event{Seq: 1})
|
||||
d.observe(&protocol.Event{Seq: 10})
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("quiet mode should suppress warnings, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorZeroSeqIgnored(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf}
|
||||
d.observe(&protocol.Event{Seq: 5})
|
||||
d.observe(&protocol.Event{Seq: 0})
|
||||
d.observe(&protocol.Event{Seq: 6})
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("unexpected warning across legacy zero-seq event: %s", buf.String())
|
||||
}
|
||||
if d.lastSeq != 6 {
|
||||
t.Errorf("expected lastSeq=6 after legacy skip, got %d", d.lastSeq)
|
||||
}
|
||||
}
|
||||
223
internal/event/consume/loop_test.go
Normal file
223
internal/event/consume/loop_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
func echoKeyDef(key string) *event.KeyDefinition {
|
||||
return &event.KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
BufferSize: 32,
|
||||
Workers: 1,
|
||||
Process: func(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
return raw.Payload, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func busSide(t *testing.T, server net.Conn, events []*protocol.Event, ackLast bool) {
|
||||
t.Helper()
|
||||
for _, evt := range events {
|
||||
if err := protocol.Encode(server, evt); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
br := bufio.NewReader(server)
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
_ = server.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
msg, decErr := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if decErr != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := msg.(*protocol.PreShutdownCheck); ok {
|
||||
_ = protocol.Encode(server, protocol.NewPreShutdownAck(ackLast))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
events := []*protocol.Event{
|
||||
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)),
|
||||
protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"n":2}`)),
|
||||
}
|
||||
go busSide(t, server, events, true)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
opts := Options{
|
||||
EventKey: "test.key",
|
||||
Out: &stdout,
|
||||
ErrOut: io.Discard,
|
||||
Quiet: true,
|
||||
MaxEvents: 2,
|
||||
}
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
|
||||
if err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 2 {
|
||||
t.Errorf("emitted = %d, want 2", got)
|
||||
}
|
||||
if !lastForKey {
|
||||
t.Error("lastForKey = false, want true (bus acked LastForKey=true)")
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`{"n":1}`, `{"n":2}`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("stdout missing %q; full:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
events := []*protocol.Event{
|
||||
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)),
|
||||
protocol.NewEvent("test.evt", "e5", "", 5, json.RawMessage(`{"n":5}`)),
|
||||
}
|
||||
go busSide(t, server, events, true)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
opts := Options{
|
||||
EventKey: "test.key",
|
||||
Out: &stdout,
|
||||
ErrOut: &stderr,
|
||||
Quiet: false,
|
||||
MaxEvents: 2,
|
||||
}
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 2 {
|
||||
t.Errorf("emitted = %d, want 2", got)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "WARN: event seq gap 1->5") {
|
||||
t.Errorf("stderr missing seq-gap warning; got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
events := []*protocol.Event{
|
||||
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"keep":true,"n":1}`)),
|
||||
protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"keep":false,"n":2}`)),
|
||||
}
|
||||
go busSide(t, server, events, true)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
opts := Options{
|
||||
EventKey: "test.key",
|
||||
Out: &stdout,
|
||||
ErrOut: io.Discard,
|
||||
Quiet: true,
|
||||
JQExpr: "select(.keep) | .n",
|
||||
MaxEvents: 1,
|
||||
}
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 1 {
|
||||
t.Errorf("emitted = %d, want 1", got)
|
||||
}
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if out != "1" {
|
||||
t.Errorf("stdout = %q, want %q", out, "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
opts := Options{
|
||||
EventKey: "test.key",
|
||||
Out: io.Discard,
|
||||
ErrOut: io.Discard,
|
||||
Quiet: true,
|
||||
JQExpr: "not a valid jq expression (((",
|
||||
}
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
|
||||
if err == nil {
|
||||
t.Fatal("consumeLoop should fail immediately on bad jq expression")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminalSinkError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"EPIPE raw", syscall.EPIPE, true},
|
||||
{"EPIPE wrapped", fmt.Errorf("write: %w", syscall.EPIPE), true},
|
||||
{"ErrClosed", io.ErrClosedPipe, false},
|
||||
{"transient disk full", errors.New("no space left on device"), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isTerminalSinkError(tc.err); got != tc.want {
|
||||
t.Errorf("isTerminalSinkError(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
55
internal/event/consume/remote_preflight.go
Normal file
55
internal/event/consume/remote_preflight.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
type APIClient = event.APIClient
|
||||
|
||||
// CheckRemoteConnections returns the count of active WebSocket connections for this app.
|
||||
func CheckRemoteConnections(ctx context.Context, client APIClient) (int, error) {
|
||||
raw, err := client.CallAPI(ctx, "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("connection check: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OnlineInstanceCnt int `json:"online_instance_cnt"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return 0, fmt.Errorf("connection check: decode: %w (body=%s)", err, truncateForError(raw))
|
||||
}
|
||||
// Distinguish "verified zero" from "check failed" — non-zero code decodes Cnt=0.
|
||||
if result.Code != 0 {
|
||||
return 0, fmt.Errorf("connection check: api error code=%d msg=%q", result.Code, result.Msg)
|
||||
}
|
||||
return result.Data.OnlineInstanceCnt, nil
|
||||
}
|
||||
|
||||
// truncateForError bounds length and collapses control chars to defang log injection.
|
||||
func truncateForError(b []byte) string {
|
||||
const max = 256
|
||||
s := string(b)
|
||||
if len(s) > max {
|
||||
s = s[:max] + "…(truncated)"
|
||||
}
|
||||
out := make([]byte, 0, len(s))
|
||||
for _, r := range s {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
out = append(out, ' ')
|
||||
continue
|
||||
}
|
||||
out = append(out, string(r)...)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
74
internal/event/consume/remote_preflight_test.go
Normal file
74
internal/event/consume/remote_preflight_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/testutil"
|
||||
)
|
||||
|
||||
func TestCheckRemoteConnections_Success(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":0,"msg":"success","data":{"online_instance_cnt":1}}`}
|
||||
count, err := CheckRemoteConnections(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("count = %d, want 1", count)
|
||||
}
|
||||
if c.GotMethod != "GET" || c.GotPath != "/open-apis/event/v1/connection" {
|
||||
t.Errorf("wrong request: %s %s", c.GotMethod, c.GotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRemoteConnections_ZeroConnections(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"online_instance_cnt":0}}`}
|
||||
count, err := CheckRemoteConnections(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRemoteConnections_APIErrorPropagated(t *testing.T) {
|
||||
want := errors.New("API GET /open-apis/event/v1/connection: [99991663] token is invalid")
|
||||
c := &testutil.StubAPIClient{Err: want}
|
||||
_, err := CheckRemoteConnections(context.Background(), c)
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("err = %v, want wrapping %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRemoteConnections_MalformedJSON(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `not json at all`}
|
||||
_, err := CheckRemoteConnections(context.Background(), c)
|
||||
if err == nil {
|
||||
t.Fatal("expected decode error")
|
||||
}
|
||||
}
|
||||
|
||||
// Non-zero OAPI business code must surface as error so callers don't mistake it for "verified zero remote buses".
|
||||
func TestCheckRemoteConnections_NonZeroAPICodeSurfaced(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":99991663,"msg":"token is invalid","data":{}}`}
|
||||
count, err := CheckRemoteConnections(context.Background(), c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero OAPI code, got nil")
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("count = %d, want 0 on error", count)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "99991663") {
|
||||
t.Errorf("error message missing code 99991663: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "token is invalid") {
|
||||
t.Errorf("error message missing msg field: %q", msg)
|
||||
}
|
||||
}
|
||||
42
internal/event/consume/shutdown.go
Normal file
42
internal/event/consume/shutdown.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
const preShutdownAckTimeout = 2 * time.Second
|
||||
|
||||
// checkLastForKey atomically reserves a cleanup lock; on any error defaults to true
|
||||
// (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight.
|
||||
func checkLastForKey(conn net.Conn, eventKey string) bool {
|
||||
msg := protocol.NewPreShutdownCheck(eventKey)
|
||||
if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if err := conn.SetReadDeadline(time.Now().Add(preShutdownAckTimeout)); err != nil {
|
||||
return true
|
||||
}
|
||||
br := bufio.NewReader(conn)
|
||||
for {
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
resp, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ack, ok := resp.(*protocol.PreShutdownAck); ok {
|
||||
return ack.LastForKey
|
||||
}
|
||||
}
|
||||
}
|
||||
95
internal/event/consume/shutdown_test.go
Normal file
95
internal/event/consume/shutdown_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// checkLastForKey must skip non-ack frames buffered before PreShutdownAck.
|
||||
func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
errs := make(chan error, 2)
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
if _, err := server.Read(buf); err != nil && err != io.EOF {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
evt := protocol.NewEvent("im.msg", "evt_1", "", 1, json.RawMessage(`{}`))
|
||||
if err := protocol.Encode(server, evt); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
ack := protocol.NewPreShutdownAck(false)
|
||||
if err := protocol.Encode(server, ack); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
if got != false {
|
||||
t.Errorf("checkLastForKey = %v, want false", got)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errs:
|
||||
t.Fatalf("server goroutine error: %v", err)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLastForKey_ReturnsAckValue(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
_, _ = server.Read(buf)
|
||||
ack := protocol.NewPreShutdownAck(true)
|
||||
_ = protocol.Encode(server, ack)
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
if got != true {
|
||||
t.Errorf("checkLastForKey = %v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
if _, err := server.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if got != true {
|
||||
t.Errorf("checkLastForKey = %v, want true (default on timeout)", got)
|
||||
}
|
||||
if elapsed > preShutdownAckTimeout+2*time.Second {
|
||||
t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout)
|
||||
}
|
||||
}
|
||||
76
internal/event/consume/sink.go
Normal file
76
internal/event/consume/sink.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type Sink interface {
|
||||
Write(data json.RawMessage) error
|
||||
}
|
||||
|
||||
func newSink(opts Options) (Sink, error) {
|
||||
if opts.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create output dir: %w", err)
|
||||
}
|
||||
// PID disambiguates filenames across processes sharing a Dir.
|
||||
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil
|
||||
}
|
||||
out := opts.Out
|
||||
if out == nil {
|
||||
out = os.Stdout //nolint:forbidigo // library-caller fallback; cmd path always sets Options.Out
|
||||
}
|
||||
return &WriterSink{W: out, ErrOut: opts.ErrOut}, nil
|
||||
}
|
||||
|
||||
// WriterSink writes one JSON event per line; mu serialises concurrent worker writes.
|
||||
type WriterSink struct {
|
||||
W io.Writer
|
||||
Pretty bool
|
||||
ErrOut io.Writer
|
||||
prettyWarned atomic.Bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *WriterSink) Write(data json.RawMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.Pretty {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(data, &v); err == nil {
|
||||
pretty, _ := json.MarshalIndent(v, "", " ")
|
||||
_, err := fmt.Fprintln(s.W, string(pretty))
|
||||
return err
|
||||
}
|
||||
// non-JSON payload (e.g. --jq output): fall through to raw, log once
|
||||
if s.ErrOut != nil && s.prettyWarned.CompareAndSwap(false, true) {
|
||||
fmt.Fprintln(s.ErrOut, "WARN: --pretty: payload is not valid JSON; falling back to raw output (this and future malformed events)")
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(s.W, string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
// DirSink writes one JSON file per event; nanos+pid+seq filename avoids cross-process collisions.
|
||||
type DirSink struct {
|
||||
Dir string
|
||||
pid int
|
||||
seq atomic.Int64
|
||||
}
|
||||
|
||||
func (s *DirSink) Write(data json.RawMessage) error {
|
||||
name := fmt.Sprintf("%d_%d_%d.json", time.Now().UnixNano(), s.pid, s.seq.Add(1))
|
||||
return vfs.WriteFile(filepath.Join(s.Dir, name), data, 0600) // 0600: payloads may carry PII
|
||||
}
|
||||
118
internal/event/consume/sink_test.go
Normal file
118
internal/event/consume/sink_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriterSink_PrettyFallbackWarnsOnce(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
s := &WriterSink{W: &out, Pretty: true, ErrOut: &errOut}
|
||||
|
||||
if err := s.Write(json.RawMessage("not json {{{")); err != nil {
|
||||
t.Fatalf("first write: %v", err)
|
||||
}
|
||||
if err := s.Write(json.RawMessage("still not json")); err != nil {
|
||||
t.Fatalf("second write: %v", err)
|
||||
}
|
||||
|
||||
warnings := strings.Count(errOut.String(), "WARN:")
|
||||
if warnings != 1 {
|
||||
t.Errorf("expected exactly 1 WARN line, got %d: %q", warnings, errOut.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "pretty") {
|
||||
t.Errorf("warning should mention pretty: %q", errOut.String())
|
||||
}
|
||||
|
||||
if strings.Count(out.String(), "not json") != 2 {
|
||||
t.Errorf("expected 2 raw passthrough lines in W, got: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterSink_PrettyHappyPath(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
s := &WriterSink{W: &out, Pretty: true, ErrOut: &errOut}
|
||||
|
||||
if err := s.Write(json.RawMessage(`{"k":"v"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if errOut.Len() != 0 {
|
||||
t.Errorf("expected no warning on valid JSON, got: %q", errOut.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "\n \"k\"") {
|
||||
t.Errorf("expected indented output, got: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterSink_PrettyNoErrOut(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
s := &WriterSink{W: &out, Pretty: true}
|
||||
|
||||
if err := s.Write(json.RawMessage("not json")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "not json") {
|
||||
t.Errorf("expected raw passthrough, got: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirSink_FilenameIncludesPID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := &DirSink{Dir: dir, pid: os.Getpid()}
|
||||
|
||||
if err := s.Write(json.RawMessage(`{"a":1}`)); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d: %v", len(entries), err)
|
||||
}
|
||||
name := entries[0].Name()
|
||||
wantPID := fmt.Sprintf("_%d_", os.Getpid())
|
||||
if !strings.Contains(name, wantPID) {
|
||||
t.Errorf("filename %q should contain PID segment %q", name, wantPID)
|
||||
}
|
||||
if filepath.Ext(name) != ".json" {
|
||||
t.Errorf("filename %q should have .json extension", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirSink_FilenameFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := &DirSink{Dir: dir, pid: 12345}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := s.Write(json.RawMessage(`{}`)); err != nil {
|
||||
t.Fatalf("write %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 files, got %d", len(entries))
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
trimmed := strings.TrimSuffix(name, ".json")
|
||||
parts := strings.Split(trimmed, "_")
|
||||
if len(parts) != 3 {
|
||||
t.Errorf("filename %q should split into 3 underscore parts, got %d", name, len(parts))
|
||||
continue
|
||||
}
|
||||
if parts[1] != "12345" {
|
||||
t.Errorf("filename %q should have PID=12345 as middle segment, got %q", name, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
174
internal/event/consume/startup.go
Normal file
174
internal/event/consume/startup.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
dialRetryInterval = 50 * time.Millisecond
|
||||
dialTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// EnsureBus dials the bus daemon for appID, forking a new one if none is running.
|
||||
// apiClient nil skips remote-connection probe. Local-bus hits skip remote check (see `event status`).
|
||||
func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain string, apiClient APIClient, errOut io.Writer) (net.Conn, error) {
|
||||
if errOut == nil {
|
||||
errOut = os.Stderr //nolint:forbidigo // library-caller fallback
|
||||
}
|
||||
addr := tr.Address(appID)
|
||||
|
||||
if conn, err := probeAndDialBus(tr, addr); err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
fmt.Fprintf(errOut, "[event] local bus not found; checking remote connections...\n")
|
||||
|
||||
if apiClient != nil {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
count, checkErr := CheckRemoteConnections(ctx, apiClient)
|
||||
if checkErr != nil {
|
||||
fmt.Fprintf(errOut, "[event] remote connection check failed: %v (proceeding to start local bus)\n", checkErr)
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
|
||||
if count > 0 {
|
||||
return nil, fmt.Errorf("another event bus is already connected to this app "+
|
||||
"(%d active connection(s) detected via API).\n"+
|
||||
"Only one bus should run globally to avoid duplicate event delivery.\n"+
|
||||
"Use 'lark-cli event status' to check, or 'lark-cli event stop' on the other machine first", count)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] no API client supplied; skipping remote connection check\n")
|
||||
}
|
||||
|
||||
// ErrHeld = another consume is forking; let dial retry catch its bus.
|
||||
pid, forkErr := forkBus(tr, appID, profileName, domain)
|
||||
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
|
||||
eventsRoot := filepath.Join(core.GetConfigDir(), "events")
|
||||
return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
|
||||
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
|
||||
}
|
||||
if pid > 0 {
|
||||
announceForkedBus(errOut, pid)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(dialTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(dialRetryInterval):
|
||||
}
|
||||
if conn, err := tr.Dial(addr); err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
logPath := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(appID), "bus.log")
|
||||
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
|
||||
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
|
||||
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
|
||||
return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
|
||||
}
|
||||
|
||||
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.
|
||||
func probeAndDialBus(tr transport.IPC, addr string) (net.Conn, error) {
|
||||
probe, err := tr.Dial(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
probe.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
if err := protocol.Encode(probe, protocol.NewStatusQuery()); err != nil {
|
||||
probe.Close()
|
||||
return nil, fmt.Errorf("bus probe: encode: %w", err)
|
||||
}
|
||||
br := bufio.NewReader(probe)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
probe.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bus probe: read status: %w", err)
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bus probe: decode status: %w", err)
|
||||
}
|
||||
if _, ok := msg.(*protocol.StatusResponse); !ok {
|
||||
return nil, fmt.Errorf("bus probe: expected StatusResponse, got %T", msg)
|
||||
}
|
||||
|
||||
return tr.Dial(addr)
|
||||
}
|
||||
|
||||
// forkBus holds bus.fork.lock until the spawned daemon is dial-able, so concurrent callers can't race past the empty-socket gap and fork independent buses.
|
||||
func forkBus(tr transport.IPC, appID, profileName, domain string) (int, error) {
|
||||
lockPath := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(appID), "bus.fork.lock")
|
||||
if err := vfs.MkdirAll(filepath.Dir(lockPath), 0700); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
lock := lockfile.New(lockPath)
|
||||
if err := lock.TryLock(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer lock.Unlock()
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
args := buildForkArgs(profileName, domain)
|
||||
cmd := exec.Command(exe, args...)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
applyDetachAttrs(cmd)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
addr := tr.Address(appID)
|
||||
deadline := time.Now().Add(dialTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if conn, dialErr := tr.Dial(addr); dialErr == nil {
|
||||
conn.Close()
|
||||
return cmd.Process.Pid, nil
|
||||
}
|
||||
time.Sleep(dialRetryInterval)
|
||||
}
|
||||
return cmd.Process.Pid, fmt.Errorf("bus did not become ready within %v", dialTimeout)
|
||||
}
|
||||
|
||||
func buildForkArgs(profileName, domain string) []string {
|
||||
args := []string{"event", "_bus", "--profile", profileName}
|
||||
if domain != "" {
|
||||
args = append(args, "--domain", domain)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// announceForkedBus: "auto-exits 30s" must track bus.idleTimeout.
|
||||
func announceForkedBus(w io.Writer, pid int) {
|
||||
fmt.Fprintf(w, "[event] started bus daemon pid=%d (auto-exits 30s after last consumer)\n", pid)
|
||||
}
|
||||
26
internal/event/consume/startup_announce_test.go
Normal file
26
internal/event/consume/startup_announce_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnnounceForkedBus(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
announceForkedBus(&buf, 12345)
|
||||
|
||||
got := buf.String()
|
||||
for _, want := range []string{
|
||||
"[event] started bus daemon",
|
||||
"pid=12345",
|
||||
"auto-exits 30s after last consumer",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
64
internal/event/consume/startup_fork_test.go
Normal file
64
internal/event/consume/startup_fork_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Fork argv ("event", "_bus", "--profile", appID) is a contract with internal/event/busdiscover orphan detector.
|
||||
func TestBuildForkArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
profile string
|
||||
domain string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no domain (lark default)",
|
||||
profile: "cli_XXXXXXXXXXXXXXXX",
|
||||
domain: "",
|
||||
want: []string{"event", "_bus", "--profile", "cli_XXXXXXXXXXXXXXXX"},
|
||||
},
|
||||
{
|
||||
name: "custom domain appended",
|
||||
profile: "cli_x",
|
||||
domain: "https://open.feishu.cn",
|
||||
want: []string{
|
||||
"event", "_bus",
|
||||
"--profile", "cli_x",
|
||||
"--domain", "https://open.feishu.cn",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty profile still keeps flag skeleton",
|
||||
profile: "",
|
||||
domain: "",
|
||||
want: []string{"event", "_bus", "--profile", ""},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := buildForkArgs(tc.profile, tc.domain)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("buildForkArgs(%q, %q) = %v, want %v", tc.profile, tc.domain, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForkArgs_SubcommandStable(t *testing.T) {
|
||||
got := buildForkArgs("cli_x", "")
|
||||
if len(got) < 2 || got[0] != "event" || got[1] != "_bus" {
|
||||
t.Fatalf("argv[0:2] = %v, want [event _bus]", got[:min(2, len(got))])
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
146
internal/event/consume/startup_probe_test.go
Normal file
146
internal/event/consume/startup_probe_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
type probeMockTransport struct {
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
addr string
|
||||
|
||||
wg sync.WaitGroup
|
||||
conns []net.Conn
|
||||
}
|
||||
|
||||
func newProbeMockTransport(t *testing.T) *probeMockTransport {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
return &probeMockTransport{listener: ln, addr: ln.Addr().String()}
|
||||
}
|
||||
|
||||
func (m *probeMockTransport) Listen(addr string) (net.Listener, error) {
|
||||
return m.listener, nil
|
||||
}
|
||||
|
||||
func (m *probeMockTransport) Dial(addr string) (net.Conn, error) {
|
||||
return net.Dial("tcp", m.addr)
|
||||
}
|
||||
|
||||
func (m *probeMockTransport) Address(appID string) string { return m.addr }
|
||||
func (m *probeMockTransport) Cleanup(addr string) {}
|
||||
|
||||
func (m *probeMockTransport) trackConn(c net.Conn) {
|
||||
m.mu.Lock()
|
||||
m.conns = append(m.conns, c)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *probeMockTransport) stop() {
|
||||
m.mu.Lock()
|
||||
_ = m.listener.Close()
|
||||
conns := append([]net.Conn(nil), m.conns...)
|
||||
m.conns = nil
|
||||
m.mu.Unlock()
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
m.wg.Wait()
|
||||
}
|
||||
|
||||
func runHealthyBus(t *testing.T, m *probeMockTransport) {
|
||||
t.Helper()
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
probeConn, err := m.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.trackConn(probeConn)
|
||||
br := bufio.NewReader(probeConn)
|
||||
line, _ := br.ReadBytes('\n')
|
||||
msg, _ := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if _, ok := msg.(*protocol.StatusQuery); ok {
|
||||
_ = protocol.Encode(probeConn, protocol.NewStatusResponse(12345, 10, 0, nil))
|
||||
}
|
||||
_ = probeConn.Close()
|
||||
|
||||
realConn, err := m.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.trackConn(realConn)
|
||||
}()
|
||||
}
|
||||
|
||||
func runDeadBus(t *testing.T, m *probeMockTransport) {
|
||||
t.Helper()
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
for {
|
||||
conn, err := m.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.trackConn(conn)
|
||||
m.wg.Add(1)
|
||||
go func(c net.Conn) {
|
||||
defer m.wg.Done()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
if _, err := c.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func TestProbeAndDialBusHealthy(t *testing.T) {
|
||||
m := newProbeMockTransport(t)
|
||||
t.Cleanup(m.stop)
|
||||
runHealthyBus(t, m)
|
||||
|
||||
conn, err := probeAndDialBus(m, m.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("expected non-nil conn")
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func TestProbeAndDialBusUnresponsive(t *testing.T) {
|
||||
m := newProbeMockTransport(t)
|
||||
t.Cleanup(m.stop)
|
||||
runDeadBus(t, m)
|
||||
|
||||
start := time.Now()
|
||||
conn, err := probeAndDialBus(m, m.addr)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("expected error on unresponsive bus")
|
||||
}
|
||||
if elapsed > 3*time.Second {
|
||||
t.Errorf("expected ~2s timeout, got %v", elapsed)
|
||||
}
|
||||
}
|
||||
16
internal/event/consume/startup_unix.go
Normal file
16
internal/event/consume/startup_unix.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// applyDetachAttrs: Setsid prevents SIGHUP-on-shell-exit from killing the bus.
|
||||
func applyDetachAttrs(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
21
internal/event/consume/startup_windows.go
Normal file
21
internal/event/consume/startup_windows.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// applyDetachAttrs: Windows daemonize via DETACHED_PROCESS + new process group + HideWindow.
|
||||
func applyDetachAttrs(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP,
|
||||
}
|
||||
}
|
||||
71
internal/event/dedup.go
Normal file
71
internal/event/dedup.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDedupTTL = 5 * time.Minute
|
||||
defaultRingSize = 10000
|
||||
)
|
||||
|
||||
// DedupFilter: seen map is sole authority; ring only bounds map size via overflow eviction.
|
||||
type DedupFilter struct {
|
||||
seen map[string]time.Time
|
||||
ring []string
|
||||
pos int
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewDedupFilter() *DedupFilter {
|
||||
return NewDedupFilterWithSize(defaultRingSize, defaultDedupTTL)
|
||||
}
|
||||
|
||||
func NewDedupFilterWithSize(ringSize int, ttl time.Duration) *DedupFilter {
|
||||
return &DedupFilter{
|
||||
seen: make(map[string]time.Time),
|
||||
ring: make([]string, ringSize),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DedupFilter) IsDuplicate(eventID string) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if ts, ok := d.seen[eventID]; ok {
|
||||
if now.Sub(ts) < d.ttl {
|
||||
return true
|
||||
}
|
||||
delete(d.seen, eventID)
|
||||
}
|
||||
|
||||
d.seen[eventID] = now
|
||||
|
||||
if old := d.ring[d.pos]; old != "" && old != eventID {
|
||||
delete(d.seen, old)
|
||||
}
|
||||
d.ring[d.pos] = eventID
|
||||
d.pos = (d.pos + 1) % len(d.ring)
|
||||
|
||||
if d.pos%1000 == 0 {
|
||||
d.cleanupExpired(now)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *DedupFilter) cleanupExpired(now time.Time) {
|
||||
for id, ts := range d.seen {
|
||||
if now.Sub(ts) >= d.ttl {
|
||||
delete(d.seen, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
160
internal/event/dedup_test.go
Normal file
160
internal/event/dedup_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDedupFilter_FirstSeen(t *testing.T) {
|
||||
d := NewDedupFilter()
|
||||
if d.IsDuplicate("evt-1") {
|
||||
t.Error("first occurrence should not be duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupFilter_SecondSeen(t *testing.T) {
|
||||
d := NewDedupFilter()
|
||||
d.IsDuplicate("evt-1")
|
||||
if !d.IsDuplicate("evt-1") {
|
||||
t.Error("second occurrence within TTL should be duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupFilter_TTLExpiry(t *testing.T) {
|
||||
d := NewDedupFilterWithSize(defaultRingSize, 10*time.Millisecond)
|
||||
d.IsDuplicate("evt-1")
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if d.IsDuplicate("evt-1") {
|
||||
t.Error("should not be duplicate after TTL expires")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupFilter_RingBuffer(t *testing.T) {
|
||||
d := NewDedupFilterWithSize(5, 10*time.Millisecond)
|
||||
for i := 0; i < 5; i++ {
|
||||
d.IsDuplicate("evt-" + string(rune('a'+i)))
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if !d.IsDuplicate("evt-" + string(rune('a'+i))) {
|
||||
t.Errorf("evt-%c should still be duplicate", rune('a'+i))
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
for i := 5; i < 10; i++ {
|
||||
d.IsDuplicate("evt-" + string(rune('a'+i)))
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if d.IsDuplicate("evt-" + string(rune('a'+i))) {
|
||||
t.Errorf("evt-%c should not be duplicate after ring eviction + TTL expiry", rune('a'+i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupFilter_ConcurrentSafe(t *testing.T) {
|
||||
d := NewDedupFilter()
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < 100; i++ {
|
||||
go func(id string) {
|
||||
d.IsDuplicate(id)
|
||||
done <- struct{}{}
|
||||
}("evt-" + string(rune(i)))
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// Under N concurrent writers, exactly N IsDuplicate calls must observe first-seen.
|
||||
func TestDedupFilter_ConcurrentFirstSeenExactlyOnce(t *testing.T) {
|
||||
const n = 200
|
||||
d := NewDedupFilter()
|
||||
|
||||
ids := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
ids[i] = "evt-unique-" + string(rune('A'+i%26)) + string(rune('a'+(i/26)%26)) + string(rune('0'+i%10))
|
||||
}
|
||||
|
||||
results := make(chan bool, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func(id string) {
|
||||
results <- d.IsDuplicate(id)
|
||||
}(ids[i])
|
||||
}
|
||||
|
||||
firstSeen := 0
|
||||
for i := 0; i < n; i++ {
|
||||
if !<-results {
|
||||
firstSeen++
|
||||
}
|
||||
}
|
||||
if firstSeen != n {
|
||||
t.Errorf("first-seen count = %d, want %d", firstSeen, n)
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
if !d.IsDuplicate(id) {
|
||||
t.Errorf("ID %q not flagged as duplicate on second call", id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reinserting an ID that already occupies its own ring slot must not delete the fresh seen entry.
|
||||
func TestDedupFilter_SelfEvictionPreservesFreshEntry(t *testing.T) {
|
||||
d := NewDedupFilterWithSize(2, time.Hour)
|
||||
d.ring[0] = "X"
|
||||
d.pos = 0
|
||||
|
||||
if d.IsDuplicate("X") {
|
||||
t.Fatal("first call should not be duplicate (seen empty)")
|
||||
}
|
||||
if !d.IsDuplicate("X") {
|
||||
t.Error("self-slot reinsert wiped seen[X] — duplicate signal lost")
|
||||
}
|
||||
}
|
||||
|
||||
// After cleanupExpired, an ID past its TTL must not be reported as duplicate even if still in the ring.
|
||||
func TestDedupFilter_TTLExpiryAfterCleanupRunRespected(t *testing.T) {
|
||||
d := NewDedupFilterWithSize(10, 10*time.Millisecond)
|
||||
if d.IsDuplicate("A") {
|
||||
t.Fatal("first IsDuplicate(A) should be false")
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
for i := 0; i < 9; i++ {
|
||||
d.IsDuplicate("f" + string(rune('0'+i)))
|
||||
}
|
||||
if d.IsDuplicate("A") {
|
||||
t.Error("A is past TTL — must NOT be reported as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupFilter_ConcurrentRingEviction(t *testing.T) {
|
||||
const ringSize = 16
|
||||
const writers = 8
|
||||
const perWriter = 40
|
||||
d := NewDedupFilterWithSize(ringSize, 5*time.Millisecond)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(writers)
|
||||
for w := 0; w < writers; w++ {
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perWriter; i++ {
|
||||
d.IsDuplicate("evt-w" + string(rune('0'+w)) + "-" + string(rune('0'+i%10)) + string(rune('a'+i/10)))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
for i := 0; i < ringSize*4; i++ {
|
||||
d.IsDuplicate("evt-fill-" + string(rune('0'+i%10)) + string(rune('a'+i/10)))
|
||||
}
|
||||
if d.IsDuplicate("evt-w0-0a") {
|
||||
t.Error("evicted ID should not be reported as duplicate")
|
||||
}
|
||||
}
|
||||
352
internal/event/integration_test.go
Normal file
352
internal/event/integration_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package event_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/bus"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/source"
|
||||
"github.com/larksuite/cli/internal/event/testutil"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
type integTestOut struct{ A string }
|
||||
|
||||
func integNativeSchema() event.SchemaDef {
|
||||
return event.SchemaDef{Native: &event.SchemaSpec{Type: reflect.TypeOf(integTestOut{})}}
|
||||
}
|
||||
|
||||
func waitForBusReady(t *testing.T, tr transport.IPC, addr string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if conn, err := tr.Dial(addr); err == nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("bus at %s did not come up within 2s", addr)
|
||||
}
|
||||
|
||||
func runBus(t *testing.T, b *bus.Bus, ctx context.Context) {
|
||||
t.Helper()
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- b.Run(ctx) }()
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("bus.Run returned unexpected error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Log("bus did not exit within 2s of test cleanup (non-fatal)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type mockIntegSource struct {
|
||||
mu sync.Mutex
|
||||
emitFn func(*event.RawEvent)
|
||||
}
|
||||
|
||||
func (s *mockIntegSource) Name() string { return "mock-integration" }
|
||||
|
||||
func (s *mockIntegSource) Start(ctx context.Context, _ []string, emit func(*event.RawEvent), _ source.StatusNotifier) error {
|
||||
s.mu.Lock()
|
||||
s.emitFn = emit
|
||||
s.mu.Unlock()
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockIntegSource) emit(e *event.RawEvent) {
|
||||
s.mu.Lock()
|
||||
fn := s.emitFn
|
||||
s.mu.Unlock()
|
||||
if fn != nil {
|
||||
fn(e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_BusToConsume(t *testing.T) {
|
||||
event.ResetRegistryForTest()
|
||||
source.ResetForTest()
|
||||
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "test.event.v1",
|
||||
EventType: "test.event.v1",
|
||||
Schema: integNativeSchema(),
|
||||
})
|
||||
|
||||
mockSrc := &mockIntegSource{}
|
||||
source.Register(mockSrc)
|
||||
|
||||
dir := t.TempDir()
|
||||
addr := filepath.Join(dir, "t.sock")
|
||||
|
||||
tr := transport.New()
|
||||
logger := log.New(os.Stderr, "[test-bus] ", log.LstdFlags)
|
||||
|
||||
testTr := testutil.NewWrappedFake(tr, addr)
|
||||
b := bus.NewBus("test-app", "test-secret", "", testTr, logger)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
runBus(t, b, ctx)
|
||||
waitForBusReady(t, testTr, addr)
|
||||
|
||||
conn, err := testTr.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
hello := &protocol.Hello{
|
||||
Type: protocol.MsgTypeHello,
|
||||
PID: os.Getpid(),
|
||||
EventKey: "test.event.v1",
|
||||
EventTypes: []string{"test.event.v1"},
|
||||
Version: "v1",
|
||||
}
|
||||
if err := protocol.Encode(conn, hello); err != nil {
|
||||
t.Fatalf("encode hello: %v", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if !scanner.Scan() {
|
||||
t.Fatal("no hello_ack received")
|
||||
}
|
||||
msg, err := protocol.Decode(scanner.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode hello_ack: %v", err)
|
||||
}
|
||||
ack, ok := msg.(*protocol.HelloAck)
|
||||
if !ok {
|
||||
t.Fatalf("expected HelloAck, got %T", msg)
|
||||
}
|
||||
if !ack.FirstForKey {
|
||||
t.Error("expected first_for_key to be true")
|
||||
}
|
||||
|
||||
mockSrc.emit(&event.RawEvent{
|
||||
EventID: "evt-integration-1",
|
||||
EventType: "test.event.v1",
|
||||
Payload: json.RawMessage(`{"test": true}`),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if !scanner.Scan() {
|
||||
t.Fatal("no event received")
|
||||
}
|
||||
evtMsg, err := protocol.Decode(scanner.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode event: %v", err)
|
||||
}
|
||||
evt, ok := evtMsg.(*protocol.Event)
|
||||
if !ok {
|
||||
t.Fatalf("expected Event, got %T", evtMsg)
|
||||
}
|
||||
if evt.EventType != "test.event.v1" {
|
||||
t.Errorf("expected event_type %q, got %q", "test.event.v1", evt.EventType)
|
||||
}
|
||||
var payloadMap map[string]interface{}
|
||||
if err := json.Unmarshal(evt.Payload, &payloadMap); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if v, ok := payloadMap["test"]; !ok || v != true {
|
||||
t.Errorf("unexpected payload: %s", string(evt.Payload))
|
||||
}
|
||||
|
||||
conn.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
func TestIntegration_MultipleConsumers(t *testing.T) {
|
||||
event.ResetRegistryForTest()
|
||||
source.ResetForTest()
|
||||
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "multi.event.v1",
|
||||
EventType: "multi.event.v1",
|
||||
Schema: integNativeSchema(),
|
||||
})
|
||||
|
||||
mockSrc := &mockIntegSource{}
|
||||
source.Register(mockSrc)
|
||||
|
||||
dir := t.TempDir()
|
||||
addr := filepath.Join(dir, "m.sock")
|
||||
tr := transport.New()
|
||||
logger := log.New(os.Stderr, "[test-multi] ", log.LstdFlags)
|
||||
|
||||
testTr := testutil.NewWrappedFake(tr, addr)
|
||||
b := bus.NewBus("test-multi", "test-secret", "", testTr, logger)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
runBus(t, b, ctx)
|
||||
waitForBusReady(t, testTr, addr)
|
||||
|
||||
connectConsumer := func(name string) (net.Conn, *bufio.Scanner) {
|
||||
conn, err := testTr.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial %s: %v", name, err)
|
||||
}
|
||||
hello := &protocol.Hello{
|
||||
Type: protocol.MsgTypeHello,
|
||||
PID: os.Getpid(),
|
||||
EventKey: "multi.event.v1",
|
||||
EventTypes: []string{"multi.event.v1"},
|
||||
Version: "v1",
|
||||
}
|
||||
protocol.Encode(conn, hello)
|
||||
sc := bufio.NewScanner(conn)
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if !sc.Scan() {
|
||||
t.Fatalf("%s: no hello_ack", name)
|
||||
}
|
||||
msg, _ := protocol.Decode(sc.Bytes())
|
||||
if _, ok := msg.(*protocol.HelloAck); !ok {
|
||||
t.Fatalf("%s: expected HelloAck, got %T", name, msg)
|
||||
}
|
||||
return conn, sc
|
||||
}
|
||||
|
||||
conn1, sc1 := connectConsumer("consumer-1")
|
||||
defer conn1.Close()
|
||||
conn2, sc2 := connectConsumer("consumer-2")
|
||||
defer conn2.Close()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
mockSrc.emit(&event.RawEvent{
|
||||
EventID: "evt-multi-1",
|
||||
EventType: "multi.event.v1",
|
||||
Payload: json.RawMessage(`{"fan":"out"}`),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
conn net.Conn
|
||||
sc *bufio.Scanner
|
||||
}{
|
||||
{"consumer-1", conn1, sc1},
|
||||
{"consumer-2", conn2, sc2},
|
||||
} {
|
||||
tc.conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if !tc.sc.Scan() {
|
||||
t.Fatalf("%s: no event received", tc.name)
|
||||
}
|
||||
evtMsg, err := protocol.Decode(tc.sc.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("%s: decode event: %v", tc.name, err)
|
||||
}
|
||||
evt, ok := evtMsg.(*protocol.Event)
|
||||
if !ok {
|
||||
t.Fatalf("%s: expected Event, got %T", tc.name, evtMsg)
|
||||
}
|
||||
if evt.EventType != "multi.event.v1" {
|
||||
t.Errorf("%s: expected event_type %q, got %q", tc.name, "multi.event.v1", evt.EventType)
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
func TestIntegration_DedupFilter(t *testing.T) {
|
||||
event.ResetRegistryForTest()
|
||||
source.ResetForTest()
|
||||
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "dedup.event.v1",
|
||||
EventType: "dedup.event.v1",
|
||||
Schema: integNativeSchema(),
|
||||
})
|
||||
|
||||
mockSrc := &mockIntegSource{}
|
||||
source.Register(mockSrc)
|
||||
|
||||
dir := t.TempDir()
|
||||
addr := filepath.Join(dir, "d.sock")
|
||||
tr := transport.New()
|
||||
logger := log.New(os.Stderr, "[test-dedup] ", log.LstdFlags)
|
||||
|
||||
testTr := testutil.NewWrappedFake(tr, addr)
|
||||
b := bus.NewBus("test-dedup", "test-secret", "", testTr, logger)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
runBus(t, b, ctx)
|
||||
waitForBusReady(t, testTr, addr)
|
||||
|
||||
conn, err := testTr.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
hello := &protocol.Hello{
|
||||
Type: protocol.MsgTypeHello,
|
||||
PID: os.Getpid(),
|
||||
EventKey: "dedup.event.v1",
|
||||
EventTypes: []string{"dedup.event.v1"},
|
||||
Version: "v1",
|
||||
}
|
||||
protocol.Encode(conn, hello)
|
||||
sc := bufio.NewScanner(conn)
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if !sc.Scan() {
|
||||
t.Fatal("no hello_ack")
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
mockSrc.emit(&event.RawEvent{
|
||||
EventID: "evt-dedup-same",
|
||||
EventType: "dedup.event.v1",
|
||||
Payload: json.RawMessage(`{"dup": true}`),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
if !sc.Scan() {
|
||||
t.Fatal("expected at least one event")
|
||||
}
|
||||
evtMsg, _ := protocol.Decode(sc.Bytes())
|
||||
if _, ok := evtMsg.(*protocol.Event); !ok {
|
||||
t.Fatalf("expected Event, got %T", evtMsg)
|
||||
}
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
if sc.Scan() {
|
||||
t.Error("received duplicate event; dedup filter should have blocked it")
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
106
internal/event/protocol/codec.go
Normal file
106
internal/event/protocol/codec.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package protocol defines the newline-delimited JSON wire format used over IPC.
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MaxFrameBytes = 1 << 20 // reject larger frames to bound reader buffer growth
|
||||
|
||||
// ErrFrameTooLarge is returned by ReadFrame when a single frame exceeds MaxFrameBytes.
|
||||
var ErrFrameTooLarge = errors.New("protocol: frame exceeds MaxFrameBytes")
|
||||
|
||||
const WriteTimeout = 5 * time.Second // bound writes against wedged peer kernel buffer
|
||||
|
||||
type typeEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func Encode(w io.Writer, msg interface{}) error {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("protocol encode: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func EncodeWithDeadline(conn net.Conn, msg interface{}, timeout time.Duration) error {
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
return Encode(conn, msg)
|
||||
}
|
||||
|
||||
// ReadFrame reads one newline-delimited message; caps at MaxFrameBytes to defang slowloris.
|
||||
func ReadFrame(br *bufio.Reader) ([]byte, error) {
|
||||
var buf []byte
|
||||
for {
|
||||
chunk, err := br.ReadSlice('\n')
|
||||
switch err {
|
||||
case nil:
|
||||
if len(buf) == 0 {
|
||||
return chunk, nil
|
||||
}
|
||||
if len(buf)+len(chunk) > MaxFrameBytes {
|
||||
return nil, ErrFrameTooLarge
|
||||
}
|
||||
return append(buf, chunk...), nil
|
||||
case bufio.ErrBufferFull:
|
||||
if len(buf)+len(chunk) > MaxFrameBytes {
|
||||
return nil, ErrFrameTooLarge
|
||||
}
|
||||
buf = append(buf, chunk...)
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Decode(line []byte) (interface{}, error) {
|
||||
var env typeEnvelope
|
||||
if err := json.Unmarshal(line, &env); err != nil {
|
||||
return nil, fmt.Errorf("protocol decode type: %w", err)
|
||||
}
|
||||
|
||||
var msg interface{}
|
||||
switch env.Type {
|
||||
case MsgTypeHello:
|
||||
msg = &Hello{}
|
||||
case MsgTypeHelloAck:
|
||||
msg = &HelloAck{}
|
||||
case MsgTypeEvent:
|
||||
msg = &Event{}
|
||||
case MsgTypeBye:
|
||||
msg = &Bye{}
|
||||
case MsgTypePreShutdownCheck:
|
||||
msg = &PreShutdownCheck{}
|
||||
case MsgTypePreShutdownAck:
|
||||
msg = &PreShutdownAck{}
|
||||
case MsgTypeStatusQuery:
|
||||
msg = &StatusQuery{}
|
||||
case MsgTypeStatusResponse:
|
||||
msg = &StatusResponse{}
|
||||
case MsgTypeShutdown:
|
||||
msg = &Shutdown{}
|
||||
case MsgTypeSourceStatus:
|
||||
msg = &SourceStatus{}
|
||||
default:
|
||||
return nil, fmt.Errorf("protocol: unknown message type %q", env.Type)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(line, msg); err != nil {
|
||||
return nil, fmt.Errorf("protocol decode %s: %w", env.Type, err)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
79
internal/event/protocol/codec_test.go
Normal file
79
internal/event/protocol/codec_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodeDecodeHello(t *testing.T) {
|
||||
msg := &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: 12345,
|
||||
EventKey: "mail.user_mailbox.event.message_received_v1",
|
||||
EventTypes: []string{"mail.user_mailbox.event.message_received_v1"},
|
||||
Version: "v1",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := Encode(&buf, msg); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := Decode(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
hello, ok := decoded.(*Hello)
|
||||
if !ok {
|
||||
t.Fatalf("expected *Hello, got %T", decoded)
|
||||
}
|
||||
if hello.PID != 12345 || hello.EventKey != "mail.user_mailbox.event.message_received_v1" {
|
||||
t.Errorf("unexpected hello: %+v", hello)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeEvent(t *testing.T) {
|
||||
payload := json.RawMessage(`{"foo":"bar"}`)
|
||||
msg := &Event{
|
||||
Type: MsgTypeEvent,
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := Encode(&buf, msg); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := Decode(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
evt, ok := decoded.(*Event)
|
||||
if !ok {
|
||||
t.Fatalf("expected *Event, got %T", decoded)
|
||||
}
|
||||
if evt.EventType != "im.message.receive_v1" {
|
||||
t.Errorf("got event_type %q", evt.EventType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeAddsNewline(t *testing.T) {
|
||||
msg := &Bye{Type: MsgTypeBye}
|
||||
var buf bytes.Buffer
|
||||
Encode(&buf, msg)
|
||||
if buf.Bytes()[buf.Len()-1] != '\n' {
|
||||
t.Error("encoded message should end with newline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeUnknownType(t *testing.T) {
|
||||
_, err := Decode([]byte(`{"type":"unknown_xyz"}`))
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown type")
|
||||
}
|
||||
}
|
||||
158
internal/event/protocol/messages.go
Normal file
158
internal/event/protocol/messages.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
MsgTypeHello = "hello"
|
||||
MsgTypeHelloAck = "hello_ack"
|
||||
MsgTypeEvent = "event"
|
||||
MsgTypeBye = "bye"
|
||||
MsgTypePreShutdownCheck = "pre_shutdown_check"
|
||||
MsgTypePreShutdownAck = "pre_shutdown_ack"
|
||||
MsgTypeStatusQuery = "status_query"
|
||||
MsgTypeStatusResponse = "status_response"
|
||||
MsgTypeShutdown = "shutdown"
|
||||
MsgTypeSourceStatus = "source_status"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceStateConnecting = "connecting"
|
||||
SourceStateConnected = "connected"
|
||||
SourceStateDisconnected = "disconnected"
|
||||
SourceStateReconnecting = "reconnecting"
|
||||
)
|
||||
|
||||
// SourceStatus is best-effort: hub drops it when consumer's send channel is full.
|
||||
type SourceStatus struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
State string `json:"state"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type Hello struct {
|
||||
Type string `json:"type"`
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
EventTypes []string `json:"event_types"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type HelloAck struct {
|
||||
Type string `json:"type"`
|
||||
BusVersion string `json:"bus_version"`
|
||||
FirstForKey bool `json:"first_for_key"`
|
||||
}
|
||||
|
||||
// Event: Seq is per-conn monotonic; gaps signal bus drop-oldest backpressure loss.
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
EventType string `json:"event_type"`
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
SourceTime string `json:"source_time,omitempty"` // ms-precision unix timestamp, stringified
|
||||
Seq uint64 `json:"seq,omitempty"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type Bye struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// PreShutdownCheck atomically reserves the cleanup lock for EventKey.
|
||||
type PreShutdownCheck struct {
|
||||
Type string `json:"type"`
|
||||
EventKey string `json:"event_key"`
|
||||
}
|
||||
|
||||
type PreShutdownAck struct {
|
||||
Type string `json:"type"`
|
||||
LastForKey bool `json:"last_for_key"`
|
||||
}
|
||||
|
||||
type StatusQuery struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type ConsumerInfo struct {
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
Received int64 `json:"received"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
Type string `json:"type"`
|
||||
PID int `json:"pid"`
|
||||
UptimeSec int `json:"uptime_sec"`
|
||||
ActiveConns int `json:"active_conns"`
|
||||
Consumers []ConsumerInfo `json:"consumers"`
|
||||
}
|
||||
|
||||
type Shutdown struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func NewHello(pid int, eventKey string, eventTypes []string, version string) *Hello {
|
||||
return &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: pid,
|
||||
EventKey: eventKey,
|
||||
EventTypes: eventTypes,
|
||||
Version: version,
|
||||
}
|
||||
}
|
||||
|
||||
func NewHelloAck(busVersion string, firstForKey bool) *HelloAck {
|
||||
return &HelloAck{
|
||||
Type: MsgTypeHelloAck,
|
||||
BusVersion: busVersion,
|
||||
FirstForKey: firstForKey,
|
||||
}
|
||||
}
|
||||
|
||||
func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.RawMessage) *Event {
|
||||
return &Event{
|
||||
Type: MsgTypeEvent,
|
||||
EventType: eventType,
|
||||
EventID: eventID,
|
||||
SourceTime: sourceTime,
|
||||
Seq: seq,
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPreShutdownCheck(eventKey string) *PreShutdownCheck {
|
||||
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey}
|
||||
}
|
||||
|
||||
func NewPreShutdownAck(lastForKey bool) *PreShutdownAck {
|
||||
return &PreShutdownAck{Type: MsgTypePreShutdownAck, LastForKey: lastForKey}
|
||||
}
|
||||
|
||||
func NewStatusQuery() *StatusQuery {
|
||||
return &StatusQuery{Type: MsgTypeStatusQuery}
|
||||
}
|
||||
|
||||
func NewStatusResponse(pid int, uptimeSec int, activeConns int, consumers []ConsumerInfo) *StatusResponse {
|
||||
return &StatusResponse{
|
||||
Type: MsgTypeStatusResponse,
|
||||
PID: pid,
|
||||
UptimeSec: uptimeSec,
|
||||
ActiveConns: activeConns,
|
||||
Consumers: consumers,
|
||||
}
|
||||
}
|
||||
|
||||
func NewShutdown() *Shutdown { return &Shutdown{Type: MsgTypeShutdown} }
|
||||
|
||||
func NewSourceStatus(source, state, detail string) *SourceStatus {
|
||||
return &SourceStatus{
|
||||
Type: MsgTypeSourceStatus,
|
||||
Source: source,
|
||||
State: state,
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
107
internal/event/protocol/messages_test.go
Normal file
107
internal/event/protocol/messages_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Every NewXxx helper must set the Type discriminator (Decode rejects messages without it).
|
||||
func TestConstructors_PinTypeField(t *testing.T) {
|
||||
if got := NewHello(1, "k", []string{"t"}, "v1"); got.Type != MsgTypeHello {
|
||||
t.Errorf("NewHello.Type = %q, want %q", got.Type, MsgTypeHello)
|
||||
}
|
||||
if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey {
|
||||
t.Errorf("NewHelloAck mismatch: %+v", got)
|
||||
}
|
||||
if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 {
|
||||
t.Errorf("NewEvent mismatch: %+v", got)
|
||||
}
|
||||
if got := NewPreShutdownCheck("k"); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
|
||||
t.Errorf("NewPreShutdownCheck mismatch: %+v", got)
|
||||
}
|
||||
if got := NewPreShutdownAck(true); got.Type != MsgTypePreShutdownAck || !got.LastForKey {
|
||||
t.Errorf("NewPreShutdownAck mismatch: %+v", got)
|
||||
}
|
||||
if got := NewStatusQuery(); got.Type != MsgTypeStatusQuery {
|
||||
t.Errorf("NewStatusQuery.Type = %q", got.Type)
|
||||
}
|
||||
if got := NewStatusResponse(42, 10, 2, []ConsumerInfo{{PID: 1}, {PID: 2}}); got.Type != MsgTypeStatusResponse || got.PID != 42 || len(got.Consumers) != 2 {
|
||||
t.Errorf("NewStatusResponse mismatch: %+v", got)
|
||||
}
|
||||
if got := NewShutdown(); got.Type != MsgTypeShutdown {
|
||||
t.Errorf("NewShutdown.Type = %q", got.Type)
|
||||
}
|
||||
if got := NewSourceStatus("feishu-ws", SourceStateConnected, "ok"); got.Type != MsgTypeSourceStatus || got.Detail != "ok" {
|
||||
t.Errorf("NewSourceStatus mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncode_DecodeRoundtripAllTypes(t *testing.T) {
|
||||
roundtrip := func(t *testing.T, msg interface{}, want interface{}) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := Encode(&buf, msg); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
line := bytes.TrimRight(buf.Bytes(), "\n")
|
||||
got, err := Decode(line)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if gotT, wantT := fmt.Sprintf("%T", got), fmt.Sprintf("%T", want); gotT != wantT {
|
||||
t.Errorf("decoded type = %s, want %s", gotT, wantT)
|
||||
}
|
||||
}
|
||||
roundtrip(t, NewHelloAck("v1", true), &HelloAck{})
|
||||
roundtrip(t, NewPreShutdownCheck("im.msg"), &PreShutdownCheck{})
|
||||
roundtrip(t, NewPreShutdownAck(false), &PreShutdownAck{})
|
||||
roundtrip(t, NewStatusQuery(), &StatusQuery{})
|
||||
roundtrip(t, NewStatusResponse(7, 120, 1, []ConsumerInfo{{PID: 99, EventKey: "k"}}), &StatusResponse{})
|
||||
roundtrip(t, NewShutdown(), &Shutdown{})
|
||||
roundtrip(t, NewSourceStatus("feishu", SourceStateReconnecting, "attempt 2"), &SourceStatus{})
|
||||
roundtrip(t, &Bye{Type: MsgTypeBye}, &Bye{})
|
||||
}
|
||||
|
||||
// EncodeWithDeadline must apply a write deadline so a wedged peer can't stall the writer forever.
|
||||
func TestEncodeWithDeadline_AppliesDeadline(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
start := time.Now()
|
||||
err := EncodeWithDeadline(client, NewShutdown(), 100*time.Millisecond)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("expected deadline error, got nil")
|
||||
}
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Errorf("EncodeWithDeadline didn't honour deadline: took %v (want ~100ms)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrame_RejectsOversized(t *testing.T) {
|
||||
big := bytes.Repeat([]byte("a"), MaxFrameBytes+1)
|
||||
big = append(big, '\n')
|
||||
br := bufio.NewReader(bytes.NewReader(big))
|
||||
_, err := ReadFrame(br)
|
||||
if !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("ReadFrame on oversized input: err = %v, want ErrFrameTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrame_PropagatesEOF(t *testing.T) {
|
||||
br := bufio.NewReader(bytes.NewReader(nil))
|
||||
_, err := ReadFrame(br)
|
||||
if err != io.EOF {
|
||||
t.Errorf("err = %v, want io.EOF", err)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user