Add /vcard command
This commit is contained in:
parent
75f0532193
commit
8663a29e15
|
@ -15,7 +15,7 @@ import (
|
||||||
goxmpp "gosrc.io/xmpp"
|
goxmpp "gosrc.io/xmpp"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version string = "1.5.0"
|
var version string = "1.6.0-dev"
|
||||||
var commit string
|
var commit string
|
||||||
|
|
||||||
var sm *goxmpp.StreamManager
|
var sm *goxmpp.StreamManager
|
||||||
|
|
|
@ -64,6 +64,7 @@ var chatCommands = map[string]command{
|
||||||
"silent": command{"message", "send a message without sound"},
|
"silent": command{"message", "send a message without sound"},
|
||||||
"schedule": command{"{online | 2006-01-02T15:04:05 | 15:04:05} message", "schedules a message either to timestamp or to whenever the user goes online"},
|
"schedule": command{"{online | 2006-01-02T15:04:05 | 15:04:05} message", "schedules a message either to timestamp or to whenever the user goes online"},
|
||||||
"forward": command{"message_id target_chat", "forwards a message"},
|
"forward": command{"message_id target_chat", "forwards a message"},
|
||||||
|
"vcard": command{"", "print vCard as text"},
|
||||||
"add": command{"@username", "add @username to your chat list"},
|
"add": command{"@username", "add @username to your chat list"},
|
||||||
"join": command{"https://t.me/invite_link", "join to chat via invite link or @publicname"},
|
"join": command{"https://t.me/invite_link", "join to chat via invite link or @publicname"},
|
||||||
"group": command{"title", "create groupchat «title» with current user"},
|
"group": command{"title", "create groupchat «title» with current user"},
|
||||||
|
@ -172,6 +173,10 @@ func rawCmdArguments(cmdline string, start uint8) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func keyValueString(key, value string) string {
|
||||||
|
return fmt.Sprintf("%s: %s", key, value)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) unsubscribe(chatID int64) error {
|
func (c *Client) unsubscribe(chatID int64) error {
|
||||||
return gateway.SendPresence(
|
return gateway.SendPresence(
|
||||||
c.xmpp,
|
c.xmpp,
|
||||||
|
@ -636,6 +641,21 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool)
|
||||||
c.ProcessIncomingMessage(targetChatId, message)
|
c.ProcessIncomingMessage(targetChatId, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// print vCard
|
||||||
|
case "vcard":
|
||||||
|
info, err := c.GetVcardInfo(chatID)
|
||||||
|
if err != nil {
|
||||||
|
return err.Error(), true
|
||||||
|
}
|
||||||
|
_, link := c.PermastoreFile(info.Photo, true)
|
||||||
|
entries := []string{
|
||||||
|
keyValueString("Chat title", info.Fn),
|
||||||
|
keyValueString("Photo", link),
|
||||||
|
keyValueString("Username", info.Nickname),
|
||||||
|
keyValueString("Full name", info.Given + " " + info.Family),
|
||||||
|
keyValueString("Phone number", info.Tel),
|
||||||
|
}
|
||||||
|
return strings.Join(entries, "\n"), true
|
||||||
// add @contact
|
// add @contact
|
||||||
case "add":
|
case "add":
|
||||||
return c.cmdAdd(args), true
|
return c.cmdAdd(args), true
|
||||||
|
|
|
@ -24,6 +24,16 @@ import (
|
||||||
"github.com/zelenin/go-tdlib/client"
|
"github.com/zelenin/go-tdlib/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type VCardInfo struct {
|
||||||
|
Fn string
|
||||||
|
Photo *client.File
|
||||||
|
Nickname string
|
||||||
|
Given string
|
||||||
|
Family string
|
||||||
|
Tel string
|
||||||
|
Info string
|
||||||
|
}
|
||||||
|
|
||||||
var errOffline = errors.New("TDlib instance is offline")
|
var errOffline = errors.New("TDlib instance is offline")
|
||||||
|
|
||||||
var spaceRegex = regexp.MustCompile(`\s+`)
|
var spaceRegex = regexp.MustCompile(`\s+`)
|
||||||
|
@ -207,7 +217,7 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o
|
||||||
|
|
||||||
var photo string
|
var photo string
|
||||||
if chat != nil && chat.Photo != nil {
|
if chat != nil && chat.Photo != nil {
|
||||||
file, path, err := c.OpenPhotoFile(chat.Photo.Small, 1)
|
file, path, err := c.ForceOpenFile(chat.Photo.Small, 1)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
|
@ -408,6 +418,20 @@ func (c *Client) formatForward(fwd *client.MessageForwardInfo) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) formatFile(file *client.File, compact bool) (string, string) {
|
func (c *Client) formatFile(file *client.File, compact bool) (string, string) {
|
||||||
|
if file == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
src, link := c.PermastoreFile(file, false)
|
||||||
|
|
||||||
|
if compact {
|
||||||
|
return link, link
|
||||||
|
} else {
|
||||||
|
return fmt.Sprintf("%s (%v kbytes) | %s", filepath.Base(src), file.Size/1024, link), link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermastoreFile steals a file out of TDlib control into an independent shared directory
|
||||||
|
func (c *Client) PermastoreFile(file *client.File, clone bool) (string, string) {
|
||||||
log.Debugf("file: %#v", file)
|
log.Debugf("file: %#v", file)
|
||||||
if file == nil || file.Local == nil || file.Remote == nil {
|
if file == nil || file.Local == nil || file.Remote == nil {
|
||||||
return "", ""
|
return "", ""
|
||||||
|
@ -434,6 +458,45 @@ func (c *Client) formatFile(file *client.File, compact bool) (string, string) {
|
||||||
dest := c.content.Path + "/" + basename // destination path
|
dest := c.content.Path + "/" + basename // destination path
|
||||||
link = c.content.Link + "/" + basename // download link
|
link = c.content.Link + "/" + basename // download link
|
||||||
|
|
||||||
|
if clone {
|
||||||
|
file, path, err := c.ForceOpenFile(file, 1)
|
||||||
|
if err == nil {
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// mode
|
||||||
|
mode := os.FileMode(0644)
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
mode = fi.Mode().Perm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// create destination
|
||||||
|
tempFile, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
||||||
|
if err != nil {
|
||||||
|
pathErr := err.(*os.PathError)
|
||||||
|
if pathErr.Err.Error() == "file exists" {
|
||||||
|
log.Warn(err.Error())
|
||||||
|
return src, link
|
||||||
|
} else {
|
||||||
|
log.Errorf("File creation error: %v", err)
|
||||||
|
return "<ERROR>", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer tempFile.Close()
|
||||||
|
// copy
|
||||||
|
_, err = io.Copy(tempFile, file)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("File copying error: %v", err)
|
||||||
|
return "<ERROR>", ""
|
||||||
|
}
|
||||||
|
} else if path != "" {
|
||||||
|
log.Errorf("Source file does not exist: %v", path)
|
||||||
|
return "<ERROR>", ""
|
||||||
|
} else {
|
||||||
|
log.Errorf("PHOTO: %#v", err.Error())
|
||||||
|
return "<ERROR>", ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// move
|
// move
|
||||||
err = os.Rename(src, dest)
|
err = os.Rename(src, dest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -445,7 +508,7 @@ func (c *Client) formatFile(file *client.File, compact bool) (string, string) {
|
||||||
return "<ERROR>", ""
|
return "<ERROR>", ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gateway.CachedStorageSize += size64
|
}
|
||||||
|
|
||||||
// chown
|
// chown
|
||||||
if c.content.User != "" {
|
if c.content.User != "" {
|
||||||
|
@ -464,13 +527,12 @@ func (c *Client) formatFile(file *client.File, compact bool) (string, string) {
|
||||||
log.Errorf("Wrong user name for chown: %v", err)
|
log.Errorf("Wrong user name for chown: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copy or move should have succeeded at this point
|
||||||
|
gateway.CachedStorageSize += size64
|
||||||
}
|
}
|
||||||
|
|
||||||
if compact {
|
return src, link
|
||||||
return link, link
|
|
||||||
} else {
|
|
||||||
return fmt.Sprintf("%s (%v kbytes) | %s", filepath.Base(src), file.Size/1024, link), link
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) formatBantime(hours int64) int32 {
|
func (c *Client) formatBantime(hours int64) int32 {
|
||||||
|
@ -1148,20 +1210,20 @@ func (c *Client) DownloadFile(id int32, priority int32, synchronous bool) (*clie
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenPhotoFile reliably obtains a photo if possible
|
// ForceOpenFile reliably obtains a file if possible
|
||||||
func (c *Client) OpenPhotoFile(photoFile *client.File, priority int32) (*os.File, string, error) {
|
func (c *Client) ForceOpenFile(tgFile *client.File, priority int32) (*os.File, string, error) {
|
||||||
if photoFile == nil {
|
if tgFile == nil {
|
||||||
return nil, "", errors.New("Photo file not found")
|
return nil, "", errors.New("File not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
path := photoFile.Local.Path
|
path := tgFile.Local.Path
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return file, path, nil
|
return file, path, nil
|
||||||
} else
|
} else
|
||||||
// obtain the photo right now if still not downloaded
|
// obtain the photo right now if still not downloaded
|
||||||
if !photoFile.Local.IsDownloadingCompleted {
|
if !tgFile.Local.IsDownloadingCompleted {
|
||||||
tdFile, tdErr := c.DownloadFile(photoFile.Id, priority, true)
|
tdFile, tdErr := c.DownloadFile(tgFile.Id, priority, true)
|
||||||
if tdErr == nil {
|
if tdErr == nil {
|
||||||
path = tdFile.Local.Path
|
path = tdFile.Local.Path
|
||||||
file, err = os.Open(path)
|
file, err = os.Open(path)
|
||||||
|
@ -1248,3 +1310,28 @@ func (c *Client) prepareDiskSpace(size uint64) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetVcardInfo(toID int64) (VCardInfo, error) {
|
||||||
|
var info VCardInfo
|
||||||
|
chat, user, err := c.GetContactByID(toID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if chat != nil {
|
||||||
|
info.Fn = chat.Title
|
||||||
|
|
||||||
|
if chat.Photo != nil {
|
||||||
|
info.Photo = chat.Photo.Small
|
||||||
|
}
|
||||||
|
info.Info = c.GetChatDescription(chat)
|
||||||
|
}
|
||||||
|
if user != nil {
|
||||||
|
info.Nickname = user.Username
|
||||||
|
info.Given = user.FirstName
|
||||||
|
info.Family = user.LastName
|
||||||
|
info.Tel = user.PhoneNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
106
xmpp/handlers.go
106
xmpp/handlers.go
|
@ -10,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"dev.narayana.im/narayana/telegabber/persistence"
|
"dev.narayana.im/narayana/telegabber/persistence"
|
||||||
|
"dev.narayana.im/narayana/telegabber/telegram"
|
||||||
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
|
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
|
||||||
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
||||||
|
|
||||||
|
@ -319,45 +320,12 @@ func handleGetVcardIq(s xmpp.Sender, iq *stanza.IQ, typ byte) {
|
||||||
log.Error("Invalid IQ to")
|
log.Error("Invalid IQ to")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
chat, user, err := session.GetContactByID(toID, nil)
|
info, err := session.GetVcardInfo(toID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var fn, photo, nickname, given, family, tel, info string
|
|
||||||
if chat != nil {
|
|
||||||
fn = chat.Title
|
|
||||||
|
|
||||||
if chat.Photo != nil {
|
|
||||||
file, path, err := session.OpenPhotoFile(chat.Photo.Small, 32)
|
|
||||||
if err == nil {
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
binval := base64.NewEncoder(base64.StdEncoding, buf)
|
|
||||||
_, err = io.Copy(binval, file)
|
|
||||||
binval.Close()
|
|
||||||
if err == nil {
|
|
||||||
photo = buf.String()
|
|
||||||
} else {
|
|
||||||
log.Errorf("Error calculating base64: %v", path)
|
|
||||||
}
|
|
||||||
} else if path != "" {
|
|
||||||
log.Errorf("Photo does not exist: %v", path)
|
|
||||||
} else {
|
|
||||||
log.Errorf("PHOTO: %#v", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info = session.GetChatDescription(chat)
|
|
||||||
}
|
|
||||||
if user != nil {
|
|
||||||
nickname = user.Username
|
|
||||||
given = user.FirstName
|
|
||||||
family = user.LastName
|
|
||||||
tel = user.PhoneNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
answer := stanza.IQ{
|
answer := stanza.IQ{
|
||||||
Attrs: stanza.Attrs{
|
Attrs: stanza.Attrs{
|
||||||
From: iq.To,
|
From: iq.To,
|
||||||
|
@ -365,7 +333,7 @@ func handleGetVcardIq(s xmpp.Sender, iq *stanza.IQ, typ byte) {
|
||||||
Id: iq.Id,
|
Id: iq.Id,
|
||||||
Type: "result",
|
Type: "result",
|
||||||
},
|
},
|
||||||
Payload: makeVCardPayload(typ, iq.To, fn, photo, nickname, given, family, tel, info),
|
Payload: makeVCardPayload(typ, iq.To, info, session),
|
||||||
}
|
}
|
||||||
log.Debugf("%#v", answer)
|
log.Debugf("%#v", answer)
|
||||||
|
|
||||||
|
@ -426,53 +394,75 @@ func toToID(to string) (int64, bool) {
|
||||||
return toID, true
|
return toID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeVCardPayload(typ byte, id, fn, photo, nickname, given, family, tel, info string) stanza.IQPayload {
|
func makeVCardPayload(typ byte, id string, info telegram.VCardInfo, session *telegram.Client) stanza.IQPayload {
|
||||||
|
var base64Photo string
|
||||||
|
if info.Photo != nil {
|
||||||
|
file, path, err := session.ForceOpenFile(info.Photo, 32)
|
||||||
|
if err == nil {
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
binval := base64.NewEncoder(base64.StdEncoding, buf)
|
||||||
|
_, err = io.Copy(binval, file)
|
||||||
|
binval.Close()
|
||||||
|
if err == nil {
|
||||||
|
base64Photo = buf.String()
|
||||||
|
} else {
|
||||||
|
log.Errorf("Error calculating base64: %v", path)
|
||||||
|
}
|
||||||
|
} else if path != "" {
|
||||||
|
log.Errorf("Photo does not exist: %v", path)
|
||||||
|
} else {
|
||||||
|
log.Errorf("PHOTO: %#v", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if typ == TypeVCardTemp {
|
if typ == TypeVCardTemp {
|
||||||
vcard := &extensions.IqVcardTemp{}
|
vcard := &extensions.IqVcardTemp{}
|
||||||
|
|
||||||
vcard.Fn.Text = fn
|
vcard.Fn.Text = info.Fn
|
||||||
if photo != "" {
|
if base64Photo != "" {
|
||||||
vcard.Photo.Type.Text = "image/jpeg"
|
vcard.Photo.Type.Text = "image/jpeg"
|
||||||
vcard.Photo.Binval.Text = photo
|
vcard.Photo.Binval.Text = base64Photo
|
||||||
}
|
}
|
||||||
vcard.Nickname.Text = nickname
|
vcard.Nickname.Text = info.Nickname
|
||||||
vcard.N.Given.Text = given
|
vcard.N.Given.Text = info.Given
|
||||||
vcard.N.Family.Text = family
|
vcard.N.Family.Text = info.Family
|
||||||
vcard.Tel.Number.Text = tel
|
vcard.Tel.Number.Text = info.Tel
|
||||||
vcard.Desc.Text = info
|
vcard.Desc.Text = info.Info
|
||||||
|
|
||||||
return vcard
|
return vcard
|
||||||
} else if typ == TypeVCard4 {
|
} else if typ == TypeVCard4 {
|
||||||
nodes := []stanza.Node{}
|
nodes := []stanza.Node{}
|
||||||
if fn != "" {
|
if info.Fn != "" {
|
||||||
nodes = append(nodes, stanza.Node{
|
nodes = append(nodes, stanza.Node{
|
||||||
XMLName: xml.Name{Local: "fn"},
|
XMLName: xml.Name{Local: "fn"},
|
||||||
Nodes: []stanza.Node{
|
Nodes: []stanza.Node{
|
||||||
stanza.Node{
|
stanza.Node{
|
||||||
XMLName: xml.Name{Local: "text"},
|
XMLName: xml.Name{Local: "text"},
|
||||||
Content: fn,
|
Content: info.Fn,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if photo != "" {
|
if base64Photo != "" {
|
||||||
nodes = append(nodes, stanza.Node{
|
nodes = append(nodes, stanza.Node{
|
||||||
XMLName: xml.Name{Local: "photo"},
|
XMLName: xml.Name{Local: "photo"},
|
||||||
Nodes: []stanza.Node{
|
Nodes: []stanza.Node{
|
||||||
stanza.Node{
|
stanza.Node{
|
||||||
XMLName: xml.Name{Local: "uri"},
|
XMLName: xml.Name{Local: "uri"},
|
||||||
Content: "data:image/jpeg;base64," + photo,
|
Content: "data:image/jpeg;base64," + base64Photo,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if nickname != "" {
|
if info.Nickname != "" {
|
||||||
nodes = append(nodes, stanza.Node{
|
nodes = append(nodes, stanza.Node{
|
||||||
XMLName: xml.Name{Local: "nickname"},
|
XMLName: xml.Name{Local: "nickname"},
|
||||||
Nodes: []stanza.Node{
|
Nodes: []stanza.Node{
|
||||||
stanza.Node{
|
stanza.Node{
|
||||||
XMLName: xml.Name{Local: "text"},
|
XMLName: xml.Name{Local: "text"},
|
||||||
Content: nickname,
|
Content: info.Nickname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, stanza.Node{
|
}, stanza.Node{
|
||||||
|
@ -480,44 +470,44 @@ func makeVCardPayload(typ byte, id, fn, photo, nickname, given, family, tel, inf
|
||||||
Nodes: []stanza.Node{
|
Nodes: []stanza.Node{
|
||||||
stanza.Node{
|
stanza.Node{
|
||||||
XMLName: xml.Name{Local: "uri"},
|
XMLName: xml.Name{Local: "uri"},
|
||||||
Content: "https://t.me/" + nickname,
|
Content: "https://t.me/" + info.Nickname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if family != "" || given != "" {
|
if info.Family != "" || info.Given != "" {
|
||||||
nodes = append(nodes, stanza.Node{
|
nodes = append(nodes, stanza.Node{
|
||||||
XMLName: xml.Name{Local: "n"},
|
XMLName: xml.Name{Local: "n"},
|
||||||
Nodes: []stanza.Node{
|
Nodes: []stanza.Node{
|
||||||
stanza.Node{
|
stanza.Node{
|
||||||
XMLName: xml.Name{Local: "surname"},
|
XMLName: xml.Name{Local: "surname"},
|
||||||
Content: family,
|
Content: info.Family,
|
||||||
},
|
},
|
||||||
stanza.Node{
|
stanza.Node{
|
||||||
XMLName: xml.Name{Local: "given"},
|
XMLName: xml.Name{Local: "given"},
|
||||||
Content: given,
|
Content: info.Given,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if tel != "" {
|
if info.Tel != "" {
|
||||||
nodes = append(nodes, stanza.Node{
|
nodes = append(nodes, stanza.Node{
|
||||||
XMLName: xml.Name{Local: "tel"},
|
XMLName: xml.Name{Local: "tel"},
|
||||||
Nodes: []stanza.Node{
|
Nodes: []stanza.Node{
|
||||||
stanza.Node{
|
stanza.Node{
|
||||||
XMLName: xml.Name{Local: "uri"},
|
XMLName: xml.Name{Local: "uri"},
|
||||||
Content: "tel:" + tel,
|
Content: "tel:" + info.Tel,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if info != "" {
|
if info.Info != "" {
|
||||||
nodes = append(nodes, stanza.Node{
|
nodes = append(nodes, stanza.Node{
|
||||||
XMLName: xml.Name{Local: "note"},
|
XMLName: xml.Name{Local: "note"},
|
||||||
Nodes: []stanza.Node{
|
Nodes: []stanza.Node{
|
||||||
stanza.Node{
|
stanza.Node{
|
||||||
XMLName: xml.Name{Local: "text"},
|
XMLName: xml.Name{Local: "text"},
|
||||||
Content: info,
|
Content: info.Info,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue