webXMPP

Check-in [df0f18ff49]
Login

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Overview
Comment:Add MMS support
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | master | trunk
Files: files | file ages | folders
SHA3-256:df0f18ff49682b102099debde8842079360dba3497962a2779573b1030c0f933
User & Date: web 2019-04-28 18:47:47
Context
2019-05-05
02:39
Don't post notification when it's a mirror of a message we generated ourselves on another client instance. check-in: 289d95fd34 user: web tags: master, trunk
2019-04-28
18:47
Add MMS support check-in: df0f18ff49 user: web tags: master, trunk
2019-04-12
23:12
Add whitelist support, while generalising and keeping blacklist support too. check-in: a93fd62e0f user: web tags: master, trunk
Changes
Hide Diffs Unified Diffs Ignore Whitespace Patch

Changes to acct_sms.py.

116
117
118
119
120
121
122
123

124
125
126
	# No nickname, so learn this onto our roster
	if phnum not in u.roster:
	    sys.stderr.write(" learn %s on %s\n" % (a, phnum))
	    u.roster[phnum] = a

    # A message *to* us, from an SMS account
    sys.stderr.write(" post to user\n")
    u.add(True, fromwhom, msg["body"])


    sys.stderr.write(" delivered\n")








|
>



116
117
118
119
120
121
122
123
124
125
126
127
	# No nickname, so learn this onto our roster
	if phnum not in u.roster:
	    sys.stderr.write(" learn %s on %s\n" % (a, phnum))
	    u.roster[phnum] = a

    # A message *to* us, from an SMS account
    sys.stderr.write(" post to user\n")
    u.add(True, fromwhom, msg["body"],
	msg.get("mtype"), msg.get("fname"))

    sys.stderr.write(" delivered\n")

Changes to acct_xmpp.py.

43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
	if m is None:
	    # No data; typing, paused, etc.
	    return
	body  = toascii(m)
	sys.stderr.write("Message received from %s for %s\n" %
	    (sender, self.acct))
	sys.stderr.write("  Body: %s\n" % (body,))
	self.user.add(True, aname(sender), toascii(body))

    # Stop this Account; the user has gone away
    # We close off XMPP; the thread should come out of its loop
    #  and exit()
    def stop(self, tid):
	self.stopping = True








|







43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
	if m is None:
	    # No data; typing, paused, etc.
	    return
	body  = toascii(m)
	sys.stderr.write("Message received from %s for %s\n" %
	    (sender, self.acct))
	sys.stderr.write("  Body: %s\n" % (body,))
	self.user.add(True, aname(sender), toascii(body), None, None)

    # Stop this Account; the user has gone away
    # We close off XMPP; the thread should come out of its loop
    #  and exit()
    def stop(self, tid):
	self.stopping = True

Changes to get.py.

55
56
57
58
59
60
61

62
63
64
65
66
67
68
..
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

    # Paths for a GET
    def __init__(self):
	self.dispatchers.append( ("GET", self.send_favicon) )
	self.dispatchers.append( ("GET", self.send_ajax) )
	self.dispatchers.append( ("GET", self.send_config) )
	self.dispatchers.append( ("GET", self.send_sw) )


    # Short circuit for favicon
    def send_favicon(self):
	if self.path_match("favicon.ico"):
	    self.send_error(404)
	    return True,None
	return False,None
................................................................................
	# "sw*.js" -> "js/sw*.js"
	if (len(pp) == 1) and \
		((pp[0].endswith("-sw.js")) or
		 (pp[0] == "manifest.json")):
	    return True,self.send_files("js/" + pp[0])
	return False,None




























    # Send what we have, based on generation @gen
    def send_current(self, gen, rgen):
	webxmpp = self.server.approot
	user = webxmpp.users[self.user]
	msgs = []
	for tup in user.msgs:
	    if tup[0] >= gen:
		d = {}
		d["rx"] = tup[1]
		# TBD, more handling of nicknames
		d["them"] = tup[2]
		d["body"] = tup[3]


		msgs.append(d)

	# Here's the messages we have right now
	resp = { "serial": user.serial,
	    "gen": user.gen, "rgen": user.rgen,
	    "msgs": msgs }
	user.serial += 1







>







 







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>












>
>







55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
..
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

    # Paths for a GET
    def __init__(self):
	self.dispatchers.append( ("GET", self.send_favicon) )
	self.dispatchers.append( ("GET", self.send_ajax) )
	self.dispatchers.append( ("GET", self.send_config) )
	self.dispatchers.append( ("GET", self.send_sw) )
	self.dispatchers.append( ("GET", self.send_media) )

    # Short circuit for favicon
    def send_favicon(self):
	if self.path_match("favicon.ico"):
	    self.send_error(404)
	    return True,None
	return False,None
