Compare commits

...

10 Commits

8 changed files with 577 additions and 4 deletions

View File

@ -0,0 +1,111 @@
// TODO(toby3d): save lines in a public channel for references?
package discord
import (
"log"
"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"
)
type Handler struct {
logger *log.Logger
}
const InteractionID string = "line"
func init() {
for _, entry := range [...]domain.CatalogEntry{
{
Tag: language.English, Key: "You must describe the subject of the line.",
Message: "You must describe the subject of the line.",
},
{
Tag: language.Russian, Key: "You must describe the subject of the line.",
Message: "Вы должны описать предмет линии.",
},
{Tag: language.English, Key: "(Line: %s)", Message: "(Line: %s)"},
{Tag: language.Russian, Key: "(Line: %s)", Message: "(Линия: %s)"},
} {
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(logger *log.Logger) *Handler {
return &Handler{
logger: logger,
}
}
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: "request a complete content ban",
DescriptionLocalizations: &map[discordgo.Locale]string{
discordgo.Russian: "запросить полный запрет контента",
},
Options: []*discordgo.ApplicationCommandOption{{
Type: discordgo.ApplicationCommandOptionString,
Name: "description",
NameLocalizations: map[discordgo.Locale]string{
discordgo.Russian: "описание",
},
Description: "description of the content to be banned",
DescriptionLocalizations: map[discordgo.Locale]string{
discordgo.Russian: "описание контента который требуется запретить",
},
Required: true,
}},
}); 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
}
data := i.ApplicationCommandData()
if data.Name != InteractionID {
return
}
printer := message.NewPrinter(language.Make(string(i.Locale)))
if len(data.Options) == 0 {
go s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: printer.Sprintf("You must describe the subject of the line."),
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
go s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: printer.Sprintf("(Line: %s)", data.Options[0].StringValue()),
},
})
}

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
}
data := i.ApplicationCommandData()
if data.Name != InteractionID {
return
}
printer := message.NewPrinter(language.Make(string(i.Locale)))
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))
}
}

22
main.go
View File

@ -3,14 +3,20 @@ 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"
linediscorddelivery "source.toby3d.me/toby3d/alice/internal/line/delivery/discord"
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"
@ -46,15 +52,23 @@ func main() {
voicemailDiscordHandler := voicemaildiscorddelivery.NewHandler(voicemailsService, assets, logger, *config)
xDiscordHandler := xdiscorddelivery.NewHandler(assets, logger)
characterDiscordDelivery := characterdiscorddelivery.NewHandler(logger)
lineDiscordHandler := linediscorddelivery.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,
voicemailDiscordHandler.ServeReady,
characterDiscordDelivery.ServeReady,
lineDiscordHandler.ServeReady,
searchDiscordDelivery.ServeReady,
voicemailDiscordHandler.ServeReady,
xDiscordHandler.ServeReady,
voicemailDiscordHandler.ServeMessage,
xDiscordHandler.ServeInteraction,
voicemailDiscordHandler.ServeInteraction,
characterDiscordDelivery.ServeInteraction,
lineDiscordHandler.ServeInteraction,
searchDiscordDelivery.ServeInteraction,
voicemailDiscordHandler.ServeInteraction,
xDiscordHandler.ServeInteraction,
} {
session.AddHandler(h)
}