Reduce Telegram tool message noise
This commit is contained in:
@@ -60,6 +60,25 @@ func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, opt
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string) error {
|
||||
params := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"action": action,
|
||||
}
|
||||
var ok bool
|
||||
return c.postJSON(ctx, "sendChatAction", params, &ok)
|
||||
}
|
||||
|
||||
func (c *Client) SendMessageDraft(ctx context.Context, chatID int64, draftID int64, text string) error {
|
||||
params := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"draft_id": draftID,
|
||||
"text": text,
|
||||
}
|
||||
var ok bool
|
||||
return c.postJSON(ctx, "sendMessageDraft", params, &ok)
|
||||
}
|
||||
|
||||
func (c *Client) EditMessageText(ctx context.Context, chatID int64, messageID int, text string, opts EditMessageTextOptions) (Message, error) {
|
||||
params := map[string]any{
|
||||
"chat_id": chatID,
|
||||
|
||||
@@ -42,9 +42,20 @@ type Bot struct {
|
||||
}
|
||||
|
||||
type outputState struct {
|
||||
chatID int64
|
||||
assistant strings.Builder
|
||||
sentAny bool
|
||||
chatID int64
|
||||
assistant strings.Builder
|
||||
sentAny bool
|
||||
tools map[string]toolMessageState
|
||||
workingIndicatorOff context.CancelFunc
|
||||
}
|
||||
|
||||
type toolMessageState struct {
|
||||
chatID int64
|
||||
messageID int
|
||||
toolHTML string
|
||||
approvalHTML string
|
||||
approvalMarkup *InlineKeyboardMarkup
|
||||
editedAt string
|
||||
}
|
||||
|
||||
type codexThreadItemView struct {
|
||||
@@ -1039,9 +1050,9 @@ func (b *Bot) handleApprovalCallback(ctx context.Context, callback *CallbackQuer
|
||||
if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Sent to Codex."); err != nil {
|
||||
return err
|
||||
}
|
||||
updated := renderApprovalHTML(approval.Kind, json.RawMessage(approval.PayloadJSON), approvalStatusLine(decision))
|
||||
updated := b.resolveApprovalMessageHTML(approval, decision)
|
||||
_, err = b.tg.EditMessageText(ctx, callback.Message.Chat.ID, callback.Message.MessageID, updated, EditMessageTextOptions{ParseMode: "HTML"})
|
||||
return err
|
||||
return ignoreTelegramMessageNotModified(err)
|
||||
}
|
||||
|
||||
func (b *Bot) handleCodexEvents(ctx context.Context) {
|
||||
@@ -1288,7 +1299,7 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
|
||||
return b.flushAssistantMessage(ctx, params.ThreadID)
|
||||
}
|
||||
if params.ThreadID != "" {
|
||||
return b.sendOutputHTMLBlock(ctx, params.ThreadID, renderCodexItemStarted(item))
|
||||
return b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemStarted(item))
|
||||
}
|
||||
case "item/agentMessage/delta":
|
||||
var params struct {
|
||||
@@ -1322,7 +1333,7 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
|
||||
return b.flushAssistantMessage(ctx, params.ThreadID)
|
||||
}
|
||||
if params.ThreadID != "" {
|
||||
return b.sendOutputHTMLBlock(ctx, params.ThreadID, renderCodexItemCompleted(item))
|
||||
return b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemCompleted(item))
|
||||
}
|
||||
case "turn/diff/updated":
|
||||
var params struct {
|
||||
@@ -1424,9 +1435,16 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event
|
||||
return err
|
||||
}
|
||||
text := renderApprovalHTML(kind, event.Params, "")
|
||||
markup := approvalMarkup(approval.ID)
|
||||
if msg, ok, err := b.attachApprovalToToolMessage(ctx, params.ThreadID, params.ItemID, text, markup); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
return b.store.UpdatePendingApprovalMessage(ctx, approval.ID, msg.Chat.ID, msg.MessageID)
|
||||
}
|
||||
|
||||
msg, err := b.tg.SendMessage(ctx, thread.TelegramUserID, text, SendMessageOptions{
|
||||
ParseMode: "HTML",
|
||||
ReplyMarkup: approvalMarkup(approval.ID),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1434,16 +1452,73 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event
|
||||
return b.store.UpdatePendingApprovalMessage(ctx, approval.ID, msg.Chat.ID, msg.MessageID)
|
||||
}
|
||||
|
||||
func (b *Bot) newOutputState(chatID int64) *outputState {
|
||||
return &outputState{
|
||||
chatID: chatID,
|
||||
tools: make(map[string]toolMessageState),
|
||||
workingIndicatorOff: b.startWorkingIndicator(chatID),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) registerOutput(threadID string, chatID int64) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.outputs[threadID] = &outputState{chatID: chatID}
|
||||
if state := b.outputs[threadID]; state != nil && state.workingIndicatorOff != nil {
|
||||
state.workingIndicatorOff()
|
||||
}
|
||||
b.outputs[threadID] = b.newOutputState(chatID)
|
||||
}
|
||||
|
||||
func (b *Bot) clearOutput(threadID string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
state := b.outputs[threadID]
|
||||
delete(b.outputs, threadID)
|
||||
b.mu.Unlock()
|
||||
if state != nil && state.workingIndicatorOff != nil {
|
||||
state.workingIndicatorOff()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) startWorkingIndicator(chatID int64) context.CancelFunc {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
draftID := time.Now().UnixNano()
|
||||
go func() {
|
||||
useDraft := true
|
||||
sendDraft := func() bool {
|
||||
return b.tg.SendMessageDraft(ctx, chatID, draftID, "") == nil
|
||||
}
|
||||
sendTyping := func() {
|
||||
if err := b.tg.SendChatAction(ctx, chatID, "typing"); err != nil && ctx.Err() == nil {
|
||||
b.logger.Printf("send typing action: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !sendDraft() {
|
||||
useDraft = false
|
||||
sendTyping()
|
||||
}
|
||||
|
||||
draftTicker := time.NewTicker(25 * time.Second)
|
||||
typingTicker := time.NewTicker(4 * time.Second)
|
||||
defer draftTicker.Stop()
|
||||
defer typingTicker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-draftTicker.C:
|
||||
if useDraft && !sendDraft() {
|
||||
useDraft = false
|
||||
sendTyping()
|
||||
}
|
||||
case <-typingTicker.C:
|
||||
if !useDraft {
|
||||
sendTyping()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return cancel
|
||||
}
|
||||
|
||||
func (b *Bot) hasAssistantText(threadID string) bool {
|
||||
@@ -1513,6 +1588,182 @@ func (b *Bot) sendOutputHTMLBlock(ctx context.Context, threadID, htmlText string
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s toolMessageState) html() string {
|
||||
return FitHTMLMessage(addEditedAtLine(combineToolApprovalHTML(s.toolHTML, s.approvalHTML), s.editedAt), TelegramHTMLMessageLimit)
|
||||
}
|
||||
|
||||
func combineToolApprovalHTML(toolHTML, approvalHTML string) string {
|
||||
toolHTML = strings.TrimSpace(toolHTML)
|
||||
approvalHTML = strings.TrimSpace(approvalHTML)
|
||||
switch {
|
||||
case toolHTML == "":
|
||||
return approvalHTML
|
||||
case approvalHTML == "":
|
||||
return toolHTML
|
||||
default:
|
||||
return toolHTML + "\n\n" + approvalHTML
|
||||
}
|
||||
}
|
||||
|
||||
func addEditedAtLine(htmlText, editedAt string) string {
|
||||
htmlText = strings.TrimSpace(htmlText)
|
||||
if htmlText == "" || editedAt == "" {
|
||||
return htmlText
|
||||
}
|
||||
line := EscapeHTML("Edited at: " + editedAt)
|
||||
quoteIndex := strings.Index(htmlText, "<blockquote expandable>")
|
||||
if quoteIndex < 0 {
|
||||
return htmlText + "\n" + line
|
||||
}
|
||||
summary := strings.TrimRight(htmlText[:quoteIndex], "\n")
|
||||
details := strings.TrimLeft(htmlText[quoteIndex:], "\n")
|
||||
if summary == "" {
|
||||
return line + "\n" + details
|
||||
}
|
||||
return summary + "\n" + line + "\n" + details
|
||||
}
|
||||
|
||||
func editedAtTimestamp() string {
|
||||
return time.Now().UTC().Format("2006-01-02 15:04:05 MST")
|
||||
}
|
||||
|
||||
func (b *Bot) upsertToolMessage(ctx context.Context, threadID, itemID, htmlText string) error {
|
||||
htmlText = strings.TrimSpace(htmlText)
|
||||
if htmlText == "" {
|
||||
return nil
|
||||
}
|
||||
if itemID == "" {
|
||||
return b.sendOutputHTMLBlock(ctx, threadID, htmlText)
|
||||
}
|
||||
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
|
||||
return err
|
||||
}
|
||||
chatID, err := b.outputChatID(ctx, threadID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
state := b.outputs[threadID]
|
||||
if state != nil && state.tools == nil {
|
||||
state.tools = make(map[string]toolMessageState)
|
||||
}
|
||||
if state != nil {
|
||||
tool, ok := state.tools[itemID]
|
||||
if ok && tool.messageID != 0 {
|
||||
tool.toolHTML = htmlText
|
||||
tool.editedAt = editedAtTimestamp()
|
||||
state.tools[itemID] = tool
|
||||
combined := tool.html()
|
||||
msgChatID := tool.chatID
|
||||
msgID := tool.messageID
|
||||
markup := tool.approvalMarkup
|
||||
b.mu.Unlock()
|
||||
_, err := b.tg.EditMessageText(ctx, msgChatID, msgID, combined, EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: markup})
|
||||
if err := ignoreTelegramMessageNotModified(err); err != nil {
|
||||
return err
|
||||
}
|
||||
b.markOutputSent(threadID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
msg, err := b.sendHTMLMessage(ctx, chatID, htmlText, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.mu.Lock()
|
||||
state = b.outputs[threadID]
|
||||
if state != nil {
|
||||
if state.tools == nil {
|
||||
state.tools = make(map[string]toolMessageState)
|
||||
}
|
||||
state.tools[itemID] = toolMessageState{chatID: msg.Chat.ID, messageID: msg.MessageID, toolHTML: htmlText}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
b.markOutputSent(threadID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) attachApprovalToToolMessage(ctx context.Context, threadID, itemID, approvalHTML string, markup *InlineKeyboardMarkup) (Message, bool, error) {
|
||||
approvalHTML = strings.TrimSpace(approvalHTML)
|
||||
if threadID == "" || itemID == "" || approvalHTML == "" {
|
||||
return Message{}, false, nil
|
||||
}
|
||||
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
|
||||
return Message{}, false, err
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
state := b.outputs[threadID]
|
||||
if state == nil {
|
||||
b.mu.Unlock()
|
||||
return Message{}, false, nil
|
||||
}
|
||||
tool, ok := state.tools[itemID]
|
||||
if !ok || tool.messageID == 0 {
|
||||
b.mu.Unlock()
|
||||
return Message{}, false, nil
|
||||
}
|
||||
tool.approvalHTML = approvalHTML
|
||||
tool.approvalMarkup = markup
|
||||
tool.editedAt = editedAtTimestamp()
|
||||
state.tools[itemID] = tool
|
||||
combined := tool.html()
|
||||
msg := Message{MessageID: tool.messageID, Chat: Chat{ID: tool.chatID}}
|
||||
b.mu.Unlock()
|
||||
|
||||
_, err := b.tg.EditMessageText(ctx, msg.Chat.ID, msg.MessageID, combined, EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: markup})
|
||||
if err := ignoreTelegramMessageNotModified(err); err != nil {
|
||||
b.clearToolApproval(threadID, itemID)
|
||||
b.logger.Printf("edit tool approval message %s/%s: %v", threadID, itemID, err)
|
||||
return Message{}, false, nil
|
||||
}
|
||||
b.markOutputSent(threadID)
|
||||
return msg, true, nil
|
||||
}
|
||||
|
||||
func (b *Bot) clearToolApproval(threadID, itemID string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if state := b.outputs[threadID]; state != nil {
|
||||
if tool, ok := state.tools[itemID]; ok {
|
||||
tool.approvalHTML = ""
|
||||
tool.approvalMarkup = nil
|
||||
state.tools[itemID] = tool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) resolveApprovalMessageHTML(approval store.PendingApproval, decision string) string {
|
||||
approvalHTML := renderApprovalHTML(approval.Kind, json.RawMessage(approval.PayloadJSON), approvalStatusLine(decision))
|
||||
if approval.ItemID == "" {
|
||||
return approvalHTML
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
state := b.outputs[approval.CodexThreadID]
|
||||
if state == nil {
|
||||
return approvalHTML
|
||||
}
|
||||
tool, ok := state.tools[approval.ItemID]
|
||||
if !ok || tool.messageID == 0 || tool.messageID != approval.MessageID {
|
||||
return approvalHTML
|
||||
}
|
||||
tool.approvalHTML = approvalHTML
|
||||
tool.approvalMarkup = nil
|
||||
tool.editedAt = editedAtTimestamp()
|
||||
state.tools[approval.ItemID] = tool
|
||||
return tool.html()
|
||||
}
|
||||
|
||||
func ignoreTelegramMessageNotModified(err error) error {
|
||||
if err != nil && strings.Contains(err.Error(), "message is not modified") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (b *Bot) appendAssistantDelta(ctx context.Context, threadID, delta string) error {
|
||||
if delta == "" {
|
||||
return nil
|
||||
@@ -1560,8 +1811,12 @@ func (b *Bot) completeTurnOutput(ctx context.Context, threadID string) error {
|
||||
}
|
||||
chatID := state.chatID
|
||||
sentAny := state.sentAny
|
||||
workingIndicatorOff := state.workingIndicatorOff
|
||||
delete(b.outputs, threadID)
|
||||
b.mu.Unlock()
|
||||
if workingIndicatorOff != nil {
|
||||
workingIndicatorOff()
|
||||
}
|
||||
|
||||
if !sentAny {
|
||||
_, err := b.tg.SendMessage(ctx, chatID, "Done.", SendMessageOptions{})
|
||||
@@ -1606,10 +1861,14 @@ func (b *Bot) sendLong(ctx context.Context, chatID int64, text string) error {
|
||||
}
|
||||
|
||||
func (b *Bot) sendHTML(ctx context.Context, chatID int64, htmlText string) error {
|
||||
_, err := b.tg.SendMessage(ctx, chatID, htmlText, SendMessageOptions{ParseMode: "HTML"})
|
||||
_, err := b.sendHTMLMessage(ctx, chatID, htmlText, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Bot) sendHTMLMessage(ctx context.Context, chatID int64, htmlText string, markup *InlineKeyboardMarkup) (Message, error) {
|
||||
return b.tg.SendMessage(ctx, chatID, htmlText, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup})
|
||||
}
|
||||
|
||||
func (b *Bot) sendError(ctx context.Context, chatID int64, prefix string, err error) error {
|
||||
_, sendErr := b.tg.SendMessage(ctx, chatID, EscapeHTML(prefix+": "+err.Error()), SendMessageOptions{ParseMode: "HTML"})
|
||||
return sendErr
|
||||
|
||||
@@ -66,6 +66,108 @@ func SummaryDetailsHTMLLimited(summary, details string, limit int) string {
|
||||
return SummaryDetailsHTML(summary, suffix)
|
||||
}
|
||||
|
||||
func FitHTMLMessage(htmlText string, limit int) string {
|
||||
if limit <= 0 {
|
||||
limit = TelegramHTMLMessageLimit
|
||||
}
|
||||
htmlText = strings.TrimSpace(htmlText)
|
||||
if len([]rune(htmlText)) <= limit {
|
||||
return htmlText
|
||||
}
|
||||
|
||||
const open = "<blockquote expandable>"
|
||||
const close = "</blockquote>"
|
||||
const truncatedQuote = "...[truncated]"
|
||||
for len([]rune(htmlText)) > limit {
|
||||
contentStart, contentEnd, content := largestBlockquoteContent(htmlText, open, close)
|
||||
if contentStart < 0 {
|
||||
return truncateHTMLText(htmlText, limit)
|
||||
}
|
||||
|
||||
contentRunes := []rune(strings.TrimSpace(html.UnescapeString(content)))
|
||||
over := len([]rune(htmlText)) - limit
|
||||
keep := len(contentRunes) - over - 80
|
||||
if keep < 0 {
|
||||
keep = 0
|
||||
}
|
||||
if keep >= len(contentRunes) {
|
||||
keep = len(contentRunes) - 1
|
||||
}
|
||||
replacementText := truncatedQuote
|
||||
if keep > 0 {
|
||||
replacementText = strings.TrimSpace(string(contentRunes[:keep])) + "\n" + truncatedQuote
|
||||
}
|
||||
replacement := EscapeHTML(replacementText)
|
||||
if replacement == content {
|
||||
return summaryOnlyHTML(htmlText, limit)
|
||||
}
|
||||
htmlText = htmlText[:contentStart] + replacement + htmlText[contentEnd:]
|
||||
}
|
||||
return htmlText
|
||||
}
|
||||
|
||||
func largestBlockquoteContent(htmlText, open, close string) (int, int, string) {
|
||||
bestStart := -1
|
||||
bestEnd := -1
|
||||
bestContent := ""
|
||||
searchFrom := 0
|
||||
for {
|
||||
start := strings.Index(htmlText[searchFrom:], open)
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
start += searchFrom
|
||||
contentStart := start + len(open)
|
||||
end := strings.Index(htmlText[contentStart:], close)
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
end += contentStart
|
||||
content := htmlText[contentStart:end]
|
||||
if bestStart < 0 || len([]rune(content)) > len([]rune(bestContent)) {
|
||||
bestStart = contentStart
|
||||
bestEnd = end
|
||||
bestContent = content
|
||||
}
|
||||
searchFrom = end + len(close)
|
||||
}
|
||||
return bestStart, bestEnd, bestContent
|
||||
}
|
||||
|
||||
func summaryOnlyHTML(htmlText string, limit int) string {
|
||||
const open = "<blockquote expandable>"
|
||||
const close = "</blockquote>"
|
||||
for {
|
||||
start := strings.Index(htmlText, open)
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(htmlText[start+len(open):], close)
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
end += start + len(open) + len(close)
|
||||
htmlText = strings.TrimRight(htmlText[:start], "\n") + "\n" + strings.TrimLeft(htmlText[end:], "\n")
|
||||
}
|
||||
return truncateHTMLText(strings.TrimSpace(htmlText), limit)
|
||||
}
|
||||
|
||||
func truncateHTMLText(htmlText string, limit int) string {
|
||||
if limit <= 0 {
|
||||
limit = TelegramHTMLMessageLimit
|
||||
}
|
||||
suffix := "\n...[truncated]"
|
||||
runes := []rune(htmlText)
|
||||
if len(runes) <= limit {
|
||||
return htmlText
|
||||
}
|
||||
keep := limit - len([]rune(suffix))
|
||||
if keep < 0 {
|
||||
keep = 0
|
||||
}
|
||||
return string(runes[:keep]) + suffix
|
||||
}
|
||||
|
||||
func ChunkText(text string, max int) []string {
|
||||
if max <= 0 {
|
||||
max = TelegramMessageLimit
|
||||
|
||||
@@ -101,6 +101,37 @@ func TestRenderCodexStartedItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolMessageAddsEditedAtBeforeDetails(t *testing.T) {
|
||||
tool := toolMessageState{
|
||||
toolHTML: SummaryDetailsHTML("Tool call: command finished\nCommand: go test ./...", "full output"),
|
||||
editedAt: "2026-05-21 12:34:56 UTC",
|
||||
}
|
||||
text := tool.html()
|
||||
want := "Command: go test ./...\nEdited at: 2026-05-21 12:34:56 UTC\n<blockquote expandable>"
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("edited timestamp not placed before details: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolMessageFitsCombinedApprovalDetails(t *testing.T) {
|
||||
largeToolDetails := strings.Repeat("tool output line\n", 600)
|
||||
largeApprovalDetails := strings.Repeat("approval payload line\n", 600)
|
||||
tool := toolMessageState{
|
||||
toolHTML: SummaryDetailsHTML("Tool call: command finished", largeToolDetails),
|
||||
approvalHTML: SummaryDetailsHTML("Codex requests command approval", largeApprovalDetails),
|
||||
editedAt: "2026-05-21 12:34:56 UTC",
|
||||
}
|
||||
text := tool.html()
|
||||
if len([]rune(text)) > TelegramHTMLMessageLimit {
|
||||
t.Fatalf("tool message exceeds Telegram limit: %d", len([]rune(text)))
|
||||
}
|
||||
for _, want := range []string{"Tool call: command finished", "Codex requests command approval", "Edited at: 2026-05-21 12:34:56 UTC", "...[truncated]"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("fitted tool message missing %q in %q", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeCallbackData(t *testing.T) {
|
||||
threadID, ok := ParseResumeThreadCallbackData(ResumeThreadCallbackData(123))
|
||||
if !ok || threadID != 123 {
|
||||
|
||||
Reference in New Issue
Block a user