ニジカのスカトロ,ニジカトロ. https://bsky.app/profile/deerjika-bot.bsky.social
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.

313 lines
12 KiB

  1. from __future__ import annotations
  2. import asyncio
  3. import os
  4. import time
  5. from datetime import datetime
  6. from io import BytesIO
  7. from typing import Any, TypedDict
  8. import atproto # type: ignore
  9. import requests
  10. from atproto import Client # type: ignore
  11. from atproto_client.models import AppBskyEmbedExternal, AppBskyEmbedImages # type: ignore
  12. from atproto_client.models.app.bsky.feed.get_timeline import Response # type: ignore
  13. from atproto_client.models.app.bsky.feed.post import ReplyRef # type: ignore
  14. from requests.exceptions import Timeout
  15. import account
  16. import nicolib
  17. from nizika_ai.consts import Character, GPTModel, Platform, QueryType
  18. from nizika_ai.models import Answer, AnsweredFlag, Query, User
  19. TARGET_WORDS = ['deerjika', 'ニジカ', 'ぼっち', '虹夏', '郁代', 'バーカ',
  20. 'kfif', 'kita-flatten-ikuyo-flatten', 'ラマ田', 'ゴートう',
  21. 'ぼざクリ', 'オオミソカ', '伊地知', '喜多ちゃん',
  22. '喜タイ', '洗澡鹿', 'シーザオ', '今日は本当に',
  23. 'ダイソーで', '変なチンチン', 'daisoで', 'だね~(笑)',
  24. 'おやつタイム', 'わさしが', 'わさび県', 'たぬマ', 'にくまる',
  25. 'ルイズマリー', '餅', 'ニジゴ', 'ゴニジ', 'ニジニジ',
  26. '新年だよね', 'うんこじゃん', 'ほくほくのジャガイモ']
  27. time.sleep (60)
  28. client = Client (base_url = 'https://bsky.social')
  29. client.login (account.USER_ID, account.PASSWORD)
  30. async def main (
  31. ) -> None:
  32. """
  33. メーン処理
  34. """
  35. await asyncio.gather (like_posts (),
  36. check_mentions (),
  37. answer ())
  38. async def like_posts (
  39. ) -> None:
  40. while True:
  41. try:
  42. for post in fetch_target_posts ():
  43. client.like (**post)
  44. except Exception as e:
  45. print (f"[like_posts] { type (e).__name__ }: { e }")
  46. await asyncio.sleep (60)
  47. async def check_mentions (
  48. ) -> None:
  49. while True:
  50. try:
  51. for uri in check_notifications ():
  52. records = fetch_thread_contents (uri, 20)
  53. if records:
  54. record = records[0]
  55. image_url: str | None = None
  56. if record['embed'] and hasattr (record['embed'], 'images'):
  57. image_url = ('https://cdn.bsky.app/img/feed_fullsize/plain'
  58. f"/{ record['did'] }"
  59. f"/{ record['embed'].images[0].image.ref.link }")
  60. user = _fetch_user (record['did'], record['name'])
  61. _add_query (user, record['text'], image_url, {
  62. 'uri': record['strong_ref']['uri'],
  63. 'cid': record['strong_ref']['cid'] })
  64. except Exception as e:
  65. print (f"[check_mentions] { type (e).__name__ }: { e }")
  66. await asyncio.sleep (60)
  67. async def answer (
  68. ) -> None:
  69. while True:
  70. answered_flags = (
  71. AnsweredFlag
  72. .where ('platform', Platform.BLUESKY.value)
  73. .where ('answered', False)
  74. .get ())
  75. for answered_flag in answered_flags:
  76. td: dict[str, Any]
  77. answer = answered_flag.answer
  78. match QueryType (answer.query_rel.query_type):
  79. case QueryType.BLUESKY_COMMENT:
  80. td = answer.query_rel.transfer_data or { }
  81. uri: str | None = td.get ('uri')
  82. cid: str | None = td.get ('cid')
  83. if (not uri) or (not cid):
  84. continue
  85. sref = { 'uri': uri, 'cid': cid }
  86. strong_ref = atproto.models.create_strong_ref (sref) # type: ignore
  87. reply_ref = ReplyRef (root = strong_ref, parent = strong_ref)
  88. try:
  89. client.post (answer.content, reply_to = reply_ref)
  90. except Exception as e:
  91. print (f"[answer/reply] { type (e).__name__ }: { e }")
  92. continue
  93. answered_flag.answered = True
  94. answered_flag.save ()
  95. case QueryType.KIRIBAN | QueryType.NICO_REPORT:
  96. td = answer.query_rel.transfer_data or { }
  97. video_code: str | None = td.get ('video_code')
  98. if not video_code:
  99. continue
  100. uri = f"https://www.nicovideo.jp/watch/{ video_code }"
  101. (title, description, thumbnail) = nicolib.fetch_embed_info (uri)
  102. try:
  103. resp = requests.get (thumbnail, timeout = 60)
  104. resp.raise_for_status ()
  105. upload = client.com.atproto.repo.upload_blob (BytesIO (resp.content))
  106. thumb = upload.blob
  107. except Timeout:
  108. thumb = None
  109. except Exception as e:
  110. print (f"[answer/nico-thumb] { type (e).__name__ }: { e }")
  111. thumb = None
  112. external = AppBskyEmbedExternal.External (
  113. title = title,
  114. description = description,
  115. thumb = thumb,
  116. uri = uri)
  117. embed_external = AppBskyEmbedExternal.Main (external = external)
  118. try:
  119. client.post (answer.content, embed = embed_external)
  120. except Exception as e:
  121. print (f"[answer/nico-post] { type (e).__name__ }: { e }")
  122. continue
  123. answered_flag.answered = True
  124. answered_flag.save ()
  125. case QueryType.SNACK_TIME:
  126. try:
  127. with open ('./assets/snack-time.jpg', 'rb') as f:
  128. image = AppBskyEmbedImages.Image (
  129. alt = (
  130. '左に喜多ちゃん、右に人面鹿のニジカが'
  131. 'V字に並んでいる。'
  132. '喜多ちゃんは右手でピースサインをして'
  133. '片目をウインクしている。'
  134. 'ニジカは両手を広げ、'
  135. '右手にスプーンを持って'
  136. 'ポーズを取っている。'
  137. '背景には'
  138. '赤と黄色の放射線状の模様が広がり、'
  139. '下部に「おやつタイムだ!!!!」という'
  140. '日本語のテキストが表示されている。'),
  141. image = client.com.atproto.repo.upload_blob (f).blob)
  142. client.post (answer.content,
  143. embed = AppBskyEmbedImages.Main (images = [image]))
  144. answered_flag.answered = True
  145. answered_flag.save ()
  146. except Exception:
  147. pass
  148. case QueryType.HOT_SPRING:
  149. try:
  150. with open ('./assets/hot-spring.jpg', 'rb') as f:
  151. image = AppBskyEmbedImages.Image (
  152. alt = ('左に喜多ちゃん、右にわさび県産滋賀県が'
  153. 'V字に並んでいる。'
  154. '喜多ちゃんは右手でピースサインをして'
  155. '片目をウインクしている。'
  156. 'わさび県産滋賀県はただ茫然と'
  157. '立ち尽くしている。'
  158. '背景には'
  159. '血と空の色をした放射線状の模様が広がり、'
  160. '下部に「温泉に入ろう!!!」という'
  161. '日本語のテキストが表示されている。'),
  162. image = client.com.atproto.repo.upload_blob (f).blob)
  163. client.post (answer.content,
  164. embed = AppBskyEmbedImages.Main (images = [image]))
  165. answered_flag.answered = True
  166. answered_flag.save ()
  167. except Exception:
  168. pass
  169. await asyncio.sleep (10)
  170. def check_notifications (
  171. ) -> list[str]:
  172. uris: list[str] = []
  173. last_seen_at = client.get_current_time_iso ()
  174. notifications = client.app.bsky.notification.list_notifications ()
  175. for notification in notifications.notifications:
  176. if not notification.is_read:
  177. match notification.reason:
  178. case 'mention' | 'reply' | 'quote':
  179. uris.append (notification.uri)
  180. case 'follow':
  181. client.follow (notification.author.did)
  182. client.app.bsky.notification.update_seen ({ 'seen_at': last_seen_at })
  183. return uris
  184. def fetch_thread_contents (
  185. uri: str,
  186. parent_height: int,
  187. ) -> list[Record]:
  188. post_thread = client.get_post_thread (uri = uri, parent_height = parent_height)
  189. if not post_thread:
  190. return []
  191. res = post_thread.thread
  192. records: list[Record] = []
  193. while res:
  194. if hasattr (res, 'post'):
  195. records.append ({ 'strong_ref': { 'uri': res.post.uri,
  196. 'cid': res.post.cid },
  197. 'did': res.post.author.did,
  198. 'handle': res.post.author.handle,
  199. 'name': (res.post.author.display_name
  200. or res.post.author.handle),
  201. 'datetime': res.post.record.created_at,
  202. 'text': getattr (res.post.record, 'text', None) or '',
  203. 'embed': getattr (res.post.record, 'embed', None) })
  204. res = res.parent
  205. else:
  206. break
  207. return records
  208. def fetch_target_posts (
  209. ) -> list[LikeParams]:
  210. posts: list[LikeParams] = []
  211. timeline: Response = client.get_timeline ()
  212. for feed in timeline.feed:
  213. me = getattr (client, 'me', None)
  214. my_did = me.did if me else ''
  215. if (feed.post.author.did != my_did
  216. and (feed.post.viewer.like is None)
  217. and any (target_word in (feed.post.record.text or '').casefold ()
  218. for target_word in TARGET_WORDS)):
  219. posts.append (LikeParams ({ 'uri': feed.post.uri, 'cid': feed.post.cid }))
  220. return posts
  221. def _add_query (
  222. user: User,
  223. content: str,
  224. image_url: str | None = None,
  225. transfer_data: dict[str, Any] | None = None,
  226. ) -> None:
  227. query = Query ()
  228. query.user_id = user.id
  229. query.target_character = Character.DEERJIKA.value
  230. query.content = content
  231. query.image_url = image_url or None
  232. query.query_type = QueryType.BLUESKY_COMMENT.value
  233. query.model = GPTModel.GPT3_TURBO.value
  234. query.sent_at = datetime.now ()
  235. query.answered = False
  236. query.transfer_data = transfer_data
  237. query.save ()
  238. # TODO: 履歴情報の追加
  239. def _fetch_user (
  240. did: str,
  241. name: str,
  242. ) -> User:
  243. user = User.where ('platform', Platform.BLUESKY.value).where ('code', did).first ()
  244. if user is None:
  245. user = User ()
  246. user.platform = Platform.BLUESKY.value
  247. user.code = did
  248. user.name = name
  249. user.save ()
  250. return user
  251. class LikeParams (TypedDict):
  252. uri: str
  253. cid: str
  254. class Record (TypedDict):
  255. strong_ref: StrongRef
  256. did: str
  257. handle: str
  258. name: str
  259. datetime: str
  260. text: str
  261. embed: object
  262. class StrongRef (TypedDict):
  263. uri: str
  264. cid: str
  265. if __name__ == '__main__':
  266. asyncio.run (main ())