Este post é uma reedição do artigo publicado na Revista Portugal a Programar número 55 de março de 2017, páginas 26 a 34.

Eu um mundo com tantos aplicativos de chat instantâneo, o Telegram se destaca pela rica API que disponibiliza para criação de bots. Os bots são pequenos programas que podem interagir com os usuários e prestar serviços, como executar comandos, gerenciar arquivos ou imagens e até mesmo propor jogos!

Faz tempo que a comunidade Python explora bibliotecas como a Telebot e mais recetemente, a Telepot. Embora a diferença no nome das duas seja apenas uma letra, o desenho da Telepot me parece mais robusto e o melhor de tudo: integra chamadas assíncronas!

O objetivo deste tutorial é mostrar como criar um bot assíncrono, usando a Telepot em Python 3.6. Ele é divido em quatro partes: por que assíncrono? obtenção da chave para rodar o bot, criação do bot, o jogo da velha em si (com minimax).

Por que assíncrono?

Sendo um grande fã de programação assíncrona, eu não poderia perder a oportunidade de escrever um bot assíncrono. Mas por que assíncrono?

A programação assíncrona veio para simplificar a vida de programadores que não querem e que não precisam usar threads. Ela é extremamente útil quando a aplicação passa grandes períodos de tempo esperando uma resposta de um dispositivo de entrada ou saída, como a rede. A ideia da programação assíncrona é dividir o programa em tarefas. Cada tarefa é executada por um loop de eventos, fornecido pela biblioteca que é responsável por ativar e desativar tarefas. Quando uma tarefa precisa executar uma outra tarefa assíncrona, ela simplesmente pede ao loop para interromper sua própria execução e retornar apenas quando a outra tarefa houver terminado. Este ciclo de ativação e desativação se repete até a conclusão de nosso programa. Sua grande vantagem é que o processador não fica parado a espera de uma resposta da rede, de um arquivo ou do banco de dados. Desta forma, várias requisições podem ser atendidas simultaneamente e tudo isso sem usar threads. Embora a programação assíncrona não substituia a programação paralela em todos os casos, esta simplifica enormemente a escrita dos programas que não precisam se preocupar com locking ou concorrência de acesso.

No caso de um bot, vários usuários do Telegram podem jogar ao mesmo tempo. Para melhorar a utilização do processador, o melhor é escrever um bot assíncrono que vai responder os comandos do jogo, mas sem bloquear um jogador quando outro está simplesmente esperando receber a resposta do bot. Isto é possível porque o bot utiliza chamadas REST via http para se comunicar com o Telegram. Desta forma, os novos comandos chegam pela rede e suas respostas também são enviadas em requisições http, criando uma ótima oportunidade de suspender a execução durante a transmissão e recepção de comandos e liberando o bot para responder outras requisições.

Em Python 3.5, a programação assíncrona teve importante melhorarias, principalmente em relação a sintaxe. Podemos usar async def para criar uma nova corotina assíncrona e await para esperar sua execução. Veremos mais no código do bot em si. Mas para responder a pergunta desta seção, assíncrono para melhorar a utilização do processador e responder a vários jogadores, alternando entre eles durante o processamento de entra/saída, sem fazê-los esperar. Como hoje pagamos instâncias na Amazon, Google Compute Engine ou Azure por hora, executar o máximo de requisições com o mínimo de instâncias é uma pressão financeira importante para o desenvolvedor. A programação assíncrona é uma alternativa para aliviar esta pressão, sem aumentar a dificuldade de desenvolvimento da solução em si.

Obtenção da chave

Primeiramente, se você ainda não conhece o Telegram, baixe-o em Telegram.org. Você pode instalá-lo em seu telefone ou simplesmente criar uma conta para usar na web. Siga as instruções do site.

Para criar um novo bot, devemos falar com o BotFather, um bot gerenciador de bots do Telegram. É este bot que vai criar um novo bot e uma chave de acesso que precisamos obter para usar a API. Você pode falar com o BotFather clicando no link BotFather ou procurando por @BotFather.

The Botfather

Uma vez com a tela de chat aberta com o BotFather, digite o comando:

