Skip to content

Commit 05a8f0e

Browse files
authored
Add tests & slight refactor to TelegramTransport key handling (#528)
This PR: 1. Adds a few additional tests to TelegramTransport 2. Improves TelegramTransport's handling of dynamically set keys (it already had this; this PR makes it a bit more robust) This will enable a few nice things form a user perspective: 1. Users can re-bind an Agent via the Web UI to a new Telegram Bot 2. Users can defer binding their Agent to a Telegram Bot until after they've created an instance Follow-on work is a PR to do for Telegram what we just did for Slack in the web UI: ![image](https://github.com/steamship-core/python-client/assets/63262/1974dc4a-2171-42cf-b90b-d0e6d79de18c)
1 parent 5d3ee70 commit 05a8f0e

10 files changed

Lines changed: 264 additions & 63 deletions

File tree

src/steamship/agents/examples/telegram_bot.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class TelegramBot(AgentService):
3939
"""
4040

4141
class TelegramBotConfig(Config):
42-
bot_token: str = Field(description="The secret token for your Telegram bot")
42+
telegram_bot_token: str = Field(description="The secret token for your Telegram bot")
4343

4444
config: TelegramBotConfig
4545

@@ -66,7 +66,7 @@ def __init__(self, **kwargs):
6666
TelegramTransport(
6767
client=self.client,
6868
# IMPORTANT: This is the TelegramTransportConfig, not the AgentService config!
69-
config=TelegramTransportConfig(bot_token=self.config.bot_token),
69+
config=TelegramTransportConfig(bot_token=self.config.telegram_bot_token),
7070
agent_service=self,
7171
)
7272
)

src/steamship/agents/mixins/transports/slack.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -556,15 +556,19 @@ def slack_respond(self, **kwargs) -> InvocableResponse[str]:
556556
@post("set_slack_access_token")
557557
def set_slack_access_token(self, token: str) -> InvocableResponse[str]:
558558
"""Set the slack access token."""
559-
kv = KeyValueStore(client=self.agent_service.client, store_identifier=SETTINGS_KVSTORE_KEY)
559+
kv = KeyValueStore(
560+
client=self.agent_service.client, store_identifier=self.setting_store_key()
561+
)
560562
kv.set("slack_token", {"token": token})
561563
return InvocableResponse(string="OK")
562564

563565
def get_slack_access_token(self) -> Optional[str]:
564566
"""Return the Slack Access token, which permits the agent to post to Slack."""
565567
if self.bot_token:
566568
return self.bot_token
567-
kv = KeyValueStore(client=self.agent_service.client, store_identifier=SETTINGS_KVSTORE_KEY)
569+
kv = KeyValueStore(
570+
client=self.agent_service.client, store_identifier=self.setting_store_key()
571+
)
568572
v = kv.get("slack_token")
569573
if not v:
570574
return None
@@ -578,3 +582,6 @@ def is_slack_token_set(self) -> InvocableResponse[bool]:
578582
if token is None:
579583
return InvocableResponse(json=False)
580584
return InvocableResponse(json=True)
585+
586+
def setting_store_key(self):
587+
return f"{SETTINGS_KVSTORE_KEY}-{self.agent_service.context.invocable_instance_handle}"

src/steamship/agents/mixins/transports/telegram.py

Lines changed: 138 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
from steamship.agents.mixins.transports.transport import Transport
1010
from steamship.agents.schema import EmitFunc, Metadata
1111
from steamship.agents.service.agent_service import AgentService
12-
from steamship.invocable import Config, InvocableResponse, InvocationContext, post
12+
from steamship.invocable import Config, InvocableResponse, post
1313
from steamship.utils.kv_store import KeyValueStore
1414

15+
SETTINGS_KVSTORE_KEY = "telegram-transport"
16+
1517

1618
class TelegramTransportConfig(Config):
1719
bot_token: Optional[str] = Field("", description="The secret token for your Telegram bot")
@@ -23,11 +25,9 @@ class TelegramTransportConfig(Config):
2325
class TelegramTransport(Transport):
2426
"""Experimental base class to encapsulate a Telegram communication channel."""
2527

26-
api_root: str
27-
bot_token: str
28+
bot_token: Optional[str]
2829
agent_service: AgentService
2930
config: TelegramTransportConfig
30-
context: InvocationContext
3131

3232
def __init__(
3333
self,
@@ -37,29 +37,37 @@ def __init__(
3737
):
3838
super().__init__(client=client)
3939
self.config = config
40-
self.store = KeyValueStore(self.client, store_identifier="_telegram_config")
41-
bot_token = (self.store.get("bot_token") or {}).get("token")
42-
self.bot_token = config.bot_token or bot_token
43-
self.api_root = f"{config.api_base}{self.bot_token}"
4440
self.agent_service = agent_service
41+
try:
42+
self.bot_token = self.get_telegram_access_token() or None
43+
except BaseException as e:
44+
logging.warning(e)
45+
self.bot_token = None
4546

4647
def instance_init(self):
47-
if self.bot_token:
48-
self.api_root = f"{self.config.api_base}{self.config.bot_token or self.bot_token}"
48+
if self.get_telegram_access_token():
4949
try:
50-
self._instance_init()
50+
self.telegram_connect_webhook()
5151
except Exception: # noqa: S110
5252
pass
5353

54-
def _instance_init(self):
54+
@post("telegram_connect_webhook")
55+
def telegram_connect_webhook(self):
56+
"""Register this AgentService with Telegram."""
5557
webhook_url = self.agent_service.context.invocable_url + "telegram_respond"
5658

59+
api_root = self.get_api_root()
60+
if not api_root:
61+
raise SteamshipError(
62+
message="Unable to determine Telegram API root -- perhaps your bot token isn't set?"
63+
)
64+
5765
logging.info(
58-
f"Setting Telegram webhook URL: {webhook_url}. Post is to {self.api_root}/setWebhook"
66+
f"Setting Telegram webhook URL: {webhook_url}. Post is to {api_root}/setWebhook"
5967
)
6068

6169
response = requests.get(
62-
f"{self.api_root}/setWebhook",
70+
f"{api_root}/setWebhook",
6371
params={
6472
"url": webhook_url,
6573
"allowed_updates": ["message"],
@@ -74,31 +82,38 @@ def _instance_init(self):
7482

7583
@post("telegram_webhook_info")
7684
def telegram_webhook_info(self) -> dict:
77-
return requests.get(self.api_root + "/getWebhookInfo").json()
78-
79-
@post("connect_telegram")
80-
def connect_telegram(self, bot_token: str):
81-
self.store.set("bot_token", {"token": bot_token})
82-
self.bot_token = bot_token
85+
api_root = self.get_api_root()
86+
if not api_root:
87+
raise SteamshipError(
88+
message="Unable to fetch Telegram API info -- perhaps your bot token isn't set?"
89+
)
8390

84-
try:
85-
self.instance_init()
86-
return "OK"
87-
except Exception as e:
88-
return f"Could not set webhook for bot. Exception: {e}"
91+
return requests.get(api_root + "/getWebhookInfo").json()
8992

9093
@post("telegram_disconnect_webhook")
9194
def telegram_disconnect_webhook(self, *args, **kwargs):
9295
"""Unsubscribe from Telegram updates."""
93-
requests.get(f"{self.api_root}/deleteWebhook")
96+
api_root = self.get_api_root()
97+
if not api_root:
98+
raise SteamshipError(
99+
message="Unable to disconnect from Telegram -- perhaps your bot token isn't set?"
100+
)
101+
102+
requests.get(f"{api_root}/deleteWebhook")
94103

95104
def _send(self, blocks: [Block], metadata: Metadata):
96105
"""Send a response to the Telegram chat."""
106+
api_root = self.get_api_root()
107+
if not api_root:
108+
raise SteamshipError(
109+
message="Unable to send to Telegram -- perhaps your bot token isn't set?"
110+
)
111+
97112
for block in blocks:
98113
chat_id = block.chat_id
99114
if block.is_text() or block.text:
100115
params = {"chat_id": int(chat_id), "text": block.text}
101-
requests.get(f"{self.api_root}/sendMessage", params=params)
116+
requests.get(f"{api_root}/sendMessage", params=params)
102117
elif block.is_image() or block.is_audio() or block.is_video():
103118
if block.is_image():
104119
suffix = "sendPhoto"
@@ -115,7 +130,7 @@ def _send(self, blocks: [Block], metadata: Metadata):
115130
temp_file.write(_bytes)
116131
temp_file.seek(0)
117132
resp = requests.post(
118-
url=f"{self.api_root}/{suffix}?chat_id={chat_id}",
133+
url=f"{api_root}/{suffix}?chat_id={chat_id}",
119134
files={key: temp_file},
120135
)
121136
if resp.status_code != 200:
@@ -129,12 +144,16 @@ def _send(self, blocks: [Block], metadata: Metadata):
129144
)
130145

131146
def _get_file(self, file_id: str) -> Dict[str, Any]:
132-
return requests.get(f"{self.api_root}/getFile", params={"file_id": file_id}).json()[
133-
"result"
134-
]
147+
api_root = self.get_api_root()
148+
if not api_root:
149+
raise SteamshipError(
150+
message="Unable to get Telegram file -- perhaps your bot token isn't set?"
151+
)
152+
153+
return requests.get(f"{api_root}/getFile", params={"file_id": file_id}).json()["result"]
135154

136155
def _get_file_url(self, file_id: str) -> str:
137-
return f"https://api.telegram.org/file/bot{self.bot_token}/{self._get_file(file_id)['file_path']}"
156+
return f"https://api.telegram.org/file/bot{self.get_telegram_access_token()}/{self._get_file(file_id)['file_path']}"
138157

139158
def _download_file(self, file_id: str):
140159
result = requests.get(self._get_file_url(file_id))
@@ -233,3 +252,90 @@ def telegram_respond(self, **kwargs) -> InvocableResponse[str]:
233252
self.send([response])
234253
# Even if we do nothing, make sure we return ok
235254
return InvocableResponse(string="OK")
255+
256+
@post("set_telegram_access_token")
257+
def set_telegram_access_token(self, token: str) -> InvocableResponse[str]:
258+
"""Set the telegram access token."""
259+
existing_token = self.get_telegram_access_token()
260+
if existing_token:
261+
try:
262+
self.telegram_disconnect_webhook()
263+
except BaseException as e:
264+
# Note: we don't want to fully fail here, because that would mean that a bot user that had some
265+
# other error relating to disconnecting would never be able to RE-connect to a new bot.
266+
logging.error(e)
267+
268+
kv = KeyValueStore(
269+
client=self.agent_service.client, store_identifier=self.setting_store_key()
270+
)
271+
kv.set("telegram_token", {"token": token})
272+
273+
# Now attempt to modify the connection in Telegram
274+
self.bot_token = token
275+
try:
276+
self.telegram_connect_webhook()
277+
return InvocableResponse(string="OK")
278+
except Exception as e:
279+
raise SteamshipError(message=f"Could not set Telegram Webhook. Exception: {e}")
280+
281+
def get_api_root(self) -> Optional[str]:
282+
"""Return the API root"""
283+
bot_token = self.get_telegram_access_token()
284+
api_base = self.config.api_base
285+
286+
# Ensure we have an API Base
287+
if not api_base:
288+
raise SteamshipError(message="Missing Telegram API Base")
289+
290+
# Ensure it ends in a trailing slash
291+
if api_base[-1] != "/":
292+
api_base += "/"
293+
294+
if bot_token:
295+
if ".steamship.run/" in api_base:
296+
# This is a special case for our testing pipeline -- it contains a mock Telegram server.
297+
return api_base
298+
else:
299+
return f"{api_base}{bot_token}"
300+
else:
301+
return None
302+
303+
def setting_store_key(self):
304+
return f"{SETTINGS_KVSTORE_KEY}-{self.agent_service.context.invocable_instance_handle}"
305+
306+
def get_telegram_access_token(self) -> Optional[str]:
307+
"""Return the Telegram Access token, which permits the agent to post to Telegram."""
308+
309+
# Warning: This can't be an 'is not None' check since the config system uses an empty string to represent None
310+
if self.bot_token:
311+
return self.bot_token
312+
313+
_dynamically_set_token = None
314+
_fallback_token = None
315+
316+
# Prefer the dynamically set token if available
317+
kv = KeyValueStore(
318+
client=self.agent_service.client, store_identifier=self.setting_store_key()
319+
)
320+
v = kv.get("telegram_token")
321+
if v:
322+
_dynamically_set_token = v.get("token", None)
323+
324+
# Fall back on the config-provided one
325+
if self.config:
326+
_fallback_token = self.config.bot_token
327+
328+
_return_token = _dynamically_set_token or _fallback_token
329+
330+
# Cache it to avoid another KV Store lookup and return
331+
self.bot_token = _return_token
332+
return _return_token
333+
334+
@post("is_telegram_token_set")
335+
def is_telegram_token_set(self) -> InvocableResponse[bool]:
336+
"""Return whether the Telegram token has been set as a way for a remote UI to check status."""
337+
338+
# Warning: This can't be an 'is not None' check since the config system uses an empty string to represent None
339+
if not self.get_telegram_access_token():
340+
return InvocableResponse(json=False)
341+
return InvocableResponse(json=True)

tests/assets/demo_package_spec.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,15 @@
225225
"verb": "GET",
226226
"doc": null
227227
},
228+
{
229+
"args": [],
230+
"className": "TestPackage",
231+
"config": {},
232+
"doc": null,
233+
"path": "/resp_false",
234+
"returns": "<class 'foo.InvocableResponse[dict]'>",
235+
"verb": "POST"
236+
},
228237
{
229238
"returns": "<class 'foo.InvocableResponse[bytes]'>",
230239
"args": [],
@@ -252,6 +261,15 @@
252261
"verb": "GET",
253262
"doc": null
254263
},
264+
{
265+
"args": [],
266+
"className": "TestPackage",
267+
"config": {},
268+
"doc": null,
269+
"path": "/resp_true",
270+
"returns": "<class 'foo.InvocableResponse[dict]'>",
271+
"verb": "POST"
272+
},
255273
{
256274
"returns": "<class 'foo.InvocableResponse[dict]'>",
257275
"args": [],

tests/assets/packages/demo_package.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ def resp_dict(self) -> InvocableResponse[dict]:
4848
def resp_obj(self) -> InvocableResponse[dict]:
4949
return InvocableResponse(json=TestObj(name="Foo"))
5050

51+
@post("resp_true")
52+
def resp_true(self) -> InvocableResponse[dict]:
53+
return InvocableResponse(json=True)
54+
55+
@post("resp_false")
56+
def resp_false(self) -> InvocableResponse[dict]:
57+
return InvocableResponse(json=False)
58+
5159
@get("resp_binary")
5260
def resp_binary(self) -> InvocableResponse[bytes]:
5361
_bytes = base64.b64decode(PALM_TREE_BASE_64)

tests/assets/packages/transports/test_transports_agent.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010

1111

1212
class TestTransportsAgentConfig(Config):
13-
bot_token: Optional[str] = ""
14-
api_base: Optional[str] = ""
13+
telegram_token: Optional[str] = ""
14+
telegram_api_base: Optional[str] = ""
1515
slack_api_base: Optional[str] = ""
1616

1717

@@ -78,25 +78,26 @@ def __init__(
7878
# Including the web widget transport on the telegram test
7979
# agent to make sure it doesn't interfere
8080
self.add_mixin(SteamshipWidgetTransport(client=client, agent_service=self))
81-
if self.config.bot_token:
82-
# Only add the mixin if a telegram key was provided.
83-
self.add_mixin(
84-
TelegramTransport(
85-
client=client,
86-
# TODO: We need to rename these telegram_token and telegram_api_base
87-
config=TelegramTransportConfig(
88-
bot_token=self.config.bot_token, api_base=self.config.api_base
89-
),
90-
agent_service=self,
91-
)
81+
82+
# Only add the mixin if a telegram key was provided.
83+
self.add_mixin(
84+
TelegramTransport(
85+
client=client,
86+
config=TelegramTransportConfig(
87+
bot_token=self.config.telegram_token, api_base=self.config.telegram_api_base
88+
),
89+
agent_service=self,
9290
)
91+
)
92+
9393
self.add_mixin(
9494
SlackTransport(
9595
client=client,
9696
config=SlackTransportConfig(slack_api_base=self.config.slack_api_base),
9797
agent_service=self,
9898
)
9999
)
100+
100101
# TODO: Future Transport authors: add your transport here.
101102

102103
@classmethod

0 commit comments

Comments
 (0)