move gotd fork into repo. (#111)

- update to latest telegram layer
- remove some references to fields in tg.Entities that don't exist in
the schema
- originally added here:
https://github.com/beeper/td/commit/820929062a2ba0104397bc01235ab58a9cff780e
  - referenced here
-
https://github.com/mautrix/telegramgo/commit/124f0967ed195b5a380c9bd02e170ada9710dde3
-
https://github.com/mautrix/telegramgo/commit/4205047aab2e0639217148b5d125bfaab668bd8e
This commit is contained in:
Adam Van Ymeren
2025-06-27 20:03:37 -07:00
committed by GitHub
parent 0952df0244
commit 7a04f298d2
19264 changed files with 1539697 additions and 84 deletions
+99
View File
@@ -0,0 +1,99 @@
package message
import (
"context"
"time"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Audio creates new AudioDocumentBuilder to create audio attachment.
func (u *UploadedDocumentBuilder) Audio() *AudioDocumentBuilder {
b := u
if u.doc.MimeType == "" {
b = u.MIME(DefaultAudioMIME)
}
return &AudioDocumentBuilder{
doc: b,
attr: tg.DocumentAttributeAudio{},
}
}
// Voice creates new AudioDocumentBuilder to create voice attachment.
func (u *UploadedDocumentBuilder) Voice() *AudioDocumentBuilder {
return u.MIME(DefaultVoiceMIME).Audio().Voice()
}
// AudioDocumentBuilder is an Audio media option.
type AudioDocumentBuilder struct {
doc *UploadedDocumentBuilder
attr tg.DocumentAttributeAudio
}
// Voice sets flag to mark this audio as voice message.
func (u *AudioDocumentBuilder) Voice() *AudioDocumentBuilder {
u.attr.Voice = true
return u
}
// Duration sets duration of audio file.
func (u *AudioDocumentBuilder) Duration(duration time.Duration) *AudioDocumentBuilder {
return u.DurationSeconds(int(duration.Seconds()))
}
// DurationSeconds sets duration in seconds.
func (u *AudioDocumentBuilder) DurationSeconds(duration int) *AudioDocumentBuilder {
u.attr.Duration = duration
return u
}
// Title sets name of song.
func (u *AudioDocumentBuilder) Title(title string) *AudioDocumentBuilder {
u.attr.Title = title
return u
}
// Performer sets performer.
func (u *AudioDocumentBuilder) Performer(performer string) *AudioDocumentBuilder {
u.attr.Performer = performer
return u
}
// Waveform sets waveform representation of the voice message.
func (u *AudioDocumentBuilder) Waveform(waveform []byte) *AudioDocumentBuilder {
u.attr.Waveform = waveform
return u
}
// apply implements MediaOption.
func (u *AudioDocumentBuilder) apply(ctx context.Context, b *multiMediaBuilder) error {
return u.doc.Attributes(&u.attr).apply(ctx, b)
}
// applyMulti implements MultiMediaOption.
func (u *AudioDocumentBuilder) applyMulti(ctx context.Context, b *multiMediaBuilder) error {
return u.doc.Attributes(&u.attr).applyMulti(ctx, b)
}
// Audio adds audio attachment.
func Audio(file tg.InputFileClass, caption ...StyledTextOption) *AudioDocumentBuilder {
return UploadedDocument(file, caption...).Audio()
}
// Voice adds voice attachment.
func Voice(file tg.InputFileClass) *AudioDocumentBuilder {
return UploadedDocument(file).Voice()
}
// Audio sends audio file.
func (b *Builder) Audio(
ctx context.Context,
file tg.InputFileClass, caption ...StyledTextOption,
) (tg.UpdatesClass, error) {
return b.Media(ctx, Audio(file, caption...))
}
// Voice sends voice message.
func (b *Builder) Voice(ctx context.Context, file tg.InputFileClass) (tg.UpdatesClass, error) {
return b.Media(ctx, Voice(file))
}
@@ -0,0 +1,63 @@
package message_test
import (
"context"
"fmt"
"os"
"os/signal"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/uploader"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func sendAudio(ctx context.Context) error {
client, err := telegram.ClientFromEnvironment(telegram.Options{})
if err != nil {
return err
}
return client.Run(ctx, func(ctx context.Context) error {
raw := tg.NewClient(client)
// Upload file.
f, err := uploader.NewUploader(raw).FromPath(ctx, "vsyo idyot po planu.mp3", "")
if err != nil {
return errors.Wrap(err, "upload")
}
sender := message.NewSender(raw)
r := sender.Resolve("@durovschat")
// Sends audio to the @durovschat.
if _, err := r.Audio(ctx, f); err != nil {
return err
}
// Sends audio with title to the @durovschat.
if _, err := r.Media(ctx, message.Audio(f).
Performer("Yegor Letov").
Title("Everything is going according to plan")); err != nil {
return err
}
// Sends voice message to the @durovschat.
if _, err := r.Voice(ctx, f); err != nil {
return err
}
return nil
})
}
func ExampleAudio() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := sendAudio(ctx); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%+v\n", err)
os.Exit(2)
}
}
+52
View File
@@ -0,0 +1,52 @@
package message
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestVoice(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
file := &tg.InputFile{
ID: 10,
}
expectSendMedia(t, &tg.InputMediaUploadedDocument{
File: file,
MimeType: DefaultVoiceMIME,
Attributes: []tg.DocumentAttributeClass{
&tg.DocumentAttributeAudio{
Voice: true,
},
},
}, mock)
expectSendMedia(t, &tg.InputMediaUploadedDocument{
File: file,
MimeType: DefaultAudioMIME,
Attributes: []tg.DocumentAttributeClass{
&tg.DocumentAttributeAudio{
Duration: 10,
Title: "Big Iron",
Performer: "Marty Robbins",
Waveform: []byte{10},
},
},
}, mock)
_, err := sender.Self().Voice(ctx, file)
require.NoError(t, err)
_, err = sender.Self().Media(ctx, Audio(file).
Duration(10*time.Second).
Title("Big Iron").
Performer("Marty Robbins").
Waveform([]byte{10}),
)
require.NoError(t, err)
}
+133
View File
@@ -0,0 +1,133 @@
package message
import (
"time"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/markup"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/peer"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
type peerPromise = peer.Promise
// CloneBuilder returns copy of message Builder inside RequestBuilder.
func (b *RequestBuilder) CloneBuilder() *Builder {
return b.Builder.copy()
}
// Builder is a message builder.
type Builder struct {
// Sender to use.
sender *Sender
// The destination where the message will be sent.
peer peerPromise
// Set this flag to disable generation of the webpage preview.
noWebpage bool
// Send this message silently (no notifications for the receivers).
silent bool
// Send this message as background message.
background bool
// Clear the draft field.
clearDraft bool
// noForwards whether that sent message cannot be forwarded.
noForwards bool
// The reply target.
replyTo tg.InputReplyToClass
// Reply markup for sending bot buttons.
replyMarkup tg.ReplyMarkupClass
// Scheduled message date for scheduled messages.
scheduleDate int
// sendAs sets peer to send message as it.
sendAs tg.InputPeerClass
}
func (b *Builder) copy() *Builder {
if b == nil {
return nil
}
r := *b
return &r
}
// Silent sets flag to send this message silently (no notifications for the receivers).
func (b *Builder) Silent() *Builder {
b.silent = true
return b
}
// Background sets flag to send this message as background message.
func (b *Builder) Background() *Builder {
b.background = true
return b
}
// Clear sets flag to clear the draft field.
func (b *Builder) Clear() *Builder {
b.clearDraft = true
return b
}
// Reply sets message ID to reply.
func (b *Builder) Reply(id int) *Builder {
b.replyTo = &tg.InputReplyToMessage{
ReplyToMsgID: id,
}
return b
}
// ReplyMsg sets message to reply.
func (b *Builder) ReplyMsg(msg tg.MessageClass) *Builder {
return b.Reply(msg.GetID())
}
// ScheduleTS sets scheduled message timestamp for scheduled messages.
func (b *Builder) ScheduleTS(date int) *Builder {
b.scheduleDate = date
return b
}
// Schedule sets scheduled message date for scheduled messages.
func (b *Builder) Schedule(date time.Time) *Builder {
return b.ScheduleTS(int(date.Unix()))
}
// NoWebpage sets flag to disable generation of the webpage preview.
func (b *Builder) NoWebpage() *Builder {
b.noWebpage = true
return b
}
// NoForwards whether that sent message cannot be forwarded.
//
// See https://telegram.org/blog/protected-content-delete-by-date-and-more#protected-content-in-groups-and-channels.
func (b *Builder) NoForwards() *Builder {
b.noForwards = true
return b
}
// Markup sets reply markup for sending bot buttons.
//
// NB: markup will not be used, if you send multiple media attachments.
func (b *Builder) Markup(m tg.ReplyMarkupClass) *Builder {
b.replyMarkup = m
return b
}
// Row sets single row keyboard markup for sending bot buttons.
//
// NB: markup will not be used, if you send multiple media attachments.
func (b *Builder) Row(buttons ...tg.KeyboardButtonClass) *Builder {
return b.Markup(markup.InlineRow(buttons...))
}
// SendAs sets peer to send as.
//
// See https://telegram.org/blog/protected-content-delete-by-date-and-more#anonymous-posting-in-public-groups.
func (b *Builder) SendAs(p tg.InputPeerClass) *Builder {
b.sendAs = p
return b
}
+38
View File
@@ -0,0 +1,38 @@
package message
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestBuilder(t *testing.T) {
a := require.New(t)
b := new(Builder)
b = b.Silent()
a.True(b.silent)
b = b.Background()
a.True(b.background)
b = b.Clear()
a.True(b.clearDraft)
b = b.NoWebpage()
a.True(b.noWebpage)
b = b.ReplyMsg(&tg.Message{ID: 10})
a.Equal(10, b.replyTo.(*tg.InputReplyToMessage).ReplyToMsgID)
date := time.Now()
b = b.Schedule(date)
a.Equal(int(date.Unix()), b.scheduleDate)
markup := &tg.ReplyInlineMarkup{}
b = b.Markup(markup)
a.Equal(markup, b.replyMarkup)
}
+14
View File
@@ -0,0 +1,14 @@
package message
var (
_ MediaOption = (*PhotoExternalBuilder)(nil)
_ MediaOption = (*DocumentExternalBuilder)(nil)
)
var (
_ MultiMediaOption = (*UploadedPhotoBuilder)(nil)
_ MultiMediaOption = (*UploadedDocumentBuilder)(nil)
_ MultiMediaOption = (*VideoDocumentBuilder)(nil)
_ MultiMediaOption = (*AudioDocumentBuilder)(nil)
_ MultiMediaOption = (*SearchDocumentBuilder)(nil)
)
+8
View File
@@ -0,0 +1,8 @@
package message
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
// Contact adds contact attachment.
func Contact(contact tg.InputMediaContact, caption ...StyledTextOption) MediaOption {
return Media(&contact, caption...)
}
+28
View File
@@ -0,0 +1,28 @@
package message
import (
"context"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/styling"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestContact(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
contact := tg.InputMediaContact{
FirstName: "Михал Палыч",
LastName: "Терентьев",
PhoneNumber: "22 505",
}
expectSendMediaAndText(t, &contact, mock, "че с деньгами?", &tg.MessageEntityBold{
Length: utf8.RuneCountInString("че с деньгами?"),
})
_, err := sender.Self().Media(ctx, Contact(contact, styling.Bold("че с деньгами?")))
require.NoError(t, err)
}
+81
View File
@@ -0,0 +1,81 @@
package message
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/peer"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// DeleteBuilder is an intermediate builder to delete messages.
// Unlike RevokeBuilder will keep messages for other users.
type DeleteBuilder struct {
sender *Sender
}
// Delete creates new DeleteBuilder.
func (s *Sender) Delete() *DeleteBuilder {
return &DeleteBuilder{sender: s}
}
// Messages deletes messages by given IDs, but keeps it for other users.
//
// NB: Telegram counts message IDs globally for private chats (but not for channels). This method does not check that
// all given message IDs from one chat.
func (b *DeleteBuilder) Messages(ctx context.Context, ids ...int) (*tg.MessagesAffectedMessages, error) {
r, err := b.sender.deleteMessages(ctx, &tg.MessagesDeleteMessagesRequest{
ID: ids,
})
if err != nil {
return nil, errors.Wrap(err, "delete messages")
}
return r, nil
}
// RevokeBuilder is an intermediate builder to delete messages.
// Unlike DeleteBuilder will not keep messages for other users.
type RevokeBuilder struct {
builder *RequestBuilder
}
// Revoke creates new RevokeBuilder.
func (b *RequestBuilder) Revoke() *RevokeBuilder {
return &RevokeBuilder{builder: b}
}
// Messages deletes messages by given IDs.
//
// NB: Telegram counts message IDs globally for private chats (but not for channels). This method does not check that
// all given message IDs from one chat.
func (b *RevokeBuilder) Messages(ctx context.Context, ids ...int) (*tg.MessagesAffectedMessages, error) {
p, err := b.builder.peer(ctx)
if err != nil {
return nil, errors.Wrap(err, "peer")
}
ch, isChannel := peer.ToInputChannel(p)
if isChannel {
r, err := b.builder.sender.deleteChannelMessages(ctx, &tg.ChannelsDeleteMessagesRequest{
Channel: ch,
ID: ids,
})
if err != nil {
return nil, errors.Wrap(err, "delete channel messages")
}
return r, nil
}
r, err := b.builder.sender.deleteMessages(ctx, &tg.MessagesDeleteMessagesRequest{
Revoke: true,
ID: ids,
})
if err != nil {
return nil, errors.Wrap(err, "delete messages")
}
return r, nil
}
+65
View File
@@ -0,0 +1,65 @@
package message
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestRequestBuilder_Delete(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
mock.ExpectCall(&tg.MessagesDeleteMessagesRequest{
ID: []int{1, 2, 3},
}).ThenResult(&tg.MessagesAffectedMessages{})
_, err := sender.Delete().Messages(ctx, 1, 2, 3)
require.NoError(t, err)
mock.ExpectCall(&tg.MessagesDeleteMessagesRequest{
ID: []int{1, 2, 3},
}).ThenRPCErr(testRPCError())
_, err = sender.Delete().Messages(ctx, 1, 2, 3)
require.Error(t, err)
}
func TestRequestBuilder_Revoke(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
mock.ExpectCall(&tg.MessagesDeleteMessagesRequest{
Revoke: true,
ID: []int{1, 2, 3},
}).ThenResult(&tg.MessagesAffectedMessages{})
_, err := sender.To(&tg.InputPeerChat{ChatID: 10}).Revoke().Messages(ctx, 1, 2, 3)
require.NoError(t, err)
mock.ExpectCall(&tg.MessagesDeleteMessagesRequest{
Revoke: true,
ID: []int{1, 2, 3},
}).ThenRPCErr(testRPCError())
_, err = sender.To(&tg.InputPeerChat{ChatID: 10}).Revoke().Messages(ctx, 1, 2, 3)
require.Error(t, err)
ch := &tg.InputPeerChannel{ChannelID: 10, AccessHash: 10}
inputCh := &tg.InputChannel{
ChannelID: ch.ChannelID,
AccessHash: ch.AccessHash,
}
mock.ExpectCall(&tg.ChannelsDeleteMessagesRequest{
Channel: inputCh,
ID: []int{1, 2, 3},
}).ThenResult(&tg.MessagesAffectedMessages{})
_, err = sender.To(ch).Revoke().Messages(ctx, 1, 2, 3)
require.NoError(t, err)
mock.ExpectCall(&tg.ChannelsDeleteMessagesRequest{
Channel: inputCh,
ID: []int{1, 2, 3},
}).ThenRPCErr(testRPCError())
_, err = sender.To(ch).Revoke().Messages(ctx, 1, 2, 3)
require.Error(t, err)
}
+89
View File
@@ -0,0 +1,89 @@
package message
import (
"context"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// MediaDice adds a dice-based animated sticker.
func MediaDice(emoticon string) MediaOption {
return Media(&tg.InputMediaDice{
Emoticon: emoticon,
})
}
const (
// DiceEmoticon is an emoticon to send dice sticker.
DiceEmoticon = "🎲"
// DartsEmoticon is an emoticon to send darts sticker.
DartsEmoticon = "🎯"
// BasketballEmoticon is an emoticon to send basketball sticker.
BasketballEmoticon = "🏀"
// FootballEmoticon is an emoticon to send football sticker.
FootballEmoticon = "⚽"
// CasinoEmoticon is an emoticon to send casino sticker.
CasinoEmoticon = "🎰"
// BowlingEmoticon is an emoticon to send bowling sticker.
BowlingEmoticon = "🎳"
)
// Dice adds a dice animated sticker.
func Dice() MediaOption {
return MediaDice(DiceEmoticon)
}
// Darts adds a darts animated sticker.
func Darts() MediaOption {
return MediaDice(DartsEmoticon)
}
// Basketball adds a basketball animated sticker.
func Basketball() MediaOption {
return MediaDice(BasketballEmoticon)
}
// Football adds a football animated sticker.
func Football() MediaOption {
return MediaDice(FootballEmoticon)
}
// Casino adds a casino animated sticker.
func Casino() MediaOption {
return MediaDice(CasinoEmoticon)
}
// Bowling adds a bowling animated sticker.
func Bowling() MediaOption {
return MediaDice(BowlingEmoticon)
}
// Dice sends a dice animated sticker.
func (b *Builder) Dice(ctx context.Context) (tg.UpdatesClass, error) {
return b.Media(ctx, Dice())
}
// Darts sends a darts animated sticker.
func (b *Builder) Darts(ctx context.Context) (tg.UpdatesClass, error) {
return b.Media(ctx, Darts())
}
// Basketball sends a basketball animated sticker.
func (b *Builder) Basketball(ctx context.Context) (tg.UpdatesClass, error) {
return b.Media(ctx, Basketball())
}
// Football sends a football animated sticker.
func (b *Builder) Football(ctx context.Context) (tg.UpdatesClass, error) {
return b.Media(ctx, Football())
}
// Casino sends a casino animated sticker.
func (b *Builder) Casino(ctx context.Context) (tg.UpdatesClass, error) {
return b.Media(ctx, Casino())
}
// Bowling sends a bowling animated sticker.
func (b *Builder) Bowling(ctx context.Context) (tg.UpdatesClass, error) {
return b.Media(ctx, Bowling())
}
@@ -0,0 +1,45 @@
package message_test
import (
"context"
"fmt"
"os"
"os/signal"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func sendDice(ctx context.Context) error {
client, err := telegram.ClientFromEnvironment(telegram.Options{})
if err != nil {
return err
}
return client.Run(ctx, func(ctx context.Context) error {
sender := message.NewSender(tg.NewClient(client))
// Sends dice "🎲" to the @durovschat.
if _, err := sender.Resolve("@durovschat").Dice(ctx); err != nil {
return err
}
// Sends darts "🎯" to the @durovschat.
if _, err := sender.Resolve("https://t.me/durovschat").Darts(ctx); err != nil {
return err
}
return nil
})
}
func ExampleMediaDice() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := sendDice(ctx); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%+v\n", err)
os.Exit(2)
}
}
+39
View File
@@ -0,0 +1,39 @@
package message
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestMediaDice(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
expectDice := func(emoticon string) {
expectSendMedia(t, &tg.InputMediaDice{Emoticon: emoticon}, mock)
}
expectDice(DiceEmoticon)
expectDice(DartsEmoticon)
expectDice(BasketballEmoticon)
expectDice(FootballEmoticon)
expectDice(CasinoEmoticon)
expectDice(BowlingEmoticon)
_, err := sender.Self().Dice(ctx)
require.NoError(t, err)
_, err = sender.Self().Darts(ctx)
require.NoError(t, err)
_, err = sender.Self().Basketball(ctx)
require.NoError(t, err)
_, err = sender.Self().Football(ctx)
require.NoError(t, err)
_, err = sender.Self().Casino(ctx)
require.NoError(t, err)
_, err = sender.Self().Bowling(ctx)
require.NoError(t, err)
}
+2
View File
@@ -0,0 +1,2 @@
// Package message contains some useful utilities for creating Telegram messages.
package message
+50
View File
@@ -0,0 +1,50 @@
package message
import (
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgmock"
)
func testSender(t *testing.T) (*Sender, *tgmock.Mock) {
mock := tgmock.NewRequire(t)
sender := NewSender(tg.NewClient(mock))
return sender, mock
}
func testRPCError() *tgerr.Error {
return &tgerr.Error{
Code: 1337,
Message: "TEST_ERROR",
Type: "TEST_ERROR",
}
}
func expectSendMedia(t *testing.T, attachment tg.InputMediaClass, mock *tgmock.Mock) {
expectSendMediaAndText(t, attachment, mock, "")
}
func expectSendMediaAndText(
t *testing.T, attachment tg.InputMediaClass, mock *tgmock.Mock,
msg string, entities ...tg.MessageEntityClass,
) {
mock.ExpectFunc(func(b bin.Encoder) {
req, ok := b.(*tg.MessagesSendMediaRequest)
require.True(t, ok)
require.True(t, ok)
require.Equal(t, &tg.InputPeerSelf{}, req.Peer)
require.Equal(t, msg, req.Message)
require.Equal(t, attachment, req.Media)
require.NotZero(t, req.RandomID)
require.Equal(t, len(entities), len(req.Entities))
if len(entities) > 0 {
require.Equal(t, entities, req.Entities)
}
}).ThenResult(&tg.Updates{})
}
+295
View File
@@ -0,0 +1,295 @@
package message
import (
"context"
"encoding/hex"
"time"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// DocumentBuilder is a Document media option.
type DocumentBuilder struct {
doc tg.InputMediaDocument
caption []StyledTextOption
}
// TTL sets time to live of self-destructing document.
func (u *DocumentBuilder) TTL(ttl time.Duration) *DocumentBuilder {
return u.TTLSeconds(int(ttl.Seconds()))
}
// TTLSeconds sets time to live in seconds of self-destructing document.
func (u *DocumentBuilder) TTLSeconds(ttl int) *DocumentBuilder {
u.doc.TTLSeconds = ttl
return u
}
// Query sets query field of InputMediaDocument.
func (u *DocumentBuilder) Query(query string) *DocumentBuilder {
u.doc.Query = query
return u
}
// apply implements MediaOption.
func (u *DocumentBuilder) apply(ctx context.Context, b *multiMediaBuilder) error {
return Media(&u.doc, u.caption...).apply(ctx, b)
}
// applyMulti implements MultiMediaOption.
func (u *DocumentBuilder) applyMulti(ctx context.Context, b *multiMediaBuilder) error {
return u.apply(ctx, b)
}
// Document adds document attachment.
func Document(doc FileLocation, caption ...StyledTextOption) *DocumentBuilder {
v := new(tg.InputDocument)
v.FillFrom(doc)
return &DocumentBuilder{
doc: tg.InputMediaDocument{
ID: v,
},
caption: caption,
}
}
// Document sends document.
func (b *Builder) Document(
ctx context.Context, file FileLocation, caption ...StyledTextOption,
) (tg.UpdatesClass, error) {
return b.Media(ctx, Document(file, caption...))
}
// SearchDocumentBuilder is a Document media option which uses messages.getDocumentByHash
// to find document.
//
// See https://core.telegram.org/method/messages.getDocumentByHash.
//
// See https://core.telegram.org/api/files#re-using-pre-uploaded-files.
type SearchDocumentBuilder struct {
hash []byte
size int64
mime string
builder *DocumentBuilder
}
// TTL sets time to live of self-destructing document.
func (u *SearchDocumentBuilder) TTL(ttl time.Duration) *SearchDocumentBuilder {
return u.TTLSeconds(int(ttl.Seconds()))
}
// TTLSeconds sets time to live in seconds of self-destructing document.
func (u *SearchDocumentBuilder) TTLSeconds(ttl int) *SearchDocumentBuilder {
u.builder.doc.TTLSeconds = ttl
return u
}
// Query sets query field of InputMediaDocument.
func (u *SearchDocumentBuilder) Query(query string) *SearchDocumentBuilder {
u.builder.doc.Query = query
return u
}
// apply implements MediaOption.
func (u *SearchDocumentBuilder) apply(ctx context.Context, b *multiMediaBuilder) error {
result, err := b.sender.getDocumentByHash(ctx, &tg.MessagesGetDocumentByHashRequest{
SHA256: u.hash,
Size: u.size,
MimeType: u.mime,
})
if err != nil {
return errors.Wrap(err, "find document")
}
doc, ok := result.AsNotEmpty()
if !ok {
return errors.Errorf("document with hash %q not found", hex.EncodeToString(u.hash))
}
v := new(tg.InputDocument)
v.FillFrom(doc)
u.builder.doc.ID = v
return Media(&u.builder.doc, u.builder.caption...).apply(ctx, b)
}
// applyMulti implements MultiMediaOption.
func (u *SearchDocumentBuilder) applyMulti(ctx context.Context, b *multiMediaBuilder) error {
return u.apply(ctx, b)
}
// DocumentByHash finds document by hash and adds as attachment.
//
// See https://core.telegram.org/method/messages.getDocumentByHash.
//
// See https://core.telegram.org/api/files#re-using-pre-uploaded-files.
func DocumentByHash(
hash []byte, size int64, mime string,
caption ...StyledTextOption,
) *SearchDocumentBuilder {
return &SearchDocumentBuilder{
hash: hash,
size: size,
mime: mime,
builder: &DocumentBuilder{
caption: caption,
},
}
}
// DocumentByHash finds document by hash and sends as attachment.
func (b *Builder) DocumentByHash(
ctx context.Context, hash []byte, size int64, mime string,
caption ...StyledTextOption,
) (tg.UpdatesClass, error) {
return b.Media(ctx, DocumentByHash(hash, size, mime, caption...))
}
// DocumentExternalBuilder is a DocumentExternal media option.
type DocumentExternalBuilder struct {
doc tg.InputMediaDocumentExternal
caption []StyledTextOption
}
// TTL sets time to live of self-destructing document.
func (u *DocumentExternalBuilder) TTL(ttl time.Duration) *DocumentExternalBuilder {
return u.TTLSeconds(int(ttl.Seconds()))
}
// TTLSeconds sets time to live in seconds of self-destructing document.
func (u *DocumentExternalBuilder) TTLSeconds(ttl int) *DocumentExternalBuilder {
u.doc.TTLSeconds = ttl
return u
}
// apply implements MediaOption.
func (u *DocumentExternalBuilder) apply(ctx context.Context, b *multiMediaBuilder) error {
return Media(&u.doc, u.caption...).apply(ctx, b)
}
// DocumentExternal adds document attachment that will be downloaded by the Telegram servers.
func DocumentExternal(url string, caption ...StyledTextOption) *DocumentExternalBuilder {
return &DocumentExternalBuilder{
doc: tg.InputMediaDocumentExternal{
URL: url,
},
caption: caption,
}
}
// DocumentExternal sends document attachment that will be downloaded by the Telegram servers.
func (b *Builder) DocumentExternal(ctx context.Context, url string, caption ...StyledTextOption) (tg.UpdatesClass, error) {
return b.Media(ctx, DocumentExternal(url, caption...))
}
// UploadedDocumentBuilder is a UploadedDocument media option.
type UploadedDocumentBuilder struct {
doc tg.InputMediaUploadedDocument
caption []StyledTextOption
}
// NosoundVideo sets flag that the specified document is a video file with no audio tracks
// (a GIF animation (even as MPEG4), for example).
func (u *UploadedDocumentBuilder) NosoundVideo(v bool) *UploadedDocumentBuilder {
u.doc.NosoundVideo = v
return u
}
// ForceFile sets flag to force the media file to be uploaded as document.
func (u *UploadedDocumentBuilder) ForceFile(v bool) *UploadedDocumentBuilder {
u.doc.ForceFile = v
return u
}
// Thumb sets thumbnail of the document, uploaded as for the file.
func (u *UploadedDocumentBuilder) Thumb(file tg.InputFileClass) *UploadedDocumentBuilder {
u.doc.Thumb = file
return u
}
// MIME sets MIME type of document.
func (u *UploadedDocumentBuilder) MIME(mime string) *UploadedDocumentBuilder {
u.doc.MimeType = mime
return u
}
// Attributes adds given attributes to the document.
// Attribute specify the type of the document (video, audio, voice, sticker, etc.).
func (u *UploadedDocumentBuilder) Attributes(attrs ...tg.DocumentAttributeClass) *UploadedDocumentBuilder {
u.doc.Attributes = append(u.doc.Attributes, attrs...)
return u
}
// Filename sets name of uploaded file.
func (u *UploadedDocumentBuilder) Filename(name string) *UploadedDocumentBuilder {
return u.Attributes(&tg.DocumentAttributeFilename{
FileName: name,
})
}
// HasStickers sets flag that document attachment has stickers.
func (u *UploadedDocumentBuilder) HasStickers() *UploadedDocumentBuilder {
return u.Attributes(&tg.DocumentAttributeHasStickers{})
}
// Stickers adds attached mask stickers.
func (u *UploadedDocumentBuilder) Stickers(stickers ...FileLocation) *UploadedDocumentBuilder {
u.doc.Stickers = append(u.doc.Stickers, inputDocuments(stickers...)...)
return u
}
// TTL sets time to live of self-destructing document.
func (u *UploadedDocumentBuilder) TTL(ttl time.Duration) *UploadedDocumentBuilder {
return u.TTLSeconds(int(ttl.Seconds()))
}
// TTLSeconds sets time to live in seconds of self-destructing document.
func (u *UploadedDocumentBuilder) TTLSeconds(ttl int) *UploadedDocumentBuilder {
u.doc.TTLSeconds = ttl
return u
}
// apply implements MediaOption.
func (u *UploadedDocumentBuilder) apply(ctx context.Context, b *multiMediaBuilder) error {
return Media(&u.doc, u.caption...).apply(ctx, b)
}
// applyMulti implements MultiMediaOption.
func (u *UploadedDocumentBuilder) applyMulti(ctx context.Context, b *multiMediaBuilder) error {
m, err := b.sender.uploadMedia(ctx, &tg.MessagesUploadMediaRequest{
Peer: b.peer,
Media: &u.doc,
})
if err != nil {
return errors.Wrap(err, "upload media")
}
input, err := convertMessageMediaToInput(m)
if err != nil {
return errors.Wrap(err, "convert")
}
return Media(input, u.caption...).apply(ctx, b)
}
// UploadedDocument adds document attachment.
func UploadedDocument(file tg.InputFileClass, caption ...StyledTextOption) *UploadedDocumentBuilder {
return &UploadedDocumentBuilder{
doc: tg.InputMediaUploadedDocument{
File: file,
},
caption: caption,
}
}
// File adds document attachment and forces it to be used as plain file, not media.
func File(file tg.InputFileClass, caption ...StyledTextOption) *UploadedDocumentBuilder {
return UploadedDocument(file, caption...).ForceFile(true)
}
// File sends uploaded file as document and forces it to be used as plain file, not media.
func (b *Builder) File(ctx context.Context, file tg.InputFileClass, caption ...StyledTextOption) (tg.UpdatesClass, error) {
return b.Media(ctx, File(file, caption...))
}
+127
View File
@@ -0,0 +1,127 @@
package message
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestDocument(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
loc := &tg.InputDocument{
ID: 10,
}
expectSendMedia(t, &tg.InputMediaDocument{ID: loc}, mock)
expectSendMedia(t, &tg.InputMediaDocument{
ID: loc,
TTLSeconds: 10,
Query: "10",
}, mock)
_, err := sender.Self().Document(ctx, loc)
require.NoError(t, err)
_, err = sender.Self().Media(ctx, Document(loc).
TTL(10*time.Second).Query("10"))
require.NoError(t, err)
}
func TestDocumentExternal(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
expectSendMedia(t, &tg.InputMediaDocumentExternal{URL: "https://google.com"}, mock)
expectSendMedia(t, &tg.InputMediaDocumentExternal{
URL: "https://github.com",
TTLSeconds: 10,
}, mock)
_, err := sender.Self().DocumentExternal(ctx, "https://google.com")
require.NoError(t, err)
_, err = sender.Self().Media(ctx, DocumentExternal("https://github.com").TTL(10*time.Second))
require.NoError(t, err)
}
func TestDocumentByHash(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
doc := &tg.Document{
ID: 10,
FileReference: []byte{10},
}
loc := new(tg.InputDocument)
loc.FillFrom(doc)
hash := []byte{1, 2, 3}
size := int64(10)
mime := "rustmustdie"
mock.ExpectCall(&tg.MessagesGetDocumentByHashRequest{
SHA256: hash,
Size: size,
MimeType: mime,
}).ThenResult(doc)
expectSendMedia(t, &tg.InputMediaDocument{ID: loc}, mock)
_, err := sender.Self().DocumentByHash(ctx, hash, size, mime)
require.NoError(t, err)
mock.ExpectCall(&tg.MessagesGetDocumentByHashRequest{
SHA256: hash,
Size: size,
MimeType: mime,
}).ThenResult(doc)
expectSendMedia(t, &tg.InputMediaDocument{
ID: loc,
TTLSeconds: 10,
Query: "10",
}, mock)
_, err = sender.Self().Media(ctx, DocumentByHash(hash, size, mime).
TTL(10*time.Second).Query("10"))
require.NoError(t, err)
}
func TestUploadedDocument(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
file := &tg.InputFile{
ID: 10,
}
loc := &tg.InputDocumentFileLocation{
ID: 10,
}
expectSendMedia(t, &tg.InputMediaUploadedDocument{
File: file,
ForceFile: true,
}, mock)
expectSendMedia(t, &tg.InputMediaUploadedDocument{
File: file,
Attributes: []tg.DocumentAttributeClass{
&tg.DocumentAttributeFilename{FileName: "abc.jpg"},
},
TTLSeconds: 10,
}, mock)
expectSendMedia(t, &tg.InputMediaUploadedDocument{
File: file,
Thumb: file,
Stickers: []tg.InputDocumentClass{&tg.InputDocument{
ID: loc.GetID(),
}},
Attributes: []tg.DocumentAttributeClass{
&tg.DocumentAttributeHasStickers{},
},
}, mock)
_, err := sender.Self().File(ctx, file)
require.NoError(t, err)
_, err = sender.Self().Media(ctx, UploadedDocument(file).TTL(10*time.Second).
Filename("abc.jpg"))
require.NoError(t, err)
_, err = sender.Self().Media(ctx, UploadedDocument(file).Thumb(file).Stickers(loc).HasStickers())
require.NoError(t, err)
}
+67
View File
@@ -0,0 +1,67 @@
package message
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/styling"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func (b *Builder) saveDraftRequest(
peer tg.InputPeerClass,
msg string,
entities []tg.MessageEntityClass,
) *tg.MessagesSaveDraftRequest {
return &tg.MessagesSaveDraftRequest{
NoWebpage: b.noWebpage,
Peer: peer,
ReplyTo: b.replyTo,
Message: msg,
Entities: entities,
}
}
// ClearDraft clears draft.
// Also, you can use Clear() builder option with any other message send method.
//
// See https://core.telegram.org/api/drafts#clearing-drafts.
func (b *Builder) ClearDraft(ctx context.Context) error {
p, err := b.peer(ctx)
if err != nil {
return errors.Wrap(err, "peer")
}
return b.sender.saveDraft(ctx, &tg.MessagesSaveDraftRequest{Peer: p})
}
// SaveDraft saves given message as draft.
//
// See https://core.telegram.org/api/drafts#saving-drafts.
func (b *Builder) SaveDraft(ctx context.Context, msg string) error {
p, err := b.peer(ctx)
if err != nil {
return errors.Wrap(err, "peer")
}
return b.sender.saveDraft(ctx, b.saveDraftRequest(p, msg, nil))
}
// SaveStyledDraft saves given styled message as draft.
//
// See https://core.telegram.org/api/drafts#saving-drafts.
func (b *Builder) SaveStyledDraft(ctx context.Context, texts ...StyledTextOption) error {
p, err := b.peer(ctx)
if err != nil {
return errors.Wrap(err, "peer")
}
tb := entity.Builder{}
if err := styling.Perform(&tb, texts...); err != nil {
return err
}
msg, entities := tb.Complete()
return b.sender.saveDraft(ctx, b.saveDraftRequest(p, msg, entities))
}
@@ -0,0 +1,54 @@
package message_test
import (
"context"
"fmt"
"os"
"os/signal"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/styling"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func saveDraft(ctx context.Context) error {
client, err := telegram.ClientFromEnvironment(telegram.Options{})
if err != nil {
return err
}
return client.Run(ctx, func(ctx context.Context) error {
sender := message.NewSender(tg.NewClient(client))
r := sender.Resolve("@durov")
// Save draft message.
if err := r.SaveDraft(ctx, "Hi!"); err != nil {
return errors.Wrap(err, "draft")
}
// Save styled draft message.
if err := r.SaveStyledDraft(ctx, styling.Bold("Hi!")); err != nil {
return errors.Wrap(err, "draft")
}
// Clear draft for resolved @durov peer.
if err := r.ClearDraft(ctx); err != nil {
return errors.Wrap(err, "draft")
}
return nil
})
}
func ExampleBuilder_SaveDraft() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := saveDraft(ctx); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%+v\n", err)
os.Exit(2)
}
}
+37
View File
@@ -0,0 +1,37 @@
package message
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/styling"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestDraft(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
mock.ExpectCall(&tg.MessagesSaveDraftRequest{
Peer: &tg.InputPeerSelf{},
Message: "text",
}).ThenTrue()
mock.ExpectCall(&tg.MessagesSaveDraftRequest{
Peer: &tg.InputPeerSelf{},
Message: "styled text",
Entities: []tg.MessageEntityClass{
&tg.MessageEntityBold{
Length: len("styled text"),
},
},
}).ThenTrue()
mock.ExpectCall(&tg.MessagesSaveDraftRequest{
Peer: &tg.InputPeerSelf{},
}).ThenTrue()
require.NoError(t, sender.Self().SaveDraft(ctx, "text"))
require.NoError(t, sender.Self().SaveStyledDraft(ctx, styling.Bold("styled text")))
require.NoError(t, sender.Self().ClearDraft(ctx))
}
+102
View File
@@ -0,0 +1,102 @@
package message
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/styling"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// EditMessageBuilder creates edit message builder.
type EditMessageBuilder struct {
builder *Builder
id int
}
func (b *EditMessageBuilder) editTextRequest(
p tg.InputPeerClass,
msg string,
entities []tg.MessageEntityClass,
) *tg.MessagesEditMessageRequest {
return &tg.MessagesEditMessageRequest{
NoWebpage: b.builder.noWebpage,
Peer: p,
ID: b.id,
Message: msg,
ReplyMarkup: b.builder.replyMarkup,
Entities: entities,
ScheduleDate: b.builder.scheduleDate,
}
}
// Text edits message.
func (b *EditMessageBuilder) Text(ctx context.Context, msg string) (tg.UpdatesClass, error) {
p, err := b.builder.peer(ctx)
if err != nil {
return nil, errors.Wrap(err, "peer")
}
upd, err := b.builder.sender.editMessage(ctx, b.editTextRequest(p, msg, nil))
if err != nil {
return nil, errors.Wrap(err, "edit styled text message")
}
return upd, nil
}
// Textf formats and edits message .
func (b *EditMessageBuilder) Textf(ctx context.Context, format string, args ...interface{}) (tg.UpdatesClass, error) {
return b.Text(ctx, formatMessage(format, args...))
}
// StyledText edits message using given message.
func (b *EditMessageBuilder) StyledText(ctx context.Context, texts ...StyledTextOption) (tg.UpdatesClass, error) {
p, err := b.builder.peer(ctx)
if err != nil {
return nil, errors.Wrap(err, "peer")
}
tb := entity.Builder{}
if err := styling.Perform(&tb, texts...); err != nil {
return nil, err
}
msg, entities := tb.Complete()
upd, err := b.builder.sender.editMessage(ctx, b.editTextRequest(p, msg, entities))
if err != nil {
return nil, errors.Wrap(err, "edit styled text message")
}
return upd, nil
}
// Media edits message using given media and text.
func (b *EditMessageBuilder) Media(ctx context.Context, media MediaOption) (tg.UpdatesClass, error) {
p, err := b.builder.peer(ctx)
if err != nil {
return nil, errors.Wrap(err, "peer")
}
attachment, err := b.builder.applySingleMedia(ctx, p, media)
if err != nil {
return nil, err
}
req := b.editTextRequest(p, attachment.Message, attachment.Entities)
req.Media = attachment.Media
upd, err := b.builder.sender.editMessage(ctx, req)
if err != nil {
return nil, errors.Wrap(err, "send media")
}
return upd, nil
}
// Edit edits message by ID.
func (b *Builder) Edit(id int) *EditMessageBuilder {
return &EditMessageBuilder{builder: b, id: id}
}
+100
View File
@@ -0,0 +1,100 @@
package message
import (
"context"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/styling"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestEditMessageBuilder_Text(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
msg := "abc"
mock.ExpectCall(&tg.MessagesEditMessageRequest{
Peer: &tg.InputPeerSelf{},
ID: 10,
Message: msg,
}).ThenResult(&tg.Updates{})
_, err := sender.Self().Edit(10).Text(ctx, msg)
require.NoError(t, err)
mock.ExpectCall(&tg.MessagesEditMessageRequest{
Peer: &tg.InputPeerSelf{},
ID: 10,
Message: msg,
}).ThenRPCErr(testRPCError())
_, err = sender.Self().Edit(10).Textf(ctx, "%s", msg)
require.Error(t, err)
}
func TestEditMessageBuilder_StyledText(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
msg := "abc"
mock.ExpectCall(&tg.MessagesEditMessageRequest{
Peer: &tg.InputPeerSelf{},
ID: 10,
Message: msg,
Entities: []tg.MessageEntityClass{
&tg.MessageEntityBold{
Length: utf8.RuneCountInString(msg),
},
},
}).ThenResult(&tg.Updates{})
_, err := sender.Self().Edit(10).StyledText(ctx, styling.Bold(msg))
require.NoError(t, err)
mock.ExpectCall(&tg.MessagesEditMessageRequest{
Peer: &tg.InputPeerSelf{},
ID: 10,
Message: msg,
Entities: []tg.MessageEntityClass{
&tg.MessageEntityBold{
Length: utf8.RuneCountInString(msg),
},
},
}).ThenRPCErr(testRPCError())
_, err = sender.Self().Edit(10).StyledText(ctx, styling.Bold(msg))
require.Error(t, err)
}
func TestEditMessageBuilder_Media(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
loc := &tg.InputPhoto{
ID: 10,
}
mock.ExpectCall(&tg.MessagesEditMessageRequest{
Peer: &tg.InputPeerSelf{},
ID: 10,
Media: &tg.InputMediaPhoto{
ID: loc,
},
}).ThenResult(&tg.Updates{})
_, err := sender.Self().Edit(10).Media(ctx, Photo(loc))
require.NoError(t, err)
mock.ExpectCall(&tg.MessagesEditMessageRequest{
Peer: &tg.InputPeerSelf{},
ID: 10,
Media: &tg.InputMediaPhoto{
ID: loc,
},
}).ThenRPCErr(testRPCError())
_, err = sender.Self().Edit(10).Media(ctx, Photo(loc))
require.Error(t, err)
}
+2
View File
@@ -0,0 +1,2 @@
// Package entity contains message formatting and styling helpers.
package entity
+150
View File
@@ -0,0 +1,150 @@
package entity
import (
"reflect"
"sort"
"strings"
"unicode"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// SortEntities sorts entities as TDLib does it.
func SortEntities(entity []tg.MessageEntityClass) {
sort.Sort(entitySorter(entity))
}
type entitySorter []tg.MessageEntityClass
func (e entitySorter) Len() int {
return len(e)
}
func (e entitySorter) Less(i, j int) bool {
a, b := e[i], e[j]
return a.GetOffset() < b.GetOffset() ||
a.GetLength() > b.GetLength()
}
func (e entitySorter) Swap(i, j int) {
e[i], e[j] = e[j], e[i]
}
// setLength sets Length field of entity.
func setLength(index, value int, slice []tg.MessageEntityClass) {
reflect.ValueOf(&slice[index]).
Elem().Elem().Elem().
FieldByName("Length").
SetInt(int64(value))
}
// fixEntities trims space, if needed and fixes entities offsets.
func (b *Builder) fixEntities(msg string, entities []tg.MessageEntityClass) (string, []tg.MessageEntityClass) {
// If there are no entities or last text block does not have entities,
// so we just return built message.
if len(b.lengths) == 0 || b.lastFormatIndex >= len(entities) {
return msg, entities
}
// Since Telegram client does not handle space after formatted message
// we should compute length of the last block to trim it.
// Get first entity of last text block.
entity := b.lengths[len(b.lengths)-1]
offset := entity.offset
length := entity.length
// Get last text block.
lastBlock := msg[offset:]
// Trim this block.
trimmed := strings.TrimRightFunc(lastBlock, unicode.IsSpace)
// If there are a difference, we should change length of the all entities.
if length >= len(lastBlock) && len(trimmed) != len(lastBlock) {
length := ComputeLength(trimmed)
for idx := range entities[b.lastFormatIndex:] {
setLength(idx, length, entities[b.lastFormatIndex:])
}
msg = msg[:offset+len(trimmed)]
}
return msg, entities
}
// Raw returns raw result and resets builder without fixing spaces.
func (b *Builder) Raw() (string, []tg.MessageEntityClass) {
msg := b.message.String()
entities := b.entities
b.Reset()
return msg, entities
}
// Complete returns build result and resets builder.
func (b *Builder) Complete() (string, []tg.MessageEntityClass) {
msg, entities := b.Raw()
defer SortEntities(entities)
return b.fixEntities(msg, entities)
}
// ShrinkPreCode merges following <pre> and <code> entities, if needed.
//
// This function is used by formatters to be compliant with TDLib.
func (b *Builder) ShrinkPreCode() {
b.entities = shrinkPreCode(b.entities)
}
// equalRange compares ranges of given entities.
func equalRange(a, b tg.MessageEntityClass) bool {
return a.GetLength() == b.GetLength() && a.GetOffset() == b.GetOffset()
}
// shrinkPreCode merges following <pre> and <code> entities, if needed.
func shrinkPreCode(entities []tg.MessageEntityClass) []tg.MessageEntityClass {
for i, j := 0, len(entities)-1; i < j; i, j = i+1, j-1 {
entities[i], entities[j] = entities[j], entities[i]
}
filter := func(keep func(prev, cur tg.MessageEntityClass) bool) []tg.MessageEntityClass {
n := 0
for i, val := range entities {
if i == 0 || keep(entities[i-1], val) {
entities[n] = val
n++
}
}
return entities[:n]
}
isPreCode := func(class tg.MessageEntityClass) bool {
typeID := class.TypeID()
return typeID == tg.MessageEntityCodeTypeID || typeID == tg.MessageEntityPreTypeID
}
hasLang := func(class tg.MessageEntityClass) bool {
pre, ok := class.(*tg.MessageEntityPre)
return ok && pre.Language != ""
}
resetLang := func(class tg.MessageEntityClass) {
pre, ok := class.(*tg.MessageEntityPre)
if !ok {
return
}
pre.Language = ""
}
return filter(func(prev, cur tg.MessageEntityClass) bool {
if !isPreCode(prev) ||
!isPreCode(cur) ||
prev.TypeID() == cur.TypeID() {
// Keep if not is Pre/Code entities or if they are same.
return true
}
if !equalRange(prev, cur) {
resetLang(prev)
resetLang(cur)
return true
}
return !hasLang(prev)
})
}
@@ -0,0 +1,98 @@
package entity
import (
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestEnsureTrim(t *testing.T) {
a := require.New(t)
prefix := "pre"
expected := "abc\nabc"
b := Builder{}
b.Plain(prefix)
b.Format(expected+"\n\n\n", Bold(), Italic())
msg, ent := b.Complete()
a.Equal(prefix+expected, msg)
a.Equal([]tg.MessageEntityClass{
&tg.MessageEntityBold{
Offset: len(prefix),
Length: ComputeLength(expected),
},
&tg.MessageEntityItalic{
Offset: len(prefix),
Length: ComputeLength(expected),
},
}, ent)
}
func TestComplete(t *testing.T) {
tests := []struct {
name string
format func(e *Builder)
msg string
entities []tg.MessageEntityClass
}{
{"PlainBold", func(e *Builder) {
e.Plain("plain").Bold("bold")
}, "plainbold", []tg.MessageEntityClass{
&tg.MessageEntityBold{
Offset: ComputeLength("plain"),
Length: ComputeLength("bold"),
},
}},
{"PlainBoldAndStrike", func(e *Builder) {
e.Plain("plain").Format("10\n\n\n\n", Bold(), Strike())
}, "plain10", []tg.MessageEntityClass{
&tg.MessageEntityBold{
Offset: ComputeLength("plain"),
Length: ComputeLength("10"),
},
&tg.MessageEntityStrike{
Offset: ComputeLength("plain"),
Length: ComputeLength("10"),
},
}},
{"BoldPlainBold", func(e *Builder) {
e.Bold("bold").Plain("plain").Bold("bold2\n\n\n\n")
}, "boldplainbold2", []tg.MessageEntityClass{
&tg.MessageEntityBold{
Offset: ComputeLength("boldplain"),
Length: ComputeLength("bold2"),
},
&tg.MessageEntityBold{
Offset: 0,
Length: ComputeLength("bold"),
},
}},
{"BoldBold", func(e *Builder) {
e.Bold("bold\n\n\n\n").Bold("bold2\n\n\n\n")
}, "bold\n\n\n\nbold2", []tg.MessageEntityClass{
&tg.MessageEntityBold{
Offset: 0,
Length: ComputeLength("bold\n\n\n\n"),
},
&tg.MessageEntityBold{
Offset: ComputeLength("bold\n\n\n\n"),
Length: ComputeLength("bold2"),
},
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
a := require.New(t)
b := Builder{}
test.format(&b)
msg, entities := b.Complete()
a.Equal(test.msg, msg)
a.Equal(test.entities, entities)
})
}
}
@@ -0,0 +1,80 @@
package entity
import (
"strings"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
type utf8entity struct {
offset int
length int
}
// Builder builds message string and text entities.
type Builder struct {
entities []tg.MessageEntityClass
// lengths stores offset/length of entities too, but in UTF-8 codepoints.
lengths []utf8entity
// We store index of first entity added at last Format call.
// It needed to trim space in all entities of last text block.
lastFormatIndex int
// utf16length stores length in UTF-16 codepoints.
utf16length int
// message is message string builder.
message strings.Builder
}
// GrowText grows internal buffer capacity.
func (b *Builder) GrowText(n int) {
b.message.Grow(n)
}
// GrowEntities grows internal buffer capacity.
func (b *Builder) GrowEntities(n int) {
if n < 0 {
panic("entity.Builder.GrowEntities: negative count")
}
buf := make([]tg.MessageEntityClass, len(b.entities), 2*cap(b.entities)+n)
copy(buf, b.entities)
b.entities = buf
}
// Reset resets the Builder to be empty.
func (b *Builder) Reset() {
b.message.Reset()
b.entities = nil
b.utf16length = 0
}
// UTF8Len returns length of text in bytes.
func (b *Builder) UTF8Len() int {
return b.message.Len()
}
// UTF16Len returns length of text in UTF-16 codepoints.
func (b *Builder) UTF16Len() int {
return b.utf16length
}
// EntitiesLen return length of added entities.
func (b *Builder) EntitiesLen() int {
return len(b.entities)
}
// TextRange returns message text of given byte (UTF-8) range.
//
// If range is invalid, it will panic.
func (b *Builder) TextRange(from, to int) string {
return b.message.String()[from:to]
}
// LastEntity returns last entity if any.
func (b *Builder) LastEntity() (tg.MessageEntityClass, bool) {
l := b.EntitiesLen()
if l < 1 {
return nil, false
}
return b.entities[l-1], true
}
@@ -0,0 +1,71 @@
package entity
import (
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestBuilder_TextRange(t *testing.T) {
var (
a = require.New(t)
b Builder
)
_, _ = b.WriteString("abc")
a.Equal("abc"[1:2], b.TextRange(1, 2))
a.Equal("abc"[0:0], b.TextRange(0, 0))
panicRanges := [][2]int{
{1, 0},
{-1, 0},
{0, -1},
}
for _, r := range panicRanges {
a.Panics(func() {
b.TextRange(r[0], r[1])
})
}
}
func TestBuilder_LastEntity(t *testing.T) {
var (
a = require.New(t)
b Builder
)
e, ok := b.LastEntity()
a.False(ok)
a.Nil(e)
b.Underline("abc")
e, ok = b.LastEntity()
a.True(ok)
a.Equal(&tg.MessageEntityUnderline{
Offset: 0,
Length: 3,
}, e)
}
func TestBuilder_GrowText(t *testing.T) {
var (
a = require.New(t)
b Builder
)
b.GrowText(100)
a.LessOrEqual(100, b.message.Cap())
}
func TestBuilder_GrowEntities(t *testing.T) {
var (
a = require.New(t)
b Builder
)
b.GrowEntities(100)
a.Equal(100, cap(b.entities))
a.Panics(func() {
b.GrowEntities(-1)
})
}
@@ -0,0 +1,398 @@
// Code generated by mkentity, DO NOT EDIT.
package entity
import (
"context"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
var (
_ = tg.Invoker(nil)
_ = context.Context(nil)
)
// Unknown creates Formatter of Unknown message entity.
//
// See https://core.telegram.org/constructor/messageEntityUnknown.
func Unknown() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityUnknown{
Offset: offset,
Length: length,
}
}
}
// Unknown adds and formats message as Unknown message entity.
//
// See https://core.telegram.org/constructor/messageEntityUnknown.
func (b *Builder) Unknown(s string) *Builder {
return b.Format(s, Unknown())
}
// Mention creates Formatter of Mention message entity.
//
// See https://core.telegram.org/constructor/messageEntityMention.
func Mention() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityMention{
Offset: offset,
Length: length,
}
}
}
// Mention adds and formats message as Mention message entity.
//
// See https://core.telegram.org/constructor/messageEntityMention.
func (b *Builder) Mention(s string) *Builder {
return b.Format(s, Mention())
}
// Hashtag creates Formatter of Hashtag message entity.
//
// See https://core.telegram.org/constructor/messageEntityHashtag.
func Hashtag() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityHashtag{
Offset: offset,
Length: length,
}
}
}
// Hashtag adds and formats message as Hashtag message entity.
//
// See https://core.telegram.org/constructor/messageEntityHashtag.
func (b *Builder) Hashtag(s string) *Builder {
return b.Format(s, Hashtag())
}
// BotCommand creates Formatter of BotCommand message entity.
//
// See https://core.telegram.org/constructor/messageEntityBotCommand.
func BotCommand() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityBotCommand{
Offset: offset,
Length: length,
}
}
}
// BotCommand adds and formats message as BotCommand message entity.
//
// See https://core.telegram.org/constructor/messageEntityBotCommand.
func (b *Builder) BotCommand(s string) *Builder {
return b.Format(s, BotCommand())
}
// URL creates Formatter of URL message entity.
//
// See https://core.telegram.org/constructor/messageEntityUrl.
func URL() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityURL{
Offset: offset,
Length: length,
}
}
}
// URL adds and formats message as URL message entity.
//
// See https://core.telegram.org/constructor/messageEntityUrl.
func (b *Builder) URL(s string) *Builder {
return b.Format(s, URL())
}
// Email creates Formatter of Email message entity.
//
// See https://core.telegram.org/constructor/messageEntityEmail.
func Email() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityEmail{
Offset: offset,
Length: length,
}
}
}
// Email adds and formats message as Email message entity.
//
// See https://core.telegram.org/constructor/messageEntityEmail.
func (b *Builder) Email(s string) *Builder {
return b.Format(s, Email())
}
// Bold creates Formatter of Bold message entity.
//
// See https://core.telegram.org/constructor/messageEntityBold.
func Bold() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityBold{
Offset: offset,
Length: length,
}
}
}
// Bold adds and formats message as Bold message entity.
//
// See https://core.telegram.org/constructor/messageEntityBold.
func (b *Builder) Bold(s string) *Builder {
return b.Format(s, Bold())
}
// Italic creates Formatter of Italic message entity.
//
// See https://core.telegram.org/constructor/messageEntityItalic.
func Italic() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityItalic{
Offset: offset,
Length: length,
}
}
}
// Italic adds and formats message as Italic message entity.
//
// See https://core.telegram.org/constructor/messageEntityItalic.
func (b *Builder) Italic(s string) *Builder {
return b.Format(s, Italic())
}
// Code creates Formatter of Code message entity.
//
// See https://core.telegram.org/constructor/messageEntityCode.
func Code() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityCode{
Offset: offset,
Length: length,
}
}
}
// Code adds and formats message as Code message entity.
//
// See https://core.telegram.org/constructor/messageEntityCode.
func (b *Builder) Code(s string) *Builder {
return b.Format(s, Code())
}
// Pre creates Formatter of Pre message entity.
//
// See https://core.telegram.org/constructor/messageEntityPre.
func Pre(language string) Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityPre{
Offset: offset,
Length: length,
Language: language,
}
}
}
// Pre adds and formats message as Pre message entity.
//
// See https://core.telegram.org/constructor/messageEntityPre.
func (b *Builder) Pre(s string, language string) *Builder {
return b.Format(s, Pre(language))
}
// TextURL creates Formatter of TextURL message entity.
//
// See https://core.telegram.org/constructor/messageEntityTextUrl.
func TextURL(uRL string) Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityTextURL{
Offset: offset,
Length: length,
URL: uRL,
}
}
}
// TextURL adds and formats message as TextURL message entity.
//
// See https://core.telegram.org/constructor/messageEntityTextUrl.
func (b *Builder) TextURL(s string, uRL string) *Builder {
return b.Format(s, TextURL(uRL))
}
// MentionName creates Formatter of MentionName message entity.
//
// See https://core.telegram.org/constructor/inputMessageEntityMentionName.
func MentionName(userID tg.InputUserClass) Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.InputMessageEntityMentionName{
Offset: offset,
Length: length,
UserID: userID,
}
}
}
// MentionName adds and formats message as MentionName message entity.
//
// See https://core.telegram.org/constructor/inputMessageEntityMentionName.
func (b *Builder) MentionName(s string, userID tg.InputUserClass) *Builder {
return b.Format(s, MentionName(userID))
}
// Phone creates Formatter of Phone message entity.
//
// See https://core.telegram.org/constructor/messageEntityPhone.
func Phone() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityPhone{
Offset: offset,
Length: length,
}
}
}
// Phone adds and formats message as Phone message entity.
//
// See https://core.telegram.org/constructor/messageEntityPhone.
func (b *Builder) Phone(s string) *Builder {
return b.Format(s, Phone())
}
// Cashtag creates Formatter of Cashtag message entity.
//
// See https://core.telegram.org/constructor/messageEntityCashtag.
func Cashtag() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityCashtag{
Offset: offset,
Length: length,
}
}
}
// Cashtag adds and formats message as Cashtag message entity.
//
// See https://core.telegram.org/constructor/messageEntityCashtag.
func (b *Builder) Cashtag(s string) *Builder {
return b.Format(s, Cashtag())
}
// Underline creates Formatter of Underline message entity.
//
// See https://core.telegram.org/constructor/messageEntityUnderline.
func Underline() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityUnderline{
Offset: offset,
Length: length,
}
}
}
// Underline adds and formats message as Underline message entity.
//
// See https://core.telegram.org/constructor/messageEntityUnderline.
func (b *Builder) Underline(s string) *Builder {
return b.Format(s, Underline())
}
// Strike creates Formatter of Strike message entity.
//
// See https://core.telegram.org/constructor/messageEntityStrike.
func Strike() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityStrike{
Offset: offset,
Length: length,
}
}
}
// Strike adds and formats message as Strike message entity.
//
// See https://core.telegram.org/constructor/messageEntityStrike.
func (b *Builder) Strike(s string) *Builder {
return b.Format(s, Strike())
}
// BankCard creates Formatter of BankCard message entity.
//
// See https://core.telegram.org/constructor/messageEntityBankCard.
func BankCard() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityBankCard{
Offset: offset,
Length: length,
}
}
}
// BankCard adds and formats message as BankCard message entity.
//
// See https://core.telegram.org/constructor/messageEntityBankCard.
func (b *Builder) BankCard(s string) *Builder {
return b.Format(s, BankCard())
}
// Spoiler creates Formatter of Spoiler message entity.
//
// See https://core.telegram.org/constructor/messageEntitySpoiler.
func Spoiler() Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntitySpoiler{
Offset: offset,
Length: length,
}
}
}
// Spoiler adds and formats message as Spoiler message entity.
//
// See https://core.telegram.org/constructor/messageEntitySpoiler.
func (b *Builder) Spoiler(s string) *Builder {
return b.Format(s, Spoiler())
}
// CustomEmoji creates Formatter of CustomEmoji message entity.
//
// See https://core.telegram.org/constructor/messageEntityCustomEmoji.
func CustomEmoji(documentID int64) Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityCustomEmoji{
Offset: offset,
Length: length,
DocumentID: documentID,
}
}
}
// CustomEmoji adds and formats message as CustomEmoji message entity.
//
// See https://core.telegram.org/constructor/messageEntityCustomEmoji.
func (b *Builder) CustomEmoji(s string, documentID int64) *Builder {
return b.Format(s, CustomEmoji(documentID))
}
// Blockquote creates Formatter of Blockquote message entity.
//
// See https://core.telegram.org/constructor/messageEntityBlockquote.
func Blockquote(collapsed bool) Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.MessageEntityBlockquote{
Offset: offset,
Length: length,
Collapsed: collapsed,
}
}
}
// Blockquote adds and formats message as Blockquote message entity.
//
// See https://core.telegram.org/constructor/messageEntityBlockquote.
func (b *Builder) Blockquote(s string, collapsed bool) *Builder {
return b.Format(s, Blockquote(collapsed))
}
@@ -0,0 +1,20 @@
package entity
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
// Formatter is a message entity constructor.
type Formatter func(offset, limit int) tg.MessageEntityClass
// Plain formats message as plain text.
func (b *Builder) Plain(s string) *Builder {
_, _ = b.WriteString(s)
b.lastFormatIndex = len(b.entities)
return b
}
// Format formats message using given formatters.
func (b *Builder) Format(s string, formats ...Formatter) *Builder {
return b.appendMessage(s, formats...)
}
//go:generate go run go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/internal/mkentity -output options.gen.go
@@ -0,0 +1,186 @@
package entity
import (
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestBuilder(t *testing.T) {
b := Builder{}
t.Run("Plain", func(t *testing.T) {
_, ent := b.Plain("abc").Complete()
require.Empty(t, ent)
})
t.Run("EmptyString", func(t *testing.T) {
msg, ent := b.Bold("").Complete()
require.Empty(t, msg)
require.Empty(t, ent)
})
t.Run("Format", func(t *testing.T) {
_, ent := b.Format("abc", Bold(), Italic()).Complete()
require.Equal(t, []tg.MessageEntityClass{
&tg.MessageEntityBold{
Offset: 0,
Length: len("abc"),
},
&tg.MessageEntityItalic{
Offset: 0,
Length: len("abc"),
},
}, ent)
})
t.Run("Unknown", func(t *testing.T) {
_, ent := b.Unknown("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityUnknown{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("Mention", func(t *testing.T) {
_, ent := b.Mention("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityMention{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("Hashtag", func(t *testing.T) {
_, ent := b.Hashtag("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityHashtag{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("BotCommand", func(t *testing.T) {
_, ent := b.BotCommand("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityBotCommand{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("URL", func(t *testing.T) {
_, ent := b.URL("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityURL{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("Email", func(t *testing.T) {
_, ent := b.Email("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityEmail{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("Bold", func(t *testing.T) {
_, ent := b.Bold("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityBold{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("Italic", func(t *testing.T) {
_, ent := b.Italic("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityItalic{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("Code", func(t *testing.T) {
_, ent := b.Code("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityCode{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("Pre", func(t *testing.T) {
_, ent := b.Pre("abc", "lang").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityPre{
Offset: 0,
Length: len("abc"),
Language: "lang",
}, r)
})
t.Run("TextURL", func(t *testing.T) {
_, ent := b.TextURL("abc", "url").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityTextURL{
Offset: 0,
Length: len("abc"),
URL: "url",
}, r)
})
t.Run("MentionName", func(t *testing.T) {
user := &tg.InputUser{
UserID: 10,
AccessHash: 10,
}
_, ent := b.MentionName("abc", user).Complete()
r := ent[0]
require.Equal(t, &tg.InputMessageEntityMentionName{
Offset: 0,
Length: len("abc"),
UserID: user,
}, r)
})
t.Run("Phone", func(t *testing.T) {
_, ent := b.Phone("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityPhone{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("Cashtag", func(t *testing.T) {
_, ent := b.Cashtag("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityCashtag{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("Underline", func(t *testing.T) {
_, ent := b.Underline("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityUnderline{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("Strike", func(t *testing.T) {
_, ent := b.Strike("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityStrike{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("Blockquote", func(t *testing.T) {
_, ent := b.Blockquote("abc", false).Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityBlockquote{
Offset: 0,
Length: len("abc"),
}, r)
})
t.Run("BankCard", func(t *testing.T) {
_, ent := b.BankCard("abc").Complete()
r := ent[0]
require.Equal(t, &tg.MessageEntityBankCard{
Offset: 0,
Length: len("abc"),
}, r)
})
}
+48
View File
@@ -0,0 +1,48 @@
package entity
// Token represents raw point in a message string.
type Token struct {
utf8offset int
utf16offset int
}
// UTF8Offset return UTF-8 offset.
func (t Token) UTF8Offset() int {
return t.utf8offset
}
// UTF16Offset returns UTF-16 offset.
func (t Token) UTF16Offset() int {
return t.utf16offset
}
// UTF8Length return UTF-8 length between token start and current state.
func (t Token) UTF8Length(builder *Builder) int {
return builder.UTF8Len() - t.utf8offset
}
// UTF16Length returns UTF-16 length between token start and current state.
func (t Token) UTF16Length(builder *Builder) int {
return builder.UTF16Len() - t.utf16offset
}
// Text message string between token start and current state.
func (t Token) Text(builder *Builder) string {
return builder.TextRange(t.utf8offset, builder.UTF8Len())
}
// Apply formats range between token start and current state using given Formatter slice.
func (t Token) Apply(builder *Builder, f ...Formatter) {
builder.appendEntities(t.utf16offset, t.UTF16Length(builder), utf8entity{
offset: t.utf8offset,
length: t.UTF8Length(builder),
}, f...)
}
// Token creates new Token.
func (b *Builder) Token() Token {
return Token{
utf8offset: b.UTF8Len(),
utf16offset: b.UTF16Len(),
}
}
@@ -0,0 +1,38 @@
package entity
import (
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestToken_Apply(t *testing.T) {
var (
a = require.New(t)
b = &Builder{}
)
_ = b.WriteByte('a')
tok := b.Token()
a.Equal(1, tok.UTF8Offset())
a.Equal(1, tok.UTF16Offset())
a.Zero(tok.UTF8Length(b))
a.Zero(tok.UTF16Length(b))
a.Empty(tok.Text(b))
text := "abc🏳"
_, _ = b.WriteString(text)
a.Equal(text, tok.Text(b))
utf16Len := ComputeLength(tok.Text(b))
a.Equal(b.message.Len()-tok.UTF8Offset(), tok.UTF8Length(b))
a.Equal(utf16Len, tok.UTF16Length(b))
tok.Apply(b, Bold())
a.Equal(1, b.EntitiesLen())
e, ok := b.LastEntity()
a.True(ok)
a.Equal(&tg.MessageEntityBold{Offset: 1, Length: utf16Len}, e)
}
@@ -0,0 +1,6 @@
package entity
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
// UserResolver is callback for resolving InputUser by ID.
type UserResolver = func(id int64) (tg.InputUserClass, error)
+108
View File
@@ -0,0 +1,108 @@
package entity
import (
"io"
"unicode/utf8"
)
// ComputeLength returns length of s encoded as UTF-16 string.
//
// While Telegram API docs state that they expect the number of UTF-8
// code points, in fact they are talking about UTF-16 code units.
func ComputeLength(s string) int {
// From utf16 package.
n := 0
for _, v := range s {
n += utf16RuneLen(v)
}
return n
}
// ComputeLengthBytes returns length of s encoded as UTF-16 string.
//
// While Telegram API docs state that they expect the number of UTF-8
// code points, in fact they are talking about UTF-16 code units.
func ComputeLengthBytes(s []byte) (n int) {
// From utf16 package.
var i int
for i < len(s) {
v, size := utf8.DecodeRune(s[i:])
i += size
n += utf16RuneLen(v)
}
return n
}
func utf16RuneLen(v rune) int {
const (
surrSelf = 0x10000
maxRune = '\U0010FFFF' // Maximum valid Unicode code point.
)
if surrSelf <= v && v <= maxRune {
return 2
}
return 1
}
func (b *Builder) appendMessage(s string, formats ...Formatter) *Builder {
if s == "" {
return b
}
offset := b.utf16length
length := ComputeLength(s)
b.appendEntities(offset, length, utf8entity{
offset: b.message.Len(),
length: len(s),
}, formats...)
_, _ = b.WriteString(s)
return b
}
func (b *Builder) appendEntities(offset, length int, u utf8entity, formats ...Formatter) *Builder {
b.lastFormatIndex = len(b.entities)
for i := range formats {
b.entities = append(b.entities, formats[i](offset, length))
b.lengths = append(b.lengths, u)
}
return b
}
var _ = []interface {
io.Writer
io.StringWriter
io.ByteWriter
WriteRune(rune) (int, error)
}{
(*Builder)(nil),
}
// Write implements io.Writer.
func (b *Builder) Write(s []byte) (int, error) {
n, err := b.message.Write(s)
b.utf16length += ComputeLengthBytes(s)
return n, err
}
// WriteString implements io.StringWriter.
func (b *Builder) WriteString(s string) (int, error) {
n, err := b.message.WriteString(s)
b.utf16length += ComputeLength(s)
return n, err
}
// WriteByte implements io.ByteWriter.
func (b *Builder) WriteByte(s byte) error {
err := b.message.WriteByte(s)
b.utf16length++
return err
}
// WriteRune implements rune writer.
func (b *Builder) WriteRune(s rune) (int, error) {
n, err := b.message.WriteRune(s)
b.utf16length += utf16RuneLen(s)
return n, err
}
@@ -0,0 +1,55 @@
package entity
import (
"encoding/hex"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/testutil"
)
func TestComputeLength(t *testing.T) {
tests := []struct {
s string
want int
}{
{string([]rune{97, 127987, 65039, 8205, 127752}), 7},
{string([]int32{97, 127987, 65039, 8205, 127752, 127987, 65039, 8205, 127752}), 13},
{string([]int32{97, 128104, 8205, 128102, 8205, 128102}), 9},
}
for _, tt := range tests {
r := []byte(tt.s)
testutil.ZeroAlloc(t, func() {
_ = ComputeLength(tt.s)
})
testutil.ZeroAlloc(t, func() {
_ = ComputeLengthBytes(r)
})
t.Run(hex.EncodeToString([]byte(tt.s)), func(t *testing.T) {
require.Equal(t, tt.want, ComputeLength(tt.s))
require.Equal(t, tt.want, ComputeLengthBytes(r))
})
}
}
func TestBuilder_Write(t *testing.T) {
var (
a = require.New(t)
b Builder
)
_, err := b.Write([]byte("abc"))
a.NoError(err)
_, err = b.WriteString("abc")
a.NoError(err)
a.NoError(b.WriteByte('\n'))
a.Equal(3+3+1, b.UTF8Len())
a.Equal(3+3+1, b.UTF16Len())
var r rune = 127987
_, err = b.WriteRune(r)
a.NoError(err)
a.Equal(3+3+1+utf8.RuneLen(r), b.UTF8Len())
a.Equal(3+3+1+utf16RuneLen(r), b.UTF16Len())
}
+21
View File
@@ -0,0 +1,21 @@
package message
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
// FileLocation is an abstraction of Telegram file location.
type FileLocation interface {
GetID() (value int64)
GetAccessHash() (value int64)
GetFileReference() (value []byte)
}
func inputDocuments(files ...FileLocation) (r []tg.InputDocumentClass) {
r = make([]tg.InputDocumentClass, len(files))
for i := range files {
v := new(tg.InputDocument)
v.FillFrom(files[i])
r[i] = v
}
return
}
+123
View File
@@ -0,0 +1,123 @@
package message
import (
"context"
"io"
"io/fs"
"go.uber.org/atomic"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/uploader"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/uploader/source"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Uploader is an abstraction for Telegram file uploader.
type Uploader interface {
FromFile(ctx context.Context, f uploader.File, name string) (tg.InputFileClass, error)
FromPath(ctx context.Context, path, name string) (tg.InputFileClass, error)
FromFS(ctx context.Context, filesystem fs.FS, path, name string) (tg.InputFileClass, error)
FromReader(ctx context.Context, name string, f io.Reader) (tg.InputFileClass, error)
FromBytes(ctx context.Context, name string, b []byte) (tg.InputFileClass, error)
FromURL(ctx context.Context, rawURL string) (tg.InputFileClass, error)
FromSource(ctx context.Context, src source.Source, rawURL string) (tg.InputFileClass, error)
}
type uploadBuilder struct {
upload Uploader
}
// UploadOption is a UploadBuilder creation option.
type UploadOption interface {
apply(ctx context.Context, b uploadBuilder) (tg.InputFileClass, error)
}
// uploadOptionFunc is a functional adapter for UploadOption.
type uploadOptionFunc func(ctx context.Context, b uploadBuilder) (tg.InputFileClass, error)
func (f uploadOptionFunc) apply(ctx context.Context, b uploadBuilder) (tg.InputFileClass, error) {
return f(ctx, b)
}
type fileCache atomic.Value
func (r *fileCache) Store(result tg.InputFileClass) {
r.Value.Store(result)
}
func (r *fileCache) Load() (result tg.InputFileClass, ok bool) {
result, ok = r.Value.Load().(tg.InputFileClass)
return
}
// FilePromise is a upload file promise.
type FilePromise = func(ctx context.Context, b Uploader) (tg.InputFileClass, error)
// Upload creates new upload options using given promise.
func Upload(promise FilePromise) UploadOption {
once := &fileCache{}
return uploadOptionFunc(func(ctx context.Context, b uploadBuilder) (r tg.InputFileClass, err error) {
if v, ok := once.Load(); ok {
return v, nil
}
defer func() {
if err == nil && r != nil {
once.Store(r)
}
}()
return promise(ctx, b.upload)
})
}
// FromFile uploads given File.
// NB: FromFile does not close given file.
func FromFile(f uploader.File) UploadOption {
return Upload(func(ctx context.Context, b Uploader) (tg.InputFileClass, error) {
return b.FromFile(ctx, f, "")
})
}
// FromPath uploads file from given path.
func FromPath(path, name string) UploadOption {
return Upload(func(ctx context.Context, b Uploader) (tg.InputFileClass, error) {
return b.FromPath(ctx, path, name)
})
}
// FromFS uploads file from given path using given fs.FS.
func FromFS(filesystem fs.FS, path, name string) UploadOption {
return Upload(func(ctx context.Context, b Uploader) (tg.InputFileClass, error) {
return b.FromFS(ctx, filesystem, path, name)
})
}
// FromReader uploads file from given io.Reader.
// NB: totally stream should not exceed the limit for
// small files (10 MB as docs says, may be a bit bigger).
func FromReader(name string, r io.Reader) UploadOption {
return Upload(func(ctx context.Context, b Uploader) (tg.InputFileClass, error) {
return b.FromReader(ctx, name, r)
})
}
// FromBytes uploads file from given byte slice.
func FromBytes(name string, data []byte) UploadOption {
return Upload(func(ctx context.Context, b Uploader) (tg.InputFileClass, error) {
return b.FromBytes(ctx, name, data)
})
}
// FromURL uploads file from given URL.
func FromURL(rawURL string) UploadOption {
return Upload(func(ctx context.Context, b Uploader) (tg.InputFileClass, error) {
return b.FromURL(ctx, rawURL)
})
}
// FromSource uploads file from given URL using given Source.
func FromSource(src source.Source, rawURL string) UploadOption {
return Upload(func(ctx context.Context, b Uploader) (tg.InputFileClass, error) {
return b.FromSource(ctx, src, rawURL)
})
}
@@ -0,0 +1,57 @@
package message_test
import (
"context"
"fmt"
"os"
"os/signal"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func filePromiseResult(ctx context.Context) error {
client, err := telegram.ClientFromEnvironment(telegram.Options{})
if err != nil {
return err
}
return client.Run(ctx, func(ctx context.Context) error {
sender := message.NewSender(tg.NewClient(client))
r := sender.Resolve("@durov")
var result tg.InputFileClass
_, err := r.Upload(message.Upload(func(ctx context.Context, b message.Uploader) (tg.InputFileClass, error) {
r, err := b.FromPath(ctx, "file.jpg", "")
if err != nil {
return nil, err
}
result = r
return r, nil
})).Photo(ctx)
if err != nil {
return errors.Wrap(err, "upload photo")
}
_, err = r.Media(ctx, message.UploadedDocument(result))
if err != nil {
return errors.Wrap(err, "upload document")
}
return nil
})
}
func ExampleUpload() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := filePromiseResult(ctx); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%+v\n", err)
os.Exit(2)
}
}
+7
View File
@@ -0,0 +1,7 @@
package message
import "fmt"
func formatMessage(msg string, args ...interface{}) string {
return fmt.Sprintf(msg, args...)
}
+70
View File
@@ -0,0 +1,70 @@
package message
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// ForwardBuilder is a forward request builder.
type ForwardBuilder struct {
builder *Builder
from tg.InputPeerClass
ids []int
withMyScore bool
}
// WithMyScore sets flag to include your score in the forwarded game.
func (b *ForwardBuilder) WithMyScore() *ForwardBuilder {
b.withMyScore = true
return b
}
// Send sends forwarded messages.
func (b *ForwardBuilder) Send(ctx context.Context) (tg.UpdatesClass, error) {
p, err := b.builder.peer(ctx)
if err != nil {
return nil, errors.Wrap(err, "peer")
}
upd, err := b.builder.sender.forwardMessages(ctx, &tg.MessagesForwardMessagesRequest{
Silent: b.builder.silent,
Background: b.builder.background,
WithMyScore: b.withMyScore,
FromPeer: b.from,
ID: b.ids,
ToPeer: p,
ScheduleDate: b.builder.scheduleDate,
})
if err != nil {
return nil, errors.Wrap(err, "send inline bot result")
}
return upd, nil
}
// ForwardIDs creates builder to forward messages by ID.
func (b *Builder) ForwardIDs(from tg.InputPeerClass, id int, ids ...int) *ForwardBuilder {
return &ForwardBuilder{
builder: b,
from: from,
ids: append([]int{id}, ids...),
}
}
// ForwardMessages creates builder to forward messages.
func (b *Builder) ForwardMessages(from tg.InputPeerClass, msg tg.MessageClass, m ...tg.MessageClass) *ForwardBuilder {
r := make([]int, 1+len(m))
r[0] = msg.GetID()
for i := range m {
r[i+1] = m[i].GetID()
}
return &ForwardBuilder{
builder: b,
from: from,
ids: r,
}
}
+40
View File
@@ -0,0 +1,40 @@
package message
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestBuilder_ForwardIDs(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
mock.ExpectFunc(func(b bin.Encoder) {
req, ok := b.(*tg.MessagesForwardMessagesRequest)
require.True(t, ok)
require.Equal(t, &tg.InputPeerSelf{}, req.ToPeer)
require.Equal(t, &tg.InputPeerSelf{}, req.FromPeer)
require.Len(t, req.ID, 1)
require.Equal(t, 10, req.ID[0])
require.True(t, req.WithMyScore)
}).ThenResult(&tg.Updates{})
_, err := sender.Self().ForwardIDs(&tg.InputPeerSelf{}, 10).WithMyScore().Send(ctx)
require.NoError(t, err)
mock.ExpectFunc(func(b bin.Encoder) {
req, ok := b.(*tg.MessagesForwardMessagesRequest)
require.True(t, ok)
require.Equal(t, &tg.InputPeerSelf{}, req.ToPeer)
require.Equal(t, &tg.InputPeerSelf{}, req.FromPeer)
require.Len(t, req.ID, 1)
require.Equal(t, 10, req.ID[0])
require.True(t, req.WithMyScore)
}).ThenRPCErr(testRPCError())
_, err = sender.Self().ForwardIDs(&tg.InputPeerSelf{}, 10).WithMyScore().Send(ctx)
require.Error(t, err)
}
+10
View File
@@ -0,0 +1,10 @@
package message
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
// Game adds a game attachment.
func Game(id tg.InputGameClass, caption ...StyledTextOption) MediaOption {
return Media(&tg.InputMediaGame{
ID: id,
}, caption...)
}
+24
View File
@@ -0,0 +1,24 @@
package message
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestGame(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
game := &tg.InputGameID{
ID: 10,
}
expectSendMedia(t, &tg.InputMediaGame{
ID: game,
}, mock)
_, err := sender.Self().Media(ctx, Game(game))
require.NoError(t, err)
}
+15
View File
@@ -0,0 +1,15 @@
package message
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
// GeoPoint adds geo point attachment.
// NB: parameter accuracy may be zero and will not be used.
func GeoPoint(lat, long float64, accuracy int, caption ...StyledTextOption) MediaOption {
return Media(&tg.InputMediaGeoPoint{
GeoPoint: &tg.InputGeoPoint{
Lat: lat,
Long: long,
AccuracyRadius: accuracy,
},
}, caption...)
}
@@ -0,0 +1,27 @@
package message
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestGeoPoint(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
point := tg.InputGeoPoint{
Lat: 11,
Long: 12,
AccuracyRadius: 10,
}
expectSendMedia(t, &tg.InputMediaGeoPoint{
GeoPoint: &point,
}, mock)
_, err := sender.Self().Media(ctx, GeoPoint(11, 12, 10))
require.NoError(t, err)
}
+26
View File
@@ -0,0 +1,26 @@
package message
import (
"context"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// GIF add attributes to create GIF attachment.
func (u *UploadedDocumentBuilder) GIF() *UploadedDocumentBuilder {
return u.Attributes(&tg.DocumentAttributeAnimated{}).
MIME(DefaultGifMIME)
}
// GIF adds gif attachment.
func GIF(file tg.InputFileClass, caption ...StyledTextOption) *UploadedDocumentBuilder {
return UploadedDocument(file, caption...).GIF()
}
// GIF sends gif.
func (b *Builder) GIF(
ctx context.Context,
file tg.InputFileClass, caption ...StyledTextOption,
) (tg.UpdatesClass, error) {
return b.Media(ctx, GIF(file, caption...))
}
@@ -0,0 +1,42 @@
package message_test
import (
"context"
"fmt"
"os"
"os/signal"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func sendGif(ctx context.Context) error {
client, err := telegram.ClientFromEnvironment(telegram.Options{})
if err != nil {
return err
}
return client.Run(ctx, func(ctx context.Context) error {
sender := message.NewSender(tg.NewClient(client))
// Uploads and sends gif to the @durovschat.
if _, err := sender.Resolve("https://t.me/durovschat").
Upload(message.FromPath("./rickroll.gif", "")).
GIF(ctx); err != nil {
return err
}
return nil
})
}
func ExampleGIF() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := sendGif(ctx); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%+v\n", err)
os.Exit(2)
}
}
+40
View File
@@ -0,0 +1,40 @@
package message
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestGIF(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
file := &tg.InputFile{
ID: 10,
}
expectSendMedia(t, &tg.InputMediaUploadedDocument{
File: file,
MimeType: DefaultGifMIME,
Attributes: []tg.DocumentAttributeClass{
&tg.DocumentAttributeAnimated{},
},
}, mock)
expectSendMedia(t, &tg.InputMediaUploadedDocument{
File: file,
MimeType: DefaultGifMIME,
Attributes: []tg.DocumentAttributeClass{
&tg.DocumentAttributeAnimated{},
},
TTLSeconds: 10,
}, mock)
_, err := sender.Self().GIF(ctx, file)
require.NoError(t, err)
_, err = sender.Self().Media(ctx, GIF(file).TTL(10*time.Second))
require.NoError(t, err)
}
+50
View File
@@ -0,0 +1,50 @@
// Package html contains HTML styling options.
package html
import (
"bytes"
"fmt"
"io"
"strings"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/styling"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Bytes reads HTML from given byte slice and returns styling option
// to build styled text block.
func Bytes(resolver func(id int64) (tg.InputUserClass, error), b []byte) styling.StyledTextOption {
return Reader(resolver, bytes.NewReader(b))
}
// String reads HTML from given string and returns styling option
// to build styled text block.
func String(resolver func(id int64) (tg.InputUserClass, error), s string) styling.StyledTextOption {
return Reader(resolver, strings.NewReader(s))
}
// Format formats string using fmt, parses HTML from formatted string and returns styling option
// to build styled text block.
func Format(resolver func(id int64) (tg.InputUserClass, error), format string, args ...interface{}) styling.StyledTextOption {
return styling.Custom(func(eb *entity.Builder) error {
var buf bytes.Buffer
_, err := fmt.Fprintf(&buf, format, args...)
if err != nil {
return err
}
return HTML(&buf, eb, Options{
UserResolver: resolver,
})
})
}
// Reader reads HTML from given reader and returns styling option
// to build styled text block.
func Reader(resolver func(id int64) (tg.InputUserClass, error), r io.Reader) styling.StyledTextOption {
return styling.Custom(func(eb *entity.Builder) error {
return HTML(r, eb, Options{
UserResolver: resolver,
})
})
}
@@ -0,0 +1,52 @@
package html_test
import (
"context"
"fmt"
"os"
"os/signal"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/html"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func sendHTML(ctx context.Context) error {
client, err := telegram.ClientFromEnvironment(telegram.Options{})
if err != nil {
return err
}
// This example creates a styled message from BotAPI examples
// and sends to your Saved Messages folder.
// See https://core.telegram.org/bots/api#html-style.
return client.Run(ctx, func(ctx context.Context) error {
_, err := message.NewSender(tg.NewClient(client)).
Self().StyledText(ctx, html.String(nil, `<b>bold</b>, <strong>bold</strong>
<i>italic</i>, <em>italic</em>
<u>underline</u>, <ins>underline</ins>
<s>strikethrough</s>, <strike>strikethrough</strike>, <del>strikethrough</del>
<span class="tg-spoiler">spoiler</span>, <tg-spoiler>spoiler</tg-spoiler>
<b>bold <i>italic bold <s>italic bold strikethrough <span class="tg-spoiler">italic bold strikethrough spoiler</span></s> <u>underline italic bold</u></i> bold</b>
<a href="http://www.example.com/">inline URL</a>
<a href="tg://user?id=123456789">inline mention of a user</a>
<tg-emoji emoji-id="5368324170671202286">👍</tg-emoji>
<code>inline fixed-width code</code>
<pre>pre-formatted fixed-width code block</pre>
<pre><code class="language-python">pre-formatted fixed-width code block written in the Python programming language</code></pre>
<blockquote>Block quotation started\nBlock quotation continued\nThe last line of the block quotation</blockquote>
<blockquote expandable>Expandable block quotation started\nExpandable block quotation continued\nExpandable block quotation continued\nHidden by default part of the block quotation started\nExpandable block quotation continued\nThe last line of the block quotation</blockquote>`))
return err
})
}
func ExampleString() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := sendHTML(ctx); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%+v\n", err)
os.Exit(2)
}
}
+28
View File
@@ -0,0 +1,28 @@
package html
import (
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Options is options of HTML.
type Options struct {
// UserResolver is used to resolve user by ID during formatting. May be nil.
//
// If userResolver is nil, formatter will create tg.InputUser using only ID.
// Notice that it's okay for bots, but not for users.
UserResolver entity.UserResolver
// DisableTelegramEscape disable Telegram BotAPI escaping and uses default
// golang.org/x/net/html escape.
DisableTelegramEscape bool
}
func (o *Options) setDefaults() {
if o.UserResolver == nil {
o.UserResolver = func(id int64) (tg.InputUserClass, error) {
return &tg.InputUser{
UserID: id,
}, nil
}
}
}
+282
View File
@@ -0,0 +1,282 @@
package html
import (
"io"
"strconv"
"strings"
"github.com/go-faster/errors"
"golang.org/x/net/html"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
type htmlParser struct {
tokenizer *html.Tokenizer
builder *entity.Builder
stack stack
attr map[string]string
opts Options
}
func (p *htmlParser) fillAttrs() {
// Clear old attrs.
for k := range p.attr {
delete(p.attr, k)
}
// Fill with new attributes.
for {
key, value, ok := p.tokenizer.TagAttr()
p.attr[string(key)] = string(value)
if !ok {
break
}
}
}
const (
pre = "pre"
code = "code"
em = "em"
ins = "ins"
strike = "strike"
del = "del"
strong = "strong"
span = "span"
tgSpoiler = "tg-spoiler"
tgEmoji = "tg-emoji"
blockquote = "blockquote"
)
func (p *htmlParser) tag(tn []byte) string {
// Here we intern some well-known tags.
switch string(tn) {
case "b":
return "b"
case strong:
return strong
case "i":
return "i"
case em:
return em
case "u":
return "u"
case ins:
return ins
case "s":
return "s"
case strike:
return strike
case del:
return del
case "a":
return "a"
case pre:
return pre
case code:
return code
case span:
return span
case tgSpoiler:
return tgSpoiler
case tgEmoji:
return tgEmoji
case blockquote:
return blockquote
default:
return string(tn)
}
}
func (p *htmlParser) startTag() error {
var e stackElem
tn, hasAttr := p.tokenizer.TagName()
e.tag = p.tag(tn)
if hasAttr {
p.fillAttrs()
}
e.token = p.builder.Token()
// See https://core.telegram.org/bots/api#html-style.
switch e.tag {
case "b", strong:
e.format = entity.Bold()
case "i", em:
e.format = entity.Italic()
case "u", ins:
e.format = entity.Underline()
case "s", strike, del:
e.format = entity.Strike()
case "a":
e.attr = p.attr["href"]
if e.attr == "" {
break
}
f, err := getURLFormatter(e.attr, p.opts.UserResolver)
if err != nil {
f = nil
}
e.format = f
case code:
const langPrefix = "language-"
e.format = entity.Code()
e.attr = strings.TrimPrefix(p.attr["class"], langPrefix)
if len(p.stack) < 1 {
break
}
// BotAPI docs says:
// > Use nested <pre> and <code> tags, to define programming language for <pre> entity.
last := &p.stack[len(p.stack)-1]
if last.tag != pre {
break
}
if lang := e.attr; lang != "" {
// Set language parameter.
last.format = entity.Pre(lang)
}
case pre:
e.format = entity.Pre("")
if len(p.stack) < 1 {
break
}
last := &p.stack[len(p.stack)-1]
if last.tag != code {
break
}
if lang := last.attr; lang != "" {
// Set language parameter.
e.format = entity.Pre(lang)
}
case span:
if p.attr["class"] == "tg-spoiler" {
e.format = entity.Spoiler()
}
case tgSpoiler:
e.format = entity.Spoiler()
case tgEmoji:
if id, err := strconv.ParseInt(p.attr["emoji-id"], 10, 64); err == nil {
e.format = entity.CustomEmoji(id)
}
case blockquote:
_, collapsed := p.attr["expandable"]
e.format = entity.Blockquote(collapsed)
}
p.stack.push(e)
return nil
}
func (p *htmlParser) endTag(checkName bool) error {
tn, _ := p.tokenizer.TagName()
s, ok := p.stack.pop()
switch {
case !ok:
return errors.Errorf("unexpected end tag %q", tn)
case checkName && s.tag != string(tn):
return errors.Errorf("expected tag %q, got %q", s.tag, tn)
}
// Compute UTF-16 length of entity.
length := s.token.UTF16Length(p.builder)
switch s.tag {
case "a":
// TDLib tries to parse link from <a> body, so we should too.
if s.attr == "" {
msg := s.token.Text(p.builder)
if f, err := getURLFormatter(msg, p.opts.UserResolver); err == nil {
s.format = f
}
}
case "code":
l, ok := p.builder.LastEntity()
if !ok {
break
}
last, ok := l.(*tg.MessageEntityPre)
if !ok {
break
}
// Do not add Code entity, if last entity is Pre with same offset.
if last.GetOffset() == s.token.UTF16Offset() && last.GetLength() == length {
return nil
}
}
// Do not add empty entities.
if length == 0 || s.format == nil {
return nil
}
s.token.Apply(p.builder, s.format)
return nil
}
func (p *htmlParser) parse() error {
for {
tt := p.tokenizer.Next()
switch tt {
case html.ErrorToken:
if err := p.tokenizer.Err(); !errors.Is(err, io.EOF) {
return err
}
return nil
case html.TextToken:
var text []byte
if p.opts.DisableTelegramEscape {
text = p.tokenizer.Text()
} else {
text = telegramUnescape(p.tokenizer.Raw())
}
_, _ = p.builder.Write(text)
case html.StartTagToken:
if err := p.startTag(); err != nil {
return err
}
case html.EndTagToken:
if err := p.endTag(true); err != nil {
return err
}
case html.CommentToken:
// html.Tokenizer returns comment token for empty closing tags.
raw := p.tokenizer.Raw()
if len(raw) >= 3 && string(raw[:2]) == "</" && raw[len(raw)-1] == '>' {
if err := p.endTag(false); err != nil {
return err
}
}
}
}
}
// HTML parses given input from reader and adds parsed entities to given builder.
// Notice that this parser ignores unsupported tags.
//
// Parameter userResolver is used to resolve user by ID during formatting. May be nil.
// If userResolver is nil, formatter will create tg.InputUser using only ID.
// Notice that it's okay for bots, but not for users.
//
// See https://core.telegram.org/bots/api#html-style.
func HTML(r io.Reader, b *entity.Builder, opts Options) error {
opts.setDefaults()
p := htmlParser{
tokenizer: html.NewTokenizer(r),
builder: b,
attr: map[string]string{},
opts: opts,
}
if err := p.parse(); err != nil {
return errors.Wrap(err, "parse")
}
b.ShrinkPreCode()
return nil
}
@@ -0,0 +1,34 @@
package html
import (
"strings"
"testing"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
)
func BenchmarkHTML(b *testing.B) {
input := `<b>bold</b>, <strong>bold</strong>
<i>italic</i>, <em>italic</em>
<u>underline</u>, <ins>underline</ins>
<s>strikethrough</s>, <strike>strikethrough</strike>, <del>strikethrough</del>
<b>bold <i>italic bold <s>italic bold strikethrough</s> <u>underline italic bold</u></i> bold</b>
<a href="http://www.example.com/">inline URL</a>
<a href="tg://user?id=123456789">inline mention of a user</a>
<code>inline fixed-width code</code>
<pre>pre-formatted fixed-width code block</pre>
<pre><code class="language-python">pre-formatted fixed-width code block written in the Python programming language</code></pre>`
reader := strings.NewReader(input)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
reader.Reset(input)
builder := entity.Builder{}
if err := HTML(reader, &builder, Options{}); err != nil {
b.Fatal(err)
}
}
}
@@ -0,0 +1,211 @@
package html
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/net/html"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
type htmlTestCase struct {
html string
msg string
entities func(msg string) []tg.MessageEntityClass
wantErr bool
skipReason string
}
func getEntities(formats ...entity.Formatter) func(msg string) []tg.MessageEntityClass {
return func(msg string) []tg.MessageEntityClass {
length := entity.ComputeLength(msg)
r := make([]tg.MessageEntityClass, len(formats))
for i := range formats {
r[i] = formats[i](0, length)
}
return r
}
}
func TestHTML(t *testing.T) {
runTests := func(tests []htmlTestCase, numericName bool) func(t *testing.T) {
return func(t *testing.T) {
for i, test := range tests {
testName := test.msg
if numericName || testName == "" {
testName = fmt.Sprintf("Test%d", i+1)
}
t.Run(strings.Title(testName), func(t *testing.T) {
t.Cleanup(func() {
if t.Failed() {
t.Logf("Input: %q", test.html)
}
})
if test.skipReason != "" {
t.Skip(test.skipReason)
}
a := require.New(t)
b := entity.Builder{}
err := HTML(strings.NewReader(test.html), &b, Options{})
if test.wantErr {
a.Error(err)
return
}
a.NoError(err)
var (
msg string
entities []tg.MessageEntityClass
)
if strings.TrimSpace(test.msg) != test.msg {
// Complete cuts spaces and fixes entities, but TDLib test expects
// that it happens after parsing.
msg, entities = b.Raw()
entity.SortEntities(entities)
} else {
msg, entities = b.Complete()
}
a.Equal(test.msg, msg)
if test.entities != nil {
expect := test.entities(test.msg)
a.Len(entities, len(expect))
a.ElementsMatch(expect, entities)
} else {
a.Empty(entities)
}
})
}
}
}
{
tests := []htmlTestCase{
{html: "<b>bold</b>", msg: "bold", entities: getEntities(entity.Bold())},
{html: "<strong>bold</strong>", msg: "bold", entities: getEntities(entity.Bold())},
{html: "<i>italic</i>", msg: "italic", entities: getEntities(entity.Italic())},
{html: "<em>italic</em>", msg: "italic", entities: getEntities(entity.Italic())},
{html: "<u>underline</u>", msg: "underline", entities: getEntities(entity.Underline())},
{html: "<ins>underline</ins>", msg: "underline", entities: getEntities(entity.Underline())},
{html: "<s>strikethrough</s>", msg: "strikethrough", entities: getEntities(entity.Strike())},
{html: "<strike>strikethrough</strike>", msg: "strikethrough", entities: getEntities(entity.Strike())},
{html: "<del>strikethrough</del>", msg: "strikethrough", entities: getEntities(entity.Strike())},
{html: "<code>code</code>", msg: "code", entities: getEntities(entity.Code())},
{html: "<pre>abc</pre>", msg: "abc", entities: getEntities(entity.Pre(""))},
{html: `<a href="http://www.example.com/">inline URL</a>`, msg: "inline URL",
entities: getEntities(entity.TextURL("http://www.example.com/"))},
{html: `<a href="tg://user?id=123456789">inline mention of a user</a>`, msg: "inline mention of a user",
entities: getEntities(entity.MentionName(&tg.InputUser{
UserID: 123456789,
}))},
{html: `<pre><code class="language-python">python code</code></pre>`, msg: "python code",
entities: getEntities(entity.Pre("python"))},
{html: "<b>&lt;</b>", msg: "<", entities: getEntities(entity.Bold())},
{html: `<span class="tg-spoiler">spoiler</span>`, msg: "spoiler", entities: getEntities(entity.Spoiler())},
{html: "<tg-emoji emoji-id=\"5368324170671202286\">👍</tg-emoji>", msg: "👍", entities: getEntities(entity.CustomEmoji(5368324170671202286))},
{html: "<blockquote expandable>quote</blockquote>", msg: "quote", entities: getEntities(entity.Blockquote(true))},
{html: "<blockquote>quote</blockquote>", msg: "quote", entities: getEntities(entity.Blockquote(false))},
}
t.Run("Common", runTests(tests, false))
}
{
negativeTests := []htmlTestCase{
{html: "&#57311;", wantErr: true},
{html: "&#xDFDF;", wantErr: true},
{html: "&#xDFDF", wantErr: true},
{html: "🏟 🏟&lt;<abacaba", wantErr: true},
{html: "🏟 🏟&lt;<abac aba>", wantErr: true},
{html: "🏟 🏟&lt;<abac>", wantErr: true},
{html: "🏟 🏟&lt;<i =aba>", wantErr: true},
{html: "🏟 🏟&lt;<i aba>", wantErr: true},
{html: "🏟 🏟&lt;<i aba = ", wantErr: true},
{html: "🏟 🏟&lt;<i aba = 190azAz-.,", wantErr: true},
{html: "🏟 🏟&lt;<i aba = \"&lt;&gt;&quot;>", wantErr: true},
{html: "🏟 🏟&lt;<i aba = \\'&lt;&gt;&quot;>", wantErr: true},
{html: "🏟 🏟&lt;</", wantErr: true},
{html: "🏟 🏟&lt;<b></b></", wantErr: true},
{html: "🏟 🏟&lt;<i>a</i ", wantErr: true},
{html: "🏟 🏟&lt;<i>a</em >", wantErr: true},
}
// FIXME(tdakkota): sanitize HTML
_ = negativeTests
t.Run("TDLib", runTests(tdlibHTMLTests(), true))
}
}
func TestIssue525(t *testing.T) {
test := func(text string, expected []tg.MessageEntityClass) func(t *testing.T) {
return func(t *testing.T) {
a := require.New(t)
b := entity.Builder{}
p := htmlParser{
tokenizer: html.NewTokenizer(strings.NewReader(text)),
builder: &b,
attr: map[string]string{},
}
a.NoError(p.parse())
_, entities := b.Complete()
a.Equal(expected, entities)
}
}
t.Run("Ru", test(`Строка
<i>Строка текста курсивом</i>
Обычный текст с <a href="https://google.com">Ссылкой</a> внутри, и
ещё одна ссылка - <a href="https://go.dev">Здесь</a>.
Ещё одна строка.
`,
[]tg.MessageEntityClass{
&tg.MessageEntityItalic{
Offset: 7,
Length: 22,
},
&tg.MessageEntityTextURL{
Offset: 47,
Length: 7,
URL: "https://google.com",
},
&tg.MessageEntityTextURL{
Offset: 83,
Length: 5,
URL: "https://go.dev",
},
}),
)
t.Run("En", test(`Line
<i>Italic line of text</i>
Normal line of text with <a href="https://google.com">Link</a> inside, and
another link now - <a href="https://go.dev">Here</a>.
One more line.
`,
[]tg.MessageEntityClass{
&tg.MessageEntityItalic{
Offset: 5,
Length: 19,
},
&tg.MessageEntityTextURL{
Offset: 51,
Length: 4,
URL: "https://google.com",
},
&tg.MessageEntityTextURL{
Offset: 87,
Length: 4,
URL: "https://go.dev",
},
}),
)
}
+35
View File
@@ -0,0 +1,35 @@
package html
import "go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
type stackElem struct {
token entity.Token
tag string
attr string
format entity.Formatter
}
type stack []stackElem
func (s *stack) push(e stackElem) {
*s = append(*s, e)
}
func (s *stack) last() (stackElem, bool) {
l := len(*s)
if l == 0 {
return stackElem{}, false
}
elem := (*s)[l-1]
return elem, true
}
func (s *stack) pop() (stackElem, bool) {
e, ok := s.last()
if !ok {
return stackElem{}, false
}
*s = (*s)[:len(*s)-1]
return e, true
}
@@ -0,0 +1,413 @@
package html
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
func tdlibHTMLTests() []htmlTestCase {
entities := func(e ...tg.MessageEntityClass) func(msg string) []tg.MessageEntityClass {
return func(msg string) []tg.MessageEntityClass {
return e
}
}
return []htmlTestCase{
{"", "", nil, false, ""},
{"➡️ ➡️", "➡️ ➡️", nil, false, ""},
{
"&lt;&gt;&amp;&quot;&laquo;&raquo;&#12345678;",
"<>&\"&laquo;&raquo;&#12345678;",
nil,
false,
"",
},
{
"➡️ ➡️<i>➡️ ➡️</i>",
"➡️ ➡️➡️ ➡️",
entities(&tg.MessageEntityItalic{Offset: 5, Length: 5}),
false,
"",
},
{
"➡️ ➡️<em>➡️ ➡️</em>", "➡️ ➡️➡️ ➡️",
entities(&tg.MessageEntityItalic{Offset: 5, Length: 5}),
false,
"",
},
{
"➡️ ➡️<b>➡️ ➡️</b>",
"➡️ ➡️➡️ ➡️",
entities(&tg.MessageEntityBold{Offset: 5, Length: 5}),
false,
"",
},
{
"➡️ ➡️<strong>➡️ ➡️</strong>",
"➡️ ➡️➡️ ➡️",
entities(&tg.MessageEntityBold{Offset: 5, Length: 5}),
false,
"",
},
{
"➡️ ➡️<u>➡️ ➡️</u>",
"➡️ ➡️➡️ ➡️",
entities(&tg.MessageEntityUnderline{Offset: 5, Length: 5}),
false,
"",
},
{
"➡️ ➡️<ins>➡️ ➡️</ins>",
"➡️ ➡️➡️ ➡️",
entities(&tg.MessageEntityUnderline{Offset: 5, Length: 5}),
false,
"",
},
{
"➡️ ➡️<s>➡️ ➡️</s>",
"➡️ ➡️➡️ ➡️",
entities(&tg.MessageEntityStrike{Offset: 5, Length: 5}),
false,
"",
},
{
"➡️ ➡️<strike>➡️ ➡️</strike>",
"➡️ ➡️➡️ ➡️",
entities(&tg.MessageEntityStrike{Offset: 5, Length: 5}),
false,
"",
},
{
"➡️ ➡️<del>➡️ ➡️</del>",
"➡️ ➡️➡️ ➡️",
entities(&tg.MessageEntityStrike{Offset: 5, Length: 5}),
false,
"",
},
{
"➡️ ➡️<i>➡️ ➡️</i><b>➡️ ➡️</b>",
"➡️ ➡️➡️ ➡️➡️ ➡️",
entities(
&tg.MessageEntityItalic{Offset: 5, Length: 5},
&tg.MessageEntityBold{Offset: 10, Length: 5},
),
false,
"",
},
{
"🏟 🏟<i>🏟 &lt🏟</i>",
"🏟 🏟🏟 <🏟",
entities(&tg.MessageEntityItalic{Offset: 5, Length: 6}),
false,
"",
},
{
"🏟 🏟<i>🏟 &gt;<b aba = caba>&lt🏟</b></i>",
"🏟 🏟🏟 ><🏟",
entities(
&tg.MessageEntityItalic{Offset: 5, Length: 7},
&tg.MessageEntityBold{Offset: 9, Length: 3},
),
false,
"",
},
{
"🏟 🏟&lt;<i aba = 190azAz-. >a</i>",
"🏟 🏟<a",
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
false,
"",
},
{
"🏟 🏟&lt;<i aba = 190azAz-.>a</i>",
"🏟 🏟<a",
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
false,
"",
},
{
"🏟 🏟&lt;<i aba = \"&lt;&gt;&quot;\">a</i>",
"🏟 🏟<a",
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
false,
"",
},
{
"🏟 🏟&lt;<i aba = '&lt;&gt;&quot;'>a</i>",
"🏟 🏟<a",
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
false,
"",
},
{
"🏟 🏟&lt;<i aba = '&lt;&gt;&quot;'>a</>",
"🏟 🏟<a",
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
false,
"",
},
{
"🏟 🏟&lt;<i>🏟 🏟&lt;</>",
"🏟 🏟<🏟 🏟<",
entities(&tg.MessageEntityItalic{Offset: 6, Length: 6}),
false,
"",
},
{
"🏟 🏟&lt;<i>a</ >",
"🏟 🏟<a",
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
false,
"",
},
{
"🏟 🏟&lt;<i>a</i >",
"🏟 🏟<a",
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
false,
"",
},
// Empty entity.
{
"🏟 🏟&lt;<b></b>",
"🏟 🏟<",
nil,
false,
"",
},
// Space handling.
{
"<i>\t</i>",
"\t",
entities(&tg.MessageEntityItalic{Offset: 0, Length: 1}),
false,
"",
},
{
"<i>\r</i>",
"\r",
entities(&tg.MessageEntityItalic{Offset: 0, Length: 1}),
false,
"",
},
{
"<i>\n</i>",
"\n",
entities(&tg.MessageEntityItalic{Offset: 0, Length: 1}),
false,
"",
},
{
"➡️ ➡️<span class = \"tg-spoiler\">➡️ ➡️</span><b>➡️ ➡️</b>",
"➡️ ➡️➡️ ➡️➡️ ➡️",
entities(&tg.MessageEntitySpoiler{Offset: 5, Length: 5}, &tg.MessageEntityBold{Offset: 10, Length: 5}),
false,
"",
},
{
"🏟 🏟<span class=\"tg-spoiler\">🏟 &lt🏟</span>",
"🏟 🏟🏟 <🏟",
entities(&tg.MessageEntitySpoiler{Offset: 5, Length: 6}),
false,
"",
},
{
"🏟 🏟<span class=\"tg-spoiler\">🏟 &gt;<b aba = caba>&lt🏟</b></span>",
"🏟 🏟🏟 ><🏟",
entities(&tg.MessageEntitySpoiler{Offset: 5, Length: 7}, &tg.MessageEntityBold{Offset: 9, Length: 3}),
false,
"",
},
{
"➡️ ➡️<tg-spoiler>➡️ ➡️</tg-spoiler><b>➡️ ➡️</b>",
"➡️ ➡️➡️ ➡️➡️ ➡️",
entities(&tg.MessageEntitySpoiler{Offset: 5, Length: 5}, &tg.MessageEntityBold{Offset: 10, Length: 5}),
false,
"",
},
{
"🏟 🏟<tg-spoiler>🏟 &lt🏟</tg-spoiler>",
"🏟 🏟🏟 <🏟",
entities(&tg.MessageEntitySpoiler{Offset: 5, Length: 6}),
false,
"",
},
{
"🏟 🏟<tg-spoiler>🏟 &gt;<b aba = caba>&lt🏟</b></tg-spoiler>",
"🏟 🏟🏟 ><🏟",
entities(&tg.MessageEntitySpoiler{Offset: 5, Length: 7}, &tg.MessageEntityBold{Offset: 9, Length: 3}),
false,
"",
},
{
"<a href=telegram.org>\t</a>",
"\t",
entities(&tg.MessageEntityTextURL{Offset: 0, Length: 1, URL: "http://telegram.org/"}),
false,
"",
},
{
"<a href=telegram.org>\r</a>",
"\r",
entities(&tg.MessageEntityTextURL{Offset: 0, Length: 1, URL: "http://telegram.org/"}),
false,
"",
},
{
"<a href=telegram.org>\n</a>",
"\n",
entities(&tg.MessageEntityTextURL{Offset: 0, Length: 1, URL: "http://telegram.org/"}),
false,
"",
},
{
"<code><i><b> </b></i></code><i><b><code> </code></b></i>",
" ",
entities(
&tg.MessageEntityCode{Offset: 0, Length: 1},
&tg.MessageEntityBold{Offset: 0, Length: 1},
&tg.MessageEntityItalic{Offset: 0, Length: 1},
&tg.MessageEntityCode{Offset: 1, Length: 1},
&tg.MessageEntityBold{Offset: 1, Length: 1},
&tg.MessageEntityItalic{Offset: 1, Length: 1}),
false,
"",
},
{
"<i><b> </b> <code> </code></i>",
" ",
entities(
&tg.MessageEntityItalic{Offset: 0, Length: 3},
&tg.MessageEntityBold{Offset: 0, Length: 1},
&tg.MessageEntityCode{Offset: 2, Length: 1},
),
false,
"",
},
{
"<a href=telegram.org> </a>",
" ",
entities(&tg.MessageEntityTextURL{Offset: 0, Length: 1, URL: "http://telegram.org/"}),
false,
"",
},
{
"<a href =\"telegram.org\" > </a>",
" ",
entities(&tg.MessageEntityTextURL{Offset: 0, Length: 1, URL: "http://telegram.org/"}),
false,
"",
},
{
"<a href= 'telegram.org' > </a>",
" ",
entities(&tg.MessageEntityTextURL{Offset: 0, Length: 1, URL: "http://telegram.org/"}),
false,
"",
},
{
"<a href= 'telegram.org?&lt;' > </a>",
" ",
entities(&tg.MessageEntityTextURL{Offset: 0, Length: 1, URL: "http://telegram.org/?<"}),
false,
"",
},
// URL handling
{
"<a>telegram.org </a>",
"telegram.org ",
nil,
false,
"",
},
{
"<a>telegram.org</a>", "telegram.org",
entities(&tg.MessageEntityTextURL{
Offset: 0,
Length: 12,
URL: "http://telegram.org/",
}),
false,
"",
},
{
"<a>https://telegram.org/asdsa?asdasdwe#12e3we</a>",
"https://telegram.org/asdsa?asdasdwe#12e3we",
entities(&tg.MessageEntityTextURL{
Offset: 0,
Length: 42,
URL: "https://telegram.org/asdsa?asdasdwe#12e3we",
}),
false,
"",
},
// <pre> and <code> handling
{
"🏟 🏟&lt;<pre >🏟 🏟&lt;</>",
"🏟 🏟<🏟 🏟<",
entities(&tg.MessageEntityPre{Offset: 6, Length: 6}),
false,
"",
},
{
"🏟 🏟&lt;<code >🏟 🏟&lt;</>",
"🏟 🏟<🏟 🏟<",
entities(&tg.MessageEntityCode{Offset: 6, Length: 6}),
false,
"",
},
{
"🏟 🏟&lt;<pre><code>🏟 🏟&lt;</code></>",
"🏟 🏟<🏟 🏟<",
entities(
&tg.MessageEntityPre{Offset: 6, Length: 6},
&tg.MessageEntityCode{Offset: 6, Length: 6},
),
false,
"",
},
{
"🏟 🏟&lt;<pre><code class=\"language-\">🏟 🏟&lt;</code></>",
"🏟 🏟<🏟 🏟<",
entities(
&tg.MessageEntityPre{Offset: 6, Length: 6},
&tg.MessageEntityCode{Offset: 6, Length: 6},
),
false,
"",
},
{
"🏟 🏟&lt;<pre><code class=\"language-fift\">🏟 🏟&lt;</></>",
"🏟 🏟<🏟 🏟<",
entities(&tg.MessageEntityPre{Offset: 6, Length: 6, Language: "fift"}),
false,
"",
},
{
"🏟 🏟&lt;<code class=\"language-fift\"><pre>🏟 🏟&lt;</></>",
"🏟 🏟<🏟 🏟<",
entities(&tg.MessageEntityPre{Offset: 6, Length: 6, Language: "fift"}),
false,
"",
},
{
"🏟 🏟&lt;<pre><code class=\"language-fift\">🏟 🏟&lt;</> </>",
"🏟 🏟<🏟 🏟< ",
entities(
&tg.MessageEntityPre{Offset: 6, Length: 7},
&tg.MessageEntityCode{Offset: 6, Length: 6},
),
false,
"",
},
{
"🏟 🏟&lt;<pre> <code class=\"language-fift\">🏟 🏟&lt;</></>",
"🏟 🏟< 🏟 🏟<",
entities(
&tg.MessageEntityPre{Offset: 6, Length: 7},
&tg.MessageEntityCode{Offset: 7, Length: 6},
),
false,
"",
},
}
}
@@ -0,0 +1,89 @@
package html
import (
"net"
"net/url"
"strconv"
"strings"
"unicode/utf8"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/ascii"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
)
func isIPv6(str string) bool {
ip := net.ParseIP(str)
return strings.Contains(str, ":") && ip != nil
}
func validateHostname(u *url.URL) error {
// TODO(tdakkota): make sure that it is correct
ipv6 := isIPv6(u.Host)
if !strings.ContainsRune(u.Host, '.') && ipv6 {
return errors.New("wrong HTTP URL")
}
if ipv6 {
return nil
}
allowedSymbol := func(c rune) bool {
return ascii.IsLatinLetter(c) ||
ascii.IsDigit(c) ||
(c >= '&' && c <= '.') ||
c == '_' ||
c == '!' ||
c == '$' ||
c == '~' ||
c == ';' ||
c == '=' ||
c > utf8.RuneSelf
}
for _, c := range u.Host {
if !allowedSymbol(c) {
return errors.Errorf("disallowed character %c in URL host", c)
}
}
return nil
}
func getURLFormatter(rawURL string, resolver entity.UserResolver) (entity.Formatter, error) {
const defaultProtocol = "http"
if rawURL == "" {
return nil, errors.New("empty URL")
}
// FIXME(tdakkota): move normalization to deeplink package when it's done?
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
if u.Scheme == "tg" && u.Host == "user" {
id, err := strconv.ParseInt(u.Query().Get("id"), 10, 64)
if err != nil {
return nil, errors.Wrapf(err, "invalid user ID %q", id)
}
user, err := resolver(id)
if err != nil {
return nil, errors.Wrapf(err, "can't resolve user %q", id)
}
return entity.MentionName(user), nil
}
if u.Scheme == "" {
u.Scheme = defaultProtocol
u.Host = u.Path
u.Path = "/"
rawURL = u.String()
}
if err := validateHostname(u); err != nil {
return nil, err
}
return entity.TextURL(rawURL), nil
}
+129
View File
@@ -0,0 +1,129 @@
package html
import "unicode/utf8"
// unescapeEntity reads an entity like "&lt;" from b[src:] and writes the
// corresponding "<" to b[dst:], returning the incremented dst and src cursors.
// Precondition: b[src] == '&' && dst <= src.
//
// This is adaption of html.UnescapeString from Go sources.
func unescapeEntity(b []byte, dst, src int) (dst1, src1 int) {
// i starts at 1 because we already know that s[0] == '&'.
i, s := 1, b[src:]
if len(s) <= 1 {
b[dst] = b[src]
return dst + 1, src + 1
}
if s[i] == '#' {
if len(s) <= 3 { // We need to have at least "&#.".
b[dst] = b[src]
return dst + 1, src + 1
}
i++
c := s[i]
hex := false
if c == 'x' || c == 'X' {
hex = true
i++
}
x := '\x00'
for i < len(s) {
c = s[i]
i++
if hex {
switch {
case '0' <= c && c <= '9':
x = 16*x + rune(c) - '0'
continue
case 'a' <= c && c <= 'f':
x = 16*x + rune(c) - 'a' + 10
continue
case 'A' <= c && c <= 'F':
x = 16*x + rune(c) - 'A' + 10
continue
}
} else if '0' <= c && c <= '9' {
x = 10*x + rune(c) - '0'
continue
}
if c != ';' {
i--
}
break
}
if i <= 3 { // No characters matched.
b[dst] = b[src]
return dst + 1, src + 1
}
if x == 0 || x >= 0x10ffff {
b[dst] = b[src]
return dst + 1, src + 1
}
return dst + utf8.EncodeRune(b[dst:], x), src + i
}
// Consume the maximum number of characters possible, with the
// consumed characters matching one of the named references.
for i < len(s) {
c := s[i]
i++
// Lower-cased characters are more common in entities, so we check for them first.
if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' {
continue
}
if c != ';' {
i--
}
break
}
var x rune
tagEnd := i
if i > 0 && s[tagEnd-1] == ';' {
tagEnd--
}
switch string(s[1:tagEnd]) {
case "lt":
x = '<'
case "gt":
x = '>'
case "amp":
x = '&'
case "quot":
x = '"'
}
if x != 0 {
return dst + utf8.EncodeRune(b[dst:], x), src + i
}
dst1, src1 = dst+i, src+i
copy(b[dst:dst1], b[src:src1])
return dst1, src1
}
// telegramEscape implements Telegram BotAPI HTML unescape.
func telegramUnescape(b []byte) []byte {
for i, c := range b {
if c == '&' {
dst, src := unescapeEntity(b, i, i)
for src < len(b) {
c := b[src]
if c == '&' {
dst, src = unescapeEntity(b, dst, src)
} else {
b[dst] = c
dst, src = dst+1, src+1
}
}
return b[0:dst]
}
}
return b
}
@@ -0,0 +1,35 @@
package html
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_telegramUnescape(t *testing.T) {
tests := []struct {
name string
b string
want string
}{
{"NoEscapeCode", "&", "&"},
{"NoEscapeCode", "&#", "&#"},
{"UnicodeFlag", "&#127987", string(rune(127987))},
{"UnicodeFlag", "&#127987;", string(rune(127987))},
{"UnicodeFlagHex", "&#x1F3f3", string(rune(0x1f3f3))},
{"UnicodeFlagHex", "&#x1F3f3;", string(rune(0x1f3f3))},
{"lt", "&lt;", "<"},
{"lt", "&lt", "<"},
{"gt", "&gt;", ">"},
{"gt", "&gt", ">"},
{"amp", "&amp;", "&"},
{"amp", "&amp", "&"},
{"quot", "&quot;", `"`},
{"quot", "&quot", `"`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, []byte(tt.want), telegramUnescape([]byte(tt.b)))
})
}
}
+50
View File
@@ -0,0 +1,50 @@
package message
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/html"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestHTMLBuilder_String(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
msg := "abc"
send := "<b>" + msg + "</b>"
mock.ExpectFunc(func(b bin.Encoder) {
req, ok := b.(*tg.MessagesSendMessageRequest)
require.True(t, ok)
require.Equal(t, &tg.InputPeerSelf{}, req.Peer)
require.Equal(t, msg, req.Message)
require.NotZero(t, req.Entities)
require.Equal(t, &tg.MessageEntityBold{
Offset: 0,
Length: entity.ComputeLength(msg),
}, req.Entities[0])
}).ThenResult(&tg.Updates{})
_, err := sender.Self().StyledText(ctx, html.Format(nil, "<b>%s</b>", msg))
require.NoError(t, err)
mock.ExpectFunc(func(b bin.Encoder) {
req, ok := b.(*tg.MessagesSendMessageRequest)
require.True(t, ok)
require.Equal(t, &tg.InputPeerSelf{}, req.Peer)
require.Equal(t, msg, req.Message)
require.NotZero(t, req.Entities)
require.Equal(t, &tg.MessageEntityBold{
Offset: 0,
Length: entity.ComputeLength(msg),
}, req.Entities[0])
}).ThenRPCErr(testRPCError())
_, err = sender.Self().StyledText(ctx, html.Bytes(nil, []byte(send)))
require.Error(t, err)
}
+45
View File
@@ -0,0 +1,45 @@
package message
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/inline"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// InlineResult is a user method to send bot inline query result message.
func (b *Builder) InlineResult(ctx context.Context, id string, queryID int64, hideVia bool) (tg.UpdatesClass, error) {
p, err := b.peer(ctx)
if err != nil {
return nil, errors.Wrap(err, "peer")
}
upd, err := b.sender.sendInlineBotResult(ctx, &tg.MessagesSendInlineBotResultRequest{
Silent: b.silent,
Background: b.background,
ClearDraft: b.clearDraft,
HideVia: hideVia,
Peer: p,
ReplyTo: b.replyTo,
QueryID: queryID,
ID: id,
ScheduleDate: b.scheduleDate,
})
if err != nil {
return nil, errors.Wrap(err, "send inline bot result")
}
return upd, nil
}
// InlineUpdate is an abstraction for
type InlineUpdate interface {
GetQueryID() int64
}
// Inline creates new inline.ResultBuilder using given update.
func (s *Sender) Inline(upd InlineUpdate) *inline.ResultBuilder {
return inline.New(s.raw, s.rand, upd.GetQueryID())
}
@@ -0,0 +1,86 @@
package inline
import (
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// ArticleResultBuilder is article result option builder.
type ArticleResultBuilder struct {
result *tg.InputBotInlineResult
msg MessageOption
}
func (b *ArticleResultBuilder) apply(r *resultPageBuilder) error {
m, err := b.msg.apply()
if err != nil {
return err
}
t := tg.InputBotInlineResult{
ID: b.result.ID,
Type: b.result.Type,
Title: b.result.Title,
Description: b.result.Description,
URL: b.result.URL,
Thumb: b.result.Thumb,
Content: b.result.Content,
}
if t.ID == "" {
t.ID, err = r.generateID()
if err != nil {
return err
}
}
t.SendMessage = m
r.results = append(r.results, &t)
return nil
}
// ID sets ID of result.
// Should not be empty, so if id is not provided, random will be used.
func (b *ArticleResultBuilder) ID(id string) *ArticleResultBuilder {
b.result.ID = id
return b
}
// Title sets Result description.
func (b *ArticleResultBuilder) Title(title string) *ArticleResultBuilder {
b.result.SetTitle(title)
return b
}
// Description sets Result description.
func (b *ArticleResultBuilder) Description(description string) *ArticleResultBuilder {
b.result.SetDescription(description)
return b
}
// URL sets URL of result.
func (b *ArticleResultBuilder) URL(url string) *ArticleResultBuilder {
b.result.SetURL(url)
return b
}
// Thumb sets Thumbnail for result.
func (b *ArticleResultBuilder) Thumb(thumb tg.InputWebDocument) *ArticleResultBuilder {
b.result.SetThumb(thumb)
return b
}
// Content sets Result contents.
func (b *ArticleResultBuilder) Content(content tg.InputWebDocument) *ArticleResultBuilder {
b.result.SetContent(content)
return b
}
// Article creates article result option builder.
func Article(title string, msg MessageOption) *ArticleResultBuilder {
return &ArticleResultBuilder{
result: &tg.InputBotInlineResult{
Type: ArticleType,
Title: title,
},
msg: msg,
}
}
@@ -0,0 +1,44 @@
package inline
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestArticle(t *testing.T) {
ctx := context.Background()
builder, mock := testBuilder(t)
mock.ExpectFunc(func(b bin.Encoder) {
v, ok := b.(*tg.MessagesSetInlineBotResultsRequest)
require.True(t, ok)
require.Equal(t, int64(10), v.QueryID)
for i := range v.Results {
r, ok := v.Results[i].(*tg.InputBotInlineResult)
require.True(t, ok)
require.NotZero(t, r.ID)
require.Equal(t, r.Title, r.Type)
require.Equal(t, r.Description, r.Title)
require.Equal(t, r.URL, r.Description)
}
}).ThenTrue()
_, err := builder.Set(ctx,
Article(ArticleType, MessageText("article")).
Description(ArticleType).URL(ArticleType),
Article(ArticleType, MediaAuto("article")).ID("10").Title(ArticleType).
Description(ArticleType).URL(ArticleType),
)
require.NoError(t, err)
mock.Expect().ThenRPCErr(testRPCError())
_, err = builder.Set(ctx,
Article(ArticleType, MessageText("article")),
)
require.Error(t, err)
}
+2
View File
@@ -0,0 +1,2 @@
// Package inline contains inline query results builder.
package inline
@@ -0,0 +1,99 @@
package inline
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
// DocumentResultBuilder is document result option builder.
type DocumentResultBuilder struct {
result *tg.InputBotInlineResultDocument
msg MessageOption
}
func (b *DocumentResultBuilder) apply(r *resultPageBuilder) error {
m, err := b.msg.apply()
if err != nil {
return err
}
t := tg.InputBotInlineResultDocument{
ID: b.result.ID,
Type: b.result.Type,
Title: b.result.Title,
Description: b.result.Description,
Document: b.result.Document,
}
if t.ID == "" {
t.ID, err = r.generateID()
if err != nil {
return err
}
}
t.SendMessage = m
r.results = append(r.results, &t)
return nil
}
// ID sets ID of result.
// Should not be empty, so if id is not provided, random will be used.
func (b *DocumentResultBuilder) ID(id string) *DocumentResultBuilder {
b.result.ID = id
return b
}
// Title sets Result description.
func (b *DocumentResultBuilder) Title(title string) *DocumentResultBuilder {
b.result.SetTitle(title)
return b
}
// Description sets Result description.
func (b *DocumentResultBuilder) Description(description string) *DocumentResultBuilder {
b.result.SetDescription(description)
return b
}
// Document creates document result option builder.
func Document(doc tg.InputDocumentClass, typ string, msg MessageOption) *DocumentResultBuilder {
return &DocumentResultBuilder{
result: &tg.InputBotInlineResultDocument{
Type: typ,
Document: doc,
},
msg: msg,
}
}
// Video creates video result option builder.
func Video(doc tg.InputDocumentClass, msg MessageOption) *DocumentResultBuilder {
return Document(doc, VideoType, msg)
}
// Audio creates audio result option builder.
func Audio(doc tg.InputDocumentClass, msg MessageOption) *DocumentResultBuilder {
return Document(doc, AudioType, msg)
}
// File creates document result option builder.
func File(doc tg.InputDocumentClass, msg MessageOption) *DocumentResultBuilder {
return Document(doc, DocumentType, msg)
}
// GIF creates gif result option builder.
func GIF(doc tg.InputDocumentClass, msg MessageOption) *DocumentResultBuilder {
return Document(doc, GIFType, msg)
}
// MPEG4GIF creates mpeg4gif result option builder.
func MPEG4GIF(doc tg.InputDocumentClass, msg MessageOption) *DocumentResultBuilder {
return Document(doc, MPEG4GIFType, msg)
}
// Voice creates voice result option builder.
func Voice(doc tg.InputDocumentClass, msg MessageOption) *DocumentResultBuilder {
return Document(doc, VoiceType, msg)
}
// Sticker creates sticker result option builder.
func Sticker(doc tg.InputDocumentClass, msg MessageOption) *DocumentResultBuilder {
return Document(doc, StickerType, msg)
}
@@ -0,0 +1,56 @@
package inline
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestDocument(t *testing.T) {
ctx := context.Background()
builder, mock := testBuilder(t)
doc := &tg.InputDocument{ID: 10, AccessHash: 10, FileReference: []byte{10}}
mock.ExpectFunc(func(b bin.Encoder) {
v, ok := b.(*tg.MessagesSetInlineBotResultsRequest)
require.True(t, ok)
require.Equal(t, int64(10), v.QueryID)
for i := range v.Results {
r, ok := v.Results[i].(*tg.InputBotInlineResultDocument)
require.True(t, ok)
require.NotZero(t, r.ID)
require.Equal(t, doc, r.Document)
require.Equal(t, r.Title, r.Type)
require.Equal(t, r.Description, r.Title)
}
}).ThenTrue()
_, err := builder.Set(ctx,
Video(doc, MessageText("video")).Title(VideoType).
Description(VideoType),
File(doc, MessageText("file")).ID("10").Title(DocumentType).
Description(DocumentType),
Audio(doc, MessageText("audio")).ID("10").Title(AudioType).
Description(AudioType),
GIF(doc, MessageText("gif")).ID("10").Title(GIFType).
Description(GIFType),
MPEG4GIF(doc, MessageText("mpeg4gif")).ID("10").Title(MPEG4GIFType).
Description(MPEG4GIFType),
Voice(doc, MessageText("voice")).ID("10").Title(VoiceType).
Description(VoiceType),
Sticker(doc, MessageText("sticker")).ID("10").Title(StickerType).
Description(StickerType),
)
require.NoError(t, err)
mock.Expect().ThenRPCErr(testRPCError())
_, err = builder.Set(ctx,
Video(doc, MessageText("video")).Title(VideoType).
Description(VideoType),
)
require.Error(t, err)
}
+50
View File
@@ -0,0 +1,50 @@
package inline
import (
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// GameResultBuilder is game result option builder.
type GameResultBuilder struct {
result *tg.InputBotInlineResultGame
msg MessageOption
}
func (b *GameResultBuilder) apply(r *resultPageBuilder) error {
m, err := b.msg.apply()
if err != nil {
return err
}
t := tg.InputBotInlineResultGame{
ID: b.result.ID,
ShortName: b.result.ShortName,
}
if t.ID == "" {
t.ID, err = r.generateID()
if err != nil {
return err
}
}
t.SendMessage = m
r.results = append(r.results, &t)
return nil
}
// ID sets ID of result.
// Should not be empty, so if id is not provided, random will be used.
func (b *GameResultBuilder) ID(id string) *GameResultBuilder {
b.result.ID = id
return b
}
// Game creates game result option builder.
func Game(shortName string, msg MessageOption) *GameResultBuilder {
return &GameResultBuilder{
result: &tg.InputBotInlineResultGame{
ShortName: shortName,
},
msg: msg,
}
}
@@ -0,0 +1,42 @@
package inline
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestGame(t *testing.T) {
ctx := context.Background()
builder, mock := testBuilder(t)
gameName := "game"
mock.ExpectFunc(func(b bin.Encoder) {
v, ok := b.(*tg.MessagesSetInlineBotResultsRequest)
require.True(t, ok)
require.Equal(t, int64(10), v.QueryID)
for i := range v.Results {
r, ok := v.Results[i].(*tg.InputBotInlineResultGame)
require.True(t, ok)
require.NotZero(t, r.ID)
require.Equal(t, gameName, r.ShortName)
}
}).ThenTrue()
_, err := builder.Set(ctx,
Game(gameName, MessageText("game")),
Game(gameName, MessageText("game")).ID("10"),
)
require.NoError(t, err)
mock.Expect().ThenRPCErr(testRPCError())
_, err = builder.Set(ctx,
Game(gameName, MessageText("game")),
Game(gameName, MessageText("game")).ID("10"),
)
require.Error(t, err)
}
+130
View File
@@ -0,0 +1,130 @@
package inline
import (
"context"
"io"
"time"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// ResultBuilder is inline result builder.
type ResultBuilder struct {
raw *tg.Client
random io.Reader
// Set this flag if the results are composed of media files
gallery bool
// Set this flag if results may be cached on the server side only for the user that sent
// the query. By default, results may be returned to any user who sends the same query
private bool
// Unique identifier for the answered query
queryID int64
// The maximum amount of time in seconds that the result of the inline query may be
// cached on the server. Defaults to 300.
cacheTime int
// Pass the offset that a client should send in the next query with the same text to
// receive more results. Pass an empty string if there are no more results or if you
// dont support pagination. Offset length cant exceed 64 bytes.
nextOffset string
// If passed, clients will display a button with specified text that switches the user to
// a private chat with the bot and sends the bot a start message with a certain parameter.
switchPm tg.InlineBotSwitchPM
// If passed, clients will display a button on top of the remaining inline result list
// with the specified text, that switches the user to the specified bot web app.
switchWebview tg.InlineBotWebView
}
// New creates new ResultBuilder.
func New(raw *tg.Client, random io.Reader, queryID int64) *ResultBuilder {
return &ResultBuilder{raw: raw, random: random, queryID: queryID}
}
// Gallery sets flag if the results are composed of media files.
func (r *ResultBuilder) Gallery(gallery bool) *ResultBuilder {
r.gallery = gallery
return r
}
// Private sets flag if results may be cached on the server side only for the user that sent
// the query. By default, results may be returned to any user who sends the same query.
func (r *ResultBuilder) Private(private bool) *ResultBuilder {
r.private = private
return r
}
// CacheTime sets the maximum amount of time that the result of the inline query may be
// cached on the server. Server's default is 300 seconds.
func (r *ResultBuilder) CacheTime(cacheTime time.Duration) *ResultBuilder {
return r.CacheTimeSeconds(int(cacheTime.Seconds()))
}
// CacheTimeSeconds sets the maximum amount of time in seconds that the result of the inline query may be
// cached on the server. Server's default is 300.
func (r *ResultBuilder) CacheTimeSeconds(cacheTime int) *ResultBuilder {
r.cacheTime = cacheTime
return r
}
// NextOffset sets offset that a client should send in the next query with the same text to
// receive more results. Pass an empty string if there are no more results or if you
// dont support pagination. Offset length cant exceed 64 bytes.
func (r *ResultBuilder) NextOffset(nextOffset string) *ResultBuilder {
r.nextOffset = nextOffset
return r
}
// SwitchPM sets SwitchPm field.
//
// If passed, clients will display a button with specified text that switches the user to
// a private chat with the bot and sends the bot a start message with a certain parameter.
func (r *ResultBuilder) SwitchPM(text, startParam string) *ResultBuilder {
r.switchPm = tg.InlineBotSwitchPM{
Text: text,
StartParam: startParam,
}
return r
}
// SwitchWebview sets SwitchWebview field.
//
// If passed, clients will display a button on top of the remaining inline result list
// with the specified text, that switches the user to the specified bot web app.
func (r *ResultBuilder) SwitchWebview(text, url string) *ResultBuilder {
r.switchWebview = tg.InlineBotWebView{
Text: text,
URL: url,
}
return r
}
// Set sets inline results for given query.
func (r *ResultBuilder) Set(ctx context.Context, opts ...ResultOption) (bool, error) {
res := resultPageBuilder{
results: nil,
random: r.random,
}
for idx, opt := range opts {
if err := opt.apply(&res); err != nil {
return false, errors.Wrapf(err, "apply %d option", idx+1)
}
}
ok, err := r.raw.MessagesSetInlineBotResults(ctx, &tg.MessagesSetInlineBotResultsRequest{
Private: r.private,
QueryID: r.queryID,
Results: res.results,
CacheTime: r.cacheTime,
NextOffset: r.nextOffset,
SwitchPm: r.switchPm,
Gallery: r.gallery,
SwitchWebview: r.switchWebview,
})
if err != nil {
return false, errors.Wrap(err, "set inline results")
}
return ok, nil
}
@@ -0,0 +1,66 @@
package inline
import (
"context"
"crypto/rand"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgmock"
)
func testBuilder(t *testing.T) (*ResultBuilder, *tgmock.Mock) {
mock := tgmock.New(t)
sender := New(tg.NewClient(mock), rand.Reader, 10)
return sender, mock
}
func testRPCError() *tgerr.Error {
return &tgerr.Error{
Code: 1337,
Message: "TEST_ERROR",
Type: "TEST_ERROR",
}
}
func TestResultBuilder_Set(t *testing.T) {
ctx := context.Background()
builder, mock := testBuilder(t)
mock.ExpectFunc(func(b bin.Encoder) {
v, ok := b.(*tg.MessagesSetInlineBotResultsRequest)
require.True(t, ok)
require.True(t, v.Gallery)
}).ThenTrue()
_, err := builder.Gallery(true).Set(ctx)
require.NoError(t, err)
mock.ExpectFunc(func(b bin.Encoder) {
v, ok := b.(*tg.MessagesSetInlineBotResultsRequest)
require.True(t, ok)
require.True(t, v.Private)
}).ThenTrue()
_, err = builder.Private(true).Set(ctx)
require.NoError(t, err)
mock.ExpectFunc(func(b bin.Encoder) {
v, ok := b.(*tg.MessagesSetInlineBotResultsRequest)
require.True(t, ok)
require.Equal(t, 1, v.CacheTime)
}).ThenTrue()
_, err = builder.CacheTime(time.Second).Set(ctx)
require.NoError(t, err)
mock.ExpectFunc(func(b bin.Encoder) {
v, ok := b.(*tg.MessagesSetInlineBotResultsRequest)
require.True(t, ok)
require.Equal(t, "offset", v.NextOffset)
}).ThenTrue()
_, err = builder.NextOffset("offset").Set(ctx)
require.NoError(t, err)
}
@@ -0,0 +1,55 @@
package inline
import (
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/markup"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/styling"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// MessageMediaAutoBuilder is a builder of inline result text message.
type MessageMediaAutoBuilder struct {
message *tg.InputBotInlineMessageMediaAuto
options []styling.StyledTextOption
}
func (b *MessageMediaAutoBuilder) apply() (tg.InputBotInlineMessageClass, error) {
tb := entity.Builder{}
if err := styling.Perform(&tb, b.options...); err != nil {
return nil, err
}
msg, entities := tb.Complete()
r := *b.message
r.Message = msg
r.Entities = entities
return &r, nil
}
// MediaAuto creates new message text option builder.
func MediaAuto(msg string) *MessageMediaAutoBuilder {
return MediaAutoStyled(styling.Plain(msg))
}
// MediaAutoStyled creates new message text option builder.
func MediaAutoStyled(texts ...styling.StyledTextOption) *MessageMediaAutoBuilder {
return &MessageMediaAutoBuilder{
message: &tg.InputBotInlineMessageMediaAuto{},
options: texts,
}
}
// Markup sets reply markup for sending bot buttons.
// NB: markup will not be used, if you send multiple media attachments.
func (b *MessageMediaAutoBuilder) Markup(m tg.ReplyMarkupClass) *MessageMediaAutoBuilder {
b.message.ReplyMarkup = m
return b
}
// Row sets single row keyboard markup for sending bot buttons.
// NB: markup will not be used, if you send multiple media attachments.
func (b *MessageMediaAutoBuilder) Row(
buttons ...tg.KeyboardButtonClass,
) *MessageMediaAutoBuilder {
return b.Markup(markup.InlineRow(buttons...))
}
@@ -0,0 +1,37 @@
package inline
import (
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/markup"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// MessageGameBuilder is a builder of inline result game message.
type MessageGameBuilder struct {
message *tg.InputBotInlineMessageGame
}
// nolint:unparam
func (b *MessageGameBuilder) apply() (tg.InputBotInlineMessageClass, error) {
r := *b.message
return &r, nil
}
// MessageGame creates new message option builder.
func MessageGame() *MessageGameBuilder {
return &MessageGameBuilder{
message: &tg.InputBotInlineMessageGame{},
}
}
// Markup sets reply markup for sending bot buttons.
// NB: markup will not be used, if you send multiple media attachments.
func (b *MessageGameBuilder) Markup(m tg.ReplyMarkupClass) *MessageGameBuilder {
b.message.ReplyMarkup = m
return b
}
// Row sets single row keyboard markup for sending bot buttons.
// NB: markup will not be used, if you send multiple media attachments.
func (b *MessageGameBuilder) Row(buttons ...tg.KeyboardButtonClass) *MessageGameBuilder {
return b.Markup(markup.InlineRow(buttons...))
}
@@ -0,0 +1,71 @@
package inline
import (
"time"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/markup"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// MessageMediaGeoBuilder is a builder of inline result geo message.
type MessageMediaGeoBuilder struct {
message *tg.InputBotInlineMessageMediaGeo
}
// nolint:unparam
func (b *MessageMediaGeoBuilder) apply() (tg.InputBotInlineMessageClass, error) {
r := *b.message
return &r, nil
}
// MessageGeo creates new message geo option builder.
func MessageGeo(point tg.InputGeoPointClass) *MessageMediaGeoBuilder {
return &MessageMediaGeoBuilder{
message: &tg.InputBotInlineMessageMediaGeo{
GeoPoint: point,
},
}
}
// Heading sets for live locations¹, a direction in which the location moves, in degrees; 1-360.
//
// Links:
// 1. https://core.telegram.org/api/live-location
func (b *MessageMediaGeoBuilder) Heading(heading int) *MessageMediaGeoBuilder {
b.message.Heading = heading
return b
}
// Period sets validity period.
func (b *MessageMediaGeoBuilder) Period(dur time.Duration) *MessageMediaGeoBuilder {
return b.PeriodSeconds(int(dur.Seconds()))
}
// PeriodSeconds sets validity period in seconds.
func (b *MessageMediaGeoBuilder) PeriodSeconds(period int) *MessageMediaGeoBuilder {
b.message.Period = period
return b
}
// ProximityNotificationRadius sets for live locations¹, a maximum distance to another chat member for proximity
// alerts, in meters (0-100000)
//
// Links:
// 1. https://core.telegram.org/api/live-location
func (b *MessageMediaGeoBuilder) ProximityNotificationRadius(radius int) *MessageMediaGeoBuilder {
b.message.ProximityNotificationRadius = radius
return b
}
// Markup sets reply markup for sending bot buttons.
// NB: markup will not be used, if you send multiple media attachments.
func (b *MessageMediaGeoBuilder) Markup(m tg.ReplyMarkupClass) *MessageMediaGeoBuilder {
b.message.ReplyMarkup = m
return b
}
// Row sets single row keyboard markup for sending bot buttons.
// NB: markup will not be used, if you send multiple media attachments.
func (b *MessageMediaGeoBuilder) Row(buttons ...tg.KeyboardButtonClass) *MessageMediaGeoBuilder {
return b.Markup(markup.InlineRow(buttons...))
}
@@ -0,0 +1,59 @@
package inline
import (
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/markup"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/styling"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// MessageTextBuilder is a builder of inline result text message.
type MessageTextBuilder struct {
message *tg.InputBotInlineMessageText
options []styling.StyledTextOption
}
func (b *MessageTextBuilder) apply() (tg.InputBotInlineMessageClass, error) {
tb := entity.Builder{}
if err := styling.Perform(&tb, b.options...); err != nil {
return nil, err
}
msg, entities := tb.Complete()
r := *b.message
r.Message = msg
r.Entities = entities
return &r, nil
}
// MessageText creates new message text option builder.
func MessageText(msg string) *MessageTextBuilder {
return MessageStyledText(styling.Plain(msg))
}
// MessageStyledText creates new message text option builder.
func MessageStyledText(texts ...styling.StyledTextOption) *MessageTextBuilder {
return &MessageTextBuilder{
message: &tg.InputBotInlineMessageText{},
options: texts,
}
}
// NoWebpage sets flag to disable generation of the webpage preview.
func (b *MessageTextBuilder) NoWebpage() *MessageTextBuilder {
b.message.NoWebpage = true
return b
}
// Markup sets reply markup for sending bot buttons.
// NB: markup will not be used, if you send multiple media attachments.
func (b *MessageTextBuilder) Markup(m tg.ReplyMarkupClass) *MessageTextBuilder {
b.message.ReplyMarkup = m
return b
}
// Row sets single row keyboard markup for sending bot buttons.
// NB: markup will not be used, if you send multiple media attachments.
func (b *MessageTextBuilder) Row(buttons ...tg.KeyboardButtonClass) *MessageTextBuilder {
return b.Markup(markup.InlineRow(buttons...))
}
@@ -0,0 +1,46 @@
package inline
import (
"io"
"strconv"
"go.mau.fi/mautrix-telegram/pkg/gotd/crypto"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
type resultPageBuilder struct {
results []tg.InputBotInlineResultClass
random io.Reader
}
func (r *resultPageBuilder) generateID() (string, error) {
n, err := crypto.RandInt64(r.random)
if err != nil {
return "", err
}
return strconv.FormatInt(n, 10), nil
}
// ResultOption is an option of inline result.
type ResultOption interface {
apply(r *resultPageBuilder) error
}
// MessageOption is an option of inline result message.
type MessageOption interface {
apply() (tg.InputBotInlineMessageClass, error)
}
type messageOptionFunc func() (tg.InputBotInlineMessageClass, error)
func (m messageOptionFunc) apply() (tg.InputBotInlineMessageClass, error) {
return m()
}
// ResultMessage creates new MessageOption from given message object.
func ResultMessage(r tg.InputBotInlineMessageClass) MessageOption {
return messageOptionFunc(func() (tg.InputBotInlineMessageClass, error) {
return r, nil
})
}
+52
View File
@@ -0,0 +1,52 @@
package inline
import (
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// PhotoResultBuilder is photo result option builder.
type PhotoResultBuilder struct {
result *tg.InputBotInlineResultPhoto
msg MessageOption
}
func (b *PhotoResultBuilder) apply(r *resultPageBuilder) error {
m, err := b.msg.apply()
if err != nil {
return err
}
t := tg.InputBotInlineResultPhoto{
ID: b.result.ID,
Type: b.result.Type,
Photo: b.result.Photo,
}
if t.ID == "" {
t.ID, err = r.generateID()
if err != nil {
return err
}
}
t.SendMessage = m
r.results = append(r.results, &t)
return nil
}
// ID sets ID of result.
// Should not be empty, so if id is not provided, random will be used.
func (b *PhotoResultBuilder) ID(id string) *PhotoResultBuilder {
b.result.ID = id
return b
}
// Photo creates game result option builder.
func Photo(photo tg.InputPhotoClass, msg MessageOption) *PhotoResultBuilder {
return &PhotoResultBuilder{
result: &tg.InputBotInlineResultPhoto{
Type: PhotoType,
Photo: photo,
},
msg: msg,
}
}
@@ -0,0 +1,50 @@
package inline
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestPhoto(t *testing.T) {
ctx := context.Background()
builder, mock := testBuilder(t)
photo := &tg.InputPhoto{
ID: 10,
AccessHash: 10,
FileReference: []byte{10},
}
mock.ExpectFunc(func(b bin.Encoder) {
v, ok := b.(*tg.MessagesSetInlineBotResultsRequest)
require.True(t, ok)
require.Equal(t, int64(10), v.QueryID)
for i := range v.Results {
r, ok := v.Results[i].(*tg.InputBotInlineResultPhoto)
require.True(t, ok)
require.NotZero(t, r.ID)
require.Equal(t, photo, r.Photo)
}
}).ThenTrue()
_, err := builder.Set(ctx,
Photo(photo, MessageText("photo")),
Photo(photo, MessageText("photo")).ID("10"),
)
require.NoError(t, err)
mock.Expect().ThenRPCErr(testRPCError())
_, err = builder.Set(ctx,
Photo(photo, MessageGeo(&tg.InputGeoPoint{
Lat: 10,
Long: 42,
AccuracyRadius: 1337,
})),
Photo(photo, MessageGame()).ID("10"),
)
require.Error(t, err)
}
+45
View File
@@ -0,0 +1,45 @@
package inline
const (
// PhotoType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultphoto.
PhotoType = "photo"
// ArticleType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultarticle.
ArticleType = "article"
// VideoType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultvideo.
VideoType = "video"
// AudioType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultaudio.
AudioType = "audio"
// DocumentType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultdocument.
DocumentType = "document"
// GIFType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultgif.
GIFType = "gif"
// MPEG4GIFType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultmpeg4gif.
MPEG4GIFType = "mpeg4_gif"
// VoiceType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultvoice.
VoiceType = "voice"
// StickerType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultsticker.
StickerType = "sticker"
// LocationType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultlocation.
LocationType = "location"
// VenueType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultvenue.
VenueType = "venue"
// ContactType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultcontact.
ContactType = "contact"
// GameType is a type string of inline result.
// See https://core.telegram.org/bots/api#inlinequeryresultgame.
GameType = "game"
)
+38
View File
@@ -0,0 +1,38 @@
package message
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestBuilder_InlineResult(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
mock.ExpectFunc(func(b bin.Encoder) {
req, ok := b.(*tg.MessagesSendInlineBotResultRequest)
require.True(t, ok)
require.Equal(t, &tg.InputPeerSelf{}, req.Peer)
require.Equal(t, int64(10), req.QueryID)
require.Equal(t, "10", req.ID)
require.True(t, req.HideVia)
}).ThenResult(&tg.Updates{})
_, err := sender.Self().InlineResult(ctx, "10", 10, true)
require.NoError(t, err)
mock.ExpectFunc(func(b bin.Encoder) {
req, ok := b.(*tg.MessagesSendInlineBotResultRequest)
require.True(t, ok)
require.Equal(t, &tg.InputPeerSelf{}, req.Peer)
require.Equal(t, int64(10), req.QueryID)
require.Equal(t, "10", req.ID)
require.False(t, req.HideVia)
}).ThenRPCErr(testRPCError())
_, err = sender.Self().InlineResult(ctx, "10", 10, false)
require.Error(t, err)
}
@@ -0,0 +1,43 @@
package main
const entityTmpl = `// Code generated by mkentity, DO NOT EDIT.
package {{ $.PackageName }}
import (
"context"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
var (
_ = tg.Invoker(nil)
_ = context.Context(nil)
)
{{- /*gotype: go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/internal/mkrun.Config*/ -}}
{{- range $typ := $.Data }}
{{ $helperName := trimPrefix ( trimPrefix $typ.Name "Input" ) "MessageEntity" -}}
// {{ $helperName }} creates Formatter of {{ $helperName }} message entity.
//
// See https://core.telegram.org/constructor/{{ $typ.SchemaType.Name }}.
func {{ $helperName }}({{- range $f := $typ.Fields }}{{ lowerFirst $f.Name }} {{ $f.Type }}{{- end }}) Formatter {
return func(offset, length int) tg.MessageEntityClass {
return &tg.{{ $typ.Name }}{
Offset: offset,
Length: length,
{{- range $f := $typ.Fields }}
{{ $f.Name }}: {{ lowerFirst $f.Name }},
{{- end }}
}
}
}
// {{ $helperName }} adds and formats message as {{ $helperName }} message entity.
//
// See https://core.telegram.org/constructor/{{ $typ.SchemaType.Name }}.
func (b *Builder) {{ $helperName }}(s string,
{{- range $f := $typ.Fields }}{{ lowerFirst $f.Name }} {{ $f.Type }}{{- end }}) *Builder {
return b.Format(s, {{ $helperName }}({{- range $f := $typ.Fields }}{{ lowerFirst $f.Name }},{{- end }}))
}
{{- end }}
`
@@ -0,0 +1,103 @@
package main
import (
"flag"
"reflect"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tdp"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/internal/mkrun"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Field represents type field.
type Field struct {
// Name is Go name of field.
Name string
// Type is Go type of field.
Type string
}
// Type represents generated type.
type Type struct {
// Name is Go name of type.
Name string
// Fields is slice of type fields.
Fields []Field
// SchemaType is related schema type.
SchemaType tdp.Type
}
var (
constructors = tg.ClassConstructorsMap()
create = tg.TypesConstructorMap()
templates = map[string]string{
"entity": entityTmpl,
"styling": stylingTmpl,
}
)
type generator struct {
template string
}
func (g *generator) Name() string {
return "mkentity"
}
func (g *generator) Flags(set *flag.FlagSet) {
set.StringVar(&g.template, "template", "entity", "template to use")
}
func (g *generator) Template() string {
return templates[g.template]
}
func (g *generator) Data() (interface{}, error) {
var types []Type
for _, typeID := range constructors[tg.MessageEntityClassName] {
v, ok := create[typeID]().(tdp.Object)
if !ok {
return nil, errors.Errorf("bad type %#x", typeID)
}
schemaType := v.TypeInfo()
// Skip messageEntityMentionName because we should use inputMessageEntityMentionName.
if schemaType.Name == "messageEntityMentionName" {
continue
}
tv := reflect.TypeOf(v).Elem()
var fields []Field
for _, field := range schemaType.Fields {
// These fields set by Formatter callee.
if field.Name == "Offset" || field.Name == "Length" {
continue
}
rf, ok := tv.FieldByName(field.Name)
if !ok {
return nil, errors.Errorf(
"field of %q type %q not found",
field.Name, schemaType.Name,
)
}
fields = append(fields, Field{
Name: field.Name,
Type: rf.Type.String(),
})
}
types = append(types, Type{
Name: tv.Name(),
Fields: fields,
SchemaType: v.TypeInfo(),
})
}
return types, nil
}
func main() {
mkrun.Main(&generator{})
}
@@ -0,0 +1,31 @@
package main
const stylingTmpl = `// Code generated by mkentity, DO NOT EDIT.
package {{ $.PackageName }}
import (
"context"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
var (
_ = tg.Invoker(nil)
_ = context.Context(nil)
)
{{- /*gotype: go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/internal/mkrun.Config*/ -}}
{{- range $typ := $.Data }}
{{ $helperName := trimPrefix ( trimPrefix $typ.Name "Input" ) "MessageEntity" -}}
// {{ $helperName }} formats text as {{ $helperName }} entity.
//
// See https://core.telegram.org/constructor/{{ $typ.SchemaType.Name }}.
func {{ $helperName }}(s string,
{{- range $f := $typ.Fields }}{{ lowerFirst $f.Name }} {{ $f.Type }}{{- end }}) StyledTextOption {
return styledTextOption(s, func(b *textBuilder) error {
b.{{ $helperName }}(s, {{- range $f := $typ.Fields }}{{ lowerFirst $f.Name }},{{- end }})
return nil
})
}
{{- end }}
`
@@ -0,0 +1,15 @@
package mkrun
import "flag"
// Generator represents generator script.
type Generator interface {
// Name is generator name.
Name() string
// Flags sets generator flags.
Flags(set *flag.FlagSet)
// Template returns generation template.
Template() string
// Data returns associated generation data.
Data() (interface{}, error)
}
@@ -0,0 +1,115 @@
// Package mkrun contains some helpers for generation scripts.
package mkrun
import (
"bytes"
"flag"
"fmt"
"go/format"
"io"
"os"
"strings"
"text/template"
"time"
"unicode"
"unicode/utf8"
"github.com/go-faster/errors"
"go.uber.org/multierr"
)
// Config is generation config.
type Config struct {
PackageName string
Data interface{}
}
func generate(w io.Writer, pkgName string, g Generator) error {
start := time.Now()
data, err := g.Data()
if err != nil {
return err
}
collectInfoTime := time.Since(start)
start = time.Now()
buf := bytes.Buffer{}
t, err := template.New("gen").Funcs(template.FuncMap{
"trimPrefix": strings.TrimPrefix,
"trimSuffix": strings.TrimSuffix,
"lowerFirst": func(s string) string {
r, size := utf8.DecodeRuneInString(s)
if r == utf8.RuneError || unicode.IsLower(r) {
return s
}
return string(unicode.ToLower(r)) + s[size:]
},
}).Parse(g.Template())
if err != nil {
return errors.Errorf("parse: %w", err)
}
if err := t.Execute(&buf, Config{
PackageName: pkgName,
Data: data,
}); err != nil {
return errors.Errorf("execute: %w", err)
}
formatted, err := format.Source(buf.Bytes())
if err != nil {
_, _ = os.Stderr.Write(buf.Bytes())
return errors.Errorf("format: %w", err)
}
if _, err := w.Write(formatted); err != nil {
return errors.Errorf("write: %w", err)
}
writeTime := time.Since(start)
fmt.Printf("Generation %s complete, collect time: %s, write time: %s\n",
g.Name(),
collectInfoTime,
writeTime,
)
return nil
}
func run(g Generator) (rErr error) {
set := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
var (
o = set.String("output", "", "output file")
pkgName = set.String("package", os.Getenv("GOPACKAGE"), "package name")
)
g.Flags(set)
if err := set.Parse(os.Args[1:]); err != nil {
return errors.Wrap(err, "parse")
}
if *pkgName == "" {
if *o != "" {
return errors.New("package name is empty")
}
*pkgName = "pkg"
}
var w io.Writer = os.Stdout
if path := *o; path != "" {
f, err := os.Create(path) // #nosec G304
if err != nil {
return err
}
defer multierr.AppendInvoke(&rErr, multierr.Close(f))
w = f
}
return generate(w, *pkgName, g)
}
// Main is generation main function.
func Main(g Generator) {
if err := run(g); err != nil {
panic(err)
}
}
@@ -0,0 +1,115 @@
// Binary mktyping generates TypingActionBuilder.
package main
import (
"flag"
"reflect"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tdp"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/internal/mkrun"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Field represents type field.
type Field struct {
// Name is Go name of field.
Name string
// Type is Go type of field.
Type string
}
// Type represents generated type.
type Type struct {
// Name is Go name of type.
Name string
// Fields is slice of type fields.
Fields []Field
// SchemaType is related schema type.
SchemaType tdp.Type
}
const rawTemplate = `// Code generated by mktyping, DO NOT EDIT.
package {{ $.PackageName }}
import (
"context"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
var (
_ = tg.Invoker(nil)
_ = context.Context(nil)
)
{{- /*gotype: go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/internal/mkrun.Config*/ -}}
{{- range $typ := $.Data }}
{{ $helperName := trimSuffix (trimPrefix $typ.Name "SendMessage") "Action" -}}
// {{ $helperName }} sends {{ $typ.Name }}.
func (b *TypingActionBuilder) {{ $helperName }}(ctx context.Context,
{{- range $f := $typ.Fields }}{{ lowerFirst $f.Name }} {{ $f.Type }},{{ end }}) error {
return b.send(ctx, &tg.{{ $typ.Name }}{
{{- range $f := $typ.Fields }}
{{ $f.Name }}: {{ lowerFirst $f.Name }},
{{- end }}
})
}
{{- end }}
`
var (
constructors = tg.ClassConstructorsMap()
create = tg.TypesConstructorMap()
)
type generator struct{}
func (g generator) Name() string {
return "mktyping"
}
func (g generator) Flags(set *flag.FlagSet) {}
func (g generator) Template() string {
return rawTemplate
}
func (g generator) Data() (interface{}, error) {
var types []Type
for _, typeID := range constructors[tg.SendMessageActionClassName] {
v, ok := create[typeID]().(tdp.Object)
if !ok {
return nil, errors.Errorf("bad type %#x", typeID)
}
schemaType := v.TypeInfo()
tv := reflect.TypeOf(v).Elem()
var fields []Field
for _, field := range schemaType.Fields {
rf, ok := tv.FieldByName(field.Name)
if !ok {
return nil, errors.Errorf(
"field of %q type %q not found",
field.Name, schemaType.Name,
)
}
fields = append(fields, Field{
Name: field.Name,
Type: rf.Type.String(),
})
}
types = append(types, Type{
Name: tv.Name(),
Fields: fields,
SchemaType: v.TypeInfo(),
})
}
return types, nil
}
func main() {
mkrun.Main(generator{})
}
+98
View File
@@ -0,0 +1,98 @@
package message
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/internal/deeplink"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/peer"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
)
// JoinLink joins to private chat using given link or hash.
// Input examples:
//
// t.me/+AAAAAAAAAAAAAAAA
// https://t.me/+AAAAAAAAAAAAAAAA
// t.me/joinchat/AAAAAAAAAAAAAAAA
// https://t.me/joinchat/AAAAAAAAAAAAAAAA
// tg:join?invite=AAAAAAAAAAAAAAAA
// tg://join?invite=AAAAAAAAAAAAAAAA
func (s *Sender) JoinLink(ctx context.Context, link string) (tg.UpdatesClass, error) {
hash := link
if deeplink.IsDeeplinkLike(link) {
l, err := deeplink.Expect(link, deeplink.Join)
if err != nil {
return nil, err
}
hash = l.Args.Get("invite")
}
return s.JoinHash(ctx, hash)
}
// JoinHash joins to private chat using given hash.
func (s *Sender) JoinHash(ctx context.Context, hash string) (tg.UpdatesClass, error) {
return s.importChatInvite(ctx, hash)
}
// Join joins resolved channel.
// NB: if resolved peer is not a channel, error will be returned.
func (b *RequestBuilder) Join(ctx context.Context) (tg.UpdatesClass, error) {
p, err := b.peer(ctx)
if err != nil {
return nil, errors.Wrap(err, "peer")
}
input, ok := peer.ToInputChannel(p)
if !ok {
return nil, errors.Errorf("unexpected type %T", p)
}
return b.sender.joinChannel(ctx, input)
}
func (b *RequestBuilder) leave(ctx context.Context, revoke bool) (tg.UpdatesClass, error) {
p, err := b.peer(ctx)
if err != nil {
return nil, errors.Wrap(err, "peer")
}
input, ok := peer.ToInputChannel(p)
if ok {
r, err := b.sender.leaveChannel(ctx, input)
if err != nil {
return nil, errors.Wrap(err, "leave channel")
}
return r, nil
}
chat, ok := p.(*tg.InputPeerChat)
if !ok {
return &tg.Updates{}, nil
}
r, err := b.sender.deleteChatUser(ctx, &tg.MessagesDeleteChatUserRequest{
RevokeHistory: revoke,
ChatID: chat.ChatID,
UserID: &tg.InputUserSelf{},
})
if err != nil {
// Happens if chat was deactivated.
if tgerr.Is(err, tg.ErrPeerIDInvalid) {
return &tg.Updates{}, nil
}
return nil, errors.Wrap(err, "leave chat")
}
return r, nil
}
// Leave leaves resolved peer.
//
// NB: if resolved peer is not a channel or chat, or chat is deactivated, empty *tg.Updates will be returned.
func (b *RequestBuilder) Leave(ctx context.Context) (tg.UpdatesClass, error) {
return b.leave(ctx, false)
}
@@ -0,0 +1,40 @@
package message_test
import (
"context"
"fmt"
"os"
"os/signal"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func sendJoin(ctx context.Context) error {
client, err := telegram.ClientFromEnvironment(telegram.Options{})
if err != nil {
return err
}
return client.Run(ctx, func(ctx context.Context) error {
sender := message.NewSender(tg.NewClient(client))
// Join to private chat by link.
if _, err := sender.JoinLink(ctx, "https://t.me/+aBCdeFG123AAAAAA"); err != nil {
return err
}
return nil
})
}
func ExampleSender_JoinLink() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := sendJoin(ctx); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%+v\n", err)
os.Exit(2)
}
}
+112
View File
@@ -0,0 +1,112 @@
package message
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestSender_JoinLink(t *testing.T) {
formats := []struct {
fmt string
wantErr bool
}{
{`t.me/+%s`, false},
{`t.me/+%s/`, false},
{`https://t.me/+%s`, false},
{`https://t.me/+%s/`, false},
{`t.me/joinchat/%s`, false},
{`t.me/joinchat/%s/`, false},
{`https://t.me/joinchat/%s`, false},
{`https://t.me/joinchat/%s/`, false},
{`tg:join?invite=%s`, false},
{`tg://join?invite=%s`, false},
}
inputs := []struct {
value string
wantErr bool
}{
{"AAAAAAAAAAAAAAAAAA", false},
{"", true},
}
for _, format := range formats {
for _, input := range inputs {
link := fmt.Sprintf(format.fmt, input.value)
t.Run(link, func(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
wantErr := format.wantErr || input.wantErr
if !wantErr {
mock.ExpectCall(&tg.MessagesImportChatInviteRequest{
Hash: input.value,
}).ThenResult(&tg.Updates{})
}
_, err := sender.JoinLink(ctx, link)
if wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
}
func TestRequestBuilder_Join(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
peer := &tg.InputPeerChannel{
ChannelID: 10,
AccessHash: 10,
}
mock.ExpectCall(&tg.ChannelsJoinChannelRequest{
Channel: &tg.InputChannel{
ChannelID: 10,
AccessHash: 10,
},
}).ThenResult(&tg.Updates{})
_, err := sender.To(peer).Join(ctx)
require.NoError(t, err)
mock.ExpectCall(&tg.ChannelsJoinChannelRequest{
Channel: &tg.InputChannel{
ChannelID: 10,
AccessHash: 10,
},
}).ThenRPCErr(testRPCError())
_, err = sender.To(peer).Join(ctx)
require.Error(t, err)
}
func TestRequestBuilder_Leave(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
peer := &tg.InputPeerChannel{
ChannelID: 10,
AccessHash: 10,
}
ch := &tg.InputChannel{
ChannelID: peer.ChannelID,
AccessHash: peer.AccessHash,
}
mock.ExpectCall(&tg.ChannelsLeaveChannelRequest{
Channel: ch,
}).ThenResult(&tg.Updates{})
_, err := sender.To(peer).Leave(ctx)
require.NoError(t, err)
mock.ExpectCall(&tg.ChannelsLeaveChannelRequest{
Channel: ch,
}).ThenRPCErr(testRPCError())
_, err = sender.To(peer).Leave(ctx)
require.Error(t, err)
}
@@ -0,0 +1,44 @@
package message_test
import (
"context"
"fmt"
"os"
"os/signal"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/markup"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func sendKeyboard(ctx context.Context) error {
client, err := telegram.ClientFromEnvironment(telegram.Options{})
if err != nil {
return err
}
return client.Run(ctx, func(ctx context.Context) error {
sender := message.NewSender(tg.NewClient(client))
// Uploads and sends keyboard result to the @durovschat.
if _, err := sender.Resolve("@durovschat").Row(
markup.URL("Blue", "https://github.com/xelaj/mtproto"),
markup.URL("Red", "https://go.mau.fi/mautrix-telegram/pkg/gotd"),
).Text(ctx, "Choose the pill"); err != nil {
return err
}
return nil
})
}
func ExampleKeyboard() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := sendKeyboard(ctx); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%+v\n", err)
os.Exit(2)
}
}
+155
View File
@@ -0,0 +1,155 @@
package markup
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
// Row creates keyboard row.
func Row(buttons ...tg.KeyboardButtonClass) tg.KeyboardButtonRow {
return tg.KeyboardButtonRow{
Buttons: buttons,
}
}
// Button creates new plain text button.
func Button(text string) *tg.KeyboardButton {
return &tg.KeyboardButton{
Text: text,
}
}
// URL creates new URL button.
func URL(text, url string) *tg.KeyboardButtonURL {
return &tg.KeyboardButtonURL{
Text: text,
URL: url,
}
}
// Callback creates new callback button.
func Callback(text string, data []byte) *tg.KeyboardButtonCallback {
return &tg.KeyboardButtonCallback{
Text: text,
Data: data,
}
}
// RequestPhone creates button to request a user's phone number.
func RequestPhone(text string) *tg.KeyboardButtonRequestPhone {
return &tg.KeyboardButtonRequestPhone{
Text: text,
}
}
// RequestGeoLocation creates button to request a user's geo location.
func RequestGeoLocation(text string) *tg.KeyboardButtonRequestGeoLocation {
return &tg.KeyboardButtonRequestGeoLocation{
Text: text,
}
}
// SwitchInline creates button to force a user to switch to inline mode.
// Pressing the button will prompt the user to select one of their chats, open that chat and insert the bots username
// and the specified inline query in the input field.
//
// If samePeer set, pressing the button will insert the bots
// username and the specified inline query in the current chat's input field.
func SwitchInline(text, query string, samePeer bool) *tg.KeyboardButtonSwitchInline {
return &tg.KeyboardButtonSwitchInline{
SamePeer: samePeer,
Text: text,
Query: query,
}
}
// Game creates button to start a game.
func Game(text string) *tg.KeyboardButtonGame {
return &tg.KeyboardButtonGame{
Text: text,
}
}
// Buy creates button to buy a product.
func Buy(text string) *tg.KeyboardButtonBuy {
return &tg.KeyboardButtonBuy{
Text: text,
}
}
// InputURLAuth creates button to request a user to authorize via URL using Seamless Telegram Login.
// Can only be sent or received as part of an inline keyboard, use URLAuth for reply keyboards.
func InputURLAuth(requestWriteAccess bool, text, fwdText, url string, bot tg.InputUserClass) *tg.InputKeyboardButtonURLAuth {
return &tg.InputKeyboardButtonURLAuth{
RequestWriteAccess: requestWriteAccess,
Text: text,
FwdText: fwdText,
URL: url,
Bot: bot,
}
}
// URLAuth creates button to request a user to authorize via URL using Seamless Telegram Login.
// Can only be sent or received as part of a reply keyboard, use InputURLAuth for inline keyboards.
func URLAuth(text, url string, buttonID int, fwdText string) *tg.KeyboardButtonURLAuth {
return &tg.KeyboardButtonURLAuth{
Text: text,
URL: url,
ButtonID: buttonID,
FwdText: fwdText,
}
}
// RequestPoll creates button that allows the user to create and send a poll when pressed.
// Available only in private.
func RequestPoll(text string, quiz bool) *tg.KeyboardButtonRequestPoll {
return &tg.KeyboardButtonRequestPoll{
Text: text,
Quiz: quiz,
}
}
// InputUserProfile creates button that links directly to a user profile.
// Can only be sent or received as part of an inline keyboard, use UserProfile for reply keyboards.
func InputUserProfile(text string, user tg.InputUserClass) *tg.InputKeyboardButtonUserProfile {
return &tg.InputKeyboardButtonUserProfile{
Text: text,
UserID: user,
}
}
// UserProfile creates button that links directly to a user profile.
// Can only be sent or received as part of a reply keyboard, use InputUserProfile for inline keyboards.
func UserProfile(text string, userID int64) *tg.KeyboardButtonUserProfile {
return &tg.KeyboardButtonUserProfile{
Text: text,
UserID: userID,
}
}
// WebView creates button to open a bot web app using messages.requestWebView, sending over user information after
// user confirmation.
// Can only be sent or received as part of an inline keyboard, use SimpleWebView for reply keyboards.
func WebView(text, url string) *tg.KeyboardButtonWebView {
return &tg.KeyboardButtonWebView{
Text: text,
URL: url,
}
}
// SimpleWebView creates button to open a bot web app using messages.requestSimpleWebView, without sending user
// information to the web app.
// Can only be sent or received as part of a reply keyboard, use WebView for inline keyboards.
func SimpleWebView(text, url string) *tg.KeyboardButtonSimpleWebView {
return &tg.KeyboardButtonSimpleWebView{
Text: text,
URL: url,
}
}
// RequestPeer creates button that prompts the user to select and share a peer with the bot using
// messages.sendBotRequestedPeer.
func RequestPeer(text string, buttonID int, peerType tg.RequestPeerTypeClass) *tg.KeyboardButtonRequestPeer {
return &tg.KeyboardButtonRequestPeer{
Text: text,
ButtonID: buttonID,
PeerType: peerType,
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package markup contain bots inline markup builder.
package markup
@@ -0,0 +1,11 @@
package markup
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
// ForceReply creates markup to force the user to send a reply.
func ForceReply(singleUse, selective bool) tg.ReplyMarkupClass {
return &tg.ReplyKeyboardForceReply{
SingleUse: singleUse,
Selective: selective,
}
}
+23
View File
@@ -0,0 +1,23 @@
package markup
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
// Hide creates markup to hide markup.
func Hide() tg.ReplyMarkupClass {
return &tg.ReplyKeyboardHide{}
}
// SelectiveHide creates markup to hide markup.
// Use this builder if you want to remove the keyboard for specific users only.
// Targets:
// 1. users that are @mentioned in the text of the Message object;
// 2. if the bot's message is a reply (has reply_to_message_id), sender of the original message.
//
// Example: A user votes in a poll, bot returns confirmation message in reply to the vote
// and removes the keyboard for that user, while still showing the keyboard with poll
// options to users who haven't voted yet.
func SelectiveHide() tg.ReplyMarkupClass {
return &tg.ReplyKeyboardHide{
Selective: true,
}
}
@@ -0,0 +1,19 @@
package markup
import (
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestHide(t *testing.T) {
v, ok := Hide().(*tg.ReplyKeyboardHide)
require.True(t, ok)
require.False(t, v.Selective)
v, ok = SelectiveHide().(*tg.ReplyKeyboardHide)
require.True(t, ok)
require.True(t, v.Selective)
}
@@ -0,0 +1,15 @@
package markup
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
// InlineRow creates inline keyboard with single row using given buttons.
func InlineRow(buttons ...tg.KeyboardButtonClass) tg.ReplyMarkupClass {
return InlineKeyboard(Row(buttons...))
}
// InlineKeyboard creates inline keyboard using given rows.
func InlineKeyboard(rows ...tg.KeyboardButtonRow) tg.ReplyMarkupClass {
return &tg.ReplyInlineMarkup{
Rows: rows,
}
}
@@ -0,0 +1,37 @@
package markup
import (
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestInlineRow(t *testing.T) {
a := require.New(t)
buttons := []tg.KeyboardButtonClass{
Button("gotd"),
URL("Google!", "https://google.com?q=gotd"),
RequestPhone("phone"),
RequestGeoLocation("geo"),
SwitchInline("inline", "query", true),
Game("game"),
Buy("buy"),
InputURLAuth(false, "text", "fwdText", "url", &tg.InputUserSelf{}),
RequestPoll("poll", true),
InputUserProfile("me", &tg.InputUserSelf{}),
WebView("demo", "https://webappcontent.telegram.org/demo"),
}
v, ok := InlineRow(buttons...).(*tg.ReplyInlineMarkup)
a.True(ok)
a.Len(v.Rows, 1)
row := v.Rows[0]
a.Len(row.Buttons, len(buttons))
for i, b := range buttons {
a.Equal(b, row.Buttons[i])
}
}
@@ -0,0 +1,62 @@
package markup
import (
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// ReplyKeyboardMarkupBuilder is a keyboard markup builder.
type ReplyKeyboardMarkupBuilder struct {
kb tg.ReplyKeyboardMarkup
}
// Resize sets flag to request clients to resize the keyboard vertically for
// optimal fit (e.g., make the keyboard smaller if there are just two rows of buttons).
// If not set, the custom keyboard is always of the same height as the app's standard keyboard.
func (b *ReplyKeyboardMarkupBuilder) Resize() *ReplyKeyboardMarkupBuilder {
b.kb.Resize = true
return b
}
// SingleUse sets flag to request clients to hide the keyboard as soon as it's been used.
// The keyboard will still be available, but clients will automatically display the usual letter-keyboard
// in the chat the user can press a special button in the input field to see the custom keyboard again.
func (b *ReplyKeyboardMarkupBuilder) SingleUse() *ReplyKeyboardMarkupBuilder {
b.kb.SingleUse = true
return b
}
// Selective sets flag to show the keyboard to specific users only.
// Targets:
// 1. users that are @mentioned in the text of the Message object;
// 2. if the bot's message is a reply (has reply_to_message_id), sender of the original message.
//
// Example: A user requests to change the bots language, bot replies to the request
// with a keyboard to select the new language.
// Other users in the group dont see the keyboard.
func (b *ReplyKeyboardMarkupBuilder) Selective() *ReplyKeyboardMarkupBuilder {
b.kb.Selective = true
return b
}
// Build returns created keyboard.
func (b *ReplyKeyboardMarkupBuilder) Build(rows ...tg.KeyboardButtonRow,
) tg.ReplyMarkupClass {
cp := b.kb
cp.Rows = rows
return &cp
}
// BuildKeyboard creates keyboard builder.
func BuildKeyboard() *ReplyKeyboardMarkupBuilder {
return &ReplyKeyboardMarkupBuilder{}
}
// SingleRow creates keyboard with single row using given buttons.
func SingleRow(buttons ...tg.KeyboardButtonClass) tg.ReplyMarkupClass {
return Keyboard(Row(buttons...))
}
// Keyboard creates keyboard using given rows.
func Keyboard(rows ...tg.KeyboardButtonRow) tg.ReplyMarkupClass {
return BuildKeyboard().Build(rows...)
}
@@ -0,0 +1,38 @@
package markup
import (
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestSingleRow(t *testing.T) {
a := require.New(t)
buttons := []tg.KeyboardButtonClass{
Button("gotd"),
URL("Google!", "https://google.com?q=gotd"),
RequestPhone("phone"),
RequestGeoLocation("geo"),
SwitchInline("inline", "query", true),
Game("game"),
Buy("buy"),
URLAuth("text", "url", 1, "fwd"),
RequestPoll("poll", true),
UserProfile("BotFather", 93372553),
SimpleWebView("demo", "https://webappcontent.telegram.org/demo"),
RequestPeer("peer", 0, &tg.RequestPeerTypeUser{}),
}
v, ok := SingleRow(buttons...).(*tg.ReplyKeyboardMarkup)
a.True(ok)
a.Len(v.Rows, 1)
row := v.Rows[0]
a.Len(row.Buttons, len(buttons))
for i, b := range buttons {
a.Equal(b, row.Buttons[i])
}
}
+171
View File
@@ -0,0 +1,171 @@
package message
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/entity"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/styling"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func performTextOptions(media *tg.InputSingleMedia, opts []StyledTextOption) error {
if len(opts) > 0 {
tb := entity.Builder{}
if err := styling.Perform(&tb, opts...); err != nil {
return err
}
media.Message, media.Entities = tb.Complete()
}
return nil
}
// Media adds given media attachment to message.
func Media(media tg.InputMediaClass, caption ...StyledTextOption) MediaOption {
return mediaOptionFunc(func(ctx context.Context, b *multiMediaBuilder) error {
singleMedia := tg.InputSingleMedia{
Media: media,
}
if err := performTextOptions(&singleMedia, caption); err != nil {
return err
}
b.media = append(b.media, singleMedia)
return nil
})
}
// Album sends message with multiple media attachments.
func (b *Builder) Album(ctx context.Context, media MultiMediaOption, album ...MultiMediaOption) (tg.UpdatesClass, error) {
p, err := b.peer(ctx)
if err != nil {
return nil, errors.Wrap(err, "peer")
}
if len(album) < 1 {
return b.Media(ctx, media)
}
mb, err := b.applyMedia(ctx, p, media, album...)
if err != nil {
return nil, err
}
upd, err := b.sender.sendMultiMedia(ctx, &tg.MessagesSendMultiMediaRequest{
Silent: b.silent,
Background: b.background,
ClearDraft: b.clearDraft,
Peer: p,
ReplyTo: b.replyTo,
MultiMedia: mb,
ScheduleDate: b.scheduleDate,
})
if err != nil {
return nil, errors.Wrap(err, "send album")
}
return upd, nil
}
func (b *Builder) applyMedia(
ctx context.Context,
p tg.InputPeerClass,
media MultiMediaOption, album ...MultiMediaOption,
) ([]tg.InputSingleMedia, error) {
mb := multiMediaBuilder{
sender: b.sender,
peer: p,
media: make([]tg.InputSingleMedia, 0, len(album)+1),
}
if err := media.applyMulti(ctx, &mb); err != nil {
return nil, errors.Wrap(err, "media first option")
}
if len(album) > 0 {
for i, opt := range album {
if err := opt.applyMulti(ctx, &mb); err != nil {
return nil, errors.Wrapf(err, "media option %d", i+2)
}
}
}
return mb.media, nil
}
// Media sends message with media attachment.
func (b *Builder) Media(ctx context.Context, media MediaOption) (tg.UpdatesClass, error) {
p, err := b.peer(ctx)
if err != nil {
return nil, errors.Wrap(err, "peer")
}
attachment, err := b.applySingleMedia(ctx, p, media)
if err != nil {
return nil, err
}
upd, err := b.sender.sendMedia(ctx, &tg.MessagesSendMediaRequest{
Silent: b.silent,
Background: b.background,
ClearDraft: b.clearDraft,
Peer: p,
ReplyTo: b.replyTo,
Media: attachment.Media,
Message: attachment.Message,
ReplyMarkup: b.replyMarkup,
Entities: attachment.Entities,
ScheduleDate: b.scheduleDate,
})
if err != nil {
return nil, errors.Wrap(err, "send media")
}
return upd, nil
}
func (b *Builder) applySingleMedia(
ctx context.Context,
p tg.InputPeerClass,
media MediaOption,
) (tg.InputSingleMedia, error) {
mb := multiMediaBuilder{
sender: b.sender,
peer: p,
media: make([]tg.InputSingleMedia, 0, 1),
}
if err := media.apply(ctx, &mb); err != nil {
return tg.InputSingleMedia{}, errors.Wrap(err, "media first option")
}
return mb.media[0], nil
}
// UploadMedia uses messages.uploadMedia to upload a file and associate it to
// a chat (without actually sending it to the chat).
//
// See https://core.telegram.org/method/messages.uploadMedia.
func (b *Builder) UploadMedia(ctx context.Context, media MediaOption) (tg.MessageMediaClass, error) {
p, err := b.peer(ctx)
if err != nil {
return nil, errors.Wrap(err, "peer")
}
attachment, err := b.applySingleMedia(ctx, p, media)
if err != nil {
return nil, err
}
r, err := b.sender.uploadMedia(ctx, &tg.MessagesUploadMediaRequest{
Peer: p,
Media: attachment.Media,
})
if err != nil {
return nil, errors.Wrap(err, "upload media")
}
return r, nil
}
+77
View File
@@ -0,0 +1,77 @@
package message
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestBuilder_Album(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
loc := &tg.InputPhoto{
ID: 10,
}
mock.ExpectFunc(func(b bin.Encoder) {
req, ok := b.(*tg.MessagesSendMultiMediaRequest)
require.True(t, ok)
require.Equal(t, &tg.InputPeerSelf{}, req.Peer)
require.Len(t, req.MultiMedia, 2)
for i := range req.MultiMedia {
require.Equal(t, req.MultiMedia[i].Media, &tg.InputMediaPhoto{ID: loc})
require.NotZero(t, req.MultiMedia[i].RandomID)
}
}).ThenResult(&tg.Updates{})
_, err := sender.Self().Album(ctx, Photo(loc), Photo(loc))
require.NoError(t, err)
doc := &tg.InputDocument{
ID: 10,
}
mock.ExpectFunc(func(b bin.Encoder) {
req, ok := b.(*tg.MessagesSendMultiMediaRequest)
require.True(t, ok)
require.Equal(t, &tg.InputPeerSelf{}, req.Peer)
require.Len(t, req.MultiMedia, 2)
for i := range req.MultiMedia {
require.Equal(t, req.MultiMedia[i].Media, &tg.InputMediaDocument{ID: doc})
require.NotZero(t, req.MultiMedia[i].RandomID)
}
}).ThenResult(&tg.Updates{})
_, err = sender.Self().Album(ctx, Document(doc), Document(doc))
require.NoError(t, err)
}
func TestBuilder_UploadMedia(t *testing.T) {
ctx := context.Background()
sender, mock := testSender(t)
file := &tg.InputFile{
ID: 10,
}
expected := &tg.MessageMediaEmpty{}
mock.ExpectCall(&tg.MessagesUploadMediaRequest{
Peer: &tg.InputPeerSelf{},
Media: &tg.InputMediaUploadedPhoto{
File: file,
},
}).ThenResult(expected)
r, err := sender.Self().UploadMedia(ctx, UploadedPhoto(file))
require.NoError(t, err)
require.Equal(t, expected, r)
mock.ExpectCall(&tg.MessagesUploadMediaRequest{
Peer: &tg.InputPeerSelf{},
Media: &tg.InputMediaUploadedPhoto{
File: file,
},
}).ThenRPCErr(testRPCError())
_, err = sender.Self().UploadMedia(ctx, UploadedPhoto(file))
require.Error(t, err)
}

Some files were not shown because too many files have changed in this diff Show More