/newbot

Não esqueça de digitar Enter no fim da linha.

A criação do bot é realizada “conversando” com o BotFather. A primeira coisa que ele pergunta é sobre o nome do bot. Escolha um novo nome e digite em uma nova linha. Caso o nome já esteja em uso, o bot father pedirá para você escoher outro.

Depois de aceitar o nome do seu novo bot, o BotFather exibe esta mensagem. Na segunda linha, ele mostra o novo link para acessar seu bot pela web. No meu caso, o bot se chama velha_bot. O que estamos procurando é a chave para acessar a API. No caso, a chave para seu novo bot será mostrada após: “Use this token to access the HTTP API”. Eu pintei minha chave de preto, mas você deve ter uma string com números e letras. Copie esta linha e guarde em um local seguro, você precisará da chave para poder usar a API. A chave se parece com:

934802948:AODJcjoijcijaoAISDJoiajdoaijAzcWFIS

Pronto, acabamos de criar nosso bot. Agora precisamos de um programa para trazê-lo a vida!

Criação do bot

Vamos começar baixando o bot que desenvolvi para este artigo. O código fonte está disponível no GitHub. Você precisa ter o git e o Python 3.6 instalados em sua máquina para continuar.

Observação: como várias distribuições de Linux ainda não possuem a versão 3.6 disponível em seus gerenciadores de pacotes, você também pode converter o bot para rodar em Python 3.5. As diferenças são pequenas. Apenas substituia as f-strings como f”{nome}” por “{}”.format(nome).

Se você utiliza Windows, instale o git e o Python 3.6, baixando diretamente de Python.org.

Se você utiliza Linux, verifique a versão instalada em seu sistema digitando:

python -V

Dependendo da sua distribuição, você pode precisar digitar python3 em vez de python. Qualquer versão posterior ao 3.6 já está ok.

Caso queira instalar uma outra versão do interpretador, siga as instruções abaixo. Ubuntu 18.04 LTS: instale o git e o Python 3.7 com os comandos abaixo:

sudo apt update
sudo apt install -y python3.7 wget curl git 

e depois baixe o bot com:

git clone https://github.com/lskbr/velha.git

Crie um virtualenv usando o utilitário que você preferir, como o virtualenvwrapper. Siga as instruções de instalação, caso não tenha instalado ainda.

mkvirtualenv velha -p `which python3.7`

O git deve baixar o código fonte e criar um diretório velha em seu computador.

Digite:

cd velha
pip install -r requirements.txt

Agora vamos precisar da chave que você obteve do BotFather. Substitua sua chave após o =. Digite:

export BOT_TOKEN=934802948:AODJcjoijcijaoAISDJoiajdoaijAzcWFIS

e rode o bot com:

python velha.py

Pronto, seu bot já deve estar rodando!

Vamos testá-lo. Use a pesquisa do Telegram e procure pelo nome do seu bot. No meu caso é @velha_bot, mas você deve substituir pelo nome do seu bot!

Clique ou toque em START.

O bot responde e pergunta com qual nível de dificuldade você deseja jogar. Fácil é completamente aleatório, o computador vai simplesmente preencher uma posição vazia. Médio e Difícil usam um algoritmos simples, mas bem interessante que vamos falar depois. Eu sugiro que você escolha Difícil.

O bot então pergunta se você quer começar jogando (X) ou se quer jogar após o computador (O).

Veja que as opções anteriores foram apagadas e que a mensagem foi substituída. Escolha X.

Finalmente podemos jogar. Clique em uma posição disponível e divirta-se com o jogo da velha.

Para jogar novamente, clique em Recomeçar. O bot avisa o resultado da partida.

O Jogo da Velha

Agora que instalamos tudo, podemos ver como o programa foi feito! O programa ficou bem maior do que eu esperava, vamos por partes.

O programa começa na linha 395, com:

# Pega o token da variável de ambiente BOT_TOKEN

TOKEN = os.getenv("BOT_TOKEN")
if __name__ == "__main__":
    logging.basicConfig(level=logging.DEBUG, 
                        format='%(asctime)s %(levelname)6s %(filename)s %(funcName)s:%(lineno)d %(message)s')

