| package handler |
|
|
| import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| "fmt" |
| "io" |
| "log/slog" |
| "net/http" |
| "net/url" |
| "strings" |
| "sync" |
| "time" |
| ) |
|
|
| |
| type dingTalkClientConfig struct { |
| ClientID string |
| ClientSecret string |
| TokenURL string |
| UserInfoURL string |
| } |
|
|
| type DingTalkClient struct { |
| cfg dingTalkClientConfig |
| appToken string |
| appTokenExp time.Time |
| mu sync.Mutex |
| httpClient *http.Client |
| |
| } |
|
|
| type DingTalkUserTokenResp struct { |
| AccessToken string `json:"accessToken"` |
| RefreshToken string `json:"refreshToken"` |
| ExpireIn int64 `json:"expireIn"` |
| CorpID string `json:"corpId"` |
| } |
|
|
| func (c *DingTalkClient) ExchangeCodeForUserToken(ctx context.Context, code string) (*DingTalkUserTokenResp, error) { |
| body := map[string]string{ |
| "clientId": c.cfg.ClientID, |
| "clientSecret": c.cfg.ClientSecret, |
| "code": code, |
| "grantType": "authorization_code", |
| } |
| payload, _ := json.Marshal(body) |
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.TokenURL, bytes.NewReader(payload)) |
| if err != nil { |
| return nil, err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| resp, err := c.httpClient.Do(req) |
| if err != nil { |
| return nil, err |
| } |
| defer func() { _ = resp.Body.Close() }() |
| raw, _ := io.ReadAll(resp.Body) |
| if resp.StatusCode != http.StatusOK { |
| return nil, parseDingTalkErr(raw, resp.StatusCode) |
| } |
| var out DingTalkUserTokenResp |
| if err := json.Unmarshal(raw, &out); err != nil { |
| return nil, err |
| } |
| if strings.TrimSpace(out.AccessToken) == "" { |
| return nil, parseDingTalkErr(raw, resp.StatusCode) |
| } |
| return &out, nil |
| } |
|
|
| type DingTalkAPIError struct { |
| Code string |
| Message string |
| HTTP int |
| } |
|
|
| func (e *DingTalkAPIError) Error() string { |
| return fmt.Sprintf("dingtalk api error code=%s msg=%s http=%d", e.Code, e.Message, e.HTTP) |
| } |
|
|
| func parseDingTalkErr(raw []byte, status int) error { |
| var v struct { |
| Code string `json:"code"` |
| Message string `json:"message"` |
| ErrCode int `json:"errcode"` |
| ErrMsg string `json:"errmsg"` |
| } |
| _ = json.Unmarshal(raw, &v) |
| code := v.Code |
| if code == "" && v.ErrCode != 0 { |
| code = fmt.Sprintf("%d", v.ErrCode) |
| } |
| msg := v.Message |
| if msg == "" { |
| msg = v.ErrMsg |
| } |
| return &DingTalkAPIError{Code: code, Message: msg, HTTP: status} |
| } |
|
|
| |
| |
| func (c *DingTalkClient) GetUnionIdByUserToken(ctx context.Context, userToken string) (unionID string, nick string, err error) { |
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.UserInfoURL, nil) |
| if err != nil { |
| return "", "", err |
| } |
| req.Header.Set("x-acs-dingtalk-access-token", userToken) |
| resp, err := c.httpClient.Do(req) |
| if err != nil { |
| return "", "", err |
| } |
| defer func() { _ = resp.Body.Close() }() |
| raw, _ := io.ReadAll(resp.Body) |
| if resp.StatusCode != http.StatusOK { |
| return "", "", parseDingTalkErr(raw, resp.StatusCode) |
| } |
| var v struct { |
| UnionID string `json:"unionId"` |
| Nick string `json:"nick"` |
| } |
| if err := json.Unmarshal(raw, &v); err != nil { |
| return "", "", err |
| } |
| if strings.TrimSpace(v.UnionID) == "" { |
| return "", "", parseDingTalkErr(raw, resp.StatusCode) |
| } |
| return v.UnionID, v.Nick, nil |
| } |
|
|
| type DingTalkStaffInfo struct { |
| UserID string |
| Name string |
| Nickname string |
| Email string |
| DeptIDs []int64 |
| |
| } |
|
|
| |
| |
| func (c *DingTalkClient) dingTalkOAPIBase() string { |
| u, err := url.Parse(c.cfg.UserInfoURL) |
| if err != nil || u.Scheme == "" || u.Host == "" { |
| return "https://oapi.dingtalk.com" |
| } |
| host := u.Host |
| if strings.HasPrefix(host, "api.") { |
| host = "oapi." + strings.TrimPrefix(host, "api.") |
| } |
| return u.Scheme + "://" + host |
| } |
|
|
| func (c *DingTalkClient) GetAppToken(ctx context.Context) (string, error) { |
| c.mu.Lock() |
| defer c.mu.Unlock() |
| if c.appToken != "" && time.Now().Before(c.appTokenExp) { |
| return c.appToken, nil |
| } |
| body := map[string]string{"appKey": c.cfg.ClientID, "appSecret": c.cfg.ClientSecret} |
| payload, _ := json.Marshal(body) |
| |
| |
| appTokenURL := strings.Replace(c.cfg.TokenURL, "/oauth2/userAccessToken", "/oauth2/accessToken", 1) |
| if !strings.Contains(appTokenURL, "accessToken") && !strings.Contains(appTokenURL, "gettoken") { |
| appTokenURL = c.cfg.TokenURL |
| } |
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, appTokenURL, bytes.NewReader(payload)) |
| if err != nil { |
| return "", err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| resp, err := c.httpClient.Do(req) |
| if err != nil { |
| return "", err |
| } |
| defer func() { _ = resp.Body.Close() }() |
| raw, _ := io.ReadAll(resp.Body) |
| if resp.StatusCode != http.StatusOK { |
| return "", parseDingTalkErr(raw, resp.StatusCode) |
| } |
| var v struct { |
| AccessToken string `json:"accessToken"` |
| ExpireIn int64 `json:"expireIn"` |
| } |
| if err := json.Unmarshal(raw, &v); err != nil { |
| return "", err |
| } |
| if v.AccessToken == "" { |
| return "", parseDingTalkErr(raw, resp.StatusCode) |
| } |
| c.appToken = v.AccessToken |
| ttl := v.ExpireIn |
| if ttl > 200 { |
| ttl -= 200 |
| } |
| c.appTokenExp = time.Now().Add(time.Duration(ttl) * time.Second) |
| return c.appToken, nil |
| } |
|
|
| func (c *DingTalkClient) GetUserIdByUnionId(ctx context.Context, unionID string) (string, error) { |
| appToken, err := c.GetAppToken(ctx) |
| if err != nil { |
| return "", err |
| } |
| body := map[string]string{"unionid": unionID} |
| payload, _ := json.Marshal(body) |
| |
| |
| var targetURL string |
| if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") { |
| targetURL = c.dingTalkOAPIBase() + "/topapi/user/getbyunionid?access_token=" + url.QueryEscape(appToken) |
| } else { |
| targetURL = c.cfg.UserInfoURL |
| } |
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload)) |
| if err != nil { |
| return "", err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| resp, err := c.httpClient.Do(req) |
| if err != nil { |
| return "", err |
| } |
| defer func() { _ = resp.Body.Close() }() |
| raw, _ := io.ReadAll(resp.Body) |
| if resp.StatusCode != http.StatusOK { |
| return "", parseDingTalkErr(raw, resp.StatusCode) |
| } |
| var v struct { |
| Result struct { |
| UserID string `json:"userid"` |
| } `json:"result"` |
| ErrCode int `json:"errcode"` |
| ErrMsg string `json:"errmsg"` |
| } |
| if err := json.Unmarshal(raw, &v); err != nil { |
| return "", err |
| } |
| if v.ErrCode != 0 { |
| return "", parseDingTalkErr(raw, resp.StatusCode) |
| } |
| if strings.TrimSpace(v.Result.UserID) == "" { |
| return "", parseDingTalkErr(raw, resp.StatusCode) |
| } |
| return v.Result.UserID, nil |
| } |
|
|
| |
| type DingTalkDeptInfo struct { |
| DeptID int64 |
| Name string |
| ParentID int64 |
| } |
|
|
| |
| |
| func (c *DingTalkClient) GetDeptInfo(ctx context.Context, deptID int64) (*DingTalkDeptInfo, error) { |
| appToken, err := c.GetAppToken(ctx) |
| if err != nil { |
| return nil, err |
| } |
| body := map[string]any{"dept_id": deptID, "language": "zh_CN"} |
| payload, _ := json.Marshal(body) |
| var targetURL string |
| if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") { |
| targetURL = c.dingTalkOAPIBase() + "/topapi/v2/department/get?access_token=" + url.QueryEscape(appToken) |
| } else { |
| targetURL = c.cfg.UserInfoURL |
| } |
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload)) |
| if err != nil { |
| return nil, err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| resp, err := c.httpClient.Do(req) |
| if err != nil { |
| return nil, err |
| } |
| defer func() { _ = resp.Body.Close() }() |
| raw, _ := io.ReadAll(resp.Body) |
| if resp.StatusCode != http.StatusOK { |
| return nil, parseDingTalkErr(raw, resp.StatusCode) |
| } |
| var v struct { |
| Result struct { |
| DeptID int64 `json:"dept_id"` |
| Name string `json:"name"` |
| ParentID int64 `json:"parent_id"` |
| } `json:"result"` |
| ErrCode int `json:"errcode"` |
| ErrMsg string `json:"errmsg"` |
| } |
| if err := json.Unmarshal(raw, &v); err != nil { |
| return nil, err |
| } |
| if v.ErrCode != 0 { |
| return nil, parseDingTalkErr(raw, resp.StatusCode) |
| } |
| return &DingTalkDeptInfo{ |
| DeptID: v.Result.DeptID, |
| Name: v.Result.Name, |
| ParentID: v.Result.ParentID, |
| }, nil |
| } |
|
|
| func (c *DingTalkClient) GetStaffInfoByUserId(ctx context.Context, userID string) (*DingTalkStaffInfo, error) { |
| appToken, err := c.GetAppToken(ctx) |
| if err != nil { |
| return nil, err |
| } |
| body := map[string]string{"userid": userID} |
| payload, _ := json.Marshal(body) |
| |
| var targetURL string |
| if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") { |
| targetURL = c.dingTalkOAPIBase() + "/topapi/v2/user/get?access_token=" + url.QueryEscape(appToken) |
| } else { |
| targetURL = c.cfg.UserInfoURL |
| } |
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload)) |
| if err != nil { |
| return nil, err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| resp, err := c.httpClient.Do(req) |
| if err != nil { |
| return nil, err |
| } |
| defer func() { _ = resp.Body.Close() }() |
| raw, _ := io.ReadAll(resp.Body) |
| if resp.StatusCode != http.StatusOK { |
| return nil, parseDingTalkErr(raw, resp.StatusCode) |
| } |
| var v struct { |
| Result struct { |
| UserID string `json:"userid"` |
| Name string `json:"name"` |
| Nickname string `json:"nickname"` |
| Email string `json:"email"` |
| OrgEmail string `json:"org_email"` |
| Extension string `json:"extension"` |
| DeptID []int64 `json:"dept_id_list"` |
| } `json:"result"` |
| ErrCode int `json:"errcode"` |
| ErrMsg string `json:"errmsg"` |
| } |
| if err := json.Unmarshal(raw, &v); err != nil { |
| return nil, err |
| } |
| if v.ErrCode != 0 { |
| return nil, parseDingTalkErr(raw, resp.StatusCode) |
| } |
| if strings.TrimSpace(v.Result.UserID) == "" { |
| return nil, parseDingTalkErr(raw, resp.StatusCode) |
| } |
| |
| email := strings.TrimSpace(v.Result.OrgEmail) |
| emailSource := "org_email" |
| if email == "" { |
| email = strings.TrimSpace(v.Result.Email) |
| emailSource = "email" |
| } |
| extensionParsed := false |
| if email == "" && strings.TrimSpace(v.Result.Extension) != "" { |
| var ext map[string]string |
| if err := json.Unmarshal([]byte(v.Result.Extension), &ext); err == nil { |
| extensionParsed = true |
| if v, ok := ext["企业邮箱"]; ok { |
| email = strings.TrimSpace(v) |
| emailSource = "extension.企业邮箱" |
| } |
| } |
| } |
| if email == "" { |
| emailSource = "none" |
| } |
| slog.Info("dingtalk staff fetched", |
| "userid", v.Result.UserID, |
| "name_present", v.Result.Name != "", |
| "nickname_present", v.Result.Nickname != "", |
| "name_eq_nickname", v.Result.Name != "" && v.Result.Name == v.Result.Nickname, |
| "email_present", v.Result.Email != "", |
| "org_email_present", v.Result.OrgEmail != "", |
| "extension_present", v.Result.Extension != "", |
| "extension_parsed", extensionParsed, |
| "email_source", emailSource, |
| "dept_count", len(v.Result.DeptID), |
| ) |
| return &DingTalkStaffInfo{ |
| UserID: v.Result.UserID, |
| Name: v.Result.Name, |
| Nickname: v.Result.Nickname, |
| Email: email, |
| DeptIDs: v.Result.DeptID, |
| }, nil |
| } |
|
|