Merge branch 'feature/search' into develop

This commit is contained in:
Maxim Lebedev 2023-08-29 06:19:04 +06:00
commit 4844174303
Signed by: toby3d
GPG Key ID: 1F14E25B7C119FC5
16 changed files with 558 additions and 0 deletions

BIN
assets/cards/search/10k.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 KiB

BIN
assets/cards/search/van.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

99
internal/domain/search.go Normal file
View File

@ -0,0 +1,99 @@
package domain
import (
"golang.org/x/text/language"
"golang.org/x/text/message"
"golang.org/x/text/message/catalog"
)
type Search struct {
search string
}
var (
SearchUnd Search = Search{} // "und"
Search10K Search = Search{"10k"} // "10k"
SearchAlcohol Search = Search{"alcohol"} // "alcohol"
SearchBlade Search = Search{"blade"} // "blade"
SearchBlood Search = Search{"blood"} // "blood"
SearchFirearm Search = Search{"firearm"} // "firearm"
SearchFollow Search = Search{"follow"} // "follow"
SearchMask Search = Search{"mask"} // "mask"
SearchVan Search = Search{"van"} // "van"
)
var SearchesBase = [...]Search{
Search10K,
SearchAlcohol,
SearchBlade,
SearchBlood,
SearchFirearm,
SearchFollow,
SearchMask,
SearchVan,
}
func init() {
for _, entry := range [...]CatalogEntry{
{Tag: language.English, Message: "$10,000 in a duffel bag", Key: "$10,000 in a duffel bag"},
{Tag: language.English, Message: "a broken switchblade", Key: "a broken switchblade"},
{Tag: language.English, Message: "a creepy mask left behind", Key: "a creepy mask left behind"},
{Tag: language.English, Message: "a loaded firearm", Key: "a loaded firearm"},
{Tag: language.English, Message: "a sketchy white van", Key: "a sketchy white van"},
{Tag: language.English, Message: "drops of blood in the snow", Key: "drops of blood in the snow"},
{Tag: language.English, Message: "half-full bottle of alcohol", Key: "half-full bottle of alcohol"},
{Tag: language.English, Message: "you think you're being followed (you can't tell by whom)", Key: "you think you're being followed (you can't tell by whom)"},
{Tag: language.Russian, Message: "$10,000 in a duffel bag", Key: "десять тысяч долларов в вещмешке"},
{Tag: language.Russian, Message: "a broken switchblade", Key: "сломанный складной нож"},
{Tag: language.Russian, Message: "a creepy mask left behind", Key: "утерянная жуткая маска"},
{Tag: language.Russian, Message: "a loaded firearm", Key: "заряженное огнестрельное оружие"},
{Tag: language.Russian, Message: "a sketchy white van", Key: "неброский белый микроавтобус"},
{Tag: language.Russian, Message: "drops of blood in the snow", Key: "пятна капель крови на снегу"},
{Tag: language.Russian, Message: "half-full bottle of alcohol", Key: "полбутылки алкоголя"},
{Tag: language.Russian, Message: "you think you're being followed (you can't tell by whom)", Key: "кажется, за вами следят (вы не можете определить, кто именно)"},
} {
switch msg := entry.Message.(type) {
case string:
message.SetString(entry.Tag, entry.Key, msg)
case catalog.Message:
message.Set(entry.Tag, entry.Key, msg)
case []catalog.Message:
message.Set(entry.Tag, entry.Key, msg...)
}
}
}
func (s Search) Name() string {
switch s {
default:
return ""
case Search10K:
return "$10,000 in a duffel bag"
case SearchAlcohol:
return "half-full bottle of alcohol"
case SearchBlade:
return "a broken switchblade"
case SearchBlood:
return "drops of blood in the snow"
case SearchFirearm:
return "a loaded firearm"
case SearchFollow:
return "you think you're being followed (you can't tell by whom)"
case SearchMask:
return "a creepy mask left behind"
case SearchVan:
return "a sketchy white van"
}
}
func (s Search) String() string {
if s.search != "" {
return s.search
}
return "und"
}
func (s Search) GoString() string {
return "domain.Search(" + s.String() + ")"
}

View File

