Skip to content
9 changes: 5 additions & 4 deletions flexus_client_kit/ckit_ask_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,17 +229,18 @@ async def captured_thread_post_user_message(
ft_app_searchable: str,
content: Union[str, List[Dict[str, Any]]],
only_to_expert: str = "",
ftm_provenance: Optional[Dict[str, Any]] = None,
) -> str:
ftm_content = json.dumps(content)
async with http as h:
r = await h.execute(gql.gql("""
mutation CapturedThreadPostSafe($persona_id: String!, $ft_app_searchable: String!, $ftm_content: String!, $only_to_expert: String!) {
captured_thread_post_user_message(persona_id: $persona_id, ft_app_searchable: $ft_app_searchable, ftm_content: $ftm_content, only_to_expert: $only_to_expert)
mutation CapturedThreadPostSafe($persona_id: String!, $ft_app_searchable: String!, $ftm_content: String!, $only_to_expert: String!, $ftm_provenance: String) {
captured_thread_post_user_message(persona_id: $persona_id, ft_app_searchable: $ft_app_searchable, ftm_content: $ftm_content, only_to_expert: $only_to_expert, ftm_provenance: $ftm_provenance)
}"""),
variable_values={
"persona_id": persona_id,
"ft_app_searchable": ft_app_searchable,
"ftm_content": ftm_content,
"ftm_content": json.dumps(content),
"ftm_provenance": json.dumps(ftm_provenance) if ftm_provenance else None,
"only_to_expert": only_to_expert,
},
)
Expand Down
7 changes: 5 additions & 2 deletions flexus_client_kit/ckit_bot_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ async def marketplace_upsert_dev_bot(
marketable_auth_needed: List[str] = [],
marketable_auth_supported: List[str] = [],
marketable_auth_scopes: Optional[Dict[str, List[str]]] = None,
marketable_features: List[str] = [],
add_integrations_into_expert_system_prompt: Optional[List[ckit_integrations_db.IntegrationRecord]] = None,
) -> FBotInstallOutput:
assert ws_id, "Set FLEXUS_WORKSPACE environment variable to your workspace ID"
Expand Down Expand Up @@ -137,7 +138,7 @@ async def marketplace_upsert_dev_bot(
http = await client.use_http()
async with http as h:
r = await h.execute(
gql.gql(f"""mutation InstallBot($ws: String!, $name: String!, $ver: String!, $title1: String!, $title2: String!, $author: String!, $accent_color: String!, $occupation: String!, $desc: String!, $typical_group: String!, $repo: String!, $run: String!, $setup: String!, $featured: [FFeaturedActionInput!]!, $intro: String!, $model: String!, $daily: Int!, $inbox: Int!, $experts: [FMarketplaceExpertInput!]!, $schedule: String!, $big: String!, $small: String!, $tags: [String!]!, $forms: String, $required_policydocs: [String!]!, $auth_needed: [String!]!, $auth_supported: [String!]!, $auth_scopes: String, $max_inprogress: Int!) {{
gql.gql(f"""mutation InstallBot($ws: String!, $name: String!, $ver: String!, $title1: String!, $title2: String!, $author: String!, $accent_color: String!, $occupation: String!, $desc: String!, $typical_group: String!, $repo: String!, $run: String!, $setup: String!, $featured: [FFeaturedActionInput!]!, $intro: String!, $model: String!, $daily: Int!, $inbox: Int!, $experts: [FMarketplaceExpertInput!]!, $schedule: String!, $big: String!, $small: String!, $tags: [String!]!, $forms: String, $required_policydocs: [String!]!, $auth_needed: [String!]!, $auth_supported: [String!]!, $auth_scopes: String, $max_inprogress: Int!, $features: [String!]!) {{
marketplace_upsert_dev_bot(
ws_id: $ws,
marketable_name: $name,
Expand Down Expand Up @@ -167,7 +168,8 @@ async def marketplace_upsert_dev_bot(
marketable_auth_needed: $auth_needed,
marketable_auth_supported: $auth_supported,
marketable_auth_scopes: $auth_scopes,
marketable_max_inprogress: $max_inprogress
marketable_max_inprogress: $max_inprogress,
marketable_features: $features
) {{
{gql_utils.gql_fields(FBotInstallOutput)}
}}
Expand Down Expand Up @@ -202,6 +204,7 @@ async def marketplace_upsert_dev_bot(
"auth_supported": marketable_auth_supported,
"auth_scopes": json.dumps(marketable_auth_scopes) if marketable_auth_scopes else None,
"max_inprogress": marketable_max_inprogress,
"features": marketable_features,
},
)
return gql_utils.dataclass_from_dict(r["marketplace_upsert_dev_bot"], FBotInstallOutput)
Expand Down
14 changes: 14 additions & 0 deletions flexus_client_kit/ckit_integrations_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,19 @@ async def _init_telegram(rcx, setup):
integr_prompt=fi_messenger.MESSENGER_PROMPT,
))

elif name == "magic_desk":
from flexus_client_kit.integrations import fi_magic_desk
async def _init_magic_desk(rcx, setup):
return fi_magic_desk.IntegrationMagicDesk(rcx.fclient, rcx)
result.append(IntegrationRecord(
integr_name=name,
integr_tools=[fi_magic_desk.MAGIC_DESK_TOOL],
integr_init=_init_magic_desk,
integr_setup_handlers=lambda obj, rcx: [rcx.on_tool_call("magic_desk")(obj.called_by_model)],
integr_is_messenger=True,
integr_prompt="",
))

elif name.startswith("erp"): # "erp[meta, data]" or "erp[meta, data, crud, csv_import]"
from flexus_client_kit.integrations import fi_erp
subset = _parse_bracket_list(name)
Expand Down Expand Up @@ -383,5 +396,6 @@ async def _messenger_updated_message(msg: ckit_ask_model.FThreadMessageOutput):
# Don't worry, you can override it. The default reaction to assistant messages is to get it past messengers:
for m in rcx.messengers:
await m.look_assistant_might_have_posted_something(msg)
await m.look_user_message_got_confirmed(msg)

return result
153 changes: 153 additions & 0 deletions flexus_client_kit/integrations/fi_magic_desk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import json
import logging
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, Optional

import gql

from flexus_client_kit import ckit_ask_model, ckit_bot_exec, ckit_bot_query, ckit_client, ckit_cloudtool, ckit_kanban
from flexus_client_kit.integrations import fi_messenger

logger = logging.getLogger("mdesk")

MAGIC_DESK_TOOL = ckit_cloudtool.CloudTool(
strict=False,
name="magic_desk",
description="Interact with the Magic Desk chat widget. Call with op=\"help\" for usage.",
parameters={
"type": "object",
"properties": {
"op": {"type": "string", "description": "Start with 'help' for usage"},
"args": {"type": "object"},
},
},
)

HELP = """Help:

magic_desk(op="capture", args={"session_id": "uuid"})
Capture a Magic Desk chat session. Messages will appear here and your responses will be sent back.

magic_desk(op="uncapture")
Stop capturing this session.
"""


@dataclass
class ActivityMagicDesk:
session_id: str
text: str


class IntegrationMagicDesk(fi_messenger.FlexusMessenger):
platform_name = "magic_desk"
emessage_type = "MAGIC_DESK"

def __init__(self, fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext, default_fexp_name: str = "default"):
super().__init__(fclient, rcx)
self.default_fexp_name = default_fexp_name
self._activity_callback: Callable[[ActivityMagicDesk, bool], Awaitable[None]] = self.default_activity_to_inbox

def on_incoming_activity(self, handler: Callable[[ActivityMagicDesk, bool], Awaitable[None]]):
self._activity_callback = handler
return handler

async def default_activity_to_inbox(self, a: ActivityMagicDesk, already_posted: bool):
if already_posted:
return
await ckit_kanban.bot_kanban_post_into_inbox(
self.fclient, self.rcx.persona.persona_id,
title=f"Magic Desk session={a.session_id}\n{a.text}",
details_json=json.dumps({"session_id": a.session_id, "text": a.text}),
provenance_message="magic_desk_inbound",
fexp_name=self.default_fexp_name,
)

async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]]) -> str:
if not model_produced_args:
return HELP
op = model_produced_args.get("op", "")
args, args_error = ckit_cloudtool.sanitize_args(model_produced_args)
if args_error:
return args_error
if not op or "help" in op or "status" in op:
return HELP
if op == "capture":
session_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "session_id", None)
if not session_id:
return "Missing session_id parameter\n"
if already := self.recent_thread_that_captures(session_id):
if already.thread_fields.ft_id == toolcall.fcall_ft_id:
return "Already captured\n"
return fi_messenger.OTHER_CHAT_ALREADY_CAPTURING_MSG % session_id
http = await self.fclient.use_http()
await ckit_ask_model.thread_app_capture_patch(http, toolcall.fcall_ft_id, ft_app_searchable=f"magic_desk/{session_id}")
if fthread := self.rcx.latest_threads.get(toolcall.fcall_ft_id):
fthread.thread_fields.ft_app_searchable = f"magic_desk/{session_id}"
return fi_messenger.CAPTURE_SUCCESS_MSG % session_id + fi_messenger.CAPTURE_ADVICE_MSG
if op == "uncapture":
http = await self.fclient.use_http()
await ckit_ask_model.thread_app_capture_patch(http, toolcall.fcall_ft_id, ft_app_searchable="")
if fthread := self.rcx.latest_threads.get(toolcall.fcall_ft_id):
fthread.thread_fields.ft_app_searchable = ""
return fi_messenger.UNCAPTURE_SUCCESS_MSG
return fi_messenger.UNKNOWN_OPERATION_MSG % op