loop = asyncio.get_event_loop()
bot = telepot.aio.Bot(TOKEN, loop=loop) # Cria o bot
jogo = JogoDaVelha(bot) # Cria o jogo
loop.create_task(jogo.stats(loop)) # Cria a tarefa que limpa as partidas velhas
loop.create_task(bot.message_loop({'chat': jogo.chat_handler, 'callback_query': jogo.callback_query}))

try:
    loop.run_forever()
except KeyboardInterrupt:
    pass

Como o TOKEN não deve ser guardado no GitHub (caso contrário seria público), o programa precisa obté-lo do ambiente. No caso, usamos os.getenv para obter o token da variável BOT_TOKEN. Por isso precisamos fazer o export durante a configuração.

Depois de obter o TOKEN, configuramos o log do programa e o loop de eventos em:

loop = asyncio.get_event_loop()

Quando escrevemos código para Python assíncrono utilizamos um loop de eventos. Este loop é criado e mantido pelo módulo asyncio. Diferente de um programa clássico, um programa assíncrono submete rotinas para serem executadas no loop. O loop executa apenas uma rotina de cada vez, o que simplifica nosso código sem nos preocuparmos com paralelismo. A grande vantagem do loop é que ele pode parar a execução de uma rotina enquanto ela espera por um dispositivo, ou uma resposta da rede. Veremos em breve como Python faz isso. A contra partida do loop é que este só executa uma rotina por vez, assim, caso uma rotina demore demais a retornar a execução para o loop, todo o programa ficará bloqueado!

Uma vez que temos nosso loop e o token, criamos nosso bot com apenas uma linha!

bot = telepot.aio.Bot(TOKEN, loop=loop) # Cria o bot

Veja que utilizamos a teleport.aio que é o módulo da telepot que suporta programação assíncrona. Passamos o TOKEN e o loop como parâmetros.

Finalmente criamos um objeto da classe do Jogo:

jogo = JogoDaVelha(bot) # Cria o jogo

Veja que passamos o bot como parâmetro. Isso é importante porque uma vez finalizada a inicialização, é o loop que controlará a execução de nosso programa.

Agendamos a execução de uma rotina que exibe quantas partidas temos em memória com:

loop.create_task(jogo.stats(loop)) # Cria a tarefa que limpa as partidas velhas

Veja que criamos a tarefa (create_task) no loop, passando jogo.stats(loop). Aqui as coisas começam a ficar diferentes de um programa tradicional. O método jogo.stats é uma corotina e não é executado ao ser chamado. Na realidade, ele cria uma corotina que pode ser chamada pelo loop. O código é um pouco diferente de um método normal em Python. O método stats está na linha 386:

async def stats(self, loop):
    """Imprime estatísticas e limpa jogos antigos"""

    while True:
        partidas = len(self.partidas)
        logger.info(f"Partidas em memória: {partidas}")
        self.partidas.limpa_antigas()

    await asyncio.sleep(60, loop=loop)

Veja que ele é definido com async def e não com apenas def! É esta mudança que faz com que este método possa ser chamado pelo loop de forma assíncrona. O resto do código é praticamente normal, exceto a última linha:

await asyncio.sleep(60, loop=loop)

Aqui aparece outra novidade, await. É esta contrução que faz nosso bot assíncrono especial. Quando usamos await, pedimos ao loop para parar nossa corotina atual (criada com async def) para executar uma outra corotina, no caso asyncio.sleep que espera 60 segundos para retornar. Veja que usamos o sleep de asyncio. Isto é importante pois time.sleep não é uma corotina compatível com o loop e se a utilizarmos, todo o programa parará enquanto esta não retornar.

Com await, pedimos a execução de asyncio.sleep. O loop então pára a execução de stats, chama asyncio.sleep e volta para stats quando esta terminar. A vantagem é que asyncio.sleep não pára o loop, deixando-o livre para executar outras corotinas. E nosso código continua simples, como se esta quebra de execução não tivesse ocorrido.

A etapa seguinte é a configuração do bot para chamar métodos do jogo ao receber uma mensagem ou uma resposta (click dos botões):