@ -0,0 +1,216 @@
package discord
import (
"context"
"io/fs"
"log"
"mime"
"path/filepath"
"strings"
"github.com/bwmarrin/discordgo"
"golang.org/x/text/language"
"golang.org/x/text/message"
"golang.org/x/text/message/catalog"
"source.toby3d.me/toby3d/alice/internal/domain"
"source.toby3d.me/toby3d/alice/internal/search"
)
type Handler struct {
logger *log.Logger
searches search.UseCase
cards fs.FS
cache map[domain.Search]*discordgo.MessageEmbedImage
}
const InteractionID string = "search"
func init() {
for _, entry := range [...]domain.CatalogEntry{
{Tag: language.English, Key: "You go on a search for something that can help you...", Message: "You go on a search for something that can help you..."},
{Tag: language.Russian, Key: "You go on a search for something that can help you...", Message: "Вы пытаетесь что-нибудь найти..."},
{Tag: language.English, Key: "Unfortunately, you've already found all you can find.", Message: "Unfortunately, you've already found all you can find."},
{Tag: language.Russian, Key: "Unfortunately, you've already found all you can find.", Message: "К сожалению, вы уже нашли всё, что могли найти."},
} {
switch msg := entry.Message.(type) {
case string:
message.SetString(entry.Tag, entry.Key, msg)
case catalog.Message:
message.Set(entry.Tag, entry.Key, msg)
case []catalog.Message:
message.Set(entry.Tag, entry.Key, msg...)
}
}
}
func NewHandler(searches search.UseCase, cards fs.FS, logger *log.Logger) *Handler {
return &Handler{
cards: cards,
logger: logger,
searches: searches,
cache: make(map[domain.Search]*discordgo.MessageEmbedImage),
}
}
func (h *Handler) ServeReady(s *discordgo.Session, r *discordgo.Ready) {
for i := range r.Guilds {
if _, err := s.ApplicationCommandCreate(s.State.User.ID, r.Guilds[i].ID, &discordgo.ApplicationCommand{
Name: InteractionID,
NameLocalizations: &map[discordgo.Locale]string{
discordgo.Russian: "поиск",
},
Description: "do a search",
DescriptionLocalizations: &map[discordgo.Locale]string{
discordgo.Russian: "производит поиск",
},
}); err != nil {
h.logger.Println("cannot register command:", err)
}
}
}
func (h *Handler) ServeInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Type != discordgo.InteractionApplicationCommand {
return
}
printer := message.NewPrinter(language.Make(string(i.Locale)))
data := i.ApplicationCommandData()
if data.Name != InteractionID {
return
}
respond := &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: printer.Sprintf("You go on a search for something that can help you..."),
Flags: discordgo.MessageFlagsEphemeral,
},
}
// NOTE(toby3d): see if there's anything else we can find.
found, err := h.searches.Search(context.Background())
if err != nil {
respond.Data.Content = printer.Sprintf("Unfortunately, you've already found all you can find.")
go s.InteractionRespond(i.Interaction, respond)
h.logger.Println("cannot do search interaction:", err)
return
}
go s.InteractionRespond(i.Interaction, respond)
// NOTE(toby3d): send search card into personal character channel
roles, err := s.GuildRoles(i.GuildID)
if err != nil {
h.logger.Printf("cannot fetch guild roles: %s", err)
return
}
// NOTE(toby3d): get current user character via roles.
var character domain.Character
for _, role := range i.Member.Roles {
for j := range roles {
if role != roles[j].ID {
continue
}
if character, err = domain.ParseCharacter(strings.ToLower(roles[j].Name)); err != nil {
continue
}
break
}
}
if character == domain.CharacterUnd {
h.logger.Println("cannot check user character role")
return
}
// NOTE(toby3d): find personal character text channel for sending card.
channels, err := s.GuildChannels(i.GuildID)
if err != nil {
h.logger.Printf("cannot fetch guild channels: %s", err)
return
}
var characterChannel *discordgo.Channel
for _, channel := range channels {
if !strings.EqualFold(channel.Name, character.String()+"-clues") {
continue
}
characterChannel = channel
break
}
if characterChannel == nil {
h.logger.Printf("cannot find %s text channel", character)
return
}
// NOTE(toby3d): send card from cache, if exists.
message := &discordgo.MessageSend{
Content: "",
File: nil,
Embed: &discordgo.MessageEmbed{
Type: discordgo.EmbedTypeImage,
Color: 15844367, // NOTE(toby3d): gold
Image: h.cache[found],
},
}
if h.cache[found] == nil {
var f fs.File
for _, ext := range []string{".jpg", ".jpeg", ".png"} {
f, err = h.cards.Open(filepath.Join("assets", "cards", "search", found.String()+ext))
if err != nil {
h.logger.Printf("cannot find %s card with extention '%s': %s", found, ext, err)
continue
}
break
}
if f == nil {
h.logger.Printf("cannot find any cards for %s, check your embeding directory contents", found)
return
}
info, _ := f.Stat()
message.File = &discordgo.File{
Name: info.Name(),
ContentType: mime.TypeByExtension(filepath.Ext(info.Name())),
Reader: f,
}
message.Embed.Image = &discordgo.MessageEmbedImage{
URL: "attachment://" + info.Name(),
}
}
resp, err := s.ChannelMessageSendComplex(characterChannel.ID, message)
if err != nil {
h.logger.Println("cannot send search card:", err)
}
// NOTE(toby3d): if card is not cached, then store response embed to reuse later.
if h.cache[found] != nil {
return
}
h.cache[found] = resp.Embeds[0].Image
}

