handlers

package
v0.0.0-...-910ec09 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2023 License: MIT Imports: 8 Imported by: 0

Documentation

Index

Constants

View Source
const (
	PrimaryEmbedColor = 9383347
	ErrorEmbedColor   = 13179932
)

Variables

View Source
var CommandHandlers = map[string]CommandHandler{
	"ping": func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate) {
		err := session.InteractionRespond(interactionCreate.Interaction, &discordgo.InteractionResponse{
			Type: discordgo.InteractionResponseChannelMessageWithSource,
			Data: &discordgo.InteractionResponseData{
				Content: "Pong!",
				Flags:   1 << 6,
			},
		})
		if err != nil {
			log.Printf("Error responding to interaction: %v", err)
		}
	},
	"whitelist": func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate) {
		whitelistPlayers, err := connection.GetPlayerWhitelist()
		if err != nil {
			interactionRespondError(session, interactionCreate.Interaction, "Failed to get player whitelist")
			return
		}
		whitelistPlayerCount := len(whitelistPlayers)

		var whitelistPlayerString string
		if whitelistPlayerCount != 0 {
			whitelistPlayerString = "`" + strings.Join(whitelistPlayers, "`, `") + "`"
			if len(whitelistPlayerString) > 1019 {
				whitelistPlayerString = "`" + strings.Join(whitelistPlayers, "`, `")[:1021] + "`..."
			}
		} else {
			whitelistPlayerString = "The whitelist is empty."
		}

		err = session.InteractionRespond(interactionCreate.Interaction, &discordgo.InteractionResponse{
			Type: discordgo.InteractionResponseChannelMessageWithSource,
			Data: &discordgo.InteractionResponseData{
				Embeds: []*discordgo.MessageEmbed{
					{
						Title:       "Whitelist",
						Description: whitelistPlayerString,
						Color:       PrimaryEmbedColor,
					},
				},
			},
		})

		if err != nil {
			log.Printf("Error responding to interaction: %v", err)
		}
	},
	"settings": func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate) {
		if interactionCreate.Member.Permissions&discordgo.PermissionAdministrator == 0 {
			interactionRespondError(session, interactionCreate.Interaction, "Sorry, you don't have permission.")
			return
		}

		options := interactionCreate.ApplicationCommandData().Options

		if options[0].Name == "set" {
			subcommandOptions := options[0].Options

			settingName := subcommandOptions[0].StringValue()
			settingValue := subcommandOptions[1].StringValue()

			err := connection.SetSettingValue(connection.SettingName(settingName), settingValue)
			if err != nil {
				interactionRespondError(session, interactionCreate.Interaction, fmt.Sprintf("Error occurred: %v", err))
				return
			}

			err = session.InteractionRespond(interactionCreate.Interaction, &discordgo.InteractionResponse{
				Type: discordgo.InteractionResponseChannelMessageWithSource,
				Data: &discordgo.InteractionResponseData{
					Embeds: []*discordgo.MessageEmbed{
						{
							Title:       "Setting edited",
							Description: "Setting edited successfully",
							Color:       PrimaryEmbedColor,
							Fields: []*discordgo.MessageEmbedField{
								{
									Name:  "Name",
									Value: settingName,
								},
								{
									Name:  "Value",
									Value: settingValue,
								},
							},
						},
					},
					Flags: 1 << 6,
				},
			})
			if err != nil {
				log.Printf("Error responding to interaction: %v", err)
			}
		} else if options[0].Name == "view" {
			settings, err := connection.GetSettings()
			if err != nil {
				interactionRespondError(session, interactionCreate.Interaction, fmt.Sprintf("Error occurred: %v", err))
				return
			}

			var fields []*discordgo.MessageEmbedField
			for _, setting := range settings {
				fields = append(fields, &discordgo.MessageEmbedField{
					Name:  setting.Name,
					Value: setting.Value,
				})
			}

			err = session.InteractionRespond(interactionCreate.Interaction, &discordgo.InteractionResponse{
				Type: discordgo.InteractionResponseChannelMessageWithSource,
				Data: &discordgo.InteractionResponseData{
					Embeds: []*discordgo.MessageEmbed{
						{
							Title:       "Settings",
							Description: "Full list of settings",
							Color:       PrimaryEmbedColor,
							Fields:      fields,
						},
					},
					Flags: 1 << 6,
				},
			})
			if err != nil {
				log.Printf("Error responding to interaction: %v", err)
			}
		} else if options[0].Name == "delete" {
			subcommandOptions := options[0].Options
			settingName := subcommandOptions[0].StringValue()

			err := connection.DeleteSetting(connection.SettingName(settingName))
			if err != nil {
				interactionRespondError(session, interactionCreate.Interaction, fmt.Sprintf("Error occurred: %v", err))
				return
			}

			err = session.InteractionRespond(interactionCreate.Interaction, &discordgo.InteractionResponse{
				Type: discordgo.InteractionResponseChannelMessageWithSource,
				Data: &discordgo.InteractionResponseData{
					Embeds: []*discordgo.MessageEmbed{
						{
							Title:       "Setting deleted",
							Description: "Setting deleted successfully",
							Color:       PrimaryEmbedColor,
							Fields: []*discordgo.MessageEmbedField{
								{
									Name:  "Name",
									Value: settingName,
								},
							},
						},
					},
					Flags: 1 << 6,
				},
			})
			if err != nil {
				log.Printf("Error responding to interaction: %v", err)
			}
		}
	},
	"register": func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate) {
		if interactionCreate.Member.Permissions&discordgo.PermissionManageServer == 0 {
			interactionRespondError(session, interactionCreate.Interaction, "Sorry, you don't have permission.")
			return
		}

		err := session.InteractionRespond(interactionCreate.Interaction, &discordgo.InteractionResponse{
			Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
			Data: &discordgo.InteractionResponseData{
				Flags: 1 << 6,
			},
		})
		if err != nil {
			log.Printf("Error responding to interaction: %v", err)
			return
		}

		options := interactionCreate.ApplicationCommandData().Options
		minecraftNickname := options[1].StringValue()
		user := options[0].UserValue(session)

		password, err := connection.RegisterPlayer(options[0].UserValue(session).ID, minecraftNickname)
		if errors.Is(err, connection.PlayerAlreadyExistsError) {
			interactionResponseErrorEdit(session, interactionCreate.Interaction, "Player already exists")
			return
		} else if err != nil {
			interactionResponseErrorEdit(session, interactionCreate.Interaction, fmt.Sprintf("Error occurred registring player: %v", err))
			connection.DeletePlayer(user.ID)
			connection.RemovePlayerWhitelist(minecraftNickname)
			return
		}

		channel, messageErr := session.UserChannelCreate(user.ID)

		if messageErr == nil {
			_, messageErr = session.ChannelMessageSendComplex(channel.ID, &discordgo.MessageSend{
				Embeds: []*discordgo.MessageEmbed{
					{
						Title:       "Minecraft Server Night Pix",
						Description: "You have been successfully registered on the server.",
						Fields: []*discordgo.MessageEmbedField{
							{
								Name:   "Discord member",
								Value:  fmt.Sprintf("<@%v>", user.ID),
								Inline: true,
							},
							{
								Name:   "Minecraft nickname",
								Value:  minecraftNickname,
								Inline: true,
							},
							{
								Name:   "Password",
								Value:  fmt.Sprintf("||%v||", password),
								Inline: true,
							},
						},
						Color: PrimaryEmbedColor,
					},
				},
			})
		}

		setting, roleErr := connection.GetSetting(connection.MinecraftRoleSetting)
		if roleErr == nil {
			roleErr = session.GuildMemberRoleAdd(GuildId, user.ID, setting.Value)
		}

		go updateWhitelistMessage(session)

		_, err = session.FollowupMessageCreate(interactionCreate.Interaction, true, &discordgo.WebhookParams{
			Embeds: []*discordgo.MessageEmbed{
				{
					Title:       "Player registered",
					Description: "Successfully registered new player.",
					Fields: []*discordgo.MessageEmbedField{
						{
							Name:   "Discord member",
							Value:  fmt.Sprintf("<@%v>", user.ID),
							Inline: true,
						},
						{
							Name:   "Minecraft nickname",
							Value:  minecraftNickname,
							Inline: true,
						},
						{
							Name:   "Password",
							Value:  fmt.Sprintf("||%v||", password),
							Inline: true,
						},
						{
							Name:   "Message error",
							Value:  fmt.Sprint(messageErr),
							Inline: true,
						},
						{
							Name:   "Role error",
							Value:  fmt.Sprint(roleErr),
							Inline: true,
						},
					},
					Color: PrimaryEmbedColor,
				},
			},
			Flags: 1 << 6,
		})
		if err != nil {
			log.Printf("Error responding to interaction: %v", err)
		}
	},
	"unregister": func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate) {
		if interactionCreate.Member.Permissions&discordgo.PermissionManageServer == 0 {
			interactionRespondError(session, interactionCreate.Interaction, "Sorry, you don't have permission.")
			return
		}

		err := session.InteractionRespond(interactionCreate.Interaction, &discordgo.InteractionResponse{
			Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
			Data: &discordgo.InteractionResponseData{
				Flags: 1 << 6,
			},
		})
		if err != nil {
			log.Printf("Error responding to interaction: %v", err)
			return
		}

		user := interactionCreate.Interaction.ApplicationCommandData().Options[0].UserValue(session)

		player, err := connection.UnregisterPlayer(user.ID)
		if err != nil {
			log.Printf("Error unregistring player: %v", err)
			interactionResponseErrorEdit(session, interactionCreate.Interaction, fmt.Sprintf("Error occurred unregistring player: %v", err))
			return
		}

		setting, roleErr := connection.GetSetting(connection.MinecraftRoleSetting)
		if roleErr == nil {
			roleErr = session.GuildMemberRoleRemove(GuildId, user.ID, setting.Value)
		}

		go updateWhitelistMessage(session)

		_, err = session.FollowupMessageCreate(interactionCreate.Interaction, true, &discordgo.WebhookParams{
			Embeds: []*discordgo.MessageEmbed{
				{
					Title:       "Player unregistered",
					Description: "Successfully unregistered player.",
					Fields: []*discordgo.MessageEmbedField{
						{
							Name:   "Discord member",
							Value:  fmt.Sprintf("<@%v>", user.ID),
							Inline: true,
						},
						{
							Name:   "Minecraft nickname",
							Value:  player.MinecraftNickname,
							Inline: true,
						},
						{
							Name:   "Role error",
							Value:  fmt.Sprint(roleErr),
							Inline: true,
						},
					},
					Color: PrimaryEmbedColor,
				},
			},
			Flags: 1 << 6,
		})
		if err != nil {
			log.Printf("Error responding to interaction: %v", err)
		}
	},
	"reset-password": resetPasswordHandler,
	"send-whitelist": func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate) {
		if interactionCreate.Member.Permissions&discordgo.PermissionAdministrator == 0 {
			interactionRespondError(session, interactionCreate.Interaction, "Sorry, you don't have permission.")
			return
		}

		channel := interactionCreate.ApplicationCommandData().Options[0].ChannelValue(session)

		if channel.Type != discordgo.ChannelTypeGuildText {
			interactionRespondError(session, interactionCreate.Interaction, "Wrong channel type.")
			return
		}

		embed, err := createWhitelistEmbed()
		if err != nil {
			log.Printf("Error creating whitelist message: %v", err)
			return
		}

		message, err := session.ChannelMessageSendComplex(channel.ID, &discordgo.MessageSend{
			Embeds: []*discordgo.MessageEmbed{
				embed,
			},
			Components: []discordgo.MessageComponent{
				discordgo.ActionsRow{
					Components: []discordgo.MessageComponent{
						Components["reset_password"].MessageComponent,
						Components["change_password"].MessageComponent,
					},
				},
			},
		})
		if err != nil {
			interactionRespondError(session, interactionCreate.Interaction, fmt.Sprintf("Error occured sending whitelist: %v", err))
			return
		}

		err = connection.SetSettingValue(connection.WhitelistChannelSetting, channel.ID)
		if err != nil {
			interactionRespondError(session, interactionCreate.Interaction, fmt.Sprintf("Error occured: %v", err))
			return
		}
		err = connection.SetSettingValue(connection.WhitelistMessageSetting, message.ID)
		if err != nil {
			err := connection.DeleteSetting(connection.WhitelistChannelSetting)
			if err != nil {
				log.Printf("Error deleting setting: %v", err)
			}
			interactionRespondError(session, interactionCreate.Interaction, fmt.Sprintf("Error occured: %v", err))
			return
		}

		err = session.InteractionRespond(interactionCreate.Interaction, &discordgo.InteractionResponse{
			Type: discordgo.InteractionResponseChannelMessageWithSource,
			Data: &discordgo.InteractionResponseData{
				Embeds: []*discordgo.MessageEmbed{
					{
						Title:       "Whitelist info",
						Description: "Message sent.",
						Color:       PrimaryEmbedColor,
						Fields: []*discordgo.MessageEmbedField{
							{
								Name:  "Whitelist channel",
								Value: channel.ID,
							},
							{
								Name:  "Whitelist message",
								Value: message.ID,
							},
						},
					},
				},
				Flags: 1 << 6,
			},
		})
		if err != nil {
			log.Printf("Error responding to interaction: %v", err)
		}
	},
}
View Source
var (
	Commands = []*discordgo.ApplicationCommand{
		{
			Type:        discordgo.ChatApplicationCommand,
			Name:        "ping",
			Description: "Pong!",
		},
		{
			Type:        discordgo.ChatApplicationCommand,
			Name:        "whitelist",
			Description: "Get player whitelist",
		},
		{
			Type:        discordgo.ChatApplicationCommand,
			Name:        "settings",
			Description: "Settings management",
			Options: []*discordgo.ApplicationCommandOption{
				{
					Type:        discordgo.ApplicationCommandOptionSubCommand,
					Name:        "set",
					Description: "Set setting value",
					Options: []*discordgo.ApplicationCommandOption{
						{
							Type:        discordgo.ApplicationCommandOptionString,
							Name:        "name",
							Description: "Setting name",
							Required:    true,
							Choices: []*discordgo.ApplicationCommandOptionChoice{
								{
									Name:  "Minecraft role ID",
									Value: connection.MinecraftRoleSetting,
								},
								{
									Name:  "Whitelist info channel ID",
									Value: connection.WhitelistChannelSetting,
								},
								{
									Name:  "Whitelist info message ID",
									Value: connection.WhitelistMessageSetting,
								},
							},
						},
						{
							Type:        discordgo.ApplicationCommandOptionString,
							Name:        "value",
							Description: "Setting value",
							Required:    true,
						},
					},
				},
				{
					Type:        discordgo.ApplicationCommandOptionSubCommand,
					Name:        "view",
					Description: "View all settings",
				},
				{
					Type:        discordgo.ApplicationCommandOptionSubCommand,
					Name:        "delete",
					Description: "Delete setting",
					Options: []*discordgo.ApplicationCommandOption{
						{
							Type:        discordgo.ApplicationCommandOptionString,
							Name:        "name",
							Description: "Setting name",
							Required:    true,
						},
					},
				},
			},
		},
		{
			Type:        discordgo.ChatApplicationCommand,
			Name:        "register",
			Description: "Register new player",
			Options: []*discordgo.ApplicationCommandOption{
				{
					Type:        discordgo.ApplicationCommandOptionUser,
					Name:        "member",
					Description: "Discord server member",
					Required:    true,
				},
				{
					Type:        discordgo.ApplicationCommandOptionString,
					Name:        "nickname",
					Description: "Minecraft nickname",
					Required:    true,
				},
			},
		},
		{
			Type:        discordgo.ChatApplicationCommand,
			Name:        "unregister",
			Description: "Unregister player",
			Options: []*discordgo.ApplicationCommandOption{
				{
					Type:        discordgo.ApplicationCommandOptionUser,
					Name:        "member",
					Description: "Discord server member",
					Required:    true,
				},
			},
		},
		{
			Type:        discordgo.ChatApplicationCommand,
			Name:        "reset-password",
			Description: "Reset player password",
		},
		{
			Type:        discordgo.ChatApplicationCommand,
			Name:        "send-whitelist",
			Description: "Send whitelist message",
			Options: []*discordgo.ApplicationCommandOption{
				{
					Type:        discordgo.ApplicationCommandOptionChannel,
					Name:        "channel",
					Description: "Whitelist channel",
					ChannelTypes: []discordgo.ChannelType{
						discordgo.ChannelTypeGuildText,
					},
					Required: true,
				},
			},
		},
	}
)
View Source
var Components = map[string]Component{
	"apply_for_whitelist": {
		MessageComponent: &discordgo.Button{
			CustomID: "apply_for_whitelist",
			Label:    "Apply for whitelist",
			Style:    discordgo.SuccessButton,
			Emoji: discordgo.ComponentEmoji{
				Name: "✅",
			},
		},
		Handler: func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate) {
			_, err := connection.GetPlayerByDiscord(interactionCreate.Member.User.ID)
			if !errors.Is(err, mongo.ErrNoDocuments) {
				interactionRespondError(session, interactionCreate.Interaction, "You are already registered.")
				return
			}
			if err != nil {
				log.Printf("Error occurred getting player: %v", err)
				return
			}

			err = session.InteractionRespond(interactionCreate.Interaction, &discordgo.InteractionResponse{
				Type: discordgo.InteractionResponseModal,
				Data: Modals["apply_for_whitelist"].Modal,
			})
			if err != nil {
				log.Printf("Error responding to interaction: %v", err)
			}
		},
	},
	"reset_password": {
		MessageComponent: &discordgo.Button{
			CustomID: "reset_password",
			Label:    "Reset password",
			Style:    discordgo.PrimaryButton,
			Emoji: discordgo.ComponentEmoji{
				Name: "🔐",
			},
		},
		Handler: resetPasswordHandler,
	},
	"change_password": {
		MessageComponent: &discordgo.Button{
			CustomID: "change_password",
			Label:    "Change password",
			Style:    discordgo.PrimaryButton,
			Emoji: discordgo.ComponentEmoji{
				Name: "✏",
			},
		},
		Handler: func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate) {
			err := session.InteractionRespond(interactionCreate.Interaction, &discordgo.InteractionResponse{
				Type: discordgo.InteractionResponseModal,
				Data: Modals["change_password"].Modal,
			})
			if err != nil {
				log.Printf("Error responding to interaction: %v", err)
			}
		},
	},
}
View Source
var GuildId string
View Source
var Handlers = []interface{}{
	func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate) {
		switch interactionCreate.Type {
		case discordgo.InteractionApplicationCommand:
			CommandHandlers[interactionCreate.ApplicationCommandData().Name](session, interactionCreate)
		case discordgo.InteractionMessageComponent:
			Components[interactionCreate.MessageComponentData().CustomID].Handler(session, interactionCreate)
		case discordgo.InteractionModalSubmit:
			Modals[interactionCreate.ModalSubmitData().CustomID].Handler(session, interactionCreate)
		}
	},
	func(session *discordgo.Session, ready *discordgo.Ready) {
		updateWhitelistMessage(session)
	},
	func(session *discordgo.Session, guildMemberRemove *discordgo.GuildMemberRemove) {
		_, err := connection.UnregisterPlayer(guildMemberRemove.User.ID)
		if err != nil {
			log.Printf("Error unregistring player: %v", err)
			return
		}
		updateWhitelistMessage(session)
	},
}
View Source
var Modals = map[string]Modal{
	"change_password": {
		Modal: &discordgo.InteractionResponseData{
			Title:    "Change password",
			CustomID: "change_password",
			Components: []discordgo.MessageComponent{
				discordgo.ActionsRow{
					Components: []discordgo.MessageComponent{
						discordgo.TextInput{
							CustomID:    "new_password",
							Label:       "Enter your new password",
							Style:       discordgo.TextInputShort,
							Placeholder: "New password",
							Required:    true,
							MaxLength:   15,
							MinLength:   3,
						},
					},
				},
			},
		},
		Handler: func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate) {
			data := interactionCreate.ModalSubmitData()
			user := interactionCreate.Member.User

			player, err := connection.GetPlayerByDiscord(user.ID)
			if errors.Is(err, mongo.ErrNoDocuments) {
				interactionRespondError(session, interactionCreate.Interaction, "You are not registered.")
				return
			}
			if err != nil {
				interactionRespondError(session, interactionCreate.Interaction, fmt.Sprintf("Error occurred getting player: %v", err))
				return
			}

			password := data.Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput).Value
			err = connection.ChangeMinecraftPlayerPassword(player.MinecraftNickname, password)
			if err != nil {
				interactionRespondError(session, interactionCreate.Interaction, fmt.Sprintf("Error occurred changing player password: %v", err))
				return
			}

			channel, messageErr := session.UserChannelCreate(user.ID)

			if messageErr == nil {
				_, messageErr = session.ChannelMessageSendComplex(channel.ID, &discordgo.MessageSend{
					Embeds: []*discordgo.MessageEmbed{
						{
							Title:       "Minecraft Server Night Pix",
							Description: "Your password has been changed.",
							Fields: []*discordgo.MessageEmbedField{
								{
									Name:   "Discord member",
									Value:  fmt.Sprintf("<@%v>", user.ID),
									Inline: true,
								},
								{
									Name:   "Minecraft nickname",
									Value:  player.MinecraftNickname,
									Inline: true,
								},
								{
									Name:   "Password",
									Value:  fmt.Sprintf("||%v||", password),
									Inline: true,
								},
							},
							Color: PrimaryEmbedColor,
						},
					},
				})
			}
			if messageErr != nil {
				log.Printf("Error sending message: %v", err)
			}

			err = session.InteractionRespond(interactionCreate.Interaction, &discordgo.InteractionResponse{
				Type: discordgo.InteractionResponseChannelMessageWithSource,
				Data: &discordgo.InteractionResponseData{
					Embeds: []*discordgo.MessageEmbed{
						{
							Title:       "Password changed",
							Description: "Your password has been changed.",
							Color:       PrimaryEmbedColor,
							Fields: []*discordgo.MessageEmbedField{
								{
									Name:   "Discord member",
									Value:  fmt.Sprintf("<@%v>", user.ID),
									Inline: true,
								},
								{
									Name:   "Minecraft nickname",
									Value:  player.MinecraftNickname,
									Inline: true,
								},
								{
									Name:   "Password",
									Value:  fmt.Sprintf("||%v||", password),
									Inline: true,
								},
								{
									Name:   "Message error",
									Value:  fmt.Sprint(messageErr),
									Inline: true,
								},
							},
						},
					},
					Flags: 1 << 6,
				},
			})
			if err != nil {
				log.Printf("Error responding to interaction: %v", err)
				return
			}
		},
	},
	"apply_for_whitelist": {
		Modal: &discordgo.InteractionResponseData{
			Title:    "Apply for whitelist",
			CustomID: "apply_for_whitelist",
			Components: []discordgo.MessageComponent{
				discordgo.ActionsRow{
					Components: []discordgo.MessageComponent{
						discordgo.TextInput{
							CustomID:    "minecraft_nickname",
							Label:       "Enter your Minecraft nickname",
							Style:       discordgo.TextInputShort,
							Placeholder: "Minecraft nickname",
							Required:    true,
							MaxLength:   16,
							MinLength:   3,
						},
					},
				},
			},
		},
		Handler: func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate) {

		},
	},
}

Functions

func AddHandlers

func AddHandlers(session *discordgo.Session)

func CreateApplicationCommands

func CreateApplicationCommands(session *discordgo.Session, guildId string)

Types

type CommandHandler

type CommandHandler func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate)

type Component

type Component struct {
	MessageComponent discordgo.MessageComponent
	Handler          func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate)
}
type Modal struct {
	Modal   *discordgo.InteractionResponseData
	Handler func(session *discordgo.Session, interactionCreate *discordgo.InteractionCreate)
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL