Actions Failure, Succes, Recover Webhooks (#7508)

Implement Actions Success, Failure and Recover webhooks for Forgejo, Gitea, Gogs, Slack, Discord, DingTalk, Telegram, Microsoft Teams, Feishu / Lark Suite, Matrix, WeCom (Wechat Work), Packagist. Some of these webhooks have not been manually tested.

Implement settings for these new webhooks.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] I do not want this change to show in the release notes.
- [x] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/7508): <!--number 7508 --><!--line 0 --><!--description QWN0aW9ucyBGYWlsdXJlLCBTdWNjZXMsIFJlY292ZXIgV2ViaG9va3M=-->Actions Failure, Succes, Recover Webhooks<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7508
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: christopher-besch <mail@chris-besch.com>
Co-committed-by: christopher-besch <mail@chris-besch.com>
This commit is contained in:
christopher-besch 2025-06-03 14:29:19 +02:00 committed by Earl Warren
commit d17aa98262
27 changed files with 683 additions and 12 deletions

View file

@ -207,6 +207,12 @@ func (dc dingtalkConvertor) Package(p *api.PackagePayload) (DingtalkPayload, err
return createDingtalkPayload(text, text, "view package", p.Package.HTMLURL), nil
}
func (dc dingtalkConvertor) Action(p *api.ActionPayload) (DingtalkPayload, error) {
text, _ := getActionPayloadInfo(p, noneLinkFormatter)
return createDingtalkPayload(text, text, "view action", p.Run.HTMLURL), nil
}
func createDingtalkPayload(title, text, singleTitle, singleURL string) DingtalkPayload {
return DingtalkPayload{
MsgType: "actionCard",

View file

@ -325,6 +325,12 @@ func (d discordConvertor) Package(p *api.PackagePayload) (DiscordPayload, error)
return d.createPayload(p.Sender, text, "", p.Package.HTMLURL, color), nil
}
func (d discordConvertor) Action(p *api.ActionPayload) (DiscordPayload, error) {
text, color := getActionPayloadInfo(p, noneLinkFormatter)
return d.createPayload(p.Run.TriggerUser, text, "", p.Run.HTMLURL, color), nil
}
var _ shared.PayloadConvertor[DiscordPayload] = discordConvertor{}
func (discordHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {

View file

@ -191,6 +191,12 @@ func (fc feishuConvertor) Package(p *api.PackagePayload) (FeishuPayload, error)
return newFeishuTextPayload(text), nil
}
func (fc feishuConvertor) Action(p *api.ActionPayload) (FeishuPayload, error) {
text, _ := getActionPayloadInfo(p, noneLinkFormatter)
return newFeishuTextPayload(text), nil
}
type feishuConvertor struct{}
var _ shared.PayloadConvertor[FeishuPayload] = feishuConvertor{}

View file

@ -304,6 +304,25 @@ func getPackagePayloadInfo(p *api.PackagePayload, linkFormatter linkFormatter, w
return text, color
}
func getActionPayloadInfo(p *api.ActionPayload, linkFormatter linkFormatter) (text string, color int) {
runLink := linkFormatter(p.Run.HTMLURL, p.Run.Title)
repoLink := linkFormatter(p.Run.Repo.HTMLURL, p.Run.Repo.FullName)
switch p.Action {
case api.HookActionFailure:
text = fmt.Sprintf("%s Action Failed in %s %s", runLink, repoLink, p.Run.PrettyRef)
color = redColor
case api.HookActionRecover:
text = fmt.Sprintf("%s Action Recovered in %s %s", runLink, repoLink, p.Run.PrettyRef)
color = greenColor
case api.HookActionSuccess:
text = fmt.Sprintf("%s Action Succeeded in %s %s", runLink, repoLink, p.Run.PrettyRef)
color = greenColor
}
return text, color
}
// ToHook convert models.Webhook to api.Hook
// This function is not part of the convert package to prevent an import cycle
func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) {

View file

@ -270,6 +270,22 @@ func pullReleaseTestPayload() *api.ReleasePayload {
}
}
func ActionTestPayload() *api.ActionPayload {
// this is not a complete action payload but enough for testing purposes
return &api.ActionPayload{
Run: &api.ActionRun{
Repo: &api.Repository{
HTMLURL: "http://localhost:3000/test/repo",
Name: "repo",
FullName: "test/repo",
},
PrettyRef: "main",
HTMLURL: "http://localhost:3000/test/repo/actions/runs/69",
Title: "Build release",
},
}
}
func pullRequestTestPayload() *api.PullRequestPayload {
return &api.PullRequestPayload{
Action: api.HookIssueOpened,
@ -675,3 +691,36 @@ func TestGetIssueCommentPayloadInfo(t *testing.T) {
assert.Equal(t, c.color, color, "case %d", i)
}
}
func TestGetActionPayloadInfo(t *testing.T) {
p := ActionTestPayload()
cases := []struct {
action api.HookActionAction
text string
color int
}{
{
api.HookActionFailure,
"Build release Action Failed in test/repo main",
redColor,
},
{
api.HookActionSuccess,
"Build release Action Succeeded in test/repo main",
greenColor,
},
{
api.HookActionRecover,
"Build release Action Recovered in test/repo main",
greenColor,
},
}
for i, c := range cases {
p.Action = c.action
text, color := getActionPayloadInfo(p, noneLinkFormatter)
assert.Equal(t, c.text, text, "case %d", i)
assert.Equal(t, c.color, color, "case %d", i)
}
}

View file

@ -273,6 +273,12 @@ func (m matrixConvertor) Package(p *api.PackagePayload) (MatrixPayload, error) {
return m.newPayload(text)
}
func (m matrixConvertor) Action(p *api.ActionPayload) (MatrixPayload, error) {
text, _ := getActionPayloadInfo(p, htmlLinkFormatter)
return m.newPayload(text)
}
var urlRegex = regexp.MustCompile(`<a [^>]*?href="([^">]*?)">(.*?)</a>`)
func getMessageBody(htmlText string) string {

View file

@ -326,6 +326,23 @@ func (m msteamsConvertor) Package(p *api.PackagePayload) (MSTeamsPayload, error)
), nil
}
func (m msteamsConvertor) Action(p *api.ActionPayload) (MSTeamsPayload, error) {
title, color := getActionPayloadInfo(p, noneLinkFormatter)
// TODO: is TriggerUser correct here?
// if you'd like to test these proprietary services, see the discussion on: https://codeberg.org/forgejo/forgejo/pulls/7508
return createMSTeamsPayload(
p.Run.Repo,
p.Run.TriggerUser,
title,
"",
p.Run.HTMLURL,
color,
// TODO: does this make any sense?
&MSTeamsFact{"Action:", p.Run.Title},
), nil
}
func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTarget string, color int, fact *MSTeamsFact) MSTeamsPayload {
facts := make([]MSTeamsFact, 0, 2)
if r != nil {

View file

@ -6,6 +6,7 @@ package webhook
import (
"context"
actions_model "forgejo.org/models/actions"
issues_model "forgejo.org/models/issues"
packages_model "forgejo.org/models/packages"
"forgejo.org/models/perm"
@ -887,6 +888,38 @@ func (m *webhookNotifier) PackageDelete(ctx context.Context, doer *user_model.Us
notifyPackage(ctx, doer, pd, api.HookPackageDeleted)
}
func (m *webhookNotifier) ActionRunNowDone(ctx context.Context, run *actions_model.ActionRun, priorStatus actions_model.Status, lastRun *actions_model.ActionRun) {
source := EventSource{
Repository: run.Repo,
Owner: run.TriggerUser,
}
payload := &api.ActionPayload{
Run: convert.ToActionRun(ctx, run),
LastRun: convert.ToActionRun(ctx, lastRun),
PriorStatus: priorStatus.String(),
}
if run.Status.IsSuccess() {
payload.Action = api.HookActionSuccess
if err := PrepareWebhooks(ctx, source, webhook_module.HookEventActionRunSuccess, payload); err != nil {
log.Error("PrepareWebhooks: %v", err)
}
// send another event when this is a recover
if lastRun != nil && !lastRun.Status.IsSuccess() {
payload.Action = api.HookActionRecover
if err := PrepareWebhooks(ctx, source, webhook_module.HookEventActionRunRecover, payload); err != nil {
log.Error("PrepareWebhooks: %v", err)
}
}
} else {
payload.Action = api.HookActionFailure
if err := PrepareWebhooks(ctx, source, webhook_module.HookEventActionRunFailure, payload); err != nil {
log.Error("PrepareWebhooks: %v", err)
}
}
}
func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_model.PackageDescriptor, action api.HookPackageAction) {
source := EventSource{
Repository: pd.Repository,

View file

@ -6,6 +6,7 @@ package webhook
import (
"testing"
actions_model "forgejo.org/models/actions"
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
@ -13,10 +14,12 @@ import (
webhook_model "forgejo.org/models/webhook"
"forgejo.org/modules/git"
"forgejo.org/modules/json"
"forgejo.org/modules/log"
"forgejo.org/modules/repository"
"forgejo.org/modules/setting"
"forgejo.org/modules/structs"
"forgejo.org/modules/test"
webhook_module "forgejo.org/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -119,3 +122,190 @@ func TestPushCommits(t *testing.T) {
assert.Equal(t, "2c54faec6c45d31c1abfaecdab471eac6633738a", payloadContent.Commits[0].ID)
})
}
func assertActionEqual(t *testing.T, expectedRun *actions_model.ActionRun, actualRun *structs.ActionRun) {
assert.NotNil(t, expectedRun)
assert.NotNil(t, actualRun)
// only test a few things
assert.Equal(t, expectedRun.ID, actualRun.ID)
assert.Equal(t, expectedRun.Status.String(), actualRun.Status)
assert.Equal(t, expectedRun.Index, actualRun.Index)
assert.Equal(t, expectedRun.RepoID, actualRun.Repo.ID)
// convert to unix because of time zones
assert.Equal(t, expectedRun.Stopped.AsTime().Unix(), actualRun.Stopped.Unix())
assert.Equal(t, expectedRun.Title, actualRun.Title)
assert.Equal(t, expectedRun.WorkflowID, actualRun.WorkflowID)
}
func TestAction(t *testing.T) {
defer unittest.OverrideFixtures("services/webhook/TestPushCommits")()
require.NoError(t, unittest.PrepareTestDatabase())
triggerUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: triggerUser.ID})
oldSuccessRun := &actions_model.ActionRun{
ID: 1,
Status: actions_model.StatusSuccess,
Index: 1,
RepoID: repo.ID,
Stopped: 1693648027,
WorkflowID: "some_workflow",
Title: "oldSuccessRun",
TriggerUser: triggerUser,
TriggerUserID: triggerUser.ID,
TriggerEvent: "push",
}
oldSuccessRun.LoadAttributes(db.DefaultContext)
oldFailureRun := &actions_model.ActionRun{
ID: 1,
Status: actions_model.StatusFailure,
Index: 1,
RepoID: repo.ID,
Stopped: 1693648027,
WorkflowID: "some_workflow",
Title: "oldFailureRun",
TriggerUser: triggerUser,
TriggerUserID: triggerUser.ID,
TriggerEvent: "push",
}
oldFailureRun.LoadAttributes(db.DefaultContext)
newSuccessRun := &actions_model.ActionRun{
ID: 1,
Status: actions_model.StatusSuccess,
Index: 1,
RepoID: repo.ID,
Stopped: 1693648327,
WorkflowID: "some_workflow",
Title: "newSuccessRun",
TriggerUser: triggerUser,
TriggerUserID: triggerUser.ID,
TriggerEvent: "push",
}
newSuccessRun.LoadAttributes(db.DefaultContext)
newFailureRun := &actions_model.ActionRun{
ID: 1,
Status: actions_model.StatusFailure,
Index: 1,
RepoID: repo.ID,
Stopped: 1693648327,
WorkflowID: "some_workflow",
Title: "newFailureRun",
TriggerUser: triggerUser,
TriggerUserID: triggerUser.ID,
TriggerEvent: "push",
}
newFailureRun.LoadAttributes(db.DefaultContext)
t.Run("Successful Run after Nothing", func(t *testing.T) {
defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)()
NewNotifier().ActionRunNowDone(db.DefaultContext, newSuccessRun, actions_model.StatusWaiting, nil)
// there's only one of these at the time
hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_success' AND payload_content LIKE '%success%newSuccessRun%'"))
assert.Equal(t, webhook_module.HookEventActionRunSuccess, hookTask.EventType)
var payloadContent structs.ActionPayload
require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent))
assert.Equal(t, structs.HookActionSuccess, payloadContent.Action)
assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus)
assertActionEqual(t, newSuccessRun, payloadContent.Run)
assert.Nil(t, payloadContent.LastRun)
})
t.Run("Successful Run after Failure", func(t *testing.T) {
defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)()
NewNotifier().ActionRunNowDone(db.DefaultContext, newSuccessRun, actions_model.StatusWaiting, oldFailureRun)
{
hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_success' AND payload_content LIKE '%success%newSuccessRun%oldFailureRun%'"))
assert.Equal(t, webhook_module.HookEventActionRunSuccess, hookTask.EventType)
var payloadContent structs.ActionPayload
require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent))
assert.Equal(t, structs.HookActionSuccess, payloadContent.Action)
assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus)
assertActionEqual(t, newSuccessRun, payloadContent.Run)
assertActionEqual(t, oldFailureRun, payloadContent.LastRun)
}
{
hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_recover' AND payload_content LIKE '%recover%newSuccessRun%oldFailureRun%'"))
assert.Equal(t, webhook_module.HookEventActionRunRecover, hookTask.EventType)
log.Error("something: %s", hookTask.PayloadContent)
var payloadContent structs.ActionPayload
require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent))
assert.Equal(t, structs.HookActionRecover, payloadContent.Action)
assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus)
assertActionEqual(t, newSuccessRun, payloadContent.Run)
assertActionEqual(t, oldFailureRun, payloadContent.LastRun)
}
})
t.Run("Successful Run after Success", func(t *testing.T) {
defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)()
NewNotifier().ActionRunNowDone(db.DefaultContext, newSuccessRun, actions_model.StatusWaiting, oldSuccessRun)
hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_success' AND payload_content LIKE '%success%newSuccessRun%oldSuccessRun%'"))
assert.Equal(t, webhook_module.HookEventActionRunSuccess, hookTask.EventType)
var payloadContent structs.ActionPayload
require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent))
assert.Equal(t, structs.HookActionSuccess, payloadContent.Action)
assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus)
assertActionEqual(t, newSuccessRun, payloadContent.Run)
assertActionEqual(t, oldSuccessRun, payloadContent.LastRun)
})
t.Run("Failed Run after Nothing", func(t *testing.T) {
defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)()
NewNotifier().ActionRunNowDone(db.DefaultContext, newFailureRun, actions_model.StatusWaiting, nil)
// there should only be this one at the time
hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_failure' AND payload_content LIKE '%failure%newFailureRun%'"))
assert.Equal(t, webhook_module.HookEventActionRunFailure, hookTask.EventType)
var payloadContent structs.ActionPayload
require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent))
assert.Equal(t, structs.HookActionFailure, payloadContent.Action)
assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus)
assertActionEqual(t, newFailureRun, payloadContent.Run)
assert.Nil(t, payloadContent.LastRun)
})
t.Run("Failed Run after Failure", func(t *testing.T) {
defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)()
NewNotifier().ActionRunNowDone(db.DefaultContext, newFailureRun, actions_model.StatusWaiting, oldFailureRun)
hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_failure' AND payload_content LIKE '%failure%newFailureRun%oldFailureRun%'"))
assert.Equal(t, webhook_module.HookEventActionRunFailure, hookTask.EventType)
var payloadContent structs.ActionPayload
require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent))
assert.Equal(t, structs.HookActionFailure, payloadContent.Action)
assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus)
assertActionEqual(t, newFailureRun, payloadContent.Run)
assertActionEqual(t, oldFailureRun, payloadContent.LastRun)
})
t.Run("Failed Run after Success", func(t *testing.T) {
defer test.MockVariableValue(&setting.Webhook.PayloadCommitLimit, 10)()
NewNotifier().ActionRunNowDone(db.DefaultContext, newFailureRun, actions_model.StatusWaiting, oldSuccessRun)
hookTask := unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{}, unittest.Cond("event_type == 'action_run_failure' AND payload_content LIKE '%failure%newFailureRun%oldSuccessRun%'"))
assert.Equal(t, webhook_module.HookEventActionRunFailure, hookTask.EventType)
var payloadContent structs.ActionPayload
require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payloadContent))
assert.Equal(t, structs.HookActionFailure, payloadContent.Action)
assert.Equal(t, actions_model.StatusWaiting.String(), payloadContent.PriorStatus)
assertActionEqual(t, newFailureRun, payloadContent.Run)
assertActionEqual(t, oldSuccessRun, payloadContent.LastRun)
})
}

