Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "claude-tunnels",
"description": "Turn any folder of projects into an AI-orchestrated workspace — route tasks from Slack or Telegram to the right project, execute in dependency-aware phases, and get structured results back.",
"version": "1.0.0",
"description": "AI-orchestrated multi-project workspace with Slack, Telegram, and Teams integration.",
"version": "1.1.0",
"author": {
"name": "Claude Tunnels"
"name": "Arnold De La Vega"
},
"homepage": "https://github.com/matteblack9/claude-code-tunnels",
"repository": "https://github.com/matteblack9/claude-code-tunnels",
"homepage": "https://github.com/arnoldadlv/claude-code-tunnels",
"repository": "https://github.com/arnoldadlv/claude-code-tunnels",
"license": "MIT",
"keywords": ["orchestrator", "slack", "telegram", "multi-project", "automation", "agent"]
"keywords": ["orchestrator", "slack", "telegram", "teams", "multi-project", "automation", "agent"]
}
29 changes: 29 additions & 0 deletions marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "claude-tunnels",
"owner": {
"name": "Arnold De La Vega"
},
"metadata": {
"description": "AI-orchestrated multi-project workspace with Slack, Telegram, and Teams integration.",
"version": "1.1.0"
},
"plugins": [
{
"name": "claude-tunnels",
"source": {
"source": "github",
"repo": "arnoldadlv/claude-code-tunnels"
},
"description": "Route tasks from Slack, Telegram, or Teams to the right project, execute in dependency-aware phases, and get structured results back.",
"version": "1.1.0",
"author": {
"name": "Arnold De La Vega"
},
"homepage": "https://github.com/arnoldadlv/claude-code-tunnels",
"repository": "https://github.com/arnoldadlv/claude-code-tunnels",
"license": "MIT",
"keywords": ["orchestrator", "slack", "telegram", "teams", "multi-project", "automation", "agent"],
"category": "productivity"
}
]
}
3 changes: 3 additions & 0 deletions orchestrator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ channels:
enabled: false
telegram:
enabled: false
teams:
enabled: false
port: 3978

# Remote workspaces (populated by /setup-remote-project and /setup-remote-workspace)
# Each entry maps a workspace to a remote listener
Expand Down
13 changes: 6 additions & 7 deletions orchestrator/channel/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,14 @@ async def _handle_text(
callback_info=callback_info,
raw_message=user_text,
)
session.pending_request_id = request_id
session.state = SessionState.PENDING_CONFIRM

confirm_msg = (
f"[{request_id}] I understood your request as:\n"
f"> {user_text}\n\n"
f'Reply "yes" to proceed, "cancel" to abort.'
# Auto-confirm: skip the yes/no prompt and execute immediately
session.pending_request_id = None
session.state = SessionState.EXECUTING
await self._send_and_record(
session, callback_info, f"`{request_id}` Starting work..."
)
await self._send_and_record(session, callback_info, confirm_msg)
await self._do_confirm(session, request_id, callback_info)

async def _do_confirm(
self,
Expand Down
185 changes: 185 additions & 0 deletions orchestrator/channel/teams.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Microsoft Teams channel adapter: Bot Framework webhook receive + async reply.

Uses botbuilder-integration-aiohttp to handle incoming Activities from Azure Bot
Service. Inherits BaseChannel for shared session management, confirm/cancel flow,
and message splitting.
"""

from __future__ import annotations

import asyncio
import logging
import sys
import traceback
from pathlib import Path
from typing import Any, TYPE_CHECKING

from aiohttp import web
from botbuilder.core import TurnContext, MessageFactory
from botbuilder.core.integration import aiohttp_error_middleware
from botbuilder.core.teams import TeamsActivityHandler
from botbuilder.integration.aiohttp import CloudAdapter, ConfigurationBotFrameworkAuthentication
from botbuilder.schema import Activity

from orchestrator import ARCHIVE_PATH
from orchestrator.channel.base import BaseChannel, load_credential_file, split_message

if TYPE_CHECKING:
from orchestrator.server import ConfirmGate

logger = logging.getLogger(__name__)

CREDENTIAL_PATH = ARCHIVE_PATH / "teams" / "credentials"
DEFAULT_PORT = 3978


def load_credentials(path: Path | None = None) -> dict[str, str]:
p = path or CREDENTIAL_PATH
return load_credential_file(p)


class _BotFrameworkConfig:
"""Config object that ConfigurationBotFrameworkAuthentication reads from."""

def __init__(self, app_id: str, app_password: str, app_type: str = "MultiTenant", tenant_id: str = ""):
self.APP_ID = app_id
self.APP_PASSWORD = app_password
self.APP_TYPE = app_type
self.APP_TENANTID = tenant_id


class TeamsChannel(BaseChannel):
"""Teams channel: receives messages via Bot Framework webhook, sends via Bot Connector.

Shares BaseChannel's session state machine, confirm/cancel flow, and
message splitting. Only the transport layer (Bot Framework SDK) is
Teams-specific.
"""

channel_name = "teams"

def __init__(self, confirm_gate: ConfirmGate, port: int = DEFAULT_PORT) -> None:
super().__init__(confirm_gate)
creds = load_credentials()
self._app_id = creds["app_id"]
self._port = port

config = _BotFrameworkConfig(
app_id=creds["app_id"],
app_password=creds["app_password"],
app_type=creds.get("app_type", "MultiTenant"),
tenant_id=creds.get("app_tenant_id", ""),
)
self._adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config))
self._adapter.on_turn_error = self._on_turn_error

allowed = creds.get("allowed_users", "")
self._allowed_users: set[str] = set()
if allowed:
self._allowed_users = {u.strip() for u in allowed.split(",") if u.strip()}

# Store conversation references for async replies, keyed by conversation ID
self._conv_refs: dict[str, Any] = {}

self._runner: web.AppRunner | None = None
self._bot = _TeamsBot(self)

# -- Transport: send -------------------------------------------------------

async def _send(self, callback_info: Any, text: str) -> None:
"""BaseChannel calls this to deliver messages. We split + send via Bot Connector."""
conv_id = callback_info.get("conversation_id", "")
conv_ref = self._conv_refs.get(conv_id)
if not conv_ref:
logger.error("No conversation reference for %s, cannot send reply", conv_id)
return

chunks = split_message(text, max_len=4096)
for chunk in chunks:
await self._adapter.continue_conversation(
conv_ref,
lambda turn_ctx, c=chunk: turn_ctx.send_activity(MessageFactory.text(c)),
self._app_id,
)

# -- Incoming message handling ---------------------------------------------

async def _on_teams_message(self, turn_context: TurnContext) -> None:
"""Called by the inner _TeamsBot when an @mention message arrives."""
activity = turn_context.activity

# Strip @mention
TurnContext.remove_recipient_mention(activity)
text = (activity.text or "").strip()
if not text:
return

user_id = activity.from_property.id if activity.from_property else ""
user_name = activity.from_property.name if activity.from_property else ""

if self._allowed_users:
if user_id not in self._allowed_users and user_name not in self._allowed_users:
logger.warning("Teams: unauthorized user %s (%s)", user_name, user_id)
return

# Save conversation reference for async replies
conv_ref = TurnContext.get_conversation_reference(activity)
conv_id = activity.conversation.id if activity.conversation else ""
self._conv_refs[conv_id] = conv_ref

callback_info = {
"conversation_id": conv_id,
"user_id": user_id,
"user_name": user_name,
}

logger.info("Teams from %s (conv %s): %s", user_name or user_id, conv_id[:20], text[:100])
await self._handle_text(text, conv_id, callback_info)

# -- Error handler ---------------------------------------------------------

async def _on_turn_error(self, turn_context: TurnContext, error: Exception) -> None:
logger.error("Teams bot turn error: %s", error)
traceback.print_exc(file=sys.stderr)
try:
await turn_context.send_activity("An error occurred processing your request.")
except Exception:
logger.exception("Failed to send error message to Teams")

# -- Lifecycle -------------------------------------------------------------

async def start(self) -> None:
app = web.Application(middlewares=[aiohttp_error_middleware])
app.router.add_post("/api/messages", self._handle_webhook)
app.router.add_get("/health", self._handle_health)

self._runner = web.AppRunner(app)
await self._runner.setup()
site = web.TCPSite(self._runner, "0.0.0.0", self._port)
await site.start()
logger.info("Teams channel started on port %d", self._port)

async def stop(self) -> None:
if self._runner:
await self._runner.cleanup()
self._runner = None
logger.info("Teams channel stopped.")

# -- Webhook endpoint ------------------------------------------------------

async def _handle_webhook(self, request: web.Request) -> web.Response:
return await self._adapter.process(request, self._bot)

async def _handle_health(self, request: web.Request) -> web.Response:
return web.json_response({"status": "ok", "channel": "teams", "port": self._port})


class _TeamsBot(TeamsActivityHandler):
"""Inner bot class that delegates to TeamsChannel for message handling."""

def __init__(self, channel: TeamsChannel) -> None:
super().__init__()
self._channel = channel

async def on_message_activity(self, turn_context: TurnContext) -> None:
await self._channel._on_teams_message(turn_context)
4 changes: 2 additions & 2 deletions orchestrator/direct_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ async def handle_direct_request(user_message: str) -> str:
stderr_lines: list[str] = []
options = ClaudeAgentOptions(
cwd=str(BASE), system_prompt=DIRECT_HANDLER_SYSTEM_PROMPT,
allowed_tools=["Read","Glob","Grep","Bash","WebFetch","WebSearch"],
max_turns=30, setting_sources=["project"],
allowed_tools=["Read","Glob","Grep","Bash","WebFetch","WebSearch","Skill"],
max_turns=30, setting_sources=["project", "user"],
permission_mode="bypassPermissions", model="sonnet",
stderr=lambda line: stderr_lines.append(line),
)
Expand Down
2 changes: 1 addition & 1 deletion orchestrator/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ async def run_workspace(project: str, workspace: str, task: str,
options = ClaudeAgentOptions(
cwd=str(cwd), max_turns=100,
allowed_tools=["Read","Write","Edit","Bash","Glob","Grep","Agent","WebFetch","WebSearch","TodoWrite","NotebookEdit","Skill"],
setting_sources=["project"], permission_mode="bypassPermissions",
setting_sources=["project", "user"], permission_mode="bypassPermissions",
)

collected_texts: list[str] = []
Expand Down
8 changes: 8 additions & 0 deletions orchestrator/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ async def main() -> None:
tasks.append(asyncio.create_task(tg_ch.start()))
logger.info(" Telegram: Long Polling")

if channels_config.get("teams", {}).get("enabled"):
from orchestrator.channel.teams import TeamsChannel
teams_port = channels_config.get("teams", {}).get("port", 3978)
teams_ch = TeamsChannel(confirm_gate, port=teams_port)
register_channel("teams", teams_ch)
tasks.append(asyncio.create_task(teams_ch.start()))
logger.info(" Teams: Bot Framework webhook on port %d", teams_port)

loop = asyncio.get_running_loop()
stop_event = asyncio.Event()
for sig in (signal.SIGINT, signal.SIGTERM):
Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ pyyaml>=6.0
# Telegram (install if using Telegram channel)
# aiohttp is already required above

# Teams (install if using Teams channel)
botbuilder-integration-aiohttp>=4.14.5

# Remote deployment (install if using /setup-remote-project or /setup-remote-workspace)
requests>=2.31
Loading