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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

*.out
.idea
.vscode

report.json
coverage.txt
Expand Down
44 changes: 44 additions & 0 deletions docs/services/lark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Lark

## URL Format

!!! info ""
lark://__`host`__/__`token`__?[secret=__`secret`__]

--8<-- "docs/services/lark/config.md"

## Create Custom Bot in Lark

Official Documents: [Link](https://open.larksuite.com/document/client-docs/bot-v3/add-custom-bot)

1. Invite custom bot join group.

a. Enter the target group, click the `More` button in the upper right corner of the group, and then click `Settings`.

b. On the right-side `Settings`, click on `Group Bot`.

c. Click `Add a Bot` on the `Group Bot`.

b. In `Add Bot` dialog box, find the `Custom Bot` and add it.

e. Set the name and description of the custom robot, and click `Add`.

2. Get the webhook address of the custom robot and click `Finish`.

## Get Host and Token of Custom Bot

If you are using `Lark`, then the `Host` is `open.larksuite.com`.

If you are using `Feishu` or `飞书`, then the `Host` is `open.feishu.cn`.

`Token` is the last part of the webhook address. For example, if the webhook address is `https://open.larksuite.com/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxx`, then the token corresponds to `xxxxxxxxxxxxxxxxx`.

## Get Secret of Custom Bot

1. In the group settings, open the bot list, find the custom bot and click on it to enter the configuration page.

2. In the `Security Settings`, select `Signature Verification`.

3. Click `Copy` to copy the secret.

4. Click `Save` to make the configuration take effect.
1 change: 1 addition & 0 deletions docs/services/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Click on the service for a more thorough explanation. <!-- @formatter:off -->
| [Teams](./teams.md) | *teams://__`group`__@__`tenant`__/__`altId`__/__`groupOwner`__?host=__`organization`__.webhook.office.com* |
| [Telegram](./telegram.md) | *telegram://__`token`__@telegram?chats=__`@channel-1`__[,__`chat-id-1`__,...]* |
| [Zulip Chat](./zulip.md) | *zulip://__`bot-mail`__:__`bot-key`__@__`zulip-domain`__/?stream=__`name-or-id`__&topic=__`name`__* |
| [Lark](./lark.md) | *lark://__`host`__/__`token`__?secret=__`secret`__* |

## Specialized services

Expand Down
2 changes: 2 additions & 0 deletions pkg/router/servicemap.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/containrrr/shoutrrr/pkg/services/gotify"
"github.com/containrrr/shoutrrr/pkg/services/ifttt"
"github.com/containrrr/shoutrrr/pkg/services/join"
"github.com/containrrr/shoutrrr/pkg/services/lark"
"github.com/containrrr/shoutrrr/pkg/services/logger"
"github.com/containrrr/shoutrrr/pkg/services/matrix"
"github.com/containrrr/shoutrrr/pkg/services/mattermost"
Expand Down Expand Up @@ -46,4 +47,5 @@ var serviceMap = map[string]func() t.Service{
"teams": func() t.Service { return &teams.Service{} },
"telegram": func() t.Service { return &telegram.Service{} },
"zulip": func() t.Service { return &zulip.Service{} },
"lark": func() t.Service { return &lark.Service{} },
}
145 changes: 145 additions & 0 deletions pkg/services/lark/lark.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package lark

import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"

"github.com/containrrr/shoutrrr/pkg/format"
"github.com/containrrr/shoutrrr/pkg/services/standard"
"github.com/containrrr/shoutrrr/pkg/types"
)

const (
apiFormat = "https://%s/open-apis/bot/v2/hook/%s"
maxLength = 4096
defaultTime = 30 * time.Second
)

type Service struct {
standard.Standard
config *Config
pkr format.PropKeyResolver
}

var (
ErrInvalidHost = errors.New("invalid host, use 'open.larksuite.com' or 'open.feishu.cn'")
ErrNoPath = errors.New("no path, path like 'xxx' in 'https://open.larksuite.com/open-apis/bot/v2/hook/xxx'")
ErrLargeMessage = errors.New("message exceeds the max length")

httpClient = &http.Client{Timeout: defaultTime}
)

