Compare commits

...

1 Commits

Author SHA1 Message Date
liangshuo-1
55fd9e26e7 feat: add attendance +records shortcut with --detail projection
Collapse the attendance user_tasks/query meta API into a single
`attendance +records` shortcut. It internalises the boilerplate the meta
API forced on callers (employee_type, empty user_ids, need_overtime_result)
and exposes only --from / --to / --detail.

Default output is a compact result-oriented view (date, shift type,
check-in/out result enums). --detail returns the full flattened rows
(punch time, location, scheduled time, group/shift/employee) derived from a
single typed projection. Device-fingerprint fields (ssid/bssid/device_id/
photo_urls) are deliberately excluded; time fields are raw second-level unix
timestamps. Risk is read; user identity only. Updates the lark-attendance
skill with --detail routing.

Change-Id: I375b4c0e5bdf0629881c7c03f00bf0234c52fccf
2026-06-10 13:55:18 +08:00
5 changed files with 747 additions and 40 deletions

View File

@@ -0,0 +1,315 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package attendance
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const userTasksQueryPath = "/open-apis/attendance/v1/user_tasks/query"
type userTasksQueryReq struct {
UserIDs []string `json:"user_ids"` // empty (non-nil) array = self-query
CheckDateFrom int `json:"check_date_from"`
CheckDateTo int `json:"check_date_to"`
NeedOvertimeResult bool `json:"need_overtime_result"` // always true: surface overtime segments via task_shift_type
}
// userFlow is the punch flow nested under check_in_record / check_out_record.
// The device-fingerprint fields (ssid/bssid/device_id/photo_urls/...) are left
// undeclared on purpose so they never reach output — zero agent value, and a
// surveillance/privacy surface.
type userFlow struct {
CheckTime string `json:"check_time"`
LocationName string `json:"location_name"`
}
type shiftRecord struct {
CheckInResult string `json:"check_in_result"`
CheckInResultSupplement string `json:"check_in_result_supplement"`
CheckInShiftTime string `json:"check_in_shift_time"`
CheckInRecord *userFlow `json:"check_in_record"`
CheckInRecordID string `json:"check_in_record_id"`
CheckOutResult string `json:"check_out_result"`
CheckOutResultSupplement string `json:"check_out_result_supplement"`
CheckOutShiftTime string `json:"check_out_shift_time"`
CheckOutRecord *userFlow `json:"check_out_record"`
CheckOutRecordID string `json:"check_out_record_id"`
TaskShiftType int `json:"task_shift_type"`
}
type userTaskResult struct {
EmployeeName string `json:"employee_name"`
UserID string `json:"user_id"`
GroupID string `json:"group_id"`
ShiftID string `json:"shift_id"`
Day int `json:"day"`
Records []shiftRecord `json:"records"`
}
type userTasksQueryData struct {
UserTaskResults []userTaskResult `json:"user_task_results"`
}
type userTasksQueryResp struct {
Data *userTasksQueryData `json:"data"`
}
type punchRow struct {
Date string `json:"date"`
ShiftType string `json:"shift_type"`
CheckIn string `json:"check_in"`
CheckInSupp string `json:"check_in_supplement,omitempty"`
CheckOut string `json:"check_out"`
CheckOutSupp string `json:"check_out_supplement,omitempty"`
}
// punchDetail is the single projection target; the default view derives from it
// via compact(). Time fields are raw second-level unix timestamps — no
// rendering, no timezone arithmetic.
type punchDetail struct {
Date string `json:"date"`
ShiftType string `json:"shift_type"`
EmployeeName string `json:"employee_name,omitempty"`
UserID string `json:"user_id,omitempty"`
GroupID string `json:"group_id,omitempty"`
ShiftID string `json:"shift_id,omitempty"`
CheckIn string `json:"check_in"`
CheckInSupp string `json:"check_in_supplement,omitempty"`
CheckInScheduled int64 `json:"check_in_scheduled_at,omitempty"`
CheckInPunchAt int64 `json:"check_in_punch_at,omitempty"`
CheckInLocation string `json:"check_in_location,omitempty"`
CheckInRecordID string `json:"check_in_record_id,omitempty"`
CheckOut string `json:"check_out"`
CheckOutSupp string `json:"check_out_supplement,omitempty"`
CheckOutScheduled int64 `json:"check_out_scheduled_at,omitempty"`
CheckOutPunchAt int64 `json:"check_out_punch_at,omitempty"`
CheckOutLocation string `json:"check_out_location,omitempty"`
CheckOutRecordID string `json:"check_out_record_id,omitempty"`
}
func (d punchDetail) compact() punchRow {
return punchRow{
Date: d.Date,
ShiftType: d.ShiftType,
CheckIn: d.CheckIn,
CheckInSupp: d.CheckInSupp,
CheckOut: d.CheckOut,
CheckOutSupp: d.CheckOutSupp,
}
}
// parseWorkday converts a strict YYYY-MM-DD date into a yyyyMMdd integer. The
// round-trip guard rejects non-canonical widths (e.g. "2026-6-1") that
// time.Parse would otherwise accept, so the caller and the API mean the same day.
func parseWorkday(s string) (int, error) {
t, err := time.Parse("2006-01-02", s)
if err != nil {
return 0, fmt.Errorf("expected a calendar date in YYYY-MM-DD form, got %q", s)
}
if t.Format("2006-01-02") != s {
return 0, fmt.Errorf("expected a calendar date in YYYY-MM-DD form, got %q", s)
}
return t.Year()*10000 + int(t.Month())*100 + t.Day(), nil
}
func formatWorkday(d int) string {
return fmt.Sprintf("%04d-%02d-%02d", d/10000, (d/100)%100, d%100)
}
func shiftTypeLabel(t int) string {
if t == 1 {
return "overtime"
}
return "normal"
}
// omitNone collapses the API's "None" sentinel to "" so omitempty drops it.
func omitNone(s string) string {
if s == "None" {
return ""
}
return s
}
// atoiTS returns 0 for empty/"None"/unparseable input so omitempty drops the
// field rather than emitting a misleading 0.
func atoiTS(s string) int64 {
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0
}
return n
}
// flowTime / flowLoc tolerate a nil record (the API omits it for shifts that
// never required a punch, e.g. NoNeedCheck).
func flowTime(f *userFlow) int64 {
if f == nil {
return 0
}
return atoiTS(f.CheckTime)
}
func flowLoc(f *userFlow) string {
if f == nil {
return ""
}
return f.LocationName
}
// projectDetails is the single typed boundary: it flattens the nested results
// into one punchDetail per shift segment so nothing downstream touches the raw maps.
func projectDetails(d *userTasksQueryData) []punchDetail {
if d == nil {
return nil
}
var rows []punchDetail
for _, t := range d.UserTaskResults {
date := formatWorkday(t.Day)
for _, r := range t.Records {
rows = append(rows, punchDetail{
Date: date, ShiftType: shiftTypeLabel(r.TaskShiftType),
EmployeeName: t.EmployeeName, UserID: t.UserID, GroupID: t.GroupID, ShiftID: t.ShiftID,
CheckIn: r.CheckInResult, CheckInSupp: omitNone(r.CheckInResultSupplement),
CheckInScheduled: atoiTS(r.CheckInShiftTime), CheckInPunchAt: flowTime(r.CheckInRecord),
CheckInLocation: flowLoc(r.CheckInRecord), CheckInRecordID: r.CheckInRecordID,
CheckOut: r.CheckOutResult, CheckOutSupp: omitNone(r.CheckOutResultSupplement),
CheckOutScheduled: atoiTS(r.CheckOutShiftTime), CheckOutPunchAt: flowTime(r.CheckOutRecord),
CheckOutLocation: flowLoc(r.CheckOutRecord), CheckOutRecordID: r.CheckOutRecordID,
})
}
}
return rows
}
// parseRange is the single source of truth for the date contract, shared by
// Validate, DryRun and Execute so the rules can't drift. --from is required;
// --to defaults to --from (single-day query).
func parseRange(runtime *common.RuntimeContext) (from, to int, err error) {
fromStr := runtime.Str("from")
if fromStr == "" {
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--from is required, e.g. --from 2026-06-01 (a YYYY-MM-DD workday)").WithParam("--from")
}
from, perr := parseWorkday(fromStr)
if perr != nil {
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--from: %v", perr).WithParam("--from")
}
toStr := runtime.Str("to")
if toStr == "" {
return from, from, nil
}
to, perr = parseWorkday(toStr)
if perr != nil {
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--to: %v", perr).WithParam("--to")
}
if to < from {
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--to (%s) must not be earlier than --from (%s)", toStr, fromStr).WithParam("--to")
}
return from, to, nil
}
// queryBody fixes the boilerplate the meta API made callers supply by hand —
// employee_type (a query param) and an always-empty user_ids — so a self query
// never asks for it.
func queryBody(from, to int) *userTasksQueryReq {
return &userTasksQueryReq{
UserIDs: []string{},
CheckDateFrom: from,
CheckDateTo: to,
NeedOvertimeResult: true,
}
}
var AttendanceRecords = common.Shortcut{
Service: "attendance",
Command: "+records",
Description: "Query your own attendance punch results over a workday range",
Risk: "read",
Scopes: []string{"attendance:task:readonly"},
AuthTypes: []string{"user"}, // self-query needs a user token; a bot has no "self"
Flags: []common.Flag{
{Name: "from", Desc: "start workday YYYY-MM-DD (required)"},
{Name: "to", Desc: "end workday YYYY-MM-DD (defaults to --from, i.e. a single day)"},
{Name: "detail", Type: "bool", Desc: "return full detail (punch time, location, scheduled time, group/shift/employee); default returns result enums only"},
},
Tips: []string{
"Single day: lark-cli attendance +records --from 2026-06-01",
"Date range: lark-cli attendance +records --from 2026-06-01 --to 2026-06-08",
"For punch time / location / group detail: add --detail",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
from, to, err := parseRange(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(userTasksQueryPath).
Params(map[string]interface{}{"employee_type": "employee_no"}).
Body(queryBody(from, to))
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := parseRange(runtime)
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
from, to, err := parseRange(runtime)
if err != nil {
return err
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: "POST",
ApiPath: userTasksQueryPath,
QueryParams: larkcore.QueryParams{"employee_type": []string{"employee_no"}},
Body: queryBody(from, to),
})
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.WrapInternal(err)
}
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return err
}
var resp userTasksQueryResp
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal attendance response failed").WithCause(err)
}
// Default derives the compact view; --detail keeps the full rows. items[]
// and meta.count stay consistent across both.
details := projectDetails(resp.Data)
var items interface{}
if runtime.Bool("detail") {
items = details
} else {
rows := make([]punchRow, 0, len(details))
for _, d := range details {
rows = append(rows, d.compact())
}
items = rows
}
// prettyFn=nil → --format pretty falls back to JSON; table/csv/ndjson still render the flattened rows.
runtime.OutFormat(map[string]interface{}{"items": items}, &output.Meta{Count: len(details)}, nil)
return nil
},
}

