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