Compare commits
13 Commits
feature/lo
...
feature/me
| Author | SHA1 | Date | |
|---|---|---|---|
| 43dbd5e941 | |||
| 1dde6ea01e | |||
| ce3a7716f6 | |||
| 5cfd29b884 | |||
| 200500ecd1 | |||
| d8d2c43ade | |||
| bf1bc43e7e | |||
| baa2dda8d5 | |||
| 949dfc309b | |||
| 97555d44af | |||
| 2827ffa2d4 | |||
| f8ed4ccd2a | |||
| b71d8e7fd0 |
48
.env.example
48
.env.example
@@ -18,13 +18,6 @@ DEEPSEEK_WORKSPACES='[
|
|||||||
}
|
}
|
||||||
]'
|
]'
|
||||||
|
|
||||||
# Legacy single-workspace fallback (used if DEEPSEEK_WORKSPACES is not set)
|
|
||||||
# SLACK_BOT_TOKEN=xoxb-...
|
|
||||||
# SLACK_APP_TOKEN=xapp-...
|
|
||||||
# DEEPSEEK_API_KEY=sk-...
|
|
||||||
|
|
||||||
# -----------------------------------------------------
|
|
||||||
|
|
||||||
# Bender bot — multi-workspace config (JSON array)
|
# Bender bot — multi-workspace config (JSON array)
|
||||||
# Each entry needs: name, slack_bot_token, slack_app_token, deepseek_api_key
|
# Each entry needs: name, slack_bot_token, slack_app_token, deepseek_api_key
|
||||||
BENDER_WORKSPACES='[
|
BENDER_WORKSPACES='[
|
||||||
@@ -42,13 +35,6 @@ BENDER_WORKSPACES='[
|
|||||||
}
|
}
|
||||||
]'
|
]'
|
||||||
|
|
||||||
# Legacy single-workspace fallback (used if BENDER_WORKSPACES is not set)
|
|
||||||
# SLACK_BOT_TOKEN_BENDER=xoxb-...
|
|
||||||
# SLACK_APP_TOKEN_BENDER=xapp-...
|
|
||||||
# DEEPSEEK_API_KEY_BENDER=sk-...
|
|
||||||
|
|
||||||
# -----------------------------------------------------
|
|
||||||
|
|
||||||
# Kimi bot — multi-workspace config (JSON array)
|
# Kimi bot — multi-workspace config (JSON array)
|
||||||
# Each entry needs: name, slack_bot_token, slack_app_token, kimi_api_key
|
# Each entry needs: name, slack_bot_token, slack_app_token, kimi_api_key
|
||||||
KIMI_WORKSPACES='[
|
KIMI_WORKSPACES='[
|
||||||
@@ -60,13 +46,6 @@ KIMI_WORKSPACES='[
|
|||||||
}
|
}
|
||||||
]'
|
]'
|
||||||
|
|
||||||
# Legacy single-workspace fallback (used if KIMI_WORKSPACES is not set)
|
|
||||||
# SLACK_BOT_TOKEN=xoxb-...
|
|
||||||
# SLACK_APP_TOKEN=xapp-...
|
|
||||||
# KIMI_API_KEY=sk-...
|
|
||||||
|
|
||||||
# -----------------------------------------------------
|
|
||||||
|
|
||||||
# MiniMax bot — multi-workspace config (JSON array)
|
# MiniMax bot — multi-workspace config (JSON array)
|
||||||
# Each entry needs: name, slack_bot_token, slack_app_token, minimax_api_key
|
# Each entry needs: name, slack_bot_token, slack_app_token, minimax_api_key
|
||||||
MINIMAX_WORKSPACES='[
|
MINIMAX_WORKSPACES='[
|
||||||
@@ -78,7 +57,26 @@ MINIMAX_WORKSPACES='[
|
|||||||
}
|
}
|
||||||
]'
|
]'
|
||||||
|
|
||||||
# Legacy single-workspace fallback (used if MINIMAX_WORKSPACES is not set)
|
# wiki-charlesreid1 MediaWiki connection (used by its mediawiki-mcp sidecar)
|
||||||
# SLACK_BOT_TOKEN=xoxb-...
|
# These are substituted into mediawiki-mcp-config.json by the MCP server
|
||||||
# SLACK_APP_TOKEN=xapp-...
|
WIKI_CHARLESREID1_URL=https://charlesreid1.com
|
||||||
# MINIMAX_API_KEY=sk-...
|
WIKI_CHARLESREID1_BOT_USER=YourBotUser@YourBotPasswordName
|
||||||
|
WIKI_CHARLESREID1_BOT_PASS=YourBotPassword
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
|
||||||
|
# wiki-charlesreid1 bot — Slack workspace configs (JSON array)
|
||||||
|
# One entry per Slack workspace the bot connects to.
|
||||||
|
# Same convention as DEEPSEEK_WORKSPACES, BENDER_WORKSPACES, etc.
|
||||||
|
# Required per entry: name, slack_bot_token, slack_app_token, deepseek_api_key
|
||||||
|
# Optional per entry: deepseek_api_url, deepseek_model
|
||||||
|
WIKI_CHARLESREID1_WORKSPACES='[
|
||||||
|
{
|
||||||
|
"name": "workspace-1",
|
||||||
|
"slack_bot_token": "xoxb-...",
|
||||||
|
"slack_app_token": "xapp-...",
|
||||||
|
"deepseek_api_key": "sk-...",
|
||||||
|
"deepseek_model": "deepseek-v4-pro"
|
||||||
|
}
|
||||||
|
]'
|
||||||
|
|
||||||
|
|||||||
13
DETAILS.md
13
DETAILS.md
@@ -55,19 +55,6 @@ DEEPSEEK_WORKSPACES='[
|
|||||||
]'
|
]'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Legacy Single-Workspace Fallback
|
|
||||||
|
|
||||||
If the JSON env var is not set, each bot falls back to legacy env vars:
|
|
||||||
|
|
||||||
| Bot | Legacy Env Vars |
|
|
||||||
|---|---|
|
|
||||||
| DeepSeek | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `DEEPSEEK_API_KEY` |
|
|
||||||
| Bender | `SLACK_BOT_TOKEN_BENDER`, `SLACK_APP_TOKEN_BENDER`, `DEEPSEEK_API_KEY_BENDER` |
|
|
||||||
| Kimi | `SLACK_BOT_TOKEN_KIMI`, `SLACK_APP_TOKEN_KIMI`, `KIMI_API_KEY` |
|
|
||||||
| MiniMax | `SLACK_BOT_TOKEN_MINIMAX`, `SLACK_APP_TOKEN_MINIMAX`, `MINIMAX_API_KEY` |
|
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and fill in your values. The `.env` file is git-ignored.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|||||||
174
PlanMediaWikiMCP.md
Normal file
174
PlanMediaWikiMCP.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Plan: Create `wiki-charlesreid1-bot` — MediaWiki-powered Slack Bot
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The repo has 4 Slack chatbots (deepseek, bender, kimi, minimax) that bridge Slack @-mentions to AI model APIs. The deepseek bot is the most capable — it has a tool-calling loop with `web_search` and `web_fetch` tools. The goal is to create a new bot (`wiki-charlesreid1-bot`) that is a copy of deepseek with the same tools **plus** MediaWiki tools provided by the [MediaWiki MCP Server](https://github.com/ProfessionalWiki/MediaWiki-MCP-Server). This is the first of potentially several MediaWiki bots, so the pattern should be easy to replicate for other wikis. All wiki bots use a `wiki-` prefix to avoid @-mention conflicts with real usernames in Slack (e.g. `@wiki-charlesreid1` won't autocomplete-collide with `@charlesreid1`).
|
||||||
|
|
||||||
|
The MCP server runs as a **sidecar Docker container** using HTTP transport. The bot connects to it over the Docker network, discovers available tools at startup, and routes tool calls from DeepSeek to the MCP server. Authentication to the private MediaWiki uses **bot passwords** passed as environment variables.
|
||||||
|
|
||||||
|
Because this bot has **write access** to a wiki, it should be set up with more restricted Slack permissions than deepseek — not freely addable to any channel by anyone.
|
||||||
|
|
||||||
|
## Slack App Setup
|
||||||
|
|
||||||
|
This bot needs its own **separate Slack app** (not reusing the DeepSeek app). Users will `@wiki-charlesreid1` to interact with it. The `wiki-` prefix prevents Slack autocomplete from colliding with the `@charlesreid1` username. Steps:
|
||||||
|
|
||||||
|
1. **Create a new Slack app** at [api.slack.com/apps](https://api.slack.com/apps) — use the same manifest as DeepSeek but change `display_name` to `"wiki-charlesreid1"` and `name` to `"wiki-charlesreid1"`.
|
||||||
|
2. **Restrict who can add it**: Since this bot has wiki write access, don't let anyone freely add it to channels. In the Slack admin panel under **Manage Apps**, you can restrict which members can install/add the app, or require admin approval. In a small Slack this is low-risk, but worth noting.
|
||||||
|
3. **Generate tokens**: Same as other bots — get the Bot User OAuth Token (`xoxb-`) and App-Level Token (`xapp-`). These go into `WIKI_CHARLESREID1_WORKSPACES`.
|
||||||
|
4. **Scopes are the same** as the other bots: `app_mentions:read`, `chat:write`, `reactions:write`, `files:write`.
|
||||||
|
|
||||||
|
The bot runs as its own container alongside (not instead of) DeepSeek. Both can be running simultaneously — they're separate Slack apps responding to different @-mentions.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Slack ──@mention──→ wiki-charlesreid1-bot (Python, DeepSeek API)
|
||||||
|
│
|
||||||
|
├── web_search (local, DuckDuckGo)
|
||||||
|
├── web_fetch (local, httpx)
|
||||||
|
└── get-page, search-page, create-page, ... (via MCP)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
mediawiki-mcp-server (Docker sidecar, HTTP :3000)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
charlesreid1.com MediaWiki
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
### 1. `wiki-charlesreid1/bot.py` — Fork of `deepseek/bot.py` with MCP integration
|
||||||
|
|
||||||
|
Start from `deepseek/bot.py` and add:
|
||||||
|
|
||||||
|
- **MCP client via `mcp` Python SDK**: Use `streamablehttp_client` to connect to the sidecar over HTTP.
|
||||||
|
- **Tool discovery at startup**: Call `session.list_tools()`, convert MCP tool schemas to DeepSeek function-calling format, merge into the tools list alongside `web_search`/`web_fetch`.
|
||||||
|
- **Tool dispatch in the loop**: Extend `TOOL_EXECUTORS` — any tool name not in the local registry gets dispatched to the MCP server via `session.call_tool()`.
|
||||||
|
- **MCP server URL from env**: Read `MCP_SERVER_URL` from workspace config or env (default `http://mediawiki-mcp:3000`).
|
||||||
|
- **Workspace env var**: `WIKI_CHARLESREID1_WORKSPACES` (same JSON structure, still uses `deepseek_api_key`).
|
||||||
|
|
||||||
|
Key new functions:
|
||||||
|
- `connect_mcp(url)` — open a `streamablehttp_client` session, call `initialize()` + `list_tools()`, return session handle + tool list.
|
||||||
|
- `mcp_tools_to_deepseek(tools)` — convert MCP `Tool` objects to DeepSeek function-calling schema dicts.
|
||||||
|
- `execute_mcp_tool(session, name, args)` — call `session.call_tool(name, args)`, extract text from result content blocks.
|
||||||
|
|
||||||
|
Integration into existing flow:
|
||||||
|
- `main()` connects to MCP before starting workspace handlers, passes session into `make_app()`.
|
||||||
|
- `make_app()` receives the MCP session + merged tools list.
|
||||||
|
- `chat_with_tools()` uses the merged tools list and dispatches accordingly.
|
||||||
|
|
||||||
|
### 2. `wiki-charlesreid1/requirements.txt`
|
||||||
|
|
||||||
|
```
|
||||||
|
slack-bolt>=1.28.0
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
httpx>=0.28.1
|
||||||
|
md2mrkdwn>=0.4.3
|
||||||
|
duckduckgo-search>=8.0.0
|
||||||
|
mcp>=1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `mediawiki-mcp-config.json` (repo root)
|
||||||
|
|
||||||
|
MCP server config file. Uses `${ENV_VAR}` substitution so credentials stay in `.env`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"wikis": {
|
||||||
|
"charlesreid1": {
|
||||||
|
"server": "${WIKI_CHARLESREID1_URL}",
|
||||||
|
"scriptpath": "/w",
|
||||||
|
"articlepath": "/wiki",
|
||||||
|
"credentials": {
|
||||||
|
"botPassword": {
|
||||||
|
"username": "${WIKI_CHARLESREID1_BOT_USER}",
|
||||||
|
"password": "${WIKI_CHARLESREID1_BOT_PASS}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultWiki": "charlesreid1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `docker-compose.yml` — Add two new services
|
||||||
|
|
||||||
|
Add after the existing services:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mediawiki-mcp:
|
||||||
|
image: ghcr.io/professionalwiki/mediawiki-mcp-server
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
CONFIG: /config/config.json
|
||||||
|
MCP_TRANSPORT: http
|
||||||
|
MCP_BIND: 0.0.0.0
|
||||||
|
MCP_LOG_LEVEL: info
|
||||||
|
MCP_ALLOW_STATIC_FALLBACK: "true"
|
||||||
|
volumes:
|
||||||
|
- ./mediawiki-mcp-config.json:/config/config.json:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
wiki-charlesreid1-bot:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
APP: wiki-charlesreid1
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
MCP_SERVER_URL: http://mediawiki-mcp:3000
|
||||||
|
depends_on:
|
||||||
|
- mediawiki-mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. `.env.example` — Add new sections
|
||||||
|
|
||||||
|
Append:
|
||||||
|
|
||||||
|
```
|
||||||
|
# wiki-charlesreid1 bot — multi-workspace config
|
||||||
|
WIKI_CHARLESREID1_WORKSPACES='[
|
||||||
|
{
|
||||||
|
"name": "workspace-1",
|
||||||
|
"slack_bot_token": "xoxb-...",
|
||||||
|
"slack_app_token": "xapp-...",
|
||||||
|
"deepseek_api_key": "sk-..."
|
||||||
|
}
|
||||||
|
]'
|
||||||
|
|
||||||
|
# MediaWiki MCP server credentials (used by mediawiki-mcp-config.json)
|
||||||
|
WIKI_CHARLESREID1_URL=https://charlesreid1.com
|
||||||
|
WIKI_CHARLESREID1_BOT_USER=BotName@bot-password-name
|
||||||
|
WIKI_CHARLESREID1_BOT_PASS=your-bot-password
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. `SETUP.md` — Add wiki-charlesreid1-bot section
|
||||||
|
|
||||||
|
Add a section that covers:
|
||||||
|
- **Slack app**: Create a new Slack app named "wiki-charlesreid1" following the same steps as the other bots (Steps 1-3 in existing SETUP.md). Same scopes, same manifest structure, just a different `display_name`. The `wiki-` prefix avoids Slack autocomplete conflicts with the `@charlesreid1` username. Note to restrict who can add it to channels since it has wiki write access.
|
||||||
|
- **DeepSeek API key**: Same as other bots (Step 4).
|
||||||
|
- **MediaWiki bot password** (new step): Go to `Special:BotPasswords` on the wiki, create a bot password with grants for reading, editing, creating pages, and uploading files. Copy the username (`User@BotPasswordName`) and password.
|
||||||
|
- **Environment config**: `WIKI_CHARLESREID1_WORKSPACES` JSON (with `slack_bot_token`, `slack_app_token`, `deepseek_api_key`) plus `WIKI_CHARLESREID1_URL`, `WIKI_CHARLESREID1_BOT_USER`, `WIKI_CHARLESREID1_BOT_PASS`.
|
||||||
|
- **Security note**: This bot can edit wiki pages. Be thoughtful about which channels it's invited to.
|
||||||
|
|
||||||
|
### 7. `README.md` — Add wiki-charlesreid1-bot to the bot table
|
||||||
|
|
||||||
|
Add a row for the new bot in the existing table.
|
||||||
|
|
||||||
|
## MCP Client Session Management
|
||||||
|
|
||||||
|
The `mcp` SDK's `streamablehttp_client` is an async context manager. For a long-running bot, we need to keep the session alive. Approach:
|
||||||
|
|
||||||
|
- Open the `streamablehttp_client` context in `main()` and hold it for the lifetime of the process.
|
||||||
|
- Pass the `ClientSession` into `make_app()` so all workspace handlers share it.
|
||||||
|
- If the MCP server is temporarily unavailable, tool calls return errors gracefully (same pattern as existing tool error handling).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `docker compose build wiki-charlesreid1-bot mediawiki-mcp` — both containers build
|
||||||
|
2. `docker compose up mediawiki-mcp` — MCP server starts, logs show listening on :3000
|
||||||
|
3. `docker compose up wiki-charlesreid1-bot` — bot connects to MCP server, logs list discovered tools
|
||||||
|
4. Slack test: `@wiki-charlesreid1 search the web for Python asyncio` — uses `web_search` tool
|
||||||
|
5. Slack test: `@wiki-charlesreid1 find pages about X on the wiki` — uses MCP `search-page` tool
|
||||||
|
6. Slack test: `@wiki-charlesreid1 get the wiki page called Main Page` — uses MCP `get-page` tool
|
||||||
@@ -10,6 +10,7 @@ A collection of Slack chatbots that bridge `@mentions` to various AI model APIs
|
|||||||
| Bender | `bender/bot.py` | DeepSeek | Bender Rodríguez persona (system prompt in `bender/bender.md`) |
|
| Bender | `bender/bot.py` | DeepSeek | Bender Rodríguez persona (system prompt in `bender/bender.md`) |
|
||||||
| Kimi | `kimi/bot.py` | Moonshot Kimi | General-purpose assistant |
|
| Kimi | `kimi/bot.py` | Moonshot Kimi | General-purpose assistant |
|
||||||
| MiniMax | `minimax/bot.py` | MiniMax | General-purpose assistant |
|
| MiniMax | `minimax/bot.py` | MiniMax | General-purpose assistant |
|
||||||
|
| wiki-charlesreid1 | `wiki-charlesreid1/bot.py` | DeepSeek + MediaWiki MCP | Wiki assistant with page read/write/search via MCP |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ docker compose up -d
|
|||||||
|
|
||||||
Each bot needs a Slack app configured with Socket Mode. See [SETUP.md](SETUP.md) for a full walkthrough of creating the Slack app and generating tokens.
|
Each bot needs a Slack app configured with Socket Mode. See [SETUP.md](SETUP.md) for a full walkthrough of creating the Slack app and generating tokens.
|
||||||
|
|
||||||
For configuration details (multi-workspace setup, environment variables, legacy fallbacks, running without Docker), see [DETAILS.md](DETAILS.md).
|
For configuration details (multi-workspace setup, environment variables, running without Docker), see [DETAILS.md](DETAILS.md).
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
|
|||||||
46
SETUP.md
46
SETUP.md
@@ -189,6 +189,24 @@ BENDER_WORKSPACES='[
|
|||||||
]'
|
]'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**For the wiki-charlesreid1 bot (`wiki-charlesreid1/bot.py`)** — set `WIKI_CHARLESREID1_WORKSPACES` plus MediaWiki credentials:
|
||||||
|
|
||||||
|
```
|
||||||
|
WIKI_CHARLESREID1_WORKSPACES='[
|
||||||
|
{
|
||||||
|
"name": "my-workspace",
|
||||||
|
"slack_bot_token": "xoxb-...",
|
||||||
|
"slack_app_token": "xapp-...",
|
||||||
|
"deepseek_api_key": "sk-..."
|
||||||
|
}
|
||||||
|
]'
|
||||||
|
|
||||||
|
# MediaWiki MCP server credentials
|
||||||
|
WIKI_CHARLESREID1_URL=https://charlesreid1.com
|
||||||
|
WIKI_CHARLESREID1_BOT_USER=BotName@bot-password-name
|
||||||
|
WIKI_CHARLESREID1_BOT_PASS=your-bot-password
|
||||||
|
```
|
||||||
|
|
||||||
Each entry requires:
|
Each entry requires:
|
||||||
|
|
||||||
| Key | Description |
|
| Key | Description |
|
||||||
@@ -209,13 +227,6 @@ To add more workspaces, repeat Steps 1–4 for each one and add another entry to
|
|||||||
|
|
||||||
The `.env` file is listed in `.gitignore` so your secrets won't be committed.
|
The `.env` file is listed in `.gitignore` so your secrets won't be committed.
|
||||||
|
|
||||||
### Legacy Single-Workspace Fallback
|
|
||||||
|
|
||||||
If you only have one workspace, you can skip the JSON format and use plain env vars instead:
|
|
||||||
|
|
||||||
- `deepseek/bot.py`: `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `DEEPSEEK_API_KEY`
|
|
||||||
- `bender/bot.py`: `SLACK_BOT_TOKEN_BENDER`, `SLACK_APP_TOKEN_BENDER`, `DEEPSEEK_API_KEY_BENDER`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 6 — Build and Run with Docker Compose
|
## Step 6 — Build and Run with Docker Compose
|
||||||
@@ -319,3 +330,24 @@ These tokens are **not interchangeable**. Swapping `xoxb-` and `xapp-` is a comm
|
|||||||
- Check that your account has credit loaded — the API is pay-per-use.
|
- Check that your account has credit loaded — the API is pay-per-use.
|
||||||
- The bot logs full error details to stdout/stderr.
|
- The bot logs full error details to stdout/stderr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## wiki-charlesreid1 Bot Setup
|
||||||
|
|
||||||
|
The `wiki-charlesreid1` bot follows the same Slack app setup as DeepSeek (Steps 1-3 above) but with these differences:
|
||||||
|
|
||||||
|
- **Slack app name**: When creating the app in Step 1, use `"wiki-charlesreid1"` for both `display_information.name` and `features.bot_user.display_name`. The `wiki-` prefix prevents Slack autocomplete from colliding with the `@charlesreid1` username.
|
||||||
|
- **Restrict who can add it**: Since this bot has wiki write access, don't let anyone freely add it to channels. In Slack admin under **Manage Apps**, restrict installations or require admin approval.
|
||||||
|
|
||||||
|
### MediaWiki Bot Password
|
||||||
|
|
||||||
|
The bot uses a MediaWiki bot password (separate from your wiki login) to authenticate to the MediaWiki API:
|
||||||
|
|
||||||
|
1. Log into your wiki and go to **Special:BotPasswords** (e.g., `https://charlesreid1.com/wiki/Special:BotPasswords`)
|
||||||
|
2. Create a new bot password with grants for reading, editing, creating pages, and uploading files
|
||||||
|
3. Copy the username (e.g., `MyBot@my-bot-password`) and the password — these go into `WIKI_CHARLESREID1_BOT_USER` and `WIKI_CHARLESREID1_BOT_PASS` in `.env` (see Step 5)
|
||||||
|
|
||||||
|
### Security Note
|
||||||
|
|
||||||
|
This bot can read, create, and edit wiki pages. Be thoughtful about which channels it's invited to.
|
||||||
|
|
||||||
|
|||||||
355
bender/bot.py
355
bender/bot.py
@@ -8,6 +8,7 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from duckduckgo_search import DDGS
|
||||||
from md2mrkdwn import convert as md_to_slack
|
from md2mrkdwn import convert as md_to_slack
|
||||||
from slack_bolt import App
|
from slack_bolt import App
|
||||||
from slack_bolt.adapter.socket_mode import SocketModeHandler
|
from slack_bolt.adapter.socket_mode import SocketModeHandler
|
||||||
@@ -37,6 +38,332 @@ logger.info("Loaded system prompt from %s (%d chars)", SYSTEM_PROMPT_PATH, len(S
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
DEEPSEEK_TIMEOUT = 120 # seconds
|
DEEPSEEK_TIMEOUT = 120 # seconds
|
||||||
MAX_INLINE_LENGTH = 2800 # characters
|
MAX_INLINE_LENGTH = 2800 # characters
|
||||||
|
MAX_TOOL_TURNS = 5 # max tool-call back-and-forths with DeepSeek
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool definitions (sent to DeepSeek API for function calling)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DEEPSEEK_TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_search",
|
||||||
|
"description": (
|
||||||
|
"Search the web for current, up-to-date information. "
|
||||||
|
"Returns a list of results with titles, URLs, and snippets. "
|
||||||
|
"Use this as a first step to find relevant pages."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The search query string. Be specific and include relevant keywords.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_fetch",
|
||||||
|
"description": (
|
||||||
|
"Fetch and read the full content of a web page. "
|
||||||
|
"Use this after web_search when you need details beyond the snippet — "
|
||||||
|
"for example, to read a full article, get specific numbers, "
|
||||||
|
"or understand context that the search snippet didn't cover."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The full URL of the page to fetch.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["url"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool implementations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _format_search_results(results: list[dict]) -> str:
|
||||||
|
if not results:
|
||||||
|
return "No search results found."
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for i, r in enumerate(results[:5], 1):
|
||||||
|
title = r.get("title", "No title")
|
||||||
|
href = r.get("href", "")
|
||||||
|
body = r.get("body", "No description")
|
||||||
|
lines.append(f"{i}. {title}")
|
||||||
|
if href:
|
||||||
|
lines.append(f" URL: {href}")
|
||||||
|
lines.append(f" {body}")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_web_search(query: str) -> str:
|
||||||
|
try:
|
||||||
|
results = list(DDGS().text(query, max_results=5))
|
||||||
|
return _format_search_results(results)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Web search failed for query=%r", query)
|
||||||
|
return f"Web search error: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def execute_web_fetch(url: str) -> str:
|
||||||
|
FETCH_TIMEOUT = 15
|
||||||
|
MAX_BODY_CHARS = 5000
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (compatible; SlackBot/1.0)"},
|
||||||
|
timeout=FETCH_TIMEOUT,
|
||||||
|
allow_redirects=True,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
if "text/html" not in content_type:
|
||||||
|
body = response.text
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from lxml import html
|
||||||
|
doc = html.fromstring(response.content)
|
||||||
|
for tag in doc.xpath("//script | //style | //nav | //footer | //header"):
|
||||||
|
tag.drop_tree()
|
||||||
|
body = doc.text_content()
|
||||||
|
except Exception:
|
||||||
|
body = response.text
|
||||||
|
|
||||||
|
lines = [line.strip() for line in body.splitlines() if line.strip()]
|
||||||
|
text = "\n".join(lines)
|
||||||
|
|
||||||
|
if len(text) > MAX_BODY_CHARS:
|
||||||
|
text = text[:MAX_BODY_CHARS] + "\n\n[truncated]"
|
||||||
|
|
||||||
|
return f"Content from {url}:\n\n{text}"
|
||||||
|
|
||||||
|
except requests.Timeout:
|
||||||
|
return f"Error fetching {url}: request timed out"
|
||||||
|
except requests.HTTPError as exc:
|
||||||
|
return f"Error fetching {url}: HTTP {exc.response.status_code}"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Web fetch failed for url=%r", url)
|
||||||
|
return f"Error fetching {url}: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
TOOL_EXECUTORS = {
|
||||||
|
"web_search": execute_web_search,
|
||||||
|
"web_fetch": execute_web_fetch,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DSML fallback parser — DeepSeek V4 sometimes leaks raw DSML markup in the
|
||||||
|
# content field instead of returning structured tool_calls.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_DSML_INVOKE_RE = re.compile(
|
||||||
|
r'<||DSML||invoke\s+name="([^"]+)">'
|
||||||
|
r'(.*?)'
|
||||||
|
r'<||DSML||invoke>',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
_DSML_PARAM_RE = re.compile(
|
||||||
|
r'<||DSML||parameter\s+name="([^"]+)"[^>]*>'
|
||||||
|
r'(.*?)'
|
||||||
|
r'||DSML||parameter>',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dsml_tool_calls(content: str) -> list[dict] | None:
|
||||||
|
if "DSML" not in content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
invocations = _DSML_INVOKE_RE.findall(content)
|
||||||
|
if not invocations:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tool_calls = []
|
||||||
|
for i, (fn_name, param_block) in enumerate(invocations):
|
||||||
|
params = {}
|
||||||
|
for pname, pvalue in _DSML_PARAM_RE.findall(param_block):
|
||||||
|
params[pname] = pvalue.strip()
|
||||||
|
tool_calls.append({
|
||||||
|
"id": f"dsml_fallback_{i}",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": fn_name,
|
||||||
|
"arguments": json.dumps(params),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return tool_calls if tool_calls else None
|
||||||
|
|
||||||
|
|
||||||
|
_DSML_BLOCK_RE = re.compile(
|
||||||
|
r'<||DSML||tool_calls>.*?<||DSML||tool_calls>',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_dsml(text: str) -> str:
|
||||||
|
if not text or "DSML" not in text:
|
||||||
|
return text
|
||||||
|
return _DSML_BLOCK_RE.sub("", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DeepSeek API client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def call_deepseek(
|
||||||
|
api_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
messages: list[dict],
|
||||||
|
tools: list[dict] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
payload: dict = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
if tools:
|
||||||
|
payload["tools"] = tools
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
api_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
timeout=DEEPSEEK_TIMEOUT,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool calling loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def chat_with_tools(
|
||||||
|
api_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
prompt_text: str,
|
||||||
|
ws_logger: logging.LoggerAdapter,
|
||||||
|
) -> str:
|
||||||
|
messages: list[dict] = [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": prompt_text},
|
||||||
|
]
|
||||||
|
|
||||||
|
for turn in range(1, MAX_TOOL_TURNS + 1):
|
||||||
|
data = call_deepseek(api_url, api_key, model, messages, DEEPSEEK_TOOLS)
|
||||||
|
|
||||||
|
choice = data["choices"][0]
|
||||||
|
finish_reason = choice["finish_reason"]
|
||||||
|
assistant_msg = choice["message"]
|
||||||
|
|
||||||
|
ws_logger.info(
|
||||||
|
"DeepSeek turn %d/%d finish_reason=%s tokens=%s",
|
||||||
|
turn,
|
||||||
|
MAX_TOOL_TURNS,
|
||||||
|
finish_reason,
|
||||||
|
data.get("usage", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_calls = assistant_msg.get("tool_calls")
|
||||||
|
content = assistant_msg.get("content", "") or ""
|
||||||
|
|
||||||
|
if not tool_calls and finish_reason == "stop":
|
||||||
|
parsed = parse_dsml_tool_calls(content)
|
||||||
|
if parsed:
|
||||||
|
ws_logger.info(
|
||||||
|
"Detected %d DSML tool call(s) in content (fallback parser)",
|
||||||
|
len(parsed),
|
||||||
|
)
|
||||||
|
tool_calls = parsed
|
||||||
|
content = None
|
||||||
|
finish_reason = "tool_calls"
|
||||||
|
|
||||||
|
if tool_calls and content and "DSML" in content:
|
||||||
|
ws_logger.info("Stripping leaked DSML from assistant content")
|
||||||
|
content = None
|
||||||
|
|
||||||
|
stored_msg: dict = {"role": "assistant"}
|
||||||
|
if assistant_msg.get("reasoning_content") is not None:
|
||||||
|
stored_msg["reasoning_content"] = assistant_msg["reasoning_content"]
|
||||||
|
elif "reasoning_content" in assistant_msg:
|
||||||
|
stored_msg["reasoning_content"] = ""
|
||||||
|
if content:
|
||||||
|
stored_msg["content"] = content
|
||||||
|
if tool_calls:
|
||||||
|
stored_msg["tool_calls"] = tool_calls
|
||||||
|
|
||||||
|
messages.append(stored_msg)
|
||||||
|
|
||||||
|
if finish_reason == "tool_calls" and tool_calls:
|
||||||
|
ws_logger.info("DeepSeek requested %d tool call(s)", len(tool_calls))
|
||||||
|
|
||||||
|
for tc in tool_calls:
|
||||||
|
fn_name = tc["function"]["name"]
|
||||||
|
fn_args = json.loads(tc["function"]["arguments"])
|
||||||
|
ws_logger.info(
|
||||||
|
"Executing tool: %s(%s)", fn_name, json.dumps(fn_args)
|
||||||
|
)
|
||||||
|
|
||||||
|
executor = TOOL_EXECUTORS.get(fn_name)
|
||||||
|
if executor:
|
||||||
|
try:
|
||||||
|
result = executor(**fn_args)
|
||||||
|
except Exception as tool_exc:
|
||||||
|
result = f"Tool execution error: {tool_exc}"
|
||||||
|
ws_logger.exception("Tool %s failed", fn_name)
|
||||||
|
else:
|
||||||
|
result = f"Error: Unknown tool '{fn_name}'"
|
||||||
|
ws_logger.warning("Unknown tool requested: %s", fn_name)
|
||||||
|
|
||||||
|
messages.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc["id"],
|
||||||
|
"content": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif finish_reason == "stop":
|
||||||
|
return _strip_dsml(content)
|
||||||
|
|
||||||
|
else:
|
||||||
|
ws_logger.warning("Unexpected finish_reason: %s", finish_reason)
|
||||||
|
return _strip_dsml(content)
|
||||||
|
|
||||||
|
ws_logger.info("Tool turns exhausted; making final call without tools")
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"Please provide your answer now based on the tool results above. "
|
||||||
|
"Do not attempt any more tool calls. Summarize what you found."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
data = call_deepseek(api_url, api_key, model, messages, tools=None)
|
||||||
|
final = data["choices"][0]["message"].get("content", "") or ""
|
||||||
|
final = _strip_dsml(final)
|
||||||
|
return final or (
|
||||||
|
"I wasn't able to complete the request within the allowed number of steps. "
|
||||||
|
"Please try again or simplify your question."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -47,7 +374,6 @@ def load_workspaces():
|
|||||||
if raw:
|
if raw:
|
||||||
workspaces = json.loads(raw)
|
workspaces = json.loads(raw)
|
||||||
else:
|
else:
|
||||||
# Fallback: build a single workspace from the legacy env vars
|
|
||||||
slack_bot_token = os.environ.get("SLACK_BOT_TOKEN_BENDER")
|
slack_bot_token = os.environ.get("SLACK_BOT_TOKEN_BENDER")
|
||||||
slack_app_token = os.environ.get("SLACK_APP_TOKEN_BENDER")
|
slack_app_token = os.environ.get("SLACK_APP_TOKEN_BENDER")
|
||||||
deepseek_api_key = os.environ.get("DEEPSEEK_API_KEY_BENDER")
|
deepseek_api_key = os.environ.get("DEEPSEEK_API_KEY_BENDER")
|
||||||
@@ -92,7 +418,7 @@ def make_app(ws):
|
|||||||
ws_name = ws["name"]
|
ws_name = ws["name"]
|
||||||
deepseek_api_key = ws["deepseek_api_key"]
|
deepseek_api_key = ws["deepseek_api_key"]
|
||||||
deepseek_api_url = ws.get("deepseek_api_url", "https://api.deepseek.com/chat/completions")
|
deepseek_api_url = ws.get("deepseek_api_url", "https://api.deepseek.com/chat/completions")
|
||||||
deepseek_model = ws.get("deepseek_model", "deepseek-chat")
|
deepseek_model = ws.get("deepseek_model", "deepseek-v4-pro")
|
||||||
|
|
||||||
ws_logger = logging.LoggerAdapter(logger, {"workspace": ws_name})
|
ws_logger = logging.LoggerAdapter(logger, {"workspace": ws_name})
|
||||||
|
|
||||||
@@ -128,32 +454,21 @@ def make_app(ws):
|
|||||||
ws_logger.info("DeepSeek API call starting model=%s", deepseek_model)
|
ws_logger.info("DeepSeek API call starting model=%s", deepseek_model)
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|
||||||
response = requests.post(
|
reply_text = chat_with_tools(
|
||||||
deepseek_api_url,
|
deepseek_api_url,
|
||||||
headers={
|
deepseek_api_key,
|
||||||
"Authorization": f"Bearer {deepseek_api_key}",
|
deepseek_model,
|
||||||
"Content-Type": "application/json",
|
prompt_text,
|
||||||
},
|
ws_logger,
|
||||||
json={
|
|
||||||
"model": deepseek_model,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
|
||||||
{"role": "user", "content": prompt_text},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
timeout=DEEPSEEK_TIMEOUT,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
duration = time.time() - start
|
duration = time.time() - start
|
||||||
ws_logger.info(
|
ws_logger.info(
|
||||||
"DeepSeek API call completed status=%s duration=%.2fs",
|
"DeepSeek API call completed duration=%.2fs chars=%d",
|
||||||
response.status_code,
|
|
||||||
duration,
|
duration,
|
||||||
|
len(reply_text),
|
||||||
)
|
)
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
reply_text = data["choices"][0]["message"]["content"]
|
|
||||||
reply_text = md_to_slack(reply_text)
|
reply_text = md_to_slack(reply_text)
|
||||||
|
|
||||||
if len(reply_text) <= MAX_INLINE_LENGTH:
|
if len(reply_text) <= MAX_INLINE_LENGTH:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
slack-bolt>=1.28.0
|
slack-bolt>=1.28.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
md2mrkdwn>=0.4.3
|
md2mrkdwn>=0.4.3
|
||||||
|
duckduckgo-search>=8.0.0
|
||||||
|
lxml>=5.0.0
|
||||||
|
|||||||
117
deepseek/bot.py
117
deepseek/bot.py
@@ -166,6 +166,67 @@ TOOL_EXECUTORS = {
|
|||||||
"web_fetch": execute_web_fetch,
|
"web_fetch": execute_web_fetch,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DSML fallback parser — DeepSeek V4 sometimes leaks raw DSML markup in the
|
||||||
|
# content field instead of returning structured tool_calls.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_DSML_INVOKE_RE = re.compile(
|
||||||
|
r'<||DSML||invoke\s+name="([^"]+)">'
|
||||||
|
r'(.*?)'
|
||||||
|
r'<||DSML||invoke>',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
_DSML_PARAM_RE = re.compile(
|
||||||
|
r'<||DSML||parameter\s+name="([^"]+)"[^>]*>'
|
||||||
|
r'(.*?)'
|
||||||
|
r'||DSML||parameter>',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dsml_tool_calls(content: str) -> list[dict] | None:
|
||||||
|
"""Parse raw DSML markup into structured tool_calls.
|
||||||
|
|
||||||
|
Returns a list of tool-call dicts compatible with the OpenAI/DeepSeek
|
||||||
|
tool_calls format, or None if no DSML was detected.
|
||||||
|
"""
|
||||||
|
if "DSML" not in content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
invocations = _DSML_INVOKE_RE.findall(content)
|
||||||
|
if not invocations:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tool_calls = []
|
||||||
|
for i, (fn_name, param_block) in enumerate(invocations):
|
||||||
|
params = {}
|
||||||
|
for pname, pvalue in _DSML_PARAM_RE.findall(param_block):
|
||||||
|
params[pname] = pvalue.strip()
|
||||||
|
tool_calls.append({
|
||||||
|
"id": f"dsml_fallback_{i}",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": fn_name,
|
||||||
|
"arguments": json.dumps(params),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return tool_calls if tool_calls else None
|
||||||
|
|
||||||
|
|
||||||
|
_DSML_BLOCK_RE = re.compile(
|
||||||
|
r'<||DSML||tool_calls>.*?<||DSML||tool_calls>',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_dsml(text: str) -> str:
|
||||||
|
"""Remove any leaked DSML tool-call blocks from text."""
|
||||||
|
if not text or "DSML" not in text:
|
||||||
|
return text
|
||||||
|
return _DSML_BLOCK_RE.sub("", text).strip()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DeepSeek API client (async)
|
# DeepSeek API client (async)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -229,7 +290,6 @@ async def chat_with_tools(
|
|||||||
choice = data["choices"][0]
|
choice = data["choices"][0]
|
||||||
finish_reason = choice["finish_reason"]
|
finish_reason = choice["finish_reason"]
|
||||||
assistant_msg = choice["message"]
|
assistant_msg = choice["message"]
|
||||||
messages.append(assistant_msg)
|
|
||||||
|
|
||||||
ws_logger.info(
|
ws_logger.info(
|
||||||
"DeepSeek turn %d/%d finish_reason=%s tokens=%s",
|
"DeepSeek turn %d/%d finish_reason=%s tokens=%s",
|
||||||
@@ -239,8 +299,41 @@ async def chat_with_tools(
|
|||||||
data.get("usage", {}),
|
data.get("usage", {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
if finish_reason == "tool_calls":
|
# Determine tool_calls: either from the structured response, or by
|
||||||
tool_calls = assistant_msg.get("tool_calls", [])
|
# parsing leaked DSML markup in the content field.
|
||||||
|
tool_calls = assistant_msg.get("tool_calls")
|
||||||
|
content = assistant_msg.get("content", "") or ""
|
||||||
|
|
||||||
|
if not tool_calls and finish_reason == "stop":
|
||||||
|
parsed = parse_dsml_tool_calls(content)
|
||||||
|
if parsed:
|
||||||
|
ws_logger.info(
|
||||||
|
"Detected %d DSML tool call(s) in content (fallback parser)",
|
||||||
|
len(parsed),
|
||||||
|
)
|
||||||
|
tool_calls = parsed
|
||||||
|
content = None
|
||||||
|
finish_reason = "tool_calls"
|
||||||
|
|
||||||
|
# When tool_calls are present, strip any DSML that leaked into content
|
||||||
|
if tool_calls and content and "DSML" in content:
|
||||||
|
ws_logger.info("Stripping leaked DSML from assistant content")
|
||||||
|
content = None
|
||||||
|
|
||||||
|
# Build stored message for round-tripping back to the API
|
||||||
|
stored_msg: dict = {"role": "assistant"}
|
||||||
|
if assistant_msg.get("reasoning_content") is not None:
|
||||||
|
stored_msg["reasoning_content"] = assistant_msg["reasoning_content"]
|
||||||
|
elif "reasoning_content" in assistant_msg:
|
||||||
|
stored_msg["reasoning_content"] = ""
|
||||||
|
if content:
|
||||||
|
stored_msg["content"] = content
|
||||||
|
if tool_calls:
|
||||||
|
stored_msg["tool_calls"] = tool_calls
|
||||||
|
|
||||||
|
messages.append(stored_msg)
|
||||||
|
|
||||||
|
if finish_reason == "tool_calls" and tool_calls:
|
||||||
ws_logger.info("DeepSeek requested %d tool call(s)", len(tool_calls))
|
ws_logger.info("DeepSeek requested %d tool call(s)", len(tool_calls))
|
||||||
|
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
@@ -267,21 +360,29 @@ async def chat_with_tools(
|
|||||||
"content": result,
|
"content": result,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Continue loop — DeepSeek will process tool results in next turn
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif finish_reason == "stop":
|
elif finish_reason == "stop":
|
||||||
return assistant_msg.get("content", "")
|
return _strip_dsml(content)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
ws_logger.warning("Unexpected finish_reason: %s", finish_reason)
|
ws_logger.warning("Unexpected finish_reason: %s", finish_reason)
|
||||||
return assistant_msg.get("content", "")
|
return _strip_dsml(content)
|
||||||
|
|
||||||
# Tool turns exhausted — make one final call without tool definitions so
|
# Tool turns exhausted — make one final call without tool definitions so
|
||||||
# DeepSeek is forced to produce a textual answer from the results collected.
|
# DeepSeek is forced to produce a textual answer from the results collected.
|
||||||
ws_logger.info("Tool turns exhausted; making final call without tools")
|
ws_logger.info("Tool turns exhausted; making final call without tools")
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"Please provide your answer now based on the tool results above. "
|
||||||
|
"Do not attempt any more tool calls. Summarize what you found."
|
||||||
|
),
|
||||||
|
})
|
||||||
data = await call_deepseek(api_url, api_key, model, messages, tools=None)
|
data = await call_deepseek(api_url, api_key, model, messages, tools=None)
|
||||||
return data["choices"][0]["message"].get("content", "") or (
|
final = data["choices"][0]["message"].get("content", "") or ""
|
||||||
|
final = _strip_dsml(final)
|
||||||
|
return final or (
|
||||||
"I wasn't able to complete the request within the allowed number of steps. "
|
"I wasn't able to complete the request within the allowed number of steps. "
|
||||||
"Please try again or simplify your question."
|
"Please try again or simplify your question."
|
||||||
)
|
)
|
||||||
@@ -339,7 +440,7 @@ def make_app(ws):
|
|||||||
ws_name = ws["name"]
|
ws_name = ws["name"]
|
||||||
deepseek_api_key = ws["deepseek_api_key"]
|
deepseek_api_key = ws["deepseek_api_key"]
|
||||||
deepseek_api_url = ws.get("deepseek_api_url", "https://api.deepseek.com/chat/completions")
|
deepseek_api_url = ws.get("deepseek_api_url", "https://api.deepseek.com/chat/completions")
|
||||||
deepseek_model = ws.get("deepseek_model", "deepseek-chat")
|
deepseek_model = ws.get("deepseek_model", "deepseek-v4-pro")
|
||||||
|
|
||||||
ws_logger = logging.LoggerAdapter(logger, {"workspace": ws_name})
|
ws_logger = logging.LoggerAdapter(logger, {"workspace": ws_name})
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ aiohttp>=3.9.0
|
|||||||
httpx>=0.28.1
|
httpx>=0.28.1
|
||||||
md2mrkdwn>=0.4.3
|
md2mrkdwn>=0.4.3
|
||||||
duckduckgo-search>=8.0.0
|
duckduckgo-search>=8.0.0
|
||||||
|
lxml>=5.0.0
|
||||||
|
|||||||
@@ -30,3 +30,32 @@ services:
|
|||||||
APP: minimax
|
APP: minimax
|
||||||
env_file: .env
|
env_file: .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mediawiki-mcp:
|
||||||
|
image: ghcr.io/professionalwiki/mediawiki-mcp-server
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
CONFIG: /config/config.json
|
||||||
|
MCP_TRANSPORT: http
|
||||||
|
MCP_BIND: 0.0.0.0
|
||||||
|
MCP_LOG_LEVEL: info
|
||||||
|
MCP_ALLOW_STATIC_FALLBACK: "true"
|
||||||
|
WIKI_CHARLESREID1_URL: ${WIKI_CHARLESREID1_URL}
|
||||||
|
WIKI_CHARLESREID1_BOT_USER: ${WIKI_CHARLESREID1_BOT_USER}
|
||||||
|
WIKI_CHARLESREID1_BOT_PASS: ${WIKI_CHARLESREID1_BOT_PASS}
|
||||||
|
volumes:
|
||||||
|
- ./mediawiki-mcp-config.json:/config/config.json:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
wiki-charlesreid1-bot:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
APP: wiki-charlesreid1
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
MCP_SERVER_URL: http://mediawiki-mcp:8080/mcp
|
||||||
|
WIKI_CHARLESREID1_WORKSPACES: ${WIKI_CHARLESREID1_WORKSPACES}
|
||||||
|
depends_on:
|
||||||
|
- mediawiki-mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
16
mediawiki-mcp-config.json
Normal file
16
mediawiki-mcp-config.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"wikis": {
|
||||||
|
"charlesreid1": {
|
||||||
|
"server": "${WIKI_CHARLESREID1_URL}",
|
||||||
|
"scriptpath": "/w",
|
||||||
|
"articlepath": "/wiki",
|
||||||
|
"credentials": {
|
||||||
|
"botPassword": {
|
||||||
|
"username": "${WIKI_CHARLESREID1_BOT_USER}",
|
||||||
|
"password": "${WIKI_CHARLESREID1_BOT_PASS}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultWiki": "charlesreid1"
|
||||||
|
}
|
||||||
611
wiki-charlesreid1/bot.py
Normal file
611
wiki-charlesreid1/bot.py
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from duckduckgo_search import DDGS
|
||||||
|
from md2mrkdwn import convert as md_to_slack
|
||||||
|
from slack_bolt.async_app import AsyncApp
|
||||||
|
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
|
||||||
|
from mcp.client.session import ClientSession
|
||||||
|
from mcp.client.streamable_http import streamablehttp_client
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DEEPSEEK_TIMEOUT = 120 # seconds
|
||||||
|
MAX_INLINE_LENGTH = 2800 # characters
|
||||||
|
MAX_TOOL_TURNS = 5 # max tool-call back-and-forths with DeepSeek
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool definitions (sent to DeepSeek API for function calling)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DEEPSEEK_TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_search",
|
||||||
|
"description": (
|
||||||
|
"Search the web for current, up-to-date information. "
|
||||||
|
"Returns a list of results with titles, URLs, and snippets. "
|
||||||
|
"Use this as a first step to find relevant pages."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The search query string. Be specific and include relevant keywords.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_fetch",
|
||||||
|
"description": (
|
||||||
|
"Fetch and read the full content of a web page. "
|
||||||
|
"Use this after web_search when you need details beyond the snippet — "
|
||||||
|
"for example, to read a full article, get specific numbers, "
|
||||||
|
"or understand context that the search snippet didn't cover."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The full URL of the page to fetch.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["url"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool implementations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _format_search_results(results: list[dict]) -> str:
|
||||||
|
"""Format DuckDuckGo search results into a readable string."""
|
||||||
|
if not results:
|
||||||
|
return "No search results found."
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for i, r in enumerate(results[:5], 1):
|
||||||
|
title = r.get("title", "No title")
|
||||||
|
href = r.get("href", "")
|
||||||
|
body = r.get("body", "No description")
|
||||||
|
lines.append(f"{i}. {title}")
|
||||||
|
if href:
|
||||||
|
lines.append(f" URL: {href}")
|
||||||
|
lines.append(f" {body}")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_web_search(query: str) -> str:
|
||||||
|
"""Execute a web search using DuckDuckGo and return formatted results."""
|
||||||
|
try:
|
||||||
|
results = await asyncio.to_thread(
|
||||||
|
lambda: list(DDGS().text(query, max_results=5))
|
||||||
|
)
|
||||||
|
return _format_search_results(results)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Web search failed for query=%r", query)
|
||||||
|
return f"Web search error: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_web_fetch(url: str) -> str:
|
||||||
|
"""Fetch a web page and return its body text."""
|
||||||
|
FETCH_TIMEOUT = 15
|
||||||
|
MAX_BODY_CHARS = 5000
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=FETCH_TIMEOUT, follow_redirects=True) as client:
|
||||||
|
response = await client.get(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (compatible; SlackBot/1.0)"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
if "text/html" not in content_type:
|
||||||
|
body = response.text
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from lxml import html
|
||||||
|
doc = html.fromstring(response.content)
|
||||||
|
for tag in doc.xpath("//script | //style | //nav | //footer | //header"):
|
||||||
|
tag.drop_tree()
|
||||||
|
body = doc.text_content()
|
||||||
|
except Exception:
|
||||||
|
body = response.text
|
||||||
|
|
||||||
|
lines = [line.strip() for line in body.splitlines() if line.strip()]
|
||||||
|
text = "\n".join(lines)
|
||||||
|
|
||||||
|
if len(text) > MAX_BODY_CHARS:
|
||||||
|
text = text[:MAX_BODY_CHARS] + "\n\n[truncated]"
|
||||||
|
|
||||||
|
return f"Content from {url}:\n\n{text}"
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return f"Error fetching {url}: request timed out"
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return f"Error fetching {url}: HTTP {exc.response.status_code}"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Web fetch failed for url=%r", url)
|
||||||
|
return f"Error fetching {url}: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
# Registry mapping tool names to their async handler functions
|
||||||
|
TOOL_EXECUTORS = {
|
||||||
|
"web_search": execute_web_search,
|
||||||
|
"web_fetch": execute_web_fetch,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DSML fallback parser — DeepSeek V4 sometimes leaks raw DSML markup in the
|
||||||
|
# content field instead of returning structured tool_calls.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_DSML_INVOKE_RE = re.compile(
|
||||||
|
r'<||DSML||dsml_invoke\s+name="([^"]+)">'
|
||||||
|
r'(.*?)'
|
||||||
|
r'<||DSML||dsml_invoke>',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
_DSML_PARAM_RE = re.compile(
|
||||||
|
r'<||DSML||dsml_parameter\s+name="([^"]+)"[^>]*>'
|
||||||
|
r'(.*?)'
|
||||||
|
r'<||DSML||dsml_parameter>',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dsml_tool_calls(content: str) -> list[dict] | None:
|
||||||
|
"""Parse raw DSML markup into structured tool_calls.
|
||||||
|
|
||||||
|
Returns a list of tool-call dicts compatible with the OpenAI/DeepSeek
|
||||||
|
tool_calls format, or None if no DSML was detected.
|
||||||
|
"""
|
||||||
|
if "DSML" not in content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
invocations = _DSML_INVOKE_RE.findall(content)
|
||||||
|
if not invocations:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tool_calls = []
|
||||||
|
for i, (fn_name, param_block) in enumerate(invocations):
|
||||||
|
params = {}
|
||||||
|
for pname, pvalue in _DSML_PARAM_RE.findall(param_block):
|
||||||
|
params[pname] = pvalue.strip()
|
||||||
|
tool_calls.append({
|
||||||
|
"id": f"dsml_fallback_{i}",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": fn_name,
|
||||||
|
"arguments": json.dumps(params),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return tool_calls if tool_calls else None
|
||||||
|
|
||||||
|
|
||||||
|
_DSML_BLOCK_RE = re.compile(
|
||||||
|
r'<||DSML||dsml_tool_calls>.*?<||DSML||dsml_tool_calls>',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_dsml(text: str) -> str:
|
||||||
|
"""Remove any leaked DSML tool-call blocks from text."""
|
||||||
|
if not text or "DSML" not in text:
|
||||||
|
return text
|
||||||
|
return _DSML_BLOCK_RE.sub("", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MCP client — connects to the MediaWiki MCP server sidecar
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
async def mcp_tools_to_deepseek(tools) -> list[dict]:
|
||||||
|
"""Convert MCP tool definitions to DeepSeek function-calling schema."""
|
||||||
|
result = []
|
||||||
|
for tool in tools:
|
||||||
|
result.append({
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tool.name,
|
||||||
|
"description": tool.description or "",
|
||||||
|
"parameters": tool.inputSchema,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_mcp_tool(session: ClientSession, name: str, args: dict) -> str:
|
||||||
|
"""Call a tool on the MCP server and return text content from the result."""
|
||||||
|
result = await session.call_tool(name, args)
|
||||||
|
parts = []
|
||||||
|
for block in result.content:
|
||||||
|
if hasattr(block, "text"):
|
||||||
|
parts.append(block.text)
|
||||||
|
else:
|
||||||
|
parts.append(str(block))
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_mcp(url: str):
|
||||||
|
"""Connect to the MediaWiki MCP server, initialize, and discover tools.
|
||||||
|
|
||||||
|
Returns (context_manager, session, deepseek_tools).
|
||||||
|
The caller must keep the context_manager alive for the session to work,
|
||||||
|
and call ``await context_manager.__aexit__(None, None, None)`` on shutdown.
|
||||||
|
"""
|
||||||
|
context = streamablehttp_client(url)
|
||||||
|
read, write, _ = await context.__aenter__()
|
||||||
|
session = ClientSession(read, write)
|
||||||
|
await session.initialize()
|
||||||
|
tools_result = await session.list_tools()
|
||||||
|
logger.info("Discovered %d tools from MCP server at %s", len(tools_result.tools), url)
|
||||||
|
for t in tools_result.tools:
|
||||||
|
logger.info(" MCP tool: %s", t.name)
|
||||||
|
deepseek_tools = await mcp_tools_to_deepseek(tools_result.tools)
|
||||||
|
return context, session, deepseek_tools
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DeepSeek API client (async)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
async def call_deepseek(
|
||||||
|
api_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
messages: list[dict],
|
||||||
|
tools: list[dict] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Call the DeepSeek chat completions API asynchronously.
|
||||||
|
|
||||||
|
Returns the JSON-decoded response dict on success.
|
||||||
|
Raises httpx.HTTPStatusError on non-2xx responses.
|
||||||
|
"""
|
||||||
|
payload: dict = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
if tools:
|
||||||
|
payload["tools"] = tools
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=DEEPSEEK_TIMEOUT) as client:
|
||||||
|
response = await client.post(
|
||||||
|
api_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool calling loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
async def chat_with_tools(
|
||||||
|
api_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
prompt_text: str,
|
||||||
|
ws_logger: logging.LoggerAdapter,
|
||||||
|
mcp_session: ClientSession | None,
|
||||||
|
all_tools: list[dict],
|
||||||
|
) -> str:
|
||||||
|
"""Send a prompt to DeepSeek with tool-calling support.
|
||||||
|
|
||||||
|
Handles the full tool-calling loop:
|
||||||
|
1. Send user message + tool definitions to DeepSeek
|
||||||
|
2. If DeepSeek responds with tool_calls, execute the requested tools
|
||||||
|
3. Send tool results back to DeepSeek
|
||||||
|
4. Repeat until DeepSeek produces a final answer or max turns reached
|
||||||
|
|
||||||
|
Returns the final reply text (or error message).
|
||||||
|
"""
|
||||||
|
messages: list[dict] = [{"role": "user", "content": prompt_text}]
|
||||||
|
|
||||||
|
for turn in range(1, MAX_TOOL_TURNS + 1):
|
||||||
|
data = await call_deepseek(api_url, api_key, model, messages, all_tools)
|
||||||
|
|
||||||
|
choice = data["choices"][0]
|
||||||
|
finish_reason = choice["finish_reason"]
|
||||||
|
assistant_msg = choice["message"]
|
||||||
|
|
||||||
|
ws_logger.info(
|
||||||
|
"DeepSeek turn %d/%d finish_reason=%s tokens=%s",
|
||||||
|
turn,
|
||||||
|
MAX_TOOL_TURNS,
|
||||||
|
finish_reason,
|
||||||
|
data.get("usage", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine tool_calls: either from the structured response, or by
|
||||||
|
# parsing leaked DSML markup in the content field.
|
||||||
|
tool_calls = assistant_msg.get("tool_calls")
|
||||||
|
content = assistant_msg.get("content", "") or ""
|
||||||
|
|
||||||
|
if not tool_calls and finish_reason == "stop":
|
||||||
|
parsed = parse_dsml_tool_calls(content)
|
||||||
|
if parsed:
|
||||||
|
ws_logger.info(
|
||||||
|
"Detected %d DSML tool call(s) in content (fallback parser)",
|
||||||
|
len(parsed),
|
||||||
|
)
|
||||||
|
tool_calls = parsed
|
||||||
|
content = None
|
||||||
|
finish_reason = "tool_calls"
|
||||||
|
|
||||||
|
# When tool_calls are present, strip any DSML that leaked into content
|
||||||
|
if tool_calls and content and "DSML" in content:
|
||||||
|
ws_logger.info("Stripping leaked DSML from assistant content")
|
||||||
|
content = None
|
||||||
|
|
||||||
|
# Build stored message for round-tripping back to the API
|
||||||
|
stored_msg: dict = {"role": "assistant"}
|
||||||
|
if assistant_msg.get("reasoning_content") is not None:
|
||||||
|
stored_msg["reasoning_content"] = assistant_msg["reasoning_content"]
|
||||||
|
elif "reasoning_content" in assistant_msg:
|
||||||
|
stored_msg["reasoning_content"] = ""
|
||||||
|
if content:
|
||||||
|
stored_msg["content"] = content
|
||||||
|
if tool_calls:
|
||||||
|
stored_msg["tool_calls"] = tool_calls
|
||||||
|
|
||||||
|
messages.append(stored_msg)
|
||||||
|
|
||||||
|
if finish_reason == "tool_calls" and tool_calls:
|
||||||
|
ws_logger.info("DeepSeek requested %d tool call(s)", len(tool_calls))
|
||||||
|
|
||||||
|
for tc in tool_calls:
|
||||||
|
fn_name = tc["function"]["name"]
|
||||||
|
fn_args = json.loads(tc["function"]["arguments"])
|
||||||
|
ws_logger.info(
|
||||||
|
"Executing tool: %s(%s)", fn_name, json.dumps(fn_args)
|
||||||
|
)
|
||||||
|
|
||||||
|
executor = TOOL_EXECUTORS.get(fn_name)
|
||||||
|
if executor:
|
||||||
|
try:
|
||||||
|
result = await executor(**fn_args)
|
||||||
|
except Exception as tool_exc:
|
||||||
|
result = f"Tool execution error: {tool_exc}"
|
||||||
|
ws_logger.exception("Tool %s failed", fn_name)
|
||||||
|
elif mcp_session is not None:
|
||||||
|
try:
|
||||||
|
result = await execute_mcp_tool(mcp_session, fn_name, fn_args)
|
||||||
|
except Exception as tool_exc:
|
||||||
|
result = f"MediaWiki tool error: {tool_exc}"
|
||||||
|
ws_logger.exception("MCP tool %s failed", fn_name)
|
||||||
|
else:
|
||||||
|
result = f"Error: Unknown tool '{fn_name}'"
|
||||||
|
ws_logger.warning("Unknown tool requested: %s", fn_name)
|
||||||
|
|
||||||
|
messages.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc["id"],
|
||||||
|
"content": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif finish_reason == "stop":
|
||||||
|
return _strip_dsml(content)
|
||||||
|
|
||||||
|
else:
|
||||||
|
ws_logger.warning("Unexpected finish_reason: %s", finish_reason)
|
||||||
|
return _strip_dsml(content)
|
||||||
|
|
||||||
|
# Tool turns exhausted — make one final call without tool definitions so
|
||||||
|
# DeepSeek is forced to produce a textual answer from the results collected.
|
||||||
|
ws_logger.info("Tool turns exhausted; making final call without tools")
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"Please provide your answer now based on the tool results above. "
|
||||||
|
"Do not attempt any more tool calls. Summarize what you found."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
data = await call_deepseek(api_url, api_key, model, messages, tools=None)
|
||||||
|
final = data["choices"][0]["message"].get("content", "") or ""
|
||||||
|
final = _strip_dsml(final)
|
||||||
|
return final or (
|
||||||
|
"I wasn't able to complete the request within the allowed number of steps. "
|
||||||
|
"Please try again or simplify your question."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Load workspace configs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def load_workspaces():
|
||||||
|
raw = os.environ.get("WIKI_CHARLESREID1_WORKSPACES")
|
||||||
|
if raw:
|
||||||
|
workspaces = json.loads(raw)
|
||||||
|
else:
|
||||||
|
slack_bot_token = os.environ.get("SLACK_BOT_TOKEN")
|
||||||
|
slack_app_token = os.environ.get("SLACK_APP_TOKEN")
|
||||||
|
deepseek_api_key = os.environ.get("DEEPSEEK_API_KEY")
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, val in [
|
||||||
|
("SLACK_BOT_TOKEN", slack_bot_token),
|
||||||
|
("SLACK_APP_TOKEN", slack_app_token),
|
||||||
|
("DEEPSEEK_API_KEY", deepseek_api_key),
|
||||||
|
]
|
||||||
|
if not val
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
sys.exit(
|
||||||
|
f"ERROR: Set WIKI_CHARLESREID1_WORKSPACES (JSON) or provide legacy env vars. "
|
||||||
|
f"Missing: {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
workspaces = [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"slack_bot_token": slack_bot_token,
|
||||||
|
"slack_app_token": slack_app_token,
|
||||||
|
"deepseek_api_key": deepseek_api_key,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
required_keys = {"name", "slack_bot_token", "slack_app_token", "deepseek_api_key"}
|
||||||
|
for i, ws in enumerate(workspaces):
|
||||||
|
missing = required_keys - set(ws.keys())
|
||||||
|
if missing:
|
||||||
|
sys.exit(
|
||||||
|
f"ERROR: Workspace entry {i} is missing keys: {', '.join(sorted(missing))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return workspaces
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Build an async Slack app for one workspace
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def make_app(ws, mcp_session, all_tools):
|
||||||
|
ws_name = ws["name"]
|
||||||
|
deepseek_api_key = ws["deepseek_api_key"]
|
||||||
|
deepseek_api_url = ws.get("deepseek_api_url", "https://api.deepseek.com/chat/completions")
|
||||||
|
deepseek_model = ws.get("deepseek_model", "deepseek-v4-pro")
|
||||||
|
|
||||||
|
ws_logger = logging.LoggerAdapter(logger, {"workspace": ws_name})
|
||||||
|
|
||||||
|
slack_app = AsyncApp(token=ws["slack_bot_token"])
|
||||||
|
|
||||||
|
@slack_app.event("app_mention")
|
||||||
|
async def handle_mention(event, client, say):
|
||||||
|
channel = event["channel"]
|
||||||
|
message_ts = event["ts"]
|
||||||
|
thread_ts = event.get("thread_ts") or message_ts
|
||||||
|
raw_text = event.get("text", "")
|
||||||
|
|
||||||
|
prompt_text = re.sub(r"<@[A-Z0-9]+>", "", raw_text).strip()
|
||||||
|
|
||||||
|
ws_logger.info("Mention received channel=%s ts=%s", channel, message_ts)
|
||||||
|
ws_logger.info("Prompt: %.200r", prompt_text)
|
||||||
|
|
||||||
|
if not prompt_text:
|
||||||
|
ws_logger.info("Empty prompt -- skipping API call")
|
||||||
|
await say(
|
||||||
|
text=(
|
||||||
|
"You mentioned me but didn't include any prompt text, "
|
||||||
|
"so I didn't make an API call. "
|
||||||
|
"Try again with a question or message after the mention."
|
||||||
|
),
|
||||||
|
thread_ts=thread_ts,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await client.reactions_add(channel=channel, timestamp=message_ts, name="eyes")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws_logger.info("DeepSeek API call starting model=%s", deepseek_model)
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
reply_text = await chat_with_tools(
|
||||||
|
deepseek_api_url,
|
||||||
|
deepseek_api_key,
|
||||||
|
deepseek_model,
|
||||||
|
prompt_text,
|
||||||
|
ws_logger,
|
||||||
|
mcp_session,
|
||||||
|
all_tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
duration = time.time() - start
|
||||||
|
ws_logger.info(
|
||||||
|
"DeepSeek API call completed duration=%.2fs chars=%d",
|
||||||
|
duration,
|
||||||
|
len(reply_text),
|
||||||
|
)
|
||||||
|
|
||||||
|
reply_text = md_to_slack(reply_text)
|
||||||
|
|
||||||
|
if len(reply_text) <= MAX_INLINE_LENGTH:
|
||||||
|
await say(text=reply_text, thread_ts=thread_ts)
|
||||||
|
else:
|
||||||
|
await client.files_upload_v2(
|
||||||
|
channel=channel,
|
||||||
|
thread_ts=thread_ts,
|
||||||
|
content=reply_text,
|
||||||
|
filename="response.txt",
|
||||||
|
title="Wiki Bot Response",
|
||||||
|
initial_comment="The response was too long for an inline message.",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
ws_logger.exception("DeepSeek API call failed")
|
||||||
|
await client.reactions_add(channel=channel, timestamp=message_ts, name="x")
|
||||||
|
await say(text=f"DeepSeek API error: {exc}", thread_ts=thread_ts)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await client.reactions_remove(
|
||||||
|
channel=channel, timestamp=message_ts, name="eyes"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
ws_logger.warning("Failed to remove eyes reaction", exc_info=True)
|
||||||
|
|
||||||
|
return slack_app
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
async def main():
|
||||||
|
workspaces = load_workspaces()
|
||||||
|
logger.info("Starting wiki-charlesreid1 bot for %d workspace(s)", len(workspaces))
|
||||||
|
|
||||||
|
mcp_url = os.environ.get("MCP_SERVER_URL", "http://mediawiki-mcp:3000")
|
||||||
|
try:
|
||||||
|
mcp_context, mcp_session, mediawiki_tools = await connect_mcp(mcp_url)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to connect to MCP server at %s", mcp_url)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
all_tools = DEEPSEEK_TOOLS + mediawiki_tools
|
||||||
|
logger.info("Total tools available: %d (built-in) + %d (MediaWiki) = %d",
|
||||||
|
len(DEEPSEEK_TOOLS), len(mediawiki_tools), len(all_tools))
|
||||||
|
|
||||||
|
try:
|
||||||
|
handlers = []
|
||||||
|
for ws in workspaces:
|
||||||
|
slack_app = make_app(ws, mcp_session, all_tools)
|
||||||
|
handler = AsyncSocketModeHandler(slack_app, ws["slack_app_token"])
|
||||||
|
handlers.append(handler.start_async())
|
||||||
|
|
||||||
|
await asyncio.gather(*handlers)
|
||||||
|
finally:
|
||||||
|
await mcp_context.__aexit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
6
wiki-charlesreid1/requirements.txt
Normal file
6
wiki-charlesreid1/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
slack-bolt>=1.28.0
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
httpx>=0.28.1
|
||||||
|
md2mrkdwn>=0.4.3
|
||||||
|
duckduckgo-search>=8.0.0
|
||||||
|
mcp>=1.0.0
|
||||||
Reference in New Issue
Block a user