Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <userId>` 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
Expand Down
110 changes: 110 additions & 0 deletions docs/superpowers/specs/2026-06-04-report-sender-design.md
Original file line number Diff line number Diff line change
@@ -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 <userId> ...
```

## 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": "<sender-user-id>",
"template_id": "<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.
1 change: 1 addition & 0 deletions internal/app/legacy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
169 changes: 169 additions & 0 deletions internal/app/report_sender_submitter.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading