Skip to content

Commit

Permalink
Translation support.
Browse files Browse the repository at this point in the history
  • Loading branch information
xlatb committed Nov 19, 2022
1 parent 67e5fb4 commit 619e222
Show file tree
Hide file tree
Showing 10 changed files with 254 additions and 70 deletions.
23 changes: 19 additions & 4 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import urllib, urllib.request, requests, pymysql

#Our Classes
import nsotoken, commandparser, serverconfig, ownercmds, messagecontext
import nsotoken, commandparser, configstore, ownercmds, messagecontext
import vserver, mysqlhandler, mysqlschema, serverutils
import nsohandler, achandler, s3handler
import stringcrypt
Expand All @@ -30,6 +30,7 @@
import gameinfo.splat2
import gameinfo.splat3
import s3.schedule
import translate

# Uncomment for verbose logging from pycord
#logging.basicConfig(level=logging.DEBUG)
Expand Down Expand Up @@ -59,6 +60,8 @@
dev = True
head = {}
keyPath = f"{dirname}/config/db-secret-key.hex"
components = {}
components['translate'] = translate.Translate(f"{dirname}/share/locale/")

def loadConfig():
global configData, mysqlHandler, dev, head
Expand Down Expand Up @@ -198,6 +201,12 @@ async def cmdGithub(ctx):
async def cmdHelp(ctx):
await ctx.respond("Help Menu:", view=serverutils.HelpMenuView(f"{dirname}/help"))

@client.slash_command(name='lang', description='Select your language.')
async def cmdLang(ctx, language: Option(str, "Language", choices = [discord.OptionChoice(f"{components['translate'].LANGS[k]['name']} ({components['translate'].LANGS[k]['country']})", k) for k in components['translate'].LANGS])):
await components['user_config'].setConfigValue(ctx.user.id, "lang", language)
components['translate'].select(language)
await ctx.respond(_("Okay, I set your language to: %s") % (language,), ephemeral=True)

@adminAnnounceCmds.command(name='set', description="Sets a chat channel to receive announcements from my developers")
async def cmdAnnounceAdd(ctx, channel: Option(discord.TextChannel, "Channel to set to receive announcements", required=True)):
if ctx.guild == None:
Expand Down Expand Up @@ -764,14 +773,15 @@ async def on_ready():
if server.id not in serverVoices:
serverVoices[server.id] = vserver.voiceServer(client, mysqlHandler, server.id, configData['soundsdir'])

serverConfig = serverconfig.ServerConfig(mysqlHandler)
serverConfig = configstore.ServerConfig(mysqlHandler)
components['user_config'] = configstore.UserConfig(mysqlHandler)
commandParser = commandparser.CommandParser(serverConfig, client.user.id)
ownerCmds = ownercmds.ownerCmds(client, mysqlHandler, commandParser, owners)
serverUtils = serverutils.serverUtils(client, mysqlHandler, serverConfig)
friendCodes = friendcodes.FriendCodes(mysqlHandler, stringCrypt)
nsoTokens = nsotoken.Nsotoken(client, configData, mysqlHandler, stringCrypt, friendCodes)
nsoHandler = nsohandler.nsoHandler(client, mysqlHandler, nsoTokens, splat2info, configData)
s3Handler = s3handler.S3Handler(client, mysqlHandler, nsoTokens, splat3info, configData, fonts, cachemanager)
s3Handler = s3handler.S3Handler(client, mysqlHandler, nsoTokens, splat3info, configData, fonts, cachemanager, components)
acHandler = achandler.acHandler(client, mysqlHandler, nsoTokens, configData)
await mysqlHandler.startUp()
mysqlSchema = mysqlschema.MysqlSchema(mysqlHandler)
Expand All @@ -785,7 +795,7 @@ async def on_ready():

await nsoHandler.updateS2JSON()
await s3Handler.storedm.cacheS3JSON()
client.before_invoke(serverUtils.contextIncrementCmd)
client.before_invoke(command_hook)
print('Done\n------')
await client.change_presence(status=discord.Status.online, activity=discord.Game("Check /help for cmd info."))
else:
Expand All @@ -794,6 +804,11 @@ async def on_ready():

sys.stdout.flush()

async def command_hook(ctx):
await serverUtils.contextIncrementCmd(ctx)
if (ctx.user.id) and (lang := await components['user_config'].getConfigValue(ctx.user.id, "lang")):
components['translate'].select(lang)

@client.event
async def on_member_remove(member):
global serverUtils, doneStartup
Expand Down
72 changes: 72 additions & 0 deletions modules/configstore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import json

