telegramfmt: text formatting TG -> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ require (
|
||||
go.mau.fi/util v0.6.0
|
||||
go.mau.fi/zerozap v0.1.1
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7
|
||||
maunium.net/go/mautrix v0.19.1-0.20240719130542-cc5f225bc61c
|
||||
)
|
||||
|
||||
@@ -43,7 +44,6 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -20,6 +22,7 @@ import (
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/media"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/store"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/telegramfmt"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/util"
|
||||
)
|
||||
|
||||
@@ -37,6 +40,8 @@ type TelegramClient struct {
|
||||
|
||||
appConfig map[string]any
|
||||
appConfigHash int
|
||||
|
||||
telegramFmtParams *telegramfmt.FormatParams
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -78,6 +83,8 @@ func (u UpdateDispatcher) Handle(ctx context.Context, updates tg.UpdatesClass) e
|
||||
return u.UpdateDispatcher.Handle(ctx, updates)
|
||||
}
|
||||
|
||||
var messageLinkRegex = regexp.MustCompile(`^https?:\/\/t(?:elegram)?\.(?:me|dog)\/([A-Za-z][A-Za-z0-9_]{3,31}[A-Za-z0-9]|[Cc]\/[0-9]{1,20})\/([0-9]{1,20})$`)
|
||||
|
||||
func NewTelegramClient(ctx context.Context, tc *TelegramConnector, login *bridgev2.UserLogin) (*TelegramClient, error) {
|
||||
telegramUserID, err := ids.ParseUserLoginID(login.ID)
|
||||
if err != nil {
|
||||
@@ -141,6 +148,63 @@ func NewTelegramClient(ctx context.Context, tc *TelegramConnector, login *bridge
|
||||
})
|
||||
client.clientCancel, err = connectTelegramClient(ctx, client.client)
|
||||
client.reactionMessageLocks = map[int]*sync.Mutex{}
|
||||
|
||||
client.telegramFmtParams = &telegramfmt.FormatParams{
|
||||
GetUserInfo: func(ctx context.Context, id networkid.UserID) (telegramfmt.UserInfo, error) {
|
||||
ghost, err := tc.Bridge.GetGhostByID(ctx, id)
|
||||
if err != nil {
|
||||
return telegramfmt.UserInfo{}, err
|
||||
}
|
||||
userInfo := telegramfmt.UserInfo{MXID: ghost.Intent.GetMXID(), Name: ghost.Name}
|
||||
if id == client.userID {
|
||||
userInfo.MXID = client.userLogin.UserMXID
|
||||
}
|
||||
return userInfo, nil
|
||||
},
|
||||
NormalizeURL: func(ctx context.Context, url string) string {
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("conversion_direction", "to_matrix").
|
||||
Str("entity_type", "url").
|
||||
Logger()
|
||||
|
||||
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "ftp://") && !strings.HasPrefix(url, "magnet://") {
|
||||
url = "http://" + url
|
||||
}
|
||||
|
||||
submatches := messageLinkRegex.FindStringSubmatch(url)
|
||||
if len(submatches) == 0 {
|
||||
return url
|
||||
}
|
||||
group := submatches[1]
|
||||
msgID, err := strconv.Atoi(submatches[2])
|
||||
if err != nil {
|
||||
log.Err(err).Msg("error parsing message ID")
|
||||
return url
|
||||
}
|
||||
|
||||
var portalKey networkid.PortalKey
|
||||
if strings.HasPrefix(group, "C/") || strings.HasPrefix(group, "c/") {
|
||||
portalKey = networkid.PortalKey{ID: networkid.PortalID(fmt.Sprintf("%s:%s", ids.PeerTypeChannel, group[2:]))}
|
||||
} else {
|
||||
portalKey = networkid.PortalKey{ID: networkid.PortalID(fmt.Sprintf("%s:%s", ids.PeerTypeUser, group))}
|
||||
}
|
||||
|
||||
portal, err := tc.Bridge.DB.Portal.GetByKey(ctx, portalKey)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("error getting portal")
|
||||
return url
|
||||
}
|
||||
|
||||
message, err := tc.Bridge.DB.Message.GetFirstPartByID(ctx, client.loginID, ids.MakeMessageID(msgID))
|
||||
if err != nil {
|
||||
log.Err(err).Msg("error getting message")
|
||||
return url
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://matrix.to/#/%s/%s", portal.MXID, message.MXID)
|
||||
},
|
||||
}
|
||||
|
||||
go func() {
|
||||
err = updatesManager.Run(ctx, client.client.API(), telegramUserID, updates.AuthOptions{})
|
||||
if err != nil {
|
||||
|
||||
+37
-41
@@ -225,39 +225,33 @@ func (t *TelegramClient) onMessageEdit(ctx context.Context, update IGetMessage)
|
||||
Str("conversion_direction", "to_matrix").
|
||||
Int("message_id", msg.ID)
|
||||
},
|
||||
ID: ids.MakeMessageID(msg.ID),
|
||||
Sender: sender,
|
||||
PortalKey: ids.MakePortalKey(msg.PeerID),
|
||||
TargetMessage: ids.MakeMessageID(msg.ID),
|
||||
Data: msg,
|
||||
ConvertEditFunc: t.convertEdit,
|
||||
Timestamp: time.Unix(int64(msg.EditDate), 0),
|
||||
ID: ids.MakeMessageID(msg.ID),
|
||||
Sender: sender,
|
||||
PortalKey: ids.MakePortalKey(msg.PeerID),
|
||||
TargetMessage: ids.MakeMessageID(msg.ID),
|
||||
Data: msg,
|
||||
Timestamp: time.Unix(int64(msg.EditDate), 0),
|
||||
ConvertEditFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message, data *tg.Message) (*bridgev2.ConvertedEdit, error) {
|
||||
converted, err := t.convertToMatrix(ctx, portal, intent, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if len(existing) != len(converted.Parts) {
|
||||
return nil, fmt.Errorf("parts were added or removed in edit")
|
||||
}
|
||||
|
||||
var ce bridgev2.ConvertedEdit
|
||||
for i, part := range converted.Parts {
|
||||
if !bytes.Equal(existing[i].Metadata.(*MessageMetadata).ContentHash, part.DBMetadata.(*MessageMetadata).ContentHash) {
|
||||
ce.ModifiedParts = append(ce.ModifiedParts, part.ToEditPart(existing[i]))
|
||||
}
|
||||
}
|
||||
return &ce, nil
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TelegramClient) convertEdit(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message, msg *tg.Message) (*bridgev2.ConvertedEdit, error) {
|
||||
converted, err := t.convertToMatrix(ctx, portal, intent, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(existing) != len(converted.Parts) {
|
||||
return nil, fmt.Errorf("parts were added or removed in edit")
|
||||
}
|
||||
|
||||
var ce bridgev2.ConvertedEdit
|
||||
for i, part := range converted.Parts {
|
||||
if bytes.Equal(existing[i].Metadata.(*MessageMetadata).ContentHash, part.DBMetadata.(*MessageMetadata).ContentHash) {
|
||||
continue
|
||||
}
|
||||
|
||||
ce.ModifiedParts = append(ce.ModifiedParts, part.ToEditPart(existing[i]))
|
||||
}
|
||||
return &ce, nil
|
||||
}
|
||||
|
||||
func (t *TelegramClient) handleTelegramReactions(ctx context.Context, msg *tg.Message) {
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("handler", "handle_telegram_reactions").
|
||||
@@ -436,22 +430,24 @@ func (t *TelegramClient) getReactionLimit(ctx context.Context, sender networkid.
|
||||
func (t *TelegramClient) transferEmojisToMatrix(ctx context.Context, customEmojiIDs []int64) (result map[networkid.EmojiID]string, err error) {
|
||||
result, customEmojiIDs = emojis.ConvertKnownEmojis(customEmojiIDs)
|
||||
|
||||
if len(customEmojiIDs) > 0 {
|
||||
customEmojiDocuments, err := t.client.API().MessagesGetCustomEmojiDocuments(ctx, customEmojiIDs)
|
||||
if len(customEmojiIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
customEmojiDocuments, err := t.client.API().MessagesGetCustomEmojiDocuments(ctx, customEmojiIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, customEmojiDocument := range customEmojiDocuments {
|
||||
mxcURI, _, _, err := media.NewTransferer(t.client.API()).
|
||||
WithStickerConfig(t.main.Config.AnimatedSticker).
|
||||
WithDocument(customEmojiDocument, false).
|
||||
Transfer(ctx, t.main.Store, t.main.Bridge.Bot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, customEmojiDocument := range customEmojiDocuments {
|
||||
mxcURI, _, _, err := media.NewTransferer(t.client.API()).
|
||||
WithStickerConfig(t.main.Config.AnimatedSticker).
|
||||
WithDocument(customEmojiDocument, false).
|
||||
Transfer(ctx, t.main.Store, t.main.Bridge.Bot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[ids.MakeEmojiIDFromDocumentID(customEmojiDocument.GetID())] = string(mxcURI)
|
||||
}
|
||||
result[ids.MakeEmojiIDFromDocumentID(customEmojiDocument.GetID())] = string(mxcURI)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||
// Copyright (C) 2024 Sumner Evans
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package telegramfmt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
"golang.org/x/exp/maps"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
)
|
||||
|
||||
type UserInfo struct {
|
||||
MXID id.UserID
|
||||
Name string
|
||||
}
|
||||
|
||||
type FormatParams struct {
|
||||
CustomEmojis map[networkid.EmojiID]string
|
||||
GetUserInfo func(ctx context.Context, id networkid.UserID) (UserInfo, error)
|
||||
NormalizeURL func(ctx context.Context, url string) string
|
||||
}
|
||||
|
||||
func (fp FormatParams) GetCustomEmoji(emojiID networkid.EmojiID) (string, id.ContentURIString) {
|
||||
if strings.HasPrefix(fp.CustomEmojis[emojiID], "mxc://") {
|
||||
return "", id.ContentURIString(fp.CustomEmojis[emojiID])
|
||||
} else {
|
||||
return fp.CustomEmojis[emojiID], ""
|
||||
}
|
||||
}
|
||||
|
||||
func (fp FormatParams) WithCustomEmojis(emojis map[networkid.EmojiID]string) FormatParams {
|
||||
return FormatParams{
|
||||
CustomEmojis: emojis,
|
||||
GetUserInfo: fp.GetUserInfo,
|
||||
NormalizeURL: fp.NormalizeURL,
|
||||
}
|
||||
}
|
||||
|
||||
type formatContext struct {
|
||||
IsInCodeblock bool
|
||||
}
|
||||
|
||||
func (ctx formatContext) TextToHTML(text string) string {
|
||||
if ctx.IsInCodeblock {
|
||||
return html.EscapeString(text)
|
||||
}
|
||||
return event.TextToHTML(text)
|
||||
}
|
||||
|
||||
func Parse(ctx context.Context, message string, entities []tg.MessageEntityClass, params FormatParams) (*event.MessageEventContent, error) {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: message,
|
||||
Mentions: &event.Mentions{},
|
||||
}
|
||||
if len(entities) == 0 {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
lrt := &LinkedRangeTree{}
|
||||
mentions := map[id.UserID]struct{}{}
|
||||
utf16Message := NewUTF16String(message)
|
||||
maxLength := len(utf16Message)
|
||||
for _, e := range entities {
|
||||
br := BodyRange{
|
||||
Start: e.GetOffset(),
|
||||
Length: e.GetLength(),
|
||||
}.TruncateEnd(maxLength)
|
||||
switch entity := e.(type) {
|
||||
case *tg.MessageEntityMention:
|
||||
// TODO
|
||||
fmt.Printf("mention = %+v\n", entity)
|
||||
case *tg.MessageEntityHashtag:
|
||||
br.Value = Style{Type: StyleHashtag}
|
||||
case *tg.MessageEntityBotCommand:
|
||||
br.Value = Style{Type: StyleBotCommand}
|
||||
case *tg.MessageEntityURL:
|
||||
br.Value = Style{Type: StyleURL, URL: params.NormalizeURL(ctx, utf16Message[e.GetOffset():e.GetOffset()+e.GetLength()].String())}
|
||||
case *tg.MessageEntityEmail:
|
||||
br.Value = Style{Type: StyleEmail}
|
||||
case *tg.MessageEntityBold:
|
||||
br.Value = Style{Type: StyleBold}
|
||||
case *tg.MessageEntityItalic:
|
||||
br.Value = Style{Type: StyleItalic}
|
||||
case *tg.MessageEntityCode:
|
||||
br.Value = Style{Type: StyleCode}
|
||||
case *tg.MessageEntityPre:
|
||||
br.Value = Style{Type: StylePre, Language: entity.Language}
|
||||
case *tg.MessageEntityTextURL:
|
||||
br.Value = Style{Type: StyleURL, URL: params.NormalizeURL(ctx, entity.URL)}
|
||||
case *tg.MessageEntityMentionName:
|
||||
userID := ids.MakeUserID(entity.UserID)
|
||||
userInfo, err := params.GetUserInfo(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mentions[userInfo.MXID] = struct{}{}
|
||||
br.Value = Mention{UserInfo: userInfo, UserID: userID}
|
||||
case *tg.MessageEntityPhone:
|
||||
br.Value = Style{Type: StylePhone}
|
||||
case *tg.MessageEntityCashtag:
|
||||
br.Value = Style{Type: StyleCashtag}
|
||||
case *tg.MessageEntityUnderline:
|
||||
br.Value = Style{Type: StyleUnderline}
|
||||
case *tg.MessageEntityStrike:
|
||||
br.Value = Style{Type: StyleStrikethrough}
|
||||
case *tg.MessageEntityBankCard:
|
||||
br.Value = Style{Type: StyleBankCard}
|
||||
case *tg.MessageEntitySpoiler:
|
||||
br.Value = Style{Type: StyleSpoiler}
|
||||
case *tg.MessageEntityCustomEmoji:
|
||||
emoji, contentURI := params.GetCustomEmoji(ids.MakeEmojiIDFromDocumentID(entity.DocumentID))
|
||||
if emoji != "" {
|
||||
br.Value = Style{Type: StyleCustomEmoji, Emoji: emoji}
|
||||
} else {
|
||||
br.Value = Style{Type: StyleCustomEmoji, EmojiURI: contentURI}
|
||||
}
|
||||
case *tg.MessageEntityBlockquote:
|
||||
br.Value = Style{Type: StyleBlockquote}
|
||||
}
|
||||
lrt.Add(br)
|
||||
}
|
||||
|
||||
content.Mentions.UserIDs = maps.Keys(mentions)
|
||||
content.FormattedBody = lrt.Format(utf16Message, formatContext{})
|
||||
content.Format = event.FormatHTML
|
||||
return content, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||
// Copyright (C) 2024 Sumner Evans
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package telegramfmt_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/telegramfmt"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
formatParams := telegramfmt.FormatParams{
|
||||
GetUserInfo: func(ctx context.Context, userID networkid.UserID) (telegramfmt.UserInfo, error) {
|
||||
if userID == "real" {
|
||||
return telegramfmt.UserInfo{
|
||||
MXID: "@test:example.com",
|
||||
Name: "Matrix User",
|
||||
}, nil
|
||||
} else {
|
||||
return telegramfmt.UserInfo{
|
||||
MXID: id.UserID(fmt.Sprintf("@telegram_%s:example.com", userID)),
|
||||
Name: "Signal User",
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
ins string
|
||||
ine []tg.MessageEntityClass
|
||||
body string
|
||||
html string
|
||||
|
||||
extraChecks func(*testing.T, *event.MessageEventContent)
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
extraChecks: func(t *testing.T, content *event.MessageEventContent) {
|
||||
assert.Empty(t, content.FormattedBody)
|
||||
assert.Empty(t, content.Body)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plain",
|
||||
ins: "Hello world!",
|
||||
body: "Hello world!",
|
||||
extraChecks: func(t *testing.T, content *event.MessageEventContent) {
|
||||
assert.Empty(t, content.FormattedBody)
|
||||
assert.Empty(t, content.Format)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
parsed, err := telegramfmt.Parse(context.TODO(), test.ins, test.ine, formatParams)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.body, parsed.Body)
|
||||
assert.Equal(t, test.html, parsed.FormattedBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||
// Copyright (C) 2024 Sumner Evans
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package telegramfmt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
func (m Mention) Format(message string) string {
|
||||
return fmt.Sprintf(`<a href="%s">%s</a>`, m.MXID.URI().MatrixToURL(), m.Name)
|
||||
}
|
||||
|
||||
func (s Style) Format(message string) string {
|
||||
switch s.Type {
|
||||
case StyleBold:
|
||||
return fmt.Sprintf("<strong>%s</strong>", message)
|
||||
case StyleItalic:
|
||||
return fmt.Sprintf("<em>%s</em>", message)
|
||||
case StyleSpoiler:
|
||||
return fmt.Sprintf("<span data-mx-spoiler>%s</span>", message)
|
||||
case StyleStrikethrough:
|
||||
return fmt.Sprintf("<del>%s</del>", message)
|
||||
case StyleCode:
|
||||
if strings.ContainsRune(message, '\n') {
|
||||
// This is somewhat incorrect, as it won't allow inline text before/after a multiline monospace-formatted string.
|
||||
return fmt.Sprintf("<pre><code>%s</code></pre>", message)
|
||||
}
|
||||
return fmt.Sprintf("<code>%s</code>", message)
|
||||
case StyleUnderline:
|
||||
return fmt.Sprintf("<u>%s</u>", message)
|
||||
case StyleBlockquote:
|
||||
return fmt.Sprintf("<blockquote>%s</blockquote>", message)
|
||||
case StylePre:
|
||||
if s.Language != "" {
|
||||
return fmt.Sprintf("<pre><code class='language-%s'>%s</code></pre>", s.Language, message)
|
||||
} else {
|
||||
return fmt.Sprintf("<pre><code>%s</code></pre>", message)
|
||||
}
|
||||
case StyleEmail:
|
||||
return fmt.Sprintf(`<a href='mailto:%s'>%s</a>`, message, message)
|
||||
case StyleTextURL:
|
||||
if strings.HasPrefix(s.URL, "https://matrix.to/#") {
|
||||
return s.URL
|
||||
}
|
||||
return fmt.Sprintf(`<a href='%s'>%s</a>`, s.URL, message)
|
||||
case StyleURL:
|
||||
if strings.HasPrefix(s.URL, "https://matrix.to/#") {
|
||||
return s.URL
|
||||
}
|
||||
return fmt.Sprintf(`<a href='%s'>%s</a>`, s.URL, message)
|
||||
case StyleCustomEmoji:
|
||||
if s.Emoji != "" {
|
||||
return s.Emoji
|
||||
} else {
|
||||
return fmt.Sprintf(
|
||||
`<img data-mx-emoticon data-mau-animated-emoji src="%s" height="32" width="32" alt="%s" title="%s"/>`,
|
||||
s.EmojiURI, message, message,
|
||||
)
|
||||
}
|
||||
case StyleBotCommand:
|
||||
return fmt.Sprintf("<font color='#3771bb'>%s</font>", message)
|
||||
case StyleHashtag:
|
||||
return fmt.Sprintf("<font color='#3771bb'>%s</font>", message)
|
||||
case StyleCashtag:
|
||||
return fmt.Sprintf("<font color='#3771bb'>%s</font>", message)
|
||||
case StylePhone:
|
||||
return fmt.Sprintf("<font color='#3771bb'>%s</font>", message)
|
||||
default:
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
type UTF16String []uint16
|
||||
|
||||
func NewUTF16String(s string) UTF16String {
|
||||
return utf16.Encode([]rune(s))
|
||||
}
|
||||
|
||||
func (u UTF16String) String() string {
|
||||
return string(utf16.Decode(u))
|
||||
}
|
||||
|
||||
func (lrt *LinkedRangeTree) Format(message UTF16String, ctx formatContext) string {
|
||||
if lrt == nil || lrt.Node == nil {
|
||||
return ctx.TextToHTML(message.String())
|
||||
}
|
||||
head := message[:lrt.Node.Start]
|
||||
headStr := ctx.TextToHTML(head.String())
|
||||
inner := message[lrt.Node.Start:lrt.Node.End()]
|
||||
tail := message[lrt.Node.End():]
|
||||
ourCtx := ctx
|
||||
if lrt.Node.Value.IsCode() {
|
||||
ourCtx.IsInCodeblock = true
|
||||
}
|
||||
childMessage := lrt.Child.Format(inner, ourCtx)
|
||||
formattedChildMessage := lrt.Node.Value.Format(childMessage)
|
||||
siblingMessage := lrt.Sibling.Format(tail, ctx)
|
||||
return headStr + formattedChildMessage + siblingMessage
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||
// Copyright (C) 2024 Sumner Evans
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package telegramfmt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type BodyRangeValue interface {
|
||||
String() string
|
||||
Format(message string) string
|
||||
IsCode() bool
|
||||
}
|
||||
|
||||
type Mention struct {
|
||||
UserInfo
|
||||
UserID networkid.UserID
|
||||
}
|
||||
|
||||
var _ BodyRangeValue = Mention{}
|
||||
|
||||
func (m Mention) String() string {
|
||||
return fmt.Sprintf("Mention{MXID: id.UserID(%q), Name: %q}", m.MXID, m.Name)
|
||||
}
|
||||
|
||||
func (m Mention) IsCode() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type StyleType int
|
||||
|
||||
var _ BodyRangeValue = Mention{}
|
||||
|
||||
const (
|
||||
StyleNone StyleType = iota
|
||||
StyleBold
|
||||
StyleItalic
|
||||
StyleUnderline
|
||||
StyleStrikethrough
|
||||
StyleBlockquote
|
||||
StyleCode
|
||||
StylePre
|
||||
StyleEmail
|
||||
StyleTextURL
|
||||
StyleURL
|
||||
StyleCustomEmoji
|
||||
StyleBotCommand
|
||||
StyleHashtag
|
||||
StyleCashtag
|
||||
StylePhone
|
||||
StyleSpoiler
|
||||
StyleBankCard
|
||||
)
|
||||
|
||||
func (s StyleType) String() string {
|
||||
switch s {
|
||||
case StyleNone:
|
||||
return "StyleNone"
|
||||
case StyleBold:
|
||||
return "StyleBold"
|
||||
case StyleItalic:
|
||||
return "StyleItalic"
|
||||
case StyleUnderline:
|
||||
return "StyleUnderline"
|
||||
case StyleStrikethrough:
|
||||
return "StyleStrikethrough"
|
||||
case StyleBlockquote:
|
||||
return "StyleBlockquote"
|
||||
case StyleCode:
|
||||
return "StyleCode"
|
||||
case StylePre:
|
||||
return "StylePre"
|
||||
case StyleEmail:
|
||||
return "StyleEmail"
|
||||
case StyleTextURL:
|
||||
return "StyleTextURL"
|
||||
case StyleURL:
|
||||
return "StyleEntityURL"
|
||||
case StyleCustomEmoji:
|
||||
return "StyleCustomEmoji"
|
||||
case StyleBotCommand:
|
||||
return "StyleBotCommand"
|
||||
case StyleHashtag:
|
||||
return "StyleHashtag"
|
||||
case StyleCashtag:
|
||||
return "StyleCashtag"
|
||||
case StylePhone:
|
||||
return "StylePhone"
|
||||
case StyleSpoiler:
|
||||
return "StyleSpoiler"
|
||||
case StyleBankCard:
|
||||
return "StyleBankCard"
|
||||
default:
|
||||
return fmt.Sprintf("StyleType(%d)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Style represents a style to apply to a range of text.
|
||||
type Style struct {
|
||||
// Type is the type of style.
|
||||
Type StyleType
|
||||
|
||||
// Language is the language of the code block, if applicable.
|
||||
Language string
|
||||
|
||||
// URL is the URL to link to, if applicable.
|
||||
URL string
|
||||
|
||||
// Emoji is the emoji to display, if applicable.
|
||||
Emoji string
|
||||
|
||||
// EmojiURI is the URI to the emoji, if applicable.
|
||||
EmojiURI id.ContentURIString
|
||||
}
|
||||
|
||||
func (s Style) String() string {
|
||||
return fmt.Sprintf("Style{Type: %s, Language: %s, URL: %s}", s.Type, s.Language, s.URL)
|
||||
}
|
||||
|
||||
func (s Style) IsCode() bool {
|
||||
return s.Type == StyleCode || s.Type == StylePre
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||
// Copyright (C) 2024 Sumner Evans
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package telegramfmt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type BodyRange struct {
|
||||
Start int
|
||||
Length int
|
||||
Value BodyRangeValue
|
||||
}
|
||||
|
||||
type BodyRangeList []BodyRange
|
||||
|
||||
var _ sort.Interface = BodyRangeList(nil)
|
||||
|
||||
func (b BodyRangeList) Len() int {
|
||||
return len(b)
|
||||
}
|
||||
|
||||
func (b BodyRangeList) Less(i, j int) bool {
|
||||
return b[i].Start < b[j].Start || b[i].Length > b[j].Length
|
||||
}
|
||||
|
||||
func (b BodyRangeList) Swap(i, j int) {
|
||||
b[i], b[j] = b[j], b[i]
|
||||
}
|
||||
|
||||
func (b BodyRange) String() string {
|
||||
return fmt.Sprintf("%d:%d:%v", b.Start, b.Length, b.Value)
|
||||
}
|
||||
|
||||
// End returns the end index of the range.
|
||||
func (b BodyRange) End() int {
|
||||
return b.Start + b.Length
|
||||
}
|
||||
|
||||
// Offset changes the start of the range without affecting the length.
|
||||
func (b BodyRange) Offset(offset int) *BodyRange {
|
||||
b.Start += offset
|
||||
return &b
|
||||
}
|
||||
|
||||
// TruncateStart changes the length of the range, so it starts at the given
|
||||
// index and ends at the same index as before.
|
||||
func (b BodyRange) TruncateStart(startAt int) *BodyRange {
|
||||
if b.Start < startAt {
|
||||
b.Length -= startAt - b.Start
|
||||
b.Start = startAt
|
||||
}
|
||||
return &b
|
||||
}
|
||||
|
||||
// TruncateEnd changes the length of the range, so it ends at or before the
|
||||
// given index and starts at the same index as before.
|
||||
func (b BodyRange) TruncateEnd(maxEnd int) *BodyRange {
|
||||
if b.End() > maxEnd {
|
||||
b.Length = maxEnd - b.Start
|
||||
}
|
||||
return &b
|
||||
}
|
||||
|
||||
// LinkedRangeTree is a linked tree of formatting entities.
|
||||
//
|
||||
// It's meant to parse a list of Telegram entity ranges into nodes that either
|
||||
// overlap completely or not at all, which enables more natural conversion to
|
||||
// HTML.
|
||||
type LinkedRangeTree struct {
|
||||
Node *BodyRange
|
||||
Sibling *LinkedRangeTree
|
||||
Child *LinkedRangeTree
|
||||
}
|
||||
|
||||
func ptrAdd(to **LinkedRangeTree, r *BodyRange) {
|
||||
if *to == nil {
|
||||
*to = &LinkedRangeTree{}
|
||||
}
|
||||
(*to).Add(r)
|
||||
}
|
||||
|
||||
// Add adds the given formatting entity to this tree.
|
||||
func (lrt *LinkedRangeTree) Add(r *BodyRange) {
|
||||
if lrt.Node == nil {
|
||||
lrt.Node = r
|
||||
return
|
||||
}
|
||||
lrtEnd := lrt.Node.End()
|
||||
if r.Start >= lrtEnd {
|
||||
ptrAdd(&lrt.Sibling, r.Offset(-lrtEnd))
|
||||
return
|
||||
}
|
||||
if r.End() > lrtEnd {
|
||||
ptrAdd(&lrt.Sibling, r.TruncateStart(lrtEnd).Offset(-lrtEnd))
|
||||
}
|
||||
ptrAdd(&lrt.Child, r.TruncateEnd(lrtEnd).Offset(-lrt.Node.Start))
|
||||
}
|
||||
+46
-16
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/media"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/telegramfmt"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/util"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/waveform"
|
||||
)
|
||||
@@ -97,35 +98,33 @@ func (c *TelegramClient) mediaToMatrix(ctx context.Context, portal *bridgev2.Por
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TelegramClient) convertToMatrix(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *tg.Message) (*bridgev2.ConvertedMessage, error) {
|
||||
func (c *TelegramClient) convertToMatrix(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *tg.Message) (cm *bridgev2.ConvertedMessage, err error) {
|
||||
log := zerolog.Ctx(ctx).With().Str("conversion_direction", "to_matrix").Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
cm := &bridgev2.ConvertedMessage{}
|
||||
cm = &bridgev2.ConvertedMessage{}
|
||||
hasher := sha256.New()
|
||||
if len(msg.Message) > 0 {
|
||||
var linkPreviews []*event.BeeperLinkPreview
|
||||
hasher.Write([]byte(msg.Message))
|
||||
|
||||
content, err := c.parseBodyAndHTML(ctx, msg.Message, msg.Entities)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if media, ok := msg.GetMedia(); ok && media.TypeID() == tg.MessageMediaWebPageTypeID {
|
||||
preview, err := c.webpageToBeeperLinkPreview(ctx, intent, media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
log.Err(err).Msg("error converting webpage to link preview")
|
||||
} else if preview != nil {
|
||||
linkPreviews = append(linkPreviews, preview)
|
||||
content.BeeperLinkPreviews = append(content.BeeperLinkPreviews, preview)
|
||||
}
|
||||
}
|
||||
|
||||
hasher.Write([]byte(msg.Message))
|
||||
|
||||
// TODO formatting
|
||||
cm.Parts = []*bridgev2.ConvertedMessagePart{
|
||||
{
|
||||
ID: networkid.PartID("caption"),
|
||||
Type: event.EventMessage,
|
||||
Content: &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: msg.Message,
|
||||
BeeperLinkPreviews: linkPreviews,
|
||||
},
|
||||
ID: networkid.PartID("caption"),
|
||||
Type: event.EventMessage,
|
||||
Content: content,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -153,7 +152,38 @@ func (c *TelegramClient) convertToMatrix(ctx context.Context, portal *bridgev2.P
|
||||
ContentURI: contentURI,
|
||||
}
|
||||
|
||||
return cm, nil
|
||||
if replyTo, ok := msg.GetReplyTo(); ok {
|
||||
switch replyTo := replyTo.(type) {
|
||||
case *tg.MessageReplyHeader:
|
||||
cm.ReplyTo = &networkid.MessageOptionalPartID{
|
||||
MessageID: ids.MakeMessageID(replyTo.ReplyToMsgID),
|
||||
}
|
||||
default:
|
||||
log.Warn().Type("reply_to", replyTo).Msg("unhandled reply to type")
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (t *TelegramClient) parseBodyAndHTML(ctx context.Context, message string, entities []tg.MessageEntityClass) (*event.MessageEventContent, error) {
|
||||
if len(entities) == 0 {
|
||||
return &event.MessageEventContent{MsgType: event.MsgText, Body: message}, nil
|
||||
}
|
||||
|
||||
var customEmojiIDs []int64
|
||||
for _, entity := range entities {
|
||||
switch entity := entity.(type) {
|
||||
case *tg.MessageEntityCustomEmoji:
|
||||
customEmojiIDs = append(customEmojiIDs, entity.DocumentID)
|
||||
}
|
||||
}
|
||||
customEmojis, err := t.transferEmojisToMatrix(ctx, customEmojiIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Printf("ce %+v\n", customEmojis) // TODO DEBUG
|
||||
return telegramfmt.Parse(ctx, message, entities, t.telegramFmtParams.WithCustomEmojis(customEmojis))
|
||||
}
|
||||
|
||||
func (c *TelegramClient) webpageToBeeperLinkPreview(ctx context.Context, intent bridgev2.MatrixAPI, msgMedia tg.MessageMediaClass) (preview *event.BeeperLinkPreview, err error) {
|
||||
|
||||
Reference in New Issue
Block a user