................................................................................
	# "sw*.js" -> "js/sw*.js"
	if (len(pp) == 1) and \
		((pp[0].endswith("-sw.js")) or
		 (pp[0] == "manifest.json")):
	    return True,self.send_files("js/" + pp[0])
	return False,None

    # Media files from MMS
    def send_media(self):
	# /media/<file>
	pp = self.paths
	if (len(pp) != 2) or (pp[0] != "media"):
	    return False,None

	# Turn into path var/<phone#>/<file>
	user = self.server.approot.users[self.user]
	for tup in user.accounts:
	    tup = tup[0]
	    if tup[0] == "sms":
		phnum = tup[1]
		break
	else:
	    sys.stderr.write("No SMS acct for %s\n" % (self.user,))
	    return False,None

	# Skip this noise
	fn = pp[1]
	if (".." in fn) or ('/' in fn):
	    return False,None
	fn = "var/" + phnum + "/" + fn

	# Try and send the media file
	return True,self.send_files(fn)

    # Send what we have, based on generation @gen
    def send_current(self, gen, rgen):
	webxmpp = self.server.approot
	user = webxmpp.users[self.user]
	msgs = []
	for tup in user.msgs:
	    if tup[0] >= gen:
		d = {}
		d["rx"] = tup[1]
		# TBD, more handling of nicknames
		d["them"] = tup[2]
		d["body"] = tup[3]
		d["mtype"] = tup[4]
		d["fname"] = tup[5]
		msgs.append(d)

	# Here's the messages we have right now
	resp = { "serial": user.serial,
	    "gen": user.gen, "rgen": user.rgen,
	    "msgs": msgs }
	user.serial += 1

Changes to js/ui.js.

40
41
42
43
44
45
46
47

48
49
50
51
52
53
54
...
216
217
218
219
220
221
222












223
224
225
226
227
228
229
//		we cancel the XHR ourselves.
//  nDisplay - # messages to keep/dump into ourText display
let ourConfig = null;

// Text lines to display.  We trim them off the top and append
//  to bottom of list.  The text is HTML source, sanitised by
//  the server.
// Each message object has "rx", "them", and "body" fields.

const ourMsgs = [];

// Version of messages to request
// Starts at 0 (give me anything) and counts upward
//  as successive batches are fed to us.
let reqGen = 0;

................................................................................
	// Body
	l += m.body;

	// Add text element inside its own div
	const n = document.createTextNode(l);
	const d = document.createElement("div");
	d.appendChild(n);












	ourTexts.appendChild(d);
    }

    // Always look at the bottom of it
    ourTexts.scrollTop = 99999;
}








|
>







 







>
>
>
>
>
>
>
>
>
>
>
>







40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
...
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
//		we cancel the XHR ourselves.
//  nDisplay - # messages to keep/dump into ourText display
let ourConfig = null;

// Text lines to display.  We trim them off the top and append
//  to bottom of list.  The text is HTML source, sanitised by
//  the server.
// Each message object has "rx", "them", "body", "mtype",
//  and "fname" fields.
const ourMsgs = [];

// Version of messages to request
// Starts at 0 (give me anything) and counts upward
//  as successive batches are fed to us.
let reqGen = 0;

................................................................................
	// Body
	l += m.body;

	// Add text element inside its own div
	const n = document.createTextNode(l);
	const d = document.createElement("div");
	d.appendChild(n);

	// If MMS, link to content
	if (m.mtype != null) {
	    // Make it an href, try to open in new window/tab
	    const a = document.createElement('a');
	    a.textContent = m.mtype;
	    a.href = "/media/" + m.fname;
	    a.target = "_blank";
	    d.appendChild(a);
	}

	// Display this line
	ourTexts.appendChild(d);
    }

    // Always look at the bottom of it
    ourTexts.scrollTop = 99999;
}

Changes to main.py.

32
33
34
35
36
37
38
39

40
41
42
43
44
45
46
    # This check is before options are parsed, so self.paths
    #  isn't available and we have to look directly at the
    #  specified path.
    def handle_noauth(self):
	if self.path:

	    # SMS postings are defended by IP checks, not authentication
	    if self.path.startswith("/sms"):

		return True

	    # Let'em see our icon, if any
	    if self.path == "/favicon.ico":
		return True

	    # Mastodon pushes







|
>







