diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b75fad4..c13a0b6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Added + +- **`dws report entry submit --sender-user-id ` supports delegated report submission** (#406) — report submissions without the flag continue through MCP `report.create_report`; submissions with the flag use DingTalk OAPI `POST /topapi/report/create` and map the requested sender to `create_report_param.userid`. The OAPI route requires the caller's own AppKey/AppSecret and the “管理员工日志数据” permission, supports dry-run previews, and never falls back to MCP after an OAPI error so a report cannot silently be submitted as the wrong employee. The deprecated `dws report create` path receives the same flag. + ## [1.0.34] - 2026-06-03 ### Changed diff --git a/docs/superpowers/specs/2026-06-04-report-sender-design.md b/docs/superpowers/specs/2026-06-04-report-sender-design.md new file mode 100644 index 00000000..f9bf2d9e --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-report-sender-design.md @@ -0,0 +1,110 @@ +# Report Sender Hybrid Submission Design + +## Goal + +Allow `dws report entry submit` and its deprecated `dws report create` alias to +submit a report on behalf of a specified employee with: + +```bash +dws report entry submit --sender-user-id ... +``` + +## Constraints + +- The remote MCP `report.create_report` schema does not expose a sender field. +- DingTalk's legacy OpenAPI `POST /topapi/report/create` supports the required + `create_report_param.userid` field. +- Raw OpenAPI calls require the user's own AppKey/AppSecret and the + "管理员工日志数据" permission. The encrypted default MCP credential cannot + be used. +- Existing invocations without `--sender-user-id` must retain their current MCP + behavior. + +## Architecture + +Submission uses two routes selected by the presence of `--sender-user-id`: + +1. Without the flag, the existing command handler calls MCP + `report.create_report` unchanged. +2. With the flag, a post-merge report hook intercepts the command and calls + DingTalk OAPI `POST https://oapi.dingtalk.com/topapi/report/create`. + +The OAPI route builds the legacy request body: + +```json +{ + "create_report_param": { + "userid": "", + "template_id": "", + "dd_from": "dws", + "to_chat": false, + "to_userids": [], + "contents": [ + { + "key": "...", + "sort": "0", + "type": "1", + "content_type": "markdown", + "content": "..." + } + ] + } +} +``` + +CLI camelCase content keys are converted to the OAPI snake_case shape. Unknown +content fields are preserved when possible so the route does not discard +forward-compatible values. + +## Command Integration + +A post-merge hook adds `--sender-user-id` to: + +- `dws report entry submit` +- `dws report create` + +The hook wraps the existing `RunE`. If the flag is empty, it delegates directly +to the original handler. If the flag is set, it validates and parses the +existing flags, then invokes the OAPI submitter. + +This avoids changing the discovery envelope or sending an unsupported +`senderUserId` argument to MCP. + +## Authentication And Errors + +The OAPI route obtains an app-level token from the existing +`auth.AppTokenProvider`, using credentials already resolved from +`--client-id`/`--client-secret`, environment variables, or auth configuration. + +If credentials are missing, the command returns an actionable authentication +error. DingTalk HTTP and business errors are surfaced without falling back to +MCP, because fallback would silently submit as the wrong sender. + +## Dry Run And Output + +With `--sender-user-id --dry-run`, the command prints a structured preview of +the OAPI request and does not resolve a token or perform network I/O. The +preview must include the selected sender but never expose credentials. + +Successful OAPI responses are emitted through the normal command output path. +The returned DingTalk `result` report ID remains available to callers. + +## Testing + +Tests cover: + +- The flag is attached to both canonical and deprecated command paths. +- No sender delegates to the original MCP handler unchanged. +- A sender selects OAPI and maps all request fields correctly. +- `--contents-file`, inline `--contents`, recipients, and `to-chat` map to the + OAPI request. +- Dry run performs no network or token lookup. +- Missing app credentials and DingTalk business errors are clear and do not + fall back to MCP. +- Existing report tests continue to pass. + +## Documentation + +Update the multi and mono report references, skill summary, command help, and +`CHANGELOG.md`. Documentation must state that `--sender-user-id` requires the +caller's own app credentials and the employee report management permission. diff --git a/internal/app/legacy.go b/internal/app/legacy.go index acd6e753..e442bbd5 100644 --- a/internal/app/legacy.go +++ b/internal/app/legacy.go @@ -58,6 +58,7 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob // command surface remains predictable from the envelope alone. helpers.AttachReportLegacyInboxAlias(merged, runner) helpers.AttachReportListReadableEnrichment(merged, runner) + helpers.AttachReportSenderSubmission(merged, newReportSenderOAPISubmitter()) return merged } diff --git a/internal/app/report_sender_submitter.go b/internal/app/report_sender_submitter.go new file mode 100644 index 00000000..066ba286 --- /dev/null +++ b/internal/app/report_sender_submitter.go @@ -0,0 +1,169 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const reportCreateOAPIPath = "https://oapi.dingtalk.com/topapi/report/create" + +type reportSenderAPICaller interface { + Do(context.Context, apiclient.RawAPIRequest) (*apiclient.RawAPIResponse, error) +} + +type reportSenderOAPISubmitter struct { + resolveToken func(context.Context) (string, error) + newClient func(string) reportSenderAPICaller +} + +func newReportSenderOAPISubmitter() *reportSenderOAPISubmitter { + return &reportSenderOAPISubmitter{ + resolveToken: resolveReportSenderToken, + newClient: func(token string) reportSenderAPICaller { + return apiclient.NewClient(token, apiclient.LegacyBaseURL) + }, + } +} + +func (s *reportSenderOAPISubmitter) Submit(ctx context.Context, cmd *cobra.Command, submission helpers.ReportSenderSubmission) error { + body, err := helpers.BuildReportCreateOAPIRequest(submission) + if err != nil { + return err + } + req := apiclient.RawAPIRequest{ + Method: http.MethodPost, + Path: reportCreateOAPIPath, + Data: body, + } + if reportCommandBoolFlag(cmd, "dry-run") { + return output.WriteCommandPayload(cmd, map[string]any{ + "dry_run": true, + "route": "dingtalk_oapi", + "request": map[string]any{ + "method": req.Method, + "url": req.Path, + "body": req.Data, + }, + }, output.FormatJSON) + } + + if s == nil || s.resolveToken == nil || s.newClient == nil { + return apperrors.NewInternal("report sender OAPI submitter is not configured") + } + token, err := s.resolveToken(ctx) + if err != nil { + return err + } + client := s.newClient(token) + if client == nil { + return apperrors.NewInternal("report sender OAPI client is not configured") + } + if concrete, ok := client.(*apiclient.APIClient); ok { + if timeout := reportCommandIntFlag(cmd, "timeout"); timeout > 0 { + concrete.HTTPClient.Timeout = time.Duration(timeout) * time.Second + } + } + resp, err := client.Do(ctx, req) + if err != nil { + return apperrors.NewAPI(fmt.Sprintf("代提交日志 OAPI 请求失败: %v", err)) + } + return apiclient.HandleResponse(resp, apiclient.ResponseOptions{ + Format: output.ResolveFormat(cmd, output.FormatJSON), + JqExpr: reportCommandStringFlag(cmd, "jq"), + Fields: reportCommandStringFlag(cmd, "fields"), + Out: cmd.OutOrStdout(), + ErrOut: cmd.ErrOrStderr(), + }) +} + +func resolveReportSenderToken(ctx context.Context) (string, error) { + appKey := strings.TrimSpace(authpkg.ClientID()) + appSecret := strings.TrimSpace(authpkg.ClientSecret()) + if appKey == "" || appSecret == "" || strings.HasPrefix(appKey, "<") || strings.HasPrefix(appSecret, "<") { + return "", apperrors.NewAuth( + "--sender-user-id 代提交日志需要自有应用的 AppKey/AppSecret。\n\n" + + "请通过 --client-id/--client-secret、DWS_CLIENT_ID/DWS_CLIENT_SECRET,或 dws auth login 配置应用凭证;" + + "应用还需要“管理员工日志数据”权限。", + ) + } + provider := &authpkg.AppTokenProvider{AppKey: appKey, AppSecret: appSecret} + token, err := provider.GetToken(ctx) + if err != nil { + return "", apperrors.NewAuth(fmt.Sprintf("获取代提交日志所需的应用级 access token 失败: %v", err)) + } + return strings.TrimSpace(token), nil +} + +func reportCommandStringFlag(cmd *cobra.Command, name string) string { + for _, flags := range reportCommandFlagSets(cmd) { + if flags.Lookup(name) == nil { + continue + } + value, err := flags.GetString(name) + if err == nil { + return strings.TrimSpace(value) + } + } + return "" +} + +func reportCommandBoolFlag(cmd *cobra.Command, name string) bool { + for _, flags := range reportCommandFlagSets(cmd) { + if flags.Lookup(name) == nil { + continue + } + value, err := flags.GetBool(name) + if err == nil { + return value + } + } + return false +} + +func reportCommandIntFlag(cmd *cobra.Command, name string) int { + for _, flags := range reportCommandFlagSets(cmd) { + if flags.Lookup(name) == nil { + continue + } + value, err := flags.GetInt(name) + if err == nil { + return value + } + } + return 0 +} + +func reportCommandFlagSets(cmd *cobra.Command) []*pflag.FlagSet { + if cmd == nil { + return nil + } + sets := []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} + if root := cmd.Root(); root != nil { + sets = append(sets, root.PersistentFlags()) + } + return sets +} diff --git a/internal/app/report_sender_submitter_test.go b/internal/app/report_sender_submitter_test.go new file mode 100644 index 00000000..e497d0f4 --- /dev/null +++ b/internal/app/report_sender_submitter_test.go @@ -0,0 +1,153 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "bytes" + "context" + "net/http" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/spf13/cobra" +) + +type recordingReportAPICaller struct { + calls int + req apiclient.RawAPIRequest + resp *apiclient.RawAPIResponse + err error +} + +func (c *recordingReportAPICaller) Do(_ context.Context, req apiclient.RawAPIRequest) (*apiclient.RawAPIResponse, error) { + c.calls++ + c.req = req + return c.resp, c.err +} + +func TestReportSenderOAPISubmitterPostsLegacyCreateRequest(t *testing.T) { + t.Parallel() + + caller := &recordingReportAPICaller{resp: reportSenderTestResponse(`{"errcode":0,"result":"report-1"}`)} + tokenCalls := 0 + submitter := &reportSenderOAPISubmitter{ + resolveToken: func(context.Context) (string, error) { + tokenCalls++ + return "token-1", nil + }, + newClient: func(token string) reportSenderAPICaller { + if token != "token-1" { + t.Fatalf("client token = %q, want token-1", token) + } + return caller + }, + } + cmd, out := newReportSenderSubmitterTestCommand(false) + + err := submitter.Submit(context.Background(), cmd, helpers.ReportSenderSubmission{ + SenderUserID: "sender-1", + TemplateID: "template-1", + Contents: []map[string]any{{ + "key": "x", + "sort": "0", + "type": "1", + "contentType": "markdown", + "content": "done", + }}, + }) + if err != nil { + t.Fatalf("Submit() error = %v", err) + } + if tokenCalls != 1 || caller.calls != 1 { + t.Fatalf("token calls = %d, API calls = %d, want 1/1", tokenCalls, caller.calls) + } + if caller.req.Method != http.MethodPost || caller.req.Path != reportCreateOAPIPath { + t.Fatalf("request = %s %s, want POST %s", caller.req.Method, caller.req.Path, reportCreateOAPIPath) + } + if !strings.Contains(out.String(), "report-1") { + t.Fatalf("output missing report id: %q", out.String()) + } +} + +func TestReportSenderOAPISubmitterDryRunSkipsTokenAndHTTP(t *testing.T) { + t.Parallel() + + caller := &recordingReportAPICaller{} + tokenCalls := 0 + submitter := &reportSenderOAPISubmitter{ + resolveToken: func(context.Context) (string, error) { + tokenCalls++ + return "token-1", nil + }, + newClient: func(string) reportSenderAPICaller { return caller }, + } + cmd, out := newReportSenderSubmitterTestCommand(true) + + err := submitter.Submit(context.Background(), cmd, helpers.ReportSenderSubmission{ + SenderUserID: "sender-1", + TemplateID: "template-1", + Contents: []map[string]any{{"key": "x"}}, + }) + if err != nil { + t.Fatalf("Submit() error = %v", err) + } + if tokenCalls != 0 || caller.calls != 0 { + t.Fatalf("dry-run token calls = %d, API calls = %d, want 0/0", tokenCalls, caller.calls) + } + if !strings.Contains(out.String(), `"sender-1"`) || !strings.Contains(out.String(), `"dry_run": true`) { + t.Fatalf("dry-run output missing sender/request marker: %q", out.String()) + } +} + +func TestReportSenderOAPISubmitterReturnsBusinessError(t *testing.T) { + t.Parallel() + + caller := &recordingReportAPICaller{resp: reportSenderTestResponse(`{"errcode":60011,"errmsg":"no permission"}`)} + submitter := &reportSenderOAPISubmitter{ + resolveToken: func(context.Context) (string, error) { return "token-1", nil }, + newClient: func(string) reportSenderAPICaller { return caller }, + } + cmd, _ := newReportSenderSubmitterTestCommand(false) + + err := submitter.Submit(context.Background(), cmd, helpers.ReportSenderSubmission{ + SenderUserID: "sender-1", + TemplateID: "template-1", + Contents: []map[string]any{{"key": "x"}}, + }) + if err == nil || !strings.Contains(err.Error(), "60011") { + t.Fatalf("Submit() error = %v, want business error 60011", err) + } +} + +func newReportSenderSubmitterTestCommand(dryRun bool) (*cobra.Command, *bytes.Buffer) { + out := &bytes.Buffer{} + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("dry-run", dryRun, "") + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("fields", "", "") + root.PersistentFlags().String("jq", "", "") + cmd := &cobra.Command{Use: "submit"} + root.AddCommand(cmd) + cmd.SetOut(out) + cmd.SetErr(&bytes.Buffer{}) + return cmd, out +} + +func reportSenderTestResponse(body string) *apiclient.RawAPIResponse { + header := make(http.Header) + header.Set("Content-Type", "application/json") + return &apiclient.RawAPIResponse{StatusCode: http.StatusOK, Header: header, Body: []byte(body)} +} diff --git a/internal/helpers/report_submit_hook.go b/internal/helpers/report_submit_hook.go new file mode 100644 index 00000000..c3a39064 --- /dev/null +++ b/internal/helpers/report_submit_hook.go @@ -0,0 +1,164 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/spf13/cobra" +) + +// ReportSenderSubmitter executes the delegated OAPI submission route. +type ReportSenderSubmitter interface { + Submit(context.Context, *cobra.Command, ReportSenderSubmission) error +} + +// AttachReportSenderSubmission adds the delegated sender route after dynamic +// and helper report commands have been merged. +func AttachReportSenderSubmission(commands []*cobra.Command, submitter ReportSenderSubmitter) { + attachReportSenderSubmission(commands, submitter) +} + +func attachReportSenderSubmission(commands []*cobra.Command, submitter ReportSenderSubmitter) { + if submitter == nil { + return + } + for _, top := range commands { + if top == nil || top.Name() != "report" { + continue + } + wrapReportSenderLeaf(findReportCommand(top, "entry", "submit"), submitter) + wrapReportSenderLeaf(findReportCommand(top, "create"), submitter) + } +} + +func findReportCommand(root *cobra.Command, path ...string) *cobra.Command { + current := root + for _, name := range path { + var next *cobra.Command + for _, child := range current.Commands() { + if child != nil && child.Name() == name { + next = child + break + } + } + if next == nil { + return nil + } + current = next + } + return current +} + +func wrapReportSenderLeaf(leaf *cobra.Command, submitter ReportSenderSubmitter) { + if leaf == nil || leaf.RunE == nil { + return + } + if leaf.Flags().Lookup("sender-user-id") == nil { + leaf.Flags().String("sender-user-id", "", "日志发送人 userId;设置后使用自有应用凭证通过钉钉 OAPI 代提交") + } + originalRunE := leaf.RunE + leaf.RunE = func(cmd *cobra.Command, args []string) error { + sender, _ := cmd.Flags().GetString("sender-user-id") + if strings.TrimSpace(sender) == "" { + return originalRunE(cmd, args) + } + submission, err := reportSenderSubmissionFromFlags(cmd, sender) + if err != nil { + return err + } + return submitter.Submit(cmd.Context(), cmd, submission) + } +} + +func reportSenderSubmissionFromFlags(cmd *cobra.Command, sender string) (ReportSenderSubmission, error) { + templateID, err := requiredReportStringFlag(cmd, "template-id") + if err != nil { + return ReportSenderSubmission{}, err + } + contents, err := reportContentsFromFlags(cmd) + if err != nil { + return ReportSenderSubmission{}, err + } + ddFrom := optionalReportStringFlag(cmd, "dd-from") + if ddFrom == "" { + ddFrom = "dws" + } + toChat := false + if cmd.Flags().Lookup("to-chat") != nil { + toChat, _ = cmd.Flags().GetBool("to-chat") + } + toUserIDs := parseUserIDs(optionalReportStringFlag(cmd, "to-user-ids")) + return ReportSenderSubmission{ + SenderUserID: strings.TrimSpace(sender), + TemplateID: templateID, + Contents: contents, + DDFrom: ddFrom, + ToChat: toChat, + ToUserIDs: toUserIDs, + }, nil +} + +func requiredReportStringFlag(cmd *cobra.Command, name string) (string, error) { + value := optionalReportStringFlag(cmd, name) + if value == "" { + return "", apperrors.NewValidation("--" + name + " is required") + } + return value, nil +} + +func optionalReportStringFlag(cmd *cobra.Command, name string) string { + if cmd == nil || cmd.Flags().Lookup(name) == nil { + return "" + } + value, _ := cmd.Flags().GetString(name) + return strings.TrimSpace(value) +} + +func reportContentsFromFlags(cmd *cobra.Command) ([]map[string]any, error) { + raw := "" + if filePath := optionalReportStringFlag(cmd, "contents-file"); filePath != "" { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("read --contents-file: %v", err)) + } + raw = string(data) + } else { + raw = optionalReportStringFlag(cmd, "contents") + if raw == "-" { + data, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("read --contents from stdin: %v", err)) + } + raw = string(data) + } + } + if strings.TrimSpace(raw) == "" { + return nil, apperrors.NewValidation("--contents or --contents-file is required") + } + var contents []map[string]any + if err := json.Unmarshal([]byte(raw), &contents); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("report contents JSON parse failed: %v", err)) + } + if len(contents) == 0 { + return nil, apperrors.NewValidation("report contents must contain at least one item") + } + return contents, nil +} diff --git a/internal/helpers/report_submit_hook_test.go b/internal/helpers/report_submit_hook_test.go new file mode 100644 index 00000000..8ea272b7 --- /dev/null +++ b/internal/helpers/report_submit_hook_test.go @@ -0,0 +1,157 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +type recordingReportSenderSubmitter struct { + calls int + submission ReportSenderSubmission +} + +func (s *recordingReportSenderSubmitter) Submit(_ context.Context, _ *cobra.Command, submission ReportSenderSubmission) error { + s.calls++ + s.submission = submission + return nil +} + +func TestAttachReportSenderSubmissionAddsFlagToCanonicalAndDeprecatedPaths(t *testing.T) { + t.Parallel() + + report, _, _ := newReportSubmitTestTree() + attachReportSenderSubmission([]*cobra.Command{report}, &recordingReportSenderSubmitter{}) + + for _, path := range [][]string{{"entry", "submit"}, {"create"}} { + cmd := findReportTestCommand(report, path...) + if cmd == nil { + t.Fatalf("command %v not found", path) + } + if cmd.Flags().Lookup("sender-user-id") == nil { + t.Fatalf("command %v missing --sender-user-id", path) + } + } +} + +func TestAttachReportSenderSubmissionDelegatesWithoutSender(t *testing.T) { + t.Parallel() + + report, submit, originalCalls := newReportSubmitTestTree() + submitter := &recordingReportSenderSubmitter{} + attachReportSenderSubmission([]*cobra.Command{report}, submitter) + + if err := submit.RunE(submit, nil); err != nil { + t.Fatalf("submit RunE error = %v", err) + } + if *originalCalls != 1 { + t.Fatalf("original calls = %d, want 1", *originalCalls) + } + if submitter.calls != 0 { + t.Fatalf("OAPI submitter calls = %d, want 0", submitter.calls) + } +} + +func TestAttachReportSenderSubmissionRoutesSenderToOAPI(t *testing.T) { + t.Parallel() + + report, submit, originalCalls := newReportSubmitTestTree() + submitter := &recordingReportSenderSubmitter{} + attachReportSenderSubmission([]*cobra.Command{report}, submitter) + + contentsFile := filepath.Join(t.TempDir(), "report.json") + if err := os.WriteFile(contentsFile, []byte(`[{"key":"今日完成工作","sort":"0","type":"1","contentType":"markdown","content":"done"}]`), 0o600); err != nil { + t.Fatalf("write contents file: %v", err) + } + mustSetReportTestFlag(t, submit, "sender-user-id", "sender-1") + mustSetReportTestFlag(t, submit, "template-id", "template-1") + mustSetReportTestFlag(t, submit, "contents-file", contentsFile) + mustSetReportTestFlag(t, submit, "dd-from", "agent") + mustSetReportTestFlag(t, submit, "to-chat", "true") + mustSetReportTestFlag(t, submit, "to-user-ids", "receiver-1,receiver-2") + + if err := submit.RunE(submit, nil); err != nil { + t.Fatalf("submit RunE error = %v", err) + } + if *originalCalls != 0 { + t.Fatalf("original calls = %d, want 0", *originalCalls) + } + if submitter.calls != 1 { + t.Fatalf("OAPI submitter calls = %d, want 1", submitter.calls) + } + if got := submitter.submission.SenderUserID; got != "sender-1" { + t.Fatalf("sender = %q, want sender-1", got) + } + if got := len(submitter.submission.ToUserIDs); got != 2 { + t.Fatalf("recipient count = %d, want 2", got) + } +} + +func newReportSubmitTestTree() (*cobra.Command, *cobra.Command, *int) { + originalCalls := 0 + newLeaf := func(use string) *cobra.Command { + cmd := &cobra.Command{ + Use: use, + RunE: func(*cobra.Command, []string) error { + originalCalls++ + return nil + }, + } + cmd.Flags().String("template-id", "", "") + cmd.Flags().String("contents", "", "") + cmd.Flags().String("contents-file", "", "") + cmd.Flags().String("dd-from", "dws", "") + cmd.Flags().Bool("to-chat", false, "") + cmd.Flags().String("to-user-ids", "", "") + return cmd + } + + submit := newLeaf("submit") + entry := &cobra.Command{Use: "entry"} + entry.AddCommand(submit) + create := newLeaf("create") + report := &cobra.Command{Use: "report"} + report.AddCommand(entry, create) + return report, submit, &originalCalls +} + +func findReportTestCommand(root *cobra.Command, path ...string) *cobra.Command { + current := root + for _, name := range path { + var next *cobra.Command + for _, child := range current.Commands() { + if child.Name() == name { + next = child + break + } + } + if next == nil { + return nil + } + current = next + } + return current +} + +func mustSetReportTestFlag(t *testing.T, cmd *cobra.Command, name, value string) { + t.Helper() + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } +} diff --git a/internal/helpers/report_submit_oapi.go b/internal/helpers/report_submit_oapi.go new file mode 100644 index 00000000..417357bd --- /dev/null +++ b/internal/helpers/report_submit_oapi.go @@ -0,0 +1,85 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +// ReportSenderSubmission is the transport-neutral input for submitting a +// report on behalf of an employee. +type ReportSenderSubmission struct { + SenderUserID string + TemplateID string + Contents []map[string]any + DDFrom string + ToChat bool + ToUserIDs []string +} + +// BuildReportCreateOAPIRequest converts the CLI/MCP-shaped submission into the +// legacy DingTalk OAPI request body accepted by /topapi/report/create. +func BuildReportCreateOAPIRequest(submission ReportSenderSubmission) (map[string]any, error) { + return buildReportCreateOAPIRequest(submission) +} + +func buildReportCreateOAPIRequest(submission ReportSenderSubmission) (map[string]any, error) { + sender := strings.TrimSpace(submission.SenderUserID) + if sender == "" { + return nil, apperrors.NewValidation("--sender-user-id is required for delegated report submission") + } + templateID := strings.TrimSpace(submission.TemplateID) + if templateID == "" { + return nil, apperrors.NewValidation("--template-id is required") + } + if len(submission.Contents) == 0 { + return nil, apperrors.NewValidation("--contents or --contents-file must contain at least one report field") + } + + contents := make([]map[string]any, 0, len(submission.Contents)) + for i, item := range submission.Contents { + if item == nil { + return nil, apperrors.NewValidation(fmt.Sprintf("report content item %d must be an object", i)) + } + mapped := make(map[string]any, len(item)) + for key, value := range item { + switch key { + case "contentType": + mapped["content_type"] = value + default: + mapped[key] = value + } + } + contents = append(contents, mapped) + } + + ddFrom := strings.TrimSpace(submission.DDFrom) + if ddFrom == "" { + ddFrom = "dws" + } + param := map[string]any{ + "userid": sender, + "template_id": templateID, + "contents": contents, + "dd_from": ddFrom, + "to_chat": submission.ToChat, + } + if len(submission.ToUserIDs) > 0 { + param["to_userids"] = append([]string(nil), submission.ToUserIDs...) + } + return map[string]any{"create_report_param": param}, nil +} diff --git a/internal/helpers/report_submit_oapi_test.go b/internal/helpers/report_submit_oapi_test.go new file mode 100644 index 00000000..643f7b2f --- /dev/null +++ b/internal/helpers/report_submit_oapi_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import "testing" + +func TestBuildReportCreateOAPIRequestMapsSenderAndContents(t *testing.T) { + t.Parallel() + + request, err := buildReportCreateOAPIRequest(ReportSenderSubmission{ + SenderUserID: "sender-1", + TemplateID: "template-1", + DDFrom: "dws", + ToChat: true, + ToUserIDs: []string{"receiver-1", "receiver-2"}, + Contents: []map[string]any{{ + "key": "今日完成工作", + "sort": "0", + "type": "1", + "contentType": "markdown", + "content": "完成 CLI 开发", + }}, + }) + if err != nil { + t.Fatalf("buildReportCreateOAPIRequest() error = %v", err) + } + + param, ok := request["create_report_param"].(map[string]any) + if !ok { + t.Fatalf("create_report_param type = %T, want map[string]any", request["create_report_param"]) + } + if got := param["userid"]; got != "sender-1" { + t.Fatalf("userid = %#v, want sender-1", got) + } + if got := param["template_id"]; got != "template-1" { + t.Fatalf("template_id = %#v, want template-1", got) + } + if got := param["dd_from"]; got != "dws" { + t.Fatalf("dd_from = %#v, want dws", got) + } + if got := param["to_chat"]; got != true { + t.Fatalf("to_chat = %#v, want true", got) + } + + contents, ok := param["contents"].([]map[string]any) + if !ok || len(contents) != 1 { + t.Fatalf("contents = %#v, want one mapped item", param["contents"]) + } + if got := contents[0]["content_type"]; got != "markdown" { + t.Fatalf("content_type = %#v, want markdown", got) + } + if _, exists := contents[0]["contentType"]; exists { + t.Fatalf("camelCase contentType must not be sent to OAPI: %#v", contents[0]) + } +} + +func TestBuildReportCreateOAPIRequestRejectsMissingSender(t *testing.T) { + t.Parallel() + + _, err := buildReportCreateOAPIRequest(ReportSenderSubmission{ + TemplateID: "template-1", + Contents: []map[string]any{{"key": "x"}}, + }) + if err == nil { + t.Fatal("expected missing sender validation error") + } +} diff --git a/skills/mono/references/products/report.md b/skills/mono/references/products/report.md index f89be131..da046771 100644 --- a/skills/mono/references/products/report.md +++ b/skills/mono/references/products/report.md @@ -176,15 +176,26 @@ Example: dws report entry submit --template-id \ --contents '[{"key":"今日完成","sort":"0","content":"完成了需求评审","contentType":"markdown","type":"1"}]' \ --format json + + # 以指定员工为发送人代提交(需要自有应用凭证和“管理员工日志数据”权限) + dws report entry submit --sender-user-id \ + --template-id --contents-file ./report.json --format json Flags: --template-id string 日志模版 ID (必填),从 template list 返回中取 --contents string 日志内容 JSON 数组 (必填,或用 --contents-file);传 `-` 表示从 stdin 读取 --contents-file string 从文件读取 contents JSON(推荐用于含中文/换行/Markdown 的长内容) --dd-from string 创建来源标识 (默认 dws) + --sender-user-id string 日志发送人 userId;设置后使用自有应用凭证通过钉钉 OAPI 代提交 --to-chat 是否发送到日志接收人单聊 (默认 false,传本 flag 则为 true) --to-user-ids string 接收人 userId,逗号分隔 (可选) ``` +**发送人路由规则**: + +- 不传 `--sender-user-id`:保持原行为,通过 MCP `report.create_report` 以当前登录用户提交。 +- 传 `--sender-user-id`:通过钉钉 OAPI `POST /topapi/report/create` 代指定员工提交;不会在失败时回退 MCP,避免日志显示为错误发送人。 +- OAPI 路径需要自有应用 AppKey/AppSecret(`--client-id` / `--client-secret`、环境变量或 `dws auth login`)和“管理员工日志数据”权限。默认 MCP 凭证不支持该路径。 + **`contents` 数组元素**(与 MCP `create_report` 一致): diff --git a/skills/multi/dingtalk-report/SKILL.md b/skills/multi/dingtalk-report/SKILL.md index 149e3df5..29fd4785 100644 --- a/skills/multi/dingtalk-report/SKILL.md +++ b/skills/multi/dingtalk-report/SKILL.md @@ -30,6 +30,7 @@ metadata: | "今天收到的日志" | `python scripts/report_received_today.py` | | "看日志模版" | `dws report template list` → `dws report template detail --name "<模版名>"` | | "提交日报 / 周报(按模版)" | `dws report create --template-id --contents '[...]' | +| "以指定员工身份提交日志" | `dws report entry submit --sender-user-id --template-id --contents-file ` | | "我已发送的日志" | `dws report sent --start --end ` | | "日志已读统计" | `dws report stats --report-id ` | | "生成日报 / 周报 / 月报 / 主题报告" | 见 [05-reporting.md](references/05-reporting.md) recipe | @@ -40,6 +41,12 @@ metadata: - 列表返回后,后续 `detail` / `stats` 必须复用同一个 `reportId`;不要重新挑选、猜测或改用标题。 - 用户要正文时用 `dws report detail --report-id `;用户要已读/统计时用 `dws report stats --report-id `。 +## 指定发送人提交 + +- 未传 `--sender-user-id` 时,提交继续走默认 MCP `report.create_report`。 +- 传 `--sender-user-id` 时,CLI 改走钉钉 OAPI `/topapi/report/create`,日志发送人是指定的 userId。 +- OAPI 代提交要求配置自有应用 AppKey/AppSecret,并为应用申请“管理员工日志数据”权限;默认 MCP 凭证不能用于此路径。 + ## 跨产品协作 - 日报内容来源(待办 / 听记 / OA / 邮件 / 群消息)→ 多源采集,按 dws-shared 的 conventions.md 并行执行 diff --git a/skills/multi/dingtalk-report/references/report.md b/skills/multi/dingtalk-report/references/report.md index a1edb8ab..4f9ad26c 100644 --- a/skills/multi/dingtalk-report/references/report.md +++ b/skills/multi/dingtalk-report/references/report.md @@ -176,15 +176,26 @@ Example: dws report entry submit --template-id \ --contents '[{"key":"今日完成","sort":"0","content":"完成了需求评审","contentType":"markdown","type":"1"}]' \ --format json + + # 以指定员工为发送人代提交(需要自有应用凭证和“管理员工日志数据”权限) + dws report entry submit --sender-user-id \ + --template-id --contents-file ./report.json --format json Flags: --template-id string 日志模版 ID (必填),从 template list 返回中取 --contents string 日志内容 JSON 数组 (必填,或用 --contents-file);传 `-` 表示从 stdin 读取 --contents-file string 从文件读取 contents JSON(推荐用于含中文/换行/Markdown 的长内容) --dd-from string 创建来源标识 (默认 dws) + --sender-user-id string 日志发送人 userId;设置后使用自有应用凭证通过钉钉 OAPI 代提交 --to-chat 是否发送到日志接收人单聊 (默认 false,传本 flag 则为 true) --to-user-ids string 接收人 userId,逗号分隔 (可选) ``` +**发送人路由规则**: + +- 不传 `--sender-user-id`:保持原行为,通过 MCP `report.create_report` 以当前登录用户提交。 +- 传 `--sender-user-id`:通过钉钉 OAPI `POST /topapi/report/create` 代指定员工提交;不会在失败时回退 MCP,避免日志显示为错误发送人。 +- OAPI 路径需要自有应用 AppKey/AppSecret(`--client-id` / `--client-secret`、环境变量或 `dws auth login`)和“管理员工日志数据”权限。默认 MCP 凭证不支持该路径。 + **`contents` 数组元素**(与 MCP `create_report` 一致):