loop.create_task(bot.message_loop({'chat': jogo.chat_handler, 'callback_query': jogo.callback_query}))

Da mesma forma que fizemos com o jogo.stats, vamos criar uma corotina que será chamada mais tarde pelo loop. No caso, passamos um dicionário com os métodos que queremos chamar quando uma mensagem de chat for recebida e para callback_query. No caso, a callback_query é a resposta que o telegram envia quando clicamos nos botões do jogo.

Depois disso, deixamos o loop controlar nosso programa:

try:
    loop.run_forever()
except KeyboardInterrupt:
    pass

A execução fica dentro de loop.run_forever(), até que paremos com Control+C.

O loop fica esperando eventos e executando as corotinas que configuramos. No caso do bot, ele periodicamente acessa os servidores do Telegram para saber se há novas mensagens. Quando uma mensagem chega, ele chama jogo.chat_handler:

async def chat_handler(self, msg):
    """Processa o chat vindo do usuário"""
    content_type, chat_type, chat_id = telepot.glance(msg)
    logger.debug(f"Content_type: {content_type} Chat type: {chat_type} Messge: {msg}")
    jogo = self.pega_jogo(msg)
    await self.reply_markup(jogo, chat_id)

Veja que o método chat_handler foi definido com async def, sendo uma corotina. Ele simplesmente recebe a mensagem do telegram, que é um grande dicionário com várias informações sobre a mensagem como: o texto da mensagem, quem a enviou, em que chat, quando foi enviada, etc.

O módulo telepot tem algumas rotinas que ajudam a trabalhar com este dicionário, como a glance que extrai o tipo do conteúdo, o tipo de chat e o chat_id que precisamos para responder uma mensagem.

Quando bot chama chat_handler, ele não controla nosso jogo em si, ou seja, não sabe qual usuário enviou a mensagem, mas que o bot recebeu uma mensagem. O que fazer com a mensagem é uma responsabilidade de nosso programa. Como podemos ter vários jogos ao mesmo tempo, nós criamos um dicionário com as partidas. Cada mensagem tem um user_id de origem e este número identifica cada usuário de Telegram de forma única (mesmo que ele mude de nome ou de handle). Desta forma, usando o user_id como chave, podemos obter o jogo daquele usuário. A biblioteca Telepot tem classes bem interessantes que ajudam a manter o contexto por usuário ou por chat, estas classes não foram utilizadas neste exemplo, consulte a documentação para mais informações.

Chamamos então a reply_markup, passando o jogo e o chat_id da mensgem para que o bot gere uma resposta:

    async def reply_markup(self, jogo, chat_id=None):
        """Dependendo do estado atual do jogo, retorna as opções disponíveis"""
        if jogo.tela == 0:
            markup = jogo.nivel_de_dificuldade()
            mensagem = 'Jogo da Velha - Escolha o nível de dificuldade'
        elif jogo.tela == 1:
            markup = jogo.tipo_jogador()
            mensagem = 'X sempre joga primeiro. Você quer jogar como X ou O?'
        elif jogo.tela == 2:
            markup = jogo.constroi_grelha()
            mensagem = jogo.mensagem or 'Jogo da Velha'

        if jogo.message is None and chat_id is not None:
            message = await self.bot.sendMessage(chat_id, mensagem, reply_markup=markup)
            jogo.message = telepot.message_identifier(message)
        else:
            try:
                await self.bot.editMessageText(jogo.message, mensagem, reply_markup=markup)
            except telepot.exception.TelegramError as te:
                pass

Você deve ter percebido que o bot pergunta o nível de dificuldade, se queremos jogar com X ou O antes de nos deixar jogar. Cada uma destas etapas é controlada por uma propriedade de cada jogo que guarda onde estamos para cada jogador. Quando tela é 0, apresentamos as opções de escolha do nível de dificuldade. Quando é 1, se o jogador quer jogar com X ou O, 2 é o jogo em si e 3 uma mensagem que informa que a partida já terminou.

