phound 0.1.3__py3-none-win_amd64.whl
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.
- phound/__init__.py +7 -0
- phound/bin/uccrobot +0 -0
- phound/chats/__init__.py +0 -0
- phound/chats/text_helpers.py +36 -0
- phound/chats/utils.py +63 -0
- phound/client.py +173 -0
- phound/config.py +30 -0
- phound/event_listener.py +34 -0
- phound/events.py +376 -0
- phound/exceptions.py +18 -0
- phound/handlers.py +253 -0
- phound/logging.py +52 -0
- phound/main.py +125 -0
- phound/server.py +35 -0
- phound-0.1.3.dist-info/LICENSE +21 -0
- phound-0.1.3.dist-info/METADATA +195 -0
- phound-0.1.3.dist-info/RECORD +19 -0
- phound-0.1.3.dist-info/WHEEL +5 -0
- phound-0.1.3.dist-info/top_level.txt +1 -0
phound/__init__.py
ADDED
phound/bin/uccrobot
ADDED
|
Binary file
|
phound/chats/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from enum import Enum, IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
MENTION_FORMAT = "<mention:persona_uid=\"{}\":label=\"{}\">"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MessageTextFormat(IntEnum):
|
|
8
|
+
PLAIN = 1
|
|
9
|
+
HTML = 2
|
|
10
|
+
GPTMARKDOWN = 3
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Tag(str, Enum):
|
|
14
|
+
BOLD = "b"
|
|
15
|
+
ITALIC = "i"
|
|
16
|
+
UNDERLINE = "u"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def mention(persona_uid: str, label: str = "") -> str:
|
|
20
|
+
return MENTION_FORMAT.format(persona_uid, label)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def bold(text: str) -> str:
|
|
24
|
+
return _wrap(text, Tag.BOLD)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def italic(text: str) -> str:
|
|
28
|
+
return _wrap(text, Tag.ITALIC)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def underline(text: str) -> str:
|
|
32
|
+
return _wrap(text, Tag.UNDERLINE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _wrap(text: str, tag: Tag) -> str:
|
|
36
|
+
return f"<{tag}>{text}</{tag}>"
|
phound/chats/utils.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from typing import List, Dict, Any, Tuple
|
|
2
|
+
import re
|
|
3
|
+
import mimetypes
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
from phound.chats.text_helpers import Tag, MessageTextFormat, MENTION_FORMAT
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_attachments(attachments: List[str]) -> List[Dict[str, Any]]:
|
|
12
|
+
parsed_attachments = []
|
|
13
|
+
for path in attachments:
|
|
14
|
+
attachment = {"path": path, "type": _AttachmentType.FILE}
|
|
15
|
+
mime_type, _ = mimetypes.guess_type(path)
|
|
16
|
+
if mime_type:
|
|
17
|
+
if mime_type.startswith("audio/"):
|
|
18
|
+
attachment["type"] = _AttachmentType.SOUND
|
|
19
|
+
elif mime_type.startswith("image/"):
|
|
20
|
+
attachment["type"] = _AttachmentType.IMAGE
|
|
21
|
+
with Image.open(path) as img:
|
|
22
|
+
attachment["dimentions"] = img.size
|
|
23
|
+
parsed_attachments.append(attachment)
|
|
24
|
+
return parsed_attachments
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def extract_mentions(raw_text: str, text_format: MessageTextFormat) -> Tuple[str, List[Dict[str, int]]]:
|
|
28
|
+
mentions = []
|
|
29
|
+
text = raw_text
|
|
30
|
+
for match in re.findall(MENTION_FORMAT.format(r"\d+", r".*?"), raw_text):
|
|
31
|
+
persona_uid, label = re.findall(MENTION_FORMAT.format(r"(\d+)", r"(.*?)"), match)[0]
|
|
32
|
+
if not label:
|
|
33
|
+
label = persona_uid
|
|
34
|
+
mentions.append({
|
|
35
|
+
"uid": int(persona_uid),
|
|
36
|
+
"offset": (text.find(match)
|
|
37
|
+
if text_format != MessageTextFormat.HTML
|
|
38
|
+
else _remove_html_tags(text).find(match)),
|
|
39
|
+
"length": len(label)}
|
|
40
|
+
)
|
|
41
|
+
text = text.replace(match, label, 1)
|
|
42
|
+
return text, mentions
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_chat_id(from_uid: str, to_uid: str) -> str:
|
|
46
|
+
return "".join([_uid_to_hex(i) for i in sorted([from_uid, to_uid])])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _uid_to_hex(uid: str) -> str:
|
|
50
|
+
hex_size = 16
|
|
51
|
+
direct = hex(int(uid))[2:].upper().zfill(hex_size)
|
|
52
|
+
chunk_size = 2
|
|
53
|
+
return "".join(reversed([direct[i:i + chunk_size] for i in range(0, hex_size, chunk_size)]))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _remove_html_tags(text: str) -> str:
|
|
57
|
+
return re.sub(fr"<({'|'.join(list(Tag))})>(.*?)</\1>", r"\2", text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _AttachmentType(IntEnum):
|
|
61
|
+
FILE = 1
|
|
62
|
+
IMAGE = 2
|
|
63
|
+
SOUND = 3
|
phound/client.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional, List
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from phound.events import EventType, Message, Event
|
|
9
|
+
from phound.event_listener import EventListener
|
|
10
|
+
from phound.logging import logger, get_logging_parameters
|
|
11
|
+
from phound.config import settings
|
|
12
|
+
|
|
13
|
+
_DEFAULT_CHANNEL_ID = "0"
|
|
14
|
+
_HEALTH_CHECK_INTERVAL_SECONDS = 5
|
|
15
|
+
lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Client:
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._process = self._create_process()
|
|
21
|
+
self._event_listener = EventListener(self._process.stdout)
|
|
22
|
+
self._connect()
|
|
23
|
+
|
|
24
|
+
self._health_check_thread = _HealthCheckThread(target=self._restart_not_running_process)
|
|
25
|
+
self._health_check_thread.start()
|
|
26
|
+
|
|
27
|
+
self._channels_enabled = False
|
|
28
|
+
self._channels_port = 0
|
|
29
|
+
|
|
30
|
+
def stop(self) -> None:
|
|
31
|
+
self._health_check_thread.stop()
|
|
32
|
+
self._health_check_thread.join()
|
|
33
|
+
self._destroy_process()
|
|
34
|
+
|
|
35
|
+
def send_message(self,
|
|
36
|
+
persona_uid: str,
|
|
37
|
+
chat_id: str,
|
|
38
|
+
text: str,
|
|
39
|
+
text_format: int,
|
|
40
|
+
attachments: Optional[List[Dict[str, Any]]] = None,
|
|
41
|
+
mentions: Optional[List[Dict[str, int]]] = None,
|
|
42
|
+
app_meta: Optional[Dict[str, Any]] = None,
|
|
43
|
+
channel_id: str = _DEFAULT_CHANNEL_ID,
|
|
44
|
+
wait_sent: bool = True) -> Optional[Message]:
|
|
45
|
+
message_json = {
|
|
46
|
+
"text": text,
|
|
47
|
+
"chan": channel_id,
|
|
48
|
+
"wait": wait_sent,
|
|
49
|
+
"format": text_format,
|
|
50
|
+
}
|
|
51
|
+
if attachments:
|
|
52
|
+
message_json["attachments"] = json.dumps(attachments)
|
|
53
|
+
if mentions:
|
|
54
|
+
message_json["mentions"] = json.dumps(mentions)
|
|
55
|
+
if app_meta:
|
|
56
|
+
message_json["app_meta"] = json.dumps(app_meta)
|
|
57
|
+
self._write(f"send-message {persona_uid} {chat_id} {channel_id} <<JSON\n{json.dumps(message_json)}")
|
|
58
|
+
|
|
59
|
+
def answer_call(self, call_id: str) -> None:
|
|
60
|
+
self._write(f"answer {call_id}")
|
|
61
|
+
|
|
62
|
+
def delay_call(self, call_id: str, milliseconds: int) -> None:
|
|
63
|
+
self._write(f"delay {call_id} {milliseconds}")
|
|
64
|
+
|
|
65
|
+
def record_call(
|
|
66
|
+
self,
|
|
67
|
+
call_id: str,
|
|
68
|
+
file_path: str,
|
|
69
|
+
min_smg_len: int = 3,
|
|
70
|
+
max_sgm_len: int = 5,
|
|
71
|
+
sgm_overlap: int = 1
|
|
72
|
+
) -> None:
|
|
73
|
+
self._write(f"record {call_id} {file_path} sgm:{min_smg_len}:{max_sgm_len}:{sgm_overlap}")
|
|
74
|
+
|
|
75
|
+
def play_file(self, call_id: str, file_path: str) -> None:
|
|
76
|
+
self._write(f"play {call_id} {file_path}")
|
|
77
|
+
|
|
78
|
+
def hangup(self, call_id: str) -> None:
|
|
79
|
+
self._write(f"hangup {call_id}")
|
|
80
|
+
|
|
81
|
+
def show_typing(
|
|
82
|
+
self, persona_uid: str, chat_id: str, timeout: int = 10, channel_id: str = _DEFAULT_CHANNEL_ID
|
|
83
|
+
) -> None:
|
|
84
|
+
self._write(f"typing-indicator {persona_uid} {chat_id} {timeout} {channel_id}")
|
|
85
|
+
|
|
86
|
+
def request_chat_history(
|
|
87
|
+
self,
|
|
88
|
+
persona_uid: str,
|
|
89
|
+
chat_id: str,
|
|
90
|
+
start_message_id: str,
|
|
91
|
+
channel_id: str = _DEFAULT_CHANNEL_ID,
|
|
92
|
+
depth: int = 10
|
|
93
|
+
) -> None:
|
|
94
|
+
self._write(f"get-history-bulk {persona_uid} {chat_id} {start_message_id} {channel_id} {depth}")
|
|
95
|
+
|
|
96
|
+
def lookup_phone_number(self, phone_number: str, channel_id: str = _DEFAULT_CHANNEL_ID) -> None:
|
|
97
|
+
self._write(f"lookup-phone-number {phone_number} {channel_id}")
|
|
98
|
+
|
|
99
|
+
def request_next_event(self, channel_id: str) -> None:
|
|
100
|
+
self._write(f"pump-queue {channel_id}")
|
|
101
|
+
|
|
102
|
+
def wait_event(self, *event_types: EventType) -> Event:
|
|
103
|
+
return self._event_listener.wait_event(*event_types)
|
|
104
|
+
|
|
105
|
+
def shutdown(self) -> None:
|
|
106
|
+
self._write("shutdown")
|
|
107
|
+
|
|
108
|
+
def enable_channels(self, port: int) -> None:
|
|
109
|
+
self._channels_enabled = True
|
|
110
|
+
self._channels_port = port
|
|
111
|
+
self._write(f"set-handler channel 127.0.0.1:{port}")
|
|
112
|
+
|
|
113
|
+
def _connect(self) -> None:
|
|
114
|
+
uid, api_key = settings.token.split(".")
|
|
115
|
+
self._write(f"connect {settings.sbc} {uid} {api_key} {settings.personas}", log=False)
|
|
116
|
+
self._event_listener.wait_event(EventType.CONNECTED)
|
|
117
|
+
|
|
118
|
+
def _create_process(self) -> subprocess.Popen:
|
|
119
|
+
cmd = [f"{Path(__file__).parent.resolve()}/bin/uccrobot", "-e", "-q"]
|
|
120
|
+
|
|
121
|
+
_, log_file_dir = get_logging_parameters()
|
|
122
|
+
if log_file_dir:
|
|
123
|
+
cmd.extend(["-l", log_file_dir])
|
|
124
|
+
cmd.append("-")
|
|
125
|
+
|
|
126
|
+
# pylint: disable=W1509
|
|
127
|
+
return subprocess.Popen(
|
|
128
|
+
cmd,
|
|
129
|
+
stdin=subprocess.PIPE,
|
|
130
|
+
stdout=subprocess.PIPE,
|
|
131
|
+
# this is required in order not to receive parent's signals eg SIGINT
|
|
132
|
+
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
|
133
|
+
encoding="utf-8",
|
|
134
|
+
bufsize=0
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _destroy_process(self) -> None:
|
|
138
|
+
self._process.stdin.close()
|
|
139
|
+
self._process.stdout.close()
|
|
140
|
+
self._process.wait()
|
|
141
|
+
logger.info("UCC client process destroyed")
|
|
142
|
+
|
|
143
|
+
def _write(self, cmd: str, log: bool = True) -> None:
|
|
144
|
+
if log:
|
|
145
|
+
logger.info(f"Sending cmd '{cmd}' to ucc")
|
|
146
|
+
with lock:
|
|
147
|
+
self._process.stdin.write(cmd + os.linesep)
|
|
148
|
+
|
|
149
|
+
def _restart_not_running_process(self) -> None:
|
|
150
|
+
while not self._health_check_thread.stopped():
|
|
151
|
+
if self._process.poll() is not None:
|
|
152
|
+
logger.error("Found UCC client process died, restarting")
|
|
153
|
+
self._process = self._create_process()
|
|
154
|
+
self._event_listener = EventListener(self._process.stdout)
|
|
155
|
+
self._connect()
|
|
156
|
+
if self._channels_enabled and self._channels_port:
|
|
157
|
+
self.enable_channels(self._channels_port)
|
|
158
|
+
self._health_check_thread.wait(_HEALTH_CHECK_INTERVAL_SECONDS)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _HealthCheckThread(threading.Thread):
|
|
162
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
163
|
+
super().__init__(*args, **kwargs)
|
|
164
|
+
self._event = threading.Event()
|
|
165
|
+
|
|
166
|
+
def stop(self) -> None:
|
|
167
|
+
self._event.set()
|
|
168
|
+
|
|
169
|
+
def wait(self, timeout: int) -> None:
|
|
170
|
+
self._event.wait(timeout)
|
|
171
|
+
|
|
172
|
+
def stopped(self) -> bool:
|
|
173
|
+
return self._event.is_set()
|
phound/config.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from dotenv import load_dotenv as _load_dotenv
|
|
5
|
+
|
|
6
|
+
_load_dotenv()
|
|
7
|
+
|
|
8
|
+
_DEFAULT_SBC = "wss://ow-sbc.phound.app/ucp"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Settings:
|
|
13
|
+
sbc: str
|
|
14
|
+
token: str
|
|
15
|
+
personas: str
|
|
16
|
+
phound_log: bool
|
|
17
|
+
phound_log_dir: str
|
|
18
|
+
|
|
19
|
+
def __post_init__(self) -> None:
|
|
20
|
+
if not self.token or len(self.token.split(".")) != 2:
|
|
21
|
+
raise ValueError("Token is invalid, must be in format <uid>.<api_key>")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
settings = Settings(
|
|
25
|
+
sbc=os.environ.get("SBC", _DEFAULT_SBC),
|
|
26
|
+
token=os.environ.get("TOKEN", ""),
|
|
27
|
+
personas=os.environ.get("PERSONAS", ""),
|
|
28
|
+
phound_log=os.environ.get("PHOUND_LOG", "False").lower() == "true",
|
|
29
|
+
phound_log_dir=os.environ.get("PHOUND_LOG_DIR", ""),
|
|
30
|
+
)
|
phound/event_listener.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from typing import TextIO
|
|
2
|
+
|
|
3
|
+
from phound import events
|
|
4
|
+
from phound.events import EventType, Event
|
|
5
|
+
from phound import exceptions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EventListener:
|
|
9
|
+
def __init__(self, source: TextIO) -> None:
|
|
10
|
+
self._source = source
|
|
11
|
+
|
|
12
|
+
def wait_event(self, *event_types: EventType) -> Event:
|
|
13
|
+
event = self._get_next_event()
|
|
14
|
+
self._raise_for_error(event)
|
|
15
|
+
if event_types and event.type not in event_types:
|
|
16
|
+
raise exceptions.UnexpectedEventError(
|
|
17
|
+
f"Unexpected event: {event.type}, expected: {', '.join(event_types)}")
|
|
18
|
+
return event
|
|
19
|
+
|
|
20
|
+
def _get_next_event(self) -> Event:
|
|
21
|
+
while True:
|
|
22
|
+
data = self._source.readline()
|
|
23
|
+
if not data:
|
|
24
|
+
raise exceptions.InputError
|
|
25
|
+
event = events.parse(data)
|
|
26
|
+
if event:
|
|
27
|
+
return event
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def _raise_for_error(event: Event) -> None:
|
|
31
|
+
if event.type == EventType.ERROR:
|
|
32
|
+
raise exceptions.PhoundError(event.body)
|
|
33
|
+
if event.type == EventType.CHAT_ERROR:
|
|
34
|
+
raise exceptions.ChatError(event.body)
|