32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
    # This check is before options are parsed, so self.paths
    #  isn't available and we have to look directly at the
    #  specified path.
    def handle_noauth(self):
	if self.path:

	    # SMS postings are defended by IP checks, not authentication
	    if self.path.startswith("/sms") or \
		    self.path.startswith("/mms"):
		return True

	    # Let'em see our icon, if any
	    if self.path == "/favicon.ico":
		return True

	    # Mastodon pushes

Changes to post.py.

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
..
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
...
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
#
# POST's put state back to us--usually a text to send.
#
# /msg.json - JSON of a message to send
# /sms.json - JSON of a Flowroute-style SMS delivery
# /mast.json - We Push from Mastodon
#
import sys, json, os
from chore.utils import toascii
import chore
import acct_sms

class POST_mixin(object):
    def __init__(self):
	self.dispatchers.append( ("POST", self.post_msg) )
	self.dispatchers.append( ("POST", self.post_sms) )

	self.dispatchers.append( ("POST", self.post_mast) )

    # Does SMS blacklisting apply to this message?

    def filtered(self, msg):
	approot = self.server.approot

	# No list at all
	if not approot.smslfn:
	    return False

	# Refresh?
................................................................................
		    continue
		approot.smslnums.append(l)
	    f.close()
	    sys.stderr.write(" %d entries\n" % (len(approot.smslnums),))
	    approot.smsllatest = st.st_mtime

	# See if any list pattern is found within sender
	mf = msg.get("from", "")
	res = False
	for a in approot.smslnums:
	    if a in mf:
		sys.stderr.write("Match %slist %s on %s\n" %
		    (ltype, a, mf))
		res = True
		break
	sys.stderr.write("SMS %slist %s\n" % (ltype, mf))

	# So we reject on match for blacklist; we reject
	#  on non-match for whitelist
	return res if approot.smsisblack else (not res)














































































































    # An SMS has arrived for us
    def post_sms(self, buf):
	# /sms.json
	if not self.path_match("sms.json"):
	    return False,None

	# Verify that it's a legit source
	sys.stderr.write("POST SMS: %s\n" % (buf,))
	approot = self.server.approot
	if approot.smsok:
	    addr = self.client_address[0]
	    if not chore.net.ok_ip(approot.smsok, addr):
		# Just close'em off
		sys.stderr.write("Unknown SMS peer: %r\n" % (addr,))
		self.conn.close()

		sys.exit(0)

	# Decode message payload
	msg = json.loads(buf)
	sys.stderr.write(" JSON result %s\n" % (msg,))

	# Pre-filter

	if self.filtered(msg):
	    sys.stderr.write(" filtered\n")
	    return True,self.send_result("", "text/html")

	# Hand it off
	acct_sms.incoming(msg)

	# If we error, they just re-deliver more times, so always
................................................................................
	else:
	    # Can't figure out dest
	    self.send_error(404, "Unknown recipient")
	    return False,None


	# Log a message on our own display
	user.add(False, towhom, body)

	# Empty result body
	return True,self.send_result("", "text/html")

    # Mastodon event, web push
    def post_mast(self, buf):








|








>



>
|







 







<













>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>








<
<
<
<
<
<
<
>
|






>
|







 







|







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
..
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
...
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
#
# POST's put state back to us--usually a text to send.
#
# /msg.json - JSON of a message to send
# /sms.json - JSON of a Flowroute-style SMS delivery
# /mast.json - We Push from Mastodon
#
import sys, json, os, time, urllib2
from chore.utils import toascii
import chore
import acct_sms

class POST_mixin(object):
    def __init__(self):
	self.dispatchers.append( ("POST", self.post_msg) )
	self.dispatchers.append( ("POST", self.post_sms) )
	self.dispatchers.append( ("POST", self.post_mms) )
	self.dispatchers.append( ("POST", self.post_mast) )

    # Does SMS blacklisting apply to this message?
    # Tell if the message from @mf should be rejected (i.e., filtered)
    def filtered(self, mf):
	approot = self.server.approot

	# No list at all
	if not approot.smslfn:
	    return False

	# Refresh?
