]> git.neil.brown.name Git - freerunner.git/blob - sms/storesms.py
Lots of random updates
[freerunner.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     z = strg[-3:]
92     return time.strptime(strg[:-3], "%Y%m%d-%H%M%S")[0:6] + (int(z),)
93 def format_time(t):
94     return time.strftime("%Y%m%d-%H%M%S", time.gmtime(t))
95 def format_ltime(tm):
96     return time.strftime("%Y%m%d-%H%M%S", tm[0:6]+(0,0,0)) + ("%+03d" % tm[6])
97
98
99 class SMSmesg:
100     def __init__(self, **a):
101         if len(a) == 1 and 'line' in a:
102             # line read from a file, with 5 fields.
103             #  stamp, source, time, correspondent, text
104             line = a['line'].split()
105             self.stamp = parse_time(line[0])
106             self.source = line[1]
107             self.time = parse_ltime(line[2])
108             self.correspondents = []
109             for c in line[3].split(','):
110                 if c != '-':
111                     self.correspondents.append(urllib.unquote(c))
112             self.set_corresponent()
113             self.state = None
114
115             self.parts = None
116             txt = line[4]
117             if txt[0] != '<':
118                 self.text = urllib.unquote(txt)
119                 return
120             # multipart:   <1>text...<2>text...<3><4>
121             m = re.findall('<(\d+)>([^<]*)', txt)
122             parts = []
123             for (pos, strg) in m:
124                 p = int(pos)
125                 while len(parts) < p:
126                     parts.append(None)
127                 if strg:
128                     parts[p-1] = urllib.unquote(strg)
129             self.parts = parts
130             self.reduce_parts()
131         else:
132             self.stamp = int(time.time())
133             self.source = None
134             lt = time.localtime()
135             z = time.timezone/15/60
136             if lt[8] == 1:
137                 z -= 4
138             self.time = time.localtime()[0:6] + (z,)
139             self.correspondents = []
140             self.text = ""
141             self.state = None
142             self.ref = None
143             self.parts = None
144             part = None
145             for k in a:
146                 if k == 'stamp':
147                     self.stamp = a[k]
148                 elif k == 'source':
149                     self.source = a[k]
150                 elif k == 'time':
151                     # time can be a GSM string: 09/02/09,09:56:28+44 (ymd,HMS+z)
152                     # or a tuple (y,m,d,H,M,S,z)
153                     if type(a[k]) == str:
154                         t = a[k][:-3]
155                         z = a[k][-3:]
156                         tm = time.strptime(t, "%y/%m/%d,%H:%M:%S")
157                         self.time = tm[0:6] + (int(z),)
158                 elif k == 'to' or k == 'sender':
159                     if self.source == None:
160                         if k == 'to':
161                             self.source = 'LOCAL'
162                         if k == 'sender':
163                             self.source = 'GSM'
164                     self.correspondents = [ a[k] ]
165                 elif k == 'correspondents':
166                     self.correspondents = a[k]
167                 elif k == 'text':
168                     self.text = a[k]
169                 elif k == 'state':
170                     self.state = a[k]
171                 elif k == 'ref':
172                     if a[k] != None:
173                         self.ref = a[k]
174                 elif k == 'part':
175                     if a[k]:
176                         part = a[k]
177                 else:
178                     raise ValueError
179             if self.source == None:
180                 self.source = 'LOCAL'
181             if part:
182                 print 'part', part[0], part[1]
183                 self.parts = [None for x in range(part[1])]
184                 self.parts[part[0]-1] = self.text
185                 self.reduce_parts()
186             self.set_corresponent()
187
188         self.month_re = re.compile("^[0-9]{6}$")
189
190     def reduce_parts(self):
191         def reduce_pair(a,b):
192             if b == None:
193                 b = "...part of message missing..."
194             if a == None:
195                 return b
196             return a+b
197         self.text = reduce(reduce_pair, self.parts)
198
199
200     def set_corresponent(self):
201         if len(self.correspondents) == 1:
202             self.correspondent = self.correspondents[0]
203         elif len(self.correspondents) == 0:
204             self.correspondent = "Unsent"
205         else:
206             self.correspondent = "Multiple"
207
208     def format(self):
209         fmt =  "%s %s %s %s " % (format_time(self.stamp), self.source,
210                                    format_ltime(self.time),
211                                    self.format_correspondents())
212         if not self.parts:
213             return fmt + urllib.quote(self.text)
214
215         for i in range(len(self.parts)):
216             fmt += ("<%d>" % (i+1)) + urllib.quote(self.parts[i]) if self.parts[i] else ""
217         return fmt
218
219     def format_correspondents(self):
220         r = ""
221         for i in self.correspondents:
222             if i:
223                 r += ',' + urllib.quote(i)
224         if r:
225             return r[1:]
226         else:
227             return '-'
228         
229
230 class SMSstore:
231     def __init__(self, dir):
232         self.month_re = re.compile("^[0-9]{6}$")
233         self.cached_month = None
234         self.dirname = dir
235         # find message files
236         self.set_files()
237         self.drafts = self.load_list('draft')
238         self.newmesg = self.load_list('newmesg')
239
240     def load_list(self, name, update = None, *args):
241
242         l = []
243         try:
244             f = open(self.dirname + '/' + name, 'r+')
245         except IOError:
246             return l
247
248         if update:
249             fcntl.lockf(f, fcntl.LOCK_EX)
250         for ln in f:
251             l.append(parse_time(ln.strip()))
252         l.sort()
253         l.reverse()
254
255         if update and update(l, *args):
256             f2 = open(self.dirname + '/' + name + '.new', 'w')
257             for t in l:
258                 f2.write(format_time(t)+"\n")
259             f2.close()
260             os.rename(self.dirname + '/' + name + '.new',
261                       self.dirname + '/' + name)
262         f.close()
263         return l
264
265     def load_month(self, f):
266         # load the messages from f, which is open for read
267         rv = {}
268         for l in f:
269             l.strip()
270             m = SMSmesg(line=l)
271             rv[m.stamp] = m
272             if m.stamp in self.drafts:
273                 m.state = 'DRAFT'
274             elif m.stamp in self.newmesg:
275                 m.state = 'NEW'
276         return rv
277
278     def store_month(self, l, m):
279         dm = self.dirname + '/' + m
280         f = open(dm+'.new', 'w')
281         for s in l:
282             f.write(l[s].format() + "\n")
283         f.close()
284         os.rename(dm+'.new', dm)
285         if not m in self.files:
286             self.files.append(m)
287             self.files.sort()
288             self.files.reverse()
289         if self.cached_month == m:
290             self.cache = l
291
292     def store(self, sms):
293         orig = None
294         if sms.ref != None:
295             # This is part of a multipart.
296             # If there already exists part of this
297             # merge them together
298             #
299             times = self.load_list('multipart-' + sms.ref)
300             if len(times) == 1:
301                 orig = self.load(times[0])
302                 if orig and orig.parts:
303                     for i in range(len(sms.parts)):
304                         if sms.parts[i] == None and i < len(orig.parts):
305                             sms.parts[i] = orig.parts[i]
306                 else:
307                     orig = None
308                     
309         m = time.strftime("%Y%m", time.gmtime(sms.stamp))
310         try:
311             f = open(self.dirname + '/' + m, "r+")
312         except:
313             f = open(self.dirname + '/' + m, "w+")
314         complete = True
315         if sms.ref != None:
316             for i in sms.parts:
317                 if i == None:
318                     complete = False
319             if complete:
320                 sms.reduce_parts()
321                 sms.parts = None
322                 
323         fcntl.lockf(f, fcntl.LOCK_EX)
324         l = self.load_month(f)
325         while sms.stamp in l:
326             sms.stamp += 1
327         l[sms.stamp] = sms
328         self.store_month(l, m);
329         f.close()
330
331         if orig:
332             self.delete(orig)
333         if sms.ref != None:
334             if complete:
335                 try:
336                     os.unlink(self.dirname + '/multipart-' + sms.ref)
337                 except:
338                     pass
339             elif orig:
340                 def replacewith(l, tm):
341                     while len(l):
342                         l.pop()
343                     l.append(tm)
344                     return True
345                 self.load_list('multipart-' + sms.ref, replacewith, sms.stamp)
346             else:
347                 f = open(self.dirname +'/multipart-' + sms.ref, 'w')
348                 fcntl.lockf(f, fcntl.LOCK_EX)
349                 f.write(format_time(sms.stamp) + '\n')
350                 f.close()
351
352         if sms.state == 'NEW' or sms.state == 'DRAFT':
353             s = 'newmesg'
354             if sms.state == 'DRAFT':
355                 s = 'draft'
356             f = open(self.dirname +'/' + s, 'a')
357             fcntl.lockf(f, fcntl.LOCK_EX)
358             f.write(format_time(sms.stamp) + '\n')
359             f.close()
360         elif sms.state != None:
361             raise ValueError
362
363     def set_files(self):
364         self.files = []
365         for f in os.listdir(self.dirname):
366             if self.month_re.match(f):
367                 self.files.append(f)
368         self.files.sort()
369         self.files.reverse()
370         
371     def lookup(self, lasttime = None, state = None):
372         if lasttime == None:
373             lasttime = int(time.time())
374         if state == None:
375             return self.getmesgs(lasttime)
376         if state == 'DRAFT':
377             self.drafts = self.load_list('draft')
378             times = self.drafts
379         elif state == 'NEW':
380             self.newmesg = self.load_list('newmesg')
381             times = self.newmesg
382         else:
383             raise ValueError
384
385         self.set_files()
386         self.cached_month = None
387         self.cache = None
388         rv = []
389         for t in times:
390             if t > lasttime:
391                 continue
392             s = self.load(t)
393             if s:
394                 s.state = state
395                 rv.append(s)
396         return(0, rv)
397
398     def getmesgs(self, last):
399         rv = []
400         for m in self.files:
401             t = parse_time(m + '01-000000')
402             if t > last:
403                 continue
404             mon = self.load_month(open(self.dirname + '/' + m))
405             for mt in mon:
406                 if mt <= last:
407                     rv.append(mon[mt])
408             if rv:
409                 rv.sort(cmp = lambda x,y:cmp(y.stamp, x.stamp))
410                 return (t-1, rv)
411         return (0, [])
412
413     def load(self, t):
414         m = time.strftime("%Y%m", time.gmtime(t))
415         if not m in self.files:
416             return None
417         if self.cached_month != m:
418             self.cached_month = m
419             self.cache = self.load_month(open(self.dirname + '/' + m))
420         if t in self.cache:
421             return self.cache[t]
422         return None
423
424     def delete(self, msg):
425         if isinstance(msg, SMSmesg):
426             tm = msg.stamp
427         else:
428             tm = msg
429         m = time.strftime("%Y%m", time.gmtime(tm))
430         try:
431             f = open(self.dirname + '/' + m, "r+")
432         except:
433             return
434
435         fcntl.lockf(f, fcntl.LOCK_EX)
436         l = self.load_month(f)
437         if tm in l:
438             del l[tm]
439         self.store_month(l, m);
440         f.close()
441
442         def del1(l, tm):
443             if tm in l:
444                 l.remove(tm)
445                 return True
446             return False
447
448         self.drafts = self.load_list('draft', del1, tm)
449         self.newmesg = self.load_list('newmesg', del1, tm)
450
451     def setstate(self, msg, state):
452         tm = msg.stamp
453
454         def del1(l, tm):
455             if tm in l:
456                 l.remove(tm)
457                 return True
458             return False
459
460         if tm in self.drafts and state != 'DRAFT':
461             self.drafts = self.load_list('draft', del1, tm)
462         if tm in self.newmesg and state != 'NEW':
463             self.newmesg = self.load_list('newmesg', del1, tm)
464
465         if tm not in self.drafts and state == 'DRAFT':
466             f = open(self.dirname +'/draft', 'a')
467             fcntl.lockf(f, fcntl.LOCK_EX)
468             f.write(format_time(sms.stamp) + '\n')
469             f.close()
470             self.drafts.append(tm)
471             self.drafts.sort()
472             self.drafts.reverse()
473
474         if tm not in self.newmesg and state == 'NEW':
475             f = open(self.dirname +'/newmesg', 'a')
476             fcntl.lockf(f, fcntl.LOCK_EX)
477             f.write(format_time(sms.stamp) + '\n')
478             f.close()
479             self.newmesg.append(tm)
480             self.newmesg.sort()
481             self.newmesg.reverse()
482
483