feat: sync bot keyboards and callbacks

Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery).

Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming.
This commit is contained in:
A 2026-07-19 20:38:48 +08:00
parent 0c99ae0a9d
commit bf965f610c
80 changed files with 7212 additions and 349 deletions

View file

@ -0,0 +1,150 @@
package redisstore
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"telesrv/internal/domain"
"telesrv/internal/store"
)
const botCallbackAnswerChannel = "telesrv:bot_callback:answers"
type BotCallbackRegistryStore struct {
c redis.UniversalClient
}
func NewBotCallbackRegistryStore(c redis.UniversalClient) *BotCallbackRegistryStore {
return &BotCallbackRegistryStore{c: c}
}
func botCallbackKey(queryID int64) string {
return fmt.Sprintf("telesrv:bot_callback:%d", queryID)
}
var putBotCallbackScript = redis.NewScript(`
if redis.call('EXISTS', KEYS[1]) ~= 0 then
return 0
end
redis.call('HSET', KEYS[1],
'bot_user_id', ARGV[1],
'user_id', ARGV[2],
'created_at_unix_nano', ARGV[3])
redis.call('PEXPIRE', KEYS[1], ARGV[4])
return 1
`)
func (s *BotCallbackRegistryStore) PutBotCallbackPending(ctx context.Context, pending store.BotCallbackPending, ttl time.Duration) (bool, error) {
if s == nil || s.c == nil || pending.QueryID == 0 || pending.BotUserID <= 0 || pending.UserID <= 0 || ttl <= 0 {
return false, fmt.Errorf("invalid bot callback pending")
}
createdAt := pending.CreatedAt
if createdAt.IsZero() {
createdAt = time.Now()
}
result, err := putBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(pending.QueryID)},
pending.BotUserID, pending.UserID, createdAt.UnixNano(), ttl.Milliseconds()).Int64()
if err != nil {
return false, fmt.Errorf("put bot callback pending: %w", err)
}
return result == 1, nil
}
var resolveBotCallbackScript = redis.NewScript(`
if redis.call('HGET', KEYS[1], 'bot_user_id') ~= ARGV[1] then
return 0
end
if redis.call('HEXISTS', KEYS[1], 'answer') ~= 0 then
return 0
end
redis.call('HSET', KEYS[1], 'answer', ARGV[2])
redis.call('PUBLISH', ARGV[3], ARGV[4])
return 1
`)
func (s *BotCallbackRegistryStore) ResolveBotCallback(ctx context.Context, botUserID, queryID int64, answer domain.BotCallbackAnswer) (bool, error) {
if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 {
return false, nil
}
answerJSON, err := json.Marshal(answer)
if err != nil {
return false, fmt.Errorf("marshal bot callback answer: %w", err)
}
pushJSON, err := json.Marshal(store.BotCallbackAnswerPush{QueryID: queryID, BotUserID: botUserID, Answer: answer})
if err != nil {
return false, fmt.Errorf("marshal bot callback answer push: %w", err)
}
result, err := resolveBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(queryID)},
strconv.FormatInt(botUserID, 10), answerJSON, botCallbackAnswerChannel, pushJSON).Int64()
if err != nil {
return false, fmt.Errorf("resolve bot callback: %w", err)
}
return result == 1, nil
}
func (s *BotCallbackRegistryStore) GetBotCallbackAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) {
if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 {
return domain.BotCallbackAnswer{}, false, nil
}
values, err := s.c.HMGet(ctx, botCallbackKey(queryID), "bot_user_id", "answer").Result()
if err != nil {
return domain.BotCallbackAnswer{}, false, fmt.Errorf("get bot callback answer: %w", err)
}
if len(values) != 2 || values[0] == nil || values[1] == nil || fmt.Sprint(values[0]) != strconv.FormatInt(botUserID, 10) {
return domain.BotCallbackAnswer{}, false, nil
}
var answer domain.BotCallbackAnswer
if err := json.Unmarshal([]byte(fmt.Sprint(values[1])), &answer); err != nil {
return domain.BotCallbackAnswer{}, false, fmt.Errorf("decode bot callback answer: %w", err)
}
return answer, true, nil
}
var deleteBotCallbackScript = redis.NewScript(`
if redis.call('HGET', KEYS[1], 'bot_user_id') ~= ARGV[1] then
return 0
end
return redis.call('DEL', KEYS[1])
`)
func (s *BotCallbackRegistryStore) DeleteBotCallbackPending(ctx context.Context, botUserID, queryID int64) error {
if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 {
return nil
}
if _, err := deleteBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(queryID)}, strconv.FormatInt(botUserID, 10)).Result(); err != nil && err != redis.Nil {
return fmt.Errorf("delete bot callback pending: %w", err)
}
return nil
}
func (s *BotCallbackRegistryStore) SubscribeBotCallbackAnswers(ctx context.Context, handle func(context.Context, store.BotCallbackAnswerPush)) error {
if s == nil || s.c == nil || handle == nil {
return nil
}
pubsub := s.c.Subscribe(ctx, botCallbackAnswerChannel)
defer pubsub.Close()
if _, err := pubsub.Receive(ctx); err != nil {
return fmt.Errorf("subscribe bot callback answers: %w", err)
}
channel := pubsub.Channel(redis.WithChannelSize(256))
for {
select {
case <-ctx.Done():
return nil
case message, ok := <-channel:
if !ok {
return nil
}
var push store.BotCallbackAnswerPush
if err := json.Unmarshal([]byte(message.Payload), &push); err != nil || push.QueryID == 0 || push.BotUserID <= 0 {
continue
}
handle(ctx, push)
}
}
}

