1# This file is part of Buildbot. Buildbot is free software: you can 2# redistribute it and/or modify it under the terms of the GNU General Public 3# License as published by the Free Software Foundation, version 2. 4# 5# This program is distributed in the hope that it will be useful, but WITHOUT 6# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 7# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 8# details. 9# 10# You should have received a copy of the GNU General Public License along with 11# this program; if not, write to the Free Software Foundation, Inc., 51 12# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 13# 14# Copyright Buildbot Team Members 15 16import io 17import json 18import random 19import shlex 20 21from twisted.internet import defer 22from twisted.internet import reactor 23 24from buildbot import config 25from buildbot.process.results import CANCELLED 26from buildbot.process.results import EXCEPTION 27from buildbot.process.results import FAILURE 28from buildbot.process.results import RETRY 29from buildbot.process.results import SUCCESS 30from buildbot.process.results import WARNINGS 31from buildbot.reporters.words import Channel 32from buildbot.reporters.words import Contact 33from buildbot.reporters.words import StatusBot 34from buildbot.reporters.words import UsageError 35from buildbot.reporters.words import WebhookResource 36from buildbot.schedulers.forcesched import CollectedValidationError 37from buildbot.schedulers.forcesched import ForceScheduler 38from buildbot.util import Notifier 39from buildbot.util import asyncSleep 40from buildbot.util import bytes2unicode 41from buildbot.util import epoch2datetime 42from buildbot.util import httpclientservice 43from buildbot.util import service 44from buildbot.util import unicode2bytes 45 46 47class TelegramChannel(Channel): 48 49 def __init__(self, bot, channel): 50 assert isinstance(channel, dict), "channel must be a dict provided by Telegram API" 51 super().__init__(bot, channel['id']) 52 self.chat_info = channel 53 54 @defer.inlineCallbacks 55 def list_notified_events(self): 56 if self.notify_events: 57 yield self.send("The following events are being notified:\n{}" 58 .format("\n".join(sorted( 59 " **{}**".format(n) for n in self.notify_events)))) 60 else: 61 yield self.send(" No events are being notified.") 62 63 64def collect_fields(fields): 65 for field in fields: 66 if field['fullName']: 67 yield field 68 if 'fields' in field: 69 yield from collect_fields(field['fields']) 70 71 72class TelegramContact(Contact): 73 74 def __init__(self, user, channel=None): 75 assert isinstance(user, dict), "user must be a dict provided by Telegram API" 76 self.user_info = user 77 super().__init__(user['id'], channel) 78 self.template = None 79 80 @property 81 def chat_id(self): 82 return self.channel.id 83 84 @property 85 def user_full_name(self): 86 fullname = " ".join((self.user_info['first_name'], 87 self.user_info.get('last_name', ''))).strip() 88 return fullname 89 90 @property 91 def user_name(self): 92 return self.user_info['first_name'] 93 94 def describeUser(self): 95 user = self.user_full_name 96 try: 97 user += ' (@{})'.format(self.user_info['username']) 98 except KeyError: 99 pass 100 101 if not self.is_private_chat: 102 chat_title = self.channel.chat_info.get('title') 103 if chat_title: 104 user += " on '{}'".format(chat_title) 105 106 return user 107 108 ACCESS_DENIED_MESSAGES = [ 109 "♂️ You shall not pass! ", 110 " Oh NO! You are simply not allowed to to this! ", 111 "⛔ You cannot do this. Better go outside and relax... ", 112 "⛔ ACCESS DENIED! This incident has ben reported to NSA, KGB, and George Soros! ", 113 " Unauthorized access detected! Your device will explode in 3... 2... 1... ", 114 "☢ Radiation level too high! Continuation of the procedure forbidden! ", 115 ] 116 117 def access_denied(self, *args, tmessage, **kwargs): 118 self.send( 119 random.choice(self.ACCESS_DENIED_MESSAGES), reply_to_message_id=tmessage['message_id']) 120 121 def query_button(self, caption, payload): 122 if isinstance(payload, str) and len(payload) < 64: 123 return {'text': caption, 'callback_data': payload} 124 key = hash(repr(payload)) 125 while True: 126 cached = self.bot.query_cache.get(key) 127 if cached is None: 128 self.bot.query_cache[key] = payload 129 break 130 if cached == payload: 131 break 132 key += 1 133 return {'text': caption, 'callback_data': key} 134 135 @defer.inlineCallbacks 136 def command_START(self, args, **kwargs): 137 yield self.command_HELLO(args) 138 reactor.callLater(0.2, self.command_HELP, '') 139 140 def command_NAY(self, args, tmessage, **kwargs): 141 """forget the current command""" 142 replied_message = tmessage.get('reply_to_message') 143 if replied_message: 144 if 'reply_markup' in replied_message: 145 self.bot.edit_keyboard(self.channel.id, 146 replied_message['message_id']) 147 if self.is_private_chat: 148 self.send("Never mind...") 149 else: 150 self.send("Never mind, {}...".format(self.user_name)) 151 command_NAY.usage = "nay - never mind the command we are currently discussing" 152 153 @classmethod 154 def describe_commands(cls): 155 commands = cls.build_commands() 156 response = [] 157 for command in commands: 158 if command == 'start': 159 continue 160 meth = getattr(cls, 'command_' + command.upper()) 161 doc = getattr(meth, '__doc__', None) 162 if not doc: 163 doc = command 164 response.append("{} - {}".format(command, doc)) 165 return response 166 167 @Contact.overrideCommand 168 def command_COMMANDS(self, args, **kwargs): 169 if args.lower() == 'botfather': 170 response = self.describe_commands() 171 if response: 172 self.send('\n'.join(response)) 173 else: 174 return super().command_COMMANDS(args) 175 return None 176 177 @defer.inlineCallbacks 178 def command_GETID(self, args, **kwargs): 179 """get user and chat ID""" 180 if self.is_private_chat: 181 self.send("Your ID is {}.".format(self.user_id)) 182 else: 183 yield self.send("{}, your ID is {}.".format(self.user_name, self.user_id)) 184 self.send("This {} ID is {}.".format(self.channel.chat_info.get('type', "group"), 185 self.chat_id)) 186 command_GETID.usage = "getid - get user and chat ID that can be put in the master " \ 187 "configuration file" 188 189 @defer.inlineCallbacks 190 @Contact.overrideCommand 191 def command_LIST(self, args, **kwargs): 192 args = self.splitArgs(args) 193 if not args: 194 keyboard = [ 195 [self.query_button("️ Builders", '/list builders'), 196 self.query_button("️ (including old ones)", '/list all builders')], 197 [self.query_button("⚙ Workers", '/list workers'), 198 self.query_button("⚙ (including old ones)", '/list all workers')], 199 [self.query_button(" Changes (last 10)", '/list changes')], 200 ] 201 self.send("What do you want to list?", 202 reply_markup={'inline_keyboard': keyboard}) 203 return 204 205 all = False 206 num = 10 207 try: 208 num = int(args[0]) 209 del args[0] 210 except ValueError: 211 if args[0] == 'all': 212 all = True 213 del args[0] 214 except IndexError: 215 pass 216 217 if not args: 218 raise UsageError("Try '" + self.bot.commandPrefix + 219 "list [all|N] builders|workers|changes'.") 220 221 if args[0] == 'builders': 222 bdicts = yield self.bot.getAllBuilders() 223 online_builderids = yield self.bot.getOnlineBuilders() 224 225 response = ["I found the following **builders**:"] 226 for bdict in bdicts: 227 if bdict['builderid'] in online_builderids: 228 response.append("`{}`".format(bdict['name'])) 229 elif all: 230 response.append("`{}` ❌".format(bdict['name'])) 231 self.send('\n'.join(response)) 232 233 elif args[0] == 'workers': 234 workers = yield self.master.data.get(('workers',)) 235 236 response = ["I found the following **workers**:"] 237 for worker in workers: 238 if worker['configured_on']: 239 response.append("`{}`".format(worker['name'])) 240 if not worker['connected_to']: 241 response[-1] += " ⚠️" 242 elif all: 243 response.append("`{}` ❌".format(worker['name'])) 244 self.send('\n'.join(response)) 245 246 elif args[0] == 'changes': 247 248 wait_message = yield self.send("⏳ Getting your changes...") 249 250 if all: 251 changes = yield self.master.data.get(('changes',)) 252 self.bot.delete_message(self.channel.id, wait_message['message_id']) 253 num = len(changes) 254 if num > 50: 255 keyboard = [ 256 [self.query_button("‼ Yes, flood me with all of them!", 257 '/list {} changes'.format(num))], 258 [self.query_button("✅ No, just show last 50", '/list 50 changes')] 259 ] 260 self.send("I found {} changes. Do you really want me " 261 "to list them all?".format(num), 262 reply_markup={'inline_keyboard': keyboard}) 263 return 264 265 else: 266 changes = yield self.master.data.get(('changes',), order=['-changeid'], limit=num) 267 self.bot.delete_message(self.channel.id, wait_message['message_id']) 268 269 response = ["I found the following recent **changes**:\n"] 270 271 for change in reversed(changes): 272 change['comment'] = change['comments'].split('\n')[0] 273 change['date'] = epoch2datetime(change['when_timestamp']).strftime('%Y-%m-%d %H:%M') 274 response.append( 275 "[{comment}]({revlink})\n" 276 "_Author_: {author}\n" 277 "_Date_: {date}\n" 278 "_Repository_: {repository}\n" 279 "_Branch_: {branch}\n" 280 "_Revision_: {revision}\n".format(**change)) 281 self.send('\n'.join(response)) 282 283 @defer.inlineCallbacks 284 def get_running_builders(self): 285 builders = [] 286 for bdict in (yield self.bot.getAllBuilders()): 287 if (yield self.bot.getRunningBuilds(bdict['builderid'])): 288 builders.append(bdict['name']) 289 return builders 290 291 @defer.inlineCallbacks 292 @Contact.overrideCommand 293 def command_WATCH(self, args, **kwargs): 294 if args: 295 super().command_WATCH(args) 296 else: 297 builders = yield self.get_running_builders() 298 if builders: 299 keyboard = [ 300 [self.query_button(" " + b, '/watch {}'.format(b))] 301 for b in builders 302 ] 303 self.send("Which builder do you want to watch?", 304 reply_markup={'inline_keyboard': keyboard}) 305 else: 306 self.send("There are no currently running builds.") 307 308 @Contact.overrideCommand 309 def command_NOTIFY(self, args, tquery=None, **kwargs): 310 if args: 311 want_list = args == 'list' 312 if want_list and tquery: 313 self.bot.delete_message(self.chat_id, tquery['message']['message_id']) 314 315 super().command_NOTIFY(args) 316 317 if want_list or not tquery: 318 return 319 320 keyboard = [ 321 [ 322 self.query_button("{} {}".format(e.capitalize(), 323 '' if e in self.channel.notify_events else ''), 324 '/notify {}-quiet {}'.format( 325 'off' if e in self.channel.notify_events else 'on', e)) 326 for e in evs 327 ] 328 for evs in (('started', 'finished'), ('success', 'failure'), ('warnings', 'exception'), 329 ('problem', 'recovery'), ('worse', 'better'), ('cancelled', 'worker')) 330 ] + [[self.query_button("Hide...", '/notify list')]] 331 332 if tquery: 333 self.bot.edit_keyboard(self.chat_id, tquery['message']['message_id'], keyboard) 334 else: 335 self.send("Here are available notifications and their current state. " 336 "Click to turn them on/off.", 337 reply_markup={'inline_keyboard': keyboard}) 338 339 def ask_for_reply(self, prompt, greeting='Ok'): 340 kwargs = {} 341 if not self.is_private_chat: 342 username = self.user_info.get('username', '') 343 if username: 344 if greeting: 345 prompt = "{} @{}, now {}...".format(greeting, username, prompt) 346 else: 347 prompt = "@{}, now {}...".format(username, prompt) 348 kwargs['reply_markup'] = { 349 'force_reply': True, 350 'selective': True 351 } 352 else: 353 if greeting: 354 prompt = "{}, now reply to this message and {}...".format(greeting, prompt) 355 else: 356 prompt = "Reply to this message and {}...".format(prompt) 357 else: 358 if greeting: 359 prompt = "{}, now {}...".format(greeting, prompt) 360 else: 361 prompt = prompt[0].upper() + prompt[1:] + "..." 362 # Telegram seems to have a bug, which causes reply request to pop sometimes again. 363 # So we do not force reply to avoid it... 364 # kwargs['reply_markup'] = { 365 # 'force_reply': True 366 # } 367 self.send(prompt, **kwargs) 368 369 @defer.inlineCallbacks 370 @Contact.overrideCommand 371 def command_STOP(self, args, **kwargs): 372 argv = self.splitArgs(args) 373 if len(argv) >= 3 or \ 374 argv and argv[0] != 'build': 375 super().command_STOP(args) 376 return 377 argv = argv[1:] 378 if not argv: 379 builders = yield self.get_running_builders() 380 if builders: 381 keyboard = [ 382 [self.query_button(" " + b, '/stop build {}'.format(b))] 383 for b in builders 384 ] 385 self.send("Select builder to stop...", 386 reply_markup={'inline_keyboard': keyboard}) 387 else: # len(argv) == 1 388 self.template = '/stop ' + args + ' {}' 389 self.ask_for_reply("give me the reason to stop build on `{}`".format(argv[0])) 390 391 @Contact.overrideCommand 392 def command_SHUTDOWN(self, args, **kwargs): 393 if args: 394 return super().command_SHUTDOWN(args) 395 if self.master.botmaster.shuttingDown: 396 keyboard = [[ 397 self.query_button(" Stop Shutdown", '/shutdown stop'), 398 self.query_button("‼️ Shutdown Now", '/shutdown now') 399 ]] 400 text = "Buildbot is currently shutting down.\n\n" 401 else: 402 keyboard = [[ 403 self.query_button("↘️ Begin Shutdown", '/shutdown start'), 404 self.query_button("‼️ Shutdown Now", '/shutdown now') 405 ]] 406 text = "" 407 self.send(text + "What do you want to do?", 408 reply_markup={'inline_keyboard': keyboard}) 409 return None 410 411 @defer.inlineCallbacks 412 def command_FORCE(self, args, tquery=None, partial=None, **kwargs): 413 """force a build""" 414 415 try: 416 forceschedulers = yield self.master.data.get(('forceschedulers',)) 417 except AttributeError: 418 forceschedulers = None 419 else: 420 forceschedulers = dict((s['name'], s) for s in forceschedulers) 421 422 if not forceschedulers: 423 raise UsageError("no force schedulers configured for use by /force") 424 425 argv = self.splitArgs(args) 426 427 try: 428 sched = argv[0] 429 except IndexError: 430 if len(forceschedulers) == 1: 431 sched = next(iter(forceschedulers)) 432 else: 433 keyboard = [ 434 [self.query_button(s['label'], '/force {}'.format(s['name']))] 435 for s in forceschedulers.values() 436 ] 437 self.send("Which force scheduler do you want to activate?", 438 reply_markup={'inline_keyboard': keyboard}) 439 return 440 else: 441 if sched in forceschedulers: 442 del argv[0] 443 elif len(forceschedulers) == 1: 444 sched = next(iter(forceschedulers)) 445 else: 446 raise UsageError("Try '/force' and follow the instructions" 447 " (no force scheduler {})".format(sched)) 448 scheduler = forceschedulers[sched] 449 450 try: 451 task = argv.pop(0) 452 except IndexError: 453 task = 'config' 454 455 if tquery and task != 'config': 456 self.bot.edit_keyboard(self.chat_id, tquery['message']['message_id']) 457 458 if not argv: 459 keyboard = [ 460 [self.query_button(b, '/force {} {} {}'.format(sched, task, b))] 461 for b in scheduler['builder_names'] 462 ] 463 self.send("Which builder do you want to start?", 464 reply_markup={'inline_keyboard': keyboard}) 465 return 466 467 if task == 'ask': 468 try: 469 what = argv.pop(0) 470 except IndexError as e: 471 raise UsageError("Try '/force' and follow the instructions") from e 472 else: 473 what = None # silence PyCharm warnings 474 475 bldr = argv.pop(0) 476 if bldr not in scheduler['builder_names']: 477 raise UsageError(("Try '/force' and follow the instructions " 478 "(`{}` not configured for _{}_ scheduler)" 479 ).format(bldr, scheduler['label'])) 480 481 try: 482 params = dict(arg.split('=', 1) for arg in argv) 483 except ValueError as e: 484 raise UsageError("Try '/force' and follow the instructions ({})".format(e)) from e 485 486 all_fields = list(collect_fields(scheduler['all_fields'])) 487 required_params = [f['fullName'] for f in all_fields 488 if f['required'] and f['fullName'] not in ('username', 'owner')] 489 missing_params = [p for p in required_params if p not in params] 490 491 if task == 'build': 492 # TODO This should probably be moved to the upper class, 493 # however, it will change the force command totally 494 495 try: 496 if missing_params: 497 # raise UsageError 498 task = 'config' 499 else: 500 params.update(dict( 501 (f['fullName'], f['default']) for f in all_fields 502 if f['type'] == 'fixed' and f['fullName'] not in ('username', 'owner') 503 )) 504 505 builder = yield self.bot.getBuilder(buildername=bldr) 506 for scheduler in self.master.allSchedulers(): 507 if scheduler.name == sched and isinstance(scheduler, ForceScheduler): 508 break 509 else: 510 raise ValueError("There is no force scheduler '{}'".format(sched)) 511 try: 512 yield scheduler.force(builderid=builder['builderid'], 513 owner=self.describeUser(), 514 **params) 515 except CollectedValidationError as e: 516 raise ValueError(e.errors) from e 517 else: 518 self.send("Force build successfully requested.") 519 return 520 521 except (IndexError, ValueError) as e: 522 raise UsageError("Try '/force' and follow the instructions ({})".format(e)) from e 523 524 if task == 'config': 525 526 msg = "{}, you are about to start a new build on `{}`!"\ 527 .format(self.user_full_name, bldr) 528 529 keyboard = [] 530 args = ' '.join(shlex.quote("{}={}".format(*p)) for p in params.items()) 531 532 fields = [f for f in all_fields if f['type'] != 'fixed' 533 and f['fullName'] not in ('username', 'owner')] 534 535 if fields: 536 msg += "\n\nThe current build parameters are:" 537 for field in fields: 538 if field['type'] == 'nested': 539 msg += "\n{}".format(field['label']) 540 else: 541 field_name = field['fullName'] 542 value = params.get(field_name, field['default']).strip() 543 msg += "\n {} `{}`".format(field['label'], value) 544 if value: 545 key = "Change " 546 else: 547 key = "Set " 548 key += field_name.replace('_', ' ').title() 549 if field_name in missing_params: 550 key = "⚠️ " + key 551 msg += " ⚠️" 552 keyboard.append( 553 [self.query_button(key, '/force {} ask {} {} {}' 554 .format(sched, field_name, bldr, args))] 555 ) 556 557 msg += "\n\nWhat do you want to do?" 558 if missing_params: 559 msg += " You must set values for all parameters marked with ⚠️" 560 561 if not missing_params: 562 keyboard.append( 563 [self.query_button(" Start Build", '/force {} build {} {}' 564 .format(sched, bldr, args))], 565 ) 566 567 self.send(msg, reply_markup={'inline_keyboard': keyboard}) 568 569 elif task == 'ask': 570 prompt = "enter the new value for the " + what.replace('_', ' ').lower() 571 args = ' '.join(shlex.quote("{}={}".format(*p)) for p in params.items() 572 if p[0] != what) 573 self.template = '/force {} config {} {} {}={{}}'.format(sched, bldr, args, what) 574 self.ask_for_reply(prompt, '') 575 576 else: 577 raise UsageError("Try '/force' and follow the instructions") 578 579 command_FORCE.usage = "force - Force a build" 580 581 582class TelegramStatusBot(StatusBot): 583 584 contactClass = TelegramContact 585 channelClass = TelegramChannel 586 commandPrefix = '/' 587 588 offline_string = "offline ❌" 589 idle_string = "idle " 590 running_string = "running :" 591 592 query_cache = {} 593 594 @property 595 def commandSuffix(self): 596 if self.nickname is not None: 597 return '@' + self.nickname 598 return None 599 600 def __init__(self, token, outgoing_http, chat_ids, *args, retry_delay=30, **kwargs): 601 super().__init__(*args, **kwargs) 602 603 self.http_client = outgoing_http 604 self.retry_delay = retry_delay 605 self.token = token 606 607 self.chat_ids = chat_ids 608 609 self.nickname = None 610 611 @defer.inlineCallbacks 612 def startService(self): 613 yield super().startService() 614 for c in self.chat_ids: 615 channel = self.getChannel(c) 616 channel.add_notification_events(self.notify_events) 617 yield self.loadState() 618 619 results_emoji = { 620 SUCCESS: ' ✅', 621 WARNINGS: ' ⚠️', 622 FAILURE: '❗', 623 EXCEPTION: ' ‼️', 624 RETRY: ' ', 625 CANCELLED: ' ', 626 } 627 628 def format_build_status(self, build, short=False): 629 br = build['results'] 630 if short: 631 return self.results_emoji[br] 632 else: 633 return self.results_descriptions[br] + \ 634 self.results_emoji[br] 635 636 def getContact(self, user, channel): 637 """ get a Contact instance for ``user`` on ``channel`` """ 638 assert isinstance(user, dict), "user must be a dict provided by Telegram API" 639 assert isinstance(channel, dict), "channel must be a dict provided by Telegram API" 640 641 uid = user['id'] 642 cid = channel['id'] 643 try: 644 contact = self.contacts[(cid, uid)] 645 except KeyError: 646 valid = self.isValidUser(uid) 647 contact = self.contactClass(user=user, 648 channel=self.getChannel(channel, valid)) 649 if valid: 650 self.contacts[(cid, uid)] = contact 651 else: 652 if isinstance(user, dict): 653 contact.user_info.update(user) 654 if isinstance(channel, dict): 655 contact.channel.chat_info.update(channel) 656 return contact 657 658 def getChannel(self, channel, valid=True): 659 if not isinstance(channel, dict): 660 channel = {'id': channel} 661 cid = channel['id'] 662 try: 663 return self.channels[cid] 664 except KeyError: 665 new_channel = self.channelClass(self, channel) 666 if valid: 667 self.channels[cid] = new_channel 668 new_channel.setServiceParent(self) 669 return new_channel 670 671 @defer.inlineCallbacks 672 def process_update(self, update): 673 data = {} 674 675 message = update.get('message') 676 if message is None: 677 query = update.get('callback_query') 678 if query is None: 679 self.log('No message in Telegram update object') 680 return 'no message' 681 original_message = query.get('message', {}) 682 data = query.get('data', 0) 683 try: 684 data = self.query_cache[int(data)] 685 except ValueError: 686 text, data, notify = data, {}, None 687 except KeyError: 688 text, data, notify = None, {}, "Sorry, button is no longer valid!" 689 if original_message: 690 try: 691 self.edit_keyboard( 692 original_message['chat']['id'], 693 original_message['message_id']) 694 except KeyError: 695 pass 696 else: 697 if isinstance(data, dict): 698 data = data.copy() 699 text = data.pop('command') 700 try: 701 notify = data.pop('notify') 702 except KeyError: 703 notify = None 704 else: 705 text, data, notify = data, {}, None 706 data['tquery'] = query 707 self.answer_query(query['id'], notify) 708 message = { 709 'from': query['from'], 710 'chat': original_message.get('chat'), 711 'text': text, 712 } 713 if 'reply_to_message' in original_message: 714 message['reply_to_message'] = original_message['reply_to_message'] 715 716 chat = message['chat'] 717 718 user = message.get('from') 719 if user is None: 720 self.log('No user in incoming message') 721 return 'no user' 722 723 text = message.get('text') 724 if not text: 725 return 'no text in the message' 726 727 contact = self.getContact(user=user, channel=chat) 728 data['tmessage'] = message 729 template, contact.template = contact.template, None 730 if text.startswith(self.commandPrefix): 731 result = yield contact.handleMessage(text, **data) 732 else: 733 if template: 734 text = template.format(shlex.quote(text)) 735 result = yield contact.handleMessage(text, **data) 736 return result 737 738 @defer.inlineCallbacks 739 def post(self, path, **kwargs): 740 logme = True 741 while True: 742 try: 743 res = yield self.http_client.post(path, **kwargs) 744 except AssertionError as err: 745 # just for tests 746 raise err 747 except Exception as err: 748 msg = "ERROR: problem sending Telegram request {} (will try again): {}".format(path, 749 err) 750 if logme: 751 self.log(msg) 752 logme = False 753 yield asyncSleep(self.retry_delay) 754 else: 755 ans = yield res.json() 756 if not ans.get('ok'): 757 self.log("ERROR: cannot send Telegram request {}: " 758 "[{}] {}".format(path, res.code, ans.get('description'))) 759 return None 760 return ans.get('result', True) 761 762 @defer.inlineCallbacks 763 def set_nickname(self): 764 res = yield self.post('/getMe') 765 if res: 766 self.nickname = res.get('username') 767 768 @defer.inlineCallbacks 769 def answer_query(self, query_id, notify=None): 770 params = dict(callback_query_id=query_id) 771 if notify is not None: 772 params.update(dict(text=notify)) 773 return (yield self.post('/answerCallbackQuery', json=params)) 774 775 @defer.inlineCallbacks 776 def send_message(self, chat, message, parse_mode='Markdown', 777 reply_to_message_id=None, reply_markup=None, 778 **kwargs): 779 result = None 780 781 message = message.strip() 782 while message: 783 params = dict(chat_id=chat) 784 if parse_mode is not None: 785 params['parse_mode'] = parse_mode 786 if reply_to_message_id is not None: 787 params['reply_to_message_id'] = reply_to_message_id 788 reply_to_message_id = None # we only mark first message as a reply 789 790 if len(message) <= 4096: 791 params['text'], message = message, None 792 else: 793 n = message[:4096].rfind('\n') 794 n = n + 1 if n != -1 else 4096 795 params['text'], message = message[:n].rstrip(), message[n:].lstrip() 796 797 if not message and reply_markup is not None: 798 params['reply_markup'] = reply_markup 799 800 params.update(kwargs) 801 802 result = yield self.post('/sendMessage', json=params) 803 804 return result 805 806 @defer.inlineCallbacks 807 def edit_message(self, chat, msg, message, parse_mode='Markdown', **kwargs): 808 params = dict(chat_id=chat, message_id=msg, text=message) 809 if parse_mode is not None: 810 params['parse_mode'] = parse_mode 811 params.update(kwargs) 812 return (yield self.post('/editMessageText', json=params)) 813 814 @defer.inlineCallbacks 815 def edit_keyboard(self, chat, msg, keyboard=None): 816 params = dict(chat_id=chat, message_id=msg) 817 if keyboard is not None: 818 params['reply_markup'] = {'inline_keyboard': keyboard} 819 return (yield self.post('/editMessageReplyMarkup', json=params)) 820 821 @defer.inlineCallbacks 822 def delete_message(self, chat, msg): 823 params = dict(chat_id=chat, message_id=msg) 824 return (yield self.post('/deleteMessage', json=params)) 825 826 @defer.inlineCallbacks 827 def send_sticker(self, chat, sticker, **kwargs): 828 params = dict(chat_id=chat, sticker=sticker) 829 params.update(kwargs) 830 return (yield self.post('/sendSticker', json=params)) 831 832 833class TelegramWebhookBot(TelegramStatusBot): 834 name = "TelegramWebhookBot" 835 836 def __init__(self, token, *args, certificate=None, **kwargs): 837 TelegramStatusBot.__init__(self, token, *args, **kwargs) 838 self._certificate = certificate 839 self.webhook = WebhookResource('telegram' + token) 840 self.webhook.setServiceParent(self) 841 842 @defer.inlineCallbacks 843 def startService(self): 844 yield super().startService() 845 url = bytes2unicode(self.master.config.buildbotURL) 846 if not url.endswith('/'): 847 url += '/' 848 yield self.set_webhook(url + self.webhook.path, self._certificate) 849 850 def process_webhook(self, request): 851 update = self.get_update(request) 852 return self.process_update(update) 853 854 def get_update(self, request): 855 content = request.content.read() 856 content = bytes2unicode(content) 857 content_type = request.getHeader(b'Content-Type') 858 content_type = bytes2unicode(content_type) 859 if content_type is not None and \ 860 content_type.startswith('application/json'): 861 update = json.loads(content) 862 else: 863 raise ValueError('Unknown content type: {}' 864 .format(content_type)) 865 return update 866 867 @defer.inlineCallbacks 868 def set_webhook(self, url, certificate=None): 869 if not certificate: 870 self.log("Setting up webhook to: {}".format(url)) 871 yield self.post('/setWebhook', json=dict(url=url)) 872 else: 873 self.log("Setting up webhook to: {} (custom certificate)".format(url)) 874 certificate = io.BytesIO(unicode2bytes(certificate)) 875 yield self.post('/setWebhook', data=dict(url=url), 876 files=dict(certificate=certificate)) 877 878 879class TelegramPollingBot(TelegramStatusBot): 880 name = "TelegramPollingBot" 881 882 def __init__(self, *args, poll_timeout=120, **kwargs): 883 super().__init__(*args, **kwargs) 884 self._polling_finished_notifier = Notifier() 885 self.poll_timeout = poll_timeout 886 887 def startService(self): 888 super().startService() 889 self._polling_continue = True 890 self.do_polling() 891 892 @defer.inlineCallbacks 893 def stopService(self): 894 self._polling_continue = False 895 yield self._polling_finished_notifier.wait() 896 yield super().stopService() 897 898 @defer.inlineCallbacks 899 def do_polling(self): 900 yield self.post('/deleteWebhook') 901 offset = 0 902 kwargs = {'json': {'timeout': self.poll_timeout}} 903 logme = True 904 while self._polling_continue: 905 if offset: 906 kwargs['json']['offset'] = offset 907 try: 908 res = yield self.http_client.post('/getUpdates', 909 timeout=self.poll_timeout + 2, 910 **kwargs) 911 ans = yield res.json() 912 if not ans.get('ok'): 913 raise ValueError("[{}] {}".format(res.code, ans.get('description'))) 914 updates = ans.get('result') 915 except AssertionError as err: 916 raise err 917 except Exception as err: 918 msg = ("ERROR: cannot send Telegram request /getUpdates (will try again): {}" 919 ).format(err) 920 if logme: 921 self.log(msg) 922 logme = False 923 yield asyncSleep(self.retry_delay) 924 else: 925 logme = True 926 if updates: 927 offset = max(update['update_id'] for update in updates) + 1 928 for update in updates: 929 yield self.process_update(update) 930 931 self._polling_finished_notifier.notify(None) 932 933 934class TelegramBot(service.BuildbotService): 935 name = "TelegramBot" 936 937 in_test_harness = False 938 939 compare_attrs = ["bot_token", "chat_ids", "authz", 940 "tags", "notify_events", 941 "showBlameList", "useRevisions", 942 "certificate", "useWebhook", 943 "pollTimeout", "retryDelay"] 944 secrets = ["bot_token"] 945 946 def __init__(self, *args, **kwargs): 947 super().__init__(*args, **kwargs) 948 self.bot = None 949 950 def _get_http(self, bot_token): 951 base_url = "https://api.telegram.org/bot" + bot_token 952 return httpclientservice.HTTPClientService.getService( 953 self.master, base_url) 954 955 def checkConfig(self, bot_token, chat_ids=None, authz=None, 956 bot_username=None, tags=None, notify_events=None, 957 showBlameList=True, useRevisions=False, 958 useWebhook=False, certificate=None, 959 pollTimeout=120, retryDelay=30): 960 super().checkConfig(self.name) 961 962 if authz is not None: 963 for acl in authz.values(): 964 if not isinstance(acl, (list, tuple, bool)): 965 config.error("authz values must be bool or a list of user ids") 966 967 if isinstance(certificate, io.TextIOBase): 968 config.error("certificate file must be open in binary mode") 969 970 @defer.inlineCallbacks 971 def reconfigService(self, bot_token, chat_ids=None, authz=None, 972 bot_username=None, tags=None, notify_events=None, 973 showBlameList=True, useRevisions=False, 974 useWebhook=False, certificate=None, 975 pollTimeout=120, retryDelay=30): 976 # need to stash these so we can detect changes later 977 self.bot_token = bot_token 978 if chat_ids is None: 979 chat_ids = [] 980 self.chat_ids = chat_ids 981 self.authz = authz 982 self.useRevisions = useRevisions 983 self.tags = tags 984 if notify_events is None: 985 notify_events = set() 986 self.notify_events = notify_events 987 self.useWebhook = useWebhook 988 self.certificate = certificate 989 self.pollTimeout = pollTimeout 990 self.retryDelay = retryDelay 991 992 # This function is only called in case of reconfig with changes 993 # We don't try to be smart here. Just restart the bot if config has 994 # changed. 995 996 http = yield self._get_http(bot_token) 997 998 if self.bot is not None: 999 self.removeService(self.bot) 1000 1001 if not useWebhook: 1002 self.bot = TelegramPollingBot(bot_token, http, chat_ids, authz, 1003 tags=tags, notify_events=notify_events, 1004 useRevisions=useRevisions, 1005 showBlameList=showBlameList, 1006 poll_timeout=self.pollTimeout, 1007 retry_delay=self.retryDelay) 1008 else: 1009 self.bot = TelegramWebhookBot(bot_token, http, chat_ids, authz, 1010 tags=tags, notify_events=notify_events, 1011 useRevisions=useRevisions, 1012 showBlameList=showBlameList, 1013 retry_delay=self.retryDelay, 1014 certificate=self.certificate) 1015 if bot_username is not None: 1016 self.bot.nickname = bot_username 1017 else: 1018 yield self.bot.set_nickname() 1019 if self.bot.nickname is None: 1020 raise RuntimeError("No bot username specified and I cannot get it from Telegram") 1021 1022 yield self.bot.setServiceParent(self) 1023