slidge 0.2.0a8__py3-none-any.whl → 0.2.0a10__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- slidge/__version__.py +1 -1
- slidge/command/adhoc.py +1 -1
- slidge/command/base.py +4 -4
- slidge/contact/contact.py +3 -2
- slidge/contact/roster.py +7 -0
- slidge/core/dispatcher/__init__.py +3 -0
- slidge/core/{gateway → dispatcher}/caps.py +6 -4
- slidge/core/{gateway → dispatcher}/disco.py +11 -17
- slidge/core/dispatcher/message/__init__.py +10 -0
- slidge/core/dispatcher/message/chat_state.py +40 -0
- slidge/core/dispatcher/message/marker.py +67 -0
- slidge/core/dispatcher/message/message.py +397 -0
- slidge/core/dispatcher/muc/__init__.py +12 -0
- slidge/core/dispatcher/muc/admin.py +98 -0
- slidge/core/{gateway → dispatcher/muc}/mam.py +26 -15
- slidge/core/dispatcher/muc/misc.py +118 -0
- slidge/core/dispatcher/muc/owner.py +96 -0
- slidge/core/{gateway → dispatcher/muc}/ping.py +10 -15
- slidge/core/dispatcher/presence.py +177 -0
- slidge/core/{gateway → dispatcher}/registration.py +23 -2
- slidge/core/{gateway → dispatcher}/search.py +9 -14
- slidge/core/dispatcher/session_dispatcher.py +84 -0
- slidge/core/dispatcher/util.py +174 -0
- slidge/core/{gateway/vcard_temp.py → dispatcher/vcard.py} +26 -12
- slidge/core/{gateway/base.py → gateway.py} +42 -137
- slidge/core/mixins/attachment.py +7 -2
- slidge/core/mixins/base.py +2 -2
- slidge/core/mixins/message.py +10 -4
- slidge/core/pubsub.py +2 -1
- slidge/core/session.py +28 -2
- slidge/db/alembic/versions/45c24cc73c91_add_bob.py +42 -0
- slidge/db/models.py +13 -0
- slidge/db/store.py +128 -2
- slidge/{core/gateway → slixfix}/delivery_receipt.py +1 -1
- slidge/util/test.py +5 -1
- slidge/util/types.py +6 -0
- slidge/util/util.py +5 -2
- {slidge-0.2.0a8.dist-info → slidge-0.2.0a10.dist-info}/METADATA +2 -1
- {slidge-0.2.0a8.dist-info → slidge-0.2.0a10.dist-info}/RECORD +42 -33
- slidge/core/gateway/__init__.py +0 -3
- slidge/core/gateway/muc_admin.py +0 -35
- slidge/core/gateway/presence.py +0 -95
- slidge/core/gateway/session_dispatcher.py +0 -895
- {slidge-0.2.0a8.dist-info → slidge-0.2.0a10.dist-info}/LICENSE +0 -0
- {slidge-0.2.0a8.dist-info → slidge-0.2.0a10.dist-info}/WHEEL +0 -0
- {slidge-0.2.0a8.dist-info → slidge-0.2.0a10.dist-info}/entry_points.txt +0 -0
@@ -7,13 +7,16 @@ from slixmpp import JID, Iq
|
|
7
7
|
from slixmpp.exceptions import XMPPError
|
8
8
|
|
9
9
|
from ...db import GatewayUser
|
10
|
+
from .. import config
|
11
|
+
from .util import DispatcherMixin
|
10
12
|
|
11
13
|
if TYPE_CHECKING:
|
12
|
-
from .
|
14
|
+
from slidge.core.gateway import BaseGateway
|
13
15
|
|
14
16
|
|
15
|
-
class
|
17
|
+
class RegistrationMixin(DispatcherMixin):
|
16
18
|
def __init__(self, xmpp: "BaseGateway"):
|
19
|
+
super().__init__(xmpp)
|
17
20
|
self.xmpp = xmpp
|
18
21
|
xmpp["xep_0077"].api.register(
|
19
22
|
self.xmpp.make_registration_form, "make_registration_form"
|
@@ -25,6 +28,9 @@ class Registration:
|
|
25
28
|
# TODO: either fully use slixmpp internal API or rewrite registration without it at all
|
26
29
|
xmpp["xep_0077"].api.register(lambda *a: None, "user_remove")
|
27
30
|
|
31
|
+
xmpp.add_event_handler("user_register", self._on_user_register)
|
32
|
+
xmpp.add_event_handler("user_unregister", self._on_user_unregister)
|
33
|
+
|
28
34
|
def get_user(self, jid: JID) -> GatewayUser | None:
|
29
35
|
return self.xmpp.store.users.get(jid)
|
30
36
|
|
@@ -60,5 +66,20 @@ class Registration:
|
|
60
66
|
user.legacy_module_data.update(form_dict)
|
61
67
|
self.xmpp.store.users.update(user)
|
62
68
|
|
69
|
+
async def _on_user_register(self, iq: Iq):
|
70
|
+
session = await self._get_session(iq, wait_for_ready=False)
|
71
|
+
for jid in config.ADMINS:
|
72
|
+
self.xmpp.send_message(
|
73
|
+
mto=jid,
|
74
|
+
mbody=f"{iq.get_from()} has registered",
|
75
|
+
mtype="chat",
|
76
|
+
mfrom=self.xmpp.boundjid.bare,
|
77
|
+
)
|
78
|
+
session.send_gateway_message(self.xmpp.WELCOME_MESSAGE)
|
79
|
+
await self.xmpp.login_wrap(session)
|
80
|
+
|
81
|
+
async def _on_user_unregister(self, iq: Iq):
|
82
|
+
await self.xmpp.session_cls.kill_by_jid(iq.get_from())
|
83
|
+
|
63
84
|
|
64
85
|
log = logging.getLogger(__name__)
|
@@ -3,13 +3,15 @@ from typing import TYPE_CHECKING
|
|
3
3
|
from slixmpp import JID, CoroutineCallback, Iq, StanzaPath
|
4
4
|
from slixmpp.exceptions import XMPPError
|
5
5
|
|
6
|
+
from .util import DispatcherMixin, exceptions_to_xmpp_errors
|
7
|
+
|
6
8
|
if TYPE_CHECKING:
|
7
|
-
from .
|
9
|
+
from slidge.core.gateway import BaseGateway
|
8
10
|
|
9
11
|
|
10
|
-
class
|
12
|
+
class SearchMixin(DispatcherMixin):
|
11
13
|
def __init__(self, xmpp: "BaseGateway"):
|
12
|
-
|
14
|
+
super().__init__(xmpp)
|
13
15
|
|
14
16
|
xmpp["xep_0055"].api.register(self.search_get_form, "search_get_form")
|
15
17
|
xmpp["xep_0055"].api.register(self._search_query, "search_query")
|
@@ -45,13 +47,9 @@ class Search:
|
|
45
47
|
"""
|
46
48
|
Handles a search request
|
47
49
|
"""
|
48
|
-
|
49
|
-
if user is None:
|
50
|
-
raise XMPPError(text="Search is only allowed for registered users")
|
50
|
+
session = await self._get_session(iq)
|
51
51
|
|
52
|
-
result = await
|
53
|
-
iq["search"]["form"].get_values()
|
54
|
-
)
|
52
|
+
result = await session.on_search(iq["search"]["form"].get_values())
|
55
53
|
|
56
54
|
if not result:
|
57
55
|
raise XMPPError("item-not-found", text="Nothing was found")
|
@@ -64,19 +62,17 @@ class Search:
|
|
64
62
|
form.add_item(item)
|
65
63
|
return reply
|
66
64
|
|
65
|
+
@exceptions_to_xmpp_errors
|
67
66
|
async def _handle_gateway_iq(self, iq: Iq):
|
68
67
|
if iq.get_to() != self.xmpp.boundjid.bare:
|
69
68
|
raise XMPPError("bad-request", "This can only be used on the component JID")
|
70
69
|
|
71
|
-
user = self.xmpp.store.users.get(iq.get_from())
|
72
|
-
if user is None:
|
73
|
-
raise XMPPError("not-authorized", "Register to the gateway first")
|
74
|
-
|
75
70
|
if len(self.xmpp.SEARCH_FIELDS) > 1:
|
76
71
|
raise XMPPError(
|
77
72
|
"feature-not-implemented", "Use jabber search for this gateway"
|
78
73
|
)
|
79
74
|
|
75
|
+
session = await self._get_session(iq)
|
80
76
|
field = self.xmpp.SEARCH_FIELDS[0]
|
81
77
|
|
82
78
|
reply = iq.reply()
|
@@ -85,7 +81,6 @@ class Search:
|
|
85
81
|
reply["gateway"]["prompt"] = field.label
|
86
82
|
elif iq["type"] == "set":
|
87
83
|
prompt = iq["gateway"]["prompt"]
|
88
|
-
session = self.xmpp.session_cls.from_user(user)
|
89
84
|
result = await session.on_search({field.var: prompt})
|
90
85
|
if result is None or not result.items:
|
91
86
|
raise XMPPError(
|
@@ -0,0 +1,84 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import TYPE_CHECKING
|
3
|
+
|
4
|
+
from slixmpp import Message
|
5
|
+
from slixmpp.exceptions import IqError
|
6
|
+
from slixmpp.plugins.xep_0084.stanza import Info
|
7
|
+
|
8
|
+
from ..session import BaseSession
|
9
|
+
from .caps import CapsMixin
|
10
|
+
from .disco import DiscoMixin
|
11
|
+
from .message import MessageMixin
|
12
|
+
from .muc import MucMixin
|
13
|
+
from .presence import PresenceHandlerMixin
|
14
|
+
from .registration import RegistrationMixin
|
15
|
+
from .search import SearchMixin
|
16
|
+
from .util import exceptions_to_xmpp_errors
|
17
|
+
from .vcard import VCardMixin
|
18
|
+
|
19
|
+
if TYPE_CHECKING:
|
20
|
+
from slidge.core.gateway import BaseGateway
|
21
|
+
|
22
|
+
|
23
|
+
class SessionDispatcher(
|
24
|
+
CapsMixin,
|
25
|
+
DiscoMixin,
|
26
|
+
RegistrationMixin,
|
27
|
+
MessageMixin,
|
28
|
+
MucMixin,
|
29
|
+
PresenceHandlerMixin,
|
30
|
+
SearchMixin,
|
31
|
+
VCardMixin,
|
32
|
+
):
|
33
|
+
def __init__(self, xmpp: "BaseGateway"):
|
34
|
+
super().__init__(xmpp)
|
35
|
+
xmpp.add_event_handler(
|
36
|
+
"avatar_metadata_publish", self.on_avatar_metadata_publish
|
37
|
+
)
|
38
|
+
|
39
|
+
@exceptions_to_xmpp_errors
|
40
|
+
async def on_avatar_metadata_publish(self, m: Message):
|
41
|
+
session = await self._get_session(m, timeout=None)
|
42
|
+
if not session.user.preferences.get("sync_avatar", False):
|
43
|
+
session.log.debug("User does not want to sync their avatar")
|
44
|
+
return
|
45
|
+
info = m["pubsub_event"]["items"]["item"]["avatar_metadata"]["info"]
|
46
|
+
|
47
|
+
await self.on_avatar_metadata_info(session, info)
|
48
|
+
|
49
|
+
async def on_avatar_metadata_info(self, session: BaseSession, info: Info):
|
50
|
+
hash_ = info["id"]
|
51
|
+
|
52
|
+
if session.user.avatar_hash == hash_:
|
53
|
+
session.log.debug("We already know this avatar hash")
|
54
|
+
return
|
55
|
+
self.xmpp.store.users.set_avatar_hash(session.user_pk, None)
|
56
|
+
|
57
|
+
if hash_:
|
58
|
+
try:
|
59
|
+
iq = await self.xmpp.plugin["xep_0084"].retrieve_avatar(
|
60
|
+
session.user_jid, hash_, ifrom=self.xmpp.boundjid.bare
|
61
|
+
)
|
62
|
+
except IqError as e:
|
63
|
+
session.log.warning("Could not fetch the user's avatar: %s", e)
|
64
|
+
return
|
65
|
+
bytes_ = iq["pubsub"]["items"]["item"]["avatar_data"]["value"]
|
66
|
+
type_ = info["type"]
|
67
|
+
height = info["height"]
|
68
|
+
width = info["width"]
|
69
|
+
else:
|
70
|
+
bytes_ = type_ = height = width = hash_ = None
|
71
|
+
try:
|
72
|
+
await session.on_avatar(bytes_, hash_, type_, width, height)
|
73
|
+
except NotImplementedError:
|
74
|
+
pass
|
75
|
+
except Exception as e:
|
76
|
+
# If something goes wrong here, replying an error stanza will to the
|
77
|
+
# avatar update will likely not show in most clients, so let's send
|
78
|
+
# a normal message from the component to the user.
|
79
|
+
session.send_gateway_message(
|
80
|
+
f"Something went wrong trying to set your avatar: {e!r}"
|
81
|
+
)
|
82
|
+
|
83
|
+
|
84
|
+
log = logging.getLogger(__name__)
|
@@ -0,0 +1,174 @@
|
|
1
|
+
import logging
|
2
|
+
from functools import wraps
|
3
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar
|
4
|
+
|
5
|
+
from slixmpp import JID, Iq, Message, Presence
|
6
|
+
from slixmpp.exceptions import XMPPError
|
7
|
+
from slixmpp.xmlstream import StanzaBase
|
8
|
+
|
9
|
+
from ...util.types import Recipient, RecipientType
|
10
|
+
from ..session import BaseSession
|
11
|
+
|
12
|
+
if TYPE_CHECKING:
|
13
|
+
from slidge import BaseGateway
|
14
|
+
from slidge.group import LegacyMUC
|
15
|
+
|
16
|
+
|
17
|
+
class Ignore(BaseException):
|
18
|
+
pass
|
19
|
+
|
20
|
+
|
21
|
+
class DispatcherMixin:
|
22
|
+
def __init__(self, xmpp: "BaseGateway"):
|
23
|
+
self.xmpp = xmpp
|
24
|
+
|
25
|
+
async def _get_session(
|
26
|
+
self,
|
27
|
+
stanza: Message | Presence | Iq,
|
28
|
+
timeout: int | None = 10,
|
29
|
+
wait_for_ready=True,
|
30
|
+
logged=False,
|
31
|
+
) -> BaseSession:
|
32
|
+
xmpp = self.xmpp
|
33
|
+
if stanza.get_from().server == xmpp.boundjid.bare:
|
34
|
+
log.debug("Ignoring echo")
|
35
|
+
raise Ignore
|
36
|
+
if (
|
37
|
+
isinstance(stanza, Message)
|
38
|
+
and stanza.get_type() == "chat"
|
39
|
+
and stanza.get_to() == xmpp.boundjid.bare
|
40
|
+
):
|
41
|
+
log.debug("Ignoring message to component")
|
42
|
+
raise Ignore
|
43
|
+
session = await self._get_session_from_jid(
|
44
|
+
stanza.get_from(), timeout, wait_for_ready, logged
|
45
|
+
)
|
46
|
+
if isinstance(stanza, Message) and _ignore(session, stanza):
|
47
|
+
raise Ignore
|
48
|
+
return session
|
49
|
+
|
50
|
+
async def _get_session_from_jid(
|
51
|
+
self,
|
52
|
+
jid: JID,
|
53
|
+
timeout: int | None = 10,
|
54
|
+
wait_for_ready=True,
|
55
|
+
logged=False,
|
56
|
+
) -> BaseSession:
|
57
|
+
session = self.xmpp.get_session_from_jid(jid)
|
58
|
+
if session is None:
|
59
|
+
raise XMPPError("registration-required")
|
60
|
+
if logged:
|
61
|
+
session.raise_if_not_logged()
|
62
|
+
if wait_for_ready:
|
63
|
+
await session.wait_for_ready(timeout)
|
64
|
+
return session
|
65
|
+
|
66
|
+
async def get_muc_from_stanza(self, iq: Iq | Message | Presence) -> "LegacyMUC":
|
67
|
+
ito = iq.get_to()
|
68
|
+
if ito == self.xmpp.boundjid.bare:
|
69
|
+
raise XMPPError("bad-request", text="This is only handled for MUCs")
|
70
|
+
|
71
|
+
session = await self._get_session(iq, logged=True)
|
72
|
+
muc = await session.bookmarks.by_jid(ito)
|
73
|
+
return muc
|
74
|
+
|
75
|
+
def _xmpp_msg_id_to_legacy(self, session: "BaseSession", xmpp_id: str):
|
76
|
+
sent = self.xmpp.store.sent.get_legacy_id(session.user_pk, xmpp_id)
|
77
|
+
if sent is not None:
|
78
|
+
return self.xmpp.LEGACY_MSG_ID_TYPE(sent)
|
79
|
+
|
80
|
+
multi = self.xmpp.store.multi.get_legacy_id(session.user_pk, xmpp_id)
|
81
|
+
if multi:
|
82
|
+
return self.xmpp.LEGACY_MSG_ID_TYPE(multi)
|
83
|
+
|
84
|
+
try:
|
85
|
+
return session.xmpp_to_legacy_msg_id(xmpp_id)
|
86
|
+
except XMPPError:
|
87
|
+
raise
|
88
|
+
except Exception as e:
|
89
|
+
log.debug("Couldn't convert xmpp msg ID to legacy ID.", exc_info=e)
|
90
|
+
raise XMPPError(
|
91
|
+
"internal-server-error", "Couldn't convert xmpp msg ID to legacy ID."
|
92
|
+
)
|
93
|
+
|
94
|
+
async def _get_session_entity_thread(
|
95
|
+
self, msg: Message
|
96
|
+
) -> tuple["BaseSession", Recipient, int | str]:
|
97
|
+
session = await self._get_session(msg)
|
98
|
+
e: Recipient = await _get_entity(session, msg)
|
99
|
+
legacy_thread = await _xmpp_to_legacy_thread(session, msg, e)
|
100
|
+
return session, e, legacy_thread
|
101
|
+
|
102
|
+
|
103
|
+
def _ignore(session: "BaseSession", msg: Message):
|
104
|
+
i = msg.get_id()
|
105
|
+
if i.startswith("slidge-carbon-"):
|
106
|
+
return True
|
107
|
+
if i not in session.ignore_messages:
|
108
|
+
return False
|
109
|
+
session.log.debug("Ignored sent carbon: %s", i)
|
110
|
+
session.ignore_messages.remove(i)
|
111
|
+
return True
|
112
|
+
|
113
|
+
|
114
|
+
async def _xmpp_to_legacy_thread(
|
115
|
+
session: "BaseSession", msg: Message, recipient: RecipientType
|
116
|
+
):
|
117
|
+
xmpp_thread = msg["thread"]
|
118
|
+
if not xmpp_thread:
|
119
|
+
return
|
120
|
+
|
121
|
+
if session.MESSAGE_IDS_ARE_THREAD_IDS:
|
122
|
+
return session.xmpp.store.sent.get_legacy_thread(session.user_pk, xmpp_thread)
|
123
|
+
|
124
|
+
async with session.thread_creation_lock:
|
125
|
+
legacy_thread_str = session.xmpp.store.sent.get_legacy_thread(
|
126
|
+
session.user_pk, xmpp_thread
|
127
|
+
)
|
128
|
+
if legacy_thread_str is None:
|
129
|
+
legacy_thread = str(await recipient.create_thread(xmpp_thread))
|
130
|
+
session.xmpp.store.sent.set_thread(
|
131
|
+
session.user_pk, xmpp_thread, legacy_thread
|
132
|
+
)
|
133
|
+
return session.xmpp.LEGACY_MSG_ID_TYPE(legacy_thread)
|
134
|
+
|
135
|
+
|
136
|
+
async def _get_entity(session: "BaseSession", m: Message) -> RecipientType:
|
137
|
+
session.raise_if_not_logged()
|
138
|
+
if m.get_type() == "groupchat":
|
139
|
+
muc = await session.bookmarks.by_jid(m.get_to())
|
140
|
+
r = m.get_from().resource
|
141
|
+
if r not in muc.get_user_resources():
|
142
|
+
session.create_task(muc.kick_resource(r))
|
143
|
+
raise XMPPError("not-acceptable", "You are not connected to this chat")
|
144
|
+
return muc
|
145
|
+
else:
|
146
|
+
return await session.contacts.by_jid(m.get_to())
|
147
|
+
|
148
|
+
|
149
|
+
StanzaType = TypeVar("StanzaType", bound=StanzaBase)
|
150
|
+
HandlerType = Callable[[Any, StanzaType], Awaitable[None]]
|
151
|
+
|
152
|
+
|
153
|
+
def exceptions_to_xmpp_errors(cb: HandlerType) -> HandlerType:
|
154
|
+
@wraps(cb)
|
155
|
+
async def wrapped(*args):
|
156
|
+
try:
|
157
|
+
await cb(*args)
|
158
|
+
except Ignore:
|
159
|
+
pass
|
160
|
+
except XMPPError:
|
161
|
+
raise
|
162
|
+
except NotImplementedError:
|
163
|
+
log.debug("NotImplementedError raised in %s", cb)
|
164
|
+
raise XMPPError(
|
165
|
+
"feature-not-implemented", "Not implemented by the legacy module"
|
166
|
+
)
|
167
|
+
except Exception as e:
|
168
|
+
log.error("Failed to handle incoming stanza: %s", args, exc_info=e)
|
169
|
+
raise XMPPError("internal-server-error", str(e))
|
170
|
+
|
171
|
+
return wrapped
|
172
|
+
|
173
|
+
|
174
|
+
log = logging.getLogger(__name__)
|
@@ -1,5 +1,4 @@
|
|
1
1
|
from copy import copy
|
2
|
-
from typing import TYPE_CHECKING
|
3
2
|
|
4
3
|
from slixmpp import CoroutineCallback, Iq, StanzaPath, register_stanza_plugin
|
5
4
|
from slixmpp.exceptions import XMPPError
|
@@ -9,21 +8,23 @@ from slixmpp.plugins.xep_0292.stanza import NS as VCard4NS
|
|
9
8
|
from ...contact import LegacyContact
|
10
9
|
from ...core.session import BaseSession
|
11
10
|
from ...group import LegacyParticipant
|
11
|
+
from .util import DispatcherMixin, exceptions_to_xmpp_errors
|
12
12
|
|
13
|
-
if TYPE_CHECKING:
|
14
|
-
from .base import BaseGateway
|
15
13
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
14
|
+
class VCardMixin(DispatcherMixin):
|
15
|
+
def __init__(self, xmpp):
|
16
|
+
super().__init__(xmpp)
|
17
|
+
xmpp.register_handler(
|
18
|
+
CoroutineCallback(
|
19
|
+
"get_vcard", StanzaPath("iq@type=get/vcard"), self.on_get_vcard
|
20
|
+
)
|
21
|
+
)
|
22
|
+
xmpp.remove_handler("VCardTemp")
|
22
23
|
xmpp.register_handler(
|
23
24
|
CoroutineCallback(
|
24
25
|
"VCardTemp",
|
25
26
|
StanzaPath("iq/vcard_temp"),
|
26
|
-
self.
|
27
|
+
self.__vcard_temp_handler,
|
27
28
|
)
|
28
29
|
)
|
29
30
|
# TODO: MR to slixmpp adding this to XEP-0084
|
@@ -32,7 +33,20 @@ class VCardTemp:
|
|
32
33
|
MetaData,
|
33
34
|
)
|
34
35
|
|
35
|
-
|
36
|
+
@exceptions_to_xmpp_errors
|
37
|
+
async def on_get_vcard(self, iq: Iq):
|
38
|
+
session = await self._get_session(iq, logged=True)
|
39
|
+
contact = await session.contacts.by_jid(iq.get_to())
|
40
|
+
vcard = await contact.get_vcard()
|
41
|
+
reply = iq.reply()
|
42
|
+
if vcard:
|
43
|
+
reply.append(vcard)
|
44
|
+
else:
|
45
|
+
reply.enable("vcard")
|
46
|
+
reply.send()
|
47
|
+
|
48
|
+
@exceptions_to_xmpp_errors
|
49
|
+
async def __vcard_temp_handler(self, iq: Iq):
|
36
50
|
if iq["type"] == "get":
|
37
51
|
return await self.__handle_get_vcard_temp(iq)
|
38
52
|
|
@@ -105,7 +119,7 @@ class VCardTemp:
|
|
105
119
|
reply.send()
|
106
120
|
|
107
121
|
async def __handle_set_vcard_temp(self, iq: Iq):
|
108
|
-
muc = await self.
|
122
|
+
muc = await self.get_muc_from_stanza(iq)
|
109
123
|
to = iq.get_to()
|
110
124
|
|
111
125
|
if to.resource:
|