class ConfigStore():
def __init__(self, mysqlhandler, table_name, key_name):
self.sql = mysqlhandler
self.table_name = table_name
self.key_name = key_name

async def getConfig(self, cursor, key):
await cursor.execute(f"SELECT config FROM `{self.table_name}` WHERE (`{self.key_name}` = %s)", (key,))
row = await cursor.fetchone()
if row == None:
return {} # Blank config
return json.loads(row[0])

async def setConfig(self, cursor, key, config):
jsonconfig = json.dumps(config)
await cursor.execute(f"REPLACE INTO `{self.table_name}` (`{self.key_name}`, config) VALUES (%s, %s)", (key, jsonconfig))

async def getConfigValue(self, key, path):
cursor = await self.sql.connect()
value = await self.getConfig(cursor, key)
await self.sql.commit(cursor)
path = path.split(".")
for p in path:
if not p in value:
return None # No such key
value = value[p]
return value

async def setConfigValue(self, key, path, new):
cursor = await self.sql.connect()
config = await self.getConfig(cursor, key)
value = config
path = path.split(".")
for p in path[0:len(path) - 1]:
if not p in value:
value[p] = {} # Autovivify
elif not isinstance(value[p], dict):
value[p] = {} # Overwrite scalar with dict
value = value[p]
value[path[-1]] = new
await self.setConfig(cursor, key, config)
await self.sql.commit(cursor)
return

async def removeConfigValue(self, key, path):
cursor = await self.sql.connect()
config = await self.getConfig(cursor, key)
value = config
path = path.split(".")
for p in path[0:len(path) - 1]:
if not p in value:
await self.sql.rollback(cursor)
return # Non-existant parent element in path
elif not isinstance(value[p], dict):
await self.sql.rollback(cursor)
return # Parent element in path is not dict
value = value[p]
if path[-1] in value:
del value[path[-1]]
await self.setConfig(cursor, key, config)
await self.sql.commit(cursor)
return

class UserConfig(ConfigStore):
def __init__(self, mysqlhandler):
super().__init__(mysqlhandler, 'user_config', 'userid')

class ServerConfig(ConfigStore):
def __init__(self, mysqlhandler):
super().__init__(mysqlhandler, 'server_config', 'serverid')
14 changes: 14 additions & 0 deletions modules/mysqlschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ async def update(self):
)
await self.sqlBroker.c_commit(cur)

if not await self.sqlBroker.hasTable(cur, 'user_config'):
print("Creating table 'user_config'...")
await cur.execute(
"""
CREATE TABLE user_config
(
userid BIGINT UNSIGNED NOT NULL,
config TEXT NOT NULL,
PRIMARY KEY (userid)
) ENGINE = InnoDB
"""
)
await self.sqlBroker.c_commit(cur)

if await self.sqlBroker.hasTable(cur, 'tokens') and not await self.sqlBroker.hasColumn(cur, 'tokens', 'game_keys'):
if not await self.sqlBroker.hasTable(cur, 'tokens_migrate'):
print("Renaming old-style 'tokens' table in preparation for migration...")
Expand Down
9 changes: 6 additions & 3 deletions modules/s3handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import dateutil.parser