................................................................................
		    continue
		approot.smslnums.append(l)
	    f.close()
	    sys.stderr.write(" %d entries\n" % (len(approot.smslnums),))
	    approot.smsllatest = st.st_mtime

	# See if any list pattern is found within sender

	res = False
	for a in approot.smslnums:
	    if a in mf:
		sys.stderr.write("Match %slist %s on %s\n" %
		    (ltype, a, mf))
		res = True
		break
	sys.stderr.write("SMS %slist %s\n" % (ltype, mf))

	# So we reject on match for blacklist; we reject
	#  on non-match for whitelist
	return res if approot.smsisblack else (not res)

    # Tell if, based on source IP address, we should accept
    #  from this client.
    def src_ok(self):
	approot = self.server.approot
	if approot.smsok:
	    addr = self.client_address[0]
	    if not chore.net.ok_ip(approot.smsok, addr):
		# Just close'em off
		sys.stderr.write("Unknown SMS peer: %r\n" % (addr,))
		self.conn.close()
		return False
	return True

    # An MMS has arrived
    def post_mms(self, buf):
	# /mms.json
	if not self.path_match("mms.json"):
	    return False,None

	sys.stderr.write("POST MMS: %s\n" % (buf,))
	if not self.src_ok():
	    sys.exit(0)

	# Decode message payload
	msg = json.loads(buf)
	sys.stderr.write(" JSON result %s\n" % (msg,))

	# Decode needed fields
	try:
	    mfd = msg["data"]
	    mid = str(mfd["id"])
	    mfa = mfd["attributes"]

	    # Sender and recipient phone #'s
	    mf = str(mfa["from"])
	    mt = str(mfa["to"])

	    mfi = msg["included"][0]
	    mfia = mfi["attributes"]

	    # Sender's filename and MIME type
	    mfname = str(mfia["file_name"])
	    mftyp = str(mfia["mime_type"])

	    # Provider's transient storage name and auth key
	    url = str(mfia["url"])

	    msgok = True

	except:
	    msgok = False

	if (not msgok) or (not mid) or (not mf) or (not url) or \
		(not mfname) or (not mftyp) or (not mt):
	    sys.stderr.write(" bad JSON format\n")
	    return True,self.send_result("", "text/html")

	# Known recipient?
	if mt not in acct_sms.Numbers:
	    sys.stderr.write(" unknown recipient\n")
	    return True,self.send_result("", "text/html")

	# Whitelist/Blacklist
	if self.filtered(mf):
	    sys.stderr.write(" filtered\n")
	    return True,self.send_result("", "text/html")

	# Usable format?
	if mftyp == "image/jpeg":
	    mfext = ".jpg"
	else:
	    sys.stderr.write(" reject format %s\n" % (mftyp,))
	    return True,self.send_result("", "text/html")

	# Name based on when, storage in var/<recipient#>
	basename = str(time.time()) + mfext
	destdir = "var/" + mt
	if not os.access(destdir, os.F_OK):
	    try:
		os.mkdir(destdir)
		sys.stderr.write(" new storage %s\n" % (destdir,))
	    except:
		pass
	fn = destdir + '/' + basename
	try:
	    fw = open(fn, "w")
	except:
	    sys.stderr.write(" failed save to %s\n" % (fn,))
	    return True,self.send_result("", "text/html")

	# Pull in content, write out
	f = urllib2.urlopen(url)
	while True:
	    buf = f.read(65536)
	    if not buf:
		break
	    fw.write(buf)
	fw.close()
	f.close()

	# Synthesize a message for the recipient to get a URL
	body = "MMS"
	msg = {"from": mf, "to": mt, "body": body,
	    "mtype": mftyp, "fname": basename}

	# Hand it off
	acct_sms.incoming(msg)
	return True,self.send_result("", "text/html")

    # An SMS has arrived for us
    def post_sms(self, buf):
	# /sms.json
	if not self.path_match("sms.json"):
	    return False,None

	# Verify that it's a legit source
	sys.stderr.write("POST SMS: %s\n" % (buf,))







	if not self.src_ok():
	    sys.exit(0)

	# Decode message payload
	msg = json.loads(buf)
	sys.stderr.write(" JSON result %s\n" % (msg,))

	# Pre-filter
	mf = msg.get("from", "")
	if self.filtered(mf):
	    sys.stderr.write(" filtered\n")
	    return True,self.send_result("", "text/html")

	# Hand it off
	acct_sms.incoming(msg)

	# If we error, they just re-deliver more times, so always
................................................................................
	else:
	    # Can't figure out dest
	    self.send_error(404, "Unknown recipient")
	    return False,None


	# Log a message on our own display
	user.add(False, towhom, body, None, None)

	# Empty result body
	return True,self.send_result("", "text/html")

    # Mastodon event, web push
    def post_mast(self, buf):

Changes to telnet.py.

259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
	    assert self.curwho
	    acct = user.roster[self.curwho]

	    # Send via our account to them
	    acct.send(self.curwho, l)

	    # Also add to our own viewed history
	    user.add(False, self.curwho, l)

