diff --git a/internal/bundb/group.go b/internal/bundb/group.go index 7b5e129..7e1ef63 100644 --- a/internal/bundb/group.go +++ b/internal/bundb/group.go @@ -31,6 +31,7 @@ func (g *Group) Create(ctx context.Context, params domain.CreateGroupParams) (do ServerKey: params.ServerKey(), Barbarians: params.Barbarians(), Internals: params.Internals(), + LanguageTag: params.LanguageTag(), } if _, err := g.db.NewInsert(). @@ -232,6 +233,14 @@ func (u updateGroupsParamsApplier) apply(q *bun.UpdateQuery) *bun.UpdateQuery { } } + if u.params.LanguageTag.Valid { + if u.params.LanguageTag.String != "" { + q = q.Set("language_tag = ?", u.params.LanguageTag.String) + } else { + q = q.Set("language_tag = NULL") + } + } + if u.params.Barbarians.Valid { q = q.Set("barbarians = ?", u.params.Barbarians.Bool) } diff --git a/internal/bundb/group_test.go b/internal/bundb/group_test.go index 2bc3558..0766b04 100644 --- a/internal/bundb/group_test.go +++ b/internal/bundb/group_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/text/language" ) func TestGroup_Create(t *testing.T) { @@ -28,6 +29,7 @@ func TestGroup_Create(t *testing.T) { "592292203234328587", "en", "en113", + language.Polish.String(), "1234", "1235", true, @@ -45,6 +47,7 @@ func TestGroup_Create(t *testing.T) { assert.Equal(t, params.ChannelLosses(), group.ChannelLosses) assert.Equal(t, params.Barbarians(), group.Barbarians) assert.Equal(t, params.Internals(), group.Internals) + assert.Equal(t, params.LanguageTag(), group.LanguageTag) assert.WithinDuration(t, time.Now(), group.CreatedAt, 1*time.Second) }) } @@ -73,6 +76,10 @@ func TestGroup_Update(t *testing.T) { String: group.ChannelLosses + "update", Valid: true, }, + LanguageTag: domain.NullString{ + String: language.AmericanEnglish.String(), + Valid: true, + }, Barbarians: domain.NullBool{ Bool: !group.Barbarians, Valid: true, @@ -87,6 +94,7 @@ func TestGroup_Update(t *testing.T) { assert.NoError(t, err) assert.Equal(t, params.ChannelGains.String, updatedGroup.ChannelGains) assert.Equal(t, params.ChannelLosses.String, updatedGroup.ChannelLosses) + assert.Equal(t, params.LanguageTag.String, updatedGroup.LanguageTag) assert.Equal(t, params.Barbarians.Bool, updatedGroup.Barbarians) assert.Equal(t, params.Internals.Bool, updatedGroup.Internals) }) diff --git a/internal/bundb/internal/model/group.go b/internal/bundb/internal/model/group.go index f9e7e05..66de483 100644 --- a/internal/bundb/internal/model/group.go +++ b/internal/bundb/internal/model/group.go @@ -19,6 +19,7 @@ type Group struct { Barbarians bool `bun:"barbarians"` ServerKey string `bun:"server_key,nullzero"` VersionCode string `bun:"version_code,nullzero"` + LanguageTag string `bun:"language_tag,nullzero"` CreatedAt time.Time `bun:"created_at,nullzero"` Monitors []Monitor `bun:"monitors,rel:has-many,join:id=group_id"` } @@ -38,6 +39,7 @@ func (g Group) ToDomain() domain.GroupWithMonitors { Barbarians: g.Barbarians, ServerKey: g.ServerKey, VersionCode: g.VersionCode, + LanguageTag: g.LanguageTag, CreatedAt: g.CreatedAt, }, Monitors: monitors, diff --git a/internal/bundb/testdata/fixture.yml b/internal/bundb/testdata/fixture.yml index adca02c..98243bd 100644 --- a/internal/bundb/testdata/fixture.yml +++ b/internal/bundb/testdata/fixture.yml @@ -6,6 +6,7 @@ server_key: pl181 version_code: pl channel_losses: 125 + language_tag: pl created_at: 2022-03-15T15:00:10.000Z - _id: group-2-server-1 id: 429b790e-7186-4106-b531-4cc4931ce2ba @@ -13,12 +14,14 @@ server_key: pl181 version_code: pl channel_gains: 125 + language_tag: pl created_at: 2022-03-15T15:03:10.000Z - _id: group-1-server-2 id: abeb6c8e-70b6-445c-989f-890cd2a1f87a server_id: server-2 server_key: pl181 version_code: pl + language_tag: pl created_at: 2022-03-18T15:03:10.000Z - _id: group-2-server-2 id: 0be82203-4ca3-4b4c-a0c8-3a70099d88f7 @@ -27,6 +30,7 @@ version_code: pl channel_gains: 1555 channel_losses: 1235 + language_tag: pl created_at: 2022-03-19T15:03:10.000Z - _id: group-3-server-1 id: 982a9765-471c-43e8-9abb-1e4ee4f03738 @@ -35,6 +39,7 @@ version_code: pl channel_gains: 1555 channel_losses: 1235 + language_tag: pl created_at: 2022-03-23T18:03:10.000Z - _id: group-3-server-2 id: f3a0a5d9-07fe-4770-8b43-79cbe10bb310 @@ -43,6 +48,7 @@ version_code: en channel_gains: 1555 channel_losses: 1235 + language_tag: en created_at: 2022-04-23T18:03:10.000Z - model: Monitor rows: @@ -60,4 +66,4 @@ id: c7d63c3d-55f6-432e-b9d8-006f8c5ab407 group_id: 0be82203-4ca3-4b4c-a0c8-3a70099d88f7 tribe_id: 121 - created_at: 2022-03-19T15:10:10.000Z \ No newline at end of file + created_at: 2022-03-19T15:10:10.000Z diff --git a/internal/discord/bot.go b/internal/discord/bot.go index 266e807..d936c1e 100644 --- a/internal/discord/bot.go +++ b/internal/discord/bot.go @@ -17,6 +17,7 @@ type GroupService interface { Create(ctx context.Context, params domain.CreateGroupParams) (domain.GroupWithMonitors, error) AddTribe(ctx context.Context, id, serverID, tribeTag string) (domain.GroupWithMonitors, error) RemoveTribe(ctx context.Context, id, serverID, tribeTag string) (domain.GroupWithMonitors, error) + SetLanguageTag(ctx context.Context, id, serverID, languageTag string) (domain.GroupWithMonitors, error) SetChannelGains(ctx context.Context, id, serverID, channel string) (domain.GroupWithMonitors, error) SetChannelLosses(ctx context.Context, id, serverID, channel string) (domain.GroupWithMonitors, error) SetInternals(ctx context.Context, id, serverID string, internals bool) (domain.GroupWithMonitors, error) @@ -54,7 +55,7 @@ func NewBot( s, err := discordgo.New("Bot " + token) if err != nil { - return nil, fmt.Errorf("discordgo.New: %w", err) + return nil, err } s.Identify.Intents = discordgo.IntentsNone @@ -80,16 +81,16 @@ func NewBot( func (b *Bot) Run() error { if err := b.registerCronJobs(); err != nil { - return fmt.Errorf("initCron: %w", err) + return err } if err := b.session.Open(); err != nil { - return fmt.Errorf("s.Open: %w", err) + return err } if err := b.registerCommands(); err != nil { _ = b.session.Close() - return fmt.Errorf("couldn't register commands: %w", err) + return err } b.cron.Run() @@ -123,7 +124,7 @@ func (b *Bot) registerCronJobs() error { }{ { spec: "@every 1m", - job: &executeMonitorsJob{svc: b.groupSvc, s: b.session, logger: b.logger}, + job: &executeMonitorsJob{svc: b.groupSvc, s: b.session, localizer: b.localizer, logger: b.logger}, }, { spec: "0 */8 * * *", diff --git a/internal/discord/command_group.go b/internal/discord/command_group.go index 0139a4d..0b4f0e3 100644 --- a/internal/discord/command_group.go +++ b/internal/discord/command_group.go @@ -80,7 +80,7 @@ func (c *groupCommand) create(s *discordgo.Session) error { } func (c *groupCommand) buildSubcommandCreate() (*discordgo.ApplicationCommandOption, error) { - versionChoices, err := c.getVersionChoices() + options, err := c.buildSubcommandCreateOptions() if err != nil { return nil, err } @@ -90,6 +90,26 @@ func (c *groupCommand) buildSubcommandCreate() (*discordgo.ApplicationCommandOpt return nil, err } + return &discordgo.ApplicationCommandOption{ + Name: "create", + Description: cmdDescriptionDefault, + DescriptionLocalizations: cmdDescriptionLocalizations, + Type: discordgo.ApplicationCommandOptionSubCommand, + Options: options, + }, nil +} + +func (c *groupCommand) buildSubcommandCreateOptions() ([]*discordgo.ApplicationCommandOption, error) { + versionChoices, err := c.getVersionChoices() + if err != nil { + return nil, err + } + + languageChoices, err := c.getLanguageChoices() + if err != nil { + return nil, err + } + optionVersionDefault, optionVersionLocalizations, err := c.localizer.LocalizeDiscord("cmd.group.create.option.version.description") if err != nil { return nil, err @@ -115,6 +135,11 @@ func (c *groupCommand) buildSubcommandCreate() (*discordgo.ApplicationCommandOpt return nil, err } + optionLanguageDefault, optionLanguageLocalizations, err := c.localizer.LocalizeDiscord("cmd.group.create.option.language.description") + if err != nil { + return nil, err + } + optionChannelLossesDefault, optionChannelLossesLocalizations, err := c.localizer.LocalizeDiscord( "cmd.group.create.option.channel-losses.description", ) @@ -122,65 +147,101 @@ func (c *groupCommand) buildSubcommandCreate() (*discordgo.ApplicationCommandOpt return nil, err } - return &discordgo.ApplicationCommandOption{ - Name: "create", - Description: cmdDescriptionDefault, - DescriptionLocalizations: cmdDescriptionLocalizations, - Type: discordgo.ApplicationCommandOptionSubCommand, - Options: []*discordgo.ApplicationCommandOption{ - { - Name: "version", - Description: optionVersionDefault, - DescriptionLocalizations: optionVersionLocalizations, - Type: discordgo.ApplicationCommandOptionString, - Choices: versionChoices, - Required: true, + return []*discordgo.ApplicationCommandOption{ + { + Name: "version", + Description: optionVersionDefault, + DescriptionLocalizations: optionVersionLocalizations, + Type: discordgo.ApplicationCommandOptionString, + Choices: versionChoices, + Required: true, + }, + { + Name: "server", + Description: optionServerDefault, + DescriptionLocalizations: optionServerLocalizations, + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + { + Name: "language", + Description: optionLanguageDefault, + DescriptionLocalizations: optionLanguageLocalizations, + Type: discordgo.ApplicationCommandOptionString, + Choices: languageChoices, + Required: true, + }, + { + Name: "internals", + Description: optionInternalsDefault, + DescriptionLocalizations: optionInternalsLocalizations, + Type: discordgo.ApplicationCommandOptionBoolean, + Required: true, + }, + { + Name: "barbarians", + Description: optionBarbariansDefault, + DescriptionLocalizations: optionBarbariansLocalizations, + Type: discordgo.ApplicationCommandOptionBoolean, + Required: true, + }, + { + Name: "channel-gains", + Description: optionChannelGainsDefault, + DescriptionLocalizations: optionChannelGainsLocalizations, + Type: discordgo.ApplicationCommandOptionChannel, + ChannelTypes: []discordgo.ChannelType{ + discordgo.ChannelTypeGuildText, }, - { - Name: "server", - Description: optionServerDefault, - DescriptionLocalizations: optionServerLocalizations, - Type: discordgo.ApplicationCommandOptionString, - Required: true, - }, - { - Name: "internals", - Description: optionInternalsDefault, - DescriptionLocalizations: optionInternalsLocalizations, - Type: discordgo.ApplicationCommandOptionBoolean, - Required: true, - }, - { - Name: "barbarians", - Description: optionBarbariansDefault, - DescriptionLocalizations: optionBarbariansLocalizations, - Type: discordgo.ApplicationCommandOptionBoolean, - Required: true, - }, - { - Name: "channel-gains", - Description: optionChannelGainsDefault, - DescriptionLocalizations: optionChannelGainsLocalizations, - Type: discordgo.ApplicationCommandOptionChannel, - ChannelTypes: []discordgo.ChannelType{ - discordgo.ChannelTypeGuildText, - }, - Required: false, - }, - { - Name: "channel-losses", - Description: optionChannelLossesDefault, - DescriptionLocalizations: optionChannelLossesLocalizations, - Type: discordgo.ApplicationCommandOptionChannel, - ChannelTypes: []discordgo.ChannelType{ - discordgo.ChannelTypeGuildText, - }, - Required: false, + Required: false, + }, + { + Name: "channel-losses", + Description: optionChannelLossesDefault, + DescriptionLocalizations: optionChannelLossesLocalizations, + Type: discordgo.ApplicationCommandOptionChannel, + ChannelTypes: []discordgo.ChannelType{ + discordgo.ChannelTypeGuildText, }, + Required: false, }, }, nil } +func (c *groupCommand) getVersionChoices() ([]*discordgo.ApplicationCommandOptionChoice, error) { + choices, err := c.choiceSvc.Versions(context.Background()) + if err != nil { + return nil, fmt.Errorf("ChoiceService.Versions: %w", err) + } + + dcChoices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(choices)) + for _, v := range choices { + dcChoices = append(dcChoices, &discordgo.ApplicationCommandOptionChoice{ + Name: v.Name, + Value: v.Value, + }) + } + + return dcChoices, nil +} + +func (c *groupCommand) getLanguageChoices() ([]*discordgo.ApplicationCommandOptionChoice, error) { + tags := c.localizer.LanguageTags() + choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(tags)) + for _, tag := range tags { + lang, langLocalizations, err := c.localizer.LocalizeDiscord(buildLangMessageID(tag.String())) + if err != nil { + return nil, err + } + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: lang, + NameLocalizations: langLocalizations, + Value: tag.String(), + }) + } + return choices, nil +} + func (c *groupCommand) buildSubcommandList() (*discordgo.ApplicationCommandOption, error) { cmdDescriptionDefault, cmdDescriptionLocalizations, err := c.localizer.LocalizeDiscord("cmd.group.list.description") if err != nil { @@ -329,6 +390,7 @@ func (c *groupCommand) buildSubcommandMonitorRemove() (*discordgo.ApplicationCom func (c *groupCommand) buildSubcommandSet() (*discordgo.ApplicationCommandOption, error) { subcommandBuilders := []func() (*discordgo.ApplicationCommandOption, error){ + c.buildSubcommandSetLanguage, c.buildSubcommandSetChannelGains, c.buildSubcommandSetChannelLosses, c.buildSubcommandSetInternals, @@ -357,6 +419,52 @@ func (c *groupCommand) buildSubcommandSet() (*discordgo.ApplicationCommandOption }, nil } +func (c *groupCommand) buildSubcommandSetLanguage() (*discordgo.ApplicationCommandOption, error) { + languageChoices, err := c.getLanguageChoices() + if err != nil { + return nil, err + } + + cmdDescriptionDefault, cmdDescriptionLocalizations, err := c.localizer.LocalizeDiscord("cmd.group.set.language.description") + if err != nil { + return nil, err + } + + optionGroupDefault, optionGroupLocalizations, err := c.localizer.LocalizeDiscord("cmd.group.set.language.option.group.description") + if err != nil { + return nil, err + } + + optionLanguageDefault, optionLanguageLocalizations, err := c.localizer.LocalizeDiscord("cmd.group.set.language.option.language.description") + if err != nil { + return nil, err + } + + return &discordgo.ApplicationCommandOption{ + Name: "language", + Description: cmdDescriptionDefault, + DescriptionLocalizations: cmdDescriptionLocalizations, + Type: discordgo.ApplicationCommandOptionSubCommand, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "group", + Description: optionGroupDefault, + DescriptionLocalizations: optionGroupLocalizations, + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + { + Name: "language", + Description: optionLanguageDefault, + DescriptionLocalizations: optionLanguageLocalizations, + Type: discordgo.ApplicationCommandOptionString, + Choices: languageChoices, + Required: true, + }, + }, + }, nil +} + func (c *groupCommand) buildSubcommandSetChannelGains() (*discordgo.ApplicationCommandOption, error) { cmdDescriptionDefault, cmdDescriptionLocalizations, err := c.localizer.LocalizeDiscord("cmd.group.set.channel-gains.description") if err != nil { @@ -549,23 +657,6 @@ func (c *groupCommand) buildSubcommandDelete() (*discordgo.ApplicationCommandOpt }, nil } -func (c *groupCommand) getVersionChoices() ([]*discordgo.ApplicationCommandOptionChoice, error) { - choices, err := c.choiceSvc.Versions(context.Background()) - if err != nil { - return nil, fmt.Errorf("ChoiceService.Versions: %w", err) - } - - dcChoices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(choices)) - for _, v := range choices { - dcChoices = append(dcChoices, &discordgo.ApplicationCommandOptionChoice{ - Name: v.Name, - Value: v.Value, - }) - } - - return dcChoices, nil -} - func (c *groupCommand) handle(s *discordgo.Session, i *discordgo.InteractionCreate) { cmdData := i.ApplicationCommandData() @@ -575,7 +666,8 @@ func (c *groupCommand) handle(s *discordgo.Session, i *discordgo.InteractionCrea ctx := context.Background() - switch cmdData.Options[0].Name { + name := cmdData.Options[0].Name + switch name { case "create": c.handleCreate(ctx, s, i) case "list": @@ -588,6 +680,8 @@ func (c *groupCommand) handle(s *discordgo.Session, i *discordgo.InteractionCrea c.handleTribe(ctx, s, i) case "delete": c.handleDelete(ctx, s, i) + default: + c.logger.Error("unknown subcommand", zap.String("command", "group"), zap.String("subcommand", name)) } } @@ -647,12 +741,15 @@ func (c *groupCommand) optionsToCreateGroupParams( channelLosses := "" internals := false barbarians := false + languageTag := "" for _, opt := range options { switch opt.Name { case "version": version = opt.StringValue() case "server": server = opt.StringValue() + case "language": + languageTag = opt.StringValue() case "channel-gains": channelGains = opt.ChannelValue(s).ID case "channel-losses": @@ -663,8 +760,16 @@ func (c *groupCommand) optionsToCreateGroupParams( barbarians = opt.BoolValue() } } - - return domain.NewCreateGroupParams(guildID, version, server, channelGains, channelLosses, barbarians, internals) + return domain.NewCreateGroupParams( + guildID, + version, + server, + languageTag, + channelGains, + channelLosses, + barbarians, + internals, + ) } func (c *groupCommand) handleList(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { @@ -688,10 +793,21 @@ func (c *groupCommand) handleList(ctx context.Context, s *discordgo.Session, i * if g.ChannelGains != "" { channelGains = buildChannelMention(g.ChannelGains) } + channelLosses := "-" if g.ChannelLosses != "" { channelLosses = buildChannelMention(g.ChannelLosses) } + + langMessageID := buildLangMessageID(g.LanguageTag) + lang, localizeErr := c.localizer.Localize(locale, &i18n.LocalizeConfig{ + MessageID: langMessageID, + }) + if localizeErr != nil { + c.logger.Error("no message with the specified identifier", zap.String("id", langMessageID), zap.Error(localizeErr)) + return + } + fieldValue, localizeErr := c.localizer.Localize(locale, &i18n.LocalizeConfig{ MessageID: fieldValueMessageID, TemplateData: map[string]any{ @@ -701,12 +817,14 @@ func (c *groupCommand) handleList(ctx context.Context, s *discordgo.Session, i * "Internals": boolToEmoji(g.Internals), "Barbarians": boolToEmoji(g.Barbarians), "NumTribes": len(g.Monitors), + "Language": lang, }, }) if localizeErr != nil { c.logger.Error("no message with the specified identifier", zap.String("id", fieldValueMessageID), zap.Error(localizeErr)) return } + fields = append(fields, &discordgo.MessageEmbedField{ Name: g.ID, Value: fieldValue, @@ -770,6 +888,15 @@ func (c *groupCommand) handleDetails(ctx context.Context, s *discordgo.Session, channelLosses = buildChannelMention(g.ChannelLosses) } + langMessageID := buildLangMessageID(g.LanguageTag) + lang, localizeErr := c.localizer.Localize(locale, &i18n.LocalizeConfig{ + MessageID: langMessageID, + }) + if localizeErr != nil { + c.logger.Error("no message with the specified identifier", zap.String("id", langMessageID), zap.Error(localizeErr)) + return + } + descriptionMessageID := "cmd.group.details.embed.description" description, err := c.localizer.Localize(locale, &i18n.LocalizeConfig{ MessageID: descriptionMessageID, @@ -780,6 +907,7 @@ func (c *groupCommand) handleDetails(ctx context.Context, s *discordgo.Session, "Internals": boolToEmoji(g.Internals), "Barbarians": boolToEmoji(g.Barbarians), "Tribes": builderTribes.String(), + "Language": lang, }, }) if err != nil { @@ -802,7 +930,10 @@ func (c *groupCommand) handleDetails(ctx context.Context, s *discordgo.Session, } func (c *groupCommand) handleSet(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { - switch i.ApplicationCommandData().Options[0].Options[0].Name { + name := i.ApplicationCommandData().Options[0].Options[0].Name + switch name { + case "language": + c.handleSetLanguage(ctx, s, i) case "channel-gains": c.handleSetChannelGains(ctx, s, i) case "channel-losses": @@ -811,9 +942,54 @@ func (c *groupCommand) handleSet(ctx context.Context, s *discordgo.Session, i *d c.handleSetInternals(ctx, s, i) case "barbarians": c.handleSetBarbarians(ctx, s, i) + default: + c.logger.Error("unknown subcommand", zap.String("command", "group set"), zap.String("subcommand", name)) } } +func (c *groupCommand) handleSetLanguage(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + locale := string(i.Locale) + + group := "" + languageTag := "" + for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options { + if opt == nil { + continue + } + switch opt.Name { + case "group": + group = opt.StringValue() + case "language": + languageTag = opt.StringValue() + } + } + + _, err := c.groupSvc.SetLanguageTag(ctx, group, i.GuildID, languageTag) + if err != nil { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: c.localizer.LocalizeError(locale, err), + }, + }) + return + } + + successMessageID := "cmd.group.set.language.success" + successMsg, err := c.localizer.Localize(locale, &i18n.LocalizeConfig{MessageID: successMessageID}) + if err != nil { + c.logger.Error("no message with the specified identifier", zap.String("id", successMessageID), zap.Error(err)) + return + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: successMsg, + }, + }) +} + func (c *groupCommand) handleSetChannelGains(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { locale := string(i.Locale) @@ -987,11 +1163,14 @@ func (c *groupCommand) handleSetBarbarians(ctx context.Context, s *discordgo.Ses } func (c *groupCommand) handleTribe(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { - switch i.ApplicationCommandData().Options[0].Options[0].Name { + name := i.ApplicationCommandData().Options[0].Options[0].Name + switch name { case "add": c.handleTribeAdd(ctx, s, i) case "remove": c.handleTribeRemove(ctx, s, i) + default: + c.logger.Error("unknown subcommand", zap.String("command", "group tribe"), zap.String("subcommand", name)) } } diff --git a/internal/discord/discord.go b/internal/discord/discord.go index fbc5978..6e0565b 100644 --- a/internal/discord/discord.go +++ b/internal/discord/discord.go @@ -30,3 +30,7 @@ func boolToEmoji(val bool) string { } return ":x:" } + +func buildLangMessageID(languageTag string) string { + return "lang." + languageTag +} diff --git a/internal/discord/internal/discordi18n/locale.en.json b/internal/discord/internal/discordi18n/locale.en.json index 5cb6b8e..cec39f2 100644 --- a/internal/discord/internal/discordi18n/locale.en.json +++ b/internal/discord/internal/discordi18n/locale.en.json @@ -1,4 +1,7 @@ { + "lang.pl": "Polish", + "lang.en": "English", + "err.default": "Something went wrong. Try again later.", "err.required": "{{ .Field }} can't be blank.", "err.group-limit-reached": "The group limit has been reached ({{ .Current }}/{{ .Limit }}).", @@ -12,11 +15,14 @@ "err.tribe-not-found": "Tribe (tag={{ .Tag }}) not found.", "err.tribe-does-not-exist": "The tribe (tag={{ .Tag }}) doesn't exist.", + "job.execute-monitors.embed.description": "{{ buildPlayerMarkdown .Ennoblement.NewOwner }} has taken {{ buildLink .Ennoblement.Village.FullName .Ennoblement.Village.ProfileURL }} (Old owner: {{ buildPlayerMarkdown .Ennoblement.Village.Player }}).", + "cmd.group.description": "Manages groups on this server", "cmd.group.create.description": "Creates a new monitor group", "cmd.group.create.option.version.description": "e.g. www.tribalwars.net, www.plemiona.pl", "cmd.group.create.option.server.description": "Tribal Wars server (e.g. en115, pl170)", + "cmd.group.create.option.language.description": "Language in which you wish to receive notifications", "cmd.group.create.option.internals.description": "Show conquers in the same group", "cmd.group.create.option.barbarians.description": "Show barbarian conquers", "cmd.group.create.option.channel-gains.description": "Channel where notifications of gained villages will appear", @@ -25,10 +31,10 @@ "cmd.group.list.description": "Lists all created groups on this server", "cmd.group.list.embed.title": "Group list", - "cmd.group.list.embed.field.value": "**Server**: {{ .ServerKey }}\n**Channel gains**: {{ .ChannelGains }}\n**Channel losses**: {{ .ChannelLosses }}\n**Internals**: {{ .Internals }}\n **Barbarians**: {{ .Barbarians }}\n **Number of monitored tribes**: {{ .NumTribes }}", + "cmd.group.list.embed.field.value": "**Server**: {{ .ServerKey }}\n**Language**: {{ .Language }}\n**Channel gains**: {{ .ChannelGains }}\n**Channel losses**: {{ .ChannelLosses }}\n**Internals**: {{ .Internals }}\n **Barbarians**: {{ .Barbarians }}\n **Number of monitored tribes**: {{ .NumTribes }}", "cmd.group.details.description": "Displays group details (including added tribes)", - "cmd.group.details.embed.description": "**Server**: {{ .ServerKey }}\n**Channel gains**: {{ .ChannelGains }}\n**Channel losses**: {{ .ChannelLosses }}\n**Internals**: {{ .Internals }}\n **Barbarians**: {{ .Barbarians }}\n **Tribes**: {{ .Tribes }}", + "cmd.group.details.embed.description": "**Server**: {{ .ServerKey }}\n**Language**: {{ .Language }}\n**Channel gains**: {{ .ChannelGains }}\n**Channel losses**: {{ .ChannelLosses }}\n**Internals**: {{ .Internals }}\n **Barbarians**: {{ .Barbarians }}\n **Tribes**: {{ .Tribes }}", "cmd.group.tribe.description": "Manages tribes in a group", @@ -44,6 +50,11 @@ "cmd.group.set.description": "Sets various properties in group configuration", + "cmd.group.set.language.description": "Sets the language in which you will receive notifications", + "cmd.group.set.language.option.group.description": "Group ID", + "cmd.group.set.language.option.language.description": "Language in which you wish to receive notifications", + "cmd.group.set.language.success": "The group has been successfully updated.", + "cmd.group.set.channel-gains.description": "Enables/disables notifications of gained villages", "cmd.group.set.channel-gains.option.group.description": "Group ID", "cmd.group.set.channel-gains.option.channel.description": "Channel where notifications of gained villages will appear", diff --git a/internal/discord/internal/discordi18n/locale.pl.json b/internal/discord/internal/discordi18n/locale.pl.json index d2e07dc..1c52357 100644 --- a/internal/discord/internal/discordi18n/locale.pl.json +++ b/internal/discord/internal/discordi18n/locale.pl.json @@ -1,4 +1,7 @@ { + "lang.pl": "Polski", + "lang.en": "Angielski", + "err.default": "Coś poszło nie tak. Spróbuj ponownie później.", "err.required": "{{ .Field }} nie może być pusty.", "err.group-limit-reached": "Limit grup został osiągnięty ({{ .Current }}/{{ .Limit }}).", @@ -12,11 +15,14 @@ "err.tribe-not-found": "Plemię (skrót={{ .Tag }}) nie zostało znalezione.", "err.tribe-does-not-exist": "Plemię (skrót={{ .Tag }}) nie istnieje.", + "job.execute-monitors.embed.description": "{{ buildPlayerMarkdown .Ennoblement.NewOwner }} przejął {{ buildLink .Ennoblement.Village.FullName .Ennoblement.Village.ProfileURL }} (Poprzedni właściciel: {{ buildPlayerMarkdown .Ennoblement.Village.Player }}).", + "cmd.group.description": "Umożliwia zarządzanie grupami na tym serwerze", "cmd.group.create.description": "Tworzy nową grupę", "cmd.group.create.option.version.description": "np. www.tribalwars.net, www.plemiona.pl", "cmd.group.create.option.server.description": "Serwer (np. en115, pl170)", + "cmd.group.create.option.language.description": "Język, w którym mają być wysyłane powiadomienia", "cmd.group.create.option.internals.description": "Informuj o przejęciach wewnętrznych", "cmd.group.create.option.barbarians.description": "Informuj o przejęciach wiosek barbarzyńskich", "cmd.group.create.option.channel-gains.description": "Kanał na którym będą pojawiać się powiadomienia o zdobytych wioskach", @@ -25,10 +31,10 @@ "cmd.group.list.description": "Wyświetla wszystkie utworzone grupy na tym serwerze", "cmd.group.list.embed.title": "Grupy", - "cmd.group.list.embed.field.value": "**Serwer**: {{ .ServerKey }}\n**Kanał zdobyte wioski**: {{ .ChannelGains }}\n**Kanał stracone wioski**: {{ .ChannelLosses }}\n**Przejęcia wewnętrzne**: {{ .Internals }}\n **Przejęcia barbarek**: {{ .Barbarians }}\n **Liczba monitorowanych plemion**: {{ .NumTribes }}", + "cmd.group.list.embed.field.value": "**Serwer**: {{ .ServerKey }}\n**Język**: {{ .Language }}\n**Kanał zdobyte wioski**: {{ .ChannelGains }}\n**Kanał stracone wioski**: {{ .ChannelLosses }}\n**Przejęcia wewnętrzne**: {{ .Internals }}\n **Przejęcia barbarek**: {{ .Barbarians }}\n **Liczba monitorowanych plemion**: {{ .NumTribes }}", "cmd.group.details.description": "Wyświetla szczegóły grupy (w tym dodane plemiona)", - "cmd.group.details.embed.description": "**Serwer**: {{ .ServerKey }}\n**Kanał zdobyte wioski**: {{ .ChannelGains }}\n**Kanał stracone wioski**: {{ .ChannelLosses }}\n**Przejęcia wewnętrzne**: {{ .Internals }}\n **Przejęcia barbarek**: {{ .Barbarians }}\n **Plemiona**: {{ .Tribes }}", + "cmd.group.details.embed.description": "**Serwer**: {{ .ServerKey }}\n**Język**: {{ .Language }}\n**Kanał zdobyte wioski**: {{ .ChannelGains }}\n**Kanał stracone wioski**: {{ .ChannelLosses }}\n**Przejęcia wewnętrzne**: {{ .Internals }}\n **Przejęcia barbarek**: {{ .Barbarians }}\n **Plemiona**: {{ .Tribes }}", "cmd.group.tribe.description": "Zarządza plemionami w grupie", @@ -44,6 +50,11 @@ "cmd.group.set.description": "Ustawia różne właściwości w konfiguracji grupy", + "cmd.group.set.language.description": "Ustawia język w którym będziesz otrzymywać powiadomienia", + "cmd.group.set.language.option.group.description": "ID grupy", + "cmd.group.set.language.option.language.description": "Język, w którym mają być wysyłane powiadomienia", + "cmd.group.set.language.success": "Grupa została pomyślnie zaaktualizowana.", + "cmd.group.set.channel-gains.description": "Włącza/wyłącza powiadomienia o podbitych wioskach", "cmd.group.set.channel-gains.option.group.description": "ID grupy", "cmd.group.set.channel-gains.option.channel.description": "Kanał na którym będą pojawiać się powiadomienia o zdobytych wioskach", diff --git a/internal/discord/internal/discordi18n/localizer.go b/internal/discord/internal/discordi18n/localizer.go index 54494be..b3c1dea 100644 --- a/internal/discord/internal/discordi18n/localizer.go +++ b/internal/discord/internal/discordi18n/localizer.go @@ -112,3 +112,7 @@ func (l *Localizer) LocalizeError(lang string, errToLocalize error) string { } return msg } + +func (l *Localizer) LanguageTags() []language.Tag { + return l.bundle.LanguageTags() +} diff --git a/internal/discord/job_execute_monitors.go b/internal/discord/job_execute_monitors.go index fa5b0a4..ec81df1 100644 --- a/internal/discord/job_execute_monitors.go +++ b/internal/discord/job_execute_monitors.go @@ -2,12 +2,14 @@ package discord import ( "context" - "fmt" "sync" + "text/template" "time" + "gitea.dwysokinski.me/twhelp/dcbot/internal/discord/internal/discordi18n" "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" "github.com/bwmarrin/discordgo" + "github.com/nicksnyder/go-i18n/v2/i18n" "go.uber.org/zap" ) @@ -18,9 +20,10 @@ const ( ) type executeMonitorsJob struct { - s *discordgo.Session - svc GroupService - logger *zap.Logger + s *discordgo.Session + svc GroupService + localizer *discordi18n.Localizer + logger *zap.Logger } func (e *executeMonitorsJob) Run() { @@ -30,19 +33,32 @@ func (e *executeMonitorsJob) Run() { start := time.Now() notifications, err := e.svc.Execute(ctx) if err != nil { - e.logger.Error("something went wrong while executing monitors", zap.Error(err)) + e.logger.Error( + "something went wrong while executing monitors", + zap.Error(err), + zap.Duration("duration", time.Since(start)), + ) + return + } + + convertedNotifications, err := (ennoblementNotificationToMessageEmbedConverter{notifications, e.localizer}).convert() + if err != nil { + e.logger.Error("couldn't convert []domain.EnnoblementNotification to []*discordgo.MessageEmbed", zap.Error(err)) return } var wg sync.WaitGroup - for ch, embeds := range (ennoblementNotificationToMessageEmbedConverter{notifications}).convert() { + for ch, embeds := range convertedNotifications { wg.Add(1) go func(ch string, embeds []*discordgo.MessageEmbed) { defer wg.Done() for _, embed := range embeds { - _, _ = e.s.ChannelMessageSendEmbed(ch, embed) + _, sendErr := e.s.ChannelMessageSendEmbed(ch, embed) + if sendErr != nil { + e.logger.Warn("couldn't send notification", zap.String("channel", ch), zap.Error(sendErr)) + } } }(ch, embeds) } @@ -57,10 +73,11 @@ func (e *executeMonitorsJob) Run() { } type ennoblementNotificationToMessageEmbedConverter struct { - ns []domain.EnnoblementNotification + ns []domain.EnnoblementNotification + localizer *discordi18n.Localizer } -func (c ennoblementNotificationToMessageEmbedConverter) convert() map[string][]*discordgo.MessageEmbed { +func (c ennoblementNotificationToMessageEmbedConverter) convert() (map[string][]*discordgo.MessageEmbed, error) { m := make(map[string]map[domain.EnnoblementNotificationType][]*discordgo.MessageEmbed) timestamp := formatTimestamp(time.Now()) for _, n := range c.ns { @@ -70,8 +87,11 @@ func (c ennoblementNotificationToMessageEmbedConverter) convert() map[string][]* embeds := m[n.ChannelID][n.Type] - str := c.buildDescription(n) - if l := len(embeds); l == 0 || len(embeds[l-1].Description)+len(str) > embedDescriptionCharLimit { + description, err := c.buildDescription(n) + if err != nil { + return nil, err + } + if l := len(embeds); l == 0 || len(embeds[l-1].Description)+len(description) > embedDescriptionCharLimit { embeds = append(embeds, &discordgo.MessageEmbed{ Color: c.mapNotificationTypeToColor(n.Type), Type: discordgo.EmbedTypeRich, @@ -79,9 +99,9 @@ func (c ennoblementNotificationToMessageEmbedConverter) convert() map[string][]* }) } if len(embeds[len(embeds)-1].Description) > 0 { - str = "\n" + str + description = "\n" + description } - embeds[len(embeds)-1].Description += str + embeds[len(embeds)-1].Description += description m[n.ChannelID][n.Type] = embeds } @@ -99,16 +119,20 @@ func (c ennoblementNotificationToMessageEmbedConverter) convert() map[string][]* } } - return res + return res, nil } -func (c ennoblementNotificationToMessageEmbedConverter) buildDescription(n domain.EnnoblementNotification) string { - return fmt.Sprintf( - "%s has taken %s (Old owner: %s)", - c.buildPlayerMarkdown(n.Ennoblement.NewOwner), - buildLink(n.Ennoblement.Village.FullName, n.Ennoblement.Village.ProfileURL), - c.buildPlayerMarkdown(n.Ennoblement.Village.Player), - ) +func (c ennoblementNotificationToMessageEmbedConverter) buildDescription(n domain.EnnoblementNotification) (string, error) { + return c.localizer.Localize(n.LanguageTag, &i18n.LocalizeConfig{ + MessageID: "job.execute-monitors.embed.description", + TemplateData: map[string]any{ + "Ennoblement": n.Ennoblement, + }, + Funcs: template.FuncMap{ + "buildPlayerMarkdown": c.buildPlayerMarkdown, + "buildLink": buildLink, + }, + }) } func (c ennoblementNotificationToMessageEmbedConverter) buildPlayerMarkdown(p domain.NullPlayerMeta) string { diff --git a/internal/domain/ennoblement_notification.go b/internal/domain/ennoblement_notification.go index 201a3f2..6ab82b9 100644 --- a/internal/domain/ennoblement_notification.go +++ b/internal/domain/ennoblement_notification.go @@ -11,5 +11,6 @@ type EnnoblementNotification struct { Type EnnoblementNotificationType ServerID string ChannelID string + LanguageTag string Ennoblement Ennoblement } diff --git a/internal/domain/group.go b/internal/domain/group.go index 0940200..c2190b6 100644 --- a/internal/domain/group.go +++ b/internal/domain/group.go @@ -14,6 +14,7 @@ type Group struct { Barbarians bool // Show barbarian conquers ServerKey string // TW server key (e.g. en130) VersionCode string + LanguageTag string // BCP 47 language tag CreatedAt time.Time } @@ -37,10 +38,11 @@ type CreateGroupParams struct { channelLosses string barbarians bool internals bool + languageTag string } func NewCreateGroupParams( - serverID, versionCode, serverKey, channelGains, channelLosses string, + serverID, versionCode, serverKey, languageTag, channelGains, channelLosses string, barbarians, internals bool, ) (CreateGroupParams, error) { if serverID == "" { @@ -55,6 +57,10 @@ func NewCreateGroupParams( return CreateGroupParams{}, RequiredError{Field: "ServerKey"} } + if languageTag == "" { + return CreateGroupParams{}, RequiredError{Field: "LanguageTag"} + } + return CreateGroupParams{ serverID: serverID, serverKey: serverKey, @@ -63,6 +69,7 @@ func NewCreateGroupParams( channelLosses: channelLosses, barbarians: barbarians, internals: internals, + languageTag: languageTag, }, nil } @@ -74,6 +81,10 @@ func (c CreateGroupParams) VersionCode() string { return c.versionCode } +func (c CreateGroupParams) LanguageTag() string { + return c.languageTag +} + func (c CreateGroupParams) ServerKey() string { return c.serverKey } @@ -97,6 +108,7 @@ func (c CreateGroupParams) Internals() bool { type UpdateGroupParams struct { ChannelGains NullString ChannelLosses NullString + LanguageTag NullString Internals NullBool Barbarians NullBool } @@ -105,7 +117,8 @@ func (u UpdateGroupParams) IsZero() bool { return !u.ChannelGains.Valid && !u.ChannelLosses.Valid && !u.Internals.Valid && - !u.Barbarians.Valid + !u.Barbarians.Valid && + !u.LanguageTag.Valid } type ListGroupsParams struct { diff --git a/internal/domain/group_test.go b/internal/domain/group_test.go index 7c28152..63e94de 100644 --- a/internal/domain/group_test.go +++ b/internal/domain/group_test.go @@ -5,6 +5,7 @@ import ( "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" "github.com/stretchr/testify/assert" + "golang.org/x/text/language" ) func TestNewCreateGroupParams(t *testing.T) { @@ -15,6 +16,7 @@ func TestNewCreateGroupParams(t *testing.T) { serverID string versionCode string serverKey string + languageTag string channelGains string channelLosses string barbarians bool @@ -26,6 +28,7 @@ func TestNewCreateGroupParams(t *testing.T) { serverID: "123441", versionCode: "en", serverKey: "en113", + languageTag: language.English.String(), channelGains: "1234", channelLosses: "1234", barbarians: true, @@ -51,6 +54,14 @@ func TestNewCreateGroupParams(t *testing.T) { serverKey: "", err: domain.RequiredError{Field: "ServerKey"}, }, + { + name: "ERR: LanguageTag cannot be blank", + serverID: "1234", + versionCode: "en", + serverKey: "en113", + languageTag: "", + err: domain.RequiredError{Field: "LanguageTag"}, + }, } for _, tt := range tests { @@ -63,6 +74,7 @@ func TestNewCreateGroupParams(t *testing.T) { tt.serverID, tt.versionCode, tt.serverKey, + tt.languageTag, tt.channelGains, tt.channelLosses, tt.barbarians, @@ -93,28 +105,6 @@ func TestUpdateGroupParams_IsZero(t *testing.T) { params domain.UpdateGroupParams output bool }{ - { - name: "OK: all", - params: domain.UpdateGroupParams{ - ChannelGains: domain.NullString{ - String: "123", - Valid: true, - }, - ChannelLosses: domain.NullString{ - String: "123", - Valid: true, - }, - Barbarians: domain.NullBool{ - Bool: false, - Valid: true, - }, - Internals: domain.NullBool{ - Bool: false, - Valid: true, - }, - }, - output: false, - }, { name: "OK: ChannelGains", params: domain.UpdateGroupParams{ @@ -155,6 +145,16 @@ func TestUpdateGroupParams_IsZero(t *testing.T) { }, output: false, }, + { + name: "OK: LanguageTag", + params: domain.UpdateGroupParams{ + LanguageTag: domain.NullString{ + String: language.English.String(), + Valid: true, + }, + }, + output: false, + }, { name: "OK: empty struct", params: domain.UpdateGroupParams{}, diff --git a/internal/service/ennoblement_notification_builder.go b/internal/service/ennoblement_notification_builder.go index 1c53d25..3233c14 100644 --- a/internal/service/ennoblement_notification_builder.go +++ b/internal/service/ennoblement_notification_builder.go @@ -27,6 +27,7 @@ func (b ennoblementNotificationBuilder) buildGroup(g domain.GroupWithMonitors) [ Type: domain.EnnoblementNotificationTypeGain, ServerID: g.ServerID, ChannelID: g.ChannelGains, + LanguageTag: g.LanguageTag, Ennoblement: b.ennoblementToDomainModel(e), }) } @@ -36,6 +37,7 @@ func (b ennoblementNotificationBuilder) buildGroup(g domain.GroupWithMonitors) [ Type: domain.EnnoblementNotificationTypeLoss, ServerID: g.ServerID, ChannelID: g.ChannelLosses, + LanguageTag: g.LanguageTag, Ennoblement: b.ennoblementToDomainModel(e), }) } diff --git a/internal/service/group.go b/internal/service/group.go index 3393d04..c42c4d5 100644 --- a/internal/service/group.go +++ b/internal/service/group.go @@ -209,6 +209,15 @@ func (g *Group) SetBarbarians(ctx context.Context, id, serverID string, barbaria }) } +func (g *Group) SetLanguageTag(ctx context.Context, id, serverID, languageTag string) (domain.GroupWithMonitors, error) { + return g.update(ctx, id, serverID, domain.UpdateGroupParams{ + LanguageTag: domain.NullString{ + String: languageTag, + Valid: true, + }, + }) +} + func (g *Group) update(ctx context.Context, id, serverID string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error) { // check if group exists if _, err := g.Get(ctx, id, serverID); err != nil { diff --git a/internal/service/group_test.go b/internal/service/group_test.go index 5a1e698..0421d2e 100644 --- a/internal/service/group_test.go +++ b/internal/service/group_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" + "golang.org/x/text/language" ) func TestGroup_Create(t *testing.T) { @@ -26,6 +27,7 @@ func TestGroup_Create(t *testing.T) { "592292203234328587", "en", "en113", + language.English.String(), "1234", "1235", true, @@ -457,6 +459,90 @@ func TestGroup_RemoveTribe(t *testing.T) { }) } +func TestGroup_SetLanguageTag(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + repo.GetReturns(group, nil) + repo.UpdateCalls(func(ctx context.Context, id string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error) { + return domain.GroupWithMonitors{ + Group: domain.Group{ + ID: id, + LanguageTag: params.LanguageTag.String, + }, + }, nil + }) + + languageTag := uuid.NewString() + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + SetLanguageTag(context.Background(), group.ID, group.ServerID, languageTag) + assert.NoError(t, err) + assert.Equal(t, group.ID, g.ID) + assert.Equal(t, languageTag, g.LanguageTag) + }) + + t.Run("ERR: group not found", func(t *testing.T) { + t.Parallel() + + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + + tests := []struct { + name string + group domain.GroupWithMonitors + serverID string + err error + }{ + { + name: "repository returned error", + serverID: group.ServerID, + err: domain.GroupNotFoundError{ + ID: group.ID, + }, + }, + { + name: "incorrect server id", + group: group, + serverID: uuid.NewString(), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + repo.GetReturns(tt.group, tt.err) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + SetLanguageTag(context.Background(), group.ID, tt.serverID, uuid.NewString()) + assert.ErrorIs(t, err, domain.GroupNotFoundError{ + ID: group.ID, + }) + assert.Zero(t, g) + }) + } + }) +} + func TestGroup_SetChannelGains(t *testing.T) { t.Parallel() diff --git a/migrations/20230628110016_group_add_language_tag.sql b/migrations/20230628110016_group_add_language_tag.sql new file mode 100644 index 0000000..b85056a --- /dev/null +++ b/migrations/20230628110016_group_add_language_tag.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE groups ADD COLUMN language_tag varchar(10) NOT NULL DEFAULT 'en'; -- BCP 47 language tag +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE groups DROP COLUMN IF EXISTS language_tag; +-- +goose StatementEnd