From 0928abac7ef20221a3bef5db37b4a67ec49f89b2 Mon Sep 17 00:00:00 2001 From: Faouzi Braza Date: Mon, 29 Sep 2025 13:40:43 +0200 Subject: [PATCH 1/2] feat: add slack agent endpoint, update test, refactor code --- app/api/routers/slack_agent.py | 52 +++++++++++++++++++ app/main.py | 3 +- app/pompts/__init__.py | 0 app/pompts/slack.md | 50 ++++++++++++++++++ app/tools/slack.py | 17 ++++-- tests/test_ava_slack_post_message.py | 25 ++------- ...{test_health.py => test_health_enpoint.py} | 0 tests/test_slack_enpoint.py | 18 +++++++ 8 files changed, 138 insertions(+), 27 deletions(-) create mode 100644 app/api/routers/slack_agent.py create mode 100644 app/pompts/__init__.py create mode 100644 app/pompts/slack.md rename tests/{test_health.py => test_health_enpoint.py} (100%) create mode 100644 tests/test_slack_enpoint.py diff --git a/app/api/routers/slack_agent.py b/app/api/routers/slack_agent.py new file mode 100644 index 0000000..0f1c0a5 --- /dev/null +++ b/app/api/routers/slack_agent.py @@ -0,0 +1,52 @@ +import os +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import APIRouter +from pydantic import BaseModel +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openrouter import OpenRouterProvider +from slack_sdk import WebClient + +from app.tools import slack + +_ = load_dotenv() + +open_router_token = os.environ.get("OPEN_ROUTER_TOKEN", "") +slack_token = os.environ.get("SLACK_USER_TOKEN", "") +client = WebClient(token=slack_token) + +prompt_path = Path(__file__).resolve().parents[2] / "pompts" / "slack.md" +prompt_instructions = prompt_path.read_text(encoding="utf-8") + +model = OpenAIChatModel( + "z-ai/glm-4.5", + provider=OpenRouterProvider(api_key=open_router_token), +) + +agent = Agent( + model=model, + deps_type=slack.Deps, + instructions=prompt_instructions, +) + + +class Prompt(BaseModel): + user_instruction: str + + +router = APIRouter() + + +@router.post("/slack", tags=["slack"]) +def prompt_slack_agent(prompt: Prompt): + try: + result = agent.run_sync( + f"{prompt.user_instruction}", + deps=slack.Deps(client=client), + toolsets=[slack.slack_toolset], # type: ignore + ) + return {"content": result.output, "status": "ok"} + except Exception as e: + return {"msg": str(e), "status": "failed"} diff --git a/app/main.py b/app/main.py index fbe0200..a6e6356 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI -from app.api.routers import health +from app.api.routers import health, slack_agent app = FastAPI(title="AVA Agents", version="0.1.0") app.include_router(health.router) +app.include_router(slack_agent.router) diff --git a/app/pompts/__init__.py b/app/pompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/pompts/slack.md b/app/pompts/slack.md new file mode 100644 index 0000000..1b3a837 --- /dev/null +++ b/app/pompts/slack.md @@ -0,0 +1,50 @@ +# Role: You are “AVA Slack Operator,” an automation agent that executes Slack tasks for the user + +## Core objectives + +- Carefully understand the user’s goal, constraints, and urgency. +- Plan the steps before acting; pick the minimal, correct tool(s) to complete the task. +- Execute steps in the right order. Verify results where possible. + +## Tool usage + +- Use the provided toolsets to perform actions. Do not simulate results. +- When posting a message, use the tool slack.chat.postMessage with parameters: + +```text +channel: Slack channel name or ID (e.g., “general”, “#general”, “C123…”). Normalize “#general” to “general” unless an ID is provided. +text: The exact message to send. Preserve formatting, emojis (:rocket:), mentions, code blocks, and links exactly as the user provided unless asked to revise. +``` + +## Information extraction + +Robustly extract the target channel and message text from natural language: + +- Examples to recognize: “post ‘hello’ in general”, “send to #general: hello”, “announce in general → hello”, “publish the following in general: …” +- If the user wraps the message in quotes, treat the quoted portion as exact text. +- If multiple channels or none are specified, ask for clarification before posting. +- Avoid adding your own commentary to the message unless explicitly requested. + +## Confirmation policy + +- If the channel is ambiguous, message content is unclear, or the action seems risky (e.g., mass mentions like @channel/@here), ask for confirmation. +- If the user gives both a clear channel and exact message, proceed without extra confirmation. + +## Safety and etiquette + +- Do not leak secrets or tokens. Never echo environment values or credentials. +- Respect team etiquette; avoid mass mentions unless explicitly directed. + +## Output style + +- Internally plan your steps, but don’t expose internal reasoning. +- After tool execution, return a concise confirmation describing what was done and any important result data (e.g., message timestamp). + +## Examples + +- User: “Post this message ‘Deployment succeeded :tada:’ in channel ‘general’.” +- Plan: Extract channel=“general”, text=“Deployment succeeded :tada:”. +- Tool: slack.chat.postMessage {channel: “general”, text: “Deployment succeeded :tada:”} +- User: “Send to #general: We’ll restart at 5pm.” +- Plan: channel=“general”, text=“We’ll restart at 5pm.” +- Tool: slack.chat.postMessage {channel: “general”, text: “We’ll restart at 5pm.”} diff --git a/app/tools/slack.py b/app/tools/slack.py index ad51ad0..4117777 100644 --- a/app/tools/slack.py +++ b/app/tools/slack.py @@ -3,7 +3,7 @@ from pydantic_ai import RunContext from pydantic_ai.toolsets import FunctionToolset from slack_sdk import WebClient -from slack_sdk.web.slack_response import SlackResponse +from slack_sdk.errors import SlackApiError from app.models.tools import SlackChatPostMessageParams @@ -17,10 +17,17 @@ class Deps: @slack_toolset.tool(name="slack.chat.postMessage") -def post_message( - ctx: RunContext[Deps], params: SlackChatPostMessageParams -) -> SlackResponse: +def post_message(ctx: RunContext[Deps], params: SlackChatPostMessageParams) -> dict: """ Use this function to post a message in the specified channel """ - return ctx.deps.client.chat_postMessage(channel=params.channel, text=params.text) + try: + _ = ctx.deps.client.chat_postMessage(channel=params.channel, text=params.text) + return { + "status": 200, + "result": "Message has been posted", + "channel": f"{params.channel}", + "msg": f"{params.text}", + } + except SlackApiError as exc: + return {"status": 500, "Slack API error": f"{exc.response['error']}"} diff --git a/tests/test_ava_slack_post_message.py b/tests/test_ava_slack_post_message.py index dfdb955..0244127 100644 --- a/tests/test_ava_slack_post_message.py +++ b/tests/test_ava_slack_post_message.py @@ -13,24 +13,6 @@ _ = load_dotenv() -def test_health_ava_can_post_message(): - token = os.environ.get("SLACK_BOT_TOKEN") - if not token: - pytest.skip("SLACK_BOT_TOKEN not set; live Slack test skipped") - - client = WebClient(token=token) - message_text = "AVA backend live smoke :rocket:" - - try: - response = client.chat_postMessage(channel="sandbox", text=message_text) - except SlackApiError as exc: - pytest.fail(f"Slack API error: {exc.response['error']}") - - assert response["ok"] is True - assert response["message"]["text"] == message_text # type: ignore - assert "ts" in response - - @pytest.mark.parametrize( "channel,text,token", [ @@ -60,9 +42,10 @@ def test_post_message_with_ctx(channel: str, text: str, token: str | None): except SlackApiError as exc: pytest.fail(f"Slack API error: {exc.response['error']}") - assert response["ok"] is True - assert response["message"]["text"] == text - assert "ts" in response + assert response["status"] == 200 + assert response["result"] == "Message has been posted" + assert response["channel"] == tool_params.channel + assert response["msg"] == tool_params.text def test_slack_tools_are_synced(): diff --git a/tests/test_health.py b/tests/test_health_enpoint.py similarity index 100% rename from tests/test_health.py rename to tests/test_health_enpoint.py diff --git a/tests/test_slack_enpoint.py b/tests/test_slack_enpoint.py new file mode 100644 index 0000000..d07a05f --- /dev/null +++ b/tests/test_slack_enpoint.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.routers import slack_agent + +app = FastAPI(title="testing API") +app.include_router(slack_agent.router) + + +client = TestClient(app) +instructions = ( + "Post in the general channel that our team meeting will be delayed by 30 minutes" +) + + +def test_read_main(): + response = client.post("/slack", json={"user_instruction": f"{instructions}"}) + assert response.json() == {"status": "ok"} From c880d5cf32692991cdda00e98e81f509586341ae Mon Sep 17 00:00:00 2001 From: Faouzi Braza Date: Mon, 29 Sep 2025 13:55:54 +0200 Subject: [PATCH 2/2] build & test: add secret to action, clear out token plain in pytest output --- .github/workflows/ci.yml | 5 +++++ tests/test_ava_slack_post_message.py | 6 ++++-- tests/test_slack_enpoint.py | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3125b87..bc6ed3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,11 @@ on: branches: - "main" +env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_USER_TOKEN: ${{ secrets.SLACK_USER_TOKEN }} + OPEN_ROUTER_TOKEN: ${{ secrets.OPEN_ROUTER_TOKEN }} + jobs: test: runs-on: ubuntu-latest diff --git a/tests/test_ava_slack_post_message.py b/tests/test_ava_slack_post_message.py index 0244127..054a0bf 100644 --- a/tests/test_ava_slack_post_message.py +++ b/tests/test_ava_slack_post_message.py @@ -16,15 +16,17 @@ @pytest.mark.parametrize( "channel,text,token", [ - ( + pytest.param( "sandbox", "Hello, AVA backend live smoke :rocket:", os.environ.get("SLACK_BOT_TOKEN"), + id="bot-token", ), - ( + pytest.param( "general", "Hello, User is live smoke :rocket:", os.environ.get("SLACK_USER_TOKEN"), + id="user-token", ), ], ) diff --git a/tests/test_slack_enpoint.py b/tests/test_slack_enpoint.py index d07a05f..6bc9b4d 100644 --- a/tests/test_slack_enpoint.py +++ b/tests/test_slack_enpoint.py @@ -15,4 +15,4 @@ def test_read_main(): response = client.post("/slack", json={"user_instruction": f"{instructions}"}) - assert response.json() == {"status": "ok"} + assert response.json()["status"] == "ok"