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 ADDED
@@ -0,0 +1,7 @@
1
+ from phound.main import Phound
2
+
3
+ from phound.logging import setup_logging as _setup_logging
4
+
5
+ _setup_logging()
6
+
7
+ __all__ = ["Phound"]
phound/bin/uccrobot ADDED
Binary file
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
+ )
@@ -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)