Compare commits

..

1 Commits

Author SHA1 Message Date
Shudhanshu Singh
b09920a537 feat(webhook): support Telegram Bot API 10.1 Rich Messages (#38298)
Upgrades Gitea's Telegram webhook integration to support Telegram Bot
API 10.1 (June 2026 release). This enables Gitea webhooks to take
advantage of rich formatted messages (tables, nested blocks, collapsible
details, etc.) by routing them through the /sendRichMessage endpoint
with the new rich_message payload structure.

Old `/sendMessage` webhook URLs are written to `/sendRichMessage`
at runtime to prevent the need for database migrations

Fixes https://github.com/go-gitea/gitea/issues/38118

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-02 15:14:48 +00:00
4 changed files with 70 additions and 46 deletions

View File

@@ -6,34 +6,46 @@ on:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build-and-publish:
strategy:
fail-fast: false
matrix:
runner: [ubuntu-24.04, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
- name: Build snap
id: build
uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1.3.0
- name: Install snapcraft
run: sudo snap install snapcraft --classic
- name: Upload and release nightly build to latest/edge
uses: snapcore/action-publish@214b86e5ca036ead1668c79afb81e550e6c54d40 # v1.2.0
- name: Remote build
# remote-build runs on Launchpad and authenticates via a credentials file,
# not the Snap Store credentials used by upload
shell: bash
env:
LAUNCHPAD_CREDENTIALS: ${{ secrets.LAUNCHPAD_CREDENTIALS }}
run: |
set -euo pipefail
install -d -m 700 "$HOME/.local/share/snapcraft"
printf '%s' "$LAUNCHPAD_CREDENTIALS" > "$HOME/.local/share/snapcraft/launchpad-credentials"
chmod 600 "$HOME/.local/share/snapcraft/launchpad-credentials"
snapcraft remote-build \
--launchpad-accept-public-upload \
--build-for=amd64,arm64
- name: List built snaps
run: find . -maxdepth 1 -type f -name '*.snap' -print
- name: Upload and release snapcraft nightly build
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
with:
snap: ${{ steps.build.outputs.snap }}
release: latest/edge
run: |
set -euo pipefail
for snap in ./*.snap; do
echo "Uploading $snap to edge"
snapcraft upload --release="latest/edge" "$snap"
done

View File

@@ -429,7 +429,7 @@ func telegramHookParams(ctx *context.Context) webhookParams {
return webhookParams{
Type: webhook_module.TELEGRAM,
URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%s&message_thread_id=%s", url.PathEscape(form.BotToken), url.QueryEscape(form.ChatID), url.QueryEscape(form.ThreadID)),
URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendRichMessage?chat_id=%s&message_thread_id=%s", url.PathEscape(form.BotToken), url.QueryEscape(form.ChatID), url.QueryEscape(form.ThreadID)),
ContentType: webhook.ContentTypeJSON,
WebhookForm: form.WebhookForm,
Meta: &webhook_service.TelegramMeta{

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"html"
"net/http"
"net/url"
"strings"
webhook_model "gitea.dev/models/webhook"
@@ -23,9 +24,12 @@ import (
type (
// TelegramPayload represents
TelegramPayload struct {
Message string `json:"text"`
ParseMode string `json:"parse_mode"`
DisableWebPreview bool `json:"disable_web_page_preview"`
RichMessage InputRichMessage `json:"rich_message"`
}
// InputRichMessage represents input rich message
InputRichMessage struct {
HTML string `json:"html"`
}
// TelegramMeta contains the telegram metadata
@@ -195,13 +199,21 @@ func (telegramConvertor) WorkflowJob(p *api.WorkflowJobPayload) (TelegramPayload
func createTelegramPayloadHTML(msgHTML string) TelegramPayload {
// https://core.telegram.org/bots/api#formatting-options
return TelegramPayload{
Message: strings.TrimSpace(string(markup.Sanitize(msgHTML))),
ParseMode: "HTML",
DisableWebPreview: true,
RichMessage: InputRichMessage{
HTML: strings.TrimSpace(string(markup.Sanitize(msgHTML))),
},
}
}
func newTelegramRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
u, err := url.Parse(w.URL)
if err != nil {
return nil, nil, err
}
if urlPrefix, ok := strings.CutSuffix(u.Path, "/sendMessage"); ok {
u.Path = urlPrefix + "/sendRichMessage"
w.URL = u.String()
}
var pc payloadConvertor[TelegramPayload] = telegramConvertor{}
return newJSONRequest(pc, w, t, true)
}

View File

@@ -21,9 +21,9 @@ func TestTelegramPayload(t *testing.T) {
t.Run("Correct webhook params", func(t *testing.T) {
p := createTelegramPayloadHTML(`<a href=".">testMsg</a> <bad>`)
assert.Equal(t, TelegramPayload{
Message: `<a href="." rel="nofollow">testMsg</a>`,
ParseMode: "HTML",
DisableWebPreview: true,
RichMessage: InputRichMessage{
HTML: `<a href="." rel="nofollow">testMsg</a>`,
},
}, p)
})
@@ -33,7 +33,7 @@ func TestTelegramPayload(t *testing.T) {
pl, err := tc.Create(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] branch <a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a> created`, pl.Message)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] branch <a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a> created`, pl.RichMessage.HTML)
})
t.Run("Delete", func(t *testing.T) {
@@ -42,7 +42,7 @@ func TestTelegramPayload(t *testing.T) {
pl, err := tc.Delete(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] branch <a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a> deleted`, pl.Message)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] branch <a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a> deleted`, pl.RichMessage.HTML)
})
t.Run("Fork", func(t *testing.T) {
@@ -51,7 +51,7 @@ func TestTelegramPayload(t *testing.T) {
pl, err := tc.Fork(p)
require.NoError(t, err)
assert.Equal(t, `test/repo2 is forked to <a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>`, pl.Message)
assert.Equal(t, `test/repo2 is forked to <a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>`, pl.RichMessage.HTML)
})
t.Run("Push", func(t *testing.T) {
@@ -62,7 +62,7 @@ func TestTelegramPayload(t *testing.T) {
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>:<a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a>] 2 new commits
[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1
[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1`, pl.Message)
[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1`, pl.RichMessage.HTML)
})
t.Run("Issue", func(t *testing.T) {
@@ -74,13 +74,13 @@ func TestTelegramPayload(t *testing.T) {
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Issue opened: <a href="http://localhost:3000/test/repo/issues/2" rel="nofollow">#2 crash</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
issue body`, pl.Message)
issue body`, pl.RichMessage.HTML)
p.Action = api.HookIssueClosed
pl, err = tc.Issue(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Issue closed: <a href="http://localhost:3000/test/repo/issues/2" rel="nofollow">#2 crash</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Issue closed: <a href="http://localhost:3000/test/repo/issues/2" rel="nofollow">#2 crash</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.RichMessage.HTML)
})
t.Run("IssueComment", func(t *testing.T) {
@@ -90,7 +90,7 @@ issue body`, pl.Message)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] New comment on issue <a href="http://localhost:3000/test/repo/issues/2" rel="nofollow">#2 crash</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
more info needed`, pl.Message)
more info needed`, pl.RichMessage.HTML)
})
t.Run("PullRequest", func(t *testing.T) {
@@ -100,7 +100,7 @@ more info needed`, pl.Message)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Pull request opened: <a href="http://localhost:3000/test/repo/pulls/12" rel="nofollow">#12 Fix bug</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
fixes bug #2`, pl.Message)
fixes bug #2`, pl.RichMessage.HTML)
})
t.Run("PullRequestComment", func(t *testing.T) {
@@ -110,7 +110,7 @@ fixes bug #2`, pl.Message)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] New comment on pull request <a href="http://localhost:3000/test/repo/pulls/12" rel="nofollow">#12 Fix bug</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
changes requested`, pl.Message)
changes requested`, pl.RichMessage.HTML)
})
t.Run("Review", func(t *testing.T) {
@@ -121,7 +121,7 @@ changes requested`, pl.Message)
require.NoError(t, err)
assert.Equal(t, `[test/repo] Pull request review approved: #12 Fix bug
good job`, pl.Message)
good job`, pl.RichMessage.HTML)
})
t.Run("Repository", func(t *testing.T) {
@@ -130,7 +130,7 @@ good job`, pl.Message)
pl, err := tc.Repository(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Repository created`, pl.Message)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Repository created`, pl.RichMessage.HTML)
})
t.Run("Package", func(t *testing.T) {
@@ -139,7 +139,7 @@ good job`, pl.Message)
pl, err := tc.Package(p)
require.NoError(t, err)
assert.Equal(t, `Package created: <a href="http://localhost:3000/user1/-/packages/container/GiteaContainer/latest" rel="nofollow">GiteaContainer:latest</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
assert.Equal(t, `Package created: <a href="http://localhost:3000/user1/-/packages/container/GiteaContainer/latest" rel="nofollow">GiteaContainer:latest</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.RichMessage.HTML)
})
t.Run("Wiki", func(t *testing.T) {
@@ -149,19 +149,19 @@ good job`, pl.Message)
pl, err := tc.Wiki(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] New wiki page &#39;<a href="http://localhost:3000/test/repo/wiki/index" rel="nofollow">index</a>&#39; (Wiki change comment) by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] New wiki page &#39;<a href="http://localhost:3000/test/repo/wiki/index" rel="nofollow">index</a>&#39; (Wiki change comment) by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.RichMessage.HTML)
p.Action = api.HookWikiEdited
pl, err = tc.Wiki(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Wiki page &#39;<a href="http://localhost:3000/test/repo/wiki/index" rel="nofollow">index</a>&#39; edited (Wiki change comment) by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Wiki page &#39;<a href="http://localhost:3000/test/repo/wiki/index" rel="nofollow">index</a>&#39; edited (Wiki change comment) by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.RichMessage.HTML)
p.Action = api.HookWikiDeleted
pl, err = tc.Wiki(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Wiki page &#39;<a href="http://localhost:3000/test/repo/wiki/index" rel="nofollow">index</a>&#39; deleted by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Wiki page &#39;<a href="http://localhost:3000/test/repo/wiki/index" rel="nofollow">index</a>&#39; deleted by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.RichMessage.HTML)
})
t.Run("Release", func(t *testing.T) {
@@ -170,7 +170,7 @@ good job`, pl.Message)
pl, err := tc.Release(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Release created: <a href="http://localhost:3000/test/repo/releases/tag/v1.0" rel="nofollow">v1.0</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Release created: <a href="http://localhost:3000/test/repo/releases/tag/v1.0" rel="nofollow">v1.0</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.RichMessage.HTML)
})
}
@@ -183,7 +183,7 @@ func TestTelegramJSONPayload(t *testing.T) {
RepoID: 3,
IsActive: true,
Type: webhook_module.TELEGRAM,
URL: "https://telegram.example.com/",
URL: "https://telegram.example.com/sendMessage",
Meta: ``,
HTTPMethod: "POST",
}
@@ -200,7 +200,7 @@ func TestTelegramJSONPayload(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "POST", req.Method)
assert.Equal(t, "https://telegram.example.com/", req.URL.String())
assert.Equal(t, "https://telegram.example.com/sendRichMessage", req.URL.String())
assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
var body TelegramPayload
@@ -208,5 +208,5 @@ func TestTelegramJSONPayload(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>:<a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a>] 2 new commits
[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1
[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1`, body.Message)
[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1`, body.RichMessage.HTML)
}