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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package message contains some useful utilities for creating Telegram messages.
|
||||
package message
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package entity contains message formatting and styling helpers.
|
||||
package entity
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package message
|
||||
|
||||
import "fmt"
|
||||
|
||||
func formatMessage(msg string, args ...interface{}) string {
|
||||
return fmt.Sprintf(msg, args...)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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><</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: "�", wantErr: true},
|
||||
{html: "�", wantErr: true},
|
||||
{html: "�", wantErr: true},
|
||||
{html: "🏟 🏟<<abacaba", wantErr: true},
|
||||
{html: "🏟 🏟<<abac aba>", wantErr: true},
|
||||
{html: "🏟 🏟<<abac>", wantErr: true},
|
||||
{html: "🏟 🏟<<i =aba>", wantErr: true},
|
||||
{html: "🏟 🏟<<i aba>", wantErr: true},
|
||||
{html: "🏟 🏟<<i aba = ", wantErr: true},
|
||||
{html: "🏟 🏟<<i aba = 190azAz-.,", wantErr: true},
|
||||
{html: "🏟 🏟<<i aba = \"<>">", wantErr: true},
|
||||
{html: "🏟 🏟<<i aba = \\'<>">", wantErr: true},
|
||||
{html: "🏟 🏟<</", wantErr: true},
|
||||
{html: "🏟 🏟<<b></b></", wantErr: true},
|
||||
{html: "🏟 🏟<<i>a</i ", wantErr: true},
|
||||
{html: "🏟 🏟<<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",
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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, ""},
|
||||
{
|
||||
"<>&"«»�",
|
||||
"<>&\"«»�",
|
||||
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>🏟 <🏟</i>",
|
||||
"🏟 🏟🏟 <🏟",
|
||||
entities(&tg.MessageEntityItalic{Offset: 5, Length: 6}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<i>🏟 ><b aba = caba><🏟</b></i>",
|
||||
"🏟 🏟🏟 ><🏟",
|
||||
entities(
|
||||
&tg.MessageEntityItalic{Offset: 5, Length: 7},
|
||||
&tg.MessageEntityBold{Offset: 9, Length: 3},
|
||||
),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<i aba = 190azAz-. >a</i>",
|
||||
"🏟 🏟<a",
|
||||
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<i aba = 190azAz-.>a</i>",
|
||||
"🏟 🏟<a",
|
||||
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<i aba = \"<>"\">a</i>",
|
||||
"🏟 🏟<a",
|
||||
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<i aba = '<>"'>a</i>",
|
||||
"🏟 🏟<a",
|
||||
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<i aba = '<>"'>a</>",
|
||||
"🏟 🏟<a",
|
||||
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<i>🏟 🏟<</>",
|
||||
"🏟 🏟<🏟 🏟<",
|
||||
entities(&tg.MessageEntityItalic{Offset: 6, Length: 6}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
|
||||
{
|
||||
"🏟 🏟<<i>a</ >",
|
||||
"🏟 🏟<a",
|
||||
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<i>a</i >",
|
||||
"🏟 🏟<a",
|
||||
entities(&tg.MessageEntityItalic{Offset: 6, Length: 1}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
// Empty entity.
|
||||
{
|
||||
"🏟 🏟<<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\">🏟 <🏟</span>",
|
||||
"🏟 🏟🏟 <🏟",
|
||||
entities(&tg.MessageEntitySpoiler{Offset: 5, Length: 6}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<span class=\"tg-spoiler\">🏟 ><b aba = caba><🏟</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>🏟 <🏟</tg-spoiler>",
|
||||
"🏟 🏟🏟 <🏟",
|
||||
entities(&tg.MessageEntitySpoiler{Offset: 5, Length: 6}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<tg-spoiler>🏟 ><b aba = caba><🏟</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?<' > </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
|
||||
{
|
||||
"🏟 🏟<<pre >🏟 🏟<</>",
|
||||
"🏟 🏟<🏟 🏟<",
|
||||
entities(&tg.MessageEntityPre{Offset: 6, Length: 6}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<code >🏟 🏟<</>",
|
||||
"🏟 🏟<🏟 🏟<",
|
||||
entities(&tg.MessageEntityCode{Offset: 6, Length: 6}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<pre><code>🏟 🏟<</code></>",
|
||||
"🏟 🏟<🏟 🏟<",
|
||||
entities(
|
||||
&tg.MessageEntityPre{Offset: 6, Length: 6},
|
||||
&tg.MessageEntityCode{Offset: 6, Length: 6},
|
||||
),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<pre><code class=\"language-\">🏟 🏟<</code></>",
|
||||
"🏟 🏟<🏟 🏟<",
|
||||
entities(
|
||||
&tg.MessageEntityPre{Offset: 6, Length: 6},
|
||||
&tg.MessageEntityCode{Offset: 6, Length: 6},
|
||||
),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<pre><code class=\"language-fift\">🏟 🏟<</></>",
|
||||
"🏟 🏟<🏟 🏟<",
|
||||
entities(&tg.MessageEntityPre{Offset: 6, Length: 6, Language: "fift"}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<code class=\"language-fift\"><pre>🏟 🏟<</></>",
|
||||
"🏟 🏟<🏟 🏟<",
|
||||
entities(&tg.MessageEntityPre{Offset: 6, Length: 6, Language: "fift"}),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<pre><code class=\"language-fift\">🏟 🏟<</> </>",
|
||||
"🏟 🏟<🏟 🏟< ",
|
||||
entities(
|
||||
&tg.MessageEntityPre{Offset: 6, Length: 7},
|
||||
&tg.MessageEntityCode{Offset: 6, Length: 6},
|
||||
),
|
||||
false,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"🏟 🏟<<pre> <code class=\"language-fift\">🏟 🏟<</></>",
|
||||
"🏟 🏟< 🏟 🏟<",
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package html
|
||||
|
||||
import "unicode/utf8"
|
||||
|
||||
// unescapeEntity reads an entity like "<" 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", "🏳", string(rune(127987))},
|
||||
{"UnicodeFlag", "🏳", string(rune(127987))},
|
||||
{"UnicodeFlagHex", "🏳", string(rune(0x1f3f3))},
|
||||
{"UnicodeFlagHex", "🏳", string(rune(0x1f3f3))},
|
||||
{"lt", "<", "<"},
|
||||
{"lt", "<", "<"},
|
||||
{"gt", ">", ">"},
|
||||
{"gt", ">", ">"},
|
||||
{"amp", "&", "&"},
|
||||
{"amp", "&", "&"},
|
||||
{"quot", """, `"`},
|
||||
{"quot", """, `"`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, []byte(tt.want), telegramUnescape([]byte(tt.b)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
// don‘t support pagination. Offset length can’t 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
|
||||
// don‘t support pagination. Offset length can’t 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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 bot‘s username
|
||||
// and the specified inline query in the input field.
|
||||
//
|
||||
// If samePeer set, pressing the button will insert the bot‘s
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 bot‘s language, bot replies to the request
|
||||
// with a keyboard to select the new language.
|
||||
// Other users in the group don’t 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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user