fix: double reply bug

This commit is contained in:
Ran
2026-02-23 05:51:56 +08:00
committed by 晨苒
parent 51acb4b546
commit 5a08b280ab
2 changed files with 370 additions and 316 deletions
+352 -293
View File
@@ -13,401 +13,460 @@ import (
"github.com/memohai/memoh/internal/channel/adapters/common"
)
const inboundDedupTTL = time.Minute
type DiscordAdapter struct {
logger *slog.Logger
mu sync.RWMutex
sessions map[string]*discordgo.Session // keyed by bot token
logger *slog.Logger
mu sync.RWMutex
sessions map[string]*discordgo.Session // keyed by bot token
handlerRemovers map[string]func() // keyed by bot token
seenMessages map[string]time.Time // keyed by token:messageID
}
func NewDiscordAdapter(log *slog.Logger) *DiscordAdapter {
if log == nil {
log = slog.Default()
}
return &DiscordAdapter{
logger: log.With(slog.String("adapter", "discord")),
sessions: make(map[string]*discordgo.Session),
}
if log == nil {
log = slog.Default()
}
return &DiscordAdapter{
logger: log.With(slog.String("adapter", "discord")),
sessions: make(map[string]*discordgo.Session),
handlerRemovers: make(map[string]func()),
seenMessages: make(map[string]time.Time),
}
}
func (a *DiscordAdapter) Type() channel.ChannelType {
return Type
return Type
}
func (a *DiscordAdapter) Descriptor() channel.Descriptor {
return channel.Descriptor{
Type: Type,
DisplayName: "Discord",
Capabilities: channel.ChannelCapabilities{
Text: true,
Markdown: true,
Reply: true,
Attachments: true,
Media: true,
Streaming: true,
BlockStreaming: true,
Reactions: true,
},
ConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"botToken": {
Type: channel.FieldSecret,
Required: true,
Title: "Bot Token",
},
},
},
UserConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"user_id": {Type: channel.FieldString},
"channel_id": {Type: channel.FieldString},
"guild_id": {Type: channel.FieldString},
"username": {Type: channel.FieldString},
},
},
TargetSpec: channel.TargetSpec{
Format: "channel_id | user_id",
Hints: []channel.TargetHint{
{Label: "Channel ID", Example: "1234567890123456789"},
{Label: "User ID", Example: "1234567890123456789"},
},
},
}
return channel.Descriptor{
Type: Type,
DisplayName: "Discord",
Capabilities: channel.ChannelCapabilities{
Text: true,
Markdown: true,
Reply: true,
Attachments: true,
Media: true,
Streaming: true,
BlockStreaming: true,
Reactions: true,
},
ConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"botToken": {
Type: channel.FieldSecret,
Required: true,
Title: "Bot Token",
},
},
},
UserConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"user_id": {Type: channel.FieldString},
"channel_id": {Type: channel.FieldString},
"guild_id": {Type: channel.FieldString},
"username": {Type: channel.FieldString},
},
},
TargetSpec: channel.TargetSpec{
Format: "channel_id | user_id",
Hints: []channel.TargetHint{
{Label: "Channel ID", Example: "1234567890123456789"},
{Label: "User ID", Example: "1234567890123456789"},
},
},
}
}
func (a *DiscordAdapter) getOrCreateSession(token, configID string) (*discordgo.Session, error) {
a.mu.RLock()
session, ok := a.sessions[token]
a.mu.RUnlock()
if ok {
return session, nil
}
a.mu.RLock()
session, ok := a.sessions[token]
a.mu.RUnlock()
if ok {
return session, nil
}
a.mu.Lock()
defer a.mu.Unlock()
if s, ok := a.sessions[token]; ok {
return s, nil
}
a.mu.Lock()
defer a.mu.Unlock()
if s, ok := a.sessions[token]; ok {
return s, nil
}
session, err := discordgo.New("Bot " + token)
if err != nil {
a.logger.Error("create session failed", slog.String("config_id", configID), slog.Any("error", err))
return nil, err
}
session, err := discordgo.New("Bot " + token)
if err != nil {
a.logger.Error("create session failed", slog.String("config_id", configID), slog.Any("error", err))
return nil, err
}
session.Identify.Intents = discordgo.IntentsAll
a.sessions[token] = session
return session, nil
session.Identify.Intents = discordgo.IntentsAll
a.sessions[token] = session
return session, nil
}
func (a *DiscordAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, handler channel.InboundHandler) (channel.Connection, error) {
if a.logger != nil {
a.logger.Info("start", slog.String("config_id", cfg.ID))
}
if a.logger != nil {
a.logger.Info("start", slog.String("config_id", cfg.ID))
}
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return nil, err
}
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return nil, err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return nil, err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return nil, err
}
session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author != nil && m.Author.Bot {
return
}
remove := session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author != nil && m.Author.Bot {
return
}
if ctx.Err() != nil {
return
}
if ctx.Err() != nil {
return
}
text := strings.TrimSpace(m.Content)
botId := s.State.User.ID
if text == "" && len(m.Attachments) == 0 {
return
}
if a.isDuplicateInbound(discordCfg.BotToken, m.ID) {
return
}
attachments := a.collectAttachments(m.Message)
chatType := "direct"
if m.GuildID != "" {
chatType = "guild"
}
text := strings.TrimSpace(m.Content)
botId := s.State.User.ID
if text == "" && len(m.Attachments) == 0 {
return
}
isMentioned := a.isBotMentioned(m.Message, botId)
isReplyToBot := m.ReferencedMessage != nil &&
m.ReferencedMessage.Author != nil &&
m.ReferencedMessage.Author.ID == botId
attachments := a.collectAttachments(m.Message)
chatType := "direct"
if m.GuildID != "" {
chatType = "guild"
}
msg := channel.InboundMessage{
Channel: Type,
Message: channel.Message{
ID: m.ID,
Format: channel.MessageFormatPlain,
Text: text,
Attachments: attachments,
},
BotID: cfg.BotID,
ReplyTarget: m.ChannelID,
Sender: channel.Identity{
SubjectID: m.Author.ID,
DisplayName: m.Author.Username,
Attributes: map[string]string{
"user_id": m.Author.ID,
"username": m.Author.Username,
},
},
Conversation: channel.Conversation{
ID: m.ChannelID,
Type: chatType,
},
ReceivedAt: time.Now().UTC(),
Source: "discord",
Metadata: map[string]any{
"guild_id": m.GuildID,
"is_mentioned": isMentioned,
"is_reply_to_bot": isReplyToBot,
},
}
isMentioned := a.isBotMentioned(m.Message, botId)
isReplyToBot := m.ReferencedMessage != nil &&
m.ReferencedMessage.Author != nil &&
m.ReferencedMessage.Author.ID == botId
if a.logger != nil {
a.logger.Info("inbound received",
slog.String("config_id", cfg.ID),
slog.String("chat_type", chatType),
slog.String("user_id", m.Author.ID),
slog.String("username", m.Author.Username),
slog.String("text", common.SummarizeText(text)),
)
}
msg := channel.InboundMessage{
Channel: Type,
Message: channel.Message{
ID: m.ID,
Format: channel.MessageFormatPlain,
Text: text,
Attachments: attachments,
},
BotID: cfg.BotID,
ReplyTarget: m.ChannelID,
Sender: channel.Identity{
SubjectID: m.Author.ID,
DisplayName: m.Author.Username,
Attributes: map[string]string{
"user_id": m.Author.ID,
"username": m.Author.Username,
},
},
Conversation: channel.Conversation{
ID: m.ChannelID,
Type: chatType,
},
ReceivedAt: time.Now().UTC(),
Source: "discord",
Metadata: map[string]any{
"guild_id": m.GuildID,
"is_mentioned": isMentioned,
"is_reply_to_bot": isReplyToBot,
},
}
go func() {
if err := handler(ctx, cfg, msg); err != nil && a.logger != nil {
a.logger.Error("handle inbound failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
}
}()
})
if a.logger != nil {
a.logger.Info("inbound received",
slog.String("config_id", cfg.ID),
slog.String("chat_type", chatType),
slog.String("user_id", m.Author.ID),
slog.String("username", m.Author.Username),
slog.String("text", common.SummarizeText(text)),
)
}
if err := session.Open(); err != nil {
return nil, fmt.Errorf("discord open connection: %w", err)
}
go func() {
if err := handler(ctx, cfg, msg); err != nil && a.logger != nil {
a.logger.Error("handle inbound failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
}
}()
})
stop := func(stopCtx context.Context) error {
if a.logger != nil {
a.logger.Info("stop", slog.String("config_id", cfg.ID))
}
return session.Close()
}
a.swapHandlerRemover(discordCfg.BotToken, remove)
return channel.NewConnection(cfg, stop), nil
if err := session.Open(); err != nil {
return nil, fmt.Errorf("discord open connection: %w", err)
}
stop := func(stopCtx context.Context) error {
if a.logger != nil {
a.logger.Info("stop", slog.String("config_id", cfg.ID))
}
remove := a.clearSessionState(discordCfg.BotToken)
if remove != nil {
remove()
}
return session.Close()
}
return channel.NewConnection(cfg, stop), nil
}
func (a *DiscordAdapter) Send(ctx context.Context, cfg channel.ChannelConfig, msg channel.OutboundMessage) error {
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return err
}
channelID := strings.TrimSpace(msg.Target)
if channelID == "" {
return fmt.Errorf("discord target is required")
}
channelID := strings.TrimSpace(msg.Target)
if channelID == "" {
return fmt.Errorf("discord target is required")
}
err = sendDiscordText(session, channelID, msg)
return err
err = sendDiscordText(session, channelID, msg)
return err
}
func sendDiscordText(session *discordgo.Session, channelID string, message channel.OutboundMessage) error {
text_truncated := truncateDiscordText(message.Message.Text)
var err error
if message.Message.Reply != nil && message.Message.Reply.MessageID != "" {
_, err = session.ChannelMessageSendReply(channelID, text_truncated, &discordgo.MessageReference{
ChannelID: channelID,
MessageID: message.Message.Reply.MessageID,
})
} else {
_, err = session.ChannelMessageSend(channelID, text_truncated)
}
textTruncated := truncateDiscordText(message.Message.Text)
var err error
if message.Message.Reply != nil && message.Message.Reply.MessageID != "" {
_, err = session.ChannelMessageSendReply(channelID, textTruncated, &discordgo.MessageReference{
ChannelID: channelID,
MessageID: message.Message.Reply.MessageID,
})
} else {
_, err = session.ChannelMessageSend(channelID, textTruncated)
}
return err
return err
}
func truncateDiscordText(text string) string {
const discordMaxLength = 2000
if len(text) > discordMaxLength {
text = text[:discordMaxLength-3] + "..."
}
return text
const discordMaxLength = 2000
if len(text) > discordMaxLength {
text = text[:discordMaxLength-3] + "..."
}
return text
}
func (a *DiscordAdapter) OpenStream(ctx context.Context, cfg channel.ChannelConfig, target string, opts channel.StreamOptions) (channel.OutboundStream, error) {
target = strings.TrimSpace(target)
if target == "" {
return nil, fmt.Errorf("discord target is required")
}
target = strings.TrimSpace(target)
if target == "" {
return nil, fmt.Errorf("discord target is required")
}
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return nil, err
}
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return nil, err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return nil, err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return nil, err
}
return &discordOutboundStream{
adapter: a,
cfg: cfg,
target: target,
reply: opts.Reply,
session: session,
}, nil
return &discordOutboundStream{
adapter: a,
cfg: cfg,
target: target,
reply: opts.Reply,
session: session,
}, nil
}
func (a *DiscordAdapter) ProcessingStarted(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, info channel.ProcessingStatusInfo) (channel.ProcessingStatusHandle, error) {
chatID := strings.TrimSpace(info.ReplyTarget)
if chatID == "" {
return channel.ProcessingStatusHandle{}, nil
}
chatID := strings.TrimSpace(info.ReplyTarget)
if chatID == "" {
return channel.ProcessingStatusHandle{}, nil
}
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return channel.ProcessingStatusHandle{}, err
}
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return channel.ProcessingStatusHandle{}, err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return channel.ProcessingStatusHandle{}, err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return channel.ProcessingStatusHandle{}, err
}
// Discord typing indicator
err = session.ChannelTyping(chatID)
return channel.ProcessingStatusHandle{}, err
// Discord typing indicator
err = session.ChannelTyping(chatID)
return channel.ProcessingStatusHandle{}, err
}
func (a *DiscordAdapter) ProcessingCompleted(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, info channel.ProcessingStatusInfo, handle channel.ProcessingStatusHandle) error {
return nil
return nil
}
func (a *DiscordAdapter) ProcessingFailed(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, info channel.ProcessingStatusInfo, handle channel.ProcessingStatusHandle, cause error) error {
return nil
return nil
}
func (a *DiscordAdapter) React(ctx context.Context, cfg channel.ChannelConfig, target string, messageID string, emoji string) error {
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return err
}
return session.MessageReactionAdd(target, messageID, emoji)
return session.MessageReactionAdd(target, messageID, emoji)
}
func (a *DiscordAdapter) Unreact(ctx context.Context, cfg channel.ChannelConfig, target string, messageID string, emoji string) error {
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
discordCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return err
}
session, err := a.getOrCreateSession(discordCfg.BotToken, cfg.ID)
if err != nil {
return err
}
return session.MessageReactionRemove(target, messageID, emoji, "@me")
return session.MessageReactionRemove(target, messageID, emoji, "@me")
}
func (a *DiscordAdapter) NormalizeConfig(raw map[string]any) (map[string]any, error) {
return normalizeConfig(raw)
return normalizeConfig(raw)
}
func (a *DiscordAdapter) NormalizeUserConfig(raw map[string]any) (map[string]any, error) {
return normalizeUserConfig(raw)
return normalizeUserConfig(raw)
}
func (a *DiscordAdapter) NormalizeTarget(raw string) string {
return normalizeTarget(raw)
return normalizeTarget(raw)
}
func (a *DiscordAdapter) ResolveTarget(userConfig map[string]any) (string, error) {
return resolveTarget(userConfig)
return resolveTarget(userConfig)
}
func (a *DiscordAdapter) MatchBinding(config map[string]any, criteria channel.BindingCriteria) bool {
return matchBinding(config, criteria)
return matchBinding(config, criteria)
}
func (a *DiscordAdapter) BuildUserConfig(identity channel.Identity) map[string]any {
return buildUserConfig(identity)
return buildUserConfig(identity)
}
func (a *DiscordAdapter) collectAttachments(msg *discordgo.Message) []channel.Attachment {
if msg == nil || len(msg.Attachments) == 0 {
return nil
}
if msg == nil || len(msg.Attachments) == 0 {
return nil
}
attachments := make([]channel.Attachment, 0, len(msg.Attachments))
for _, att := range msg.Attachments {
attachment := channel.Attachment{
Type: channel.AttachmentFile,
URL: att.URL,
PlatformKey: att.ID,
SourcePlatform: Type.String(),
Name: att.Filename,
Size: int64(att.Size),
}
attachments := make([]channel.Attachment, 0, len(msg.Attachments))
for _, att := range msg.Attachments {
attachment := channel.Attachment{
Type: channel.AttachmentFile,
URL: att.URL,
PlatformKey: att.ID,
SourcePlatform: Type.String(),
Name: att.Filename,
Size: int64(att.Size),
}
if att.ContentType != "" {
switch {
case strings.HasPrefix(att.ContentType, "image/"):
attachment.Type = channel.AttachmentImage
attachment.Width = att.Width
attachment.Height = att.Height
case strings.HasPrefix(att.ContentType, "video/"):
attachment.Type = channel.AttachmentVideo
case strings.HasPrefix(att.ContentType, "audio/"):
attachment.Type = channel.AttachmentAudio
}
}
if att.ContentType != "" {
switch {
case strings.HasPrefix(att.ContentType, "image/"):
attachment.Type = channel.AttachmentImage
attachment.Width = att.Width
attachment.Height = att.Height
case strings.HasPrefix(att.ContentType, "video/"):
attachment.Type = channel.AttachmentVideo
case strings.HasPrefix(att.ContentType, "audio/"):
attachment.Type = channel.AttachmentAudio
}
}
attachments = append(attachments, attachment)
}
attachments = append(attachments, attachment)
}
return attachments
return attachments
}
func (a *DiscordAdapter) isBotMentioned(msg *discordgo.Message, botID string) bool {
if msg == nil {
return false
}
if msg == nil {
return false
}
for _, mention := range msg.Mentions {
if mention != nil && mention.ID == botID {
return true
}
}
for _, mention := range msg.Mentions {
if mention != nil && mention.ID == botID {
return true
}
}
if msg.MentionEveryone {
return true
}
if msg.MentionEveryone {
return true
}
botMention := "<@" + botID + ">"
botNickMention := "<@!" + botID + ">"
content := strings.ToLower(msg.Content)
return strings.Contains(content, strings.ToLower(botMention)) ||
strings.Contains(content, strings.ToLower(botNickMention))
}
botMention := "<@" + botID + ">"
botNickMention := "<@!" + botID + ">"
content := strings.ToLower(msg.Content)
return strings.Contains(content, strings.ToLower(botMention)) ||
strings.Contains(content, strings.ToLower(botNickMention))
}
func (a *DiscordAdapter) isDuplicateInbound(token, messageID string) bool {
if strings.TrimSpace(token) == "" || strings.TrimSpace(messageID) == "" {
return false
}
now := time.Now().UTC()
expireBefore := now.Add(-inboundDedupTTL)
a.mu.Lock()
defer a.mu.Unlock()
for key, seenAt := range a.seenMessages {
if seenAt.Before(expireBefore) {
delete(a.seenMessages, key)
}
}
seenKey := token + ":" + messageID
if _, ok := a.seenMessages[seenKey]; ok {
return true
}
a.seenMessages[seenKey] = now
return false
}
func (a *DiscordAdapter) swapHandlerRemover(token string, remove func()) {
a.mu.Lock()
defer a.mu.Unlock()
if oldRemove := a.handlerRemovers[token]; oldRemove != nil {
oldRemove()
}
a.handlerRemovers[token] = remove
}
func (a *DiscordAdapter) clearSessionState(token string) func() {
a.mu.Lock()
defer a.mu.Unlock()
remove := a.handlerRemovers[token]
delete(a.handlerRemovers, token)
delete(a.sessions, token)
return remove
}
+18 -23
View File
@@ -42,7 +42,7 @@ func (s *discordOutboundStream) Push(ctx context.Context, event channel.StreamEv
switch event.Type {
case channel.StreamEventStatus:
if event.Status == channel.StreamStatusStarted {
return s.ensureMessage(ctx, "Thinking...")
return s.ensureMessage("Thinking...")
}
return nil
@@ -56,7 +56,7 @@ func (s *discordOutboundStream) Push(ctx context.Context, event channel.StreamEv
// Discord has strict rate limits, only update periodically
if time.Since(s.lastUpdate) > 2*time.Second {
return s.updateMessage(ctx)
return s.updateMessage()
}
return nil
@@ -64,14 +64,14 @@ func (s *discordOutboundStream) Push(ctx context.Context, event channel.StreamEv
if event.Final != nil && !event.Final.Message.IsEmpty() {
finalText := strings.TrimSpace(event.Final.Message.PlainText())
if finalText != "" {
return s.finalizeMessage(ctx, finalText)
return s.finalizeMessage(finalText)
}
}
s.mu.Lock()
finalText := strings.TrimSpace(s.buffer.String())
s.mu.Unlock()
if finalText != "" {
return s.finalizeMessage(ctx, finalText)
return s.finalizeMessage(finalText)
}
return nil
@@ -80,7 +80,7 @@ func (s *discordOutboundStream) Push(ctx context.Context, event channel.StreamEv
if errText == "" {
return nil
}
return s.finalizeMessage(ctx, "Error: "+errText)
return s.finalizeMessage("Error: " + errText)
case channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd, channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted, channel.StreamEventProcessingFailed, channel.StreamEventToolCallStart, channel.StreamEventToolCallEnd:
// Status events - no action needed for Discord
@@ -104,7 +104,7 @@ func (s *discordOutboundStream) Close(ctx context.Context) error {
return nil
}
func (s *discordOutboundStream) ensureMessage(ctx context.Context, text string) error {
func (s *discordOutboundStream) ensureMessage(text string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -112,11 +112,7 @@ func (s *discordOutboundStream) ensureMessage(ctx context.Context, text string)
return nil
}
// Discord limit: 2000 characters
content := text
if len(content) > 2000 {
content = content[:1997] + "..."
}
content := truncateDiscordText(text)
msg, err := s.session.ChannelMessageSend(s.target, content)
if err != nil {
@@ -128,7 +124,7 @@ func (s *discordOutboundStream) ensureMessage(ctx context.Context, text string)
return nil
}
func (s *discordOutboundStream) updateMessage(ctx context.Context) error {
func (s *discordOutboundStream) updateMessage() error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -141,10 +137,7 @@ func (s *discordOutboundStream) updateMessage(ctx context.Context) error {
return nil
}
// Discord limit
if len(content) > 2000 {
content = content[:1997] + "..."
}
content = truncateDiscordText(content)
_, err := s.session.ChannelMessageEdit(s.target, s.msgID, content)
if err != nil {
@@ -155,18 +148,20 @@ func (s *discordOutboundStream) updateMessage(ctx context.Context) error {
return nil
}
func (s *discordOutboundStream) finalizeMessage(ctx context.Context, text string) error {
func (s *discordOutboundStream) finalizeMessage(text string) error {
s.mu.Lock()
defer s.mu.Unlock()
// Discord limit
if len(text) > 2000 {
text = text[:1997] + "..."
}
text = truncateDiscordText(text)
if s.msgID == "" {
_, err := s.session.ChannelMessageSend(s.target, text)
return err
msg, err := s.session.ChannelMessageSend(s.target, text)
if err != nil {
return err
}
s.msgID = msg.ID
s.lastUpdate = time.Now()
return nil
}
_, err := s.session.ChannelMessageEdit(s.target, s.msgID, text)