irispy-client 0.0.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: irispy-client
3
+ Version: 0.0.9
4
+ Summary: An Iris bot client in Python
5
+ Author-email: dolidolih <dolidolih@proton.me>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests
9
+ Requires-Dist: websockets
10
+ Requires-Dist: pillow
11
+ Requires-Dist: httpx
12
+
13
+ # Iris-py
14
+ Iris python websocket client based on Irispy2 and Kakaolink by github.com/ye-seola.
@@ -0,0 +1,2 @@
1
+ # Iris-py
2
+ Iris python websocket client based on Irispy2 and Kakaolink by github.com/ye-seola.
@@ -0,0 +1,6 @@
1
+ __version__ = "0.0.9"
2
+
3
+ from iris.bot import Bot
4
+ from iris.bot.models import ChatContext, Message, Room, User
5
+ from iris.kakaolink import IrisLink
6
+ from iris.util import PyKV
@@ -0,0 +1,115 @@
1
+ import json
2
+ import time
3
+ from dataclasses import dataclass
4
+ import typing as t
5
+
6
+ from websockets.sync.client import connect
7
+ from iris.bot._internal.emitter import EventEmitter
8
+ from iris.bot._internal.iris import IrisAPI, IrisRequest
9
+ from iris.bot.models import ChatContext, Message, Room, User
10
+
11
+ class Bot:
12
+ def __init__(self, iris_url: str, *, max_workers=None):
13
+ self.emitter = EventEmitter(max_workers=max_workers)
14
+
15
+ self.iris_url = iris_url.replace(
16
+ "http://",
17
+ "",
18
+ ).replace(
19
+ "https://",
20
+ "",
21
+ ).replace(
22
+ "ws://",
23
+ "",
24
+ ).replace(
25
+ "wss://",
26
+ "",
27
+ )
28
+ if self.iris_url.endswith("/"):
29
+ self.iris_url = self.iris_url[:-1]
30
+
31
+ self.iris_ws_endpoint = f"ws://{self.iris_url}/ws"
32
+ self.api = IrisAPI(f"http://{iris_url}")
33
+
34
+ def __process_chat(self, chat: ChatContext):
35
+ self.emitter.emit("chat", [chat])
36
+
37
+ origin = chat.message.v.get("origin")
38
+ if origin == "MSG":
39
+ self.emitter.emit("message", [chat])
40
+ elif origin == "NEWMEM":
41
+ self.emitter.emit("new_member", [chat])
42
+ elif origin == "DELMEM":
43
+ self.emitter.emit("del_member", [chat])
44
+
45
+ def __process_iris_request(self, req: IrisRequest):
46
+ v = {}
47
+ try:
48
+ v = json.loads(req.raw["v"])
49
+ except Exception:
50
+ pass
51
+
52
+ room = Room(
53
+ id=int(req.raw["chat_id"]),
54
+ name=req.room,
55
+ api=self.api,
56
+ )
57
+ sender = User(
58
+ id=int(req.raw["user_id"]),
59
+ chat_id=room.id,
60
+ api=self.api,
61
+ name=req.sender,
62
+ bot_id=self.bot_id,
63
+ )
64
+ message = Message(
65
+ id=int(req.raw["id"]),
66
+ type=int(req.raw["type"]),
67
+ msg=req.raw["message"],
68
+ attachment=req.raw["attachment"],
69
+ v=v,
70
+ )
71
+
72
+ chat = ChatContext(
73
+ room=room, sender=sender, message=message, raw=req.raw, api=self.api, _bot_id=self.bot_id
74
+ )
75
+ self.__process_chat(chat)
76
+
77
+ def run(self):
78
+ while True:
79
+ try:
80
+ with connect(self.iris_ws_endpoint, close_timeout=0) as ws:
81
+ print("웹소켓에 연결되었습니다")
82
+ self.bot_id = self.api.get_info()["bot_id"]
83
+ while True:
84
+ recv = ws.recv()
85
+ try:
86
+ data: dict = json.loads(recv)
87
+ data["raw"] = data.get("json")
88
+ del data["json"]
89
+
90
+ self.__process_iris_request(IrisRequest(**data))
91
+ except Exception as e:
92
+ print(
93
+ "Iris 이벤트를 처리 중 오류가 발생했습니다: {}", e
94
+ )
95
+ except KeyboardInterrupt:
96
+ print("웹소켓 연결을 종료합니다")
97
+ break
98
+
99
+ except Exception as e:
100
+ print("웹소켓 연결 오류: {}", e)
101
+ print("3초 후 재연결합니다")
102
+
103
+ time.sleep(3)
104
+
105
+ def on_event(self, name: str):
106
+ def decorator(func: t.Callable):
107
+ self.emitter.register(name, func)
108
+
109
+ def wrapper(*args, **kwargs):
110
+ return func(*args, **kwargs)
111
+
112
+ return wrapper
113
+
114
+ return decorator
115
+
File without changes
@@ -0,0 +1,46 @@
1
+ import concurrent.futures
2
+ import traceback
3
+ import typing as t
4
+ from iris.bot.models import ErrorContext
5
+ from iris.util.pykv import PyKV
6
+ import sys
7
+
8
+
9
+ class EventEmitter:
10
+ def __init__(self, max_workers=None):
11
+ self.ev: dict[str, list[t.Callable]] = {}
12
+ self.pool = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers)
13
+
14
+ def register(self, name: str, func: t.Callable):
15
+ name = name.lower()
16
+
17
+ if name not in self.ev:
18
+ self.ev[name] = []
19
+
20
+ self.ev[name].append(func)
21
+
22
+ def emit(self, name: str, args: list[t.Any]):
23
+ name = name.lower()
24
+
25
+ for func in self.ev.get(name, []):
26
+ self.pool.submit(self._handle_event, func, name, args)
27
+
28
+ def _handle_event(self, func, name, args):
29
+ try:
30
+ kv = PyKV()
31
+ func(*args)
32
+ except Exception as e:
33
+ if name == "error":
34
+ print(f"error handler에서 오류가 발생했습니다 ({e})")
35
+ traceback.print_exc()
36
+ return
37
+ else:
38
+ print(f"오류가 발생했습니다 ({e})")
39
+ traceback.print_exc()
40
+
41
+ self.emit(
42
+ "error", [ErrorContext(event=name, func=func, exception=e, args=args)]
43
+ )
44
+ finally:
45
+ kv.close()
46
+ sys.stdout.flush()
@@ -0,0 +1,113 @@
1
+ from dataclasses import dataclass
2
+ import requests
3
+ import typing as t
4
+ import base64
5
+ from io import BufferedIOBase, BytesIO, BufferedReader
6
+ from PIL import Image
7
+
8
+ @dataclass
9
+ class IrisRequest:
10
+ msg: str
11
+ room: str
12
+ sender: str
13
+ raw: dict
14
+
15
+ class IrisAPI:
16
+ def __init__(self, iris_endpoint: str):
17
+ self.iris_endpoint = iris_endpoint
18
+
19
+ def __parse(self, res: requests.Response) -> dict:
20
+ try:
21
+ data: dict = res.json()
22
+ except Exception:
23
+ raise Exception(f"Iris 응답 JSON 파싱 오류: {res.text}")
24
+
25
+ if not 200 <= res.status_code <= 299:
26
+ print(f"Iris 오류: {res}")
27
+ raise Exception(f"Iris 오류: {data.get('message', '알 수 없는 오류')}")
28
+
29
+ return data
30
+
31
+ def reply(self, room_id: int, msg: str):
32
+ res = requests.post(
33
+ f"{self.iris_endpoint}/reply",
34
+ json={"type": "text", "room": str(room_id), "data": str(msg)},
35
+ )
36
+ return self.__parse(res)
37
+
38
+ def reply_media(
39
+ self,
40
+ room_id: int,
41
+ files: t.List[BufferedIOBase | bytes | Image.Image | str],
42
+ ):
43
+ if type(files) is not list:
44
+ files = [files]
45
+ data = []
46
+ for file in files:
47
+ try:
48
+ if isinstance(file, BufferedIOBase):
49
+ data.append(base64.b64encode(file.read()).decode())
50
+ elif isinstance(file, bytes):
51
+ data.append(base64.b64encode(file).decode())
52
+ elif isinstance(file, Image.Image):
53
+ image_bytes_io = BytesIO()
54
+ img = file.convert("RGBA")
55
+ img.save(image_bytes_io, format="PNG")
56
+ image_bytes_io.seek(0)
57
+ buffered_reader = BufferedReader(image_bytes_io)
58
+ data.append(base64.b64encode(buffered_reader.read()).decode())
59
+ elif isinstance(file, str):
60
+ try:
61
+ if file.startswith("http"):
62
+ res = requests.get(file)
63
+ if res.status_code == 200:
64
+ file = res.content
65
+ else:
66
+ print(f"이미지 다운로드 실패: {res.status_code}")
67
+ else:
68
+ with open(file, "rb") as f:
69
+ file = f.read()
70
+ data.append(base64.b64encode(file).decode())
71
+ except Exception as e:
72
+ print(f"이미지 처리 중 오류 발생: {e}")
73
+ else:
74
+ print(f"지원하지 않는 형식입니다: {type(file)}")
75
+ except TypeError as e:
76
+ print(f"이미지 처리 중 오류 발생: {e}")
77
+ continue
78
+ if len(data) > 0:
79
+ res = requests.post(
80
+ f"{self.iris_endpoint}/reply",
81
+ json={
82
+ "type": "image_multiple",
83
+ "room": str(room_id),
84
+ "data": data,
85
+ },
86
+ )
87
+ return self.__parse(res)
88
+ else:
89
+ print("이미지 전송이 모두 실패하였습니다. 이미지 전송 요청 부분을 확인해주세요.")
90
+
91
+ def decrypt(self, enc: int, b64_ciphertext: str, user_id: int) -> str | None:
92
+ res = requests.post(
93
+ f"{self.iris_endpoint}/decrypt",
94
+ json={"enc": enc, "b64_ciphertext": b64_ciphertext, "user_id": user_id},
95
+ )
96
+
97
+ res = self.__parse(res)
98
+ return res.get("plain_text")
99
+
100
+ def query(self, query: str, bind: list[t.Any] | None = None) -> list[dict]:
101
+ res = requests.post(
102
+ f"{self.iris_endpoint}/query", json={"query": query, "bind": bind or []}
103
+ )
104
+ res = self.__parse(res)
105
+ return res.get("data", [])
106
+
107
+ def get_info(self):
108
+ res = requests.get(f"{self.iris_endpoint}/config")
109
+ return self.__parse(res)
110
+
111
+ def get_aot(self):
112
+ res = requests.get(f"{self.iris_endpoint}/aot")
113
+ return self.__parse(res)
@@ -0,0 +1,382 @@
1
+ from dataclasses import dataclass
2
+ import typing as t
3
+ from iris.bot._internal.iris import IrisAPI
4
+ import json
5
+ from functools import cached_property
6
+ from PIL import Image
7
+ from io import BytesIO, BufferedIOBase
8
+ import requests
9
+
10
+ @dataclass
11
+ class Message:
12
+ id: int
13
+ type: int
14
+ msg: str
15
+ attachment: str
16
+ v: dict
17
+
18
+ def __post_init__(self):
19
+ self.command, *param = self.msg.split(" ", 1)
20
+ self.has_param = len(param) > 0
21
+ self.param = param[0] if self.has_param else None
22
+ try:
23
+ self.attachment = json.loads(self.attachment)
24
+ except Exception:
25
+ pass
26
+ if self.type in [71,27,2,71+16384,27+16384,2+16384]:
27
+ self.image = ChatImage(self)
28
+ else:
29
+ self.image = None
30
+
31
+ def __repr__(self) -> str:
32
+ return f"Message(id={self.id}, type={self.type}, msg={self.msg})"
33
+
34
+ class Room:
35
+ def __init__(self, id: int, name: str, api: IrisAPI):
36
+ self.id = id
37
+ self.name = name
38
+ self._api = api
39
+
40
+ @cached_property
41
+ def type(self) -> t.Optional[str]:
42
+ try:
43
+ results = self._api.query(
44
+ 'select type from chat_rooms where id = ?',
45
+ [self.id]
46
+ )
47
+ if results and results[0]:
48
+ fetched_type = results[0].get("type")
49
+ return fetched_type
50
+ else:
51
+ return None
52
+
53
+ except Exception as e:
54
+ return None
55
+
56
+ def __repr__(self) -> str:
57
+ return f"Room(id={self.id}, name={self.name})"
58
+
59
+
60
+ class User:
61
+ def __init__(self, id: int, chat_id: int, api: IrisAPI, name: str = None, bot_id: int = None):
62
+ self.id = id
63
+ self._chat_id = chat_id
64
+ self._api = api
65
+ self._name = name
66
+ self._bot_id = bot_id
67
+ self.avatar = Avatar(id, chat_id, api)
68
+
69
+ @cached_property
70
+ def name(self) -> t.Optional[str]:
71
+ try:
72
+ if not self._name:
73
+ if self.id == self._bot_id:
74
+ query = "SELECT T2.nickname FROM chat_rooms AS T1 JOIN db2.open_profile AS T2 ON T1.link_id = T2.link_id WHERE T1.id = ?"
75
+ results = self._api.query(query, [self._chat_id])
76
+ name = results[0].get("nickname")
77
+ elif self.id < 10000000000:
78
+ query = "SELECT name, enc FROM db2.friends WHERE id = ?"
79
+ results = self._api.query(query, [self.id])
80
+ name = results[0].get("name")
81
+ else:
82
+ query = "SELECT nickname,enc FROM db2.open_chat_member WHERE user_id = ?"
83
+ results = self._api.query(query, [self.id])
84
+ name = results[0].get("original_profile_image_url")
85
+ return name
86
+
87
+ else:
88
+ return self._name
89
+
90
+ except Exception as e:
91
+ return None
92
+
93
+ @cached_property
94
+ def type(self) -> t.Optional[str]:
95
+ try:
96
+ if self.id == self._bot_id:
97
+ query = "SELECT T2.link_member_type FROM chat_rooms AS T1 INNER JOIN open_profile AS T2 ON T1.link_id = T2.link_id WHERE T1.id = ?"
98
+ results = self._api.query(query, [self._chat_id])
99
+
100
+ else:
101
+ query = "SELECT link_member_type FROM db2.open_chat_member WHERE user_id = ?"
102
+ results = self._api.query(query, [self.id])
103
+
104
+ member_type = int(results[0].get("link_member_type"))
105
+ match member_type:
106
+ case 1:
107
+ return "HOST"
108
+ case 2:
109
+ return "NORMAL"
110
+ case 4:
111
+ return "MANAGER"
112
+ case 8:
113
+ return "BOT"
114
+ case _:
115
+ return "UNKNOWN"
116
+
117
+ except Exception as e:
118
+ return "REAL_PROFILE"
119
+
120
+ def __repr__(self) -> str:
121
+ return f"User(name={self.name})"
122
+
123
+ class Avatar:
124
+ def __init__(self, id: int, chat_id: int, api: IrisAPI):
125
+ self._id = id
126
+ self._api = api
127
+ self._chat_id = chat_id
128
+
129
+ @cached_property
130
+ def url(self) -> t.Optional[str]:
131
+ try:
132
+ if self._id < 10000000000:
133
+ query = "SELECT T2.o_profile_image_url FROM chat_rooms AS T1 JOIN db2.open_profile AS T2 ON T1.link_id = T2.link_id WHERE T1.id = ?"
134
+ results = self._api.query(query, [self._chat_id])
135
+ fetched_url = results[0].get("o_profile_image_url")
136
+ else:
137
+ query = "SELECT original_profile_image_url,enc FROM db2.open_chat_member WHERE user_id = ?"
138
+ results = self._api.query(query, [self._id])
139
+ fetched_url = results[0].get("original_profile_image_url")
140
+ return fetched_url
141
+
142
+ except Exception as e:
143
+ return None
144
+
145
+ @cached_property
146
+ def img(self) -> t.Optional[bytes]:
147
+ avatar_url = self.url
148
+
149
+ if not avatar_url:
150
+ return None
151
+
152
+ try:
153
+ image_data = self.__get_image_from_url(avatar_url)
154
+ return image_data
155
+ except Exception as e:
156
+ print(f"아바타 이미지 로딩 실패: {e}")
157
+ return None
158
+
159
+ def __get_image_from_url(self, url: str) -> Image:
160
+ try:
161
+ img = Image.open(BytesIO(requests.get(url).content))
162
+ img = img.convert("RGBA")
163
+ return img
164
+ except Exception as e:
165
+ print(f"아바타 이미지 로딩 실패: {e}")
166
+ return None
167
+
168
+ def __repr__(self) -> str:
169
+ return f"Avatar(url={self.url})"
170
+
171
+ class ChatImage:
172
+ def __init__(self, message: Message):
173
+ self.url = self.__get_photo_url(message)
174
+
175
+ @cached_property
176
+ def img(self):
177
+ if not self.url:
178
+ return None
179
+
180
+ try:
181
+ imgs = []
182
+ for url in self.url:
183
+ imgs.append(self.__get_image_from_url(url))
184
+ return imgs
185
+ except Exception as e:
186
+ return None
187
+
188
+ def __get_photo_url(self, message) -> list:
189
+ try:
190
+ urls = []
191
+ if message.type == 71:
192
+ for item in message.attachment["C"]["THL"]:
193
+ urls.append(item["TH"]["THU"])
194
+ elif message.type == 27:
195
+ for item in message.attachment["imageUrls"]:
196
+ urls.append(item)
197
+ else:
198
+ urls.append(message.attachment["url"])
199
+ return urls
200
+ except Exception as e:
201
+ return None
202
+
203
+ def __get_image_from_url(self, url: str) -> Image:
204
+ try:
205
+ img = Image.open(BytesIO(requests.get(url).content))
206
+ img = img.convert("RGBA")
207
+ return img
208
+ except Exception as e:
209
+ print(f"이미지 로딩 실패: {e}")
210
+ return None
211
+
212
+ def __repr__(self) -> str:
213
+ return f"ChatImage(url={self.url})"
214
+
215
+ @dataclass
216
+ class ChatContext:
217
+ room: Room
218
+ sender: User
219
+ message: Message
220
+ raw: dict
221
+ api: IrisAPI
222
+ _bot_id: int = None
223
+
224
+ def __post_init__(self):
225
+ pass
226
+
227
+ def reply(self, message: str, room_id: int = None):
228
+ if room_id is None:
229
+ room_id = self.room.id
230
+
231
+ try:
232
+ self.api.reply(room_id, message)
233
+ except Exception as e:
234
+ print(f"reply 오류: {e}")
235
+
236
+ def reply_media(
237
+ self,
238
+ files: t.List[BufferedIOBase | bytes | Image.Image | str],
239
+ room_id: int = None,
240
+ ):
241
+ if room_id is None:
242
+ room_id = self.room.id
243
+
244
+ self.api.reply_media(room_id, files)
245
+
246
+ def get_source(self):
247
+ source_record = self.__get_reply_chat(self.message)
248
+ if source_record:
249
+ source_chat = self.__make_chat(self, source_record)
250
+ return source_chat
251
+ else:
252
+ return None
253
+
254
+ def get_next_chat(self, n: int = 1):
255
+ next_record = self.__get_next_record(self.message.id, n)
256
+ if next_record:
257
+ next_chat = self.__make_chat(self, next_record)
258
+ return next_chat
259
+ else:
260
+ return None
261
+
262
+ def get_previous_chat(self, n: int = 1):
263
+ previous_record = self.__get_previous_record(self.message.id, n)
264
+ if previous_record:
265
+ previous_chat = self.__make_chat(self, previous_record)
266
+ return previous_chat
267
+ else:
268
+ return None
269
+
270
+ def __get_reply_chat(self, message: Message):
271
+ try:
272
+ src_log_id = message.attachment['src_logId']
273
+ query = "select * from chat_logs where id = ?"
274
+ src_record = self.api.query(query,[src_log_id])
275
+ return src_record[0]
276
+ except Exception as e:
277
+ print(e)
278
+ return None
279
+
280
+ def __get_previous_record(self, log_id, n: int = 1):
281
+ if n < 0:
282
+ raise ValueError("n must be greater than 0")
283
+
284
+ query = """
285
+ WITH RECURSIVE ChatHistory AS (
286
+ SELECT *
287
+ FROM chat_logs
288
+ WHERE id = ?
289
+ UNION ALL
290
+ SELECT c.*
291
+ FROM chat_logs c
292
+ JOIN ChatHistory h ON c.id = h.prev_id
293
+ )
294
+ SELECT *
295
+ FROM ChatHistory
296
+ LIMIT 1 OFFSET ?;
297
+ """
298
+ record = self.api.query(query, [log_id, n])
299
+ return record[0] if record else None
300
+
301
+ def __get_next_record(self, log_id, n: int = 1):
302
+ n = n - 1
303
+ if n < -1:
304
+ raise ValueError("n must be greater than 0")
305
+ query = """
306
+ WITH RECURSIVE ChatHistory AS (
307
+ SELECT
308
+ *,
309
+ 0 AS depth
310
+ FROM
311
+ chat_logs
312
+ WHERE
313
+ id = ?
314
+
315
+ UNION ALL
316
+
317
+ SELECT
318
+ c.*,
319
+ h.depth + 1
320
+ FROM
321
+ chat_logs c
322
+ JOIN ChatHistory h ON c.prev_id = h.id
323
+ WHERE
324
+ h.depth < 100
325
+ AND c.prev_id IS NOT NULL
326
+ AND h.id IS NOT NULL
327
+ AND c.id IS NOT NULL
328
+ )
329
+ SELECT *
330
+ FROM ChatHistory
331
+ WHERE depth = ? + 1
332
+ LIMIT 1;
333
+ """
334
+ record = self.api.query(query, [log_id, n])
335
+ return record[0] if record else None
336
+
337
+ def __make_chat(self, chat, record):
338
+ v = {}
339
+ try:
340
+ v = json.loads(record["v"])
341
+ except Exception:
342
+ pass
343
+
344
+ room = Room(
345
+ id=int(record["chat_id"]),
346
+ name=chat.room.name,
347
+ api=self.api,
348
+ )
349
+ sender = User(
350
+ id=int(record["user_id"]),
351
+ chat_id=chat.room.id,
352
+ api=self.api,
353
+ name=self.__get_name_of_user_id(int(record["user_id"])),
354
+ bot_id=self._bot_id,
355
+ )
356
+ message = Message(
357
+ id=int(record["id"]),
358
+ type=int(record["type"]),
359
+ msg=record["message"],
360
+ attachment=record["attachment"],
361
+ v=v,
362
+ )
363
+ new_chat = ChatContext(
364
+ room=room, sender=sender, message=message, raw=record, api=self.api, _bot_id=self._bot_id
365
+ )
366
+
367
+ return new_chat
368
+
369
+ def __get_name_of_user_id(self, user_id: int):
370
+ query = "WITH info AS (SELECT ? AS user_id) SELECT COALESCE(open_chat_member.nickname, friends.name) AS name, COALESCE(open_chat_member.enc, friends.enc) AS enc FROM info LEFT JOIN db2.open_chat_member ON open_chat_member.user_id = info.user_id LEFT JOIN db2.friends ON friends.id = info.user_id;"
371
+ result = self.api.query(query,[user_id])
372
+ if len(result) == 0:
373
+ return None
374
+ return result[0]['name']
375
+
376
+ @dataclass
377
+ class ErrorContext:
378
+ event: str
379
+ func: t.Callable
380
+ exception: Exception
381
+ args: list[t.Any]
382
+