class S3Handler():
def __init__(self, client, mysqlHandler, nsotoken, splat3info, configData, fonts, cachemanager):
def __init__(self, client, mysqlHandler, nsotoken, splat3info, configData, fonts, cachemanager, components):
self.client = client
self.sqlBroker = mysqlHandler
self.nsotoken = nsotoken
Expand All @@ -30,6 +30,7 @@ def __init__(self, client, mysqlHandler, nsotoken, splat3info, configData, fonts
self.imageextractor = s3.imageextractor.S3ImageExtractor(nsotoken, cachemanager)
self.fonts = fonts
self.cachemanager = cachemanager
self.components = components

async def cmdWeaponInfo(self, ctx, name):
match = self.splat3info.weapons.matchItem(name)
Expand All @@ -38,7 +39,8 @@ async def cmdWeaponInfo(self, ctx, name):
return

weapon = match.get()
await ctx.respond(f"Weapon '{weapon.name()}' has subweapon '{weapon.sub().name()}' and special '{weapon.special().name()}'.")
lang = self.components['translate'].get_lang_ietf()
await ctx.respond(_("Weapon '**%s**' has subweapon '**%s**' and special '**%s**'.") % (weapon.name(lang), weapon.sub().name(lang), weapon.special().name(lang)))
return

async def cmdWeaponSpecial(self, ctx, name):
Expand Down Expand Up @@ -71,7 +73,8 @@ async def cmdWeaponSub(self, ctx, name):

async def cmdWeaponRandom(self, ctx):
weapon = self.splat3info.weapons.getRandomItem()
await ctx.respond(f"Random weapon: **{weapon.name()}** (subweapon **{weapon.sub().name()}**/special **{weapon.special().name()}**)")
lang = self.components['translate'].get_lang_ietf()
await ctx.respond(_("Random weapon: **%s** (subweapon **%s**/special **%s**)") % (weapon.name(lang), weapon.sub().name(lang), weapon.special().name(lang)))

async def cmdScrim(self, ctx, num, modelist):
if (num < 0) or (num > 20):
Expand Down
63 changes: 0 additions & 63 deletions modules/serverconfig.py

This file was deleted.

80 changes: 80 additions & 0 deletions modules/translate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import gettext
import re
import contextvars
import builtins

class Translate:
DOMAIN = 'jet-bot'

LANGS = {
'en_US': {'name': 'English', 'country': 'USA'},
'fr_FR': {'name': 'Français', 'country': 'France'},
}

# Breaks down a locale code into fields. There are two supported styles, POSIX and IETF.
@classmethod
def parse_locale_code(cls, code):
if m := re.match(r'^([a-z]{2})(?:_([a-zA-Z]+))?(?:[.]([^@]+))?(?:@(.*))?$', code): # POSIX
return {'language': m[1], 'territory': m[2], 'charset': m[3], 'modifier': m[4]}
elif re.match(r'^([a-zA-Z]{1,8})(?:-([a-zA-Z]))*$', code): # IETF
data = {}
parts = code.split("-")

if len(parts[0]) == 2: # Two letters in first position is ISO 639 language code
data['language'] = parts[0].lower()

if len(parts[1]) == 2: # Two letters in second position is ISO 3166 alpha-2 country code
data['territory'] = parts[1].upper()

data['extra'] = parts[2:]

return data

return None

# POSIX-style locale defined in ISO/IEC 15897 uses underscore separator.
@classmethod
def posix_locale_code(cls, code):
parsed = cls.parse_locale_code(code)
if parsed is None:
return None
return f"{parsed[language]}_{parsed[territory]}"

# IETF-style locale defined in RFC 1766 uses hyphen separator.
@classmethod
def ietf_locale_code(cls, code):
parsed = cls.parse_locale_code(code)
if parsed is None:
return None
return f"{parsed[language]}-{parsed[territory]}"

def __init__(self, path):
self.translations = {}
self.lang = contextvars.ContextVar('lang')

gettext.install(self.DOMAIN, localedir = path)

for k in self.LANGS.keys():
if k == 'en_US':
continue # Don't try to translate default language
self.translations[k] = gettext.translation(self.DOMAIN, localedir = path, languages = [k])

builtins.__dict__['_'] = self.translate

def select(self, lang):
if (lang != 'en_US') and (not lang in self.translations):
print(f"Cannot select language '{lang}'")
return

self.lang.set(lang) # Save to contextvar

def translate(self, message):
lang = self.lang.get('en_US') # Read from contextvar
if (lang == 'en_US') or (not lang in self.translations):
return message

return self.translations[lang].gettext(message)

def get_lang_ietf(self):
lang = self.lang.get('en_US') # Read from contextvar
return lang.replace("_", "-")
33 changes: 33 additions & 0 deletions share/locale/fr_FR/LC_MESSAGES/jet-bot.po
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# French translations for PACKAGE package.
# Copyright (C) 2022 THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# Automatically generated, 2022.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-11-19 14:03-0800\n"
"PO-Revision-Date: 2022-11-13 15:14-0800\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"

#: modules/s3handler.py:43
#, python-format
msgid "Weapon '**%s**' has subweapon '**%s**' and special '**%s**'."
msgstr "L'arme '**%s**' a l'arme secondaire '**%s**' et l'arme spéciale '**%s**'."

#: modules/s3handler.py:77
#, python-format
msgid "Random weapon: **%s** (subweapon **%s**/special **%s**)"
msgstr "Arme au hasard: **%s** (arme secondaire **%s**/arme spéciale **%s**)"

#: bot.py:208
#, python-format
msgid "Okay, I set your language to: %s"
msgstr "D'accord, j'ai réglé votre langue à : %s"
Loading

0 comments on commit 619e222

Please sign in to comment.