forked from CylonicRaider/basebot
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathbasebot.py
2828 lines (2477 loc) · 103 KB
/
basebot.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: ascii -*-
"""
Bot library for euphoria.io.
Important functions and classes:
normalize_nick() : Normalize a nick (remove whitespace and convert it to
lower case). Useful for comparison of @-mentions.
parse_command() : Split a string by whitespace and return a Token list.
format_datetime(): Format a UNIX timestamp nicely.
format_delta() : Format a timestamp difference nicely.
Token : A string subclass with an offset attribute, telling where
inside the supposed "original" string the string is
located.
Packet : A Euphorian packet.
Message : A single message.
SessionView : A single session.
HeimEndpoint : A bare-bones implementation of the API; useful for
minimalistic clients, or alternative expansion.
LoggingEndpoint : HeimEndpoint maintaining a user list and chat logs on
demand.
BaseBot : LoggingEndpoint supporting in-chat commands.
Bot : BaseBot conforming to the botrulez
(github.com/jedevc/botrulez).
MiniBot : Bot that implements replies to regular expressions (either
fixed or generated by a call-back).
BotManager : Class coordinating multiple Bot (or, more exactly,
HeimEndpoint) instances.
run_main() : Run one or more instances of a given bot class.
run_minibot() : Same as run_bot(), but with MiniBot as a default for the
bot class.
"""
# ---------------------------------------------------------------------------
# Preamble
# ---------------------------------------------------------------------------
# Version.
__version__ = "2.0"
# Modules - Standard library
import sys, os, re, time, stat
import collections, json
import itertools
import argparse
import logging
import threading
# Modules - Additional. Must be installed.
from websocket_server.compat import unicode
from websocket_server.cookies import CookieJar, LWPCookieJar
import websocket_server.client as websocket
from websocket_server.exceptions import WebSocketError, ConnectionClosedError
# Regex for @-mentions
# From github.com/euphoria-io/heim/blob/master/client/lib/stores/chat.js as
# of commit f9d5527beb41ac3e6e0fee0c1f5f4745c49d8f7b (adapted).
_MENTION_DELIMITER = r'[,.!?;&<\'"\s]'
MENTION_RE = re.compile('(?:^|(?<=' + _MENTION_DELIMITER + r'))@(\S+?)(?=' +
_MENTION_DELIMITER + '|$)')
# Regex for whitespace.
WHITESPACE_RE = re.compile('\s+')
# Default connection URL template.
URL_TEMPLATE = os.environ.get('BASEBOT_URL_TEMPLATE',
'wss://euphoria.io/room/{}/ws')
# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------
def normalize_nick(nick):
"""
normalize_nick(nick) -> str
Remove whitespace from the given nick, and perform any other
normalizations on it.
"""
return WHITESPACE_RE.sub('', nick).lower()
def scan_mentions(line):
"""
scan_mentions(line) -> list
Scan the given message for @-mentions and return them as a list of Token
instances (in order).
"""
ret, offset, l = [], 0, len(line)
while offset < l:
m = MENTION_RE.search(line, offset)
if not m:
break
ret.append(Token(m.group(), m.start()))
offset = m.end()
return ret
def parse_command(line):
"""
parse_command(line) -> list
Parse a single-string command line into a list of Token-s (separated by
whitespace in the original string).
"""
ret, offset, l = [], 0, len(line)
while offset < l:
wm = WHITESPACE_RE.search(line, offset)
if not wm:
ret.append(Token(line[offset:], offset))
break
elif wm.start() == offset:
offset = wm.end()
continue
ret.append(Token(line[offset:wm.start()], offset))
offset = wm.end()
return ret
def format_datetime(timestamp, fractions=True):
"""
format_datetime(timestamp, fractions=True) -> str
Produces a string representation of the timestamp similar to the
ISO 8601 format: "YYYY-MM-DD HH:MM:SS.FFF UTC". If fractions is false,
the ".FFF" part is omitted. As the platform the bots are used on is
international, there is little point to use any kind of timezone but
UTC.
"""
ts = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(timestamp))
if fractions:
ts += '.%03d' % (int(timestamp * 1000) % 1000)
return ts + ' UTC'
def format_delta(delta, decimals=True):
"""
format_delta(delta, decimals=True) -> str
Format a time difference. delta is a numeric value holding the time
difference to be formatted in seconds. The return value is composed
like that: "[- ][Xd ][Xh ][Xm ][X[.DDD]s]", with the brackets indicating
possible omission. If decimals is False, or the given time is an
integer, the fractional part is omitted. All components are included as
needed, so the result for 3600 would be "1h". As a special case, the
result for 0 is "0s" (instead of nothing).
"""
ret = []
if decimals:
delta = round(delta, 3)
else:
delta = round(delta)
if delta < 0:
ret = ret.append("-")
delta = -delta
for unit, unit_duration in (("d",86400), ("h",3600), ("m",60)):
if delta > unit_duration:
ret.append('%d' % (delta // unit_duration) + unit)
delta %= unit_duration
if delta != 0:
ret.append(format_seconds(delta))
elif ret == []:
ret.append("0s")
return " ".join(ret)
def format_seconds(delta):
if delta % 1 == 0:
return "%ds" % delta
else:
return "%ss" % delta
def spawn_thread(_target, *_args, **_kwds):
"""
spawn_thread(_target, *args, **_kwds) -> threading.Thread
Utility function for spawning background threads.
Create a threading.Thread instance configured with the given parameters,
make it daemonic, start it, and return.
"""
thr = threading.Thread(target=_target, args=_args, kwargs=_kwds)
thr.setDaemon(True)
thr.start()
return thr
class Token(str):
"""
Token(obj, offset) -> new instance
A string that is at a certain offset inside some other string. The offset
if exposed as an attribute.
"""
def __new__(cls, obj, offset):
inst = str.__new__(cls, obj)
inst.offset = offset
return inst
def __repr__(self):
return '%s(%s, %r)' % (self.__class__.__name__, str.__repr__(self),
self.offset)
class Record(dict):
"""
Record(...) -> new instance
A dictionary that exports some items as attributes as well as provides
static defaults for some keys. Can be constructed in any way a dict
can.
"""
# Export list.
_exports_ = ()
# Defaults mapping.
_defaults_ = {}
def __repr__(self):
return '%s(%s)' % (self.__class__.__name__, dict.__repr__(self))
def __getattr__(self, name):
if name not in self._exports_:
raise AttributeError(name)
try:
return self[name]
except KeyError:
raise AttributeError(name)
def __setattr__(self, name, value):
if name.startswith('_'):
return dict.__setattr__(self, name, value)
elif name not in self._exports_:
raise AttributeError(name)
try:
self[name] = value
except KeyError:
raise AttributeError(name)
def __delattr__(self, name):
if name not in self._exports_:
raise AttributeError(name)
try:
del self[name]
except KeyError:
raise AttributeError(name)
def __missing__(self, key):
return self._defaults_[key]
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class BasebotException(Exception):
"Base exception class."
class NoRoomError(BasebotException):
"No room specified before HeimEndpoint.connect() call."
class NoConnectionError(BasebotException):
"HeimEndpoint currently connected."
# ---------------------------------------------------------------------------
# Lowest abstraction layer.
# ---------------------------------------------------------------------------
class JSONWebSocket:
"""
JSONWebSocketWrapper(ws) -> new instance
JSON-reading/writing WebSocket wrapper.
Provides recv()/send() methods that transparently encode/decode JSON.
Reads and writes are serialized with independent locks; the reading
lock is to be acquired "outside" the write lock.
"""
def __init__(self, ws):
"Initializer. See class docstring for invocation details."
self.ws = ws
self.rlock = threading.RLock()
self.wlock = threading.RLock()
def _recv_raw(self):
"""
_recv_raw() -> str
Receive a WebSocket message, and return its payload.
Raises a ConnectionClosedError (imported into basebot) if the
underlying connection is closed.
"""
with self.rlock:
while 1:
message = self.ws.read_frame()
if message is None: raise ConnectionClosedError()
# TEXT frames only
if message.msgtype == 1: return message.content
def recv(self):
"""
recv() -> object
Receive a single WebSocket message, decode it using JSON, and return
the resulting object.
Raises a ConnectionClosedError (imported into basebot) if the
underlying connection is closed.
"""
return json.loads(self._recv_raw())
def _send_raw(self, data):
"""
_send_raw(data) -> None
Send the given data without modification.
Raises a ConnectionClosedError (imported into basebot) if the
underlying connection is closed.
"""
with self.wlock:
self.ws.write_text_frame(unicode(data))
def send(self, obj):
"""
send(obj) -> None
JSON-encode the given object, and send it.
Raises a ConnectionClosedError (imported into basebot) if the
underlying connection is closed.
"""
self._send_raw(json.dumps(obj))
def close(self):
"""
close() -> None
Close this connection. Repeated calls will succeed immediately.
"""
self.ws.close()
# ---------------------------------------------------------------------------
# Euphorian protocol.
# ---------------------------------------------------------------------------
# Constructed after github.com/euphoria-io/heim/blob/master/doc/api.md as of
# commit 03906c0594c6c7ab5e15d1d8aa5643c847434c97.
class Packet(Record):
"""
The "basic" members any packet must/may have.
Attributes:
id : client-generated id for associating replies with
commands (optional)
type : the name of the command, reply, or event
data : the payload of the command, reply, or event (optional)
error : this field appears in replies if a command fails
(optional)
throttled : this field appears in replies to warn the client that
it may be flooding; the client should slow down its
command rate (defaults to False)
throttled_reason: if throttled is true, this field describes why
(optional)
"""
_exports_ = ('id', 'type', 'data', 'error', 'throttled',
'throttled_reason')
class AccountView(Record):
"""
AccountView describes an account and its preferred names.
Attributes:
id : the id of the account
name: the name that the holder of the account goes by
"""
_exports_ = ('id', 'name')
# Documented by word-of-mouth.
class PersonalAccountView(AccountView):
"""
PersonalAccountView is an AccountView with an additional Email field.
Attributes:
email: the email of the account
id : the id of the account (inherited)
name : the name that the holder of the account goes by (inherited)
"""
# Overrides parent class value.
_exports_ = ('email', 'id', 'name')
class Message(Record):
"""
A Message is a node in a Room's Log. It corresponds to a chat message, or
a post, or any broadcasted event in a room that should appear in the log.
Attributes:
id : the id of the message (unique within a room)
parent : the id of the message's parent, or null if top-level
(optional)
previous_edit_id : the edit id of the most recent edit of this message,
or None if it's never been edited (optional)
time : the unix timestamp of when the message was posted
sender : the view of the sender's session (SessionView)
content : the content of the message (client-defined)
encryption_key_id: the id of the key that encrypts the message in storage
(optional)
edited : the unix timestamp of when the message was last edited
(optional)
deleted : the unix timestamp of when the message was deleted
(optional)
truncated : if true, then the full content of this message is not
included (see get-message to obtain the message with
full content) (optional)
All optional attributes default to None.
Additional read-only properties:
mention_list: Tuple of Token instances listing all the @-mentions in the
message (including the @ signs).
mention_set : frozenset of names @-mentioned in the message (excluding
the @ signs).
"""
_exports_ = ('id', 'parent', 'previous_edit_id', 'time', 'sender',
'content', 'encryption_key_id', 'edited', 'deleted',
'truncated')
_defaults_ = {'parent': None, 'previous_edit_id': None,
'encryption_key_id': None, 'edited': None, 'deleted': None,
'truncated': None}
def __init__(__self, *__args, **__kwds):
Record.__init__(__self, *__args, **__kwds)
__self.__lock = threading.RLock()
__self.__mention_list = None
__self.__mention_set = None
def __setitem__(self, key, value):
with self.__lock:
Record.__setitem__(self, key, value)
self.__mention_list = None
self.__mention_set = None
@property
def mention_list(self):
with self.__lock:
if self.__mention_list is None:
self.__mention_list = tuple(scan_mentions(self.content))
return self.__mention_list
@property
def mention_set(self):
with self.__lock:
if self.__mention_set is None:
self.__mention_set = frozenset(i[1:]
for i in self.mention_list)
return self.__mention_set
class SessionView(Record):
"""
SessionView describes a session and its identity.
Attributes:
id : the id of an agent or account
name : the name-in-use at the time this view was captured
server_id : the id of the server that captured this view
server_era: the era of the server that captured this view
session_id: id of the session, unique across all sessions globally
is_staff : if true, this session belongs to a member of staff (defaults
to False)
is_manager: if true, this session belongs to a manager of the room
(defaults to False)
Additional read-only properties:
is_account: Whether this session has an account.
is_agent : Whether this session is neither a bot nor has an account.
is_bot : Whether this is a bot.
norm_name : Normalized name.
"""
_exports_ = ('id', 'name', 'server_id', 'server_era', 'session_id',
'is_staff', 'is_manager')
_defaults_ = {'is_staff': False, 'is_manager': False}
@property
def is_account(self):
return self['id'].startswith('account:')
@property
def is_agent(self):
return self['id'].startswith('agent:')
@property
def is_bot(self):
return self['id'].startswith('bot:')
@property
def norm_name(self):
return normalize_nick(self.name)
class UserList(object):
"""
UserList() -> new instance
An iterable list of SessionView objects, with methods for modification
and quick search.
"""
def __init__(self):
"Initializer. See class docstring for invocation details."
self._list = []
self._by_session_id = {}
self._by_agent_id = {}
self._by_name = {}
self._lock = threading.RLock()
def __iter__(self):
"""
__iter__() -> iterator
Iterate over all elements in self.
"""
return iter(self.list())
def add(self, *lst):
"""
add(*lst) -> None
Add all the SessionView-s in lst to self, unless already there.
"""
with self._lock:
for i in lst:
if i.session_id in self._by_session_id:
orig = self._by_session_id.pop(i.session_id)
self._list.remove(orig)
self._by_agent_id[orig.id].remove(orig)
self._by_name[orig.name].remove(orig)
self._list.append(i)
self._by_session_id[i.session_id] = i
self._by_agent_id.setdefault(i.id, []).append(i)
self._by_name.setdefault(i.name, []).append(i)
def remove(self, *lst):
"""
remove(*lst) -> None
Remove all the SessionView-s in lst from self (unless not there
at all).
"""
with self._lock:
for i in lst:
try:
orig = self._by_session_id.pop(i.session_id)
except KeyError:
continue
self._list.remove(orig)
self._by_agent_id.get(orig.id, []).remove(orig)
try:
self._by_name.get(orig.name, []).remove(orig)
except ValueError:
pass
def remove_matching(self, pattern):
"""
remove_matching(pattern) -> None
Remove all the SessionView-s from self where all the items present
in pattern equal to the corresponding ones in the element; i.e.,
a pattern of {'name': 'test'} will remove all entries with a 'name'
value of 'test'. An empty pattern will remove all users.
Used to implement the partition network-event.
"""
if not pattern:
self.clear()
return
with self._lock:
rml, it = [], pattern.items()
for i in self._list:
for k, v in it:
try:
if i[k] != v:
break
except KeyError:
break
else:
rml.append(i)
self.remove(*rml)
def clear(self):
"""
clear() -> None
Remove everything from self.
"""
with self._lock:
self._list[:] = ()
self._by_session_id.clear()
self._by_agent_id.clear()
self._by_name.clear()
def list(self):
"""
list() -> list
Return a (Python) list holding all the SessionViews currently in
here.
"""
with self._lock:
return list(self._list)
def for_session(self, id):
"""
for_session(id) -> SessionView
Return the SessionView corresponding session ID from self.
Raises a KeyError if the given session is not known.
"""
with self._lock:
return self._by_session_id[id]
def for_agent(self, id):
"""
for_agent(id) -> list
Return all the SessionViews known with the given agent ID as a list.
"""
with self._lock:
return list(self._by_agent_id.get(id, ()))
def for_name(self, name):
"""
for_name(name) -> list
Return all the SessionViews known with the given name as a list.
"""
with self._lock:
return list(self._by_name.get(name, ()))
class MessageTree(object):
"""
MessageTree() -> new instance
Class representing a threaded chat log. Note that, because of Heim's
never-forget policy, "deleted" messages are actually only flagged as
such, and not "physically" deleted. Editing messages happens by
re-adding them.
"""
def __init__(self):
"Initializer. See class docstring for invocation details."
self._messages = {}
self._children = {}
self._earliest = None
self._latest = None
self._lock = threading.RLock()
def __iter__(self):
"""
__iter__() -> iterator
Iterate over all elements in self in order.
"""
return iter(self.list())
def __getitem__(self, key):
"""
__getitem__(key) -> Message
Equivalent to self.get(key).
"""
return self.get(key)
def add(self, *lst):
"""
add(*lst) -> None
Incorporate all the messages in lst into self.
"""
sorts = {}
with self._lock:
for msg in lst:
self._messages[msg.id] = msg
c = self._children.setdefault(msg.parent, [])
if msg.id not in c: c.append(msg.id)
if self._earliest is None or self._earliest.id > msg.id:
self._earliest = msg
if self._latest is None or self._latest.id <= msg.id:
self._latest = msg
sorts[id(c)] = c
for l in sorts.values(): l.sort()
def clear(self):
"""
clear() -> None
(Actually) remove all the messages from self.
"""
with self._lock:
self._messages.clear()
self._children.clear()
self._earliest = None
self._latest = None
def earliest(self):
"""
earliest() -> Message
Return the earliest message in self, or None of none.
"""
with self._lock:
return self._earliest
def latest(self):
"""
latest() -> Message
Return the latest message in self, or None of none.
"""
with self._lock:
return self._latest
def get(self, id):
"""
get(id) -> Message
Return the message corresponding to the given ID, or raise KeyError
if no such message present.
"""
with self._lock:
return self._messages[id]
def list(self, parent=None):
"""
list(parent=None) -> list
Return all the messages for the given parent (None for top-level
messages) in an ordered list.
"""
with self._lock:
return [self._messages[i]
for i in self._children.get(parent, ())]
def all(self):
"""
all() -> list
Return an ordered list containing all the messages in self.
"""
with self._lock:
l = list(self._messages.values())
l.sort(key=lambda m: m.id)
return l
# ---------------------------------------------------------------------------
# "Main" classes
# ---------------------------------------------------------------------------
class HeimEndpoint(object):
"""
HeimEndpoint(**config) -> new instance
Endpoint for the Heim protocol. Provides state about this endpoint and
the connection, methods to submit commands, as well as call-back methods
for some incoming replies/events, and dynamic handlers for arbitrary
incoming packets. Re-connects are handled transparently.
Attributes (assignable by keyword arguments):
url_template : Template to construct URLs from. Its format() method
will be called with the room name as the only argument.
Defaults to the global URL_TEMPLATE variable, which, in
turn, may be overridden by the environment variable
BASEBOT_URL_TEMPLATE (if set when the module is
initialized).
roomname : Name of room to connect to. Defaults to None. Must be
explicitly set for the connection to succeed.
nickname : Nick-name to set on connection. Updated when a nick-reply
is received. If None, no nick-name is set. Defaults to
the value of the NICKNAME class attribute (which, in turn,
defaults to None).
passcode : Passcode for private rooms. Sent during (re-)connection.
Defaults to None; no passcode is sent in that case.
retry_count : Amount of re-connection attempts until an operation (a
connect or a send) fails. May be None to indicate infinite
re-tries. Defaults to 4.
retry_delay : Amount of seconds to wait before a re-connection attempt.
Defaults to 10 seconds.
timeout : (Low-level) Connection timeout. Defaults to 60 seconds (as
the Heim server sends pings every 30 seconds, the
connection is either dead after that time, or generally
unstable).
do_respawn : Re-start the main loop if an unexpected exception occurs.
Defaults to False.
respawn_delay: Wait for this amount of seconds before re-spawning after a
crash. Defaults to 60.
handlers : Packet-type-to-list-of-callables mapping storing handlers
for incoming packets.
Handlers are called with the packet as the only argument;
the packet's '_self' item is set to the HeimEndpoint
instance that received the packet.
Handlers for the (virtual) packet type None (i.e. the None
singleton) are called for *any* packet, similarly to
handle_early() (but *after* the built-in handlers).
While commands and replies should be handled by the
call-back mechanism, built-in handler methods (on_*();
not in the mapping) are present for the asynchronous
events.
Event handlers are (indirectly) called from the input
loop, and should therefore finish quickly, or offload the
work to a separate thread. Mind that the Heim server will
kick any clients unreponsive for too long times!
While account-related event handlers are present, actual
support for accounts is lacking, and has to be implemented
manually.
init_cb : A function that is called in the very beginning of a bot's
main loop with the bot instance as the only argument.
close_cb : A function that is called in the very end of a bot's main
loop with the bot instance as the only argument.
logger : logging.Logger instance to log to. Defaults to the root
logger (at time of creation).
manager : BotManager instance responsible for this HeimEndpoint.
Access to the attributes should be serialized using the instance lock
(available in the lock attribute). The __enter__ and __exit__ methods
of the lock are exposed, so "with self:" can be used instead of "with
self.lock:". For convenience, packet handlers are called in a such
context; if sections explicitly need not to be protected, manual calls
to self.lock.release() and self.lock.acquire() become necessary.
Note that, to actually take effect, changes to the roomname, nickname
and passcode attributes must be peformed by using the corresponding
set_*() methods (or by performing the necessary actions oneself).
*Remember to call the parent class' methods, as some of its interna are
implemented there!*
Other attributes (not assignable by keyword arguments):
cmdid : ID of the next command packet to be sent. Used internally.
callbacks : Mapping of command ID-s to callables; used to implement
reply callbacks. Invoked after generic handlers.
eff_nickname: The nick-name as the server returned it. May differ from
the one sent (truncation etc.).
agent_id : Own agent ID, or Nont if not connected.
session_id : Own session ID, or None if not connected.
lock : Attribute access lock. Must be acquired whenever an
attribute is changed, or when multiple accesses to an
attribute should be atomic.
"""
# Default nick-name. Can be overridden by subclasses.
NICKNAME = None
def __init__(self, **config):
"Initializer. See class docstring for invocation details."
self.url_template = config.get('url_template', URL_TEMPLATE)
self.roomname = config.get('roomname', None)
self.nickname = config.get('nickname', self.NICKNAME)
self.passcode = config.get('passcode', None)
self.retry_count = config.get('retry_count', 4)
self.retry_delay = config.get('retry_delay', 10)
self.timeout = config.get('timeout', 60)
self.do_respawn = config.get('do_respawn', False)
self.respawn_delay = config.get('respawn_delay', 60)
self.handlers = config.get('handlers', {})
self.init_cb = config.get('init_cb', None)
self.close_cb = config.get('close_cb', None)
self.logger = config.get('logger', logging.getLogger())
self.manager = config.get('manager', None)
self.cmdid = 0
self.callbacks = {}
self.eff_nickname = None
self.agent_id = None
self.session_id = None
self.lock = threading.RLock()
# Actual connection.
self._connection = None
# Whether someone is poking the connection.
self._connecting = False
# Whether re-connects should not happen.
self._closing = False
# Condition variable to serialize all on.
self._conncond = threading.Condition(self.lock)
# Whether the session was properly initiated.
self._logged_in = False
# Whether a nick-name was set for the first time.
self._nick_set = False
# Deduplicate nickname setting.
self._last_nickname = None
def __enter__(self):
return self.lock.__enter__()
def __exit__(self, *args):
return self.lock.__exit__(*args)
def _make_connection(self, url, timeout):
"""
_make_connection(url, timeout) -> JSONWebSocket
Actually connect to url, with a time-out setting of timeout.
Returns the object produced, or raises an exception.
"""
self.logger.info('Connecting to %s...' % url)
with self.manager:
jar = self.manager.cookiejar
ret = JSONWebSocket(websocket.connect(url, cookies=jar,
timeout=timeout))
if jar: jar.save()
self.logger.info('Connected.')
return ret
def _attempt(self, func, exchook=None):
"""
_attempt(func, exchook=None) -> object
Attempt to run func; if it raises an exception, re-try using the
specified parameters (retry_count and retry_delay).
func is called with three arguments, the zero-based trial counter,
and the amount of re-tries that will be attempted, and the exception
that happened during the last attempt, or None if none.
exchook (if not None) is called immediately after an exception is
caught, with the same arguments as func (and the exception object
filled in); it may re-raise the exception to abort instantly. If
its return value is true, the timeout is skipped. If exchook is None,
a warning message will be logged.
If the last attempt fails, the exception that indicated the
failure is re-raised.
If the function call succeeds, the return value of func is passed
out.
"""
with self.lock:
count, delay = self.retry_count, self.retry_delay
exc = None
if count is None:
it = itertools.count()
else:
it = range(count + 1)
wait = True
for i in it:
if i and wait: time.sleep(delay)
try:
return func(i, count, exc)
except Exception as e:
exc = e
if exchook is None:
self.logger.warning('Operation failed!', exc_info=True)
wait = True
else:
wait = (not exchook(i, count, exc))
if i == count:
raise
continue
def _attempt_reconnect(self, func):
"""
_attempt_reconnect(func) -> object
Same as _attempt(), but each repeated call of func is preceded to one
of _reconnect(). Additional rather internal modifications are
applied, resulting in (hopefully) graceful handling of closes and
re-connects from other threads.
"""
def callback(i, n, exc):
if i and d['reconnect']: self._reconnect()
return func(i, n, exc)
def exchook(i, n, exc):
with self._conncond:
c = self.get_connection()
if d['conn'] is not c and c:
self.logger.warning('Reconnect happened, retrying...')
d['reconnect'] = False
return True
d['reconnect'] = True
d['conn'] = c
if self._closing:
self.logger.warning('Operation interrupted while '
'closing; aborting...')
raise ConnectionClosedError()
if exc and i != n and not isinstance(exc, ConnectionClosedError):
self.logger.warning('Operation failed (%r); '
'will re-connect...' % exc)
d = {'conn': self.get_connection(), 'reconnect': True}
return self._attempt(callback, exchook)
def _connect(self):
"""
_connect() -> None
Internal back-end for connect(). Takes care of synchronization.
"""
def do_connect(c, a, e):
return self._make_connection(url, timeout)
def exchook(i, n, exc):
return self._closing
with self._conncond:
if self.roomname is None:
raise NoRoomError('No room specified')
while self._connecting:
self._conncond.wait()
if self._connection is not None:
return
self._connecting = True
url = self.url_template.format(self.roomname)
timeout = self.timeout
conn = None
try: