昵称中还是不要使用 empathy 显示行高不同的字符好了
[gaetalk.git] / lilytalk.py
blobdc8b8f0865bf615d4cf772a511999bf28dcc47ac
1 #!/usr/bin/env python2
2 # vim:fileencoding=utf-8
4 import re
5 import logging
6 import datetime
8 from google.appengine.ext import db
9 from google.appengine.api import xmpp
11 import utils
12 import config
14 notice = u'这是一个测试群'
15 helpre = re.compile(r'^\W{0,2}help$')
17 #用户所有资源离线时,会加上“完全”二字
18 OFFLINE = u'离线'
19 AWAY = u'离开'
20 XAWAY = u'离开'
21 BUSY = u'忙碌'
22 ONLINE = u'在线'
23 CHAT = u'和我说话吧'
25 NEW = u'加入'
26 LEAVE = u'退出'
27 NICK = u'昵称更改 (%s -> %s)'
28 SNOOZE = u'snooze %ds'
29 BLACK = u'禁言 %s %ds'
30 BLACK_AUTO = u'被禁言 %ds'
31 KICK = u'删除 %s (%s)'
32 ADMIN = u'%s 成为管理员 (by %s)'
33 UNADMIN = u'%s 不再是管理员 (by %s)'
35 STATUS_CODE = {
36 '': ONLINE,
37 'away': AWAY,
38 'dnd': BUSY,
39 'xa': XAWAY,
40 'chat': CHAT,
43 #状态的排序顺序
44 STATUS_LIST = [CHAT, ONLINE, AWAY, XAWAY, BUSY, OFFLINE]
46 timezone = datetime.timedelta(hours=config.timezoneoffset)
48 class User(db.Model):
49 jid = db.StringProperty(required=True, indexed=True)
50 nick = db.StringProperty(required=True, indexed=True)
52 add_date = db.DateTimeProperty(auto_now_add=True)
53 last_online_date = db.DateTimeProperty()
54 last_offline_date = db.DateTimeProperty()
55 last_speak_date = db.DateTimeProperty()
57 msg_count = db.IntegerProperty(required=True, default=0)
58 msg_chars = db.IntegerProperty(required=True, default=0)
59 credit = db.IntegerProperty(required=True, default=0)
61 black_before = db.DateTimeProperty(auto_now_add=True)
62 snooze_before = db.DateTimeProperty()
63 flooding_point = db.IntegerProperty(default=0)
65 avail = db.StringProperty(required=True)
66 is_admin = db.BooleanProperty(required=True, default=False)
67 resources = db.StringListProperty(required=True)
69 prefix = db.StringProperty(required=True, default=config.default_prefix)
70 nick_pattern = db.StringProperty(required=True, default='[%s]')
71 nick_changed = db.BooleanProperty(required=True, default=False)
72 intro = db.StringProperty()
74 class Log(db.Model):
75 time = db.DateTimeProperty(auto_now_add=True, indexed=True)
76 msg = db.StringProperty(required=True, multiline=True)
77 jid = db.StringProperty()
78 nick = db.StringProperty()
79 type = db.StringProperty(required=True, indexed=True,
80 choices=set(['chat', 'member', 'misc']))
82 def log_msg(sender, msg):
83 l = Log(jid=sender.jid, nick=sender.nick,
84 type='chat', msg=msg)
85 l.put()
87 def log_onoff(sender, action, resource=''):
88 if resource:
89 if action == OFFLINE and not sender.resources:
90 msg = u'完全%s (%s)' % (action, resource)
91 else:
92 msg = '%s (%s)' % (action, resource)
93 else:
94 msg = action
95 l = Log(jid=sender.jid, nick=sender.nick,
96 type='member', msg=msg)
97 l.put()
99 def get_user_by_jid(jid):
100 return User.gql('where jid = :1', jid).get()
102 def get_user_by_nick(nick):
103 return User.gql('where nick = :1', nick).get()
105 def get_member_list():
106 r = []
107 now = datetime.datetime.now()
108 #一个查询中最多只能有一个不等比较
109 l = User.gql('where avail != :1', OFFLINE)
110 for u in l:
111 r.append(u)
112 return [unicode(x.jid) for x in r \
113 if x.snooze_before is None or x.snooze_before < now]
115 def send_to_all_except(jid, message):
116 if isinstance(jid, str):
117 jids = [x for x in get_member_list() if x != jid]
118 else:
119 jids = [x for x in get_member_list() if x not in jid]
120 logging.debug(jid)
121 logging.debug(jids)
122 try:
123 xmpp.send_message(jids, message)
124 except xmpp.InvalidJidError:
125 pass
127 def send_to_all(message):
128 jids = get_member_list()
129 xmpp.send_message(jids, message)
131 def handle_message(msg):
132 sender = get_user_by_jid(msg.sender.split('/')[0])
133 if sender is None:
134 msg.reply('很抱歉,出错了,请重新添加好友。')
135 return
136 if msg.body.startswith('?OTR:'):
137 msg.reply('不支持 OTR 加密!')
138 return
139 if len(msg.body) > 500:
140 msg.reply('由于技术限制,每条消息最长为 500 字。大段文本请贴 paste 网站。')
141 return
142 if sender.is_admin or sender.jid == config.root:
143 ch = AdminCommand(msg, sender)
144 else:
145 ch = BasicCommand(msg, sender)
146 if not ch.handled:
147 now = datetime.datetime.now()
148 if sender.black_before is not None \
149 and sender.black_before > now:
150 if (datetime.datetime.today()+timezone).date() == \
151 (sender.black_before+timezone).date():
152 format = '%H时%M分%S秒'
153 else:
154 format = '%m月%d日 %H时%M分%S秒'
155 msg.reply('你已被禁言至 ' \
156 + (sender.black_before+timezone).strftime(format))
157 return
159 if sender.last_speak_date is not None:
160 d = now - sender.last_speak_date
161 t = d.seconds
162 if d.days > 0 or t > 60:
163 sender.flooding_point = 0
164 else:
165 k = 1000 / (t * t + 1)
166 if k > 0:
167 sender.flooding_point += k
168 else:
169 sender.flooding_point = 0
171 k = sender.flooding_point / 1500
172 if k > 0:
173 msg.reply('刷屏啊?禁言 %d 分钟!' % k)
174 send_to_all_except(sender.jid,
175 (u'%s 已因刷屏而被禁言 %d 分钟。' % (sender.nick, k)) \
176 .encode('utf-8'))
177 log_onoff(sender, BLACK_AUTO % (60 * k))
178 sender.black_before = now + datetime.timedelta(seconds=60*k)
179 sender.put()
180 return
182 sender.last_speak_date = now
183 sender.snooze_before = None
184 try:
185 sender.msg_count += 1
186 sender.msg_chars += len(msg.body)
187 except TypeError:
188 sender.msg_count = 1
189 sender.msg_chars = len(msg.body)
190 sender.put()
191 body = utils.removelinks(msg.body)
192 for u in User.gql('where avail != :1', OFFLINE):
193 if u.snooze_before is not None and u.snooze_before >= now:
194 continue
195 if u.jid == sender.jid:
196 continue
197 try:
198 message = '%s %s' % (
199 u.nick_pattern % sender.nick,
200 body
202 xmpp.send_message(u.jid, message)
203 except xmpp.InvalidJidError:
204 pass
205 log_msg(sender, msg.body)
207 def try_add_user(jid, show=OFFLINE, resource=''):
208 '''使用 memcache 作为锁添加用户'''
209 L = utils.MemLock('add_user')
210 L.require()
211 try:
212 u = get_user_by_jid(jid)
213 if u is not None:
214 return
215 u = add_user(jid, show, resource)
216 finally:
217 L.release()
218 if show != OFFLINE:
219 log_onoff(u, show, resource)
220 logging.info(u'%s added', jid)
222 def add_user(jid, show=OFFLINE, resource=''):
223 '''resource 在 presence type 为 available 里使用'''
224 nick = jid.split('@')[0]
225 old = User.gql('where nick = :1', nick).get()
226 while old:
227 nick += '_'
228 old = User.gql('where nick = :1', nick).get()
229 u = User(jid=jid, avail=show, nick=nick)
230 if show != OFFLINE:
231 u.last_online_date = datetime.datetime.now()
232 if resource:
233 u.resources.append(resource)
234 u.put()
235 log_onoff(u, NEW)
236 logging.info(u'%s 已经加入' % jid)
237 send_to_all_except(jid, u'%s 已经加入' % u.nick)
238 xmpp.send_presence(jid, status=notice)
239 xmpp.send_message(jid, u'欢迎 %s 加入!获取使用帮助,请输入 %shelp' % (
240 u.nick, u.prefix))
241 return u
243 class BasicCommand:
244 handled = True
245 def __init__(self, msg, sender):
246 self.sender = sender
247 self.msg = msg
249 if helpre.match(msg.body):
250 self.do_help()
251 elif msg.body.startswith(sender.prefix):
252 cmd = msg.body[len(sender.prefix):].split()
253 try:
254 handle = getattr(self, 'do_' + cmd[0])
255 except AttributeError:
256 msg.reply(u'错误:未知命令 %s' % cmd[0])
257 except IndexError:
258 msg.reply(u'错误:无命令')
259 except UnicodeEncodeError:
260 msg.reply(u'错误:命令名解码失败。此问题在 GAE 升级其 Python 到 3.x 后方能解决。')
261 else:
262 handle(cmd[1:])
263 logging.debug('%s did command %s' % (sender.jid, msg.body))
264 else:
265 self.handled = False
267 def do_online(self, args):
268 '''显示在线成员列表'''
269 r = []
270 now = datetime.datetime.now()
271 l = User.gql('where avail != :1', OFFLINE)
272 for u in l:
273 m = u.nick
274 status = u.avail
275 if status != u'在线':
276 m += u' (%s)' % status
277 if u.snooze_before is not None and u.snooze_before > now:
278 m += u' (snoozing)'
279 if u.black_before is not None and u.black_before > now:
280 m += u' (已禁言)'
281 r.append(unicode('* ' + m))
282 r.sort()
283 n = len(r)
284 r.insert(0, u'在线成员列表:')
285 r.append(u'共 %d 人在线。' % n)
286 self.msg.reply(u'\n'.join(r).encode('utf-8'))
288 def do_chatty(self, args):
289 '''显示成员发送的消息数量'''
290 r = []
291 for u in User.gql('ORDER BY msg_chars ASC'):
292 m = u.nick
293 m = u'* %s:\t%5d条,共 %s' % (
294 u.nick, u.msg_count,
295 utils.filesize(u.msg_chars))
296 r.append(m)
297 n = len(r)
298 r.insert(0, u'消息数量排行:')
299 r.append(u'共 %d 人。' % n)
300 self.msg.reply(u'\n'.join(r).encode('utf-8'))
302 def do_nick(self, args):
303 '''更改昵称,需要一个参数'''
304 if len(args) != 1:
305 self.msg.reply('错误:请给出你想到的昵称(不能包含空格)')
306 return
308 q = User.gql('where nick = :1', args[0]).get()
309 if q is not None:
310 self.msg.reply('错误:该昵称已被使用,请使用其它昵称')
311 elif not utils.checkNick(args[0]):
312 self.msg.reply('错误:非法的昵称')
313 else:
314 if not config.nick_can_change and self.sender.nick_changed:
315 self.msg.reply('乖哦,你已经没机会再改昵称了')
316 return
317 old_nick = self.sender.nick
318 log_onoff(self.sender, NICK % (old_nick, args[0]))
319 self.sender.nick = args[0]
320 self.sender.nick_changed = True
321 self.sender.put()
322 send_to_all_except(self.sender.jid,
323 (u'%s 的昵称改成了 %s' % (old_nick, args[0])).encode('utf-8'))
324 self.msg.reply('昵称更改成功!')
325 do_nick.__doc__ += ',最长 %d 字节' % config.nick_maxlen
327 def do_help(self, args=None):
328 '''显示本帮助'''
329 doc = []
330 prefix = self.sender.prefix
331 for c, f in self.__class__.__dict__.items():
332 if c.startswith('do_'):
333 doc.append(u'%s%s:\t%s' % (prefix, c[3:], f.__doc__.decode('utf-8')))
334 for b in self.__class__.__bases__:
335 for c, f in b.__dict__.items():
336 if c.startswith('do_'):
337 doc.append(u'%s%s:\t%s' % (prefix, c[3:], f.__doc__.decode('utf-8')))
338 doc.sort()
339 doc.insert(0, u'命令指南 (当前命令前缀 %s,可设置)' % prefix)
340 doc.append(u'要离开,直接删掉好友即可。')
341 self.msg.reply(u'\n'.join(doc).encode('utf-8'))
343 def do_iam(self, args):
344 '''查看自己的信息'''
345 s = self.sender
346 r = u'昵称:\t\t%s\n消息数:\t\t%d\n消息总量:\t%s\n命令前缀:\t%s\n自我介绍:\t%s' % (
347 s.nick, s.msg_count, utils.filesize(s.msg_chars), s.prefix, s.intro)
348 self.msg.reply(r.encode('utf-8'))
350 def do_m(self, args):
351 '''给某人发私信,需要昵称和内容两个参数。私信不会以任何方式被记录。'''
352 if len(args) < 2:
353 self.msg.reply('请给出昵称和内容。')
354 return
356 target = get_user_by_nick(args[0])
357 if target is None:
358 self.msg.reply('Sorry,查无此人。')
359 return
361 msg = self.msg.body[len(self.sender.prefix):].split(None, 2)[-1]
362 msg = u'_私信_ %s %s' % (target.nick_pattern % self.sender.nick, msg)
363 if xmpp.send_message(target.jid, msg) == xmpp.NO_ERROR:
364 self.msg.reply(u'OK')
365 else:
366 self.msg.reply(u'消息发送失败')
368 def do_snooze(self, args):
369 '''暂停接收消息,参数为时间(默认单位为秒)。再次发送消息时自动清除'''
370 if len(args) != 1:
371 self.msg.reply('你想停止接收消息多久?')
372 return
373 else:
374 try:
375 n = utils.parseTime(args[0])
376 except ValueError:
377 self.msg.reply('Sorry,我无法理解你说的时间。')
378 return
380 try:
381 self.sender.snooze_before = datetime.datetime.now() + datetime.timedelta(seconds=n)
382 except OverflowError:
383 self.msg.reply('Sorry,你不能睡太久。')
384 return
385 self.sender.put()
386 if n == 0:
387 self.msg.reply('你已经醒来。')
388 else:
389 self.msg.reply('OK,停止接收消息 %d 秒。' % n)
390 log_onoff(self.sender, SNOOZE % n)
392 def do_offline(self, args):
393 '''让程序认为你的所有资源已离线。如在你离线时程序仍认为你在线,请使用此命令。'''
394 del self.sender.resources[:]
395 self.sender.avail = OFFLINE
396 self.sender.last_offline_date = datetime.datetime.now()
397 self.sender.put()
398 self.msg.reply('OK,在下次你说你在线之前我都认为你已离线。')
400 def do_old(self, args):
401 '''查询聊天记录,可选一个数字参数。默认为最后20条。特殊参数 OFFLINE 显示离线消息(最多 100 条)'''
402 s = self.sender
403 q = False
404 if not args:
405 q = Log.gql("WHERE type = 'chat' ORDER BY time DESC LIMIT 20")
406 elif len(args) == 1:
407 try:
408 n = int(args[0])
409 if n > 0:
410 q = Log.gql("WHERE type = 'chat' ORDER BY time DESC LIMIT %d" % n)
411 except ValueError:
412 if args[0].upper() == 'OFFLINE':
413 q = Log.gql("WHERE time < :1 AND time > :2 AND type = 'chat' ORDER BY time DESC LIMIT 100", s.last_online_date, s.last_offline_date)
414 else:
415 pass
416 if q is not False:
417 r = []
418 q = list(q)
419 q.reverse()
420 if q:
421 if datetime.datetime.today() - q[0].time > datetime.timedelta(hours=24):
422 show_date = True
423 else:
424 show_date = False
425 for l in q:
426 message = '%s %s %s' % (
427 utils.strftime(l.time, timezone, show_date),
428 s.nick_pattern % l.nick,
429 l.msg
431 r.append(message)
432 if r:
433 self.msg.reply(u'\n'.join(r).encode('utf-8'))
434 else:
435 self.msg.reply('没有符合的聊天记录。')
436 else:
437 self.msg.reply('Oops, 参数不正确哦。')
439 def do_set(self, args):
440 '''设置一些参数。参数格式 key=value;不带参数以查看说明。'''
441 #注意:选项名/值中不能包含空格
442 if len(args) != 1:
443 doc = []
444 for c, f in self.__class__.__dict__.items():
445 if c.startswith('set_'):
446 doc.append(u'* %s:\t%s' % (c[4:], f.__doc__.decode('utf-8')))
447 for b in self.__class__.__bases__:
448 for c, f in b.__dict__.items():
449 if c.startswith('set_'):
450 doc.append(u'* %s:\t%s' % (c[4:], f.__doc__.decode('utf-8')))
451 doc.sort()
452 doc.insert(0, u'设置选项:')
453 self.msg.reply(u'\n'.join(doc).encode('utf-8'))
454 else:
455 msg = self.msg
456 cmd = msg.body.split(None, 1)[1].split('=', 1)
457 if len(cmd) == 1:
458 msg.reply(u'错误:请给出选项值')
459 return
460 try:
461 handle = getattr(self, 'set_' + cmd[0])
462 except AttributeError:
463 msg.reply(u'错误:未知选项 %s' % cmd[0])
464 except IndexError:
465 msg.reply(u'错误:无选项')
466 except UnicodeEncodeError:
467 msg.reply(u'错误:选项名解码失败。此问题在 GAE 升级其 Python 到 3.x 后方能解决。')
468 else:
469 handle(cmd[1])
471 def set_prefix(self, arg):
472 '''设置命令前缀'''
473 self.sender.prefix = arg
474 self.sender.put()
475 self.msg.reply(u'设置成功!')
477 def set_nickpattern(self, arg):
478 '''设置昵称显示格式,用 %s 表示昵称的位置'''
479 try:
480 arg % 'test'
481 except (TypeError, ValueError):
482 self.msg.reply(u'错误:不正确的格式')
483 return
485 self.sender.nick_pattern = arg
486 self.sender.put()
487 self.msg.reply(u'设置成功!')
489 class AdminCommand(BasicCommand):
490 def do_kick(self, args):
491 '''删除某人。他仍可以重新加入。'''
492 if len(args) != 1:
493 self.msg.reply('请给出昵称。')
494 return
496 target = get_user_by_nick(args[0])
497 if target is None:
498 self.msg.reply('Sorry,查无此人。')
499 return
501 if target.jid == config.root:
502 self.msg.reply('不能删除 root 用户')
503 return
505 targetjid = target.jid
506 targetnick = target.nick
507 target.delete()
508 self.msg.reply((u'OK,删除 %s。' % target.nick).encode('utf-8'))
509 send_to_all_except(self.sender.jid, (u'%s 已被删除。' % self.sender.nick) \
510 .encode('utf-8'))
511 xmpp.send_message(targetjid, u'你已被管理员从此群中删除,请删除该好友。')
512 log_onoff(self.sender, KICK % (targetnick, targetjid))
514 def do_quiet(self, args):
515 '''禁言某人,参数为昵称和时间(默认单位秒)'''
516 if len(args) != 2:
517 self.msg.reply('请给出昵称和时间。')
518 return
519 else:
520 try:
521 n = utils.parseTime(args[1])
522 except ValueError:
523 self.msg.reply('Sorry,我无法理解你说的时间。')
524 return
526 target = get_user_by_nick(args[0])
527 if target is None:
528 self.msg.reply('Sorry,查无此人。')
529 return
531 target.black_before = datetime.datetime.now() + datetime.timedelta(seconds=n)
532 target.put()
533 self.msg.reply((u'OK,禁言 %s %d 秒。' % (target.nick, n)).encode('utf-8'))
534 send_to_all_except((self.sender.jid, target.jid),
535 (u'%s 已被禁言 %s 秒。' % (self.sender.nick, n)) \
536 .encode('utf-8'))
537 xmpp.send_message(target.jid, u'你已被管理员禁言 %d 秒。' % n)
538 log_onoff(self.sender, BLACK % (target.nick, n))
540 def do_admin(self, args):
541 '''将某人添加为管理员'''
542 if len(args) != 1:
543 self.msg.reply(u'请给出昵称。')
544 return
546 target = get_user_by_nick(args[0])
547 if target is None:
548 self.msg.reply(u'Sorry,查无此人。')
549 return
551 if target.is_admin:
552 self.msg.reply(u'%s 已经是管理员了。' % target.nick)
553 return
555 target.is_admin = True
556 target.put()
557 send_to_all_except(target.jid,
558 (u'%s 已成为管理员。' % target.nick) \
559 .encode('utf-8'))
560 xmpp.send_message(target.jid, u'你已是本群管理员。')
561 log_onoff(self.sender, ADMIN % (target.nick, self.sender.nick))
563 def do_unadmin(self, args):
564 '''取消某人管理员的权限'''
565 if len(args) != 1:
566 self.msg.reply(u'请给出昵称。')
567 return
569 target = get_user_by_nick(args[0])
570 if target is None:
571 self.msg.reply(u'Sorry,查无此人。')
572 return
574 if not target.is_admin:
575 self.msg.reply(u'%s 不是管理员。' % target.nick)
576 return
578 target.is_admin = False
579 target.put()
580 send_to_all_except(target.jid,
581 (u'%s 已不再是管理员。' % target.nick) \
582 .encode('utf-8'))
583 xmpp.send_message(target.jid, u'你已不再是本群管理员。')
584 log_onoff(self.sender, UNADMIN % (target.nick, self.sender.nick))