View file

@ -0,0 +1,76 @@
package redisstore
import (
"context"
"os"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestRedisBotCallbackRegistryCrossInstanceCASAndPubSub(t *testing.T) {
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
if addr == "" {
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
clientA, err := Open(ctx, addr, "", 0)
if err != nil {
t.Fatal(err)
}
defer clientA.Close()
clientB, err := Open(ctx, addr, "", 0)
if err != nil {
t.Fatal(err)
}
defer clientB.Close()
a, b := NewBotCallbackRegistryStore(clientA), NewBotCallbackRegistryStore(clientB)
queryID := time.Now().UnixNano()
defer a.DeleteBotCallbackPending(context.Background(), 1001, queryID)
pushes := make(chan store.BotCallbackAnswerPush, 1)
subscribed := make(chan struct{})
go func() {
_ = b.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) {
select {
case pushes <- push:
default:
}
})
}()
// Subscribe uses Redis' acknowledgement before consuming Channel. Give that
// acknowledgement one bounded scheduling turn before publishing.
time.AfterFunc(50*time.Millisecond, func() { close(subscribed) })
<-subscribed
created, err := a.PutBotCallbackPending(ctx, store.BotCallbackPending{QueryID: queryID, BotUserID: 1001, UserID: 2001}, time.Second)
if err != nil || !created {
t.Fatalf("put created=%v err=%v", created, err)
}
if duplicate, err := b.PutBotCallbackPending(ctx, store.BotCallbackPending{QueryID: queryID, BotUserID: 1001, UserID: 2002}, time.Second); err != nil || duplicate {
t.Fatalf("duplicate=%v err=%v", duplicate, err)
}
answer := domain.BotCallbackAnswer{Message: "done", CacheTime: 3}
if resolved, err := b.ResolveBotCallback(ctx, 9999, queryID, answer); err != nil || resolved {
t.Fatalf("foreign resolve=%v err=%v", resolved, err)
}
if resolved, err := b.ResolveBotCallback(ctx, 1001, queryID, answer); err != nil || !resolved {
t.Fatalf("owner resolve=%v err=%v", resolved, err)
}
if second, err := a.ResolveBotCallback(ctx, 1001, queryID, domain.BotCallbackAnswer{Message: "second"}); err != nil || second {
t.Fatalf("second resolve=%v err=%v", second, err)
}
stored, found, err := a.GetBotCallbackAnswer(ctx, 1001, queryID)
if err != nil || !found || stored.Message != "done" {
t.Fatalf("stored=%#v found=%v err=%v", stored, found, err)
}
select {
case push := <-pushes:
if push.QueryID != queryID || push.BotUserID != 1001 || push.Answer.Message != "done" {
t.Fatalf("push=%#v", push)
}
case <-ctx.Done():
t.Fatal("missing cross-instance callback pubsub")
}
}