View File

@@ -0,0 +1,397 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package attendance
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
const queryURL = "/open-apis/attendance/v1/user_tasks/query"
func defaultConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
UserOpenId: "ou_testuser", DefaultAs: "user",
}
}
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// okResponse builds a successful user_tasks/query envelope with the given results.
func okResponse(results []interface{}) map[string]interface{} {
return map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"user_task_results": results},
}
}
func TestAtoiTS(t *testing.T) {
cases := []struct {
in string
want int64
}{
{"1609722000", 1609722000},
{"", 0},
{"None", 0},
{"abc", 0},
}
for _, c := range cases {
if got := atoiTS(c.in); got != c.want {
t.Errorf("atoiTS(%q) = %d, want %d", c.in, got, c.want)
}
}
}
func TestParseWorkday_Valid(t *testing.T) {
tests := []struct {
in string
want int
}{
{"2026-06-01", 20260601},
{"2026-06-08", 20260608},
{"2019-08-17", 20190817},
{"2026-12-31", 20261231},
{"2026-01-01", 20260101},
}
for _, tt := range tests {
got, err := parseWorkday(tt.in)
if err != nil {
t.Errorf("parseWorkday(%q) unexpected error: %v", tt.in, err)
continue
}
if got != tt.want {
t.Errorf("parseWorkday(%q) = %d, want %d", tt.in, got, tt.want)
}
}
}
func TestParseWorkday_Rejects(t *testing.T) {
bad := []string{
"", // empty
"2026-6-1", // not zero-padded / not canonical
"20260601", // no separators
"2026/06/01", // wrong separator
"2026-13-01", // impossible month
"2026-02-30", // impossible day
"2026-06-01T00:00:00+08:00", // carries a time + offset (instant, not a calendar day)
"2026-06-01 ", // trailing space
"June 1, 2026", // free text
}
for _, in := range bad {
if _, err := parseWorkday(in); err == nil {
t.Errorf("parseWorkday(%q) = nil error, want rejection", in)
}
}
}
func TestFormatWorkday(t *testing.T) {
tests := []struct {
in int
want string
}{
{20260601, "2026-06-01"},
{20190817, "2019-08-17"},
{20261231, "2026-12-31"},
}
for _, tt := range tests {
if got := formatWorkday(tt.in); got != tt.want {
t.Errorf("formatWorkday(%d) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestShiftTypeLabel(t *testing.T) {
if got := shiftTypeLabel(0); got != "normal" {
t.Errorf("shiftTypeLabel(0) = %q, want normal", got)
}
if got := shiftTypeLabel(1); got != "overtime" {
t.Errorf("shiftTypeLabel(1) = %q, want overtime", got)
}
}
func TestOmitNone(t *testing.T) {
if got := omitNone("None"); got != "" {
t.Errorf("omitNone(None) = %q, want empty", got)
}
if got := omitNone("Leave"); got != "Leave" {
t.Errorf("omitNone(Leave) = %q, want Leave", got)
}
if got := omitNone(""); got != "" {
t.Errorf("omitNone(\"\") = %q, want empty", got)
}
}
func TestProjectDetails_FlattensAllFields(t *testing.T) {
data := &userTasksQueryData{
UserTaskResults: []userTaskResult{
{
EmployeeName: "test-employee", UserID: "test-user-id", GroupID: "g1", ShiftID: "s1",
Day: 20260601,
Records: []shiftRecord{
{
CheckInResult: "Normal", CheckInResultSupplement: "None",
CheckInShiftTime: "1609722000",
CheckInRecord: &userFlow{CheckTime: "1609722123", LocationName: "test-location"},
CheckInRecordID: "rid-in",
CheckOutResult: "Late", CheckOutResultSupplement: "None",
CheckOutShiftTime: "1609754400",
CheckOutRecord: &userFlow{CheckTime: "1609755000", LocationName: "test-location"},
CheckOutRecordID: "rid-out",
TaskShiftType: 0,
},
},
},
},
}
got := projectDetails(data)
if len(got) != 1 {
t.Fatalf("want 1 detail row, got %d", len(got))
}
d := got[0]
want := punchDetail{
Date: "2026-06-01", ShiftType: "normal",
EmployeeName: "test-employee", UserID: "test-user-id", GroupID: "g1", ShiftID: "s1",
CheckIn: "Normal", CheckInScheduled: 1609722000, CheckInPunchAt: 1609722123,
CheckInLocation: "test-location", CheckInRecordID: "rid-in",
CheckOut: "Late", CheckOutScheduled: 1609754400, CheckOutPunchAt: 1609755000,
CheckOutLocation: "test-location", CheckOutRecordID: "rid-out",
}
if !reflect.DeepEqual(d, want) {
t.Errorf("projectDetails mismatch:\n got: %+v\nwant: %+v", d, want)
}
}
func TestPunchDetail_Compact(t *testing.T) {
d := punchDetail{
Date: "2026-06-01", ShiftType: "overtime",
EmployeeName: "test-employee", GroupID: "g1",
CheckIn: "Normal", CheckInSupp: "ManagerModification", CheckInPunchAt: 123, CheckInLocation: "test-location",
CheckOut: "Late", CheckOutPunchAt: 456,
}
got := d.compact()
want := punchRow{
Date: "2026-06-01", ShiftType: "overtime",
CheckIn: "Normal", CheckInSupp: "ManagerModification", CheckOut: "Late",
}
if !reflect.DeepEqual(got, want) {
t.Errorf("compact() = %+v, want %+v (must drop punch_at/location/employee/group)", got, want)
}
}
func TestProjectDetails_EmptyAndNil(t *testing.T) {
if got := projectDetails(nil); len(got) != 0 {
t.Errorf("projectDetails(nil) = %+v, want empty", got)
}
if got := projectDetails(&userTasksQueryData{}); len(got) != 0 {
t.Errorf("projectDetails(empty) = %+v, want empty", got)
}
}
// ---------------------------------------------------------------------------
// +records shortcut behaviour
// ---------------------------------------------------------------------------
func TestRecords_MissingFrom_IsValidationError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, AttendanceRecords, []string{"+records"}, f, nil)
if err == nil {
t.Fatal("expected a validation error when --from is omitted, got nil")
}
if !strings.Contains(err.Error(), "--from") {
t.Errorf("error should name --from, got: %v", err)
}
}
func TestRecords_InvalidFrom_IsValidationError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026/06/01"}, f, nil)
if err == nil {
t.Fatal("expected a validation error for a malformed --from, got nil")
}
}
func TestRecords_FromAfterTo_IsValidationError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-02", "--to", "2026-06-01"}, f, nil)
if err == nil {
t.Fatal("expected a validation error when --from is after --to, got nil")
}
}
func TestRecords_RejectsBotIdentity(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01", "--as", "bot"}, f, nil)
if err == nil {
t.Fatal("expected an error for bot identity on a user-only shortcut, got nil")
}
}
// TestRecords_SingleDay_CollapsesToFromEqualsTo pins that omitting --to queries a
// single workday, and that the constant fields are auto-injected into the body.
func TestRecords_SingleDay_CollapsesToFromEqualsTo(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{Method: "POST", URL: queryURL, Body: okResponse(nil)}
reg.Register(stub)
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body struct {
UserIDs []string `json:"user_ids"`
CheckDateFrom int `json:"check_date_from"`
CheckDateTo int `json:"check_date_to"`
NeedOvertimeResult bool `json:"need_overtime_result"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("captured body not valid JSON: %v (%s)", err, stub.CapturedBody)
}
if body.CheckDateFrom != 20260601 || body.CheckDateTo != 20260601 {
t.Errorf("single-day should set from==to==20260601, got from=%d to=%d", body.CheckDateFrom, body.CheckDateTo)
}
if body.UserIDs == nil || len(body.UserIDs) != 0 {
t.Errorf("user_ids should serialise to an empty array, got %#v", body.UserIDs)
}
if !body.NeedOvertimeResult {
t.Errorf("need_overtime_result should default to true")
}
}
// detailResult builds one user_task_result with a full nested punch flow,
// including the device-fingerprint fields the projection must drop.
func detailResult() interface{} {
return map[string]interface{}{
"employee_name": "test-employee", "user_id": "test-user-id", "group_id": "g1", "shift_id": "s1",
"day": 20260601,
"records": []interface{}{
map[string]interface{}{
"check_in_result": "Normal", "check_in_result_supplement": "None",
"check_in_shift_time": "1609722000",
"check_in_record": map[string]interface{}{
"check_time": "1609722123", "location_name": "test-location",
"ssid": "office-wifi", "bssid": "aa:bb:cc", "device_id": "dev-1",
},
"check_in_record_id": "rid-in",
"check_out_result": "Late", "check_out_result_supplement": "None",
"check_out_shift_time": "1609754400",
"check_out_record": map[string]interface{}{
"check_time": "1609755000", "location_name": "test-location",
},
"check_out_record_id": "rid-out",
"task_shift_type": 0,
},
},
}
}
func TestRecords_Default_ProjectsCompactItems(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{Method: "POST", URL: queryURL, Body: okResponse([]interface{}{detailResult()})})
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "\"check_in\": \"Normal\"") {
t.Errorf("default should include result enum, got:\n%s", out)
}
for _, leaked := range []string{"check_in_punch_at", "check_in_location", "employee_name", "group_id"} {
if strings.Contains(out, leaked) {
t.Errorf("default (compact) must NOT include %q, got:\n%s", leaked, out)
}
}
}
func TestRecords_Detail_ProjectsFullItems(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{Method: "POST", URL: queryURL, Body: okResponse([]interface{}{detailResult()})})
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01", "--detail"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env struct {
Data struct {
Items []punchDetail `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("stdout not JSON envelope: %v\n%s", err, stdout.String())
}
if len(env.Data.Items) != 1 {
t.Fatalf("want 1 detail item, got %d", len(env.Data.Items))
}
d := env.Data.Items[0]
if d.CheckInPunchAt != 1609722123 || d.CheckInLocation != "test-location" || d.EmployeeName != "test-employee" || d.GroupID != "g1" {
t.Errorf("detail item missing expected fields: %+v", d)
}
}
func TestRecords_Detail_ExcludesFingerprintFields(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{Method: "POST", URL: queryURL, Body: okResponse([]interface{}{detailResult()})})
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01", "--detail"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, leaked := range []string{"ssid", "bssid", "device_id", "office-wifi", "dev-1"} {
if strings.Contains(out, leaked) {
t.Errorf("--detail must NOT leak fingerprint field %q, got:\n%s", leaked, out)
}
}
}
func TestRecords_PrettyFallsBackToJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{Method: "POST", URL: queryURL, Body: okResponse([]interface{}{detailResult()})})
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01", "--format", "pretty"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env struct {
OK bool `json:"ok"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("pretty should fall back to JSON envelope, got non-JSON:\n%s", stdout.String())
}
if !env.OK {
t.Errorf("expected ok=true envelope, got:\n%s", stdout.String())
}
}
func TestRecords_DryRun_ShowsRequestShape(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01", "--dry-run"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "user_tasks/query") {
t.Errorf("dry-run should show the endpoint path, got:\n%s", out)
}
if !strings.Contains(out, "employee_no") {
t.Errorf("dry-run should show the employee_type=employee_no query param, got:\n%s", out)
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package attendance
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all attendance shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
AttendanceRecords,
}
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/apps"
"github.com/larksuite/cli/shortcuts/attendance"
"github.com/larksuite/cli/shortcuts/base"
"github.com/larksuite/cli/shortcuts/calendar"
"github.com/larksuite/cli/shortcuts/common"
@@ -60,6 +61,7 @@ var allShortcuts []common.Shortcut
func init() {
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, attendance.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...)

View File

@@ -1,57 +1,37 @@
---
name: lark-attendance
version: 1.0.0
description: "飞书考勤打卡:查询自己的考勤打卡记录"
version: 2.0.0
description: "飞书考勤打卡:查询自己的打卡结果(迟到/早退/缺卡/补卡)。当用户问自己某天有没有打卡、某段时间的考勤/出勤情况时使用;仅限本人。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli attendance --help"
---
# attendance (v1)
# attendance
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
仅本人(`--as user`)。各命令的参数细节见其 `--help`
## 默认参数自动填充规则
## 路由
调用任何 API 时,以下参数 **必须自动填充,禁止向用户询问**
| 想做什么 | 怎么调 |
|---|---|
| 查自己的打卡(迟到/早退/缺卡/出勤) | `+records --from <YYYY-MM-DD>`(区间加 `--to`|
| 要打卡时刻 / 地点 / 考勤组等明细 | 加 `--detail`(默认只给结果枚举)|
| 相对时间(今天/本周/上月) | 先换算成具体 `YYYY-MM-DD`——命令只认具体日期 |
| 查他人 / 团队考勤 | 出本 skill 范围(需管理员)→ 见 `lark-openapi-explorer` |
| 参数 | 固定值 | 说明 |
|------|--------|------------------------------------|
| `employee_type` | `"employee_no"` | `employee_type`始终等于`"employee_no"` |
| `user_ids` | `[]`(空数组) | `user_ids`始终等于`[]` |
### 填充示例
当构建 `--params` 参数时,自动注入上述字段:
- `employee_type` 保持 `"employee_no"` 不变
当构建 `--data` 参数时,自动注入上述字段:
```json
{
"user_ids": [],
...
}
```
> **注意**`user_ids` 数组保持为空[]`employee_type` 保持 `"employee_no"` 不变。
## API Resources
## 示例
```bash
lark-cli schema attendance.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli attendance <resource> <method> [flags] # 调用 API
# 查一段时间的打卡结果
lark-cli attendance +records --as user --from 2026-06-01 --to 2026-06-08
# 要明细(几点几分、在哪打的)
lark-cli attendance +records --as user --from 2026-06-01 --detail
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### user_tasks
- `query` — 查询用户考勤打卡记录
## 权限表
| 方法 | 所需 scope |
|------|-----------|
| `user_tasks.query` | `attendance:task:readonly` |
## 注意
-`items` = 无记录(非报错)。
- 认证 / 权限报错见 `lark-shared`