View file

@ -36,6 +36,7 @@ type PayloadConvertor[T any] interface {
Release(*api.ReleasePayload) (T, error)
Wiki(*api.WikiPayload) (T, error)
Package(*api.PackagePayload) (T, error)
Action(*api.ActionPayload) (T, error)
}
func convertUnmarshalledJSON[T, P any](convert func(P) (T, error), data []byte) (T, error) {
@ -86,6 +87,8 @@ func NewPayload[T any](rc PayloadConvertor[T], data []byte, event webhook_module
return convertUnmarshalledJSON(rc.Wiki, data)
case webhook_module.HookEventPackage:
return convertUnmarshalledJSON(rc.Package, data)
case webhook_module.HookEventActionRunFailure, webhook_module.HookEventActionRunRecover, webhook_module.HookEventActionRunSuccess:
return convertUnmarshalledJSON(rc.Action, data)
}
var t T
return t, fmt.Errorf("newPayload unsupported event: %s", event)

View file

@ -142,6 +142,7 @@ func SlackLinkToRef(repoURL, ref string) string {
return SlackLinkFormatter(url, refName)
}
// TODO: fix spelling to Converter
// Create implements payloadConvertor Create method
func (s slackConvertor) Create(p *api.CreatePayload) (SlackPayload, error) {
refLink := SlackLinkToRef(p.Repo.HTMLURL, p.Ref)
@ -311,6 +312,12 @@ func (s slackConvertor) Repository(p *api.RepositoryPayload) (SlackPayload, erro
return s.createPayload(text, nil), nil
}
func (s slackConvertor) Action(p *api.ActionPayload) (SlackPayload, error) {
text, _ := getActionPayloadInfo(p, SlackLinkFormatter)
return s.createPayload(text, nil), nil
}
func (s slackConvertor) createPayload(text string, attachments []SlackAttachment) SlackPayload {
return SlackPayload{
Channel: s.Channel,

View file

@ -190,6 +190,10 @@ func (pc sourcehutConvertor) Package(_ *api.PackagePayload) (graphqlPayload[buil
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
}
func (pc sourcehutConvertor) Action(_ *api.ActionPayload) (graphqlPayload[buildsVariables], error) {
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
}
// newPayload opens and adjusts the manifest to submit to the builds service
//
// in case of an error the Error field will be set, to be visible by the end-user under recent deliveries

View file

@ -205,6 +205,12 @@ func (t telegramConvertor) Package(p *api.PackagePayload) (TelegramPayload, erro
return createTelegramPayload(text), nil
}
func (telegramConvertor) Action(p *api.ActionPayload) (TelegramPayload, error) {
text, _ := getActionPayloadInfo(p, htmlLinkFormatter)
return createTelegramPayload(text), nil
}
func createTelegramPayload(message string) TelegramPayload {
return TelegramPayload{
Message: markup.Sanitize(strings.TrimSpace(message)),

View file

@ -103,7 +103,7 @@ type EventSource struct {
Owner *user_model.User
}
// handle delivers hook tasks
// handler delivers hook tasks
func handler(items ...int64) []int64 {
ctx := graceful.GetManager().HammerContext()

View file

@ -201,6 +201,12 @@ func (wc wechatworkConvertor) Package(p *api.PackagePayload) (WechatworkPayload,
return newWechatworkMarkdownPayload(text), nil
}
func (wc wechatworkConvertor) Action(p *api.ActionPayload) (WechatworkPayload, error) {
text, _ := getActionPayloadInfo(p, noneLinkFormatter)
return newWechatworkMarkdownPayload(text), nil
}
type wechatworkConvertor struct{}
var _ shared.PayloadConvertor[WechatworkPayload] = wechatworkConvertor{}