commit 22302af994dc7e9a285ebb2f77cf50277a1f108a Author: lddsb Date: Sun Dec 2 22:40:50 2018 +0800 complete the first version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7301c65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +* +!.gitignore +!main.go +!plugin.go +!webhook.go +!README.md +!Dockerfile \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ebc4559 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM alpine:latest + +RUN apk update && \ + apk add \ + ca-certificates && \ + rm -rf /var/cache/apk/* + +ADD drone-dingtalk-message /bin/ +ENTRYPOINT ["/bin/drone-dingtalk-message"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..075b8ed --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Drone CI DingTalk Message Plugin +### Development + +> First get this repo +```shell +go get github.com/lddsb/drone-dingtalk-message +``` +> get dependent lib +```shell +go get github.com/urfave/cli +``` +> build +```shell +cd $GOPATH/src/github.com/lddsb/drone-dingtalk-message && go build . +``` +> run +```shell +./drone-dingtalk-notification -h +``` +### Drone CI Plugin Config +```yaml +pipeline: + # other step here + message: + image: lddsb/drone-dingtalk-message + environment: + - PLUGIN_ACCESS_TOKEN=xxx + - PLUGIN_MSG_TYPE=markdown +``` + +### Drone CI Plugin Configs + +|ENV|description|is require| +|:-:|:-:|:-:|:-:| +|PLUGIN_ACCESS_TOKEN|dingtalk access token|Yes| +|PLUGIN_MSG_TYPE|dingtalk message type, optional: text, markdown, link, actionCard, feedCard|Yes| +|PLUGIN_MSG_AT_ALL|dingtalk message at all in a group by robot(`default close`)|No| +|PLUGIN_MSG_AT_MOBILES|dingtalk message at anyone in a group by mobiles(`default empty`)|No| +|PLUGIN_LANG|dingtalk message lang, optional: zh_CN and en_US(`default zh_CN`)|No| diff --git a/main.go b/main.go new file mode 100644 index 0000000..f7c3bb4 --- /dev/null +++ b/main.go @@ -0,0 +1,349 @@ +package main + +import ( + "fmt" + "log" + "os" + + _ "github.com/joho/godotenv/autoload" + "github.com/urfave/cli" +) + +var Version = "0.1.1202" + +func main() { + app := cli.NewApp() + app.Name = "Drone Dingtalk Message Plugin" + app.Usage = "Sending message to Dingtalk group by robot using webhook" + app.Copyright = "© 2018 Dee Luo" + app.Authors = []cli.Author{ + { + Name: "Dee Luo", + Email: "luodi0128@gmail.com", + }, + } + app.Action = run + app.Version = Version + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "config.token", + Usage: "dingtalk webhook access token", + EnvVar: "PLUGIN_ACCESS_TOKEN", + }, + cli.StringFlag{ + Name: "config.lang", + Value: "zh_CN", + Usage: "the lang display (zh_CN or en_US, zh_CN is default)", + EnvVar: "PLUGIN_LANG", + }, + cli.StringFlag{ + Name: "config.message.type", + Usage: "dingtalk message type, like text, markdown, action card, link and feed card...", + EnvVar: "PLUGIN_MSG_TYPE", + }, + cli.StringFlag{ + Name: "config.message.at.all", + Usage: "at all in a message(only text and markdown type message can at)", + EnvVar: "PLUGIN_MSG_AT_ALL", + }, + cli.StringFlag{ + Name: "config.message.at.mobiles", + Usage: "at someone in a dingtalk group need this guy bind's mobile", + EnvVar: "PLUGIN_MSG_AT_MOBILES", + }, + cli.BoolFlag{ + Name: "drone", + Usage: "indicates the runtime environment is Drone", + EnvVar: "DRONE", + }, + cli.StringFlag{ + Name: "branch", + Usage: "providers the branch for the current build", + EnvVar: "DRONE_BRANCH", + }, + // commit args start + cli.StringFlag{ + Name: "remote.url", + Usage: "git remote url", + EnvVar: "DRONE_REMOTE_URL", + }, + cli.StringFlag{ + Name: "commit.author.avatar", + Usage: "providers the author avatar url for the current commit", + EnvVar: "DRONE_COMMIT_AUTHOR_AVATAR", + }, + cli.StringFlag{ + Name: "commit.author.email", + Usage: "providers the author email for the current commit", + EnvVar: "DRONE_COMMIT_AUTHOR_EMAIL", + }, + cli.StringFlag{ + Name: "commit.author.name", + Usage: "providers the author name for the current commit", + EnvVar: "DRONE_COMMIT_AUTHOR", + }, + cli.StringFlag{ + Name: "commit.branch", + Usage: "providers the branch for the current build", + EnvVar: "DRONE_COMMIT_BRANCH", + Value: "master", + }, + cli.StringFlag{ + Name: "commit.link", + Usage: "providers the http link to the current commit in the remote source code management system(e.g.GitHub)", + EnvVar: "DRONE_COMMIT_LINK", + }, + cli.StringFlag{ + Name: "commit.message", + Usage: "providers the commit message for the current build", + EnvVar: "DRONE_COMMIT_MESSAGE", + }, + cli.StringFlag{ + Name: "commit.ref", + Usage: "providers the reference for the current build", + EnvVar: "DRONE_COMMIT_REF", + }, + cli.StringFlag{ + Name: "commit.sha", + Usage: "providers the commit sha for the current build", + EnvVar: "DRONE_COMMIT_SHA", + }, + // commit args end + cli.StringFlag{ + Name: "git.url.http", + Usage: "providers the repository git+http url", + EnvVar: "DRONE_GIT_HTTP_URL", + }, + cli.StringFlag{ + Name: "git.url.ssh", + Usage: "providers the repository git+ssh url", + EnvVar: "DRONE_GIT_SSH_URL", + }, + cli.StringFlag{ + Name: "machine", + Usage: "providers the Drone agent hostname", + EnvVar: "DRONE_MACHINE", + }, + cli.StringFlag{ + Name: "pull.request", + Usage: "providers the pull request number for the current build.This value is only set if the build event is of type pull request", + EnvVar: "DRONE_PULL_REQUEST", + }, + // repo args start + cli.StringFlag{ + Name: "repo.fullname", + Usage: "providers the full name of the repository", + EnvVar: "DRONE_REPO", + }, + cli.StringFlag{ + Name: "repo.owner", + Usage: "repository owner", + EnvVar: "DRONE_REPO_OWNER", + }, + cli.StringFlag{ + Name: "repo.branch", + Usage: "providers the default repository branch(e.g.master)", + EnvVar: "DRONE_REPO_BRANCH", + }, + cli.StringFlag{ + Name: "repo.link", + Usage: "providers the repository http link", + EnvVar: "DRONE_REPO_LINK", + }, + cli.StringFlag{ + Name: "repo.name", + Usage: "providers the repository name", + EnvVar: "DRONE_REPO_NAME", + }, + cli.StringFlag{ + Name: "repo.avatar", + Usage: "repository avatar", + EnvVar: "DRONE_REPO_AVATAR", + }, + cli.StringFlag{ + Name: "repo.namespace", + Usage: "providers the repository namespace(e.g. account owner)", + EnvVar: "DRONE_REPO_NAMESPACE", + }, + cli.BoolFlag{ + Name: "repo.private", + Usage: "indicates the repository is public or private", + EnvVar: "DRONE_REPO_PRIVATE", + }, + cli.BoolFlag{ + Name: "repo.trusted", + Usage: "repository is trusted", + EnvVar: "DRONE_REPO_TRUSTED", + }, + // repo args end + cli.StringFlag{ + Name: "runner.host", + Usage: "provider are Drone agent hostname", + EnvVar: "DRONE_RUNNER_HOST", + }, + cli.StringFlag{ + Name: "runner.hostname", + Usage: "providers the Drone agent hostname", + EnvVar: "DRONE_RUNNER_HOSTNAME", + }, + cli.StringFlag{ + Name: "runner.platform", + Usage: "providers the Drone agent os and architecture", + EnvVar: "DRONE_RUNNER_PLATFORM", + }, + cli.StringFlag{ + Name: "runner.label", + Usage: "404 not found", + EnvVar: "DRONE_RUNNER_LABEL", + }, + cli.StringFlag{ + Name: "source.branch", + Usage: "providers the source branch for a pull request", + EnvVar: "DRONE_SOURCE_BRANCH", + }, + cli.StringFlag{ + Name: "target.branch", + Usage: "providers the target branch for a pull request", + EnvVar: "DRONE_TARGET_BRANCH", + }, + cli.StringFlag{ + Name: "system.host", + Usage: "providers the Drone server hostname", + EnvVar: "DRONE_SYSTEM_HOST", + }, + cli.StringFlag{ + Name: "system.hostname", + Usage: "providers the Drone server hostname", + EnvVar: "DRONE_SYSTEM_HOSTNAME", + }, + cli.StringFlag{ + Name: "system.version", + Usage: "providers the Drone server version", + EnvVar: "DRONE_SYSTEM_VERSION", + }, + cli.StringFlag{ + Name: "tag", + Usage: "providers the tag name for the current build.This value is only set if the build event is of type tag", + EnvVar: "DRONE_TAG", + }, + // build args start + cli.StringFlag{ + Name: "build.event", + Value: "push", + Usage: "build event", + EnvVar: "DRONE_BUILD_EVENT", + }, + cli.IntFlag{ + Name: "build.number", + Usage: "build number", + EnvVar: "DRONE_BUILD_NUMBER", + }, + cli.IntFlag{ + Name: "build.created", + Usage: "build created", + EnvVar: "DRONE_BUILD_CREATED", + }, + cli.IntFlag{ + Name: "build.started", + Usage: "build started", + EnvVar: "DRONE_BUILD_STARTED", + }, + cli.IntFlag{ + Name: "build.finished", + Usage: "build finished", + EnvVar: "DRONE_BUILD_FINISHED", + }, + cli.StringFlag{ + Name: "build.status", + Usage: "build status", + Value: "success", + EnvVar: "DRONE_BUILD_STATUS", + }, + cli.StringFlag{ + Name: "build.link", + Usage: "build link", + EnvVar: "DRONE_BUILD_LINK", + }, + cli.StringFlag{ + Name: "build.deploy", + Usage: "build deployment target", + EnvVar: "DRONE_DEPLOY_TO", + }, + cli.BoolFlag{ + Name: "yaml.verified", + Usage: "build yaml is verified", + EnvVar: "DRONE_YAML_VERIFIED", + }, + cli.BoolFlag{ + Name: "yaml.signed", + Usage: "build yaml is signed", + EnvVar: "DRONE_YAML_SIGNED", + }, + // build args end + cli.Float64Flag{ + Name: "job.started", + Usage: "job started", + EnvVar: "DRONE_JOB_STARTED", + }, + cli.Float64Flag{ + Name: "job.finished", + Usage: "job finished", + EnvVar: "DRONE_JOB_FINISHED", + }, + } + + if err := app.Run(os.Args); nil != err { + log.Println(err) + os.Exit(1) + } +} + +// run with args +func run(c *cli.Context) { + plugin := Plugin{ + // repo info + Repo: Repo{ + FullName: c.String("repo.fullname"), + Owner: c.String("repo.owner"), + Name: c.String("repo.name"), + }, + // build info + Build: Build{ + Action: c.String("build.action"), + Number: c.Int("build.number"), + Started: c.Float64("build.started"), + Created: c.Float64("build.created"), + Event: c.String("build.event"), + Status: c.String("build.status"), + Link: c.String("build.link"), + }, + Commit: Commit{ + Sha: c.String("commit.sha"), + Branch: c.String("commit.branch"), + Message: c.String("commit.message"), + Link: c.String("commit.link"), + Authors: struct { + Avatar string + Email string + Name string + }{ + Avatar: c.String("commit.author.avatar"), + Email: c.String("commit.author.email"), + Name: c.String("commit.author.name"), + }, + }, + // custom config + Config: Config{ + AccessToken: c.String("config.token"), + Lang: c.String("config.lang"), + IsAtALL: c.Bool("config.message.at.all"), + MsgType: c.String("config.message.type"), + Mobiles: c.String("config.message.at.mobiles"), + }, + } + + if err := plugin.Exec(); nil != err { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..2d92785 --- /dev/null +++ b/plugin.go @@ -0,0 +1,199 @@ +package main + +import ( + "errors" + "fmt" + "log" + "strings" +) + +type ( + // repo base info + Repo struct { + Owner string // providers the repository owner name + Name string // providers the repository name + Branch string // providers the default repository branch(e.g.master) + Link string // providers the repository http link + NameSpace string // providers the repository namespace(e.g.account owner) + Private bool // indicates the repository is public or private + Visibility string // providers the repository visibility level.Possible values are public,private and internal + SCM string // providers the repository version control system + FullName string // repository full name + } + // build info + Build struct { + Action string // document description not found + Created float64 // providers the date and time when the build was created in the system + Event string // providers the current build event + Number int // providers the current build number + Started float64 // providers the date and time when the build was started + Status string // providers the current build status + Link string // providers the current build link + } + // commit info + Commit struct { + After string // providers the commit sha for the current build + Author string // providers the author username for the current commit + Before string // providers the parent commit sha for the current build + Branch string // providers the branch for the current commit + Link string // providers the http link to the current commit in the remote source code management system(e.g.GitHub) + Message string // providers the commit message for the current build + Ref string // providers the reference for the current build + Sha string // providers the commit sha for the current build + // repo author info + Authors struct { + Avatar string // providers the author avatar for the current commit + Email string // providers the author email for the current commit + Name string // providers the author name for the current commit + } + } + // git url info + Git struct { + HttpUrl string // providers the repository git+http url + SSHUrl string // providers the repository git+ssh url + } + // Drone runner info + Runner struct { + Host string // providers the Drone agent hostname + Hostname string // providers the Drone agent hostname + Platform string // providers the Drone agent os and architecture + Label string // document description not found + } + // Drone system info + System struct { + Host string // providers the Drone server hostname + Hostname string // providers the Drone server hostname + Version string // providers the Drone server version + } + // plugin private config + Config struct { + AccessToken string + Message string + Lang string + IsAtALL bool + Mobiles string + Username string + AvatarURL string + MsgType string + LinkUrls string + LinkTitles string + HideAvatar bool + BtnOrientation bool + PicURL string + MsgURL string + } + // plugin all config + Plugin struct { + Git Git + Runner Runner + System System + Commit Commit + Repo Repo + Build Build + Config Config + WebHook *WebHook + } +) + +func (p *Plugin) Exec() error { + log.Println("start execute sending...") + if 0 == len(p.Config.AccessToken) { + msg := "missing dingtalk access token" + log.Println(msg) + return errors.New(msg) + } + log.Println("access token pass...") + p.WebHook = NewWebHook(p.Config.AccessToken) + mobiles := strings.Split(p.Config.Mobiles, ",") + linkUrls := strings.Split(p.Config.LinkUrls, ",") + linkTitles := strings.Split(p.Config.LinkTitles, ",") + log.Println("sending message type: " + p.Config.MsgType) + switch strings.ToLower(p.Config.MsgType) { + case "markdown": + err := p.WebHook.SendMarkdownMsg( + "You have a new message...", + p.baseTpl(), + p.Config.IsAtALL, + mobiles... + ) + if nil != err { + log.Println(err) + return err + } + case "text": + err := p.WebHook.SendTextMsg(p.baseTpl(), p.Config.IsAtALL, mobiles...) + if nil != err { + log.Println(err) + return err + } + case "actioncard": + err := p.WebHook.SendActionCardMsg( + "A actionCard title", + p.baseTpl(), + linkUrls, + linkTitles, + p.Config.HideAvatar, + p.Config.BtnOrientation, + ) + if nil != err { + log.Println(err) + return err + } + case "link": + err := p.WebHook.SendLinkMsg(p.Build.Status, p.baseTpl(), p.Commit.Authors.Avatar, p.Build.Link) + if nil != err { + log.Println(err) + return err + } + default: + msg := "not support message type" + log.Println(msg) + return errors.New(msg) + } + log.Println("send " + p.Config.MsgType + " message success!") + return nil +} + +func (p *Plugin) baseTpl() string { + tpl := "" + switch strings.ToLower(p.Config.MsgType) { + case "markdown": + tpl = fmt.Sprintf(`# **%s** +### [%s](%s) +##### %s (%s) +##### @%s +##### %s(%s) +`, + strings.Title(p.Build.Status), + strings.TrimSpace(p.Commit.Message), + p.Build.Link, + p.Repo.FullName, + p.Commit.Branch, + p.Commit.Sha, + p.Commit.Authors.Name, + p.Commit.Authors.Email) + case "text": + tpl = fmt.Sprintf(`[%s] %s +%s (%s) +@%s +%s (%s) +`, + p.Build.Status, + strings.TrimSpace(p.Commit.Message), + p.Repo.FullName, + p.Commit.Branch, + p.Commit.Sha, + p.Commit.Authors.Name, + p.Commit.Authors.Email) + case "link": + tpl = fmt.Sprintf(`%s(%s) @%s %s(%s)`, + p.Repo.FullName, + p.Commit.Branch, + p.Commit.Sha[:6], + p.Commit.Authors.Name, + p.Commit.Authors.Email) + + } + + return tpl +} diff --git a/webhook.go b/webhook.go new file mode 100644 index 0000000..39f5c71 --- /dev/null +++ b/webhook.go @@ -0,0 +1,241 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "regexp" +) + +// link message struct +type LinkMsg struct { + Title string `json:"title"` + MessageURL string `json:"messageURL"` + PicURL string `json:"picURL"` +} + +// action card message struct +type ActionCard struct { + Text string `json:"text"` + Title string `json:"title"` + SingleTitle string `json:"singleTitle"` + SingleURL string `json:"singleURL"` + BtnOrientation string `json:"btnOrientation"` + HideAvatar string `json:"hideAvatar"` // robot message avatar + Buttons []struct { + Title string `json:"title"` + ActionURL string `json:"actionURL"` + } `json:"btns"` +} + +// payload +type PayLoad struct { + MsgType string `json:"msgtype"` + Text struct { + Content string `json:"content"` + } `json:"text"` + Link struct { + Title string `json:"title"` + Text string `json:"text"` + PicUrl string `json:"picUrl"` + MessageUrl string `json:"messageUrl"` + } `json:"link"` + Markdown struct { + Title string `json:"title"` + Text string `json:"text"` + } `json:"markdown"` + ActionCard ActionCard `json:"actionCard"` + FeedCard struct { + Links []LinkMsg `json:"links"` + } `json:"feedCard"` + At struct { + AtMobiles []string `json:"atMobiles"` + IsAtAll bool `json:"isAtAll"` + } `json:"at"` +} + +// web hook base config +type WebHook struct { + AccessToken string `json:"accessToken"` +} + +func NewWebHook(accessToken string) *WebHook { + return &WebHook{AccessToken: accessToken} +} + +type Response struct { + ErrorCode int `json:"errcode"` + ErrorMessage string `json:"errmsg"` +} + +var baseApi = "https://oapi.dingtalk.com/robot/send?access_token=" +var reg = `^1([38][0-9]|14[57]|5[^4])\d{8}$` +var regx = regexp.MustCompile(reg) + +// real send request to api +func (w *WebHook) sendPayload(payload *PayLoad) error { + // get config + bs, err := json.Marshal(payload) + if nil != err { + return err + } + // request api + resp, err := http.Post(baseApi+w.AccessToken, "application/json", bytes.NewReader(bs)) + if nil != err { + return err + } + // read response body + body, err := ioutil.ReadAll(resp.Body) + if nil != err { + return err + } + // api unusual + if 200 != resp.StatusCode { + return fmt.Errorf("%d: %s", resp.StatusCode, string(body)) + } + + var result Response + // json decode + err = json.Unmarshal(body, &result) + if nil != err { + return err + } + if 0 != result.ErrorCode { + return fmt.Errorf("%d: %s", result.ErrorCode, result.ErrorMessage) + } + + return nil +} + +// text message +func (w *WebHook) SendTextMsg(content string, isAtAll bool, mobiles ...string) error { + // send request + return w.sendPayload(&PayLoad{ + MsgType: "text", + Text: struct { + Content string `json:"content"` + }{ + Content: content, + }, + At: struct { + AtMobiles []string `json:"atMobiles"` + IsAtAll bool `json:"isAtAll"` + }{ + AtMobiles: mobiles, + IsAtAll: isAtAll, + }, + }) +} + +// with link message +func (w *WebHook) SendLinkMsg(title, content, picURL, msgURL string) error { + return w.sendPayload(&PayLoad{ + MsgType: "link", + Link: struct { + Title string `json:"title"` + Text string `json:"text"` + PicUrl string `json:"picUrl"` + MessageUrl string `json:"messageUrl"` + }{ + Title: title, + Text: content, + PicUrl: picURL, + MessageUrl: msgURL, + }, + }) +} + +// send markdown msg +func (w *WebHook) SendMarkdownMsg(title, content string, isAtAll bool, mobiles ...string) error { + firstLine := false + for _, mobile := range mobiles { + if regx.MatchString(mobile) { + if false == firstLine { + content += "#####" + } + content += " @" + mobile + firstLine = true + } + } + // send request + return w.sendPayload(&PayLoad{ + MsgType: "markdown", + Markdown: struct { + Title string `json:"title"` + Text string `json:"text"` + }{ + Title: title, + Text: content, + }, + At: struct { + AtMobiles []string `json:"atMobiles"` + IsAtAll bool `json:"isAtAll"` + }{ + AtMobiles: mobiles, + IsAtAll: isAtAll, + }, + }) +} + +// send single action card +func (w *WebHook) SendActionCardMsg(title, content string, linkTitles, linkUrls []string, hideAvatar, btnOrientation bool) error { + // validation is empty + if 0 == len(linkTitles) || 0 == len(linkUrls) { + return errors.New("links or titles is empty!") + } + // validation is equal + if len(linkUrls) != len(linkTitles) { + return errors.New("links length and titles length is not equal!") + } + // hide robot avatar + var strHideAvatar = "0" + if hideAvatar { + strHideAvatar = "1" + } + // button sort + var strBtnOrientation = "0" + if btnOrientation { + strBtnOrientation = "1" + } + // button struct + var buttons []struct { + Title string `json:"title"` + ActionURL string `json:"actionURL"` + } + // inject to button + for i := 0; i < len(linkTitles); i++ { + buttons = append(buttons, struct { + Title string `json:"title"` + ActionURL string `json:"actionURL"` + }{ + Title: linkTitles[i], + ActionURL: linkUrls[i], + }) + } + // send request + return w.sendPayload(&PayLoad{ + MsgType: "actionCard", + ActionCard: ActionCard{ + Title: title, + Text: content, + HideAvatar: strHideAvatar, + BtnOrientation: strBtnOrientation, + Buttons: buttons, + }, + }) +} + +// send link card message +func (w *WebHook) SendLinkCardMsg(messages []LinkMsg) error { + return w.sendPayload(&PayLoad{ + MsgType: "feedCard", + FeedCard: struct { + Links []LinkMsg `json:"links"` + }{ + Links: messages, + }, + }) +}