const (
larkHost = "open.larksuite.com"
feishuHost = "open.feishu.com"
)

// Send notification to Lark
func (service *Service) Send(message string, params *types.Params) error {
if len(message) > maxLength {
return ErrLargeMessage
}

config := *service.config
if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil {
return err
}

if config.Host != larkHost && config.Host != feishuHost {
return ErrInvalidHost
}

if config.Path == "" {
return ErrNoPath
}

return service.sendMessage(message, config)
}

// Initialize loads ServiceConfig from configURL and sets logger for this Service
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
service.Logger.SetLogger(logger)
service.config = &Config{}
service.pkr = format.NewPropKeyResolver(service.config)
if err := service.config.setURL(&service.pkr, configURL); err != nil {
return err
}
return nil
}

func (service *Service) genSign(secret string, timestamp int64) string {
//timestamp + key calculate sha256, then base64 encode
stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + secret

var data []byte
h := hmac.New(sha256.New, []byte(stringToSign))
h.Write(data)
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
return signature
}

func (service *Service) sendMessage(message string, cfg Config) error {
url := fmt.Sprintf(apiFormat, cfg.Host, cfg.Path)
body := service.getRequestBody(message, cfg.Title, cfg.Secret)
data, err := json.Marshal(body)
if err != nil {
return err
}
service.Logf("Lark Request Body: %s", string(data))
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err = io.ReadAll(resp.Body)
if err != nil {
return err
}
response := Response{}
if err := json.Unmarshal(data, &response); err != nil {
return err
}
if response.Code == 0 {
return nil
}
return fmt.Errorf("code: %d, msg: %s", response.Code, response.Msg)
}

func (service *Service) getRequestBody(message, title, secret string) *RequestBody {
body := &RequestBody{}
if secret != "" {
ts := time.Now().Unix()
body.Timestamp = strconv.FormatInt(ts, 10)
body.Sign = service.genSign(secret, ts)
}
if title == "" {
body.MsgType = MsgTypeText
body.Content.Text = message
return body
}
body.MsgType = MsgTypePost
body.Content.Post = &Post{
En: &Message{
Title: title,
Content: [][]Item{{
{Tag: TagValueText, Text: message},
}},
},
}
return body
}
59 changes: 59 additions & 0 deletions pkg/services/lark/lark_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package lark

import (
"net/url"
"strings"

"github.com/containrrr/shoutrrr/pkg/format"
"github.com/containrrr/shoutrrr/pkg/types"
)

const Scheme = "lark"

type Config struct {
Host string `desc:"Custom bot URL Host" default:"open.larksuite.com" url:"Host"`
Secret string `desc:"Custom bot secret" default:"" key:"secret"`
Path string `desc:"Custom bot token" url:"Path"`
Title string `desc:"Message Title" default:"" key:"title"`
}

func (config *Config) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{}
}

func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}

func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {

return &url.URL{
Host: config.Host,
Path: "/" + config.Path,
Scheme: Scheme,
ForceQuery: true,
RawQuery: format.BuildQuery(resolver),
}

}

func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}

func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {

config.Host = url.Host
config.Path = strings.Trim(url.Path, "/")
// config.Password = url.Hostname()

for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return err
}
}

return nil
}
49 changes: 49 additions & 0 deletions pkg/services/lark/lark_message.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package lark

type RequestBody struct {
MsgType MsgType `json:"msg_type"`
Content Content `json:"content"`
Timestamp string `json:"timestamp,omitempty"`
Sign string `json:"sign,omitempty"`
}

type MsgType string

const (
MsgTypeText MsgType = "text"
MsgTypePost MsgType = "post"
)

type Content struct {
Text string `json:"text,omitempty"`
Post *Post `json:"post,omitempty"`
}

type Post struct {
Zh *Message `json:"zh_cn,omitempty"`
En *Message `json:"en_us,omitempty"`
}

type Message struct {
Title string `json:"title"`
Content [][]Item `json:"content"`
}

type Item struct {
Tag TagValue `json:"tag"`
Text string `json:"text,omitempty"`
Link string `json:"href,omitempty"`
}

type TagValue string

const (
TagValueText TagValue = "text"
TagValueLink TagValue = "a"
)

type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
Loading