Python Telegram Minesweeper Bot https://github.com/isjerryxiao/tgmsbot
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

586 lines
22 KiB

  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. from mscore import Board, check_params
  4. from copy import deepcopy
  5. from telegram import InlineKeyboardMarkup, InlineKeyboardButton
  6. from telegram.ext import Updater, CommandHandler, CallbackQueryHandler, run_async
  7. from telegram.error import TimedOut as TimedOutError, RetryAfter as RetryAfterError
  8. from numpy import array_equal
  9. from random import randint, choice, randrange
  10. from math import log
  11. from threading import Lock
  12. import time
  13. from pathlib import Path
  14. import pickle
  15. import logging
  16. from traceback import format_exc
  17. logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
  18. logger = logging.getLogger('tgmsbot')
  19. try:
  20. from data import get_player, db
  21. except ModuleNotFoundError:
  22. logger.warning('using data_ram instead of data')
  23. from data_ram import get_player, db
  24. token = "token_here"
  25. updater = Updater(token, workers=8, use_context=True)
  26. job_queue = updater.job_queue
  27. job_queue.start()
  28. PICKLE_FILE = 'tgmsbot.pickle'
  29. KBD_MIN_INTERVAL = 0.5
  30. KBD_DELAY_SECS = 0.5
  31. GARBAGE_COLLECTION_INTERVAL = 86400
  32. MAX_GAMES_PER_USER = 10
  33. HEIGHT = 8
  34. WIDTH = 8
  35. MINES = 9
  36. UNOPENED_CELL = "\u2588"
  37. FLAGGED_CELL = "\U0001f6a9"
  38. STEPPED_CELL = "\u2622"
  39. NUM_CELL_0 = "\u2800"
  40. NUM_CELL_ORD = ord("\uff11") - 1
  41. WIN_TEXT_TEMPLATE = "哇所有奇怪的地方都被你打开啦…好羞羞\n" \
  42. "地图:Op {s_op} / Is {s_is} / 3bv {s_3bv}\n操作总数 {ops_count}\n" \
  43. "统计:\n{ops_list}\n{last_player} 你要对人家负责哟/// ///\n\n" \
  44. "用时{time}秒,超时{timeouts}次\n\n" \
  45. "{last_player} {reward}\n\n" \
  46. "/mine 开始新游戏"
  47. STEP_TEXT_TEMPLATE = "{last_player} 踩到了地雷!\n" \
  48. "时间{time}秒,超时{timeouts}次\n\n" \
  49. "{last_player} {reward}\n\n" \
  50. "雷区生命值:({remain}/{ttl})"
  51. LOSE_TEXT_TEMPLATE = "一道火光之后,你就在天上飞了呢…好奇怪喵\n" \
  52. "地图:Op {s_op} / Is {s_is} / 3bv {s_3bv}\n操作总数 {ops_count}\n" \
  53. "统计:\n{ops_list}\n{last_player} 是我们中出的叛徒!\n\n" \
  54. "用时{time}秒,超时{timeouts}次\n\n" \
  55. "{last_player} {reward}\n\n" \
  56. "/mine 开始新游戏"
  57. def display_username(user, atuser=True, shorten=False, markdown=True):
  58. """
  59. atuser and shorten has no effect if markdown is True.
  60. """
  61. name = user.full_name
  62. if markdown:
  63. mdtext = user.mention_markdown(name=user.full_name)
  64. return mdtext
  65. if shorten:
  66. return name
  67. if user.username:
  68. if atuser:
  69. name += " (@{})".format(user.username)
  70. else:
  71. name += " ({})".format(user.username)
  72. return name
  73. class Saved_Game:
  74. def __init__(self, board, group, creator, lives=1):
  75. self.board = board
  76. self.group = group
  77. self.creator = creator
  78. self.msgid = None
  79. self.__actions = dict()
  80. self.last_player = None
  81. self.start_time = time.time()
  82. self.stopped = False
  83. # timestamp of the last update keyboard action,
  84. # it is used to calculate time gap between
  85. # two actions and identify unique actions.
  86. self.last_action = 0
  87. # number of timeout error catched
  88. self.timeouts = 0
  89. self.lives = lives
  90. self.ttl_lives = lives
  91. def save_action(self, user, spot):
  92. '''spot is supposed to be a tuple'''
  93. self.last_player = user
  94. if self.__actions.get(user, None):
  95. self.__actions[user].append(spot)
  96. else:
  97. self.__actions[user] = [spot,]
  98. def actions_sum(self):
  99. mysum = 0
  100. for user in self.__actions:
  101. game_count(user)
  102. count = len(self.__actions.get(user, list()))
  103. mysum += count
  104. return mysum
  105. def get_last_player(self):
  106. return display_username(self.last_player)
  107. def get_actions(self):
  108. '''Convert actions into text'''
  109. msg = ""
  110. for user in self.__actions:
  111. count = len(self.__actions.get(user, list()))
  112. msg = "{}{} - {}项操作\n".format(msg, display_username(user), count)
  113. return msg
  114. class Game:
  115. def __init__(self, *args, **kwargs):
  116. if 'unpickle' in args:
  117. assert len(args) == 2 and args[0] == 'unpickle'
  118. self.__sg = args[1]
  119. else:
  120. self.__sg = Saved_Game(*args, **kwargs)
  121. self.lock = Lock()
  122. def pickle(self):
  123. return self.__sg
  124. def __getattr__(self, name):
  125. return getattr(self.__sg, name)
  126. def __setattr__(self, name, val):
  127. if name in ('_Game__sg', 'lock'):
  128. self.__dict__[name] = val
  129. else:
  130. return setattr(self.__sg, name, val)
  131. class GameManager:
  132. def __init__(self):
  133. self.__games = dict()
  134. self.__pf = Path(PICKLE_FILE)
  135. if self.__pf.exists():
  136. try:
  137. with open(self.__pf, 'rb') as fhandle:
  138. saved_games = pickle.load(fhandle, fix_imports=True, errors="strict")
  139. self.__games = {bhash: Game('unpickle', saved_games[bhash]) for bhash in saved_games}
  140. except Exception as err:
  141. logger.error(f'Unable to load pickle file, {type(err).__name__}: {err}')
  142. assert type(self.__games) is dict
  143. for board_hash in self.__games:
  144. self.__games[board_hash].lock = Lock()
  145. def append(self, board, board_hash, group_id, creator_id):
  146. lives = int(board.mines/3)
  147. if lives <= 0:
  148. lives = 1
  149. self.__games[board_hash] = Game(board, group_id, creator_id, lives=lives)
  150. self.save_async()
  151. def remove(self, board_hash):
  152. board = self.get_game_from_hash(board_hash)
  153. if board:
  154. self.__games.pop(board_hash)
  155. self.save_async()
  156. return True
  157. else:
  158. return False
  159. def get_game_from_hash(self, board_hash):
  160. return self.__games.get(board_hash, None)
  161. def iter_game_from_user(self, user_id):
  162. for g in self.__games.copy().values():
  163. if g.creator.id == user_id:
  164. yield g
  165. def iter_all_open_game(self):
  166. for g in self.__games.copy().values():
  167. if g.group.type == 'supergroup':
  168. yield g
  169. def iter_game_from_chat(self, chat_id):
  170. for g in self.__games.copy().values():
  171. if g.group.id == chat_id:
  172. yield g
  173. def count(self):
  174. return len(self.__games)
  175. @run_async
  176. def save_async(self):
  177. self.save()
  178. def save(self):
  179. try:
  180. games_without_locks = {bhash: self.__games[bhash].pickle() for bhash in self.__games}
  181. with open(self.__pf, 'wb') as fhandle:
  182. pickle.dump(games_without_locks, fhandle, fix_imports=True)
  183. except Exception as err:
  184. logger.error(f'Unable to save pickle file, {type(err).__name__}: {err}')
  185. def do_garbage_collection(self, context):
  186. g_checked: int = 0
  187. g_freed: int = 0
  188. games = self.__games
  189. for board_hash in games.copy():
  190. g_checked += 1
  191. gm = games[board_hash]
  192. start_time = getattr(gm, 'start_time', 0.0)
  193. if time.time() - start_time > 86400*10:
  194. g_freed += 1
  195. games.pop(board_hash)
  196. self.save_async()
  197. logger.info((f'Scheduled garbage collection checked {g_checked} games, '
  198. f'freed {g_freed} games.'))
  199. game_manager = GameManager()
  200. @run_async
  201. def list_games(update, context):
  202. logger.info("List from {0}".format(update.message.from_user.id))
  203. if (_is_open_all := context.args and context.args[0] in ('open', 'all')):
  204. _iter_func = game_manager.iter_all_open_game
  205. _iter_args = list()
  206. else:
  207. _iter_func = game_manager.iter_game_from_chat
  208. _iter_args = [update.effective_chat.id,]
  209. if not _is_open_all and (not update.effective_chat or update.effective_chat.type != 'supergroup'):
  210. if update.message:
  211. update.message.reply_text('本功能仅在超级群组中可用')
  212. return
  213. games_avail = list()
  214. for gm in _iter_func(*_iter_args):
  215. if len(games_avail) >= 10:
  216. break
  217. elif gm.group and gm.group.type and gm.group.type == 'supergroup' and gm.creator and gm.msgid:
  218. if context.args and context.args[0] == 'open' and not gm.group.username:
  219. continue
  220. games_avail.append(gm)
  221. if not games_avail:
  222. if _is_open_all:
  223. nrep_text = "没有找到符合条件的游戏"
  224. else:
  225. nrep_text = "本群没有正在进行的游戏\n试试 /list open 或 /list all"
  226. update.message.reply_text(nrep_text)
  227. return
  228. links = list()
  229. def gen_link(chat, msgid, text):
  230. if chat.username:
  231. return f"[{text}](https://t.me/{chat.username}/{msgid})"
  232. chat_id = int(chat.id)
  233. assert chat_id < -1000000000000
  234. chat_id = (-chat_id) - 1000000000000
  235. return f"[{text}](https://t.me/c/{chat_id}/{msgid})"
  236. for gm in games_avail:
  237. links.append(gen_link(gm.group, gm.msgid, f"{gm.creator.first_name} created on {time.ctime(gm.start_time)}"))
  238. update.message.reply_text("\n".join(links), parse_mode="Markdown")
  239. @run_async
  240. def send_keyboard(update, context):
  241. (bot, args) = (context.bot, context.args)
  242. msg = update.message
  243. logger.info("Mine from {0}".format(update.message.from_user.id))
  244. if check_restriction(update.message.from_user):
  245. update.message.reply_text("爆炸这么多次还想扫雷?")
  246. return
  247. for (_gid, _) in enumerate(game_manager.iter_game_from_user(update.message.from_user.id)):
  248. if _gid + 1 > MAX_GAMES_PER_USER:
  249. update.message.reply_text((f"汝已经创建了超过{MAX_GAMES_PER_USER}个游戏了\n"
  250. "请结束一个先前创建的游戏并继续"))
  251. return
  252. # create a game board
  253. if args is None:
  254. args = list()
  255. if len(args) == 3:
  256. height = HEIGHT
  257. width = WIDTH
  258. mines = MINES
  259. try:
  260. height = int(args[0])
  261. width = int(args[1])
  262. mines = int(args[2])
  263. except:
  264. pass
  265. # telegram doesn't like keyboard width to exceed 8
  266. if width > 8:
  267. width = 8
  268. msg.reply_text('宽度太大,已经帮您设置成8了')
  269. # telegram doesn't like keyboard keys to exceed 100
  270. if height * width > 100:
  271. msg.reply_text('格数不能超过100')
  272. return
  273. ck = check_params(height, width, mines)
  274. if ck[0]:
  275. board = Board(height, width, mines)
  276. else:
  277. msg.reply_text(ck[1])
  278. return
  279. elif len(args) == 0:
  280. board = Board(HEIGHT, WIDTH, MINES)
  281. else:
  282. msg.reply_text('你输入的是什么鬼!')
  283. return
  284. bhash = hash(board)
  285. game_manager.append(board, bhash, msg.chat, msg.from_user)
  286. # create a new keyboard
  287. keyboard = list()
  288. for row in range(board.height):
  289. current_row = list()
  290. for col in range(board.width):
  291. cell = InlineKeyboardButton(text=UNOPENED_CELL, callback_data="{} {} {}".format(bhash, row, col))
  292. current_row.append(cell)
  293. keyboard.append(current_row)
  294. # send the keyboard
  295. try:
  296. gmsg = bot.send_message(chat_id=msg.chat.id, text="路过的大爷~来扫个雷嘛~", reply_to_message_id=msg.message_id,
  297. parse_mode="Markdown", reply_markup=InlineKeyboardMarkup(keyboard))
  298. except Exception:
  299. game_manager.remove(bhash)
  300. raise
  301. game_manager.get_game_from_hash(bhash).msgid = gmsg.message_id
  302. def send_help(update, context):
  303. logger.debug("Start from {0}".format(update.message.from_user.id))
  304. msg = update.message
  305. msg.reply_text("这是一个扫雷bot\n\n/mine 开始新游戏")
  306. def send_source(update, context):
  307. logger.debug("Source from {0}".format(update.message.from_user.id))
  308. update.message.reply_text('Source code: https://git.jerryxiao.cc/Jerry/tgmsbot')
  309. def send_status(update, context):
  310. logger.info("Status from {0}".format(update.message.from_user.id))
  311. count = game_manager.count()
  312. update.message.reply_text('当前进行的游戏: {}'.format(count))
  313. def gen_reward(user, negative=True):
  314. ''' Reward the player :) '''
  315. def __chance(percentage):
  316. if randrange(0,10000)/10000 < percentage:
  317. return True
  318. else:
  319. return False
  320. def __floating(value):
  321. return randrange(8000,12000)/10000 * value
  322. def __lose_cards(cardnum):
  323. if cardnum <= 6:
  324. return 1
  325. else:
  326. return int(__floating(log(cardnum, 2)))
  327. def __get_cards(cardnum):
  328. if cardnum >= 2:
  329. cards = __floating(1 / log(cardnum, 100))
  330. if cards > 1.0:
  331. return int(cards)
  332. else:
  333. return int(__chance(cards))
  334. else:
  335. return int(__floating(8.0))
  336. # Negative rewards
  337. def restrict_mining(player):
  338. lost_cards = __lose_cards(player.immunity_cards)
  339. player.immunity_cards -= lost_cards
  340. if player.immunity_cards >= 0:
  341. ret = "用去{}张免疫卡,还剩{}张".format(lost_cards, player.immunity_cards)
  342. else:
  343. now = int(time.time())
  344. seconds = randint(30, 120)
  345. player.restricted_until = now + seconds
  346. ret = "没有免疫卡了,被限制扫雷{}秒".format(seconds)
  347. return ret
  348. # Positive rewards
  349. def give_immunity_cards(player):
  350. rewarded_cards = __get_cards(player.immunity_cards)
  351. player.immunity_cards += rewarded_cards
  352. if rewarded_cards == 0:
  353. return "共有{}张免疫卡".format(player.immunity_cards)
  354. else:
  355. return "被奖励了{}张免疫卡,共有{}张".format(rewarded_cards, player.immunity_cards)
  356. player = get_player(user.id)
  357. try:
  358. if negative:
  359. player.death += 1
  360. return restrict_mining(player)
  361. else:
  362. player.wins += 1
  363. return give_immunity_cards(player)
  364. finally:
  365. player.save()
  366. def game_count(user):
  367. player = get_player(user.id)
  368. player.mines += 1
  369. player.save()
  370. def check_restriction(user):
  371. player = get_player(user.id)
  372. now = int(time.time())
  373. if now >= player.restricted_until:
  374. return False
  375. else:
  376. return player.restricted_until - now
  377. @run_async
  378. def player_statistics(update, context):
  379. logger.info("Statistics from {0}".format(update.message.from_user.id))
  380. user = update.message.from_user
  381. player = get_player(user.id)
  382. mines = player.mines
  383. death = player.death
  384. wins = player.wins
  385. cards = player.immunity_cards
  386. TEMPLATE = "一共玩了{mines}局,爆炸{death}次,赢了{wins}局\n" \
  387. "口袋里有{cards}张免疫卡"
  388. update.message.reply_text(TEMPLATE.format(mines=mines, death=death,
  389. wins=wins, cards=cards))
  390. def update_keyboard_request(context, bhash, game, chat_id, message_id):
  391. current_action_timestamp = time.time()
  392. if current_action_timestamp - game.last_action <= KBD_MIN_INTERVAL:
  393. logger.debug('Rate limit triggered.')
  394. game.last_action = current_action_timestamp
  395. job_queue.run_once(update_keyboard, KBD_DELAY_SECS,
  396. context=(bhash, game, chat_id, message_id, current_action_timestamp))
  397. else:
  398. game.last_action = current_action_timestamp
  399. update_keyboard(context, noqueue=(bhash, game, chat_id, message_id))
  400. def update_keyboard(context, noqueue=None):
  401. (bot, job) = (context.bot, context.job)
  402. if noqueue:
  403. (bhash, game, chat_id, message_id) = noqueue
  404. else:
  405. (bhash, game, chat_id, message_id, current_action_timestamp) = job.context
  406. if current_action_timestamp != game.last_action:
  407. logger.debug('New update action requested, abort this one.')
  408. return
  409. def gen_keyboard(board):
  410. keyboard = list()
  411. for row in range(board.height):
  412. current_row = list()
  413. for col in range(board.width):
  414. if board.map[row][col] <= 9:
  415. cell_text = UNOPENED_CELL
  416. elif board.map[row][col] == 10:
  417. cell_text = NUM_CELL_0
  418. elif board.map[row][col] == 19:
  419. cell_text = FLAGGED_CELL
  420. elif board.map[row][col] == 20:
  421. cell_text = STEPPED_CELL
  422. else:
  423. cell_text = chr(NUM_CELL_ORD + board.map[row][col] - 10)
  424. cell = InlineKeyboardButton(text=cell_text, callback_data="{} {} {}".format(bhash, row, col))
  425. current_row.append(cell)
  426. keyboard.append(current_row)
  427. return keyboard
  428. keyboard = gen_keyboard(game.board)
  429. try:
  430. bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id,
  431. reply_markup=InlineKeyboardMarkup(keyboard))
  432. except (TimedOutError, RetryAfterError):
  433. logger.debug('time out in game {}.'.format(bhash))
  434. game.timeouts += 1
  435. except Exception:
  436. logger.critical(format_exc())
  437. @run_async
  438. def handle_button_click(update, context):
  439. bot = context.bot
  440. msg = update.callback_query.message
  441. user = update.callback_query.from_user
  442. chat_id = update.callback_query.message.chat.id
  443. data = update.callback_query.data
  444. logger.debug('Button clicked by {}, data={}.'.format(user.id, data))
  445. restriction = check_restriction(user)
  446. if restriction:
  447. bot.answer_callback_query(callback_query_id=update.callback_query.id,
  448. text="还有{}秒才能扫雷".format(restriction), show_alert=True)
  449. return
  450. bot.answer_callback_query(callback_query_id=update.callback_query.id)
  451. try:
  452. data = data.split(' ')
  453. data = [int(i) for i in data]
  454. (bhash, row, col) = data
  455. except:
  456. logger.info('Unknown callback data: {} from user {}'.format(data, user.id))
  457. return
  458. game = game_manager.get_game_from_hash(bhash)
  459. if game is None:
  460. logger.debug("No game found for hash {}".format(bhash))
  461. return
  462. try:
  463. if game.stopped:
  464. return
  465. game.lock.acquire()
  466. board = game.board
  467. if board.state == 0:
  468. mmap = None
  469. else:
  470. mmap = deepcopy(board.map)
  471. board.move((row, col))
  472. if board.state != 1:
  473. game.stopped = True
  474. game.lock.release()
  475. game.save_action(user, (row, col))
  476. if not array_equal(board.map, mmap):
  477. update_keyboard_request(context, bhash, game, chat_id, msg.message_id)
  478. (s_op, s_is, s_3bv) = board.gen_statistics()
  479. ops_count = game.actions_sum()
  480. ops_list = game.get_actions()
  481. last_player = game.get_last_player()
  482. time_used = time.time() - game.start_time
  483. timeouts = game.timeouts
  484. remain = 0
  485. ttl = 0
  486. if board.state == 2:
  487. reward = gen_reward(game.last_player, negative=False)
  488. template = WIN_TEXT_TEMPLATE
  489. elif board.state == 3:
  490. reward = gen_reward(game.last_player, negative=True)
  491. game.lives -= 1
  492. if game.lives <= 0:
  493. template = LOSE_TEXT_TEMPLATE
  494. else:
  495. game.stopped = False
  496. board.state = 1
  497. remain = game.lives
  498. ttl = game.ttl_lives
  499. template = STEP_TEXT_TEMPLATE
  500. else:
  501. # Should not reach here
  502. reward = None
  503. myreply = template.format(s_op=s_op, s_is=s_is, s_3bv=s_3bv, ops_count=ops_count,
  504. ops_list=ops_list, last_player=last_player,
  505. time=round(time_used, 4), timeouts=timeouts, reward=reward,
  506. remain=remain, ttl=ttl)
  507. try:
  508. msg.reply_text(myreply, parse_mode="Markdown")
  509. except (TimedOutError, RetryAfterError):
  510. logger.debug('timeout sending report for game {}'.format(bhash))
  511. except Exception:
  512. logger.critical(format_exc())
  513. if game.stopped:
  514. game_manager.remove(bhash)
  515. elif mmap is None or (not array_equal(board.map, mmap)):
  516. game.lock.release()
  517. game.save_action(user, (row, col))
  518. update_keyboard_request(context, bhash, game, chat_id, msg.message_id)
  519. else:
  520. game.lock.release()
  521. except:
  522. try:
  523. game.lock.release()
  524. except RuntimeError:
  525. pass
  526. raise
  527. import cards
  528. setattr(cards, 'get_player', get_player)
  529. setattr(cards, 'game_manager', game_manager)
  530. updater.dispatcher.add_handler(CommandHandler('getlvl', cards.getperm))
  531. updater.dispatcher.add_handler(CommandHandler('setlvl', cards.setperm))
  532. updater.dispatcher.add_handler(CommandHandler('lvlup', cards.lvlup))
  533. updater.dispatcher.add_handler(CommandHandler('transfer', cards.transfer_cards))
  534. updater.dispatcher.add_handler(CommandHandler('rob', cards.rob_cards))
  535. updater.dispatcher.add_handler(CommandHandler('lottery', cards.cards_lottery))
  536. updater.dispatcher.add_handler(CommandHandler('dist', cards.dist_cards))
  537. updater.dispatcher.add_handler(CommandHandler('reveal', cards.reveal))
  538. updater.dispatcher.add_handler(CallbackQueryHandler(cards.dist_cards_btn_click, pattern=r'dist'))
  539. updater.dispatcher.add_handler(CommandHandler('start', send_help))
  540. updater.dispatcher.add_handler(CommandHandler('list', list_games))
  541. updater.dispatcher.add_handler(CommandHandler('mine', send_keyboard))
  542. updater.dispatcher.add_handler(CommandHandler('status', send_status))
  543. updater.dispatcher.add_handler(CommandHandler('stats', player_statistics))
  544. updater.dispatcher.add_handler(CommandHandler('source', send_source))
  545. updater.dispatcher.add_handler(CallbackQueryHandler(handle_button_click))
  546. updater.job_queue.run_repeating(game_manager.do_garbage_collection, GARBAGE_COLLECTION_INTERVAL, first=30)
  547. try:
  548. updater.start_polling()
  549. updater.idle()
  550. finally:
  551. game_manager.save()
  552. logger.info('Game_manager saved.')
  553. db.close()
  554. logger.info('DB closed.')