async def handle_emessage(self, emsg: ckit_bot_query.FExternalMessageOutput) -> None:
payload = emsg.emsg_payload if isinstance(emsg.emsg_payload, dict) else json.loads(emsg.emsg_payload)
text = payload.get("text", "")
session_id = emsg.emsg_from.split(":", 1)[1] if ":" in emsg.emsg_from else emsg.emsg_from
if not text.strip():
return
http = await self.fclient.use_http()
ft_id = await ckit_ask_model.captured_thread_post_user_message(http, self.rcx.persona.persona_id, f"magic_desk/{session_id}", text, ftm_provenance={"system_type": "captured_thread_post", "mdesk_id": emsg.emsg_external_id})
if ft_id:
logger.info("%s magic_desk inbound captured ft_id=%s session=%s: %s", self.rcx.persona.persona_id, ft_id, session_id, text[:120])
else:
logger.info("%s magic_desk inbound session=%s no capture: %s", self.rcx.persona.persona_id, session_id, text[:120])
await self._activity_callback(ActivityMagicDesk(session_id=session_id, text=text), bool(ft_id))

async def look_user_message_got_confirmed(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool:
if msg.ftm_role != "user" or msg.ftm_num < 0:
return False
searchable = msg.ft_app_searchable or ""
if not searchable.startswith("magic_desk/"):
return False
prov = msg.ftm_provenance if isinstance(msg.ftm_provenance, dict) else {}
if prov.get("system_type") != "captured_thread_post":
return False
session_id = searchable[len("magic_desk/"):]
text = fi_messenger.ftm_content_to_text(msg.ftm_content)
if not text.strip():
return False
http = await self.fclient.use_http()
async with http as h:
await h.execute(gql.gql("""
mutation MagicDeskConfirmUserMessage($session_id: String!, $text: String!, $ftm_alt: Int!, $ftm_num: Int!, $mdesk_id: String, $persona_id: String) {
magic_desk_deliver_reply(session_id: $session_id, text: $text, role: "user", ftm_alt: $ftm_alt, ftm_num: $ftm_num, mdesk_id: $mdesk_id, persona_id: $persona_id)
}"""),
variable_values={"session_id": session_id, "text": text, "ftm_alt": msg.ftm_alt, "ftm_num": msg.ftm_num, "mdesk_id": prov.get("mdesk_id"), "persona_id": self.rcx.persona.persona_id},
)
return True

async def look_assistant_might_have_posted_something(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool:
if msg.ftm_role != "assistant" or not msg.ftm_content:
return False
searchable = msg.ft_app_searchable or ""
if not searchable.startswith("magic_desk/"):
return False
session_id = searchable[len("magic_desk/"):]
text = fi_messenger.ftm_content_to_text(msg.ftm_content)
if "TASK_COMPLETED" in text and len(text) <= len("TASK_COMPLETED") + 6:
return False
text = text.replace("TASK_COMPLETED", "")
http = await self.fclient.use_http()
async with http as h:
await h.execute(gql.gql("""
mutation MagicDeskDeliverReply($session_id: String!, $text: String!, $ftm_alt: Int!, $ftm_num: Int!, $persona_id: String) {
magic_desk_deliver_reply(session_id: $session_id, text: $text, ftm_alt: $ftm_alt, ftm_num: $ftm_num, persona_id: $persona_id)
}"""),
variable_values={"session_id": session_id, "text": text, "ftm_alt": msg.ftm_alt, "ftm_num": msg.ftm_num, "persona_id": self.rcx.persona.persona_id},
)
logger.info("%s magic_desk reply to session=%s: %s", self.rcx.persona.persona_id, session_id, text[:80])
return True
15 changes: 14 additions & 1 deletion flexus_client_kit/integrations/fi_messenger.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from flexus_client_kit import ckit_ask_model, ckit_bot_exec, ckit_bot_query, ckit_client

MESSENGER_PROMPT = """
## Messaging Platforms (Telegram, Slack, WhatsApp, etc.)
## Messaging Platforms (Telegram, Slack, WhatsApp, Flexus Magic Desk, etc.)

Incoming messages from these platforms appear as kanban tasks when not captured.

Expand Down Expand Up @@ -67,6 +67,19 @@ async def handle_emessage(self, emsg: ckit_bot_query.FExternalMessageOutput) ->
async def look_assistant_might_have_posted_something(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool:
raise NotImplementedError

async def look_user_message_got_confirmed(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool:
return False


def ftm_content_to_text(content) -> str:
if isinstance(content, list):
return "\n\n".join(
x.get("m_content") or x.get("text", "")
for x in content
if isinstance(x, dict) and (x.get("m_type") or x.get("type")) == "text"
)
return content or ""


def is_text_file(data: bytes) -> bool:
if not data:
Expand Down
7 changes: 0 additions & 7 deletions flexus_simple_bots/karen/karen_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@
from flexus_client_kit import ckit_cloudtool
from flexus_client_kit import ckit_bot_exec
from flexus_client_kit import ckit_shutdown
from flexus_client_kit import ckit_ask_model
from flexus_client_kit import ckit_integrations_db
from flexus_client_kit.integrations import fi_slack
from flexus_client_kit.integrations import fi_discord2
from flexus_client_kit.integrations import fi_repo_reader
from flexus_simple_bots.karen import karen_install
Expand All @@ -26,7 +24,6 @@
*[t for rec in karen_install.KAREN_INTEGRATIONS for t in rec.integr_tools],
]


async def karen_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None:
setup = ckit_bot_exec.official_setup_mixing_procedure(karen_install.KAREN_SETUP_SCHEMA, rcx.persona.persona_setup)
integrations = await ckit_integrations_db.main_loop_integrations_init(karen_install.KAREN_INTEGRATIONS, rcx, setup)
Expand All @@ -46,10 +43,6 @@ async def karen_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.
watch_channels=setup["discord_watch_channels"],
)

@rcx.on_updated_thread
async def updated_thread_in_db(th: ckit_ask_model.FThreadOutput):
pass

@rcx.on_tool_call(fi_discord2.DISCORD_TOOL.name)
async def toolcall_discord(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str:
return await discord.called_by_model(toolcall, model_produced_args)
Expand Down
4 changes: 3 additions & 1 deletion flexus_simple_bots/karen/karen_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"slack",
"telegram",
"skills",
"magic_desk",
],
builtin_skills=KAREN_SKILLS,
)
Expand Down Expand Up @@ -114,7 +115,7 @@ async def install(
{"feat_question": "What people ask for today?", "feat_expert": "default", "feat_depends_on_setup": []},
],
marketable_intro_message="I'm here for your customers 24/7 — answering questions, remembering every detail, and always following up. I also deliver weekly feedback reports that help your team improve the product.",
marketable_preferred_model_default="grok-4-1-fast-non-reasoning",
marketable_preferred_model_default="gpt-5.4-nano",
marketable_experts=[(name, exp.filter_tools(tools)) for name, exp in EXPERTS],
add_integrations_into_expert_system_prompt=KAREN_INTEGRATIONS,
marketable_tags=["Customer Support"],
Expand All @@ -135,6 +136,7 @@ async def install(
"im:read",
],
},
marketable_features=["magic_desk"],
)


Expand Down
7 changes: 5 additions & 2 deletions flexus_simple_bots/karen/karen_prompts.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
short_prompt = """
from flexus_client_kit.integrations import fi_messenger

short_prompt = f"""
You are a VERY patient and a bit sarcastic tech support engineer. Here is what you typically do:

* Talk to people outside the company to help solve their problems on Discord, Telegram, guest channels on Slack
Expand Down Expand Up @@ -28,7 +30,7 @@
with a flexus_read_original() call that allows to read more text around the snippet
"""

very_limited = short_prompt + """
very_limited = short_prompt + f"""
# You Are Talking to a Customer

Tools you have are limited, some reminders:
Expand All @@ -37,6 +39,7 @@
* Don't talk about kanban board, just call the functions necessary
* Don't reveal task IDs, budget, internal processes

{fi_messenger.MESSENGER_PROMPT}
"""

# flexus_bot_kanban(op="restart", args={"chat_summary": "what was done"})
9 changes: 8 additions & 1 deletion flexus_simple_bots/vix/vix_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from flexus_client_kit.integrations import fi_resend
from flexus_client_kit.integrations import fi_shopify
from flexus_client_kit.integrations import fi_telegram
from flexus_client_kit.integrations import fi_magic_desk
from flexus_client_kit.integrations import fi_crm
from flexus_client_kit.integrations import fi_sched
from flexus_simple_bots.vix import vix_install
Expand All @@ -39,6 +40,7 @@
"print_widget",
"erp[meta, data, crud, csv_import]",
"crm[manage_contact, manage_deal, log_activity]",
"magic_desk",
],
builtin_skills=vix_install.VIX_SKILLS,
)
Expand All @@ -59,7 +61,7 @@
async def vix_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None:
setup = ckit_bot_exec.official_setup_mixing_procedure(vix_install.VIX_SETUP_SCHEMA, rcx.persona.persona_setup)

await ckit_integrations_db.main_loop_integrations_init(VIX_INTEGRATIONS, rcx, setup)
integrations = await ckit_integrations_db.main_loop_integrations_init(VIX_INTEGRATIONS, rcx, setup)
automations_integration = fi_crm_automations.IntegrationCrmAutomations(
fclient, rcx, setup, available_erp_tables=ERP_TABLES,
)
Expand All @@ -71,9 +73,14 @@ async def vix_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.Ro
telegram = fi_telegram.IntegrationTelegram(fclient, rcx)
await telegram.initialize()

magic_desk: fi_magic_desk.IntegrationMagicDesk = integrations["magic_desk"]
magic_desk.default_fexp_name = "sales"

@rcx.on_updated_message
async def updated_message_in_db(msg: ckit_ask_model.FThreadMessageOutput):
await telegram.look_assistant_might_have_posted_something(msg)
await magic_desk.look_assistant_might_have_posted_something(msg)
await magic_desk.look_user_message_got_confirmed(msg)

@rcx.on_updated_task
async def updated_task_in_db(t: ckit_kanban.FPersonaKanbanTaskOutput):
Expand Down
Loading