Merge branch 'feature/search' into develop
After Width: | Height: | Size: 974 KiB |
After Width: | Height: | Size: 979 KiB |
After Width: | Height: | Size: 971 KiB |
After Width: | Height: | Size: 967 KiB |
After Width: | Height: | Size: 968 KiB |
After Width: | Height: | Size: 970 KiB |
After Width: | Height: | Size: 973 KiB |
After Width: | Height: | Size: 962 KiB |
|
@ -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() + ")"
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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{}),
|
||||
}
|
||||
}
|
|
@ -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")
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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)
|
||||
}
|
||||
|
|