Veja que jogo.message é uma varíavel que utilizamos para guardar o identificador da mensagem. Isto é necessário, pois para não gerar uma nova grelha a cada resposta, editamos a mensagem precedente (a anterior é substituída por uma nova, dando a impressão de desaparecer). Assim, se jogo.message é nula, enviamos uma nova mensagem com self.bot.sendMessage. Veja que utilizamos await, pois sendMessage é uma corotina do bot. Esta linha é um pouco especial, pois estamos usando await e atribuindo seu resultado para variável message. Esta é uma outra grande vantagem do await, ele retorna os mesmos valores da corotina que acabou de executar. No caso, durante o tempo de envio desta mensagem, o loop fica livre para realizar outras tarefas. Quando sendMessage retorna, o programa volta a executar na linha do await e message recebe seu retorno. Veja que imediatamente guardamos o identificador da mensagem em jogo.message. Desta forma, na próxima vez, editaremos a mensagem precedente, em vez de enviar uma nova.

No caso de editMessageText, trocamos o conteúdo da mensagem precedente pela nossa nova tela. Um tratamento de exceção básico é realizado, pois um usuário pode clicar em um jogo antigo e gerar erros (não tratados aqui para simplificar o bot).

Cada tela do jogo é composta de uma parte texto e uma série de botões, especificados no parâmetro reply_markup de sendMessage e de editMessageText. O markup que enviamos é gerado em função do estado atual do jogo. Vejamos onde criamos o markup com o nível de diculdade:

def nivel_de_dificuldade(self):
   """Retorna as opções dos níveis de dificuldade do jogo"""

    return InlineKeyboardMarkup(
        inline_keyboard=[
            [InlineKeyboardButton(text="Fácil", callback_data='facil')],
            [InlineKeyboardButton(text="Médio", callback_data='medio')],
            [InlineKeyboardButton(text="Difícil", callback_data='dificil')]])

Estamos agora no código da classe Velha, que guarda o estado do jogo para cada jogador (linha 152). O método nível_de_dificuldade é definido na linha 195. No caso, simplesmente passamos uma lista de InlineKeyboardButton, cada um com seu texto e callback_data. O texto é mostrado ao usuário dentro do botão e o callback_data é enviado pelo Telegram caso este botão seja pressionado.

É desta forma que sabemos qual opção foi escolhida pelo usuário. Quando uma resposta do botão é recebida, o bot chama nosso método callback_query (linha 357):

async def callback_query(self, msg):
        """Processa a resposta para as escolhas do usuário"""
        query_id, from_id, query_data = telepot.glance(msg, flavor='callback_query')
        logger.debug(f'Callback Query: {query_id}, {from_id}, {query_data}')
        jogo = self.pega_jogo(msg)
        logger.debug(f"Callback query: usuário: {jogo.user_id} mensagem: {msg}")

        if jogo.tela == 0:
            self.configura_dificuldade(jogo, query_data)
            await self.reply_markup(jogo, self.msg_chat_id(msg))
        elif jogo.tela == 1:
            self.configura_primeiro_jogador(jogo, query_data)
            if jogo.computador == "X":
                self.joga_pelo_computador(jogo)
            await self.reply_markup(jogo, self.msg_chat_id(msg))
        elif query_data == "recomecar":
            jogo = self.partidas.novo_jogo(jogo)
            await self.reply_markup(jogo, self.msg_chat_id(msg))
        elif len(query_data) == 1 and query_data.isdigit() and jogo.tela == 2:
            posicao = int(query_data) - 1
            if jogo.joga(posicao, jogo.jogador):
                self.verifica_jogada(jogo)
                grelha = jogo.constroi_grelha()
                await self.bot.editMessageText(jogo.message, f"Velha: {jogo.mensagem}", reply_markup=grelha)
            else:
                await self.bot.answerCallbackQuery(query_id, text='Escolha outra posição')
        elif jogo.tela == 3:
            await self.bot.answerCallbackQuery(query_id, text='Partida terminada. Escolha recomeçar para jogar de novo')

