]> git.neil.brown.name Git - plato.git/blob - sms/storesms.py
f0d5f2ece65a79d7507e7e204fe37eecf9d7aba2
[plato.git] / sms / storesms.py
1 #
2 # FIXME
3 #  - trim newmesg and draft when possible.
4 #  - remove old multipart files
5 #
6 # Store SMS messages is a bunch of files, one per month.
7 # Each message is stored on one line with space separated .
8 # URL encoding (%XX) is used to quote white space, unprintables etc
9 # We store 5 fields:
10 # - time stamp that we first saw the message.  This is in UTC.
11 #   This is the primary key.  If a second message is seen in the same second,
12 #   we quietly add 1 to the second.
13 # - Source, one of 'LOCAL' for locally composed, 'GSM' for recieved via GSM
14 #   or maybe 'EMAIL' if received via email??
15 # - Time message was sent, Localtime with -TZ.  For GSM messages this comes with the
16 #   message. For 'LOCAL' it might be '-', or will be the time we succeeded
17 #   in sending.
18 #   time is stored as a tupple (Y m d H M S Z) where Z is timezone in multiples
19 #   of 15 minutes.
20 # - The correspondent: sender if GSM, recipient if LOCAL, or '-' if not sent.
21 #     This might be a comma-separated list of recipients.
22 # - The text of the message
23 #
24 # Times are formatted %Y%m%d-%H%M%S and local time has a  GSM TZ suffix.
25 # GSM TZ is from +48 to -48 in units of 15 minutes. (0 is +00)
26 #
27 # We never modify a message once it has been stored.
28 # If we have a draft that we edit and send, we delete the draft and
29 # create a new sent-message
30 # If we forward a message, we will then have two copies.
31 #
32 # New messages are not distinguished by a flag (which would have to be cleared)
33 # but by being in a separate list of new messages.
34 # We havea list of 'new' messages and a list of 'draft' messages.
35 #
36 # Multi-part messages are accumulated as they are received.  The quoted message
37 # contains <N>text for each part of the message.
38 #  e.g. <1><2>nd%20so%20no.....<3>
39 # if we only have part 2 of 3.
40 # For each incomplete message there is a file (like 'draft' and 'newmesg') named
41 # for the message which provides an index to each incomplete message.
42 # It will be named e.g. 'multipart-1C' when 1C is the message id.
43 #
44 # This module defines 2 classes:
45 # SMSmesg
46 #   This holds a message and so has timestamp, source, time, correspondent
47 #    and text fields.  These are decoded.
48 #   SMSmesg also has 'state' which can be one of "NEW", "DRAFT" or None
49 #   Finally it might have a 'ref' and a 'part' which is a tuple (this,max)
50 #    This is only used when storing the message to link it up with
51 #    a partner
52
53 # SMSstore
54 #  This represents a collection of messages in a directory (one file per month)
55 #  and provides lists of 'NEW' and 'DRAFT' messages.
56 #  Operations:
57 #     store(SMSmesg,  NEW|DRAFT|) -> None
58 #       stores the message and sets the timestamp
59 #  lookup(latest-time, NEW|DRAFT|ALL) -> (earlytime, [SMSmesg])
60 #     collects a list of messages in reverse time order with times no later
61 #     than 'latest-time'.  Only consider NEW or DRAFT or ALL messages.
62 #     The list may not be complete (typically one month at a time are returnned)
63 #      If you want more, call again with 'earlytime' as 'latest-time').
64 #  delete(SMSmesg)
65 #     delete the given message (based on the timestamp only)
66 #  setstate(SMSmesg, NEW|DRAFT|None)
67 #     update the 'new' and 'draft' lists or container, or not container, this
68 #     message.
69 #  
70 #
71
72 import os, fcntl, re, time, urllib
73
74 def umktime(tm):
75     # like time.mktime, but tm is UTC
76     # So we want a 't' where
77     #  time.gmtime(t)[0:6] == tm[0:6]
78     estimate = time.mktime(tm) - time.timezone
79     t2 = time.gmtime(estimate)
80     while t2[0:6] < tm[0:6]:
81         estimate += 15*60
82         t2 = time.gmtime(estimate)
83     while t2[0:6] > tm[0:6]:
84         estimate -= 15*60
85         t2 = time.gmtime(estimate)
86     return estimate
87
88 def parse_time(strg):
89     return int(umktime(time.strptime(strg, "%Y%m%d-%H%M%S")))
90 def parse_ltime(strg):
91     n=3
92     if strg[-2] == '+' or strg[-2] == '-':
93         n=2
94     z = strg[-n:]
95     return time.strptime(strg[:-n], "%Y%m%d-%H%M%S")[0:6] + (int(z),)
96 def format_time(t):
97     return time.strftime("%Y%m%d-%H%M%S", time.gmtime(t))
98 def format_ltime(tm):
99     return time.strftime("%Y%m%d-%H%M%S", tm[0:6]+(0,0,0)) + ("%+03d" % tm[6])
100
101
102 class SMSmesg:
103     def __init__(self, **a):
104         if len(a) == 1 and 'line' in a:
105             # line read from a file, with 5 fields.
106             #  stamp, source, time, correspondent, text
107             line = a['line'].split()
108             self.stamp = parse_time(line[0])
109             self.source = line[1]
110             self.time = parse_ltime(line[2])
111             self.correspondents = []
112             for c in line[3].split(','):
113                 if c != '-':
114                     self.correspondents.append(urllib.unquote(c))
115             self.set_corresponent()
116             self.state = None
117
118             self.parts = None
119             txt = line[4]
120             if txt[0] != '<':
121                 self.text = urllib.unquote(txt)
122                 return
123             # multipart:   <1>text...<2>text...<3><4>
124             m = re.findall('<(\d+)>([^<]*)', txt)
125             parts = []
126             for (pos, strg) in m:
127                 p = int(pos)
128                 while len(parts) < p:
129                     parts.append(None)
130                 if strg:
131                     parts[p-1] = urllib.unquote(strg)
132             self.parts = parts
133             self.reduce_parts()
134         else:
135             self.stamp = int(time.time())
136             self.source = None
137             lt = time.localtime()
138             z = time.timezone/15/60
139             if lt[8] == 1:
140                 z -= 4
141             self.time = time.localtime()[0:6] + (z,)
142             self.correspondents = []
143             self.text = ""
144             self.state = None
145             self.ref = None
146             self.parts = None
147             part = None
148             for k in a:
149                 if k == 'stamp':
150                     self.stamp = a[k]
151                 elif k == 'source':
152                     self.source = a[k]
153                 elif k == 'time':
154                     # time can be a GSM string: 09/02/09,09:56:28+44 (ymd,HMS+z)
155                     # or a tuple (y,m,d,H,M,S,z)
156                     if type(a[k]) == str:
157                         t = a[k][:-3]
158                         z = a[k][-3:]
159                         tm = time.strptime(t, "%y/%m/%d,%H:%M:%S")
160                         self.time = tm[0:6] + (int(z),)
161                 elif k == 'to' or k == 'sender':
162                     if self.source == None:
163                         if k == 'to':
164                             self.source = 'LOCAL'
165                         if k == 'sender':
166                             self.source = 'GSM'
167                     self.correspondents = [ a[k] ]
168                 elif k == 'correspondents':
169                     self.correspondents = a[k]
170                 elif k == 'text':
171                     self.text = a[k]
172                 elif k == 'state':
173                     self.state = a[k]
174                 elif k == 'ref':
175                     if a[k] != None:
176                         self.ref = a[k]
177                 elif k == 'part':
178                     if a[k]:
179                         part = a[k]
180                 else:
181                     raise ValueError
182             if self.source == None:
183                 self.source = 'LOCAL'
184             if part:
185                 print 'part', part[0], part[1]
186                 self.parts = [None for x in range(part[1])]
187                 self.parts[part[0]-1] = self.text
188                 self.reduce_parts()
189             self.set_corresponent()
190
191         self.month_re = re.compile("^[0-9]{6}$")
192
193     def reduce_parts(self):
194         def reduce_pair(a,b):
195             if b == None:
196                 b = "...part of message missing..."
197             if a == None:
198                 return b
199             return a+b
200         self.text = reduce(reduce_pair, self.parts)
201
202
203     def set_corresponent(self):
204         if len(self.correspondents) == 1:
205             self.correspondent = self.correspondents[0]
206         elif len(self.correspondents) == 0:
207             self.correspondent = "Unsent"
208         else:
209             self.correspondent = "Multiple"
210
211     def format(self):
212         fmt =  "%s %s %s %s " % (format_time(self.stamp), self.source,
213                                    format_ltime(self.time),
214                                    self.format_correspondents())
215         if not self.parts:
216             return fmt + urllib.quote(self.text)
217
218         for i in range(len(self.parts)):
219             fmt += ("<%d>" % (i+1)) + urllib.quote(self.parts[i]) if self.parts[i] else ""
220         return fmt
221
222     def format_correspondents(self):
223         r = ""
224         for i in self.correspondents:
225             if i:
226                 r += ',' + urllib.quote(i)
227         if r:
228             return r[1:]
229         else:
230             return '-'
231         
232
233 class SMSstore:
234     def __init__(self, dir):
235         self.month_re = re.compile("^[0-9]{6}$")
236         self.cached_month = None
237         self.dirname = dir
238         # find message files
239         self.set_files()
240         self.drafts = self.load_list('draft')
241         self.newmesg = self.load_list('newmesg')
242
243     def load_list(self, name, update = None, *args):
244
245         l = []
246         try:
247             f = open(self.dirname + '/' + name, 'r+')
248         except IOError:
249             return l
250
251         if update:
252             fcntl.lockf(f, fcntl.LOCK_EX)
253         for ln in f:
254             l.append(parse_time(ln.strip()))
255         l.sort()
256         l.reverse()
257
258         if update and update(l, *args):
259             f2 = open(self.dirname + '/' + name + '.new', 'w')
260             for t in l:
261                 f2.write(format_time(t)+"\n")
262             f2.close()
263             os.rename(self.dirname + '/' + name + '.new',
264                       self.dirname + '/' + name)
265         f.close()
266         return l
267
268     def load_month(self, f):
269         # load the messages from f, which is open for read
270         rv = {}
271         for l in f:
272             l.strip()
273             m = SMSmesg(line=l)
274             rv[m.stamp] = m
275             if m.stamp in self.drafts:
276                 m.state = 'DRAFT'
277             elif m.stamp in self.newmesg:
278                 m.state = 'NEW'
279         return rv
280
281     def store_month(self, l, m):
282         dm = self.dirname + '/' + m
283         f = open(dm+'.new', 'w')
284         for s in l:
285             f.write(l[s].format() + "\n")
286         f.close()
287         os.rename(dm+'.new', dm)
288         if not m in self.files:
289             self.files.append(m)
290             self.files.sort()
291             self.files.reverse()
292         if self.cached_month == m:
293             self.cache = l
294
295     def store(self, sms):
296         orig = None
297         if sms.ref != None:
298             # This is part of a multipart.
299             # If there already exists part of this
300             # merge them together
301             #
302             times = self.load_list('multipart-' + sms.ref)
303             if len(times) == 1:
304                 orig = self.load(times[0])
305                 if orig and orig.parts:
306                     for i in range(len(sms.parts)):
307                         if sms.parts[i] == None and i < len(orig.parts):
308                             sms.parts[i] = orig.parts[i]
309                 else:
310                     orig = None
311                     
312         m = time.strftime("%Y%m", time.gmtime(sms.stamp))
313         try:
314             f = open(self.dirname + '/' + m, "r+")
315         except:
316             f = open(self.dirname + '/' + m, "w+")
317         complete = True
318         if sms.ref != None:
319             for i in sms.parts:
320                 if i == None:
321                     complete = False
322             if complete:
323                 sms.reduce_parts()
324                 sms.parts = None
325                 
326         fcntl.lockf(f, fcntl.LOCK_EX)
327         l = self.load_month(f)
328         while sms.stamp in l:
329             sms.stamp += 1
330         l[sms.stamp] = sms
331         self.store_month(l, m);
332         f.close()
333
334         if orig:
335             self.delete(orig)
336         if sms.ref != None:
337             if complete:
338                 try:
339                     os.unlink(self.dirname + '/multipart-' + sms.ref)
340                 except:
341                     pass
342             elif orig:
343                 def replacewith(l, tm):
344                     while len(l):
345                         l.pop()
346                     l.append(tm)
347                     return True
348                 self.load_list('multipart-' + sms.ref, replacewith, sms.stamp)
349             else:
350                 f = open(self.dirname +'/multipart-' + sms.ref, 'w')
351                 fcntl.lockf(f, fcntl.LOCK_EX)
352                 f.write(format_time(sms.stamp) + '\n')
353                 f.close()
354
355         if sms.state == 'NEW' or sms.state == 'DRAFT':
356             s = 'newmesg'
357             if sms.state == 'DRAFT':
358                 s = 'draft'
359             f = open(self.dirname +'/' + s, 'a')
360             fcntl.lockf(f, fcntl.LOCK_EX)
361             f.write(format_time(sms.stamp) + '\n')
362             f.close()
363         elif sms.state != None:
364             raise ValueError
365
366     def set_files(self):
367         self.files = []
368         for f in os.listdir(self.dirname):
369             if self.month_re.match(f):
370                 self.files.append(f)
371         self.files.sort()
372         self.files.reverse()
373         
374     def lookup(self, lasttime = None, state = None):
375         if lasttime == None:
376             lasttime = int(time.time())
377         if state == None:
378             return self.getmesgs(lasttime)
379         if state == 'DRAFT':
380             self.drafts = self.load_list('draft')
381             times = self.drafts
382         elif state == 'NEW':
383             self.newmesg = self.load_list('newmesg')
384             times = self.newmesg
385         else:
386             raise ValueError
387
388         self.set_files()
389         self.cached_month = None
390         self.cache = None
391         rv = []
392         for t in times:
393             if t > lasttime:
394                 continue
395             s = self.load(t)
396             if s:
397                 s.state = state
398                 rv.append(s)
399         return(0, rv)
400
401     def getmesgs(self, last):
402         rv = []
403         for m in self.files:
404             t = parse_time(m + '01-000000')
405             if t > last:
406                 continue
407             mon = self.load_month(open(self.dirname + '/' + m))
408             for mt in mon:
409                 if mt <= last:
410                     rv.append(mon[mt])
411             if rv:
412                 rv.sort(cmp = lambda x,y:cmp(y.stamp, x.stamp))
413                 return (t-1, rv)
414         return (0, [])
415
416     def load(self, t):
417         m = time.strftime("%Y%m", time.gmtime(t))
418         if not m in self.files:
419             return None
420         if self.cached_month != m:
421             self.cached_month = m
422             self.cache = self.load_month(open(self.dirname + '/' + m))
423         if t in self.cache:
424             return self.cache[t]
425         return None
426
427     def delete(self, msg):
428         if isinstance(msg, SMSmesg):
429             tm = msg.stamp
430         else:
431             tm = msg
432         m = time.strftime("%Y%m", time.gmtime(tm))
433         try:
434             f = open(self.dirname + '/' + m, "r+")
435         except:
436             return
437
438         fcntl.lockf(f, fcntl.LOCK_EX)
439         l = self.load_month(f)
440         if tm in l:
441             del l[tm]
442         self.store_month(l, m);
443         f.close()
444
445         def del1(l, tm):
446             if tm in l:
447                 l.remove(tm)
448                 return True
449             return False
450
451         self.drafts = self.load_list('draft', del1, tm)
452         self.newmesg = self.load_list('newmesg', del1, tm)
453
454     def setstate(self, msg, state):
455         tm = msg.stamp
456
457         def del1(l, tm):
458             if tm in l:
459                 l.remove(tm)
460                 return True
461             return False
462
463         if tm in self.drafts and state != 'DRAFT':
464             self.drafts = self.load_list('draft', del1, tm)
465         if tm in self.newmesg and state != 'NEW':
466             self.newmesg = self.load_list('newmesg', del1, tm)
467
468         if tm not in self.drafts and state == 'DRAFT':
469             f = open(self.dirname +'/draft', 'a')
470             fcntl.lockf(f, fcntl.LOCK_EX)
471             f.write(format_time(sms.stamp) + '\n')
472             f.close()
473             self.drafts.append(tm)
474             self.drafts.sort()
475             self.drafts.reverse()
476
477         if tm not in self.newmesg and state == 'NEW':
478             f = open(self.dirname +'/newmesg', 'a')
479             fcntl.lockf(f, fcntl.LOCK_EX)
480             f.write(format_time(sms.stamp) + '\n')
481             f.close()
482             self.newmesg.append(tm)
483             self.newmesg.sort()
484             self.newmesg.reverse()
485
486