mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
639 lines
18 KiB
Go
639 lines
18 KiB
Go
package qq
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
"github.com/memohai/memoh/internal/channel"
|
|
)
|
|
|
|
const (
|
|
qqIntentGuilds = 1 << 0
|
|
qqIntentGuildMembers = 1 << 1
|
|
qqIntentPublicGuildMessages = 1 << 30
|
|
qqIntentGroupAndC2C = 1 << 25
|
|
)
|
|
|
|
var qqIntentLevels = []int{
|
|
qqIntentPublicGuildMessages | qqIntentGroupAndC2C,
|
|
qqIntentPublicGuildMessages | qqIntentGuildMembers,
|
|
}
|
|
|
|
type MessageAttachment struct {
|
|
ContentType string `json:"content_type"`
|
|
FileName string `json:"filename,omitempty"`
|
|
Height int `json:"height,omitempty"`
|
|
Width int `json:"width,omitempty"`
|
|
Size int64 `json:"size,omitempty"`
|
|
URL string `json:"url"`
|
|
VoiceWavURL string `json:"voice_wav_url,omitempty"`
|
|
}
|
|
|
|
type C2CAuthor struct {
|
|
ID string `json:"id,omitempty"`
|
|
UnionOpenID string `json:"union_openid,omitempty"`
|
|
UserOpenID string `json:"user_openid"`
|
|
}
|
|
|
|
type GroupAuthor struct {
|
|
ID string `json:"id,omitempty"`
|
|
MemberOpenID string `json:"member_openid"`
|
|
}
|
|
|
|
type GuildAuthor struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username,omitempty"`
|
|
Bot bool `json:"bot,omitempty"`
|
|
}
|
|
|
|
type C2CMessageEvent struct {
|
|
Author C2CAuthor `json:"author"`
|
|
Content string `json:"content"`
|
|
ID string `json:"id"`
|
|
Timestamp string `json:"timestamp"`
|
|
Attachments []MessageAttachment `json:"attachments,omitempty"`
|
|
}
|
|
|
|
type GroupMessageEvent struct {
|
|
Author GroupAuthor `json:"author"`
|
|
Content string `json:"content"`
|
|
ID string `json:"id"`
|
|
Timestamp string `json:"timestamp"`
|
|
GroupID string `json:"group_id,omitempty"`
|
|
GroupOpenID string `json:"group_openid"`
|
|
Attachments []MessageAttachment `json:"attachments,omitempty"`
|
|
}
|
|
|
|
type GuildMessageEvent struct {
|
|
ID string `json:"id"`
|
|
ChannelID string `json:"channel_id"`
|
|
GuildID string `json:"guild_id,omitempty"`
|
|
Content string `json:"content"`
|
|
Timestamp string `json:"timestamp"`
|
|
Author GuildAuthor `json:"author"`
|
|
Attachments []MessageAttachment `json:"attachments,omitempty"`
|
|
}
|
|
|
|
type wsPayload struct {
|
|
Op int `json:"op"`
|
|
D json.RawMessage `json:"d,omitempty"`
|
|
S int `json:"s,omitempty"`
|
|
T string `json:"t,omitempty"`
|
|
}
|
|
|
|
type InboundEvent struct {
|
|
Type string
|
|
C2CMessage *C2CMessageEvent
|
|
GroupMessage *GroupMessageEvent
|
|
GuildMessage *GuildMessageEvent
|
|
}
|
|
|
|
type gatewayWriter struct {
|
|
conn *websocket.Conn
|
|
mu sync.Mutex
|
|
}
|
|
|
|
type heartbeatHandle struct {
|
|
cancel context.CancelFunc
|
|
done <-chan struct{}
|
|
}
|
|
|
|
func (w *gatewayWriter) WriteJSON(v any) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
_ = w.conn.SetWriteDeadline(time.Now().Add(defaultWriteTimeout))
|
|
return w.conn.WriteJSON(v)
|
|
}
|
|
|
|
func (a *QQAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, handler channel.InboundHandler) (channel.Connection, error) {
|
|
parsed, err := parseConfig(cfg.Credentials)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
connCtx, cancel := context.WithCancel(ctx)
|
|
go a.runReceiver(connCtx, cfg, parsed, handler)
|
|
|
|
return channel.NewConnection(cfg, func(context.Context) error {
|
|
cancel()
|
|
return nil
|
|
}), nil
|
|
}
|
|
|
|
func (a *QQAdapter) runReceiver(ctx context.Context, cfg channel.ChannelConfig, parsed Config, handler channel.InboundHandler) {
|
|
backoffs := []time.Duration{time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
|
attempt := 0
|
|
for ctx.Err() == nil {
|
|
healthySession, err := a.serveConnection(ctx, cfg, parsed, handler)
|
|
if err == nil || ctx.Err() != nil {
|
|
return
|
|
}
|
|
if a.logger != nil {
|
|
a.logger.Warn("qq receiver reconnect", slog.String("config_id", cfg.ID), slog.Any("error", err))
|
|
}
|
|
delay, nextAttempt := nextReconnectDelay(backoffs, attempt, healthySession)
|
|
attempt = nextAttempt
|
|
if !sleepContext(ctx, delay) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *QQAdapter) serveConnection(ctx context.Context, cfg channel.ChannelConfig, parsed Config, handler channel.InboundHandler) (bool, error) {
|
|
client := a.getOrCreateClient(cfg, parsed)
|
|
gatewayURL, err := client.gatewayURL(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
conn, resp, err := a.dialer.DialContext(ctx, gatewayURL, nil)
|
|
if resp != nil && resp.Body != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
_ = conn.SetReadDeadline(time.Now().Add(defaultReadTimeout))
|
|
writer := &gatewayWriter{conn: conn}
|
|
|
|
session := a.loadSession(cfg.ID)
|
|
var heartbeatSeq atomic.Int64
|
|
heartbeatSeq.Store(int64(session.LastSeq))
|
|
healthySession := false
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = conn.Close()
|
|
case <-done:
|
|
}
|
|
}()
|
|
defer close(done)
|
|
|
|
var heartbeat heartbeatHandle
|
|
defer func() {
|
|
if heartbeat.cancel != nil {
|
|
heartbeat.cancel()
|
|
}
|
|
}()
|
|
|
|
for {
|
|
_, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
var closeErr *websocket.CloseError
|
|
if errors.As(err, &closeErr) {
|
|
return a.handleGatewayClose(cfg.ID, client, &session, closeErr, healthySession)
|
|
}
|
|
return healthySession, err
|
|
}
|
|
_ = conn.SetReadDeadline(time.Now().Add(defaultReadTimeout))
|
|
|
|
var payload wsPayload
|
|
if err := json.Unmarshal(data, &payload); err != nil {
|
|
return healthySession, fmt.Errorf("qq websocket payload decode: %w", err)
|
|
}
|
|
if payload.S > 0 {
|
|
session.LastSeq = payload.S
|
|
a.saveSession(cfg.ID, session)
|
|
heartbeatSeq.Store(int64(payload.S))
|
|
}
|
|
|
|
switch payload.Op {
|
|
case 10:
|
|
if err := handleHello(ctx, writer, client, &session, payload.D); err != nil {
|
|
return healthySession, err
|
|
}
|
|
if heartbeat.cancel != nil {
|
|
heartbeat.cancel()
|
|
}
|
|
interval := parseHeartbeatInterval(payload.D)
|
|
heartbeat = startHeartbeat(ctx, writer, interval, func() int {
|
|
return int(heartbeatSeq.Load())
|
|
})
|
|
case 0:
|
|
dispatchHealthy, err := a.handleDispatch(ctx, cfg, handler, payload.T, payload.D, &session)
|
|
if err != nil {
|
|
return healthySession, err
|
|
}
|
|
healthySession = healthySession || dispatchHealthy
|
|
case 7:
|
|
return healthySession, errors.New("qq gateway requested reconnect")
|
|
case 9:
|
|
a.adjustSessionAfterInvalid(cfg.ID, &session)
|
|
return healthySession, errors.New("qq invalid session")
|
|
case 11:
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *QQAdapter) handleGatewayClose(configID string, client *qqClient, session *sessionState, closeErr *websocket.CloseError, healthySession bool) (bool, error) {
|
|
switch closeErr.Code {
|
|
case 4004:
|
|
if client != nil {
|
|
client.clearToken()
|
|
}
|
|
case 4006, 4007, 4009:
|
|
a.clearSession(configID)
|
|
case 4914, 4915:
|
|
a.adjustSessionAfterIntentClose(configID, session)
|
|
return healthySession, fmt.Errorf("qq gateway closed with intent code %d", closeErr.Code)
|
|
}
|
|
return healthySession, closeErr
|
|
}
|
|
|
|
func handleHello(ctx context.Context, writer *gatewayWriter, client *qqClient, session *sessionState, _ json.RawMessage) error {
|
|
token, err := client.accessToken(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if session.SessionID != "" && session.LastSeq > 0 {
|
|
payload := map[string]any{
|
|
"op": 6,
|
|
"d": map[string]any{
|
|
"token": "QQBot " + token,
|
|
"session_id": session.SessionID,
|
|
"seq": session.LastSeq,
|
|
},
|
|
}
|
|
return writer.WriteJSON(payload)
|
|
}
|
|
intentLevel := session.IntentLevel
|
|
if intentLevel < 0 || intentLevel >= len(qqIntentLevels) {
|
|
intentLevel = 0
|
|
}
|
|
session.IntentLevel = intentLevel
|
|
return writer.WriteJSON(map[string]any{
|
|
"op": 2,
|
|
"d": map[string]any{
|
|
"token": "QQBot " + token,
|
|
"intents": qqIntentLevels[intentLevel],
|
|
"shard": []int{0, 1},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (a *QQAdapter) handleDispatch(ctx context.Context, cfg channel.ChannelConfig, handler channel.InboundHandler, eventType string, raw json.RawMessage, session *sessionState) (bool, error) {
|
|
switch eventType {
|
|
case "READY":
|
|
var ready struct {
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
if err := json.Unmarshal(raw, &ready); err != nil {
|
|
return false, err
|
|
}
|
|
session.SessionID = strings.TrimSpace(ready.SessionID)
|
|
a.saveSession(cfg.ID, *session)
|
|
return true, nil
|
|
case "RESUMED":
|
|
a.saveSession(cfg.ID, *session)
|
|
return true, nil
|
|
case "C2C_MESSAGE_CREATE":
|
|
var event C2CMessageEvent
|
|
if err := json.Unmarshal(raw, &event); err != nil {
|
|
return false, err
|
|
}
|
|
a.dispatchInbound(ctx, cfg, handler, InboundEvent{Type: eventType, C2CMessage: &event})
|
|
return false, nil
|
|
case "GROUP_AT_MESSAGE_CREATE":
|
|
var event GroupMessageEvent
|
|
if err := json.Unmarshal(raw, &event); err != nil {
|
|
return false, err
|
|
}
|
|
a.dispatchInbound(ctx, cfg, handler, InboundEvent{Type: eventType, GroupMessage: &event})
|
|
return false, nil
|
|
case "AT_MESSAGE_CREATE":
|
|
var event GuildMessageEvent
|
|
if err := json.Unmarshal(raw, &event); err != nil {
|
|
return false, err
|
|
}
|
|
a.dispatchInbound(ctx, cfg, handler, InboundEvent{Type: eventType, GuildMessage: &event})
|
|
return false, nil
|
|
default:
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
func (a *QQAdapter) dispatchInbound(ctx context.Context, cfg channel.ChannelConfig, handler channel.InboundHandler, inbound InboundEvent) {
|
|
msg, ok := eventToInboundMessage(inbound, cfg.BotID)
|
|
if !ok {
|
|
return
|
|
}
|
|
go func() {
|
|
if err := handler(ctx, cfg, msg); err != nil && a.logger != nil {
|
|
a.logger.Error("qq handle inbound failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
|
|
}
|
|
}()
|
|
}
|
|
|
|
func startHeartbeat(parent context.Context, writer *gatewayWriter, interval time.Duration, seqValue func() int) heartbeatHandle {
|
|
ctx, cancel := context.WithCancel(parent)
|
|
ticker := time.NewTicker(interval)
|
|
done := make(chan struct{})
|
|
go runHeartbeat(ctx, writer, ticker, seqValue, done)
|
|
return heartbeatHandle{
|
|
cancel: cancel,
|
|
done: done,
|
|
}
|
|
}
|
|
|
|
func runHeartbeat(ctx context.Context, writer *gatewayWriter, ticker *time.Ticker, seqValue func() int, done chan<- struct{}) {
|
|
defer ticker.Stop()
|
|
if done != nil {
|
|
defer close(done)
|
|
}
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
_ = writer.WriteJSON(map[string]any{
|
|
"op": 1,
|
|
"d": seqValue(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *QQAdapter) adjustSessionAfterInvalid(configID string, session *sessionState) {
|
|
session.SessionID = ""
|
|
session.LastSeq = 0
|
|
a.saveSession(configID, *session)
|
|
}
|
|
|
|
func (a *QQAdapter) adjustSessionAfterIntentClose(configID string, session *sessionState) {
|
|
session.SessionID = ""
|
|
session.LastSeq = 0
|
|
if last := len(qqIntentLevels) - 1; last >= 0 {
|
|
switch {
|
|
case session.IntentLevel < 0:
|
|
session.IntentLevel = 0
|
|
case session.IntentLevel < last:
|
|
session.IntentLevel++
|
|
default:
|
|
session.IntentLevel = last
|
|
}
|
|
}
|
|
a.saveSession(configID, *session)
|
|
}
|
|
|
|
func parseHeartbeatInterval(raw json.RawMessage) time.Duration {
|
|
var hello struct {
|
|
HeartbeatInterval int `json:"heartbeat_interval"`
|
|
}
|
|
if err := json.Unmarshal(raw, &hello); err != nil {
|
|
return 30 * time.Second
|
|
}
|
|
if hello.HeartbeatInterval <= 0 {
|
|
return 30 * time.Second
|
|
}
|
|
return time.Duration(hello.HeartbeatInterval) * time.Millisecond
|
|
}
|
|
|
|
func eventToInboundMessage(event InboundEvent, botID string) (channel.InboundMessage, bool) {
|
|
switch event.Type {
|
|
case "C2C_MESSAGE_CREATE":
|
|
if event.C2CMessage == nil {
|
|
return channel.InboundMessage{}, false
|
|
}
|
|
payload := event.C2CMessage
|
|
subjectID := strings.TrimSpace(payload.Author.UserOpenID)
|
|
if subjectID == "" {
|
|
return channel.InboundMessage{}, false
|
|
}
|
|
return channel.InboundMessage{
|
|
Channel: Type,
|
|
BotID: strings.TrimSpace(botID),
|
|
Message: channel.Message{
|
|
ID: strings.TrimSpace(payload.ID),
|
|
Format: channel.MessageFormatPlain,
|
|
Text: parseFaceTags(strings.TrimSpace(payload.Content)),
|
|
Attachments: toInboundAttachments(payload.Attachments),
|
|
},
|
|
ReplyTarget: "c2c:" + subjectID,
|
|
Sender: channel.Identity{
|
|
SubjectID: subjectID,
|
|
Attributes: map[string]string{
|
|
"user_openid": subjectID,
|
|
"union_openid": strings.TrimSpace(payload.Author.UnionOpenID),
|
|
},
|
|
},
|
|
Conversation: channel.Conversation{
|
|
ID: subjectID,
|
|
Type: channel.ConversationTypePrivate,
|
|
},
|
|
ReceivedAt: parseTimestamp(payload.Timestamp),
|
|
Source: "qq",
|
|
Metadata: map[string]any{
|
|
"is_mentioned": false,
|
|
},
|
|
}, true
|
|
case "GROUP_AT_MESSAGE_CREATE":
|
|
if event.GroupMessage == nil {
|
|
return channel.InboundMessage{}, false
|
|
}
|
|
payload := event.GroupMessage
|
|
subjectID := strings.TrimSpace(payload.Author.MemberOpenID)
|
|
groupID := strings.TrimSpace(payload.GroupOpenID)
|
|
if subjectID == "" || groupID == "" {
|
|
return channel.InboundMessage{}, false
|
|
}
|
|
return channel.InboundMessage{
|
|
Channel: Type,
|
|
BotID: strings.TrimSpace(botID),
|
|
Message: channel.Message{
|
|
ID: strings.TrimSpace(payload.ID),
|
|
Format: channel.MessageFormatPlain,
|
|
Text: parseFaceTags(strings.TrimSpace(payload.Content)),
|
|
Attachments: toInboundAttachments(payload.Attachments),
|
|
},
|
|
ReplyTarget: "group:" + groupID,
|
|
Sender: channel.Identity{
|
|
SubjectID: subjectID,
|
|
Attributes: map[string]string{
|
|
"user_openid": subjectID,
|
|
"group_openid": groupID,
|
|
},
|
|
},
|
|
Conversation: channel.Conversation{
|
|
ID: groupID,
|
|
Type: channel.ConversationTypeGroup,
|
|
},
|
|
ReceivedAt: parseTimestamp(payload.Timestamp),
|
|
Source: "qq",
|
|
Metadata: map[string]any{
|
|
"is_mentioned": true,
|
|
"group_id": strings.TrimSpace(payload.GroupID),
|
|
"group_openid": groupID,
|
|
},
|
|
}, true
|
|
case "AT_MESSAGE_CREATE":
|
|
if event.GuildMessage == nil {
|
|
return channel.InboundMessage{}, false
|
|
}
|
|
payload := event.GuildMessage
|
|
subjectID := strings.TrimSpace(payload.Author.ID)
|
|
channelID := strings.TrimSpace(payload.ChannelID)
|
|
if subjectID == "" || channelID == "" {
|
|
return channel.InboundMessage{}, false
|
|
}
|
|
conversationID := channelID
|
|
conversationType := channel.ConversationTypeGroup
|
|
threadID := ""
|
|
guildID := strings.TrimSpace(payload.GuildID)
|
|
if guildID != "" {
|
|
conversationID = guildID
|
|
threadID = channelID
|
|
conversationType = channel.ConversationTypeThread
|
|
}
|
|
return channel.InboundMessage{
|
|
Channel: Type,
|
|
BotID: strings.TrimSpace(botID),
|
|
Message: channel.Message{
|
|
ID: strings.TrimSpace(payload.ID),
|
|
Format: channel.MessageFormatPlain,
|
|
Text: parseFaceTags(strings.TrimSpace(payload.Content)),
|
|
Attachments: toInboundAttachments(payload.Attachments),
|
|
},
|
|
ReplyTarget: "channel:" + channelID,
|
|
Sender: channel.Identity{
|
|
SubjectID: subjectID,
|
|
DisplayName: strings.TrimSpace(payload.Author.Username),
|
|
Attributes: map[string]string{
|
|
"user_id": subjectID,
|
|
"channel_id": channelID,
|
|
"guild_id": strings.TrimSpace(payload.GuildID),
|
|
},
|
|
},
|
|
Conversation: channel.Conversation{
|
|
ID: conversationID,
|
|
Type: conversationType,
|
|
ThreadID: threadID,
|
|
},
|
|
ReceivedAt: parseTimestamp(payload.Timestamp),
|
|
Source: "qq",
|
|
Metadata: map[string]any{
|
|
"is_mentioned": true,
|
|
"guild_id": guildID,
|
|
"channel_id": channelID,
|
|
},
|
|
}, true
|
|
default:
|
|
return channel.InboundMessage{}, false
|
|
}
|
|
}
|
|
|
|
func toInboundAttachments(items []MessageAttachment) []channel.Attachment {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]channel.Attachment, 0, len(items))
|
|
for _, item := range items {
|
|
attachmentURL := normalizeQQURL(item.URL)
|
|
attType := inferAttachmentType(item)
|
|
if attType == channel.AttachmentVoice && strings.TrimSpace(item.VoiceWavURL) != "" {
|
|
attachmentURL = normalizeQQURL(item.VoiceWavURL)
|
|
}
|
|
att := channel.NormalizeInboundChannelAttachment(channel.Attachment{
|
|
Type: attType,
|
|
URL: attachmentURL,
|
|
Name: strings.TrimSpace(item.FileName),
|
|
Mime: strings.TrimSpace(item.ContentType),
|
|
Size: item.Size,
|
|
Width: item.Width,
|
|
Height: item.Height,
|
|
SourcePlatform: Type.String(),
|
|
Metadata: map[string]any{
|
|
"voice_wav_url": normalizeQQURL(item.VoiceWavURL),
|
|
},
|
|
})
|
|
result = append(result, att)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func inferAttachmentType(att MessageAttachment) channel.AttachmentType {
|
|
contentType := strings.ToLower(strings.TrimSpace(att.ContentType))
|
|
name := strings.ToLower(strings.TrimSpace(att.FileName))
|
|
switch {
|
|
case strings.HasPrefix(contentType, "image/gif"), strings.HasSuffix(name, ".gif"):
|
|
return channel.AttachmentGIF
|
|
case strings.HasPrefix(contentType, "image/"):
|
|
return channel.AttachmentImage
|
|
case strings.HasPrefix(contentType, "video/"):
|
|
return channel.AttachmentVideo
|
|
case strings.HasPrefix(contentType, "audio/"), strings.TrimSpace(att.VoiceWavURL) != "":
|
|
return channel.AttachmentVoice
|
|
default:
|
|
return channel.AttachmentFile
|
|
}
|
|
}
|
|
|
|
func normalizeQQURL(raw string) string {
|
|
value := strings.TrimSpace(raw)
|
|
if strings.HasPrefix(value, "//") {
|
|
return "https:" + value
|
|
}
|
|
return value
|
|
}
|
|
|
|
func parseTimestamp(raw string) time.Time {
|
|
if ts, err := time.Parse(time.RFC3339, strings.TrimSpace(raw)); err == nil {
|
|
return ts.UTC()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func parseFaceTags(text string) string {
|
|
if strings.TrimSpace(text) == "" {
|
|
return ""
|
|
}
|
|
return faceTagPattern.ReplaceAllStringFunc(text, func(match string) string {
|
|
value, err := decodeFaceTag(match)
|
|
if err != nil {
|
|
return match
|
|
}
|
|
return "【表情: " + value + "】"
|
|
})
|
|
}
|
|
|
|
func sleepContext(ctx context.Context, delay time.Duration) bool {
|
|
timer := time.NewTimer(delay)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-timer.C:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func nextReconnectDelay(backoffs []time.Duration, attempt int, healthySession bool) (time.Duration, int) {
|
|
if len(backoffs) == 0 {
|
|
return 0, attempt
|
|
}
|
|
if healthySession {
|
|
attempt = 0
|
|
}
|
|
delay := backoffs[intMin(attempt, len(backoffs)-1)]
|
|
return delay, attempt + 1
|
|
}
|
|
|
|
func intMin(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|