mirror of
https://github.com/Mrs4s/go-cqhttp.git
synced 2025-05-04 19:17:37 +08:00
feat: 提供 1.1.6 版本以上 qsign 的对接支持 (#2307)
* 增加签名服务超时设置 * 获取签名和err为空时尝试重新注册实例 * 可配置自动刷新token以及自动注册 * fix lint * wrap callback * add config: refresh-interval * support qsign's `auto-register` * fix: add registerLock to avoid repeat registraion. * update: Enable disabling auto token refresh * fix: use string android_id (not hexadecimal * update default_config.yml * fix: compatible with older qsign (bellow 1.1.0 * fix: refresh token * update dependency * update go.sum * fix: fix warnings on old version sign server --------- Co-authored-by: 源文雨 <41315874+fumiama@users.noreply.github.com>
This commit is contained in:
parent
13215f23c5
commit
f466ca7a72
@ -10,7 +10,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Mrs4s/MiraiGo/client"
|
"github.com/Mrs4s/MiraiGo/client"
|
||||||
@ -296,7 +298,40 @@ func energy(uin uint64, id string, _ string, salt []byte) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sign(seq uint64, uin string, cmd string, qua string, buff []byte) (sign []byte, extra []byte, token []byte, err error) {
|
// t: 提交的操作类型
|
||||||
|
func submit(uin string, cmd string, callbackID int64, buffer []byte, t string) {
|
||||||
|
signServer := base.SignServer
|
||||||
|
if !strings.HasSuffix(signServer, "/") {
|
||||||
|
signServer += "/"
|
||||||
|
}
|
||||||
|
buffStr := hex.EncodeToString(buffer)
|
||||||
|
log.Infof("submit %v: uin=%v, cmd=%v, callbackID=%v, buffer-end=%v", t, uin, cmd, callbackID,
|
||||||
|
buffStr[len(buffStr)-10:])
|
||||||
|
_, err := download.Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: signServer + "submit" + fmt.Sprintf("?uin=%v&cmd=%v&callback_id=%v&buffer=%v",
|
||||||
|
uin, cmd, callbackID, buffStr),
|
||||||
|
}.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("提交 callback 时出现错误: %v server: %v", err, signServer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// request token和签名的回调
|
||||||
|
func callback(uin string, results []gjson.Result, t string) {
|
||||||
|
for _, result := range results {
|
||||||
|
cmd := result.Get("cmd").String()
|
||||||
|
callbackID := result.Get("callbackId").Int()
|
||||||
|
body, _ := hex.DecodeString(result.Get("body").String())
|
||||||
|
ret, err := cli.SendSsoPacket(cmd, body)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("callback error: %v", err)
|
||||||
|
}
|
||||||
|
submit(uin, cmd, callbackID, ret, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _sign(seq uint64, uin string, cmd string, qua string, buff []byte) (sign []byte, extra []byte, token []byte, err error) {
|
||||||
signServer := base.SignServer
|
signServer := base.SignServer
|
||||||
if !strings.HasSuffix(signServer, "/") {
|
if !strings.HasSuffix(signServer, "/") {
|
||||||
signServer += "/"
|
signServer += "/"
|
||||||
@ -309,15 +344,19 @@ func sign(seq uint64, uin string, cmd string, qua string, buff []byte) (sign []b
|
|||||||
uin, qua, cmd, seq, hex.EncodeToString(buff), utils.B2S(device.AndroidId), hex.EncodeToString(device.Guid)))),
|
uin, qua, cmd, seq, hex.EncodeToString(buff), utils.B2S(device.AndroidId), hex.EncodeToString(device.Guid)))),
|
||||||
}.Bytes()
|
}.Bytes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("获取sso sign时出现错误: %v server: %v", err, signServer)
|
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
sign, _ = hex.DecodeString(gjson.GetBytes(response, "data.sign").String())
|
sign, _ = hex.DecodeString(gjson.GetBytes(response, "data.sign").String())
|
||||||
extra, _ = hex.DecodeString(gjson.GetBytes(response, "data.extra").String())
|
extra, _ = hex.DecodeString(gjson.GetBytes(response, "data.extra").String())
|
||||||
token, _ = hex.DecodeString(gjson.GetBytes(response, "data.token").String())
|
token, _ = hex.DecodeString(gjson.GetBytes(response, "data.token").String())
|
||||||
|
if !base.IsBelow110 {
|
||||||
|
go callback(uin, gjson.GetBytes(response, "data.requestCallback").Array(), "sign")
|
||||||
|
}
|
||||||
return sign, extra, token, nil
|
return sign, extra, token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var registerLock sync.Mutex
|
||||||
|
|
||||||
func register(uin int64, androidID, guid []byte, qimei36, key string) {
|
func register(uin int64, androidID, guid []byte, qimei36, key string) {
|
||||||
if base.IsBelow110 {
|
if base.IsBelow110 {
|
||||||
log.Warn("签名服务器版本低于1.1.0, 跳过实例注册")
|
log.Warn("签名服务器版本低于1.1.0, 跳过实例注册")
|
||||||
@ -344,6 +383,135 @@ func register(uin int64, androidID, guid []byte, qimei36, key string) {
|
|||||||
log.Infof("注册QQ实例 %v 成功: %v", uin, msg)
|
log.Infof("注册QQ实例 %v 成功: %v", uin, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshToken(uin string) error {
|
||||||
|
signServer := base.SignServer
|
||||||
|
if !strings.HasSuffix(signServer, "/") {
|
||||||
|
signServer += "/"
|
||||||
|
}
|
||||||
|
log.Info("正在刷新 token")
|
||||||
|
resp, err := download.Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: signServer + "request_token" + fmt.Sprintf("?uin=%v", uin),
|
||||||
|
}.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg := gjson.GetBytes(resp, "msg")
|
||||||
|
if gjson.GetBytes(resp, "code").Int() != 0 {
|
||||||
|
return errors.New(msg)
|
||||||
|
}
|
||||||
|
go callback(uin, gjson.GetBytes(resp, "data").Array(), "request token")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var missTokenCount = uint64(0)
|
||||||
|
|
||||||
|
func sign(seq uint64, uin string, cmd string, qua string, buff []byte) (sign []byte, extra []byte, token []byte, err error) {
|
||||||
|
i := 0
|
||||||
|
for {
|
||||||
|
sign, extra, token, err = _sign(seq, uin, cmd, qua, buff)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("获取sso sign时出现错误: %v server: %v", err, base.SignServer)
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
if (!base.IsBelow110) && base.Account.AutoRegister && err == nil && len(sign) == 0 {
|
||||||
|
if registerLock.TryLock() { // 避免并发时多处同时销毁并重新注册
|
||||||
|
log.Warn("获取签名为空,实例可能丢失,正在尝试重新注册")
|
||||||
|
defer registerLock.Unlock()
|
||||||
|
err := destroySignServer(uin)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(err)
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
register(base.Account.Uin, device.AndroidId, device.Guid, device.QImei36, base.Key)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!base.IsBelow110) && base.Account.AutoRefreshToken && len(token) == 0 {
|
||||||
|
log.Warnf("token 已过期, 总丢失 token 次数为 %v", atomic.AddUint64(&missTokenCount, 1))
|
||||||
|
if registerLock.TryLock() {
|
||||||
|
defer registerLock.Unlock()
|
||||||
|
if err := refreshToken(uin); err != nil {
|
||||||
|
log.Warnf("刷新 token 出现错误: %v server: %v", err, base.SignServer)
|
||||||
|
} else {
|
||||||
|
log.Info("刷新 token 成功")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return sign, extra, token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func destroySignServer(uin string) error {
|
||||||
|
signServer := base.SignServer
|
||||||
|
if !strings.HasSuffix(signServer, "/") {
|
||||||
|
signServer += "/"
|
||||||
|
}
|
||||||
|
signVersion, err := getSignServerVersion()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "获取签名服务版本出现错误, server: %v", signServer)
|
||||||
|
}
|
||||||
|
if global.VersionNameCompare("v"+signVersion, "v1.1.6") {
|
||||||
|
return errors.Errorf("当前签名服务器版本 %v 低于 1.1.6,无法使用 destroy 接口", signVersion)
|
||||||
|
}
|
||||||
|
resp, err := download.Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: signServer + "destroy" + fmt.Sprintf("?uin=%v&key=%v", uin, base.Key),
|
||||||
|
}.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "destroy 实例出现错误, server: %v", signServer)
|
||||||
|
}
|
||||||
|
msg := gjson.GetBytes(resp, "msg")
|
||||||
|
if gjson.GetBytes(resp, "code").Int() != 0 {
|
||||||
|
return errors.Wrapf(err, "destroy 实例出现错误, server: %v", signServer)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSignServerVersion() (version string, err error) {
|
||||||
|
signServer := base.SignServer
|
||||||
|
resp, err := download.Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: signServer,
|
||||||
|
}.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if gjson.GetBytes(resp, "code").Int() == 0 {
|
||||||
|
return gjson.GetBytes(resp, "data.version").String(), nil
|
||||||
|
}
|
||||||
|
return "", errors.New("empty version")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定时刷新 token, interval 为间隔时间(分钟)
|
||||||
|
func startRefreshTokenTask(interval int64) {
|
||||||
|
if interval <= 0 {
|
||||||
|
log.Warn("定时刷新 token 已关闭")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("每 %v 分钟将刷新一次签名 token", interval)
|
||||||
|
if interval < 10 {
|
||||||
|
log.Warnf("间隔时间 %v 分钟较短,推荐 30~40 分钟", interval)
|
||||||
|
}
|
||||||
|
if interval > 60 {
|
||||||
|
log.Warn("间隔时间不能超过 60 分钟,已自动设置为 60 分钟")
|
||||||
|
interval = 60
|
||||||
|
}
|
||||||
|
t := time.Newticker(time.Duration(interval) * time.Minute)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
err := refreshToken(strconv.FormatInt(base.Account.Uin, 10))
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("刷新 token 出现错误: %v server: %v", err, base.SignServer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func waitSignServer() bool {
|
func waitSignServer() bool {
|
||||||
t := time.NewTicker(time.Second*5)
|
t := time.NewTicker(time.Second*5)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
|
@ -166,13 +166,25 @@ func LoginInteract() {
|
|||||||
|
|
||||||
if base.SignServer != "-" && base.SignServer != "" {
|
if base.SignServer != "-" && base.SignServer != "" {
|
||||||
log.Infof("使用服务器 %s 进行数据包签名", base.SignServer)
|
log.Infof("使用服务器 %s 进行数据包签名", base.SignServer)
|
||||||
|
download.SetTimeout(time.Duration(base.HTTPTimeout) * time.Second) // 设置签名超时时间
|
||||||
// 等待签名服务器直到连接成功
|
// 等待签名服务器直到连接成功
|
||||||
if !waitSignServer() {
|
if !waitSignServer() {
|
||||||
log.Fatalf("连接签名服务器失败")
|
log.Fatalf("连接签名服务器失败")
|
||||||
}
|
}
|
||||||
register(base.Account.Uin, device.AndroidId, device.Guid, device.QImei36, base.Key)
|
register(base.Account.Uin, device.AndroidId, device.Guid, device.QImei36, base.Key)
|
||||||
|
go startRefreshTokenTask(base.Account.RefreshInterval) // 定时刷新 token
|
||||||
wrapper.DandelionEnergy = energy
|
wrapper.DandelionEnergy = energy
|
||||||
wrapper.FekitGetSign = sign
|
wrapper.FekitGetSign = sign
|
||||||
|
if !base.IsBelow110 {
|
||||||
|
if !base.Account.AutoRegister {
|
||||||
|
log.Warn("自动注册实例已关闭,若未配置 sign-server 端自动注册实例则实例丢失时需要重启 go-cqhttp 以正常签名")
|
||||||
|
}
|
||||||
|
if !base.Account.AutoRefreshToken {
|
||||||
|
log.Warn("自动刷新 token 已关闭,token 过期后获取签名时将不会立即尝试刷新获取新 token")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Warn("签名服务器版本 <= 1.1.0 ,无法使用刷新 token 等操作,建议使用 1.1.6 版本及以上签名服务器")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Warnf("警告: 未配置签名服务器, 这可能会导致登录 45 错误码或发送消息被风控")
|
log.Warnf("警告: 未配置签名服务器, 这可能会导致登录 45 错误码或发送消息被风控")
|
||||||
}
|
}
|
||||||
@ -294,6 +306,7 @@ func LoginInteract() {
|
|||||||
}
|
}
|
||||||
if !base.FastStart {
|
if !base.FastStart {
|
||||||
log.Infof("正在检查协议更新...")
|
log.Infof("正在检查协议更新...")
|
||||||
|
download.SetTimeout(time.Second * 5) // 防止协议更新堵塞过久
|
||||||
currentVersionName := device.Protocol.Version().SortVersionName
|
currentVersionName := device.Protocol.Version().SortVersionName
|
||||||
remoteVersion, err := getRemoteLatestProtocolVersion(int(device.Protocol.Version().Protocol))
|
remoteVersion, err := getRemoteLatestProtocolVersion(int(device.Protocol.Version().Protocol))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@ -378,7 +391,7 @@ func LoginInteract() {
|
|||||||
})
|
})
|
||||||
saveToken()
|
saveToken()
|
||||||
cli.AllowSlider = true
|
cli.AllowSlider = true
|
||||||
download.SetTimeout(time.Duration(base.HTTPTimeout) * time.Second) // 在登录完成后设置, 防止在堵塞协议更新
|
download.SetTimeout(time.Duration(base.HTTPTimeout) * time.Second) // 登陆完成进行最终超时设置
|
||||||
log.Infof("登录成功 欢迎使用: %v", cli.Nickname)
|
log.Infof("登录成功 欢迎使用: %v", cli.Nickname)
|
||||||
log.Info("开始加载好友列表...")
|
log.Info("开始加载好友列表...")
|
||||||
global.Check(cli.ReloadFriendList(), true)
|
global.Check(cli.ReloadFriendList(), true)
|
||||||
|
2
go.mod
2
go.mod
@ -5,7 +5,7 @@ go 1.20
|
|||||||
require (
|
require (
|
||||||
github.com/FloatTech/sqlite v1.5.7
|
github.com/FloatTech/sqlite v1.5.7
|
||||||
github.com/Microsoft/go-winio v0.6.0
|
github.com/Microsoft/go-winio v0.6.0
|
||||||
github.com/Mrs4s/MiraiGo v0.0.0-20230627090859-19e3d172596e
|
github.com/Mrs4s/MiraiGo v0.0.0-20230730133947-d344e0f318ab
|
||||||
github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e
|
github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e
|
||||||
github.com/RomiChan/websocket v1.4.3-0.20220123145318-307a86b127bc
|
github.com/RomiChan/websocket v1.4.3-0.20220123145318-307a86b127bc
|
||||||
github.com/fumiama/go-base16384 v1.6.1
|
github.com/fumiama/go-base16384 v1.6.1
|
||||||
|
4
go.sum
4
go.sum
@ -4,8 +4,8 @@ github.com/FloatTech/ttl v0.0.0-20220715042055-15612be72f5b h1:tvciXWq2nuvTbFeJG
|
|||||||
github.com/FloatTech/ttl v0.0.0-20220715042055-15612be72f5b/go.mod h1:fHZFWGquNXuHttu9dUYoKuNbm3dzLETnIOnm1muSfDs=
|
github.com/FloatTech/ttl v0.0.0-20220715042055-15612be72f5b/go.mod h1:fHZFWGquNXuHttu9dUYoKuNbm3dzLETnIOnm1muSfDs=
|
||||||
github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
|
github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
|
||||||
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
|
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
|
||||||
github.com/Mrs4s/MiraiGo v0.0.0-20230627090859-19e3d172596e h1:99itMjI//+KaFF0+0QCBg/uHhGMJ99jG2lP6z/UnOsU=
|
github.com/Mrs4s/MiraiGo v0.0.0-20230730133947-d344e0f318ab h1:SLciJTlC5YiG3qqvGJf4sHJDHDXUdH+v4rjqVhE5SIQ=
|
||||||
github.com/Mrs4s/MiraiGo v0.0.0-20230627090859-19e3d172596e/go.mod h1:mU3fBFU+7eO0kaGes7YRKtzIDtwIU84nSSwTV7NK2b0=
|
github.com/Mrs4s/MiraiGo v0.0.0-20230730133947-d344e0f318ab/go.mod h1:mU3fBFU+7eO0kaGes7YRKtzIDtwIU84nSSwTV7NK2b0=
|
||||||
github.com/RomiChan/protobuf v0.1.1-0.20230204044148-2ed269a2e54d h1:/Xuj3fIiMY2ls1TwvPKmaqQrtJsPY+c9s+0lOScVHd8=
|
github.com/RomiChan/protobuf v0.1.1-0.20230204044148-2ed269a2e54d h1:/Xuj3fIiMY2ls1TwvPKmaqQrtJsPY+c9s+0lOScVHd8=
|
||||||
github.com/RomiChan/protobuf v0.1.1-0.20230204044148-2ed269a2e54d/go.mod h1:2Ie+hdBFQpQFDHfeklgxoFmQRCE7O+KwFpISeXq7OwA=
|
github.com/RomiChan/protobuf v0.1.1-0.20230204044148-2ed269a2e54d/go.mod h1:2Ie+hdBFQpQFDHfeklgxoFmQRCE7O+KwFpISeXq7OwA=
|
||||||
github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e h1:wR3MXQ3VbUlPKOOUwLOYgh/QaJThBTYtsl673O3lqSA=
|
github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e h1:wR3MXQ3VbUlPKOOUwLOYgh/QaJThBTYtsl673O3lqSA=
|
||||||
|
@ -38,6 +38,9 @@ type Account struct {
|
|||||||
SignServer string `yaml:"sign-server"`
|
SignServer string `yaml:"sign-server"`
|
||||||
Key string `yaml:"key"`
|
Key string `yaml:"key"`
|
||||||
IsBelow110 bool `yaml:"is-below-110"`
|
IsBelow110 bool `yaml:"is-below-110"`
|
||||||
|
AutoRegister bool `yaml:"auto-register"`
|
||||||
|
AutoRefreshToken bool `yaml:"auto-refresh-token"`
|
||||||
|
RefreshInterval int64 `yaml:"refresh-interval"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config 总配置文件
|
// Config 总配置文件
|
||||||
|
@ -29,6 +29,17 @@ account: # 账号相关
|
|||||||
# 签名服务器所需要的apikey, 如果签名服务器的版本在1.1.0及以下则此项无效
|
# 签名服务器所需要的apikey, 如果签名服务器的版本在1.1.0及以下则此项无效
|
||||||
# 本地部署的默认为114514
|
# 本地部署的默认为114514
|
||||||
key: '114514'
|
key: '114514'
|
||||||
|
# 在实例可能丢失(获取到的签名为空)时是否尝试重新注册
|
||||||
|
# 为 true 时,在签名服务不可用时可能每次发消息都会尝试重新注册并签名。
|
||||||
|
# 为 false 时,将不会自动注册实例,在签名服务器重启或实例被销毁后需要重启 go-cqhttp 以获取实例
|
||||||
|
# 否则后续消息将不会正常签名。关闭此项后可以考虑开启签名服务器端 auto_register 避免需要重启
|
||||||
|
auto-register: false
|
||||||
|
# 是否在 token 过期后立即自动刷新签名 token(在需要签名时才会检测到,主要防止 token 意外丢失)
|
||||||
|
# 独立于定时刷新
|
||||||
|
auto-refresh-token: false
|
||||||
|
# 定时刷新 token 间隔时间,单位为分钟, 建议 30~40 分钟, 不可超过 60 分钟
|
||||||
|
# 目前丢失token也不会有太大影响,可设置为 0 以关闭,推荐开启
|
||||||
|
refresh-interval: 40
|
||||||
|
|
||||||
heartbeat:
|
heartbeat:
|
||||||
# 心跳频率, 单位秒
|
# 心跳频率, 单位秒
|
||||||
@ -59,7 +70,7 @@ message:
|
|||||||
skip-mime-scan: false
|
skip-mime-scan: false
|
||||||
# 是否自动转换 WebP 图片
|
# 是否自动转换 WebP 图片
|
||||||
convert-webp-image: false
|
convert-webp-image: false
|
||||||
# http超时时间
|
# http超时时间,同时作为签名服务超时时间
|
||||||
http-timeout: 0
|
http-timeout: 0
|
||||||
|
|
||||||
output:
|
output:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user