# Telnet Daemon
#
# Accepts telnet connections, and spins up a separate thread
#  to talk to the user on that connection
class TelnetD(object):
    def __init__(self, cfg, approot):







|







259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
	    assert self.curwho
	    acct = user.roster[self.curwho]

	    # Send via our account to them
	    acct.send(self.curwho, l)

	    # Also add to our own viewed history
	    user.add(False, self.curwho, l, None, None)

# Telnet Daemon
#
# Accepts telnet connections, and spins up a separate thread
#  to talk to the user on that connection
class TelnetD(object):
    def __init__(self, cfg, approot):

Changes to user.py.

20
21
22
23
24
25
26
27

28
29
30
31
32
33
34
..
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
...
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# exclusion - Mutex so only one user HTTP session at a time comes
#	through and changes things on an XMPP connection.
# active{} - Mapping from account name to (Account,Thread) serving it
# gen - Generation of content; used by client requests to detect
#	new content
# msgs[] - Current list of tuples representing messages being
#	displayed out at the browser.  There are config["nmsg"]
#	of them.  Each is (generation#, from, to, body)

# pending[] - List of clients waiting (Ajax) for new messages
#	Each is a tuple (timeout, semaphore, client-id, www_handler)
# roster{} - Map from recipient to the Account they're under
# status{} - Map from account name to their status
#	(it was under the User directly, but you can get back
#	 presence messages before the user shows up in the roster)
# serial - Running counter, so clients can detect dup (from cache)
................................................................................

	# Initial generation of content;
	#  roster current, and
	#  generation of messages.
	self.rgen = self.gen = 1

	# Always start with a welcome message
	self.msgs = [ (0, True, "WebXMPP", "Welcome to WebXMPP"), ]

	self.pending = set()

    # Open each configured account, and fire up a thread
    #  to service it.
    def start(self):
	self.activity = time.time()
................................................................................
    # An HTTP client is holding off an Ajax completion
    def await(self, timeout, sema, clientID, handler):
	self.pending.add( (time.time() + timeout, sema, clientID, handler) )

    # Add a new message
    # It's received (i.e., *to* us) if @rx, involving @them
    #  with body of @mBody
    def add(self, rx, them, mBody):
	mmax = self.top.config["nmsg"]
	msgs = self.msgs
	while len(msgs) >= mmax:
	    del msgs[0]
	msgs.append( (self.gen, rx, them, mBody) )
	self.gen += 1

	# Pending Ajax requests wake up now, clearing
	#  the list
	if self.pending:
	    pends = tuple(self.pending)
	    self.pending.clear()







|
>







 







|







 







|




|







20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
..
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
...
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# exclusion - Mutex so only one user HTTP session at a time comes
#	through and changes things on an XMPP connection.
# active{} - Mapping from account name to (Account,Thread) serving it
# gen - Generation of content; used by client requests to detect
#	new content
# msgs[] - Current list of tuples representing messages being
#	displayed out at the browser.  There are config["nmsg"]
#	of them.  Each is:
#	 (generation#, from, to, body, mime-type, media-name)
# pending[] - List of clients waiting (Ajax) for new messages
#	Each is a tuple (timeout, semaphore, client-id, www_handler)
# roster{} - Map from recipient to the Account they're under
# status{} - Map from account name to their status
#	(it was under the User directly, but you can get back
#	 presence messages before the user shows up in the roster)
# serial - Running counter, so clients can detect dup (from cache)
................................................................................

	# Initial generation of content;
	#  roster current, and
	#  generation of messages.
	self.rgen = self.gen = 1

	# Always start with a welcome message
	self.msgs = [ (0, True, "WebXMPP", "Welcome to WebXMPP", None, None), ]

	self.pending = set()

    # Open each configured account, and fire up a thread
    #  to service it.
    def start(self):
	self.activity = time.time()
................................................................................
    # An HTTP client is holding off an Ajax completion
    def await(self, timeout, sema, clientID, handler):
	self.pending.add( (time.time() + timeout, sema, clientID, handler) )

    # Add a new message
    # It's received (i.e., *to* us) if @rx, involving @them
    #  with body of @mBody
    def add(self, rx, them, mBody, mt, fname):
	mmax = self.top.config["nmsg"]
	msgs = self.msgs
	while len(msgs) >= mmax:
	    del msgs[0]
	msgs.append( (self.gen, rx, them, mBody, mt, fname) )
	self.gen += 1

	# Pending Ajax requests wake up now, clearing
	#  the list
	if self.pending:
	    pends = tuple(self.pending)
	    self.pending.clear()