匿名提问箱
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

275 lines
7.7 KiB

3 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
3 years ago
2 years ago
3 years ago
2 years ago
3 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
3 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
3 years ago
2 years ago
2 years ago
3 years ago
3 years ago
3 years ago
2 years ago
3 years ago
2 years ago
2 years ago
2 years ago
3 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
  1. from flask import Flask, request, render_template, send_from_directory, abort, redirect
  2. from flask_sqlalchemy import SQLAlchemy
  3. from flask_limiter import Limiter
  4. from flask_limiter.util import get_remote_address
  5. from flask_migrate import Migrate
  6. from mastodon import Mastodon
  7. import re
  8. import random
  9. import string
  10. import datetime
  11. from dateutil.tz import tzlocal
  12. BOT_NAME = '@ask_me_bot'
  13. CLIENT_ID = 'WQHzKKvfahkkcFm_iErT6ZdYvczi8L6Uunsoa88bCKA'
  14. CLIENT_SEC = open('client.secret', 'r').read().strip()
  15. DOMAIN = 'thu.closed.social'
  16. MENTION_BOT_TEMP = re.compile(r'<span class=\"h-card\"><a href=\"https://thu.closed.social/@ask_me_bot\" class=\"u-url mention\">@<span>.*?</span></a></span>')
  17. DELETE_TEMP = re.compile(r'<p>\s*删除\s*</p>')
  18. WORK_URL = 'https://closed.social'
  19. # WORK_URL = 'http://127.0.0.1:5000'
  20. REDIRECT_URI = WORK_URL + '/askMe/auth'
  21. token = open('token.secret', 'r').read().strip()
  22. th = Mastodon(
  23. access_token=token,
  24. api_base_url='https://' + DOMAIN
  25. )
  26. app = Flask(__name__)
  27. app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///ask.db'
  28. app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
  29. app.config['JSON_AS_ASCII'] = False
  30. limiter = Limiter(
  31. app,
  32. key_func=get_remote_address,
  33. default_limits=["50 / minute"],
  34. )
  35. db = SQLAlchemy(app)
  36. migrate = Migrate(app, db)
  37. class User(db.Model):
  38. id = db.Column(db.Integer, primary_key=True)
  39. acct = db.Column(db.String(64))
  40. disp = db.Column(db.String(64))
  41. avat = db.Column(db.String(256))
  42. url = db.Column(db.String(128))
  43. secr = db.Column(db.String(16))
  44. root = db.Column(db.BigInteger)
  45. def __init__(self, acct):
  46. self.acct = acct
  47. def __repr__(self):
  48. return f"@{self.acct}[{self.disp}]"
  49. class Question(db.Model):
  50. id = db.Column(db.Integer, primary_key=True)
  51. acct = db.Column(db.String(64))
  52. content = db.Column(db.String(400))
  53. time = db.Column(db.DateTime)
  54. toot = db.Column(db.BigInteger)
  55. def __init__(self, acct, content, toot):
  56. self.acct = acct
  57. self.content = content
  58. self.toot = toot
  59. self.time = datetime.datetime.now()
  60. def __repr__(self):
  61. return f"[{self.acct}]{self.content}({self.toot})"
  62. @app.route('/js/<path:path>')
  63. def send_js(path):
  64. return send_from_directory('static/js', path)
  65. @app.route('/img/<path:path>')
  66. def send_img(path):
  67. return send_from_directory('static/img', path)
  68. @app.route('/askMe/')
  69. def root():
  70. return app.send_static_file('ask.html')
  71. @app.route('/askMe/footer.html')
  72. def root_footer():
  73. return app.send_static_file('footer.html')
  74. @app.route('/askMe/auth')
  75. @limiter.limit("10 / minute")
  76. def set_inbox_auth():
  77. code = request.args.get('code')
  78. autoSend = request.args.get('autoSend')
  79. secr = request.args.get('secr')
  80. if secr and not re.match('[a-z]{0,16}', secr):
  81. abort(422)
  82. client = Mastodon(
  83. client_id=CLIENT_ID,
  84. client_secret=CLIENT_SEC,
  85. api_base_url='https://' + DOMAIN
  86. )
  87. client.log_in(
  88. code=code,
  89. redirect_uri=f"{REDIRECT_URI}?autoSend={autoSend or ''}&secr={secr or ''}",
  90. scopes=[
  91. 'read:accounts',
  92. 'write:statuses'
  93. ] if autoSend else ['read:accounts']
  94. )
  95. info = client.account_verify_credentials()
  96. acct = info.acct
  97. u = User.query.filter_by(acct=acct).first()
  98. if not u:
  99. u = User(acct)
  100. db.session.add(u)
  101. u.secr = secr or u.secr or ''.join(random.choice(
  102. string.ascii_lowercase) for i in range(16))
  103. u.disp = info.display_name
  104. u.url = info.url
  105. u.avat = info.avatar
  106. if autoSend:
  107. client.status_post(
  108. f"[自动发送] 我创建了一个匿名提问箱,欢迎提问~\n{WORK_URL}/askMe/{acct}/{u.secr}",
  109. visibility='public')
  110. db.session.commit()
  111. return redirect(f"/askMe/{acct}/{u.secr}")
  112. @app.route('/askMe/inbox', methods=['POST'])
  113. @limiter.limit("10 / minute")
  114. def set_inbox():
  115. acct = request.form.get('username')
  116. if not re.match('[A-Za-z0-9_]{1,30}(@[a-z\\.-_]+)?', acct):
  117. return '无效的闭社id', 422
  118. r = th.conversations()
  119. for conv in r:
  120. status = conv.last_status
  121. account = status.account
  122. if acct == account.acct:
  123. pt = status.content.strip()
  124. x = re.findall(r'新建(\[[a-z]{1,32}\])?', pt)
  125. if not x:
  126. return '私信格式无效,请检查并重新发送', 422
  127. secr = x[0][1:-1] if x[0] else ''.join(
  128. random.choice(string.ascii_lowercase) for i in range(16))
  129. u = User.query.filter_by(acct=acct).first()
  130. if not u:
  131. u = User(acct)
  132. db.session.add(u)
  133. u.disp = account.display_name
  134. u.url = account.url
  135. u.avat = account.avatar
  136. u.secr = secr
  137. db.session.commit()
  138. th.status_post(f"@{acct} 设置成功! 当前提问箱链接 {WORK_URL}/askMe/{acct}/{secr}\n(如需在微信等无链接预览的平台分享,建议先发给自己,点开,再点击分享到朋友圈等)",
  139. in_reply_to_id=status.id,
  140. visibility='direct'
  141. )
  142. return acct + '/' + secr
  143. return '未找到私信,请确认已发送且是最近发送', 404
  144. @app.route('/askMe/<acct>/<secr>/')
  145. def inbox(acct, secr):
  146. u = User.query.filter_by(acct=acct, secr=secr).first_or_404()
  147. qs = [{
  148. 'content': q.content,
  149. 'toot': q.toot,
  150. 'time': q.time.replace(tzinfo=tzlocal())
  151. } for q in Question.query.filter_by(acct=acct).all()
  152. ]
  153. return render_template('inbox.html', acct=u.acct, disp=(
  154. u.disp or u.acct), url=u.url, avat=u.avat, qs=qs)
  155. @app.route('/askMe/<acct>/<secr>/new', methods=['POST'])
  156. @limiter.limit("50 / hour; 1 / 2 second")
  157. def new_question(acct, secr):
  158. u = User.query.filter_by(acct=acct, secr=secr).first()
  159. if not u:
  160. abort(404)
  161. content = request.form.get('question')
  162. if not content or len(content) > 400:
  163. abort(422)
  164. if not Question.query.filter_by(acct=acct, content=content).first():
  165. if not u.root:
  166. toot = th.status_post(
  167. f"@{acct} 欢迎使用匿名提问箱。未来的新提问会集中显示在这里,方便管理。戳我头像了解如何回复。",
  168. visibility='direct')
  169. u.root = toot.id
  170. toot = th.status_post(f"@{acct} 叮~ 有新提问:\n\n{content}",
  171. in_reply_to_id=u.root,
  172. visibility='direct'
  173. )
  174. q = Question(acct, content, toot.id)
  175. db.session.add(q)
  176. db.session.commit()
  177. return redirect(".")
  178. def render_content(text, emojis):
  179. text = MENTION_BOT_TEMP.sub('', text)
  180. for emoji in emojis:
  181. text = text.replace(':%s:' % emoji.shortcode, '<img class="emoji" src="%s">' % emoji.url)
  182. return text
  183. @app.route('/askMe/<acct>/<secr>/<int:toot>')
  184. def question_info(acct, secr, toot):
  185. q = Question.query.filter_by(acct=acct, toot=toot).first()
  186. if not q or not User.query.filter_by(acct=acct, secr=secr).first():
  187. abort(404)
  188. context = th.status_context(toot)
  189. replies = [
  190. {
  191. 'disp': (t.account.display_name or t.account.acct),
  192. 'url': t.account.url,
  193. 'content': render_content(t.content, t.emojis),
  194. 'time': str(t.created_at),
  195. 'media': t.media_attachments,
  196. }
  197. for t in context.descendants
  198. ]
  199. if replies and DELETE_TEMP.match(replies[-1].get('content')):
  200. db.session.delete(q)
  201. db.session.commit()
  202. th.status_delete(toot)
  203. return '该提问已被删除', 404
  204. return {'replies': replies}
  205. if __name__ == '__main__':
  206. app.run(debug=True)