View File

@ -0,0 +1,23 @@
package search
import (
"context"
"source.toby3d.me/toby3d/alice/internal/domain"
)
type Repository interface {
// Load check what provided search is available, returns true if it is.
Load(ctx context.Context, search domain.Search) (bool, error)
// Remember add provided search into the memory, marked it as
// unavailable for searching again.
Remember(ctx context.Context, search domain.Search) (bool, error)
// Forget removed provided search from the memory, making it available
// for searching again.
Forget(ctx context.Context, search domain.Search) (bool, error)
// Fetch returns all remembered searches.
Fetch(ctx context.Context) ([]domain.Search, error)
}

View File

@ -0,0 +1,83 @@
package memory
import (
"context"
"fmt"
"sync"
"source.toby3d.me/toby3d/alice/internal/domain"
"source.toby3d.me/toby3d/alice/internal/search"
)
type memorySearchRepository struct {
mutex *sync.RWMutex
searches map[domain.Search]struct{}
}
// Forget implements search.Repository.
func (repo *memorySearchRepository) Forget(ctx context.Context, s domain.Search) (bool, error) {
ok, err := repo.Load(ctx, s)
if err != nil {
return ok, fmt.Errorf("cannot load search from memory before forget: %w", err)
}
if !ok {
return false, nil
}
repo.mutex.Lock()
defer repo.mutex.Unlock()
delete(repo.searches, s)
return true, nil
}
// Remember implements search.Repository.
func (repo *memorySearchRepository) Remember(ctx context.Context, s domain.Search) (bool, error) {
ok, err := repo.Load(ctx, s)
if err != nil {
return ok, fmt.Errorf("cannot load search from memory before remember: %w", err)
}
if ok {
return false, nil
}
repo.mutex.Lock()
defer repo.mutex.Unlock()
repo.searches[s] = struct{}{}
return true, nil
}
// Load implements search.Repository.
func (repo *memorySearchRepository) Load(ctx context.Context, s domain.Search) (bool, error) {
repo.mutex.RLock()
defer repo.mutex.RUnlock()
_, ok := repo.searches[s]
return ok, nil
}
func (repo *memorySearchRepository) Fetch(ctx context.Context) ([]domain.Search, error) {
repo.mutex.RLock()
defer repo.mutex.RUnlock()
out := make([]domain.Search, 0, len(repo.searches))
for s := range repo.searches {
out = append(out, s)
}
return out, nil
}
func NewMemorySearchRepository() search.Repository {
return &memorySearchRepository{
mutex: new(sync.RWMutex),
searches: make(map[domain.Search]struct{}),
}
}

View File

@ -0,0 +1,18 @@
package search
import (
"context"
"errors"
"source.toby3d.me/toby3d/alice/internal/domain"
)
type UseCase interface {
// Search make a random search.
Search(ctx context.Context) (domain.Search, error)
// Reset wipes searching history.
Reset(ctx context.Context) (bool, error)
}
var ErrNoMoreSearching = errors.New("there's no more searching")

View File