Da mesma forma que quando recebemos uma mensagem texto, uma mensagem de resposta dos botões é recebida. A resposta em si vem em query_data, extraida pelo telebot.glance (ver que passamos um parâmetro flavor=‘callback_query’:

query_id, from_id, query_data = telepot.glance(msg, flavor='callback_query')

O query_data tem o mesmo texto que especificamos no callback_data de nossos botões. O resto é Python tradicional, onde verificamos o estado do jogo e executamos as configurações necessárias.

Quando estamos na tela 2 e recebemos um número, significa que o usuário clicou na grelha. A grelha do jogo da velha foi organizada em 9 posições, numeradas de 1 a 9. Como Python indexa a partir do zero, transformamos esta resposta em um número inteiro e subtraímos 1 para termos o índice respectivo ou a posição que ele jogou.

Se você observar o código fonte, verá que o estado de cada jogo é guardado em uma lista de strings com nove elementos. Cada elemento representa uma posição da grelha. Um espaço em branco para posições vazias, X ou O se um dos jogadores já marcou aquela posição anteriormente.

Quando chamamos jogo.joga(posicao, jogo.jogador), pedimos que uma nova marcação seja realizada. Este método retorna False se o usuário já marcou esta posição e o código gera uma mensagem de erro:

await self.bot.answerCallbackQuery(query_id, text='Escolha outra posição')

Veja que answerCallbackQuery mostra apenas um overlay no Telegram e não altera nossa mensagem em si. Experimente tentar jogar duas vezes na mesma posícão para ver o efeito. Nós vamos usar este método para enviar mensagens importantes ao usuário, especialmente mensagens de erro.

Em jogo.verifica_jogada, testamos se o jogo acabou e retornamos a nova grelha.

Este é o mecanismo geral do jogo. Vejamos um pouco como fizemos o computador jogar.

E o computador Joga Velha

O jogo da Velha é bem simples e seria muito chato ter que procurar outro jogador para uma partida. Vejamos como fazer o computador jogar. Usando uma técnica chamada Minimax Minimax. Uma excelente explicação deste algoritmo para resolver o jogo da velha pode ser encontrada em português aqui: http://www.maawko.com/blog/freecodecamp/jogo-da-velha-entendendo-o-algoritimo-minimax/

O artigo original em Inglês pode ser lido aqui: http://neverstopbuilding.com/minimax.

Infelizmente, o autor escreveu a solução usando Ruby (brincadeira) e não em Python.

O método consiste em gerar as jogadas possíveis para cada jogador, a partir das posições ainda livres. Depois disso, verificamos o resultado caso jogássemos naquela posição, será que ganhamos? Perdemos? Ou empatamos? A ideia geral é atribuir um certo número de pontos quando ganhamos e o inverso deste número para o jogador oponente.

Este trabalho é feito pela classe Velhus, definida na Linha 24:

class Velhus:
    """
    Classe que simula a grelha e permite calcular as jogas possíveis.
    Utilizada para calcular a jogada do computador.

    O Estado contém a grelha como uma lista de strings.
    Espaço significa que a posição está livre.
    X ou O que o jogador já marcou esta posição.

    Grelha
    Índices   Posições
     0 1 2     1 | 2 | 3
              ---+---+---
     3 4 5     4 | 5 | 6
              ---+---+---
     6 7 8     7 | 8 | 9
    """
    GANHANTES = [set(x) for x in [(0, 1, 2), (3, 4, 5), (6, 7, 8),
                                  (0, 4, 8), (6, 4, 2),
                                  (0, 3, 6), (1, 4, 7), (2, 5, 8)]]

    def __init__(self, estado=None):
        """estado: estado inicial. Default: branco"""
        self.estado = estado or [" "] * 9

    def jogadas_possiveis(self):
        """Onde podemos jogar?"""
        return posicoes_de(self.estado, " ")

    def posicoes_por_jogador(self):
        """Retorna uma tupla com as posições do jogador X e do jogador O"""
        return (posicoes_de(self.estado, "X"), posicoes_de(self.estado, "O"))

    def ganhou(self, posicoes, jogador):
        """Verifica se um dos jogadores ganhou a partida"""
        s = set(posicoes)
        for p in Velhus.GANHANTES:
            if len(p - s) == 0:
                return True
        return False

    def joga(self, posicao, jogador):
        """Joga pelo jogador em um posição específica"""
        if self.estado[posicao] == " ":
            self.estado[posicao] = jogador
        else:
            raise ValueError(f"Posição({posicao}) inválida.")

    def resultado(self):
        jX, jO = self.posicoes_por_jogador()
        if self.ganhou(jX, "X"):
            return("X")   # X Ganhou
        elif self.ganhou(jO, "O"):
            return("O")   # O Ganhou
        elif not self.jogadas_possiveis():
            return("*")   # Empate sem resultado
        else:
            return("?")   # Inconclusivo

    @staticmethod
    def alterna(jogador):
        """Inverte o jogodor atual. X --> O e O --> X"""
        return "X" if jogador == "O" else "O"

    @staticmethod
    def melhor(result, jogador):
        if jogador == "X":
            return max(result.values())
        else:
            return min(result.values())

    def proxima(self, jogador, estado, nivel=0, nmax=3):
        """Cria um dicionário que calcula as possibilidades de futuras jogadas.
           jogador: jogador corrente (da vez)
           estado: estado do jogo (grelha)
           nivel: nivel atual de recursão, usado para diminuir a dificuldade do jogo
           nmax: nível máximo de exploração. Retorna caso o nível atual atinja o máximo.
           result: dicionário com a pontuação por resultado.
        """
        result = {}
        # Percorre as jogadas possíveis
        for possivel in self.jogadas_possiveis():
            j = Velhus(estado[:])  # Cria um tabuleiro hipotético, a partir do estado atual.
            j.joga(possivel, jogador)  # joga pelo jogador
            resultado = j.resultado()  # verifica o resultado da jogada

            if resultado == "X" or resultado == "O":
                rlocal = 10 - nivel    # Atribui pontos com base no nível atual
                result[possivel] = rlocal if resultado == "X" else -rlocal
            elif resultado == "?" and nivel < nmax:  # Como o resultado não é final, continua a jogar
                outro = self.alterna(jogador)
                lresult = j.proxima(outro, j.estado, nivel + 1, nmax)
                result[possivel] = self.melhor(lresult, outro) if lresult else 0
        return result

    def melhor_jogada(self, jogador, estado, dmax):
        """
        Calcula qual a melhor jogada para o jogador
        jogador: jogador da vez (para qual a melhor jogada será calculada)
        estado: estado atual do jogo
        dmax: nível máximo de profundidade. Usado para diminuir a dificuldade.
        """
        result = self.proxima(jogador, estado, nmax=dmax)  # Quais são as possiblidades?
        melhores_jogadas = []
        melhor = self.melhor(result, jogador)

        logger.debug(Velhus.dump_estado(estado))
        logger.debug(f"Jogador={jogador}")

        for posicao, resultado in result.items():
            if resultado == melhor:   # Se esta posição tem o melhor score
                melhores_jogadas.append(posicao)
            logger.debug(f"Melhor: {melhor} {melhores_jogadas} r={resultado} posicao={posicao}")
        return melhores_jogadas

Alguns métodos foram removidos para poupar espaço e facilitar o entendimento. Vamos analisar o método proxima. Este método recebe o jogador e o estado da grelha.

Então, para cada posição ainda livre na grelha:

for possivel in self.jogadas_possiveis():

Criamos uma nova instância de Velhus e jogamos em cada uma destas posições, uma por vez. Observe que o estado de Velhus é independente do estado do jogo, pois ainda estamos verificando onde jogar, ou seja, fazemos tudo isso sem alterar o estado do jogo em si.

    j = Velhus(estado[:]) # Cria um tabuleiro hipotético
    j.joga(possivel, jogador) # joga pelo jogador
    resultado = j.resultado() # verifica o resultado da jogada

Cada jogada tem um resultado que pode ser vitória de X, vitória de O, empate ou indeterminado (significando que ninguém ganhou ou perdeu ainda).

De posse do resultado, partimos para exeção do minimax:

if resultado == "X" or resultado == "O":
    rlocal = 10 - nivel    # Atribui pontos com base no nível atual
    result[possivel] = rlocal if resultado == "X" else -rlocal
elif resultado == "?" and nivel < nmax:  # Como o resultado não é final, continua a jogar
    outro = self.alterna(jogador)
    lresult = j.proxima(outro, j.estado, nivel + 1, nmax)
    result[possivel] = self.melhor(lresult, outro) if lresult else 0

Como dita o algoritmos, atribuiremos 10 pontos para vitória (X indica vitória do jogador X e O de seu oponente). Destes pontos, deduiremos o nível para penalizar os resultados que ocorrem após multiplas jogadas. Um detalhe importante é que o resultado para o jogador X é positivo e para O negativo, por isso a inversão de sinal!

Caso o resultado seja indeterminado, chamamos recursivamente próximo, mas alternando o jogador para simular suas jogadas. Veja que passamos o novo estado (já com a nossa jogada hipotética) e incrementamos o nível. Utilizamos o nível para limitar a expansão do jogo e criar o nível de dificuldade médio (que não avalia todas as possiblidades). Então, avaliamos o resultado de proxima, mas com a ótica do jogador oponente. Veja que o método melhor é importantíssimo, pois decide se estamos maximizando ou minimizando o resultado:

    @staticmethod
    def melhor(result, jogador):
        if jogador == "X":
            return max(result.values())
        else:
            return min(result.values())

Como dita o algoritmo de Minimax, o jogador X maximiza suas jogadas e o jogador O as minimiza!

O método proxima simplesmente calcula os valores para todas as jogadas possíveis. Desta forma, o método melhor_jogada pode decidir onde o computador vai jogar:

    def melhor_jogada(self, jogador, estado, dmax):        
        result = self.proxima(jogador, estado, nmax=dmax)  # Quais são as possiblidades?
        melhores_jogadas = []
        melhor = self.melhor(result, jogador)
        for posicao, resultado in result.items():
            if resultado == melhor:   # Se esta posição tem o melhor score
                melhores_jogadas.append(posicao)
        return melhores_jogadas

Aplicamos novamente o método melhor para filtrar a melhor jogada. A diferença é que o método melhor_jogada suporta várias posições com o mesmo resultado, ou seja, várias posições onde o resultado é igual ao máximo. As melhores jogadas são retornadas e o computador simplesmente escolhe uma delas aleatoriamente.

Este ciclo de jogar, marcar, avaliar os resultados se repete até o fim da partida.

Conclusão

Eu espero que você tenha gostado desta solução e que se divirta criando outros bots para o Telegram. Cada bot é único e apenas arranhamos a superfície do que pode ser feito com a Telepot. O Telegram também disponibiliza toda a documentação de sua api em seu site.

Se você não programa em Python, fica o convite para conhecer esta linguagem e sua comunidade. A programação assíncrona em Python é excelente e o número de bibliotecas cresce todo dia. Banco de dados, sites, rede, tupo já pode ser acessado de forma assíncrona.

Escrever bots é divertido, você pode ver um outro bot que escrevi usando a biblioteca Telebot no GitHub: https://github.com/PyNorte/pybrtelegrambot. Este bot utiliza um banco de dados SQLite para responder a comandos e guardar os dados dos membros do Grupo Python do Norte do Brasil. Embora não seja um bot assíncrono, tenho certeza que despertará sua curiosidade em escrever bots!

Sobre Nilo Menezes

Nilo Ney Coutinho Menezes é desenvolvedor de software, especializado em programação paralela, assíncrona e de sistemas distribuídos. Atuou em diversos projetos europeus como pesquisador nas áreas de simulação, telefonia móvel e redes. Coordenou equipes de desenvolvimento de software para indústrias em Manaus, Brasil. Hoje trabalha em sua empresa na Bélgica, prestando consultoria para o desenvolvimento de sistemas escalonáveis e computação em nuvem. É mestre em informática e bacharel em processamento de dados pela Universidade Federal do Amazonas. É autor do livro « Introdução à Programação com Python » editado pela Editora Novatec e disponível tanto no Brasil quanto em Portugal. Nilo pode ser encontrado pelo site: https://python.nilo.pro.br ou no telegram @lskbr e https://t.me/PyCoding.