@ -0,0 +1,68 @@
package usecase
import (
"context"
"fmt"
"math/rand"
"slices"
"source.toby3d.me/toby3d/alice/internal/domain"
"source.toby3d.me/toby3d/alice/internal/search"
)
type searchUseCase struct {
rand *rand.Rand
searches search.Repository
}
// Reset implements search.UseCase.
func (ucase *searchUseCase) Reset(ctx context.Context) (bool, error) {
searched, err := ucase.searches.Fetch(ctx)
if err != nil {
return false, fmt.Errorf("cannot fetch searched memory: %w", err)
}
for i := range searched {
if _, err = ucase.searches.Forget(ctx, searched[i]); err != nil {
return false, fmt.Errorf("cannot reset searched memory: %w", err)
}
}
return len(searched) > 0, nil
}
// Search implements search.UseCase.
func (ucase *searchUseCase) Search(ctx context.Context) (domain.Search, error) {
searched, err := ucase.searches.Fetch(ctx)
if err != nil {
return domain.SearchUnd, fmt.Errorf("cannot check searched memory: %w", err)
}
if len(searched) >= len(domain.SearchesBase) {
return domain.SearchUnd, fmt.Errorf("cannot do searching: %w", search.ErrNoMoreSearching)
}
variants := make([]domain.Search, 0, len(domain.SearchesBase))
for i := range domain.SearchesBase {
if slices.Contains(searched, domain.SearchesBase[i]) {
continue
}
variants = append(variants, domain.SearchesBase[i])
}
pick := variants[ucase.rand.Intn(len(variants))]
if _, err = ucase.searches.Remember(ctx, pick); err != nil {
return domain.SearchUnd, fmt.Errorf("cannot remember selected searching: %w", err)
}
return pick, nil
}
func NewSearchUseCase(searches search.Repository, seed rand.Source) search.UseCase {
return &searchUseCase{
rand: rand.New(seed),
searches: searches,
}
}

View File

@ -0,0 +1,40 @@
package usecase_test
import (
"context"
"math/rand"
"testing"
"source.toby3d.me/toby3d/alice/internal/domain"
searchrepo "source.toby3d.me/toby3d/alice/internal/search/repository/memory"
"source.toby3d.me/toby3d/alice/internal/search/usecase"
)
func TestSearch(t *testing.T) {
t.Parallel()
seed := rand.NewSource(1)
repo := searchrepo.NewMemorySearchRepository()
ucase := usecase.NewSearchUseCase(repo, seed)
checks := make(map[domain.Search]struct{})
// NOTE(toby3d): make a one more step for error checking
for i := 0; i < len(domain.SearchesBase); i++ {
out, err := ucase.Search(context.Background())
if err != nil {
t.Error(err)
continue
}
if _, ok := checks[out]; !ok {
checks[out] = struct{}{}
} else {
t.Errorf("%s already searched earler", out)
}
}
if len(checks) != len(domain.SearchesBase) {
t.Errorf("expect %d unique searches, got %d", len(domain.SearchesBase), len(checks))
}
}

11
main.go
View File

@ -3,14 +3,19 @@ package main
import (
"embed"
"log"
"math/rand"
"os"
"os/signal"
"time"
"github.com/bwmarrin/discordgo"
"github.com/caarlos0/env/v9"
characterdiscorddelivery "source.toby3d.me/toby3d/alice/internal/character/delivery/discord"
"source.toby3d.me/toby3d/alice/internal/domain"
searchdiscorddelivery "source.toby3d.me/toby3d/alice/internal/search/delivery/discord"
searchfsrepo "source.toby3d.me/toby3d/alice/internal/search/repository/memory"
searchusecase "source.toby3d.me/toby3d/alice/internal/search/usecase"
voicemaildiscorddelivery "source.toby3d.me/toby3d/alice/internal/voicemail/delivery/discord"
voicemailfsrepo "source.toby3d.me/toby3d/alice/internal/voicemail/repository/fs"
voicemailusecase "source.toby3d.me/toby3d/alice/internal/voicemail/usecase"
@ -47,14 +52,20 @@ func main() {
xDiscordHandler := xdiscorddelivery.NewHandler(assets, logger)
characterDiscordDelivery := characterdiscorddelivery.NewHandler(logger)
searches := searchfsrepo.NewMemorySearchRepository()
searchService := searchusecase.NewSearchUseCase(searches, rand.NewSource(time.Now().UnixNano()))
searchDiscordDelivery := searchdiscorddelivery.NewHandler(searchService, assets, logger)
for _, h := range []any{
xDiscordHandler.ServeReady,
searchDiscordDelivery.ServeReady,
voicemailDiscordHandler.ServeReady,
characterDiscordDelivery.ServeReady,
voicemailDiscordHandler.ServeMessage,
xDiscordHandler.ServeInteraction,
voicemailDiscordHandler.ServeInteraction,
characterDiscordDelivery.ServeInteraction,
searchDiscordDelivery.ServeInteraction,
} {
session.AddHandler(h)
}